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.
Files changed (35) hide show
  1. photo_objects/__init__.py +3 -0
  2. photo_objects/config.py +31 -0
  3. photo_objects/django/api/album.py +17 -3
  4. photo_objects/django/api/auth.py +5 -1
  5. photo_objects/django/api/photo.py +17 -18
  6. photo_objects/django/forms.py +101 -6
  7. photo_objects/django/management/commands/create-initial-admin-account.py +5 -3
  8. photo_objects/django/management/commands/create-site-albums.py +32 -0
  9. photo_objects/django/migrations/0002_created_at_updated_at.py +36 -0
  10. photo_objects/django/migrations/0003_admin_visibility.py +18 -0
  11. photo_objects/django/migrations/0004_camera_setup_and_settings.py +63 -0
  12. photo_objects/django/models.py +44 -13
  13. photo_objects/django/objsto.py +21 -0
  14. photo_objects/django/signals.py +34 -2
  15. photo_objects/django/templatetags/__init__.py +0 -0
  16. photo_objects/django/templatetags/photo_objects_extras.py +53 -0
  17. photo_objects/django/tests/test_album.py +109 -10
  18. photo_objects/django/tests/test_auth.py +11 -0
  19. photo_objects/django/tests/test_photo.py +87 -10
  20. photo_objects/django/tests/test_utils.py +25 -0
  21. photo_objects/django/tests/utils.py +32 -0
  22. photo_objects/django/urls.py +7 -0
  23. photo_objects/django/views/api/photo.py +10 -4
  24. photo_objects/django/views/ui/__init__.py +1 -0
  25. photo_objects/django/views/ui/album.py +60 -19
  26. photo_objects/django/views/ui/configuration.py +117 -0
  27. photo_objects/django/views/ui/photo.py +91 -28
  28. photo_objects/django/views/utils.py +8 -0
  29. photo_objects/img.py +38 -6
  30. {photo_objects-0.0.1.dist-info → photo_objects-0.0.3.dist-info}/METADATA +46 -7
  31. photo_objects-0.0.3.dist-info/RECORD +52 -0
  32. {photo_objects-0.0.1.dist-info → photo_objects-0.0.3.dist-info}/WHEEL +1 -1
  33. photo_objects-0.0.1.dist-info/RECORD +0 -44
  34. {photo_objects-0.0.1.dist-info → photo_objects-0.0.3.dist-info/licenses}/LICENSE +0 -0
  35. {photo_objects-0.0.1.dist-info → photo_objects-0.0.3.dist-info}/top_level.txt +0 -0
photo_objects/__init__.py CHANGED
@@ -0,0 +1,3 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("photo-objects")
@@ -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
- else:
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}, instance=album)
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:
@@ -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 != Album.Visibility.PUBLIC:
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
- # TODO: logging
59
- raise JsonProblem(
60
- "Could not save photo to object storage.",
61
- 500,
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
- raise JsonProblem(
126
- "Could not delete photo from object storage.",
127
- 500,
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
- raise JsonProblem(
134
- "Could not delete photo from database.",
135
- 500,
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)
@@ -9,8 +9,10 @@ from django.forms import (
9
9
  Form,
10
10
  HiddenInput,
11
11
  ModelForm,
12
- ValidationError
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
- msg = (
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
+ ]
@@ -23,7 +23,26 @@ def _timestamp_str(timestamp):
23
23
  return timestamp.isoformat() if timestamp else None
24
24
 
25
25
 
26
- class Album(models.Model):
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(models.Model):
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
- title=self.title,
116
- description=self.description,
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
  )
@@ -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