photo-objects 0.8.6__py3-none-any.whl → 0.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,14 @@
1
1
  from django.contrib import admin
2
2
 
3
- from .models import Album, Photo, PhotoChangeRequest, SiteSettings
3
+ from .models import Album, Backup, Photo, PhotoChangeRequest, SiteSettings
4
+
5
+
6
+ class BackupAdmin(admin.ModelAdmin):
7
+ readonly_fields = ["status"]
8
+
4
9
 
5
10
  admin.site.register(Album)
6
11
  admin.site.register(Photo)
7
12
  admin.site.register(SiteSettings)
8
13
  admin.site.register(PhotoChangeRequest)
14
+ admin.site.register(Backup, BackupAdmin)
@@ -1,4 +1,5 @@
1
1
  from .album import *
2
2
  from .auth import *
3
+ from .backup import *
3
4
  from .photo import *
4
5
  from .photo_change_request import *
@@ -0,0 +1,215 @@
1
+ from django.contrib.auth import get_user_model
2
+ from django.contrib.auth.models import Group, Permission
3
+ from django.contrib.sites.models import Site
4
+ from django.db import transaction
5
+
6
+ from photo_objects.django.models import Album, Backup, Photo, SiteSettings
7
+ from photo_objects.django.objsto import (
8
+ backup_data_key,
9
+ backup_info_key,
10
+ delete_backup_objects,
11
+ get_backup_data,
12
+ get_backup_object,
13
+ get_backup_objects,
14
+ put_backup_json,
15
+ )
16
+ from photo_objects.utils import timestamp_str
17
+
18
+
19
+ def _permission_dict(permission: Permission):
20
+ return {
21
+ "app_label": permission.content_type.app_label,
22
+ "codename": permission.codename,
23
+ }
24
+
25
+
26
+ def _get_permissions(permissions=None):
27
+ for permission in (permissions or []):
28
+ yield Permission.objects.get(
29
+ content_type__app_label=permission.get("app_label"),
30
+ codename=permission.get("codename"))
31
+
32
+
33
+ def _user_dict(user):
34
+ return {
35
+ "username": user.username,
36
+ "first_name": user.first_name,
37
+ "last_name": user.last_name,
38
+ "email": user.email,
39
+ "groups": [
40
+ i.name for i in user.groups.all()],
41
+ "user_permissions": [
42
+ _permission_dict(i) for i in user.user_permissions.all()],
43
+ "is_staff": user.is_staff,
44
+ "is_active": user.is_active,
45
+ "is_superuser": user.is_superuser,
46
+ "last_login": timestamp_str(
47
+ user.last_login),
48
+ "date_joined": timestamp_str(
49
+ user.date_joined)}
50
+
51
+
52
+ def _group_dict(group: Group):
53
+ return {
54
+ "name": group.name,
55
+ "permissions": [_permission_dict(i) for i in group.permissions.all()],
56
+ }
57
+
58
+
59
+ def create_backup(backup: Backup):
60
+ # Check if backup already exists
61
+ if get_backup_object(backup.id):
62
+ backup.status = "ready"
63
+ backup.save()
64
+ return
65
+
66
+ user_model = get_user_model()
67
+
68
+ backup.status = "pending"
69
+ backup.save()
70
+
71
+ try:
72
+ albums = Album.objects.all()
73
+ for album in albums:
74
+ album_dict = album.to_json()
75
+ photos = []
76
+ for photo in album.photo_set.all():
77
+ photos.append(photo.to_json())
78
+ album_dict["photos"] = photos
79
+
80
+ put_backup_json(
81
+ backup_data_key(
82
+ backup.id,
83
+ 'album',
84
+ album.key),
85
+ album_dict,
86
+ )
87
+
88
+ groups = Group.objects.all()
89
+ for group in groups:
90
+ put_backup_json(
91
+ backup_data_key(
92
+ backup.id,
93
+ 'group',
94
+ group.name),
95
+ _group_dict(group),
96
+ )
97
+
98
+ users = user_model.objects.all()
99
+ for user in users:
100
+ if user.username == "admin":
101
+ continue
102
+
103
+ put_backup_json(
104
+ backup_data_key(
105
+ backup.id,
106
+ 'user',
107
+ user.username),
108
+ _user_dict(user))
109
+
110
+ sites = Site.objects.all()
111
+ for site in sites:
112
+ put_backup_json(
113
+ backup_data_key(backup.id, 'site', site.id),
114
+ {
115
+ "id": site.id,
116
+ "domain": site.domain,
117
+ "name": site.name,
118
+ }
119
+ )
120
+
121
+ site_settings = SiteSettings.objects.all()
122
+ for settings in site_settings:
123
+ put_backup_json(
124
+ backup_data_key(backup.id, 'settings', settings.site.id),
125
+ {
126
+ "site_id": settings.site.id,
127
+ "description": settings.description,
128
+ "preview_image_key": settings.preview_image.key
129
+ if settings.preview_image else None,
130
+ }
131
+ )
132
+
133
+ put_backup_json(backup_info_key(backup.id), backup.to_json())
134
+ except Exception:
135
+ backup.status = "create_failed"
136
+ backup.save()
137
+ raise
138
+
139
+ backup.status = "ready"
140
+ backup.save()
141
+
142
+
143
+ def delete_backup(backup: Backup):
144
+ return delete_backup_objects(backup.id)
145
+
146
+
147
+ def get_backups() -> list[dict]:
148
+ return get_backup_objects()
149
+
150
+
151
+ @transaction.atomic
152
+ def restore_backup(backup_id: int):
153
+ user_model = get_user_model()
154
+
155
+ for i in get_backups():
156
+ Backup.objects.create(**i)
157
+
158
+ for i in get_backup_data(backup_id, "group"):
159
+ group = Group.objects.create(name=i.get('name'))
160
+
161
+ for permission in _get_permissions(i.get('permissions')):
162
+ group.permissions.add(permission)
163
+
164
+ group.save()
165
+
166
+ for i in get_backup_data(backup_id, "user"):
167
+ permissions = i.pop("user_permissions")
168
+ groups = i.pop("groups")
169
+
170
+ user = user_model.objects.create(**i)
171
+
172
+ for group in groups:
173
+ user.groups.add(Group.objects.get(name=group))
174
+
175
+ for permission in _get_permissions(permissions):
176
+ user.user_permissions.add(permission)
177
+
178
+ user.save()
179
+
180
+ for i in get_backup_data(backup_id, "album"):
181
+ photos = i.pop("photos")
182
+ cover_photo = i.pop("cover_photo")
183
+
184
+ album = Album.objects.create(**i)
185
+
186
+ for photo in photos:
187
+ photo.pop("album", None)
188
+ filename = photo.pop("filename")
189
+
190
+ photo = Photo.objects.create(**photo, album=album)
191
+
192
+ if cover_photo == filename:
193
+ album.cover_photo = photo
194
+ album.save()
195
+
196
+ for i in get_backup_data(backup_id, "site"):
197
+ site, _ = Site.objects.get_or_create(id=i.get("id"))
198
+ site.domain = i.get("domain")
199
+ site.name = i.get("name")
200
+ site.save()
201
+
202
+ for i in get_backup_data(backup_id, "settings"):
203
+ site = Site.objects.get(id=i.get("site_id"))
204
+
205
+ preview_image = None
206
+ if key := i.get("preview_image_key"):
207
+ preview_image = Photo.objects.get(key=key)
208
+
209
+ settings = SiteSettings.objects.create(
210
+ site=site,
211
+ description=i.get("description"),
212
+ preview_image=preview_image
213
+ if i.get("preview_image_key") else None
214
+ )
215
+ settings.save()
@@ -1,6 +1,4 @@
1
1
  import random
