photo-objects 0.3.1__py3-none-any.whl → 0.4.0__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/django/__init__.py +0 -10
- photo_objects/django/api/auth.py +3 -3
- photo_objects/django/api/utils.py +6 -10
- photo_objects/django/apps.py +14 -2
- photo_objects/django/conf.py +105 -0
- photo_objects/django/management/commands/clean-scaled-photos.py +63 -0
- photo_objects/django/objsto.py +62 -13
- photo_objects/django/tests/test_album.py +1 -1
- photo_objects/django/tests/test_auth.py +1 -1
- photo_objects/django/tests/test_commands.py +112 -0
- photo_objects/django/tests/test_img.py +29 -0
- photo_objects/django/tests/test_photo.py +1 -1
- photo_objects/django/views/api/auth.py +1 -1
- photo_objects/django/views/api/photo.py +6 -6
- photo_objects/img.py +43 -1
- photo_objects/utils.py +3 -0
- {photo_objects-0.3.1.dist-info → photo_objects-0.4.0.dist-info}/METADATA +1 -1
- {photo_objects-0.3.1.dist-info → photo_objects-0.4.0.dist-info}/RECORD +21 -16
- {photo_objects-0.3.1.dist-info → photo_objects-0.4.0.dist-info}/WHEEL +0 -0
- {photo_objects-0.3.1.dist-info → photo_objects-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {photo_objects-0.3.1.dist-info → photo_objects-0.4.0.dist-info}/top_level.txt +0 -0
photo_objects/django/__init__.py
CHANGED
photo_objects/django/api/auth.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from django.http import HttpRequest
|
|
2
2
|
|
|
3
|
-
from photo_objects.django import
|
|
3
|
+
from photo_objects.django.conf import PhotoSize
|
|
4
4
|
from photo_objects.django.models import Album, Photo
|
|
5
5
|
|
|
6
6
|
from photo_objects.django.api.utils import (
|
|
@@ -35,7 +35,7 @@ def check_photo_access(
|
|
|
35
35
|
photo_key: str,
|
|
36
36
|
size_key: str):
|
|
37
37
|
try:
|
|
38
|
-
size =
|
|
38
|
+
size = PhotoSize(size_key)
|
|
39
39
|
except ValueError:
|
|
40
40
|
raise InvalidSize(size_key)
|
|
41
41
|
|
|
@@ -47,7 +47,7 @@ def check_photo_access(
|
|
|
47
47
|
if not request.user.is_authenticated:
|
|
48
48
|
if photo.album.visibility == Album.Visibility.PRIVATE:
|
|
49
49
|
raise AlbumNotFound(album_key)
|
|
50
|
-
if size ==
|
|
50
|
+
if size == PhotoSize.ORIGINAL:
|
|
51
51
|
raise Unauthorized()
|
|
52
52
|
|
|
53
53
|
return photo
|
|
@@ -7,8 +7,9 @@ from django.shortcuts import render
|
|
|
7
7
|
from django.urls import reverse_lazy
|
|
8
8
|
|
|
9
9
|
from photo_objects.error import PhotoObjectsError
|
|
10
|
+
from photo_objects.utils import pretty_list
|
|
10
11
|
from photo_objects.django.views.utils import BackLink
|
|
11
|
-
from photo_objects.django import
|
|
12
|
+
from photo_objects.django.conf import PhotoSize
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
APPLICATION_JSON = "application/json"
|
|
@@ -17,11 +18,6 @@ MULTIPART_FORMDATA = "multipart/form-data"
|
|
|
17
18
|
APPLICATION_PROBLEM = "application/problem+json"
|
|
18
19
|
|
|
19
20
|
|
|
20
|
-
def _pretty_list(in_: list, conjunction: str):
|
|
21
|
-
return f' {conjunction} '.join(
|
|
22
|
-
i for i in (', '.join(in_[:-1]), in_[-1],) if i)
|
|
23
|
-
|
|
24
|
-
|
|
25
21
|
class JsonProblem(PhotoObjectsError):
|
|
26
22
|
def __init__(self, title, status, payload=None, headers=None, errors=None):
|
|
27
23
|
super().__init__(title)
|
|
@@ -63,7 +59,7 @@ class JsonProblem(PhotoObjectsError):
|
|
|
63
59
|
|
|
64
60
|
class MethodNotAllowed(JsonProblem):
|
|
65
61
|
def __init__(self, expected: list[str], actual: str):
|
|
66
|
-
expected_human =
|
|
62
|
+
expected_human = pretty_list(expected, "or")
|
|
67
63
|
|
|
68
64
|
super().__init__(
|
|
69
65
|
f"Expected {expected_human} method, got {actual}.",
|
|
@@ -74,7 +70,7 @@ class MethodNotAllowed(JsonProblem):
|
|
|
74
70
|
|
|
75
71
|
class UnsupportedMediaType(JsonProblem):
|
|
76
72
|
def __init__(self, expected: list[str], actual: str):
|
|
77
|
-
expected_human =
|
|
73
|
+
expected_human = pretty_list(expected, "or")
|
|
78
74
|
|
|
79
75
|
super().__init__(
|
|
80
76
|
f"Expected {expected_human} content-type, got {actual}.",
|
|
@@ -93,7 +89,7 @@ class Unauthorized(JsonProblem):
|
|
|
93
89
|
|
|
94
90
|
class InvalidSize(JsonProblem):
|
|
95
91
|
def __init__(self, actual: str):
|
|
96
|
-
expected =
|
|
92
|
+
expected = pretty_list([i.value for i in PhotoSize], "or")
|
|
97
93
|
|
|
98
94
|
super().__init__(
|
|
99
95
|
f"Expected {expected} size, got {actual or 'none'}.",
|
|
@@ -138,7 +134,7 @@ def check_permissions(request: HttpRequest, *permissions: str):
|
|
|
138
134
|
raise Unauthorized()
|
|
139
135
|
if not request.user.has_perms(permissions):
|
|
140
136
|
raise JsonProblem(
|
|
141
|
-
f"Expected {
|
|
137
|
+
f"Expected {pretty_list(permissions, 'and')} permissions",
|
|
142
138
|
403,
|
|
143
139
|
headers=dict(Allow="GET, POST")
|
|
144
140
|
)
|
photo_objects/django/apps.py
CHANGED
|
@@ -2,6 +2,8 @@ from django.apps import AppConfig
|
|
|
2
2
|
from django.core.checks import Error, register
|
|
3
3
|
from django.conf import settings
|
|
4
4
|
|
|
5
|
+
from photo_objects.django.conf import validate_photo_sizes
|
|
6
|
+
|
|
5
7
|
|
|
6
8
|
class PhotoObjects(AppConfig):
|
|
7
9
|
default_auto_field = 'django.db.models.BigAutoField'
|
|
@@ -17,7 +19,7 @@ def photo_objects_check(app_configs, **kwargs):
|
|
|
17
19
|
errors = []
|
|
18
20
|
|
|
19
21
|
try:
|
|
20
|
-
|
|
22
|
+
objsto_conf = settings.PHOTO_OBJECTS_OBJSTO
|
|
21
23
|
except AttributeError:
|
|
22
24
|
errors.append(
|
|
23
25
|
Error(
|
|
@@ -29,7 +31,7 @@ def photo_objects_check(app_configs, **kwargs):
|
|
|
29
31
|
return errors
|
|
30
32
|
|
|
31
33
|
for key in ('URL', 'ACCESS_KEY', 'SECRET_KEY',):
|
|
32
|
-
if not
|
|
34
|
+
if not objsto_conf.get(key):
|
|
33
35
|
errors.append(
|
|
34
36
|
Error(
|
|
35
37
|
f'The PHOTO_OBJECTS_OBJSTO setting must define {key} '
|
|
@@ -38,4 +40,14 @@ def photo_objects_check(app_configs, **kwargs):
|
|
|
38
40
|
obj='photo_objects',
|
|
39
41
|
))
|
|
40
42
|
|
|
43
|
+
try:
|
|
44
|
+
sizes_conf = settings.PHOTO_OBJECTS_PHOTO_SIZES
|
|
45
|
+
errors.extend(
|
|
46
|
+
validate_photo_sizes(
|
|
47
|
+
sizes_conf,
|
|
48
|
+
'The PHOTO_OBJECTS_PHOTO_SIZES'))
|
|
49
|
+
except AttributeError:
|
|
50
|
+
# Use default values if sizes are not configured
|
|
51
|
+
pass
|
|
52
|
+
|
|
41
53
|
return errors
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
|
|
6
|
+
from photo_objects.utils import pretty_list
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def objsto_settings() -> dict:
|
|
10
|
+
return settings.PHOTO_OBJECTS_OBJSTO
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PhotoSize(Enum):
|
|
14
|
+
TINY = "xs"
|
|
15
|
+
SMALL = "sm"
|
|
16
|
+
MEDIUM = "md"
|
|
17
|
+
LARGE = "lg"
|
|
18
|
+
ORIGINAL = "og"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
CONFIGURABLE_PHOTO_SIZES = [
|
|
22
|
+
PhotoSize.SMALL.value,
|
|
23
|
+
PhotoSize.MEDIUM.value,
|
|
24
|
+
PhotoSize.LARGE.value
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass()
|
|
29
|
+
class PhotoSizeDimensions:
|
|
30
|
+
max_width: int = None
|
|
31
|
+
max_height: int = None
|
|
32
|
+
max_aspect_ratio: float = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
DEFAULT_SM = dict(
|
|
36
|
+
max_width=512,
|
|
37
|
+
max_height=512,
|
|
38
|
+
max_aspect_ratio=1.5
|
|
39
|
+
)
|
|
40
|
+
DEFAULT_MD = dict(
|
|
41
|
+
max_width=2048,
|
|
42
|
+
max_height=2048
|
|
43
|
+
)
|
|
44
|
+
DEFAULT_LG = dict(
|
|
45
|
+
max_width=4096,
|
|
46
|
+
max_height=4096
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class PhotoSizes:
|
|
52
|
+
sm: PhotoSizeDimensions = None
|
|
53
|
+
md: PhotoSizeDimensions = None
|
|
54
|
+
lg: PhotoSizeDimensions = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_photo_sizes(data: dict, prefix=None) -> list[str]:
|
|
58
|
+
prefix = prefix or "Photo size"
|
|
59
|
+
errors = []
|
|
60
|
+
|
|
61
|
+
for key, value in data.items():
|
|
62
|
+
if key not in CONFIGURABLE_PHOTO_SIZES:
|
|
63
|
+
expected = pretty_list(CONFIGURABLE_PHOTO_SIZES, 'or')
|
|
64
|
+
errors.append(
|
|
65
|
+
f"{prefix} key '{key}' is invalid, expected one of {expected}")
|
|
66
|
+
|
|
67
|
+
if not isinstance(value, dict):
|
|
68
|
+
errors.append(f"{prefix} '{key}' must be a dict.")
|
|
69
|
+
|
|
70
|
+
if 'max_width' not in value and 'max_height' not in value:
|
|
71
|
+
errors.append(
|
|
72
|
+
f"{prefix} '{key}' must define at least one dimension.")
|
|
73
|
+
|
|
74
|
+
if (
|
|
75
|
+
'max_aspect_ratio' in value and
|
|
76
|
+
value['max_aspect_ratio'] is not None and
|
|
77
|
+
not isinstance(value['max_aspect_ratio'], (float, int))
|
|
78
|
+
):
|
|
79
|
+
errors.append(
|
|
80
|
+
f"{prefix} '{key}' max_aspect_ratio must be a number.")
|
|
81
|
+
|
|
82
|
+
return errors
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_photo_sizes(data: dict) -> PhotoSizes:
|
|
86
|
+
errors = validate_photo_sizes(data)
|
|
87
|
+
|
|
88
|
+
if errors:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"Invalid photo sizes configuration: {' '.join(errors)}")
|
|
91
|
+
|
|
92
|
+
return PhotoSizes(
|
|
93
|
+
sm=PhotoSizeDimensions(**data.get('sm', DEFAULT_SM)),
|
|
94
|
+
md=PhotoSizeDimensions(**data.get('md', DEFAULT_MD)),
|
|
95
|
+
lg=PhotoSizeDimensions(**data.get('lg', DEFAULT_LG)),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def photo_sizes() -> PhotoSizes:
|
|
100
|
+
try:
|
|
101
|
+
data = settings.PHOTO_OBJECTS_PHOTO_SIZES
|
|
102
|
+
except AttributeError:
|
|
103
|
+
data = {}
|
|
104
|
+
|
|
105
|
+
return parse_photo_sizes(data)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from django.core.management.base import BaseCommand
|
|
2
|
+
|
|
3
|
+
from photo_objects.utils import pretty_list
|
|
4
|
+
from photo_objects.django.conf import photo_sizes, CONFIGURABLE_PHOTO_SIZES
|
|
5
|
+
from photo_objects.django.objsto import (
|
|
6
|
+
delete_scaled_photos,
|
|
7
|
+
get_photo_sizes,
|
|
8
|
+
put_photo_sizes,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Command(BaseCommand):
|
|
13
|
+
help = "Remove scaled photos when scaling settings have changed."
|
|
14
|
+
|
|
15
|
+
def handle(self, *args, **options):
|
|
16
|
+
current = photo_sizes()
|
|
17
|
+
previous = get_photo_sizes()
|
|
18
|
+
|
|
19
|
+
if current == previous:
|
|
20
|
+
self.stdout.write(
|
|
21
|
+
self.style.SUCCESS(
|
|
22
|
+
"No changes in photo sizes configuration."
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
if previous is None:
|
|
28
|
+
self.stdout.write(
|
|
29
|
+
self.style.WARNING(
|
|
30
|
+
"No previous photo sizes configuration found. "
|
|
31
|
+
"Removing all scaled photos:"
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
to_delete = CONFIGURABLE_PHOTO_SIZES
|
|
35
|
+
else:
|
|
36
|
+
to_delete = []
|
|
37
|
+
for size in CONFIGURABLE_PHOTO_SIZES:
|
|
38
|
+
if getattr(previous, size) != getattr(current, size):
|
|
39
|
+
to_delete.append(size)
|
|
40
|
+
|
|
41
|
+
changed = pretty_list(to_delete, 'and')
|
|
42
|
+
self.stdout.write(
|
|
43
|
+
self.style.NOTICE(
|
|
44
|
+
"Found changes in photo sizes configuration for "
|
|
45
|
+
f"{changed} sizes. Deleting scaled photos:"
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
total = 0
|
|
51
|
+
for key in delete_scaled_photos(to_delete):
|
|
52
|
+
self.stdout.write(f" {key}")
|
|
53
|
+
total += 1
|
|
54
|
+
self.stdout.write(f"Total deleted photos: {total}")
|
|
55
|
+
except Exception as e:
|
|
56
|
+
self.stdout.write(
|
|
57
|
+
self.style.ERROR(
|
|
58
|
+
f"Error occurred while deleting scaled photos: {e}"
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
exit(1)
|
|
62
|
+
|
|
63
|
+
put_photo_sizes(current)
|
photo_objects/django/objsto.py
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
+
from dataclasses import asdict
|
|
2
|
+
from io import BytesIO
|
|
1
3
|
import json
|
|
2
4
|
import mimetypes
|
|
3
5
|
import urllib3
|
|
4
6
|
|
|
5
|
-
from
|
|
7
|
+
from minio import Minio, S3Error
|
|
6
8
|
|
|
7
|
-
from
|
|
9
|
+
from photo_objects.django.conf import (
|
|
10
|
+
PhotoSize,
|
|
11
|
+
PhotoSizes,
|
|
12
|
+
objsto_settings,
|
|
13
|
+
parse_photo_sizes,
|
|
14
|
+
)
|
|
8
15
|
|
|
9
16
|
|
|
10
17
|
MEGABYTE = 1 << 20
|
|
@@ -26,8 +33,7 @@ def _anonymous_readonly_policy(bucket: str):
|
|
|
26
33
|
|
|
27
34
|
|
|
28
35
|
def _objsto_access() -> tuple[Minio, str]:
|
|
29
|
-
conf =
|
|
30
|
-
|
|
36
|
+
conf = objsto_settings()
|
|
31
37
|
http = urllib3.PoolManager(
|
|
32
38
|
retries=urllib3.util.Retry(connect=1),
|
|
33
39
|
timeout=urllib3.util.Timeout(connect=2.5, read=20),
|
|
@@ -51,7 +57,7 @@ def _objsto_access() -> tuple[Minio, str]:
|
|
|
51
57
|
|
|
52
58
|
|
|
53
59
|
def photo_path(album_key, photo_key, size_key):
|
|
54
|
-
return f"{
|
|
60
|
+
return f"{size_key}/{album_key}/{photo_key}"
|
|
55
61
|
|
|
56
62
|
|
|
57
63
|
def put_photo(album_key, photo_key, size_key, photo_file):
|
|
@@ -79,14 +85,31 @@ def get_photo(album_key, photo_key, size_key):
|
|
|
79
85
|
def delete_photo(album_key, photo_key):
|
|
80
86
|
client, bucket = _objsto_access()
|
|
81
87
|
|
|
82
|
-
for i in
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
for i in PhotoSize:
|
|
89
|
+
client.remove_object(bucket, photo_path(album_key, photo_key, i.value))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def delete_scaled_photos(sizes):
|
|
93
|
+
client, bucket = _objsto_access()
|
|
94
|
+
|
|
95
|
+
for size in sizes:
|
|
96
|
+
while True:
|
|
97
|
+
objects = client.list_objects(
|
|
98
|
+
bucket,
|
|
99
|
+
prefix=f"{size}/",
|
|
100
|
+
recursive=True)
|
|
101
|
+
|
|
102
|
+
if not objects:
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
empty = True
|
|
106
|
+
for i in objects:
|
|
107
|
+
empty = False
|
|
108
|
+
client.remove_object(bucket, i.object_name)
|
|
109
|
+
yield i.object_name
|
|
110
|
+
|
|
111
|
+
if empty:
|
|
112
|
+
break
|
|
90
113
|
|
|
91
114
|
|
|
92
115
|
def get_error_code(e: Exception) -> str:
|
|
@@ -101,3 +124,29 @@ def with_error_code(msg: str, e: Exception) -> str:
|
|
|
101
124
|
if code:
|
|
102
125
|
return f'{msg} ({code})'
|
|
103
126
|
return msg
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def put_photo_sizes(sizes: PhotoSizes):
|
|
130
|
+
data = json.dumps(asdict(sizes))
|
|
131
|
+
stream = BytesIO(data.encode('utf-8'))
|
|
132
|
+
|
|
133
|
+
client, bucket = _objsto_access()
|
|
134
|
+
client.put_object(
|
|
135
|
+
bucket,
|
|
136
|
+
"photo_sizes.json",
|
|
137
|
+
stream,
|
|
138
|
+
length=-1,
|
|
139
|
+
part_size=10 * MEGABYTE,
|
|
140
|
+
content_type="application/json",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_photo_sizes() -> PhotoSizes:
|
|
145
|
+
client, bucket = _objsto_access()
|
|
146
|
+
try:
|
|
147
|
+
data = client.get_object(bucket, "photo_sizes.json")
|
|
148
|
+
return parse_photo_sizes(json.loads(data.read()))
|
|
149
|
+
except S3Error as e:
|
|
150
|
+
if e.code == "NoSuchKey":
|
|
151
|
+
return None
|
|
152
|
+
raise
|
|
@@ -94,7 +94,7 @@ class AlbumViewTests(TestCase):
|
|
|
94
94
|
User = get_user_model()
|
|
95
95
|
User.objects.create_user(username='no_permission', password='test')
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
User.objects.create_user(
|
|
98
98
|
username='superuser',
|
|
99
99
|
password='test',
|
|
100
100
|
is_staff=True,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from io import StringIO
|
|
2
|
+
|
|
3
|
+
from django.contrib.auth import get_user_model
|
|
4
|
+
from django.core.management import call_command
|
|
5
|
+
from minio import S3Error
|
|
6
|
+
|
|
7
|
+
from photo_objects.django import objsto
|
|
8
|
+
from photo_objects.django.conf import CONFIGURABLE_PHOTO_SIZES
|
|
9
|
+
from photo_objects.django.models import Album
|
|
10
|
+
|
|
11
|
+
from .utils import TestCase, open_test_photo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PhotoViewTests(TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
User = get_user_model()
|
|
17
|
+
User.objects.create_user(
|
|
18
|
+
username='superuser',
|
|
19
|
+
password='test',
|
|
20
|
+
is_staff=True,
|
|
21
|
+
is_superuser=True)
|
|
22
|
+
|
|
23
|
+
Album.objects.create(
|
|
24
|
+
key="test-photo-sizes",
|
|
25
|
+
visibility=Album.Visibility.PUBLIC)
|
|
26
|
+
|
|
27
|
+
def _scale_image(self, album_key, photo_key):
|
|
28
|
+
for size in CONFIGURABLE_PHOTO_SIZES:
|
|
29
|
+
response = self.client.get(
|
|
30
|
+
f"/api/albums/{album_key}/photos/{photo_key}/img?size={size}")
|
|
31
|
+
self.assertStatus(response, 200)
|
|
32
|
+
|
|
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
|
+
def test_clean_scaled_photos(self):
|
|
64
|
+
login_success = self.client.login(
|
|
65
|
+
username='superuser', password='test')
|
|
66
|
+
self.assertTrue(login_success)
|
|
67
|
+
|
|
68
|
+
filename = "tower.jpg"
|
|
69
|
+
file = open_test_photo(filename)
|
|
70
|
+
response = self.client.post(
|
|
71
|
+
"/api/albums/test-photo-sizes/photos",
|
|
72
|
+
{filename: file})
|
|
73
|
+
self.assertStatus(response, 201)
|
|
74
|
+
|
|
75
|
+
self._scale_image("test-photo-sizes", "tower.jpg")
|
|
76
|
+
self.assertPhotoFound("test-photo-sizes",
|
|
77
|
+
"tower.jpg", ["sm", "md", "lg", "og"])
|
|
78
|
+
|
|
79
|
+
out = StringIO()
|
|
80
|
+
call_command('clean-scaled-photos', stdout=out)
|
|
81
|
+
output = out.getvalue()
|
|
82
|
+
self.assertIn("No previous photo sizes configuration found", output)
|
|
83
|
+
self.assertIn("Total deleted photos: 3", output)
|
|
84
|
+
self.assertPhotoNotFound(
|
|
85
|
+
"test-photo-sizes",
|
|
86
|
+
"tower.jpg",
|
|
87
|
+
CONFIGURABLE_PHOTO_SIZES)
|
|
88
|
+
self.assertPhotoFound("test-photo-sizes", "tower.jpg", "og")
|
|
89
|
+
|
|
90
|
+
self._scale_image("test-photo-sizes", "tower.jpg")
|
|
91
|
+
self.assertPhotoFound("test-photo-sizes",
|
|
92
|
+
"tower.jpg", ["sm", "md", "lg", "og"])
|
|
93
|
+
|
|
94
|
+
with self.settings(PHOTO_OBJECTS_PHOTO_SIZES=dict(
|
|
95
|
+
sm=dict(max_width=256, max_height=256),
|
|
96
|
+
)):
|
|
97
|
+
out = StringIO()
|
|
98
|
+
call_command('clean-scaled-photos', stdout=out)
|
|
99
|
+
output = out.getvalue()
|
|
100
|
+
self.assertIn(
|
|
101
|
+
"Found changes in photo sizes configuration for sm sizes.",
|
|
102
|
+
output)
|
|
103
|
+
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"])
|
|
107
|
+
|
|
108
|
+
response = self.client.delete(
|
|
109
|
+
"/api/albums/test-photo-sizes/photos/tower.jpg")
|
|
110
|
+
self.assertStatus(response, 204)
|
|
111
|
+
self.assertPhotoNotFound(
|
|
112
|
+
"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 input, expected in testdata:
|
|
20
|
+
with self.subTest(w=input[0], h=input[1]):
|
|
21
|
+
original = BytesIO()
|
|
22
|
+
image = Image.new("RGB", input, 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)
|
|
@@ -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(
|
|
@@ -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,7 +79,7 @@ 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
|
f"Could not fetch photo from object storage", 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)
|
photo_objects/img.py
CHANGED
|
@@ -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,23 +1,26 @@
|
|
|
1
1
|
photo_objects/__init__.py,sha256=I1508w_ntomEqTFQgC74SurhxVXfCiDWZLRsny2f59g,60
|
|
2
2
|
photo_objects/config.py,sha256=e9uSuxytkEUR_s0z_WLy5yNWN6fSCdDLq8aw2k5250w,743
|
|
3
3
|
photo_objects/error.py,sha256=7afLYjxM0EaYioxVw_XUqHTvfSMSuQPUwwle0OVlaDY,45
|
|
4
|
-
photo_objects/img.py,sha256=
|
|
5
|
-
photo_objects/
|
|
4
|
+
photo_objects/img.py,sha256=YzR5WnImupOUzSUukXrmviOygFR-z-7aOziEiJ_3zbs,4517
|
|
5
|
+
photo_objects/utils.py,sha256=sYliXid-bv2EgNA97woRaOnWU75yZFq7fuqzWaseiDg,139
|
|
6
|
+
photo_objects/django/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
7
|
photo_objects/django/admin.py,sha256=sRnKTODk-8s6TAfwvsa2YAd7ECIfVmoOTR6PDASsvGg,122
|
|
7
|
-
photo_objects/django/apps.py,sha256=
|
|
8
|
+
photo_objects/django/apps.py,sha256=Apqu6o6fpoxda18NQgKupvQRvTAZxVviIK_-dUR3rck,1444
|
|
9
|
+
photo_objects/django/conf.py,sha256=s5lUbSLpjRWKlE2eYqAZmz60q4Oe10-e7JbIERPa6qI,2463
|
|
8
10
|
photo_objects/django/context_processors.py,sha256=DLhLZHAvGlrpxYegaLiKEx9X7rpyZUFiAnAVF9jjkFA,165
|
|
9
11
|
photo_objects/django/forms.py,sha256=VoDlyZAwiTLyNxW3rRk5bzjfPJvNuKV-wpuLGCe5TCY,6505
|
|
10
12
|
photo_objects/django/models.py,sha256=tRHBqswASWMnyz8rwiKsmHDqEvQX54ly9k8tvU7qNOM,4597
|
|
11
|
-
photo_objects/django/objsto.py,sha256=
|
|
13
|
+
photo_objects/django/objsto.py,sha256=5z4F8pft01WKLBipyE3Ro9Ac9AvWbuV_rG9Iiour90E,3588
|
|
12
14
|
photo_objects/django/signals.py,sha256=NEh_pfvsSt2f7MuHIJE0uLHUkqjKH4ipAFS7tmvm9LI,2400
|
|
13
15
|
photo_objects/django/urls.py,sha256=ZBzTTYSLkeyuGM_NdSDzz_Yt4xs6hwIFhz04j59Q1r8,2023
|
|
14
16
|
photo_objects/django/api/__init__.py,sha256=BnEHlm3mwyBy-1xhk-NFasgZa4fjCDjtfkBUoH0puPY,62
|
|
15
17
|
photo_objects/django/api/album.py,sha256=c5qDvy6ebIZCzggxcYgSgLBR0cco79V47nJf2OHuBj8,2486
|
|
16
|
-
photo_objects/django/api/auth.py,sha256=
|
|
18
|
+
photo_objects/django/api/auth.py,sha256=Iduv5grfcgvKiQ2wEFOQYgntiug02LGwL_LwC9-qcss,1390
|
|
17
19
|
photo_objects/django/api/photo.py,sha256=NGCg_Qd4X9NAd7t6lqByK9JGsoTq8HKkyEr-HwMCimI,3838
|
|
18
|
-
photo_objects/django/api/utils.py,sha256=
|
|
20
|
+
photo_objects/django/api/utils.py,sha256=MB15scKVrK7BMR1df0tc-_dQGI5WDE7iyxnRWl1znPw,5158
|
|
19
21
|
photo_objects/django/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
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
|
|
21
24
|
photo_objects/django/management/commands/create-initial-admin-account.py,sha256=M4qv1d_qFSGy_lDcKbyKOmfGsQt8i16Zoit9cJ6PwAU,1099
|
|
22
25
|
photo_objects/django/management/commands/create-site-albums.py,sha256=mz99RfmczM0x7zuVRabReq4uBZXN-2Ow7VoNWARvBig,959
|
|
23
26
|
photo_objects/django/migrations/0001_initial.py,sha256=BLW-EZ38sBgDhOYyprc-h_vuPpRxA11qxt4ZuYNO1Wo,2424
|
|
@@ -28,17 +31,19 @@ photo_objects/django/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm
|
|
|
28
31
|
photo_objects/django/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
29
32
|
photo_objects/django/templatetags/photo_objects_extras.py,sha256=D-xmbDPpvZYSdYyeCExnmLXW7BikOkrQhZab7j0Sjns,1311
|
|
30
33
|
photo_objects/django/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
31
|
-
photo_objects/django/tests/test_album.py,sha256=
|
|
32
|
-
photo_objects/django/tests/test_auth.py,sha256=
|
|
33
|
-
photo_objects/django/tests/
|
|
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
|
|
34
39
|
photo_objects/django/tests/test_utils.py,sha256=DkhzDtZqu5OXg-IKzqHz2GY0sFS8RbwYC3w81RuPxS4,1259
|
|
35
40
|
photo_objects/django/tests/utils.py,sha256=lcs1yL-oqLGFkV-NZNf_lOOuiZZZaSQNi7iznHm7BHY,2492
|
|
36
41
|
photo_objects/django/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
37
42
|
photo_objects/django/views/utils.py,sha256=369CUBWwm6LGh7wX1QVqIPE_K_6nb7ePEgHWvV2sKOw,245
|
|
38
43
|
photo_objects/django/views/api/__init__.py,sha256=SxK-b7MgMtD9VRMz46rDV5qtm6AvkRcg3Moa1AWl5pY,108
|
|
39
44
|
photo_objects/django/views/api/album.py,sha256=EZMOkxYzLSWr9wwXnd4yAO64JtXZq2k3FYohiNMFbGQ,1602
|
|
40
|
-
photo_objects/django/views/api/auth.py,sha256=
|
|
41
|
-
photo_objects/django/views/api/photo.py,sha256=
|
|
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
|
|
42
47
|
photo_objects/django/views/api/utils.py,sha256=uQzKdSKHRAux5OZzqgWQr0gsK_FeweQP0cg_67OWA_Y,264
|
|
43
48
|
photo_objects/django/views/ui/__init__.py,sha256=N3ro5KggdV-JnfyHwoStX73b3SbVbpcsMuQNlxntVJs,92
|
|
44
49
|
photo_objects/django/views/ui/album.py,sha256=gOVwkpNZFytTrEU0N6OfJu4U4l85AIbL6b0L6x0eA4E,5232
|
|
@@ -46,8 +51,8 @@ photo_objects/django/views/ui/configuration.py,sha256=pzNRHq3FSrVCTx2R31_FW3A3aJ
|
|
|
46
51
|
photo_objects/django/views/ui/photo.py,sha256=ioECWNMZGLgnEAZBpvYjEVDYZMAyvCwXgl6y3L8jKsE,6249
|
|
47
52
|
photo_objects/django/views/ui/users.py,sha256=nlJyW7rhmr-ZR4LeSHMRPVmJzpiyHCEB2ry6uH2ihOc,713
|
|
48
53
|
photo_objects/django/views/ui/utils.py,sha256=YV_YcUbX-zUkdFnBlezPChR6aPDhZJ9loSOHBSzF6Cc,273
|
|
49
|
-
photo_objects-0.
|
|
50
|
-
photo_objects-0.
|
|
51
|
-
photo_objects-0.
|
|
52
|
-
photo_objects-0.
|
|
53
|
-
photo_objects-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|