photo-objects 0.3.1__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.
- photo_objects/config.py +6 -0
- photo_objects/django/__init__.py +0 -10
- photo_objects/django/api/album.py +2 -2
- photo_objects/django/api/auth.py +6 -6
- photo_objects/django/api/photo.py +4 -4
- photo_objects/django/api/utils.py +8 -12
- photo_objects/django/apps.py +14 -2
- photo_objects/django/conf.py +105 -0
- photo_objects/django/forms.py +2 -2
- photo_objects/django/management/commands/clean-scaled-photos.py +64 -0
- photo_objects/django/management/commands/create-initial-admin-account.py +6 -4
- photo_objects/django/management/commands/create-site-albums.py +1 -1
- photo_objects/django/objsto.py +62 -13
- photo_objects/django/signals.py +0 -2
- photo_objects/django/templatetags/photo_objects_extras.py +3 -2
- photo_objects/django/tests/test_album.py +7 -7
- photo_objects/django/tests/test_auth.py +3 -3
- photo_objects/django/tests/test_commands.py +80 -0
- photo_objects/django/tests/test_img.py +29 -0
- photo_objects/django/tests/test_photo.py +4 -4
- photo_objects/django/tests/test_utils.py +24 -3
- photo_objects/django/tests/utils.py +35 -1
- photo_objects/django/urls.py +1 -2
- photo_objects/django/views/api/auth.py +1 -1
- photo_objects/django/views/api/photo.py +7 -7
- photo_objects/django/views/ui/album.py +8 -5
- photo_objects/django/views/ui/configuration.py +1 -1
- photo_objects/django/views/ui/photo.py +8 -4
- photo_objects/django/views/ui/users.py +1 -1
- photo_objects/django/views/utils.py +48 -0
- photo_objects/img.py +44 -2
- photo_objects/utils.py +3 -0
- {photo_objects-0.3.1.dist-info → photo_objects-0.4.1.dist-info}/METADATA +2 -2
- photo_objects-0.4.1.dist-info/RECORD +58 -0
- photo_objects-0.3.1.dist-info/RECORD +0 -53
- {photo_objects-0.3.1.dist-info → photo_objects-0.4.1.dist-info}/WHEEL +0 -0
- {photo_objects-0.3.1.dist-info → photo_objects-0.4.1.dist-info}/licenses/LICENSE +0 -0
- {photo_objects-0.3.1.dist-info → photo_objects-0.4.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from io import StringIO
|
|
2
|
+
|
|
3
|
+
from django.contrib.auth import get_user_model
|
|
4
|
+
from django.core.management import call_command
|
|
5
|
+
|
|
6
|
+
from photo_objects.django.conf import CONFIGURABLE_PHOTO_SIZES
|
|
7
|
+
from photo_objects.django.models import Album
|
|
8
|
+
|
|
9
|
+
from .utils import TestCase, open_test_photo
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PhotoViewTests(TestCase):
|
|
13
|
+
def setUp(self):
|
|
14
|
+
user = get_user_model()
|
|
15
|
+
user.objects.create_user(
|
|
16
|
+
username='superuser',
|
|
17
|
+
password='test',
|
|
18
|
+
is_staff=True,
|
|
19
|
+
is_superuser=True)
|
|
20
|
+
|
|
21
|
+
Album.objects.create(
|
|
22
|
+
key="test-photo-sizes",
|
|
23
|
+
visibility=Album.Visibility.PUBLIC)
|
|
24
|
+
|
|
25
|
+
def _scale_image(self, album_key, photo_key):
|
|
26
|
+
for size in CONFIGURABLE_PHOTO_SIZES:
|
|
27
|
+
response = self.client.get(
|
|
28
|
+
f"/api/albums/{album_key}/photos/{photo_key}/img?size={size}")
|
|
29
|
+
self.assertStatus(response, 200)
|
|
30
|
+
|
|
31
|
+
def test_clean_scaled_photos(self):
|
|
32
|
+
login_success = self.client.login(
|
|
33
|
+
username='superuser', password='test')
|
|
34
|
+
self.assertTrue(login_success)
|
|
35
|
+
|
|
36
|
+
filename = "tower.jpg"
|
|
37
|
+
file = open_test_photo(filename)
|
|
38
|
+
response = self.client.post(
|
|
39
|
+
"/api/albums/test-photo-sizes/photos",
|
|
40
|
+
{filename: file})
|
|
41
|
+
self.assertStatus(response, 201)
|
|
42
|
+
|
|
43
|
+
self._scale_image("test-photo-sizes", "tower.jpg")
|
|
44
|
+
self.assertPhotoInObjsto(
|
|
45
|
+
"test-photo-sizes", "tower.jpg", ["sm", "md", "lg", "og"])
|
|
46
|
+
|
|
47
|
+
out = StringIO()
|
|
48
|
+
call_command('clean-scaled-photos', stdout=out)
|
|
49
|
+
output = out.getvalue()
|
|
50
|
+
self.assertIn("No previous photo sizes configuration found", output)
|
|
51
|
+
self.assertIn("Total deleted photos: 3", output)
|
|
52
|
+
self.assertPhotoNotInObjsto(
|
|
53
|
+
"test-photo-sizes",
|
|
54
|
+
"tower.jpg",
|
|
55
|
+
CONFIGURABLE_PHOTO_SIZES)
|
|
56
|
+
self.assertPhotoInObjsto("test-photo-sizes", "tower.jpg", "og")
|
|
57
|
+
|
|
58
|
+
self._scale_image("test-photo-sizes", "tower.jpg")
|
|
59
|
+
self.assertPhotoInObjsto(
|
|
60
|
+
"test-photo-sizes", "tower.jpg", ["sm", "md", "lg", "og"])
|
|
61
|
+
|
|
62
|
+
with self.settings(PHOTO_OBJECTS_PHOTO_SIZES=dict(
|
|
63
|
+
sm=dict(max_width=256, max_height=256),
|
|
64
|
+
)):
|
|
65
|
+
out = StringIO()
|
|
66
|
+
call_command('clean-scaled-photos', stdout=out)
|
|
67
|
+
output = out.getvalue()
|
|
68
|
+
self.assertIn(
|
|
69
|
+
"Found changes in photo sizes configuration for sm sizes.",
|
|
70
|
+
output)
|
|
71
|
+
self.assertIn("Total deleted photos: 1", output)
|
|
72
|
+
self.assertPhotoNotInObjsto("test-photo-sizes", "tower.jpg", "sm")
|
|
73
|
+
self.assertPhotoInObjsto(
|
|
74
|
+
"test-photo-sizes", "tower.jpg", ["md", "lg", "og"])
|
|
75
|
+
|
|
76
|
+
response = self.client.delete(
|
|
77
|
+
"/api/albums/test-photo-sizes/photos/tower.jpg")
|
|
78
|
+
self.assertStatus(response, 204)
|
|
79
|
+
self.assertPhotoNotInObjsto(
|
|
80
|
+
"test-photo-sizes", "tower.jpg", ["sm", "md", "lg", "og"])
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from io import BytesIO
|
|
2
|
+
|
|
3
|
+
from PIL import Image
|
|
4
|
+
|
|
5
|
+
from photo_objects.django.conf import DEFAULT_SM
|
|
6
|
+
from photo_objects.img import scale_photo
|
|
7
|
+
|
|
8
|
+
from .utils import TestCase
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ImgTests(TestCase):
|
|
12
|
+
def test_scale_photo(self):
|
|
13
|
+
testdata = [
|
|
14
|
+
((1000, 200), (300, 200)),
|
|
15
|
+
((600, 2000), (341, 512)),
|
|
16
|
+
((1000, 1000), (512, 512)),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
for size, expected in testdata:
|
|
20
|
+
with self.subTest(w=size[0], h=size[1]):
|
|
21
|
+
original = BytesIO()
|
|
22
|
+
image = Image.new("RGB", size, color="red")
|
|
23
|
+
image.save(original, format="JPEG")
|
|
24
|
+
original.seek(0)
|
|
25
|
+
|
|
26
|
+
scaled = scale_photo(original, "output.jpg", **DEFAULT_SM)
|
|
27
|
+
actual = Image.open(scaled).size
|
|
28
|
+
|
|
29
|
+
self.assertEqual(actual, expected)
|
|
@@ -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
|
-
|
|
22
|
-
|
|
21
|
+
user = get_user_model()
|
|
22
|
+
user.objects.create_user(username='no_permission', password='test')
|
|
23
23
|
|
|
24
|
-
has_permission =
|
|
24
|
+
has_permission = user.objects.create_user(
|
|
25
25
|
username='has_permission', password='test')
|
|
26
26
|
permissions = [
|
|
27
27
|
'add_photo',
|
|
@@ -201,7 +201,7 @@ class PhotoViewTests(TestCase):
|
|
|
201
201
|
"/api/albums/test-photo-a/photos/tower.jpg/img?size=sm")
|
|
202
202
|
self.assertStatus(small_response, 200)
|
|
203
203
|
_, height = Image.open(BytesIO(small_response.content)).size
|
|
204
|
-
self.assertEqual(height,
|
|
204
|
+
self.assertEqual(height, 512)
|
|
205
205
|
|
|
206
206
|
# Does not scale image up from the original size
|
|
207
207
|
large_response = self.client.get(
|
|
@@ -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
|
|
20
|
-
with self.subTest(input=
|
|
21
|
-
self.assertEqual(slugify(
|
|
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.
|
photo_objects/django/urls.py
CHANGED
|
@@ -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
|
|
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 = [
|
|
@@ -15,7 +15,7 @@ def has_permission(request: HttpRequest):
|
|
|
15
15
|
'''
|
|
16
16
|
path = request.GET.get('path')
|
|
17
17
|
try:
|
|
18
|
-
album_key, photo_key
|
|
18
|
+
raw_size, album_key, photo_key = path.lstrip('/').split('/')
|
|
19
19
|
except (AttributeError, ValueError):
|
|
20
20
|
return HttpResponse(status=403)
|
|
21
21
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from dataclasses import asdict
|
|
1
2
|
import mimetypes
|
|
2
3
|
|
|
3
4
|
from django.http import HttpRequest, HttpResponse, JsonResponse
|
|
@@ -5,7 +6,7 @@ from minio.error import S3Error
|
|
|
5
6
|
from urllib3.exceptions import HTTPError
|
|
6
7
|
|
|
7
8
|
from photo_objects import logger
|
|
8
|
-
from photo_objects.django import
|
|
9
|
+
from photo_objects.django.conf import PhotoSize, photo_sizes
|
|
9
10
|
from photo_objects.django import api
|
|
10
11
|
from photo_objects.django.api.utils import (
|
|
11
12
|
JsonProblem,
|
|
@@ -78,10 +79,10 @@ def get_img(request: HttpRequest, album_key: str, photo_key: str):
|
|
|
78
79
|
except S3Error:
|
|
79
80
|
try:
|
|
80
81
|
original_photo = objsto.get_photo(
|
|
81
|
-
album_key, photo_key,
|
|
82
|
+
album_key, photo_key, PhotoSize.ORIGINAL.value)
|
|
82
83
|
except (S3Error, HTTPError) as e:
|
|
83
84
|
msg = objsto.with_error_code(
|
|
84
|
-
|
|
85
|
+
"Could not fetch photo from object storage", e)
|
|
85
86
|
logger.error(f"{msg}: {str(e)}")
|
|
86
87
|
|
|
87
88
|
code = objsto.get_error_code(e)
|
|
@@ -90,11 +91,10 @@ def get_img(request: HttpRequest, album_key: str, photo_key: str):
|
|
|
90
91
|
404 if code == "NoSuchKey" else 500,
|
|
91
92
|
).json_response
|
|
92
93
|
|
|
93
|
-
|
|
94
|
-
sizes = dict(sm=(None, 256), md=(1024, 1024),
|
|
95
|
-
lg=(2048, 2048), xl=(4096, 4096))
|
|
94
|
+
sizes = photo_sizes()
|
|
96
95
|
# TODO: handle error
|
|
97
|
-
scaled_photo = scale_photo(
|
|
96
|
+
scaled_photo = scale_photo(
|
|
97
|
+
original_photo, photo_key, **asdict(getattr(sizes, size)))
|
|
98
98
|
|
|
99
99
|
# TODO: handle error
|
|
100
100
|
scaled_photo.seek(0)
|
|
@@ -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
|
|
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
|
|
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":
|
|
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
|
-
|
|
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
|
|
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(
|
|
44
|
-
return
|
|
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
|
|
135
|
+
"description": meta_description(request, photo),
|
|
132
136
|
"back": back,
|
|
133
137
|
"details": details,
|
|
134
138
|
})
|
|
@@ -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):
|
|
@@ -97,10 +97,52 @@ def photo_details(photo_file):
|
|
|
97
97
|
)
|
|
98
98
|
|
|
99
99
|
|
|
100
|
-
def
|
|
100
|
+
def _calculate_box(
|
|
101
|
+
width,
|
|
102
|
+
height,
|
|
103
|
+
max_aspect_ratio
|
|
104
|
+
) -> tuple[float, float, float, float]:
|
|
105
|
+
if max_aspect_ratio is None:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
min_aspect_ratio = 1.0 / max_aspect_ratio
|
|
109
|
+
|
|
110
|
+
aspect_ratio = width / height
|
|
111
|
+
|
|
112
|
+
if aspect_ratio > max_aspect_ratio:
|
|
113
|
+
new_width = height * max_aspect_ratio
|
|
114
|
+
crop_amount = width - new_width
|
|
115
|
+
left = round(crop_amount / 2)
|
|
116
|
+
right = round(width - crop_amount / 2)
|
|
117
|
+
return (left, 0, right, height)
|
|
118
|
+
|
|
119
|
+
if aspect_ratio < min_aspect_ratio:
|
|
120
|
+
new_height = width / min_aspect_ratio
|
|
121
|
+
crop_amount = height - new_height
|
|
122
|
+
top = round(crop_amount / 2)
|
|
123
|
+
bottom = round(height - crop_amount / 2)
|
|
124
|
+
return (0, top, width, bottom)
|
|
125
|
+
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def scale_photo(
|
|
130
|
+
photo_file,
|
|
131
|
+
filename,
|
|
132
|
+
max_width=None,
|
|
133
|
+
max_height=None,
|
|
134
|
+
max_aspect_ratio=None):
|
|
101
135
|
image = Image.open(photo_file)
|
|
102
136
|
width, height = image.size
|
|
103
137
|
|
|
138
|
+
# Crop image if aspect ratio is:
|
|
139
|
+
# - greater than max_aspect_ratio, or
|
|
140
|
+
# - less than 1/max_aspect_ratio
|
|
141
|
+
box = _calculate_box(width, height, max_aspect_ratio)
|
|
142
|
+
if box:
|
|
143
|
+
image = image.crop(box)
|
|
144
|
+
width, height = image.size
|
|
145
|
+
|
|
104
146
|
if max_width and max_height:
|
|
105
147
|
ratio = min(
|
|
106
148
|
max_width / width,
|
photo_objects/utils.py
ADDED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: photo-objects
|
|
3
|
-
Version: 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
|
|
77
|
+
pylint back/api photo_objects
|
|
78
78
|
```
|
|
79
79
|
|
|
80
80
|
### Integration tests
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
photo_objects/__init__.py,sha256=I1508w_ntomEqTFQgC74SurhxVXfCiDWZLRsny2f59g,60
|
|
2
|
+
photo_objects/config.py,sha256=0-Aeo-z-d_fxx-cjAjxSwPJZUgYaAi7NTodiErlxIXo,861
|
|
3
|
+
photo_objects/error.py,sha256=7afLYjxM0EaYioxVw_XUqHTvfSMSuQPUwwle0OVlaDY,45
|
|
4
|
+
photo_objects/img.py,sha256=8FNxKFBVuSyxO_jA3W3PMag3bZ7sEXYKG78rpgxq71Q,4515
|
|
5
|
+
photo_objects/utils.py,sha256=sYliXid-bv2EgNA97woRaOnWU75yZFq7fuqzWaseiDg,139
|
|
6
|
+
photo_objects/django/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
photo_objects/django/admin.py,sha256=sRnKTODk-8s6TAfwvsa2YAd7ECIfVmoOTR6PDASsvGg,122
|
|
8
|
+
photo_objects/django/apps.py,sha256=Apqu6o6fpoxda18NQgKupvQRvTAZxVviIK_-dUR3rck,1444
|
|
9
|
+
photo_objects/django/conf.py,sha256=s5lUbSLpjRWKlE2eYqAZmz60q4Oe10-e7JbIERPa6qI,2463
|
|
10
|
+
photo_objects/django/context_processors.py,sha256=DLhLZHAvGlrpxYegaLiKEx9X7rpyZUFiAnAVF9jjkFA,165
|
|
11
|
+
photo_objects/django/forms.py,sha256=XiCNqjVqPDn_aV4nGM1z5NfLKHd6_ipm7y0YRt3sXgY,6505
|
|
12
|
+
photo_objects/django/models.py,sha256=tRHBqswASWMnyz8rwiKsmHDqEvQX54ly9k8tvU7qNOM,4597
|
|
13
|
+
photo_objects/django/objsto.py,sha256=5z4F8pft01WKLBipyE3Ro9Ac9AvWbuV_rG9Iiour90E,3588
|
|
14
|
+
photo_objects/django/signals.py,sha256=u1Is0GuNPh5aS47ocbYXbXhXUtjmkMyiViBW14DuyrE,2389
|
|
15
|
+
photo_objects/django/urls.py,sha256=pCUTxg4xSd3ZD8BZVjULb9QpJQ03Ek6-sKoVnYG3-OY,1975
|
|
16
|
+
photo_objects/django/api/__init__.py,sha256=BnEHlm3mwyBy-1xhk-NFasgZa4fjCDjtfkBUoH0puPY,62
|
|
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
|
+
photo_objects/django/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
|
+
photo_objects/django/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
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
|
+
photo_objects/django/migrations/0001_initial.py,sha256=BLW-EZ38sBgDhOYyprc-h_vuPpRxA11qxt4ZuYNO1Wo,2424
|
|
27
|
+
photo_objects/django/migrations/0002_created_at_updated_at.py,sha256=7OT2VvDffAkX9XKBHVY-jvzxeIl2yU0Jr1ByCNGcUfw,1039
|
|
28
|
+
photo_objects/django/migrations/0003_admin_visibility.py,sha256=PdxPOJzr-ViRBlOYUHEEGhe0hLtDysZJdMqvbjKVpEg,529
|
|
29
|
+
photo_objects/django/migrations/0004_camera_setup_and_settings.py,sha256=CS5xyIHgBE2Y7-PSJ52ffRQeCzs8p899px9upomk4O8,1844
|
|
30
|
+
photo_objects/django/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
31
|
+
photo_objects/django/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
32
|
+
photo_objects/django/templatetags/photo_objects_extras.py,sha256=FIv_Q3Xr6AgLBxx4_6aXks3VAt1tK2N3d9rzpzRtx24,1400
|
|
33
|
+
photo_objects/django/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
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
|
+
photo_objects/django/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
42
|
+
photo_objects/django/views/utils.py,sha256=oP9B6QtHPaukX97rqIlCHxanduktPGFVSCGkGxf4lzI,1691
|
|
43
|
+
photo_objects/django/views/api/__init__.py,sha256=SxK-b7MgMtD9VRMz46rDV5qtm6AvkRcg3Moa1AWl5pY,108
|
|
44
|
+
photo_objects/django/views/api/album.py,sha256=EZMOkxYzLSWr9wwXnd4yAO64JtXZq2k3FYohiNMFbGQ,1602
|
|
45
|
+
photo_objects/django/views/api/auth.py,sha256=EN_ExegzmLN-bhSzu3L9-6UE9qodPd7_ZRLilzrvc8Y,819
|
|
46
|
+
photo_objects/django/views/api/photo.py,sha256=FQCqcnPwSaEJ9MVggQF09E4WD1wVFmLZnT_pFFpWGvA,3502
|
|
47
|
+
photo_objects/django/views/api/utils.py,sha256=uQzKdSKHRAux5OZzqgWQr0gsK_FeweQP0cg_67OWA_Y,264
|
|
48
|
+
photo_objects/django/views/ui/__init__.py,sha256=N3ro5KggdV-JnfyHwoStX73b3SbVbpcsMuQNlxntVJs,92
|
|
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
|
+
photo_objects/django/views/ui/utils.py,sha256=YV_YcUbX-zUkdFnBlezPChR6aPDhZJ9loSOHBSzF6Cc,273
|
|
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,,
|