2
- import re
3
- import unicodedata
4
2
 
5
3
  from django import forms
6
4
  from django.forms import (
@@ -16,6 +14,8 @@ from django.forms import (
16
14
  from django.utils.safestring import mark_safe
17
15
  from django.utils.translation import gettext_lazy as _
18
16
 
17
+ from photo_objects.utils import slugify
18
+
19
19
  from .models import Album, Photo, PhotoChangeRequest
20
20
 
21
21
 
@@ -26,22 +26,6 @@ KEY_POSTFIX_LEN = 5
26
26
  ALT_TEXT_HELP = _('Alternative text content for the photo.')
27
27
 
28
28
 
29
- def slugify(title: str, lower=False, replace_leading_underscores=False) -> str:
30
- key = unicodedata.normalize(
31
- 'NFKD', title).encode(
32
- 'ascii', 'ignore').decode('ascii')
33
- if lower:
34
- key = key.lower()
35
-
36
- key = re.sub(r'[^a-zA-Z0-9._-]', '-', key)
37
- key = re.sub(r'[-_]{2,}', '-', key)
38
-
39
- if replace_leading_underscores:
40
- key = re.sub(r'^_+', '-', key)
41
-
42
- return key
43
-
44
-
45
29
  def _postfix_generator():
46
30
  for _ in range(13):
47
31
  yield '-' + ''.join(
@@ -12,7 +12,9 @@ class Command(BaseCommand):
12
12
 
13
13
  def handle(self, *args, **options):
14
14
  user = get_user_model()
15
- superuser_count = user.objects.filter(is_superuser=True).count()
15
+ superuser_count = user.objects.filter(
16
+ is_superuser=True).exclude(
17
+ password="").count()
16
18
 
17
19
  if superuser_count == 0:
18
20
  username = 'admin'
@@ -0,0 +1,96 @@
1
+ # pylint: disable=invalid-name
2
+ from io import StringIO
3
+
4
+ from django.core.management import call_command
5
+ from django.core.management.base import BaseCommand
6
+ from django.contrib.auth import get_user_model
7
+ from django.contrib.auth.models import Group
8
+ from django.db import connection
9
+
10
+ from photo_objects.django.api.backup import get_backups, restore_backup
11
+ from photo_objects.django.models import Album, Photo
12
+
13
+
14
+ class DatabaseStatus:
15
+ def __init__(self):
16
+ user = get_user_model()
17
+
18
+ self._users = user.objects.count()
19
+ self._groups = Group.objects.count()
20
+ self._albums = Album.objects.count()
21
+ self._photos = Photo.objects.count()
22
+
23
+ def should_restore(self):
24
+ count = self._users + self._groups + self._albums + self._photos
25
+ return count == 0
26
+
27
+ def __str__(self):
28
+ return (
29
+ f"Database contains {self._users} users, "
30
+ f"{self._groups} groups, {self._albums} albums, and "
31
+ f"{self._photos} photos"
32
+ )
33
+
34
+
35
+ def reset_sequences():
36
+ output = StringIO()
37
+ call_command(
38
+ 'sqlsequencereset',
39
+ 'photo_objects',
40
+ 'auth',
41
+ 'sites',
42
+ stdout=output,
43
+ no_color=True)
44
+
45
+ sql = output.getvalue()
46
+ with connection.cursor() as cursor:
47
+ cursor.execute(sql)
48
+
49
+ output.close()
50
+
51
+
52
+ class Command(BaseCommand):
53
+ help = "Restore latest backup if database is empty."
54
+
55
+ def handle(self, *args, **options):
56
+ status = DatabaseStatus()
57
+ if not status.should_restore():
58
+ self.stdout.write(
59
+ self.style.NOTICE(
60
+ f'Restoring backup skipped: {status}'
61
+ )
62
+ )
63
+ return
64
+
65
+ backups = get_backups()
66
+
67
+ if not backups:
68
+ self.stdout.write(
69
+ self.style.NOTICE(
70
+ 'Restoring backup skipped: No backups found.'
71
+ )
72
+ )
73
+ return
74
+
75
+ try:
76
+ id_ = backups[-1].get("id")
77
+ self.stdout.write(
78
+ self.style.NOTICE(
79
+ f'Restoring backup with ID {id_}.'
80
+ )
81
+ )
82
+ restore_backup(id_)
83
+ reset_sequences()
84
+ status = DatabaseStatus()
85
+ self.stdout.write(
86
+ self.style.SUCCESS(
87
+ f'Restored backup with ID {id_}: {status}'
88
+ )
89
+ )
90
+ except Exception as e:
91
+ self.stdout.write(
92
+ self.style.ERROR(
93
+ f'Failed to restore backup: {e}'
94
+ )
95
+ )
96
+ raise
@@ -0,0 +1,22 @@
1
+ # Generated by Django 5.2.6 on 2025-10-26 23:26
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('photo_objects', '0006_photo_alt_text'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.CreateModel(
14
+ name='Backup',
15
+ fields=[
16
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
17
+ ('created_at', models.DateTimeField(auto_now_add=True)),
18
+ ('comment', models.TextField(blank=True)),
19
+ ('status', models.TextField(blank=True)),
20
+ ],
21
+ ),
22
+ ]
@@ -4,7 +4,7 @@ from django.contrib.sites.models import Site
4
4
  from django.core.validators import RegexValidator
5
5
  from django.utils.translation import gettext_lazy as _
6
6
 
7
- from photo_objects.utils import first_paragraph_textcontent
7
+ from photo_objects.utils import first_paragraph_textcontent, timestamp_str
8
8
 
9
9
 
10
10
  album_key_validator = RegexValidator(
@@ -23,10 +23,6 @@ def _str(key, **kwargs):
23
23
  return f'{key} ({details})' if details else key
24
24
 
25
25
 
26
- def _timestamp_str(timestamp):
27
- return timestamp.isoformat() if timestamp else None
28
-
29
-
30
26
  class BaseModel(models.Model):
31
27
  title = models.CharField(blank=True)
32
28
  description = models.TextField(blank=True)
@@ -41,8 +37,8 @@ class BaseModel(models.Model):
41
37
  return dict(
42
38
  title=self.title,
43
39
  description=self.description,
44
- created_at=_timestamp_str(self.created_at),
45
- updated_at=_timestamp_str(self.updated_at),
40
+ created_at=timestamp_str(self.created_at),
41
+ updated_at=timestamp_str(self.updated_at),
46
42
  )
47
43
 
48
44
 
@@ -82,8 +78,8 @@ class Album(BaseModel):
82
78
  visibility=self.visibility,
83
79
  cover_photo=(
84
80
  self.cover_photo.filename if self.cover_photo else None),
85
- first_timestamp=_timestamp_str(self.first_timestamp),
86
- last_timestamp=_timestamp_str(self.last_timestamp),
81
+ first_timestamp=timestamp_str(self.first_timestamp),
82
+ last_timestamp=timestamp_str(self.last_timestamp),
87
83
  )
88
84
 
89
85
 
@@ -190,7 +186,7 @@ class PhotoChangeRequest(models.Model):
190
186
  return dict(
191
187
  id=self.id,
192
188
  photo=self.photo.key,
193
- created_at=_timestamp_str(self.created_at),
189
+ created_at=timestamp_str(self.created_at),
194
190
  alt_text=self.alt_text,
195
191
  )
196
192
 
@@ -233,3 +229,23 @@ def clear_cached_settings(sender, **kwargs):
233
229
 
234
230
  pre_save.connect(clear_cached_settings, sender=SiteSettings)
235
231
  pre_delete.connect(clear_cached_settings, sender=SiteSettings)
232
+
233
+
234
+ class Backup(models.Model):
235
+ created_at = models.DateTimeField(auto_now_add=True)
236
+ comment = models.TextField(blank=True)
237
+ status = models.TextField(blank=True)
238
+
239
+ def __str__(self):
240
+ return _str(
241
+ f'Backup {self.id}',
242
+ created_at=self.created_at,
243
+ comment=self.comment,
244
+ )
245
+
246
+ def to_json(self):
247
+ return dict(
248
+ id=self.id,
249
+ created_at=timestamp_str(self.created_at),
250
+ comment=self.comment,
251
+ )
@@ -13,6 +13,7 @@ from photo_objects.django.conf import (
13
13
  objsto_settings,
14
14
  parse_photo_sizes,
15
15
  )
16
+ from photo_objects.utils import slugify
16
17
 
17
18
 
18
19
  MEGABYTE = 1 << 20
@@ -33,20 +34,35 @@ def _anonymous_readonly_policy(bucket: str):
33
34
  return json.dumps(policy)
34
35
 
35
36
 
36
- def _objsto_access() -> tuple[Minio, str]:
37
+ def _objsto_access() -> tuple[dict, Minio]:
37
38
  conf = objsto_settings()
38
39
  http = urllib3.PoolManager(
39
40
  retries=urllib3.util.Retry(connect=1),
40
41
  timeout=urllib3.util.Timeout(connect=2.5, read=20),
41
42
  )
42
43
 
43
- client = Minio(
44
+ return (conf, Minio(
44
45
  conf.get('URL'),
45
46
  conf.get('ACCESS_KEY'),
46
47
  conf.get('SECRET_KEY'),
47
48
  http_client=http,
48
49
  secure=conf.get('SECURE', True),
49
- )
50
+ ))
51
+
52
+
53
+ def _backup_access() -> tuple[Minio, str]:
54
+ conf, client = _objsto_access()
55
+ bucket = conf.get('BACKUP_BUCKET', 'backups')
56
+
57
+ # TODO: move this to management command
58
+ if not client.bucket_exists(bucket):
59
+ client.make_bucket(bucket)
60
+
61
+ return client, bucket
62
+
63
+
64
+ def _photos_access() -> tuple[Minio, str]:
65
+ conf, client = _objsto_access()
50
66
  bucket = conf.get('BUCKET', 'photos')
51
67
 
52
68
  # TODO: move this to management command
@@ -57,6 +73,96 @@ def _objsto_access() -> tuple[Minio, str]:
57
73
  return client, bucket
58
74
 
59
75
 
76
+ def _put_json(key, data, access_fn):
77
+ data_str = json.dumps(data)
78
+ stream = BytesIO(data_str.encode('utf-8'))
79
+
80
+ client, bucket = access_fn()
81
+ client.put_object(
82
+ bucket,
83
+ key,
84
+ stream,
85
+ length=-1,
86
+ part_size=10 * MEGABYTE,
87
+ content_type="application/json",
88
+ )
89
+
90
+
91
+ def _list_all(client: Minio, bucket: str, prefix: str):
92
+ start_after = None
93
+ while True:
94
+ objects = client.list_objects(
95
+ bucket,
96
+ prefix=prefix,
97
+ recursive=True,
98
+ start_after=start_after)
99
+
100
+ if not objects:
101
+ break
102
+
103
+ empty = True
104
+ for i in objects:
105
+ empty = False
106
+ yield i
107
+ start_after = i.object_name
108
+
109
+ if empty:
110
+ break
111
+
112
+
113
+ def _get_all(client: Minio, bucket: str, prefix: str):
114
+ for i in _list_all(client, bucket, prefix):
115
+ yield client.get_object(bucket, i.object_name)
116
+
117
+
118
+ def _delete_all(client: Minio, bucket: str, prefix: str):
119
+ for i in _list_all(client, bucket, prefix):
120
+ client.remove_object(bucket, i.object_name)
121
+ yield i.object_name
122
+
123
+
124
+ def backup_info_key(id_):
125
+ return f'info_{id_}.json'
126
+
127
+
128
+ def backup_data_key(id_, type_, key):
129
+ return f'data_{id_}/{type_}_{slugify(key)}.json'
130
+
131
+
132
+ def backup_data_prefix(id_, type_=None):
133
+ return f'data_{id_}/{type_ or ""}'
134
+
135
+
136
+ def put_backup_json(key: str, data: dict):
137
+ return _put_json(key, data, _backup_access)
138
+
139
+
140
+ def get_backup_object(backup_id: int):
141
+ client, bucket = _backup_access()
142
+ return json.loads(
143
+ client.get_object(
144
+ bucket,
145
+ backup_info_key(backup_id)).read())
146
+
147
+
148
+ def get_backup_objects():
149
+ client, bucket = _backup_access()
150
+ return [json.loads(i.read()) for i in _get_all(client, bucket, 'info_')]
151
+
152
+
153
+ def get_backup_data(id_: int, type_=None):
154
+ client, bucket = _backup_access()
155
+ for i in _get_all(client, bucket, backup_data_prefix(id_, type_)):
156
+ yield json.loads(i.read())
157
+
158
+
159
+ def delete_backup_objects(id_: int):
160
+ client, bucket = _backup_access()
161
+ client.remove_object(bucket, backup_info_key(id_))
162
+ for _ in _delete_all(client, bucket, backup_data_prefix(id_)):
163
+ continue
164
+
165
+
60
166
  def photo_path(album_key, photo_key, size_key):
61
167
  return f"{size_key}/{album_key}/{photo_key}"
62
168
 
@@ -86,7 +192,7 @@ def photo_content_headers(
86
192
  def put_photo(album_key, photo_key, size_key, photo_file, image_format=None):
87
193
  content_type, headers = photo_content_headers(photo_key, image_format)
88
194
 
89
- client, bucket = _objsto_access()
195
+ client, bucket = _photos_access()
90
196
  return client.put_object(
91
197
  bucket,
92
198
  photo_path(album_key, photo_key, size_key),
@@ -99,7 +205,7 @@ def put_photo(album_key, photo_key, size_key, photo_file, image_format=None):
99
205
 
100
206
 
101
207
  def get_photo(album_key, photo_key, size_key):
102
- client, bucket = _objsto_access()
208
+ client, bucket = _photos_access()
103
209
  return client.get_object(
104
210
  bucket,
105
211
  photo_path(album_key, photo_key, size_key)
@@ -107,33 +213,17 @@ def get_photo(album_key, photo_key, size_key):
107
213
 
108
214
 
109
215
  def delete_photo(album_key, photo_key):
110
- client, bucket = _objsto_access()
216
+ client, bucket = _photos_access()
111
217
 
112
218
  for i in PhotoSize:
113
219
  client.remove_object(bucket, photo_path(album_key, photo_key, i.value))
114
220
 
115
221
 
116
222
  def delete_scaled_photos(sizes):
117
- client, bucket = _objsto_access()
223
+ client, bucket = _photos_access()
118
224
 
119
225
  for size in sizes:
120
- while True:
121
- objects = client.list_objects(
122
- bucket,
123
- prefix=f"{size}/",
124
- recursive=True)
125
-
126
- if not objects:
127
- break
128
-
129
- empty = True
130
- for i in objects:
131
- empty = False
132
- client.remove_object(bucket, i.object_name)
133
- yield i.object_name
134
-
135
- if empty:
136
- break
226
+ yield from _delete_all(client, bucket, f'{size}/')
137
227
 
138
228
 
139
229
  def get_error_code(e: Exception) -> str:
@@ -151,22 +241,11 @@ def with_error_code(msg: str, e: Exception) -> str:
151
241
 
152
242
 
153
243
  def put_photo_sizes(sizes: PhotoSizes):
154
- data = json.dumps(asdict(sizes))
155
- stream = BytesIO(data.encode('utf-8'))
156
-
157
- client, bucket = _objsto_access()
158
- client.put_object(
159
- bucket,
160
- "photo_sizes.json",
161
- stream,
162
- length=-1,
163
- part_size=10 * MEGABYTE,
164
- content_type="application/json",
165
- )
244
+ return _put_json("photo_sizes.json", asdict(sizes), _photos_access)
166
245
 
167
246
 
168
247
  def get_photo_sizes() -> PhotoSizes:
169
- client, bucket = _objsto_access()
248
+ client, bucket = _photos_access()
170
249
  try:
171
250
  data = client.get_object(bucket, "photo_sizes.json")
172
251
  return parse_photo_sizes(json.loads(data.read()))
@@ -1,7 +1,10 @@
1
- from django.db.models.signals import post_save, post_delete
1
+ from django.db.models.signals import post_save, post_delete, pre_delete
2
2
  from django.dispatch import receiver
3
3
 
4
- from .models import Photo
4
+ from photo_objects.django.api import create_backup
5
+ from photo_objects.django.api.backup import delete_backup
6
+
7
+ from .models import Backup, Photo
5
8
 
6
9
 
7
10
  @receiver(post_save, sender=Photo)
@@ -56,3 +59,19 @@ def update_album_on_photo_delete(sender, **kwargs):
56
59
 
57
60
  if needs_save:
58
61
  album.save()
62
+
63
+
64
+ @receiver(post_save, sender=Backup)
65
+ def create_backup_to_objsto(sender, **kwargs):
66
+ created = kwargs.get('created', False)
67
+ if not created:
68
+ return None
69
+
70
+ backup = kwargs.get('instance', None)
71
+ return create_backup(backup)
72
+
73
+
74
+ @receiver(pre_delete, sender=Backup)
75
+ def delete_backup_from_objsto(sender, **kwargs):
76
+ backup = kwargs.get('instance', None)
77
+ return delete_backup(backup)
@@ -25,6 +25,9 @@ class TestUtils(TestCase):
25
25
  def test_slugify_lower(self):
26
26
  self.assertEqual(slugify("QwErTy!", True), "qwerty-")
27
27
 
28
+ def test_slugify_number(self):
29
+ self.assertEqual(slugify(123), "123")
30
+
28
31
  def test_slugify_replace_leading_underscores(self):
29
32
  self.assertEqual(
30
33
  slugify(
@@ -14,7 +14,7 @@ from minio import S3Error
14
14
 
15
15
  from photo_objects.django import objsto
16
16
  from photo_objects.django.models import Album, Photo
17
- from photo_objects.django.objsto import _objsto_access
17
+ from photo_objects.django.objsto import _photos_access
18
18
 
19
19
 
20
20
  def add_permissions(user, *permissions):
@@ -56,7 +56,7 @@ class TestCase(DjangoTestCase):
56
56
  # pylint: disable=invalid-name
57
57
  @classmethod
58
58
  def tearDownClass(cls):
59
- client, bucket = _objsto_access()
59
+ client, bucket = _photos_access()
60
60
 
61
61
  for i in client.list_objects(bucket, recursive=True):
62
62
  client.remove_object(bucket, i.object_name)
photo_objects/utils.py CHANGED
@@ -1,3 +1,6 @@
1
+ from datetime import datetime
2
+ import re
3
+ import unicodedata
1
4
  from xml.etree import ElementTree as ET
2
5
 
3
6
  from django.utils.safestring import mark_safe
@@ -22,3 +25,26 @@ def first_paragraph_textcontent(raw: str) -> str | None:
22
25
  return None
23
26
 
24
27
  return ''.join(first.itertext())
28
+
29
+
30
+ def timestamp_str(timestamp: datetime):
31
+ return timestamp.isoformat() if timestamp else None
32
+
33
+
34
+ def slugify(
35
+ title: str | int,
36
+ lower=False,
37
+ replace_leading_underscores=False) -> str:
38
+ key = unicodedata.normalize(
39
+ 'NFKD', str(title)).encode(
40
+ 'ascii', 'ignore').decode('ascii')
41
+ if lower:
42
+ key = key.lower()
43
+
44
+ key = re.sub(r'[^a-zA-Z0-9._-]', '-', key)
45
+ key = re.sub(r'[-_]{2,}', '-', key)
46
+
47
+ if replace_leading_underscores:
48
+ key = re.sub(r'^_+', '-', key)
49
+
50
+ return key
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: photo-objects
3
- Version: 0.8.6
3
+ Version: 0.9.1
4
4
  Summary: Application for storing photos in S3 compatible object-storage.
5
5
  Author: Toni Kangas
6
6
  License: MIT License
@@ -2,33 +2,36 @@ photo_objects/__init__.py,sha256=I1508w_ntomEqTFQgC74SurhxVXfCiDWZLRsny2f59g,60
2
2
  photo_objects/config.py,sha256=0-Aeo-z-d_fxx-cjAjxSwPJZUgYaAi7NTodiErlxIXo,861
3
3
  photo_objects/error.py,sha256=7afLYjxM0EaYioxVw_XUqHTvfSMSuQPUwwle0OVlaDY,45
4
4
  photo_objects/img.py,sha256=2HVGS2g7rS2hnomozYL92oxrcN6zjDTHvWNr-UAqtGQ,4620
5
- photo_objects/utils.py,sha256=_93yzQ18UXa_QIC4pLCPvgcwi8uCGn9-pDT_7e4p_uw,579
5
+ photo_objects/utils.py,sha256=M4SeHC_R4B-2LUHx_dQrY9vuzTTKjY31uwU0Vy1Hj7Q,1181
6
6
  photo_objects/django/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- photo_objects/django/admin.py,sha256=ZrNhr-OatgIyqepaU-LDZbf4djCRQteg4yx-TS3OYy0,230
7
+ photo_objects/django/admin.py,sha256=pvyQKs2FMtKtSS2PE_NKR_jsSi7-GKz3bbsqDNgjt6w,352
8
8
  photo_objects/django/apps.py,sha256=Apqu6o6fpoxda18NQgKupvQRvTAZxVviIK_-dUR3rck,1444
9
9
  photo_objects/django/conf.py,sha256=ZpeIulEc1tpr8AO52meNKOF30Xf5osbDtDyHvQRtkx4,2593
10
10
  photo_objects/django/context_processors.py,sha256=XacUmcYV-4NMMMNXPWrHKdvNd6lfyamisngaVerREiU,306
11
- photo_objects/django/forms.py,sha256=95tdNFrvxSVWUYzy4-zrLmCaQVdEpA02umqQyfCllws,7258
12
- photo_objects/django/models.py,sha256=2qYQeyfwf32m9CNb-W1xnhU5IrT8QD5bI11HS-z7fNs,6824
13
- photo_objects/django/objsto.py,sha256=B7DxPWuqFaPFXPLhsHCFlqIzYl7EXLxcHde6zJDe89A,4238
14
- photo_objects/django/signals.py,sha256=Q_Swjl_9z6B6zP-97D_ep5zGSAEgmQfwUz0utMDY93A,1624
11
+ photo_objects/django/forms.py,sha256=zSSmdIYn4PEx1Nnw-tuo4h4otKj3C099lB4-vp2BRUc,6861
12
+ photo_objects/django/models.py,sha256=OxkkczIBg7TaFWWm4VdtbRMJOK_OQKrV29g-X2cm5BQ,7247
13
+ photo_objects/django/objsto.py,sha256=NSimuKvS06dERaEPevM6v9PDxFsgz6JUhlcKmUxnnP0,6243
14
+ photo_objects/django/signals.py,sha256=_gb4vlZkeFNYWXxwhNreaUJoOsbIWvP8OovVLtzepaE,2161
15
15
  photo_objects/django/urls.py,sha256=XnOSEB8YtAJamlEsjKYz6U1DfDu7HHZpAinpqdulR8k,2501
16
- photo_objects/django/api/__init__.py,sha256=inUPmjlTAawHwco4NzjwNEVNnf9c_IIwLH5Ze1ujpuI,98
16
+ photo_objects/django/api/__init__.py,sha256=51CRTiE975ufVhvI5x-M_2D28JP8FZWyLFiuV5EaQSg,120
17
17
  photo_objects/django/api/album.py,sha256=CJDeGLCuYoxGUDcjssZRpFnToxG_KVE9Ii7NduFW2ks,2003
18
18
  photo_objects/django/api/auth.py,sha256=lS0S1tMVH2uN30g4jlixklv3eMnQ2FbQVQvuRXeMGYo,1420
19
+ photo_objects/django/api/backup.py,sha256=_D1KMkIp7A8iPs2LxQ-MsaFcAHwCwhE7un5NRHHIvOc,6099
19
20
  photo_objects/django/api/photo.py,sha256=-lo1g6jfBr884wy-WV2DAEPxzH9V-tFUTRtitmA6i28,4471
20
21
  photo_objects/django/api/photo_change_request.py,sha256=v94RA7SUM60tC9mIZdz8HppbNKfHWeTFNPr_BPw3pys,3075
21
22
  photo_objects/django/api/utils.py,sha256=8r51YgFgKPD05Zjzhstl4jlQ4JM8DtsxUyAzhjXi5Pk,5567
22
23
  photo_objects/django/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
24
  photo_objects/django/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
25
  photo_objects/django/management/commands/clean-scaled-photos.py,sha256=KJY6phgTCxcmbMUsUfCRQjatvCmKyFninM8zT-tB3Kc,2008
25
- photo_objects/django/management/commands/create-initial-admin-account.py,sha256=SuDSEP7S7OR99NtXvjXHnIyuawNOBgMiuwoHs7aCj6g,1131
26
+ photo_objects/django/management/commands/create-initial-admin-account.py,sha256=PhC7jnRxsph8rML384vKfXzYfJqMQdd8oUHxUY7JEW8,1178
27
+ photo_objects/django/management/commands/restore-backup.py,sha256=7Hc-6O_4hv8m66CoWbedjH20KVzURFstLPGPq6ooBuk,2607
26
28
  photo_objects/django/migrations/0001_initial.py,sha256=BLW-EZ38sBgDhOYyprc-h_vuPpRxA11qxt4ZuYNO1Wo,2424
27
29
  photo_objects/django/migrations/0002_created_at_updated_at.py,sha256=7OT2VvDffAkX9XKBHVY-jvzxeIl2yU0Jr1ByCNGcUfw,1039
28
30
  photo_objects/django/migrations/0003_admin_visibility.py,sha256=PdxPOJzr-ViRBlOYUHEEGhe0hLtDysZJdMqvbjKVpEg,529
29
31
  photo_objects/django/migrations/0004_camera_setup_and_settings.py,sha256=CS5xyIHgBE2Y7-PSJ52ffRQeCzs8p899px9upomk4O8,1844
30
32
  photo_objects/django/migrations/0005_sitesettings.py,sha256=Ilf5vUwTFQfXVP37zz0NWo5dQdeHDh5e-MV3enm0ZKI,994
31
33
  photo_objects/django/migrations/0006_photo_alt_text.py,sha256=5ZWR-bfS_72Mpb0SrvtnWzVME-zlcRPzDCofmFWkUeU,1003
34
+ photo_objects/django/migrations/0007_backup.py,sha256=tLHLAnojppT6mhFF3FokGdwDbIj40_yqO9qMFm0H3UI,649
32
35
  photo_objects/django/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
36
  photo_objects/django/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
37
  photo_objects/django/templatetags/photo_objects_extras.py,sha256=1L6wweVA96rTWVXqgyf8gSey1wQKi01h6sqv9kZeTIY,1399
@@ -40,8 +43,8 @@ photo_objects/django/tests/test_img.py,sha256=HEAWcr5fpTkzePkhoQ4YrWsDO9TvFOr7my
40
43
  photo_objects/django/tests/test_og_meta.py,sha256=Kk5a9KvE88KZ60gLqXSe6rTz5YU-gdjteksYolHd-nw,1804
41
44
  photo_objects/django/tests/test_photo.py,sha256=JWXN3fF2VtuByMKm2o5b19HnxwDr6ecRwuGzgc5RsBw,13471
42
45
  photo_objects/django/tests/test_photo_change_requests.py,sha256=Ld5ytqxxZiEWrqfX8htJ6-5ARU7tqTYD-iUhb7EMcnU,3078
43
- photo_objects/django/tests/test_utils.py,sha256=zBLv8lkvcLMCaH4D6GR1KZqUe-rPowhcBkQX19-Kshs,2007
44
- photo_objects/django/tests/utils.py,sha256=7oAwFotAFzxIE2YFGysj_a_y-u7E7Z7_K7vl7ycUAzM,4639
46
+ photo_objects/django/tests/test_utils.py,sha256=0Xl0ReS2X9GsEH-jmLg8xC79hJ4nhbfULo0BOVjiykQ,2089
47
+ photo_objects/django/tests/utils.py,sha256=LiObyRARkmO4arnY2gXNi_T8XxT9eSKKszENMo2UIh8,4639
45
48
  photo_objects/django/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
46
49
  photo_objects/django/views/utils.py,sha256=AjJK5r5HmTF63E9Q4W3pKggDESuhNXUvbROpS8m70KM,1319
47
50
  photo_objects/django/views/api/__init__.py,sha256=GgywMJMSFmP5aoMEaYut5V666zachd5YFQIDBMr5znU,188
@@ -56,8 +59,8 @@ photo_objects/django/views/ui/photo.py,sha256=flwcET5nSChzfyAEWRTUlklTW2o64przNX
56
59
  photo_objects/django/views/ui/photo_change_request.py,sha256=eaYGXFqtHj8qonDAmPyn4nrEHkL13EBD-1s8Phs0XP4,2098
57
60
  photo_objects/django/views/ui/users.py,sha256=nb73cnzuV98QkJb0j8F2hqPgOGFIWpUFTFu6dXMeVwM,722
58
61
  photo_objects/django/views/ui/utils.py,sha256=YV_YcUbX-zUkdFnBlezPChR6aPDhZJ9loSOHBSzF6Cc,273
59
- photo_objects-0.8.6.dist-info/licenses/LICENSE,sha256=V3w6hTjXfP65F4r_mejveHcV5igHrblxao3-2RlfVlA,1068
60
- photo_objects-0.8.6.dist-info/METADATA,sha256=_lAqO8VjM6YpRc406swImcGwYv7KbNPg2D3UF05LY-I,3605
61
- photo_objects-0.8.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
62
- photo_objects-0.8.6.dist-info/top_level.txt,sha256=SZeL8mhf-WMGdhRtTGFvZc3aIRBboQls9O0cFDMGdQ0,14
63
- photo_objects-0.8.6.dist-info/RECORD,,
62
+ photo_objects-0.9.1.dist-info/licenses/LICENSE,sha256=V3w6hTjXfP65F4r_mejveHcV5igHrblxao3-2RlfVlA,1068
63
+ photo_objects-0.9.1.dist-info/METADATA,sha256=GoExSl5yt_qDGeEaIzeeue4bGNcIC-mcx2RgDe1i1EY,3605
64
+ photo_objects-0.9.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
65
+ photo_objects-0.9.1.dist-info/top_level.txt,sha256=SZeL8mhf-WMGdhRtTGFvZc3aIRBboQls9O0cFDMGdQ0,14
66
+ photo_objects-0.9.1.dist-info/RECORD,,