photo-objects 0.4.0__py3-none-any.whl → 0.4.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.
Files changed (31) hide show
  1. photo_objects/config.py +6 -0
  2. photo_objects/django/api/album.py +2 -2
  3. photo_objects/django/api/auth.py +3 -3
  4. photo_objects/django/api/photo.py +4 -4
  5. photo_objects/django/api/utils.py +2 -2
  6. photo_objects/django/forms.py +2 -2
  7. photo_objects/django/management/commands/clean-scaled-photos.py +1 -0
  8. photo_objects/django/management/commands/create-initial-admin-account.py +6 -4
  9. photo_objects/django/management/commands/create-site-albums.py +1 -1
  10. photo_objects/django/signals.py +0 -2
  11. photo_objects/django/templatetags/photo_objects_extras.py +3 -2
  12. photo_objects/django/tests/test_album.py +7 -7
  13. photo_objects/django/tests/test_auth.py +2 -2
  14. photo_objects/django/tests/test_commands.py +12 -44
  15. photo_objects/django/tests/test_img.py +3 -3
  16. photo_objects/django/tests/test_photo.py +3 -3
  17. photo_objects/django/tests/test_utils.py +24 -3
  18. photo_objects/django/tests/utils.py +35 -1
  19. photo_objects/django/urls.py +1 -2
  20. photo_objects/django/views/api/photo.py +1 -1
  21. photo_objects/django/views/ui/album.py +8 -5
  22. photo_objects/django/views/ui/configuration.py +1 -1
  23. photo_objects/django/views/ui/photo.py +8 -4
  24. photo_objects/django/views/ui/users.py +1 -1
  25. photo_objects/django/views/utils.py +48 -0
  26. photo_objects/img.py +1 -1
  27. {photo_objects-0.4.0.dist-info → photo_objects-0.4.1.dist-info}/METADATA +2 -2
  28. {photo_objects-0.4.0.dist-info → photo_objects-0.4.1.dist-info}/RECORD +31 -31
  29. {photo_objects-0.4.0.dist-info → photo_objects-0.4.1.dist-info}/WHEEL +0 -0
  30. {photo_objects-0.4.0.dist-info → photo_objects-0.4.1.dist-info}/licenses/LICENSE +0 -0
  31. {photo_objects-0.4.0.dist-info → photo_objects-0.4.1.dist-info}/top_level.txt +0 -0
photo_objects/config.py CHANGED
@@ -29,3 +29,9 @@ def get_secret_key() -> str:
29
29
  write_to_home_directory("secret_key", key)
30
30
 
31
31
  return key
32
+
33
+
34
+ def add_port_to_host(url: str, port: str | None) -> str:
35
+ if port:
36
+ return f"{url}:{port}"
37
+ return url
@@ -16,7 +16,7 @@ from .utils import (
16
16
  )
17
17
 
18
18
 
19
- def get_site_album(site: Site) -> Album:
19
+ def get_site_album(site: Site) -> tuple[Album, bool]:
20
20
  album_key = f'_site_{site.id}'
21
21
  return Album.objects.get_or_create(
22
22
  key=album_key,
@@ -89,4 +89,4 @@ def delete_album(request: HttpRequest, album_key: str):
89
89
  f"Album with {album_key} key can not be deleted because it "
90
90
  "contains photos.",
91
91
  409,
92
- )
92
+ ) from None
@@ -16,7 +16,7 @@ def check_album_access(request: HttpRequest, album_key: str):
16
16
  try:
17
17
  album = Album.objects.get(key=album_key)
18
18
  except Album.DoesNotExist:
19
- raise AlbumNotFound(album_key)
19
+ raise AlbumNotFound(album_key) from None
20
20
 
21
21
  if not request.user.is_authenticated:
22
22
  if album.visibility == Album.Visibility.PRIVATE:
@@ -37,12 +37,12 @@ def check_photo_access(
37
37
  try:
38
38
  size = PhotoSize(size_key)
39
39
  except ValueError:
40
- raise InvalidSize(size_key)
40
+ raise InvalidSize(size_key) from None
41
41
 
42
42
  try:
43
43
  photo = Photo.objects.get(key=join_key(album_key, photo_key))
44
44
  except Photo.DoesNotExist:
45
- raise PhotoNotFound(album_key, photo_key)
45
+ raise PhotoNotFound(album_key, photo_key) from None
46
46
 
47
47
  if not request.user.is_authenticated:
48
48
  if photo.album.visibility == Album.Visibility.PRIVATE:
@@ -36,7 +36,7 @@ def _upload_photo(album_key: str, photo_file: UploadedFile):
36
36
  raise JsonProblem(
37
37
  "Could not open photo file.",
38
38
  400,
39
- )
39
+ ) from None
40
40
 
