photo-objects 0.8.6__tar.gz → 0.9.1__tar.gz
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.
- {photo_objects-0.8.6 → photo_objects-0.9.1}/PKG-INFO +1 -1
- photo_objects-0.9.1/photo_objects/django/admin.py +14 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/api/__init__.py +1 -0
- photo_objects-0.9.1/photo_objects/django/api/backup.py +215 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/forms.py +2 -18
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/management/commands/create-initial-admin-account.py +3 -1
- photo_objects-0.9.1/photo_objects/django/management/commands/restore-backup.py +96 -0
- photo_objects-0.9.1/photo_objects/django/migrations/0007_backup.py +22 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/models.py +26 -10
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/objsto.py +116 -37
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/signals.py +21 -2
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/tests/test_utils.py +3 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/tests/utils.py +2 -2
- photo_objects-0.9.1/photo_objects/utils.py +50 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects.egg-info/PKG-INFO +1 -1
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects.egg-info/SOURCES.txt +3 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/pyproject.toml +1 -1
- photo_objects-0.8.6/photo_objects/django/admin.py +0 -8
- photo_objects-0.8.6/photo_objects/utils.py +0 -24
- {photo_objects-0.8.6 → photo_objects-0.9.1}/LICENSE +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/README.md +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/__init__.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/config.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/__init__.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/api/album.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/api/auth.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/api/photo.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/api/photo_change_request.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/api/utils.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/apps.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/conf.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/context_processors.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/management/__init__.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/management/commands/__init__.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/management/commands/clean-scaled-photos.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/migrations/0001_initial.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/migrations/0002_created_at_updated_at.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/migrations/0003_admin_visibility.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/migrations/0004_camera_setup_and_settings.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/migrations/0005_sitesettings.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/migrations/0006_photo_alt_text.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/migrations/__init__.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/templatetags/__init__.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/templatetags/photo_objects_extras.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/tests/__init__.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/tests/test_album.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/tests/test_auth.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/tests/test_commands.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/tests/test_img.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/tests/test_og_meta.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/tests/test_photo.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/tests/test_photo_change_requests.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/urls.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/views/__init__.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/views/api/__init__.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/views/api/album.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/views/api/auth.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/views/api/photo.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/views/api/utils.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/views/ui/__init__.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/views/ui/album.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/views/ui/configuration.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/views/ui/photo.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/views/ui/photo_change_request.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/views/ui/users.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/views/ui/utils.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/views/utils.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/error.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/img.py +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects.egg-info/dependency_links.txt +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects.egg-info/requires.txt +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects.egg-info/top_level.txt +0 -0
- {photo_objects-0.8.6 → photo_objects-0.9.1}/setup.cfg +0 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
|
|
3
|
+
from .models import Album, Backup, Photo, PhotoChangeRequest, SiteSettings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BackupAdmin(admin.ModelAdmin):
|
|
7
|
+
readonly_fields = ["status"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
admin.site.register(Album)
|
|
11
|
+
admin.site.register(Photo)
|
|
12
|
+
admin.site.register(SiteSettings)
|
|
13
|
+
admin.site.register(PhotoChangeRequest)
|
|
14
|
+
admin.site.register(Backup, BackupAdmin)
|
|
@@ -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(
|
|
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=
|
|
45
|
-
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=
|
|
86
|
-
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=
|
|
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[
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
223
|
+
client, bucket = _photos_access()
|
|
118
224
|
|
|
119
225
|
for size in sizes:
|
|
120
|
-
|
|
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
|
-
|
|
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 =
|
|
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 .
|
|
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
|
|
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 =
|
|
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)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
import re
|
|
3
|
+
import unicodedata
|
|
4
|
+
from xml.etree import ElementTree as ET
|
|
5
|
+
|
|
6
|
+
from django.utils.safestring import mark_safe
|
|
7
|
+
from markdown import markdown
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def pretty_list(in_: list, conjunction: str):
|
|
11
|
+
return f' {conjunction} '.join(
|
|
12
|
+
i for i in (', '.join(in_[:-1]), in_[-1],) if i)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def render_markdown(value: str):
|
|
16
|
+
return mark_safe(markdown(value))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def first_paragraph_textcontent(raw: str) -> str | None:
|
|
20
|
+
html = render_markdown(raw)
|
|
21
|
+
root = ET.fromstring(f"<root>{html}</root>")
|
|
22
|
+
|
|
23
|
+
first = root.find("p")
|
|
24
|
+
if first is None:
|
|
25
|
+
return None
|
|
26
|
+
|
|
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
|
|
@@ -24,6 +24,7 @@ photo_objects/django/urls.py
|
|
|
24
24
|
photo_objects/django/api/__init__.py
|
|
25
25
|
photo_objects/django/api/album.py
|
|
26
26
|
photo_objects/django/api/auth.py
|
|
27
|
+
photo_objects/django/api/backup.py
|
|
27
28
|
photo_objects/django/api/photo.py
|
|
28
29
|
photo_objects/django/api/photo_change_request.py
|
|
29
30
|
photo_objects/django/api/utils.py
|
|
@@ -31,12 +32,14 @@ photo_objects/django/management/__init__.py
|
|
|
31
32
|
photo_objects/django/management/commands/__init__.py
|
|
32
33
|
photo_objects/django/management/commands/clean-scaled-photos.py
|
|
33
34
|
photo_objects/django/management/commands/create-initial-admin-account.py
|
|
35
|
+
photo_objects/django/management/commands/restore-backup.py
|
|
34
36
|
photo_objects/django/migrations/0001_initial.py
|
|
35
37
|
photo_objects/django/migrations/0002_created_at_updated_at.py
|
|
36
38
|
photo_objects/django/migrations/0003_admin_visibility.py
|
|
37
39
|
photo_objects/django/migrations/0004_camera_setup_and_settings.py
|
|
38
40
|
photo_objects/django/migrations/0005_sitesettings.py
|
|
39
41
|
photo_objects/django/migrations/0006_photo_alt_text.py
|
|
42
|
+
photo_objects/django/migrations/0007_backup.py
|
|
40
43
|
photo_objects/django/migrations/__init__.py
|
|
41
44
|
photo_objects/django/templatetags/__init__.py
|
|
42
45
|
photo_objects/django/templatetags/photo_objects_extras.py
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
from xml.etree import ElementTree as ET
|
|
2
|
-
|
|
3
|
-
from django.utils.safestring import mark_safe
|
|
4
|
-
from markdown import markdown
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def pretty_list(in_: list, conjunction: str):
|
|
8
|
-
return f' {conjunction} '.join(
|
|
9
|
-
i for i in (', '.join(in_[:-1]), in_[-1],) if i)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def render_markdown(value: str):
|
|
13
|
-
return mark_safe(markdown(value))
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def first_paragraph_textcontent(raw: str) -> str | None:
|
|
17
|
-
html = render_markdown(raw)
|
|
18
|
-
root = ET.fromstring(f"<root>{html}</root>")
|
|
19
|
-
|
|
20
|
-
first = root.find("p")
|
|
21
|
-
if first is None:
|
|
22
|
-
return None
|
|
23
|
-
|
|
24
|
-
return ''.join(first.itertext())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/api/photo_change_request.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/management/commands/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/migrations/0003_admin_visibility.py
RENAMED
|
File without changes
|
|
File without changes
|
{photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/migrations/0005_sitesettings.py
RENAMED
|
File without changes
|
{photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/migrations/0006_photo_alt_text.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/tests/test_photo_change_requests.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{photo_objects-0.8.6 → photo_objects-0.9.1}/photo_objects/django/views/ui/photo_change_request.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|