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.
@@ -1,10 +0,0 @@
1
- from enum import Enum
2
-
3
-
4
- class Size(Enum):
5
- TINY = "xs"
6
- SMALL = "sm"
7
- MEDIUM = "md"
8
- LARGE = "lg"
9
- HUGE = "xl"
10
- ORIGINAL = "og"
@@ -1,6 +1,6 @@
1
1
  from django.http import HttpRequest
2
2
 
3
- from photo_objects.django import Size
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 = Size(size_key)
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 == Size.ORIGINAL:
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 Size
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 = _pretty_list(expected, "or")
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 = _pretty_list(expected, "or")
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 = _pretty_list([i.value for i in Size], "or")
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 {_pretty_list(permissions, 'and')} permissions",
137
+ f"Expected {pretty_list(permissions, 'and')} permissions",
142
138
  403,
143
139
  headers=dict(Allow="GET, POST")
144
140
  )
@@ -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
- conf = settings.PHOTO_OBJECTS_OBJSTO
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 conf.get(key):
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)
@@ -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 django.conf import settings
7
+ from minio import Minio, S3Error
6
8
 
7
- from minio import Minio
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 = settings.PHOTO_OBJECTS_OBJSTO
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"{album_key}/{photo_key}/{size_key}"
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 client.list_objects(
83
- bucket,
84
- prefix=photo_path(
85
- album_key,
86
- photo_key,
87
- ""),
88
- recursive=True):
89
- client.remove_object(bucket, i.object_name)
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
- has_permission = User.objects.create_user(
97
+ User.objects.create_user(
98
98
  username='superuser',
99
99
  password='test',
100
100
  is_staff=True,
@@ -6,7 +6,7 @@ from .utils import TestCase, create_dummy_photo
6
6
 
7
7
 
8
8
  def _path_fn(album, photo):
9
- return lambda size: f"{album}/{photo}/{size}"
9
+ return lambda size: f"{size}/{album}/{photo}"
10
10
 
11
11
 
12
12
  class AuthViewTests(TestCase):
@@ -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, 256)
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, raw_size = path.lstrip('/').split('/')
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 Size
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, Size.ORIGINAL.value)
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
- # TODO: make configurable
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(original_photo, photo_key, *sizes[size])
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 scale_photo(photo_file, filename, max_width=None, max_height=None):
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
@@ -0,0 +1,3 @@
1
+ def pretty_list(in_: list, conjunction: str):
2
+ return f' {conjunction} '.join(
3
+ i for i in (', '.join(in_[:-1]), in_[-1],) if i)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: photo-objects
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Application for storing photos in S3 compatible object-storage.
5
5
  Author: Toni Kangas
6
6
  License: MIT License
@@ -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=HzM3rCE494TCgaxUN9TwzwudJAFVu9TPj_ebEOyUXpk,3435
5
- photo_objects/django/__init__.py,sha256=be66-iYaaJljPXmRhnYKtZe9AA71Aa-S-s0cgx3NwyQ,146
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=Z5-cqA0h-axZfj51kbz50YI-B9ubQsX_EjF4fW3n3FM,1072
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=GZuoPZ1I5qX4rwEYZlOfZZiV4NQ19iknQBT6755421Y,2417
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=LfMm02_TKwrP5XFie-KzBnW3FNFWQAafBlu0UE19Gd4,1370
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=DgAsbvLLZxck_D6AXLsvUXJ3OguLufZ8sadmsToenJ4,5245
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=qeRx_M8EvY9n_as1x8-EWtp9Ob1kOPGnUfZuUnMfDO4,13979
32
- photo_objects/django/tests/test_auth.py,sha256=su00EypNzGQcvxqhmOtFevZriK31pY7yD1HxpQFcUT4,3928
33
- photo_objects/django/tests/test_photo.py,sha256=SpQsVynzJLP2F9qYrvZMzCBSiHvsRDpOZ8-W_07K_q8,13386
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=N53csbthH0DEMFjY3fqhtLmtdL7xqzkatktywjqZ8h0,819
41
- photo_objects/django/views/api/photo.py,sha256=52UMP1r6TN_xfu8fHE25yOUiTa6W1F2XbgEichM8tTE,3526
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.3.1.dist-info/licenses/LICENSE,sha256=V3w6hTjXfP65F4r_mejveHcV5igHrblxao3-2RlfVlA,1068
50
- photo_objects-0.3.1.dist-info/METADATA,sha256=rh4SAReBNJ2n0OytlvQnpTBnJ2lgiWvC7OMHjSqyO0k,3671
51
- photo_objects-0.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
52
- photo_objects-0.3.1.dist-info/top_level.txt,sha256=SZeL8mhf-WMGdhRtTGFvZc3aIRBboQls9O0cFDMGdQ0,14
53
- photo_objects-0.3.1.dist-info/RECORD,,
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,,