41
41
  f = CreatePhotoForm(dict(
42
42
  key=f"{album_key}/{slugify(photo_file.name)}",
@@ -59,7 +59,7 @@ def _upload_photo(album_key: str, photo_file: UploadedFile):
59
59
  msg = objsto.with_error_code(
60
60
  "Could not save photo to object storage", e)
61
61
  logger.error(f"{msg}: {str(e)}")
62
- raise JsonProblem(f"{msg}.", 500)
62
+ raise JsonProblem(f"{msg}.", 500) from e
63
63
 
64
64
  return photo
65
65
 
@@ -125,11 +125,11 @@ def delete_photo(request: HttpRequest, album_key: str, photo_key: str):
125
125
  msg = objsto.with_error_code(
126
126
  "Could not delete photo from object storage", e)
127
127
  logger.error(f"{msg}: {str(e)}")
128
- raise JsonProblem("{msg}.", 500)
128
+ raise JsonProblem(f"{msg}.", 500) from e
129
129
 
130
130
  try:
131
131
  photo.delete()
132
132
  except Exception as e:
133
133
  msg = "Could not delete photo from database"
134
134
  logger.error(f"{msg}: {str(e)}")
135
- raise JsonProblem(f"{msg}.", 500)
135
+ raise JsonProblem(f"{msg}.", 500) from e
@@ -50,7 +50,7 @@ class JsonProblem(PhotoObjectsError):
50
50
  return render(request, "photo_objects/problem.html", {
51
51
  "title": "Error",
52
52
  "back": BackLink(
53
- f'Back to albums',
53
+ 'Back to albums',
54
54
  reverse_lazy('photo_objects:list_albums')),
55
55
  "problem_title": self.title,
56
56
  "status": self.status
@@ -153,7 +153,7 @@ def parse_json_body(request: HttpRequest):
153
153
  raise JsonProblem(
154
154
  "Could not parse JSON data from request body.",
155
155
  400,
156
- )
156
+ ) from None
157
157
 
158
158
 
159
159
  def parse_input_data(request: HttpRequest):
@@ -23,9 +23,9 @@ KEY_POSTFIX_CHARS = 'bcdfghjklmnpqrstvwxz2456789'
23
23
  KEY_POSTFIX_LEN = 5
24
24
 
25
25
 
26
- def slugify(input: str, lower=False, replace_leading_underscores=False) -> str:
26
+ def slugify(title: str, lower=False, replace_leading_underscores=False) -> str:
27
27
  key = unicodedata.normalize(
28
- 'NFKD', input).encode(
28
+ 'NFKD', title).encode(
29
29
  'ascii', 'ignore').decode('ascii')
30
30
  if lower:
31
31
  key = key.lower()
@@ -1,3 +1,4 @@
1
+ # pylint: disable=invalid-name
1
2
  from django.core.management.base import BaseCommand
2
3
 
3
4
  from photo_objects.utils import pretty_list
@@ -1,6 +1,8 @@
1
+ # pylint: disable=invalid-name
2
+ from secrets import token_urlsafe
3
+
1
4
  from django.core.management.base import BaseCommand
2
5
  from django.contrib.auth import get_user_model
3
- from secrets import token_urlsafe
4
6
 
5
7
  from photo_objects.config import write_to_home_directory
6
8
 
@@ -9,13 +11,13 @@ class Command(BaseCommand):
9
11
  help = "Create initial admin user account."
10
12
 
11
13
  def handle(self, *args, **options):
12
- User = get_user_model()
13
- superuser_count = User.objects.filter(is_superuser=True).count()
14
+ user = get_user_model()
15
+ superuser_count = user.objects.filter(is_superuser=True).count()
14
16
 
15
17
  if superuser_count == 0:
16
18
  username = 'admin'
17
19
  password = token_urlsafe(32)
18
- User.objects.create_superuser(username, password=password)
20
+ user.objects.create_superuser(username, password=password)
19
21
 
20
22
  write_to_home_directory("initial_admin_password", password)
21
23
 
@@ -1,8 +1,8 @@
1
+ # pylint: disable=invalid-name
1
2
  from django.core.management.base import BaseCommand
2
3
  from django.contrib.sites.models import Site
3
4
 
4
5
  from photo_objects.django.api.album import get_site_album
5
- from photo_objects.django.models import Album
6
6
 
7
7
 
8
8
  class Command(BaseCommand):
@@ -1,5 +1,3 @@
1
- import re
2
-
3
1
  from django.contrib.sites.models import Site
4
2
  from django.db.models.signals import post_save, post_delete
5
3
  from django.dispatch import receiver
@@ -2,6 +2,7 @@ from datetime import datetime
2
2
  from django import template
3
3
 
4
4
  from photo_objects.django.api.album import get_site_album
5
+ from photo_objects.django.views.utils import meta_description
5
6
 
6
7
 
7
8
  register = template.Library()
@@ -47,12 +48,12 @@ def meta_og(context):
47
48
  try:
48
49
  request = context.get("request")
49
50
  site = request.site
50
- album = get_site_album(site.id)
51
+ album, _ = get_site_album(site)
51
52
 
52
53
  return {
53
54
  'request': request,
54
55
  "title": album.title or site.name,
55
- "description": album.description,
56
+ "description": meta_description(request, album.description),
56
57
  "photo": album.cover_photo,
57
58
  }
58
59
  except Exception:
@@ -22,9 +22,9 @@ PHOTOS_DIRECTORY = "photos"
22
22
  class ViewVisibilityTests(TestCase):
23
23
  @classmethod
24
24
  def setUpTestData(cls):
25
- User = get_user_model()
26
- User.objects.create_user(username='test-visibility', password='test')
27
- User.objects.create_user(
25
+ user = get_user_model()
26
+ user.objects.create_user(username='test-visibility', password='test')
27
+ user.objects.create_user(
28
28
  username='test-staff-visibility',
29
29
  password='test',
30
30
  is_staff=True)
@@ -91,16 +91,16 @@ class ViewVisibilityTests(TestCase):
91
91
 
92
92
  class AlbumViewTests(TestCase):
93
93
  def setUp(self):
94
- User = get_user_model()
95
- User.objects.create_user(username='no_permission', password='test')
94
+ user = get_user_model()
95
+ user.objects.create_user(username='no_permission', password='test')
96
96
 
97
- User.objects.create_user(
97
+ user.objects.create_user(
98
98
  username='superuser',
99
99
  password='test',
100
100
  is_staff=True,
101
101
  is_superuser=True)
102
102
 
103
- has_permission = User.objects.create_user(
103
+ has_permission = user.objects.create_user(
104
104
  username='has_permission', password='test')
105
105
  permissions = [
106
106
  'add_album',
@@ -12,8 +12,8 @@ def _path_fn(album, photo):
12
12
  class AuthViewTests(TestCase):
13
13
  @classmethod
14
14
  def setUpTestData(cls):
15
- User = get_user_model()
16
- User.objects.create_user(username='test-auth', password='test')
15
+ user = get_user_model()
16
+ user.objects.create_user(username='test-auth', password='test')
17
17
 
18
18
  public_album = Album.objects.create(
19
19
  key="test-auth-public", visibility=Album.Visibility.PUBLIC)
@@ -2,9 +2,7 @@ from io import StringIO
2
2
 
3
3
  from django.contrib.auth import get_user_model
4
4
  from django.core.management import call_command
5
- from minio import S3Error
6
5
 
7
- from photo_objects.django import objsto
8
6
  from photo_objects.django.conf import CONFIGURABLE_PHOTO_SIZES
9
7
  from photo_objects.django.models import Album
10
8
 
@@ -13,8 +11,8 @@ from .utils import TestCase, open_test_photo
13
11
 
14
12
  class PhotoViewTests(TestCase):
15
13
  def setUp(self):
16
- User = get_user_model()
17
- User.objects.create_user(
14
+ user = get_user_model()
15
+ user.objects.create_user(
18
16
  username='superuser',
19
17
  password='test',
20
18
  is_staff=True,
@@ -30,36 +28,6 @@ class PhotoViewTests(TestCase):
30
28
  f"/api/albums/{album_key}/photos/{photo_key}/img?size={size}")
31
29
  self.assertStatus(response, 200)
32
30
 
33
- def assertPhotoFound(self, album_key, photo_key, sizes):
34
- if not isinstance(sizes, list):
35
- sizes = [sizes]
36
-
37
- for size in sizes:
38
- try:
39
- objsto.get_photo(album_key, photo_key, size)
40
- except S3Error as e:
41
- if e.code == "NoSuchKey":
42
- raise AssertionError(
43
- f"Photo not found: {size}/{album_key}/{photo_key}")
44
- else:
45
- raise e
46
-
47
- def assertPhotoNotFound(self, album_key, photo_key, sizes):
48
- if not isinstance(sizes, list):
49
- sizes = [sizes]
50
-
51
- for size in sizes:
52
- with self.assertRaises(
53
- S3Error,
54
- msg=f"Photo found: {size}/{album_key}/{photo_key}"
55
- ) as e:
56
- objsto.get_photo(album_key, photo_key, size)
57
-
58
- self.assertEqual(
59
- e.exception.code,
60
- "NoSuchKey",
61
- f"Photo not found: {size}/{album_key}/{photo_key}")
62
-
63
31
  def test_clean_scaled_photos(self):
64
32
  login_success = self.client.login(
65
33
  username='superuser', password='test')
@@ -73,23 +41,23 @@ class PhotoViewTests(TestCase):
73
41
  self.assertStatus(response, 201)
74
42
 
75
43
  self._scale_image("test-photo-sizes", "tower.jpg")
76
- self.assertPhotoFound("test-photo-sizes",
77
- "tower.jpg", ["sm", "md", "lg", "og"])
44
+ self.assertPhotoInObjsto(
45
+ "test-photo-sizes", "tower.jpg", ["sm", "md", "lg", "og"])
78
46
 
79
47
  out = StringIO()
80
48
  call_command('clean-scaled-photos', stdout=out)
81
49
  output = out.getvalue()
82
50
  self.assertIn("No previous photo sizes configuration found", output)
83
51
  self.assertIn("Total deleted photos: 3", output)
84
- self.assertPhotoNotFound(
52
+ self.assertPhotoNotInObjsto(
85
53
  "test-photo-sizes",
86
54
  "tower.jpg",
87
55
  CONFIGURABLE_PHOTO_SIZES)
88
- self.assertPhotoFound("test-photo-sizes", "tower.jpg", "og")
56
+ self.assertPhotoInObjsto("test-photo-sizes", "tower.jpg", "og")
89
57
 
90
58
  self._scale_image("test-photo-sizes", "tower.jpg")
91
- self.assertPhotoFound("test-photo-sizes",
92
- "tower.jpg", ["sm", "md", "lg", "og"])
59
+ self.assertPhotoInObjsto(
60
+ "test-photo-sizes", "tower.jpg", ["sm", "md", "lg", "og"])
93
61
 
94
62
  with self.settings(PHOTO_OBJECTS_PHOTO_SIZES=dict(
95
63
  sm=dict(max_width=256, max_height=256),
@@ -101,12 +69,12 @@ class PhotoViewTests(TestCase):
101
69
  "Found changes in photo sizes configuration for sm sizes.",
102
70
  output)
103
71
  self.assertIn("Total deleted photos: 1", output)
104
- self.assertPhotoNotFound("test-photo-sizes", "tower.jpg", "sm")
105
- self.assertPhotoFound("test-photo-sizes",
106
- "tower.jpg", ["md", "lg", "og"])
72
+ self.assertPhotoNotInObjsto("test-photo-sizes", "tower.jpg", "sm")
73
+ self.assertPhotoInObjsto(
74
+ "test-photo-sizes", "tower.jpg", ["md", "lg", "og"])
107
75
 
108
76
  response = self.client.delete(
109
77
  "/api/albums/test-photo-sizes/photos/tower.jpg")
110
78
  self.assertStatus(response, 204)
111
- self.assertPhotoNotFound(
79
+ self.assertPhotoNotInObjsto(
112
80
  "test-photo-sizes", "tower.jpg", ["sm", "md", "lg", "og"])
@@ -16,10 +16,10 @@ class ImgTests(TestCase):
16
16
  ((1000, 1000), (512, 512)),
17
17
  ]
18
18
 
19
- for input, expected in testdata:
20
- with self.subTest(w=input[0], h=input[1]):
19
+ for size, expected in testdata:
20
+ with self.subTest(w=size[0], h=size[1]):
21
21
  original = BytesIO()
22
- image = Image.new("RGB", input, color="red")
22
+ image = Image.new("RGB", size, color="red")
23
23
  image.save(original, format="JPEG")
24
24
  original.seek(0)
25
25
 
@@ -18,10 +18,10 @@ from .utils import TestCase, open_test_photo, parse_timestamps
18
18
 
19
19
  class PhotoViewTests(TestCase):
20
20
  def setUp(self):
21
- User = get_user_model()
22
- User.objects.create_user(username='no_permission', password='test')
21
+ user = get_user_model()
22
+ user.objects.create_user(username='no_permission', password='test')
23
23
 
24
- has_permission = User.objects.create_user(
24
+ has_permission = user.objects.create_user(
25
25
  username='has_permission', password='test')
26
26
  permissions = [
27
27
  'add_photo',
@@ -1,9 +1,11 @@
1
1
  from unittest import TestCase
2
+ from unittest.mock import MagicMock
2
3
 
3
4
  from minio import S3Error
4
5
 
5
6
  from photo_objects.django import objsto
6
7
  from photo_objects.django.forms import slugify
8
+ from photo_objects.django.views.utils import meta_description
7
9
 
8
10
 
9
11
  class TestUtils(TestCase):
@@ -16,9 +18,9 @@ class TestUtils(TestCase):
16
18
  ("album__photo_-key", "album-photo-key"),
17
19
  ]
18
20
 
19
- for input, expected in checks:
20
- with self.subTest(input=input, expected=expected):
21
- self.assertEqual(slugify(input), expected)
21
+ for title, expected in checks:
22
+ with self.subTest(input=title, expected=expected):
23
+ self.assertEqual(slugify(title), expected)
22
24
 
23
25
  def test_slugify_lower(self):
24
26
  self.assertEqual(slugify("QwErTy!", True), "qwerty-")
@@ -41,3 +43,22 @@ class TestUtils(TestCase):
41
43
  objsto.with_error_code("Failed", e),
42
44
  "Failed (Test)",
43
45
  )
46
+
47
+ def test_meta_description(self):
48
+ md_multi_p = (
49
+ "Description with **bold** and *italics*...\n\n"
50
+ "...and multiple paragraphs")
51
+ testdata = [
52
+ ("Plain text description",
53
+ "Plain text description"),
54
+ (md_multi_p,
55
+ "Description with bold and italics..."),
56
+ (None,
57
+ "A simple self-hosted photo server."),
58
+ ]
59
+
60
+ for description, expected in testdata:
61
+ with self.subTest(expected=expected):
62
+ self.assertEqual(
63
+ meta_description(MagicMock(), description),
64
+ expected)
@@ -5,7 +5,9 @@ from django.conf import settings
5
5
  from django.test import TestCase as DjangoTestCase, override_settings
6
6
  from django.utils import timezone
7
7
  from django.utils.dateparse import parse_datetime
8
+ from minio import S3Error
8
9
 
10
+ from photo_objects.django import objsto
9
11
  from photo_objects.django.models import Album, Photo
10
12
  from photo_objects.django.objsto import _objsto_access
11
13
 
@@ -38,8 +40,9 @@ def _objsto_test_settings():
38
40
 
39
41
  @override_settings(PHOTO_OBJECTS_OBJSTO=_objsto_test_settings())
40
42
  class TestCase(DjangoTestCase):
43
+ # pylint: disable=invalid-name
41
44
  @classmethod
42
- def tearDownClass(_):
45
+ def tearDownClass(cls):
43
46
  client, bucket = _objsto_access()
44
47
 
45
48
  for i in client.list_objects(bucket, recursive=True):
@@ -47,6 +50,37 @@ class TestCase(DjangoTestCase):
47
50
 
48
51
  client.remove_bucket(bucket)
49
52
 
53
+ def assertPhotoInObjsto(self, album_key, photo_key, sizes):
54
+ if not isinstance(sizes, list):
55
+ sizes = [sizes]
56
+
57
+ for size in sizes:
58
+ try:
59
+ objsto.get_photo(album_key, photo_key, size)
60
+ except S3Error as e:
61
+ if e.code == "NoSuchKey":
62
+ raise AssertionError(
63
+ f"Photo not found: {size}/{album_key}/{photo_key}"
64
+ ) from None
65
+ else:
66
+ raise e
67
+
68
+ def assertPhotoNotInObjsto(self, album_key, photo_key, sizes):
69
+ if not isinstance(sizes, list):
70
+ sizes = [sizes]
71
+
72
+ for size in sizes:
73
+ with self.assertRaises(
74
+ S3Error,
75
+ msg=f"Photo found: {size}/{album_key}/{photo_key}"
76
+ ) as e:
77
+ objsto.get_photo(album_key, photo_key, size)
78
+
79
+ self.assertEqual(
80
+ e.exception.code,
81
+ "NoSuchKey",
82
+ f"Photo not found: {size}/{album_key}/{photo_key}")
83
+
50
84
  def assertTimestampLess(self, a, b, **kwargs):
51
85
  '''Assert a is less than b. Automatically parses strings to datetime
52
86
  objects.
@@ -1,9 +1,8 @@
1
1
  from django.contrib.auth import views as auth_views
2
2
  from django.http import HttpResponseRedirect
3
- from django.urls import path, reverse_lazy
3
+ from django.urls import path
4
4
 
5
5
  from .views import api, ui
6
- from .views.utils import BackLink
7
6
 
8
7
  app_name = "photo_objects"
9
8
  urlpatterns = [
@@ -82,7 +82,7 @@ def get_img(request: HttpRequest, album_key: str, photo_key: str):
82
82
  album_key, photo_key, PhotoSize.ORIGINAL.value)
83
83
  except (S3Error, HTTPError) as e:
84
84
  msg = objsto.with_error_code(
85
- f"Could not fetch photo from object storage", e)
85
+ "Could not fetch photo from object storage", e)
86
86
  logger.error(f"{msg}: {str(e)}")
87
87
 
88
88
  code = objsto.get_error_code(e)
@@ -1,5 +1,3 @@
1
- import re
2
-
3
1
  from django.http import HttpRequest, HttpResponseRedirect
4
2
  from django.shortcuts import render
5
3
  from django.urls import reverse
@@ -10,7 +8,11 @@ from photo_objects.django.api.album import parse_site_id
10
8
  from photo_objects.django.api.utils import FormValidationFailed
11
9
  from photo_objects.django.forms import CreateAlbumForm, ModifyAlbumForm
12
10
  from photo_objects.django.models import Album
13
- from photo_objects.django.views.utils import BackLink, render_markdown
11
+ from photo_objects.django.views.utils import (
12
+ BackLink,
13
+ meta_description,
14
+ render_markdown,
15
+ )
14
16
 
15
17
  from .utils import json_problem_as_html
16
18
 
@@ -58,6 +60,7 @@ def get_info(request: HttpRequest, album_key: str):
58
60
  "album description to configure the site description. The album "
59
61
  "title is automatically updated when the related sites name is "
60
62
  "changed and vice versa.")
63
+ return None
61
64
 
62
65
 
63
66
  @json_problem_as_html
@@ -75,7 +78,7 @@ def show_album(request: HttpRequest, album_key: str):
75
78
  "album": album,
76
79
  "photos": photos,
77
80
  "title": album.title or album.key,
78
- "description": album.description,
81
+ "description": meta_description(request, album),
79
82
  "back": back,
80
83
  "details": details,
81
84
  "photo": album.cover_photo,
@@ -147,7 +150,7 @@ def delete_album(request: HttpRequest, album_key: str):
147
150
  'This album is managed by the system and can not be deleted.')}
148
151
 
149
152
  return render(request, 'photo_objects/delete.html', {
150
- "title": f"Delete album",
153
+ "title": "Delete album",
151
154
  "back": back,
152
155
  "photo": album.cover_photo,
153
156
  "resource": target,
@@ -44,7 +44,7 @@ def uses_https(request: HttpRequest) -> Validation:
44
44
  detail += _(
45
45
  ' If you are running the API server behind a reverse proxy or '
46
46
  'a load-balancer, ensure that HTTPS termination is configured '
47
- f'correctly.')
47
+ 'correctly.')
48
48
 
49
49
  return Validation(
50
50
  check=_("Site is served over HTTPS"),
@@ -6,7 +6,11 @@ from photo_objects.django import api
6
6
  from photo_objects.django.api.utils import AlbumNotFound, FormValidationFailed
7
7
  from photo_objects.django.forms import ModifyPhotoForm, UploadPhotosForm
8
8
  from photo_objects.django.models import Photo
9
- from photo_objects.django.views.utils import BackLink, render_markdown
9
+ from photo_objects.django.views.utils import (
10
+ BackLink,
11
+ meta_description,
12
+ render_markdown,
13
+ )
10
14
 
11
15
  from .utils import json_problem_as_html
12
16
 
@@ -40,8 +44,8 @@ def upload_photos(request: HttpRequest, album_key: str):
40
44
  })
41
45
 
42
46
 
43
- def _lower(input: str):
44
- return input.lower() if input else ''
47
+ def _lower(value: str):
48
+ return value.lower() if value else ''
45
49
 
46
50
 
47
51
  def _camera(photo: Photo):
@@ -128,7 +132,7 @@ def show_photo(request: HttpRequest, album_key: str, photo_key: str):
128
132
  "previous_filename": previous_filename,
129
133
  "next_filename": next_filename,
130
134
  "title": photo.title or photo.filename,
131
- "description": photo.description,
135
+ "description": meta_description(request, photo),
132
136
  "back": back,
133
137
  "details": details,
134
138
  })
@@ -16,7 +16,7 @@ def login(request: HttpRequest):
16
16
  "photo": album.cover_photo,
17
17
  "action": "Login",
18
18
  "back": BackLink(
19
- f'Back to albums',
19
+ 'Back to albums',
20
20
  reverse_lazy('photo_objects:list_albums')),
21
21
  "class": "login"
22
22
  },
@@ -1,6 +1,12 @@
1
+ from xml.etree import ElementTree as ET
2
+
3
+ from django.http import HttpRequest
4
+ from django.utils.dateformat import format as format_date
1
5
  from django.utils.safestring import mark_safe
2
6
  from markdown import markdown
3
7
 
8
+ from photo_objects.django.models import Album, Photo
9
+
4
10
 
5
11
  class BackLink:
6
12
  def __init__(self, text, url):
@@ -10,3 +16,45 @@ class BackLink:
10
16
 
11
17
  def render_markdown(value):
12
18
  return mark_safe(markdown(value))
19
+
20
+
21
+ def _first_paragraph_textcontent(raw) -> str | None:
22
+ html = render_markdown(raw)
23
+ root = ET.fromstring(f"<root>{html}</root>")
24
+
25
+ first = root.find("p")
26
+ if first is None:
27
+ return None
28
+
29
+ return ''.join(first.itertext())
30
+
31
+
32
+ def _default_album_description(request: HttpRequest, album: Album) -> str:
33
+ count = album.photo_set.count()
34
+ plural = 's' if count != 1 else ''
35
+ return f"Album with {count} photo{plural} in {request.site.name}."
36
+
37
+
38
+ def _default_photo_description(photo: Photo) -> str:
39
+ date_str = format_date(photo.timestamp, "F Y")
40
+ return f"Photo from {date_str} in {photo.album.title} album."
41
+
42
+
43
+ def meta_description(
44
+ request: HttpRequest,
45
+ resource: Album | Photo | str | None) -> str:
46
+ text = None
47
+ if isinstance(resource, Album):
48
+ text = (
49
+ _first_paragraph_textcontent(resource.description) or
50
+ _default_album_description(request, resource))
51
+
52
+ if isinstance(resource, Photo):
53
+ text = (
54
+ _first_paragraph_textcontent(resource.description) or
55
+ _default_photo_description(resource))
56
+
57
+ if isinstance(resource, str):
58
+ text = _first_paragraph_textcontent(resource)
59
+
60
+ return text or "A simple self-hosted photo server."
photo_objects/img.py CHANGED
@@ -30,6 +30,7 @@ class ExifReader:
30
30
  value = d.get(key)
31
31
  if value is not None:
32
32
  return value
33
+ return None
33
34
 
34
35
 
35
36
  def _read_original_datetime(image: Image) -> datetime:
@@ -63,7 +64,6 @@ def _read_camera_setup_and_settings(image: Image) -> dict:
63
64
  )
64
65
  except Exception as e:
65
66
  raise e
66
- return dict()
67
67
 
68
68
 
69
69
  def _image_format(filename):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: photo-objects
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Application for storing photos in S3 compatible object-storage.
5
5
  Author: Toni Kangas
6
6
  License: MIT License
@@ -74,7 +74,7 @@ autopep8 -aaar --in-place --exclude back/api/settings.py,*/migrations/*.py back
74
74
  Run static analysis with:
75
75
 
76
76
  ```sh
77
- pylint -E --enable=invalid-name,unused-import,useless-object-inheritance back/api photo_objects
77
+ pylint back/api photo_objects
78
78
  ```
79
79
 
80
80
  ### Integration tests
@@ -1,58 +1,58 @@
1
1
  photo_objects/__init__.py,sha256=I1508w_ntomEqTFQgC74SurhxVXfCiDWZLRsny2f59g,60
2
- photo_objects/config.py,sha256=e9uSuxytkEUR_s0z_WLy5yNWN6fSCdDLq8aw2k5250w,743
2
+ photo_objects/config.py,sha256=0-Aeo-z-d_fxx-cjAjxSwPJZUgYaAi7NTodiErlxIXo,861
3
3
  photo_objects/error.py,sha256=7afLYjxM0EaYioxVw_XUqHTvfSMSuQPUwwle0OVlaDY,45
4
- photo_objects/img.py,sha256=YzR5WnImupOUzSUukXrmviOygFR-z-7aOziEiJ_3zbs,4517
4
+ photo_objects/img.py,sha256=8FNxKFBVuSyxO_jA3W3PMag3bZ7sEXYKG78rpgxq71Q,4515
5
5
  photo_objects/utils.py,sha256=sYliXid-bv2EgNA97woRaOnWU75yZFq7fuqzWaseiDg,139
6
6
  photo_objects/django/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
7
  photo_objects/django/admin.py,sha256=sRnKTODk-8s6TAfwvsa2YAd7ECIfVmoOTR6PDASsvGg,122
8
8
  photo_objects/django/apps.py,sha256=Apqu6o6fpoxda18NQgKupvQRvTAZxVviIK_-dUR3rck,1444
9
9
  photo_objects/django/conf.py,sha256=s5lUbSLpjRWKlE2eYqAZmz60q4Oe10-e7JbIERPa6qI,2463
10
10
  photo_objects/django/context_processors.py,sha256=DLhLZHAvGlrpxYegaLiKEx9X7rpyZUFiAnAVF9jjkFA,165
11
- photo_objects/django/forms.py,sha256=VoDlyZAwiTLyNxW3rRk5bzjfPJvNuKV-wpuLGCe5TCY,6505
11
+ photo_objects/django/forms.py,sha256=XiCNqjVqPDn_aV4nGM1z5NfLKHd6_ipm7y0YRt3sXgY,6505
12
12
  photo_objects/django/models.py,sha256=tRHBqswASWMnyz8rwiKsmHDqEvQX54ly9k8tvU7qNOM,4597
13
13
  photo_objects/django/objsto.py,sha256=5z4F8pft01WKLBipyE3Ro9Ac9AvWbuV_rG9Iiour90E,3588
14
- photo_objects/django/signals.py,sha256=NEh_pfvsSt2f7MuHIJE0uLHUkqjKH4ipAFS7tmvm9LI,2400
15
- photo_objects/django/urls.py,sha256=ZBzTTYSLkeyuGM_NdSDzz_Yt4xs6hwIFhz04j59Q1r8,2023
14
+ photo_objects/django/signals.py,sha256=u1Is0GuNPh5aS47ocbYXbXhXUtjmkMyiViBW14DuyrE,2389
15
+ photo_objects/django/urls.py,sha256=pCUTxg4xSd3ZD8BZVjULb9QpJQ03Ek6-sKoVnYG3-OY,1975
16
16
  photo_objects/django/api/__init__.py,sha256=BnEHlm3mwyBy-1xhk-NFasgZa4fjCDjtfkBUoH0puPY,62
17
- photo_objects/django/api/album.py,sha256=c5qDvy6ebIZCzggxcYgSgLBR0cco79V47nJf2OHuBj8,2486
18
- photo_objects/django/api/auth.py,sha256=Iduv5grfcgvKiQ2wEFOQYgntiug02LGwL_LwC9-qcss,1390
19
- photo_objects/django/api/photo.py,sha256=NGCg_Qd4X9NAd7t6lqByK9JGsoTq8HKkyEr-HwMCimI,3838
20
- photo_objects/django/api/utils.py,sha256=MB15scKVrK7BMR1df0tc-_dQGI5WDE7iyxnRWl1znPw,5158
17
+ photo_objects/django/api/album.py,sha256=ALMEt3lnH1xFwIaSzSuMBlLEC6-hUiNDDGILRMvzDO4,2509
18
+ photo_objects/django/api/auth.py,sha256=lS0S1tMVH2uN30g4jlixklv3eMnQ2FbQVQvuRXeMGYo,1420
19
+ photo_objects/django/api/photo.py,sha256=Gc4GHmcW0995_alE7Q6mdn7SMt9f1JJ-krv-p-SJi8Y,3870
20
+ photo_objects/django/api/utils.py,sha256=kHOdXp-LEaEhL82LCLanQmVpU3jFi8KNSYOgIAv-IVs,5167
21
21
  photo_objects/django/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
22
  photo_objects/django/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
- photo_objects/django/management/commands/clean-scaled-photos.py,sha256=9zZ7SwX7gluFDh5QYSTg5FGKdq6tndEtBuop4lxmwd4,1977
24
- photo_objects/django/management/commands/create-initial-admin-account.py,sha256=M4qv1d_qFSGy_lDcKbyKOmfGsQt8i16Zoit9cJ6PwAU,1099
25
- photo_objects/django/management/commands/create-site-albums.py,sha256=mz99RfmczM0x7zuVRabReq4uBZXN-2Ow7VoNWARvBig,959
23
+ photo_objects/django/management/commands/clean-scaled-photos.py,sha256=KJY6phgTCxcmbMUsUfCRQjatvCmKyFninM8zT-tB3Kc,2008
24
+ photo_objects/django/management/commands/create-initial-admin-account.py,sha256=SuDSEP7S7OR99NtXvjXHnIyuawNOBgMiuwoHs7aCj6g,1131
25
+ photo_objects/django/management/commands/create-site-albums.py,sha256=qRNg-S2u5ABr7iL9LjQNaBaa7PMSSv39mRBWvh6jp_M,944
26
26
  photo_objects/django/migrations/0001_initial.py,sha256=BLW-EZ38sBgDhOYyprc-h_vuPpRxA11qxt4ZuYNO1Wo,2424
27
27
  photo_objects/django/migrations/0002_created_at_updated_at.py,sha256=7OT2VvDffAkX9XKBHVY-jvzxeIl2yU0Jr1ByCNGcUfw,1039
28
28
  photo_objects/django/migrations/0003_admin_visibility.py,sha256=PdxPOJzr-ViRBlOYUHEEGhe0hLtDysZJdMqvbjKVpEg,529
29
29
  photo_objects/django/migrations/0004_camera_setup_and_settings.py,sha256=CS5xyIHgBE2Y7-PSJ52ffRQeCzs8p899px9upomk4O8,1844
30
30
  photo_objects/django/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
31
31
  photo_objects/django/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
- photo_objects/django/templatetags/photo_objects_extras.py,sha256=D-xmbDPpvZYSdYyeCExnmLXW7BikOkrQhZab7j0Sjns,1311
32
+ photo_objects/django/templatetags/photo_objects_extras.py,sha256=FIv_Q3Xr6AgLBxx4_6aXks3VAt1tK2N3d9rzpzRtx24,1400
33
33
  photo_objects/django/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
- photo_objects/django/tests/test_album.py,sha256=XPrL2FGoLPNEl3fXvxqAimtOOpkD4pcdyYl-qEra5d4,13962
35
- photo_objects/django/tests/test_auth.py,sha256=qZoR6h80EXLcbhVFZgrEkibQGiTBZakUYfTQvCfd5gU,3928
36
- photo_objects/django/tests/test_commands.py,sha256=MzoVS8Lbi5MI3TgXmKSs8UG3V2_dXq_MKjx9EcrQyYw,4062
37
- photo_objects/django/tests/test_img.py,sha256=wMQv8I-g8K7-aOVwiRTU4_fQLVQr0MdLrPwzMckIOXY,833
38
- photo_objects/django/tests/test_photo.py,sha256=2tfSqBV6DtbxQm4Y0BXSSUQMGrY8vsYHhWgxSNp_p_8,13386
39
- photo_objects/django/tests/test_utils.py,sha256=DkhzDtZqu5OXg-IKzqHz2GY0sFS8RbwYC3w81RuPxS4,1259
40
- photo_objects/django/tests/utils.py,sha256=lcs1yL-oqLGFkV-NZNf_lOOuiZZZaSQNi7iznHm7BHY,2492
34
+ photo_objects/django/tests/test_album.py,sha256=UIwaAPpaE_v6OvHG_JWSytybht9RgWZR7BNOyyoNiH0,13962
35
+ photo_objects/django/tests/test_auth.py,sha256=hgr1UMVLvSI1x5zY7wTEXSBKfM5E_sNMIFlx8mVWYPY,3928
36
+ photo_objects/django/tests/test_commands.py,sha256=e3lE1ZhFR39WIq2VSKDNcQHUkSJqSWDYuAcAfu29svs,2955
37
+ photo_objects/django/tests/test_img.py,sha256=HEAWcr5fpTkzePkhoQ4YrWsDO9TvFOr7my_0LqVbaO4,829
38
+ photo_objects/django/tests/test_photo.py,sha256=FzSrtWMQjv_EL-M1zM6rYekw_YGuM87JkMHRcLvB2K8,13386
39
+ photo_objects/django/tests/test_utils.py,sha256=zBLv8lkvcLMCaH4D6GR1KZqUe-rPowhcBkQX19-Kshs,2007
40
+ photo_objects/django/tests/utils.py,sha256=Yre-l6rlJn7tPeqFnzQdcmBBG-2vwuGzVLKX-2uQhDs,3643
41
41
  photo_objects/django/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
42
- photo_objects/django/views/utils.py,sha256=369CUBWwm6LGh7wX1QVqIPE_K_6nb7ePEgHWvV2sKOw,245
42
+ photo_objects/django/views/utils.py,sha256=oP9B6QtHPaukX97rqIlCHxanduktPGFVSCGkGxf4lzI,1691
43
43
  photo_objects/django/views/api/__init__.py,sha256=SxK-b7MgMtD9VRMz46rDV5qtm6AvkRcg3Moa1AWl5pY,108
44
44
  photo_objects/django/views/api/album.py,sha256=EZMOkxYzLSWr9wwXnd4yAO64JtXZq2k3FYohiNMFbGQ,1602
45
45
  photo_objects/django/views/api/auth.py,sha256=EN_ExegzmLN-bhSzu3L9-6UE9qodPd7_ZRLilzrvc8Y,819
46
- photo_objects/django/views/api/photo.py,sha256=ctfZM80W-rc-t7GIUEI02jRllwe-94FPYThoGQ41EqI,3503
46
+ photo_objects/django/views/api/photo.py,sha256=FQCqcnPwSaEJ9MVggQF09E4WD1wVFmLZnT_pFFpWGvA,3502
47
47
  photo_objects/django/views/api/utils.py,sha256=uQzKdSKHRAux5OZzqgWQr0gsK_FeweQP0cg_67OWA_Y,264
48
48
  photo_objects/django/views/ui/__init__.py,sha256=N3ro5KggdV-JnfyHwoStX73b3SbVbpcsMuQNlxntVJs,92
49
- photo_objects/django/views/ui/album.py,sha256=gOVwkpNZFytTrEU0N6OfJu4U4l85AIbL6b0L6x0eA4E,5232
50
- photo_objects/django/views/ui/configuration.py,sha256=pzNRHq3FSrVCTx2R31_FW3A3aJxzEO4pDnXsOVS75uA,5576
51
- photo_objects/django/views/ui/photo.py,sha256=ioECWNMZGLgnEAZBpvYjEVDYZMAyvCwXgl6y3L8jKsE,6249
52
- photo_objects/django/views/ui/users.py,sha256=nlJyW7rhmr-ZR4LeSHMRPVmJzpiyHCEB2ry6uH2ihOc,713
49
+ photo_objects/django/views/ui/album.py,sha256=xXGYGYUY4WicI16Jy64sSi49RGIHtsY8KIzdXW0Yz7I,5286
50
+ photo_objects/django/views/ui/configuration.py,sha256=B6PotwK40CMQywdGqyXbwGYXbJv47n5YQT4_WtCqa1w,5575
51
+ photo_objects/django/views/ui/photo.py,sha256=Zo-HE-CzMSLFEtDTL1ds1HnXi3ORsIym4iWa_ZMCPpc,6299
52
+ photo_objects/django/views/ui/users.py,sha256=9EWxw178kcjPNaDzrzKOpTzO93uJYuhjE0lyBMJPw2o,712
53
53
  photo_objects/django/views/ui/utils.py,sha256=YV_YcUbX-zUkdFnBlezPChR6aPDhZJ9loSOHBSzF6Cc,273
54
- photo_objects-0.4.0.dist-info/licenses/LICENSE,sha256=V3w6hTjXfP65F4r_mejveHcV5igHrblxao3-2RlfVlA,1068
55
- photo_objects-0.4.0.dist-info/METADATA,sha256=bHZ6ikBTFlHIWtZ-fRZzuNT0swXf7OVjVOo4JdfyX8I,3671
56
- photo_objects-0.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
57
- photo_objects-0.4.0.dist-info/top_level.txt,sha256=SZeL8mhf-WMGdhRtTGFvZc3aIRBboQls9O0cFDMGdQ0,14
58
- photo_objects-0.4.0.dist-info/RECORD,,
54
+ photo_objects-0.4.1.dist-info/licenses/LICENSE,sha256=V3w6hTjXfP65F4r_mejveHcV5igHrblxao3-2RlfVlA,1068
55
+ photo_objects-0.4.1.dist-info/METADATA,sha256=8m8l9pdPir6bJmip4Q9kBMXXTakFNMLQaLKRNwvoGao,3605
56
+ photo_objects-0.4.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
57
+ photo_objects-0.4.1.dist-info/top_level.txt,sha256=SZeL8mhf-WMGdhRtTGFvZc3aIRBboQls9O0cFDMGdQ0,14
58
+ photo_objects-0.4.1.dist-info/RECORD,,