photo-objects 0.0.1__py3-none-any.whl → 0.0.3__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.
- photo_objects/__init__.py +3 -0
- photo_objects/config.py +31 -0
- photo_objects/django/api/album.py +17 -3
- photo_objects/django/api/auth.py +5 -1
- photo_objects/django/api/photo.py +17 -18
- photo_objects/django/forms.py +101 -6
- photo_objects/django/management/commands/create-initial-admin-account.py +5 -3
- photo_objects/django/management/commands/create-site-albums.py +32 -0
- photo_objects/django/migrations/0002_created_at_updated_at.py +36 -0
- photo_objects/django/migrations/0003_admin_visibility.py +18 -0
- photo_objects/django/migrations/0004_camera_setup_and_settings.py +63 -0
- photo_objects/django/models.py +44 -13
- photo_objects/django/objsto.py +21 -0
- photo_objects/django/signals.py +34 -2
- photo_objects/django/templatetags/__init__.py +0 -0
- photo_objects/django/templatetags/photo_objects_extras.py +53 -0
- photo_objects/django/tests/test_album.py +109 -10
- photo_objects/django/tests/test_auth.py +11 -0
- photo_objects/django/tests/test_photo.py +87 -10
- photo_objects/django/tests/test_utils.py +25 -0
- photo_objects/django/tests/utils.py +32 -0
- photo_objects/django/urls.py +7 -0
- photo_objects/django/views/api/photo.py +10 -4
- photo_objects/django/views/ui/__init__.py +1 -0
- photo_objects/django/views/ui/album.py +60 -19
- photo_objects/django/views/ui/configuration.py +117 -0
- photo_objects/django/views/ui/photo.py +91 -28
- photo_objects/django/views/utils.py +8 -0
- photo_objects/img.py +38 -6
- {photo_objects-0.0.1.dist-info → photo_objects-0.0.3.dist-info}/METADATA +46 -7
- photo_objects-0.0.3.dist-info/RECORD +52 -0
- {photo_objects-0.0.1.dist-info → photo_objects-0.0.3.dist-info}/WHEEL +1 -1
- photo_objects-0.0.1.dist-info/RECORD +0 -44
- {photo_objects-0.0.1.dist-info → photo_objects-0.0.3.dist-info/licenses}/LICENSE +0 -0
- {photo_objects-0.0.1.dist-info → photo_objects-0.0.3.dist-info}/top_level.txt +0 -0
photo_objects/__init__.py
CHANGED
photo_objects/config.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from os import getenv
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from secrets import token_urlsafe
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_home_directory() -> Path:
|
|
7
|
+
return Path(getenv("PHOTO_OBJECTS_HOME", Path.home() / ".photo_objects"))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def write_to_home_directory(
|
|
11
|
+
filename: str,
|
|
12
|
+
content: str,
|
|
13
|
+
end: str = "\n") -> int:
|
|
14
|
+
home = get_home_directory()
|
|
15
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
|
|
17
|
+
with open(home / filename, "w+") as f:
|
|
18
|
+
return f.write(content) + f.write(end)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_secret_key() -> str:
|
|
22
|
+
try:
|
|
23
|
+
with open(get_home_directory() / "secret_key") as f:
|
|
24
|
+
return f.read().strip()
|
|
25
|
+
except FileNotFoundError:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
key = token_urlsafe(64)
|
|
29
|
+
write_to_home_directory("secret_key", key)
|
|
30
|
+
|
|
31
|
+
return key
|
|
@@ -16,15 +16,21 @@ from .utils import (
|
|
|
16
16
|
def get_albums(request: HttpRequest):
|
|
17
17
|
if not request.user.is_authenticated:
|
|
18
18
|
return Album.objects.filter(visibility=Album.Visibility.PUBLIC)
|
|
19
|
-
|
|
19
|
+
if request.user.is_staff:
|
|
20
20
|
return Album.objects.all()
|
|
21
21
|
|
|
22
|
+
return Album.objects.filter(visibility__in=[
|
|
23
|
+
Album.Visibility.PUBLIC,
|
|
24
|
+
Album.Visibility.HIDDEN,
|
|
25
|
+
Album.Visibility.PRIVATE,
|
|
26
|
+
])
|
|
27
|
+
|
|
22
28
|
|
|
23
29
|
def create_album(request: HttpRequest):
|
|
24
30
|
check_permissions(request, 'photo_objects.add_album')
|
|
25
31
|
data = parse_input_data(request)
|
|
26
32
|
|
|
27
|
-
f = CreateAlbumForm(data)
|
|
33
|
+
f = CreateAlbumForm(data, user=request.user)
|
|
28
34
|
if not f.is_valid():
|
|
29
35
|
raise FormValidationFailed(f)
|
|
30
36
|
|
|
@@ -36,7 +42,8 @@ def modify_album(request: HttpRequest, album_key: str):
|
|
|
36
42
|
album = check_album_access(request, album_key)
|
|
37
43
|
data = parse_input_data(request)
|
|
38
44
|
|
|
39
|
-
f = ModifyAlbumForm({**album.to_json(), **data},
|
|
45
|
+
f = ModifyAlbumForm({**album.to_json(), **data},
|
|
46
|
+
instance=album, user=request.user)
|
|
40
47
|
if not f.is_valid():
|
|
41
48
|
raise FormValidationFailed(f)
|
|
42
49
|
|
|
@@ -47,6 +54,13 @@ def delete_album(request: HttpRequest, album_key: str):
|
|
|
47
54
|
check_permissions(request, 'photo_objects.delete_album')
|
|
48
55
|
album = check_album_access(request, album_key)
|
|
49
56
|
|
|
57
|
+
if album.key.startswith('_'):
|
|
58
|
+
raise JsonProblem(
|
|
59
|
+
f"Album with {album_key} key is managed by the system and can not "
|
|
60
|
+
"be deleted.",
|
|
61
|
+
409,
|
|
62
|
+
)
|
|
63
|
+
|
|
50
64
|
try:
|
|
51
65
|
album.delete()
|
|
52
66
|
except ProtectedError:
|
photo_objects/django/api/auth.py
CHANGED
|
@@ -19,7 +19,11 @@ def check_album_access(request: HttpRequest, album_key: str):
|
|
|
19
19
|
raise AlbumNotFound(album_key)
|
|
20
20
|
|
|
21
21
|
if not request.user.is_authenticated:
|
|
22
|
-
if album.visibility
|
|
22
|
+
if album.visibility == Album.Visibility.PRIVATE:
|
|
23
|
+
raise AlbumNotFound(album_key)
|
|
24
|
+
|
|
25
|
+
if not request.user.is_staff:
|
|
26
|
+
if album.visibility == Album.Visibility.ADMIN:
|
|
23
27
|
raise AlbumNotFound(album_key)
|
|
24
28
|
|
|
25
29
|
return album
|
|
@@ -2,7 +2,9 @@ from django.core.files.uploadedfile import UploadedFile
|
|
|
2
2
|
from django.http import HttpRequest
|
|
3
3
|
from minio.error import S3Error
|
|
4
4
|
from PIL import UnidentifiedImageError
|
|
5
|
+
from urllib3.exceptions import HTTPError
|
|
5
6
|
|
|
7
|
+
from photo_objects import logger
|
|
6
8
|
from photo_objects.django import objsto
|
|
7
9
|
from photo_objects.django.forms import (
|
|
8
10
|
CreatePhotoForm,
|
|
@@ -51,15 +53,13 @@ def _upload_photo(album_key: str, photo_file: UploadedFile):
|
|
|
51
53
|
photo_file.seek(0)
|
|
52
54
|
try:
|
|
53
55
|
objsto.put_photo(photo.album.key, photo.filename, "og", photo_file)
|
|
54
|
-
except S3Error:
|
|
55
|
-
# TODO: check that there is no photo entry in the database, if object
|
|
56
|
-
# storage upload fails.
|
|
56
|
+
except (S3Error, HTTPError) as e:
|
|
57
57
|
photo.delete()
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"Could not save photo to object storage
|
|
61
|
-
|
|
62
|
-
)
|
|
58
|
+
|
|
59
|
+
msg = objsto.with_error_code(
|
|
60
|
+
"Could not save photo to object storage", e)
|
|
61
|
+
logger.error(f"{msg}: {str(e)}")
|
|
62
|
+
raise JsonProblem(f"{msg}.", 500)
|
|
63
63
|
|
|
64
64
|
return photo
|
|
65
65
|
|
|
@@ -121,16 +121,15 @@ def delete_photo(request: HttpRequest, album_key: str, photo_key: str):
|
|
|
121
121
|
|
|
122
122
|
try:
|
|
123
123
|
objsto.delete_photo(album_key, photo_key)
|
|
124
|
-
except S3Error:
|
|
125
|
-
|
|
126
|
-
"Could not delete photo from object storage
|
|
127
|
-
|
|
128
|
-
)
|
|
124
|
+
except (S3Error, HTTPError) as e:
|
|
125
|
+
msg = objsto.with_error_code(
|
|
126
|
+
"Could not delete photo from object storage", e)
|
|
127
|
+
logger.error(f"{msg}: {str(e)}")
|
|
128
|
+
raise JsonProblem("{msg}.", 500)
|
|
129
129
|
|
|
130
130
|
try:
|
|
131
131
|
photo.delete()
|
|
132
|
-
except Exception:
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
msg = "Could not delete photo from database"
|
|
134
|
+
logger.error(f"{msg}: {str(e)}")
|
|
135
|
+
raise JsonProblem(f"{msg}.", 500)
|
photo_objects/django/forms.py
CHANGED
|
@@ -9,8 +9,10 @@ from django.forms import (
|
|
|
9
9
|
Form,
|
|
10
10
|
HiddenInput,
|
|
11
11
|
ModelForm,
|
|
12
|
-
|
|
12
|
+
RadioSelect,
|
|
13
|
+
ValidationError,
|
|
13
14
|
)
|
|
15
|
+
from django.utils.safestring import mark_safe
|
|
14
16
|
from django.utils.translation import gettext_lazy as _
|
|
15
17
|
|
|
16
18
|
from .models import Album, Photo
|
|
@@ -21,28 +23,64 @@ KEY_POSTFIX_CHARS = 'bcdfghjklmnpqrstvwxz2456789'
|
|
|
21
23
|
KEY_POSTFIX_LEN = 5
|
|
22
24
|
|
|
23
25
|
|
|
24
|
-
def slugify(input: str):
|
|
26
|
+
def slugify(input: str, lower=False, replace_leading_underscores=False) -> str:
|
|
25
27
|
key = unicodedata.normalize(
|
|
26
28
|
'NFKD', input).encode(
|
|
27
29
|
'ascii', 'ignore').decode('ascii')
|
|
30
|
+
if lower:
|
|
31
|
+
key = key.lower()
|
|
32
|
+
|
|
28
33
|
key = re.sub(r'[^a-zA-Z0-9._-]', '-', key)
|
|
29
34
|
key = re.sub(r'[-_]{2,}', '-', key)
|
|
35
|
+
|
|
36
|
+
if replace_leading_underscores:
|
|
37
|
+
key = re.sub(r'^_+', '-', key)
|
|
38
|
+
|
|
30
39
|
return key
|
|
31
40
|
|
|
32
41
|
|
|
33
42
|
def _postfix_generator():
|
|
34
|
-
yield ''
|
|
35
43
|
for _ in range(13):
|
|
36
44
|
yield '-' + ''.join(
|
|
37
45
|
random.choices(KEY_POSTFIX_CHARS, k=KEY_POSTFIX_LEN))
|
|
38
46
|
|
|
39
47
|
|
|
48
|
+
def description_help(resource):
|
|
49
|
+
return {'description': _(
|
|
50
|
+
f'Optional description for the {resource}. If defined, the '
|
|
51
|
+
f'description is visible on the {resource} details page. Use Markdown '
|
|
52
|
+
'syntax to format the description.'),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _check_admin_visibility(form):
|
|
57
|
+
if form.user and form.user.is_staff:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
if form.data.get("visibility") == Album.Visibility.ADMIN:
|
|
61
|
+
form.add_error(
|
|
62
|
+
'visibility',
|
|
63
|
+
ValidationError(
|
|
64
|
+
_(
|
|
65
|
+
'Can not set admin visibility as non-admin user. Select a '
|
|
66
|
+
'different visibility setting.'),
|
|
67
|
+
code='invalid'))
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
|
|
40
71
|
class CreateAlbumForm(ModelForm):
|
|
41
72
|
key = CharField(min_length=1, widget=HiddenInput)
|
|
42
73
|
|
|
43
74
|
class Meta:
|
|
44
75
|
model = Album
|
|
45
76
|
fields = ['key', 'title', 'description', 'visibility']
|
|
77
|
+
help_texts = {
|
|
78
|
+
**description_help('album'),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def __init__(self, *args, user=None, **kwargs):
|
|
82
|
+
super().__init__(*args, **kwargs)
|
|
83
|
+
self.user = user
|
|
46
84
|
|
|
47
85
|
def clean(self):
|
|
48
86
|
super().clean()
|
|
@@ -50,8 +88,17 @@ class CreateAlbumForm(ModelForm):
|
|
|
50
88
|
key = self.cleaned_data.get('key', '')
|
|
51
89
|
title = self.cleaned_data.get('title', '')
|
|
52
90
|
|
|
91
|
+
_check_admin_visibility(self)
|
|
92
|
+
|
|
53
93
|
# If key is set to _new, generate a key from the title.
|
|
54
94
|
if key != '_new':
|
|
95
|
+
if key.startswith('_'):
|
|
96
|
+
self.add_error(
|
|
97
|
+
'key',
|
|
98
|
+
ValidationError(
|
|
99
|
+
_('Keys starting with underscore are reserved for '
|
|
100
|
+
'system albums.'),
|
|
101
|
+
code='invalid'))
|
|
55
102
|
return
|
|
56
103
|
|
|
57
104
|
if title == '':
|
|
@@ -62,7 +109,7 @@ class CreateAlbumForm(ModelForm):
|
|
|
62
109
|
code='required'))
|
|
63
110
|
return
|
|
64
111
|
|
|
65
|
-
key = slugify(title)
|
|
112
|
+
key = slugify(title, lower=True, replace_leading_underscores=True)
|
|
66
113
|
|
|
67
114
|
postfix_iter = _postfix_generator()
|
|
68
115
|
try:
|
|
@@ -81,15 +128,47 @@ class CreateAlbumForm(ModelForm):
|
|
|
81
128
|
self.cleaned_data['key'] = key + postfix
|
|
82
129
|
|
|
83
130
|
|
|
131
|
+
def photo_label(photo: Photo):
|
|
132
|
+
return mark_safe(
|
|
133
|
+
f'''
|
|
134
|
+
<img
|
|
135
|
+
alt="{photo.title}"
|
|
136
|
+
src="/img/{photo.key}/sm"
|
|
137
|
+
style="
|
|
138
|
+
background: url(data:image/png;base64,{photo.tiny_base64});
|
|
139
|
+
background-size: 100% 100%;
|
|
140
|
+
font-size: 0;"
|
|
141
|
+
height="{photo.thumbnail_height}"
|
|
142
|
+
width="{photo.thumbnail_width}"
|
|
143
|
+
/>''')
|
|
144
|
+
|
|
145
|
+
|
|
84
146
|
class ModifyAlbumForm(ModelForm):
|
|
85
147
|
class Meta:
|
|
86
148
|
model = Album
|
|
87
149
|
fields = ['title', 'description', 'cover_photo', 'visibility']
|
|
150
|
+
help_texts = {
|
|
151
|
+
**description_help('album'),
|
|
152
|
+
'cover_photo': _(
|
|
153
|
+
'Select a cover photo for the album. The cover photo is '
|
|
154
|
+
'visible on the albums list page and in album preview image.'),
|
|
155
|
+
}
|
|
156
|
+
widgets = {
|
|
157
|
+
'cover_photo': RadioSelect(attrs={'class': 'photo-select'}),
|
|
158
|
+
}
|
|
88
159
|
|
|
89
|
-
def __init__(self, *args, **kwargs):
|
|
160
|
+
def __init__(self, *args, user=None, **kwargs):
|
|
90
161
|
super().__init__(*args, **kwargs)
|
|
162
|
+
self.user = user
|
|
163
|
+
|
|
91
164
|
self.fields['cover_photo'].queryset = Photo.objects.filter(
|
|
92
165
|
album=self.instance)
|
|
166
|
+
self.fields['cover_photo'].empty_label = None
|
|
167
|
+
self.fields['cover_photo'].label_from_instance = photo_label
|
|
168
|
+
|
|
169
|
+
def clean(self):
|
|
170
|
+
super().clean()
|
|
171
|
+
_check_admin_visibility(self)
|
|
93
172
|
|
|
94
173
|
|
|
95
174
|
class CreatePhotoForm(ModelForm):
|
|
@@ -103,7 +182,16 @@ class CreatePhotoForm(ModelForm):
|
|
|
103
182
|
'timestamp',
|
|
104
183
|
'height',
|
|
105
184
|
'width',
|
|
106
|
-
'tiny_base64'
|
|
185
|
+
'tiny_base64',
|
|
186
|
+
'camera_make',
|
|
187
|
+
'camera_model',
|
|
188
|
+
'lens_make',
|
|
189
|
+
'lens_model',
|
|
190
|
+
'focal_length',
|
|
191
|
+
'f_number',
|
|
192
|
+
'exposure_time',
|
|
193
|
+
'iso_speed',
|
|
194
|
+
]
|
|
107
195
|
error_messages = {
|
|
108
196
|
'album': {
|
|
109
197
|
'invalid_choice': _('Album with %(value)s key does not exist.')
|
|
@@ -115,6 +203,13 @@ class ModifyPhotoForm(ModelForm):
|
|
|
115
203
|
class Meta:
|
|
116
204
|
model = Photo
|
|
117
205
|
fields = ['title', 'description']
|
|
206
|
+
help_texts = {
|
|
207
|
+
**description_help('photo'),
|
|
208
|
+
'title': _(
|
|
209
|
+
'Title for the photo. If not defined, the filename of the '
|
|
210
|
+
'photo is used as the title.'
|
|
211
|
+
),
|
|
212
|
+
}
|
|
118
213
|
|
|
119
214
|
|
|
120
215
|
class MultipleFileInput(ClearableFileInput):
|
|
@@ -2,6 +2,8 @@ from django.core.management.base import BaseCommand
|
|
|
2
2
|
from django.contrib.auth import get_user_model
|
|
3
3
|
from secrets import token_urlsafe
|
|
4
4
|
|
|
5
|
+
from photo_objects.config import write_to_home_directory
|
|
6
|
+
|
|
5
7
|
|
|
6
8
|
class Command(BaseCommand):
|
|
7
9
|
help = "Create initial admin user account."
|
|
@@ -15,13 +17,13 @@ class Command(BaseCommand):
|
|
|
15
17
|
password = token_urlsafe(32)
|
|
16
18
|
User.objects.create_superuser(username, password=password)
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
write_to_home_directory("initial_admin_password", password)
|
|
21
|
+
|
|
22
|
+
self.stdout.write(
|
|
19
23
|
self.style.SUCCESS('Initial admin account created:') +
|
|
20
24
|
f'\n Username: {username}'
|
|
21
25
|
f'\n Password: {password}'
|
|
22
26
|
)
|
|
23
|
-
|
|
24
|
-
self.stdout.write(msg)
|
|
25
27
|
else:
|
|
26
28
|
self.stdout.write(
|
|
27
29
|
self.style.NOTICE(
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from django.core.management.base import BaseCommand
|
|
2
|
+
from django.contrib.sites.models import Site
|
|
3
|
+
|
|
4
|
+
from photo_objects.django.models import Album
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Command(BaseCommand):
|
|
8
|
+
help = "Create albums for configuring site metadata."
|
|
9
|
+
|
|
10
|
+
def handle(self, *args, **options):
|
|
11
|
+
sites = Site.objects.all()
|
|
12
|
+
|
|
13
|
+
for site in sites:
|
|
14
|
+
album_key = f'_site_{site.id}'
|
|
15
|
+
_, created = Album.objects.get_or_create(
|
|
16
|
+
key=album_key,
|
|
17
|
+
defaults={
|
|
18
|
+
'visibility': Album.Visibility.ADMIN,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
if created:
|
|
22
|
+
self.stdout.write(
|
|
23
|
+
self.style.SUCCESS(
|
|
24
|
+
f'Album for site {site.domain} created:') +
|
|
25
|
+
f'\n Key: {album_key}')
|
|
26
|
+
else:
|
|
27
|
+
self.stdout.write(
|
|
28
|
+
self.style.NOTICE(
|
|
29
|
+
f'Album creation for site {site.domain} skipped: '
|
|
30
|
+
'Album already exists.'
|
|
31
|
+
)
|
|
32
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Generated by Django 5.0.7 on 2025-03-02 22:31
|
|
2
|
+
|
|
3
|
+
import django.utils.timezone
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
('photo_objects', '0001_initial'),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.AddField(
|
|
15
|
+
model_name='album',
|
|
16
|
+
name='created_at',
|
|
17
|
+
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
|
18
|
+
preserve_default=False,
|
|
19
|
+
),
|
|
20
|
+
migrations.AddField(
|
|
21
|
+
model_name='album',
|
|
22
|
+
name='updated_at',
|
|
23
|
+
field=models.DateTimeField(auto_now=True),
|
|
24
|
+
),
|
|
25
|
+
migrations.AddField(
|
|
26
|
+
model_name='photo',
|
|
27
|
+
name='created_at',
|
|
28
|
+
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
|
29
|
+
preserve_default=False,
|
|
30
|
+
),
|
|
31
|
+
migrations.AddField(
|
|
32
|
+
model_name='photo',
|
|
33
|
+
name='updated_at',
|
|
34
|
+
field=models.DateTimeField(auto_now=True),
|
|
35
|
+
),
|
|
36
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Generated by Django 5.0.7 on 2025-03-09 21:48
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('photo_objects', '0002_created_at_updated_at'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AlterField(
|
|
14
|
+
model_name='album',
|
|
15
|
+
name='visibility',
|
|
16
|
+
field=models.CharField(blank=True, choices=[('public', 'Public'), ('hidden', 'Hidden'), ('private', 'Private'), ('', 'Admin')], db_default='private', default='private'),
|
|
17
|
+
),
|
|
18
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Generated by Django 5.0.7 on 2025-04-05 21:45
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('photo_objects', '0003_admin_visibility'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='photo',
|
|
15
|
+
name='camera_make',
|
|
16
|
+
field=models.CharField(blank=True),
|
|
17
|
+
),
|
|
18
|
+
migrations.AddField(
|
|
19
|
+
model_name='photo',
|
|
20
|
+
name='camera_model',
|
|
21
|
+
field=models.CharField(blank=True),
|
|
22
|
+
),
|
|
23
|
+
migrations.AddField(
|
|
24
|
+
model_name='photo',
|
|
25
|
+
name='exposure_time',
|
|
26
|
+
field=models.FloatField(blank=True, null=True),
|
|
27
|
+
),
|
|
28
|
+
migrations.AddField(
|
|
29
|
+
model_name='photo',
|
|
30
|
+
name='f_number',
|
|
31
|
+
field=models.FloatField(blank=True, null=True),
|
|
32
|
+
),
|
|
33
|
+
migrations.AddField(
|
|
34
|
+
model_name='photo',
|
|
35
|
+
name='focal_length',
|
|
36
|
+
field=models.FloatField(blank=True, null=True),
|
|
37
|
+
),
|
|
38
|
+
migrations.AddField(
|
|
39
|
+
model_name='photo',
|
|
40
|
+
name='iso_speed',
|
|
41
|
+
field=models.IntegerField(blank=True, null=True),
|
|
42
|
+
),
|
|
43
|
+
migrations.AddField(
|
|
44
|
+
model_name='photo',
|
|
45
|
+
name='lens_make',
|
|
46
|
+
field=models.CharField(blank=True),
|
|
47
|
+
),
|
|
48
|
+
migrations.AddField(
|
|
49
|
+
model_name='photo',
|
|
50
|
+
name='lens_model',
|
|
51
|
+
field=models.CharField(blank=True),
|
|
52
|
+
),
|
|
53
|
+
migrations.AlterField(
|
|
54
|
+
model_name='album',
|
|
55
|
+
name='first_timestamp',
|
|
56
|
+
field=models.DateTimeField(blank=True, null=True),
|
|
57
|
+
),
|
|
58
|
+
migrations.AlterField(
|
|
59
|
+
model_name='album',
|
|
60
|
+
name='last_timestamp',
|
|
61
|
+
field=models.DateTimeField(blank=True, null=True),
|
|
62
|
+
),
|
|
63
|
+
]
|
photo_objects/django/models.py
CHANGED
|
@@ -23,7 +23,26 @@ def _timestamp_str(timestamp):
|
|
|
23
23
|
return timestamp.isoformat() if timestamp else None
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
class
|
|
26
|
+
class BaseModel(models.Model):
|
|
27
|
+
title = models.CharField(blank=True)
|
|
28
|
+
description = models.TextField(blank=True)
|
|
29
|
+
|
|
30
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
31
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
32
|
+
|
|
33
|
+
class Meta:
|
|
34
|
+
abstract = True
|
|
35
|
+
|
|
36
|
+
def to_json(self):
|
|
37
|
+
return dict(
|
|
38
|
+
title=self.title,
|
|
39
|
+
description=self.description,
|
|
40
|
+
created_at=_timestamp_str(self.created_at),
|
|
41
|
+
updated_at=_timestamp_str(self.updated_at),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Album(BaseModel):
|
|
27
46
|
class Meta:
|
|
28
47
|
ordering = ["-first_timestamp", "-last_timestamp", "key"]
|
|
29
48
|
|
|
@@ -31,6 +50,7 @@ class Album(models.Model):
|
|
|
31
50
|
PUBLIC = "public", _("Public")
|
|
32
51
|
HIDDEN = "hidden", _("Hidden")
|
|
33
52
|
PRIVATE = "private", _("Private")
|
|
53
|
+
ADMIN = "", _("Admin")
|
|
34
54
|
|
|
35
55
|
key = models.CharField(primary_key=True, validators=[album_key_validator])
|
|
36
56
|
visibility = models.CharField(
|
|
@@ -39,27 +59,23 @@ class Album(models.Model):
|
|
|
39
59
|
default=Visibility.PRIVATE,
|
|
40
60
|
choices=Visibility)
|
|
41
61
|
|
|
42
|
-
title = models.CharField(blank=True)
|
|
43
|
-
description = models.TextField(blank=True)
|
|
44
|
-
|
|
45
62
|
cover_photo = models.ForeignKey(
|
|
46
63
|
"Photo",
|
|
47
64
|
blank=True,
|
|
48
65
|
null=True,
|
|
49
66
|
on_delete=models.SET_NULL,
|
|
50
67
|
related_name="+")
|
|
51
|
-
first_timestamp = models.DateTimeField(null=True)
|
|
52
|
-
last_timestamp = models.DateTimeField(null=True)
|
|
68
|
+
first_timestamp = models.DateTimeField(blank=True, null=True)
|
|
69
|
+
last_timestamp = models.DateTimeField(blank=True, null=True)
|
|
53
70
|
|
|
54
71
|
def __str__(self):
|
|
55
72
|
return _str(self.key, title=self.title, visibility=self.visibility)
|
|
56
73
|
|
|
57
74
|
def to_json(self):
|
|
58
75
|
return dict(
|
|
76
|
+
**super().to_json(),
|
|
59
77
|
key=self.key,
|
|
60
78
|
visibility=self.visibility,
|
|
61
|
-
title=self.title,
|
|
62
|
-
description=self.description,
|
|
63
79
|
cover_photo=(
|
|
64
80
|
self.cover_photo.filename if self.cover_photo else None),
|
|
65
81
|
first_timestamp=_timestamp_str(self.first_timestamp),
|
|
@@ -67,7 +83,7 @@ class Album(models.Model):
|
|
|
67
83
|
)
|
|
68
84
|
|
|
69
85
|
|
|
70
|
-
class Photo(
|
|
86
|
+
class Photo(BaseModel):
|
|
71
87
|
class Meta:
|
|
72
88
|
ordering = ["timestamp"]
|
|
73
89
|
|
|
@@ -75,13 +91,21 @@ class Photo(models.Model):
|
|
|
75
91
|
album = models.ForeignKey("Album", null=True, on_delete=models.PROTECT)
|
|
76
92
|
|
|
77
93
|
timestamp = models.DateTimeField()
|
|
78
|
-
title = models.CharField(blank=True)
|
|
79
|
-
description = models.TextField(blank=True)
|
|
80
94
|
|
|
81
95
|
height = models.PositiveIntegerField()
|
|
82
96
|
width = models.PositiveIntegerField()
|
|
83
97
|
tiny_base64 = models.TextField(blank=True)
|
|
84
98
|
|
|
99
|
+
camera_make = models.CharField(blank=True)
|
|
100
|
+
camera_model = models.CharField(blank=True)
|
|
101
|
+
lens_make = models.CharField(blank=True)
|
|
102
|
+
lens_model = models.CharField(blank=True)
|
|
103
|
+
|
|
104
|
+
focal_length = models.FloatField(blank=True, null=True)
|
|
105
|
+
f_number = models.FloatField(blank=True, null=True)
|
|
106
|
+
exposure_time = models.FloatField(blank=True, null=True)
|
|
107
|
+
iso_speed = models.IntegerField(blank=True, null=True)
|
|
108
|
+
|
|
85
109
|
def __str__(self):
|
|
86
110
|
return _str(
|
|
87
111
|
self.key,
|
|
@@ -105,6 +129,7 @@ class Photo(models.Model):
|
|
|
105
129
|
album_key = self.album.key if self.album else None
|
|
106
130
|
|
|
107
131
|
return dict(
|
|
132
|
+
**super().to_json(),
|
|
108
133
|
key=self.key,
|
|
109
134
|
filename=self.filename,
|
|
110
135
|
album=album_key,
|
|
@@ -112,6 +137,12 @@ class Photo(models.Model):
|
|
|
112
137
|
height=self.height,
|
|
113
138
|
width=self.width,
|
|
114
139
|
tiny_base64=self.tiny_base64,
|
|
115
|
-
|
|
116
|
-
|
|
140
|
+
camera_make=self.camera_make,
|
|
141
|
+
camera_model=self.camera_model,
|
|
142
|
+
lens_make=self.lens_make,
|
|
143
|
+
lens_model=self.lens_model,
|
|
144
|
+
focal_length=self.focal_length,
|
|
145
|
+
f_number=self.f_number,
|
|
146
|
+
exposure_time=self.exposure_time,
|
|
147
|
+
iso_speed=self.iso_speed,
|
|
117
148
|
)
|
photo_objects/django/objsto.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import mimetypes
|
|
3
|
+
import urllib3
|
|
3
4
|
|
|
4
5
|
from django.conf import settings
|
|
5
6
|
|
|
@@ -27,10 +28,16 @@ def _anonymous_readonly_policy(bucket: str):
|
|
|
27
28
|
def _objsto_access() -> tuple[Minio, str]:
|
|
28
29
|
conf = settings.PHOTO_OBJECTS_OBJSTO
|
|
29
30
|
|
|
31
|
+
http = urllib3.PoolManager(
|
|
32
|
+
retries=urllib3.util.Retry(connect=1),
|
|
33
|
+
timeout=urllib3.util.Timeout(connect=2.5, read=20),
|
|
34
|
+
)
|
|
35
|
+
|
|
30
36
|
client = Minio(
|
|
31
37
|
conf.get('URL'),
|
|
32
38
|
conf.get('ACCESS_KEY'),
|
|
33
39
|
conf.get('SECRET_KEY'),
|
|
40
|
+
http_client=http,
|
|
34
41
|
secure=conf.get('SECURE', True),
|
|
35
42
|
)
|
|
36
43
|
bucket = conf.get('BUCKET', 'photos')
|
|
@@ -80,3 +87,17 @@ def delete_photo(album_key, photo_key):
|
|
|
80
87
|
""),
|
|
81
88
|
recursive=True):
|
|
82
89
|
client.remove_object(bucket, i.object_name)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_error_code(e: Exception) -> str:
|
|
93
|
+
try:
|
|
94
|
+
return e.code
|
|
95
|
+
except AttributeError:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def with_error_code(msg: str, e: Exception) -> str:
|
|
100
|
+
code = get_error_code(e)
|
|
101
|
+
if code:
|
|
102
|
+
return f'{msg} ({code})'
|
|
103
|
+
return msg
|