Skip to content

Commit 2e59957

Browse files
authored
Merge pull request #181 from PROCOLLAB-github/dev
Конвертация фото в webp, сброс пароля
2 parents c8e1bd6 + d9c4333 commit 2e59957

15 files changed

Lines changed: 773 additions & 192 deletions

files/admin.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from django.contrib import admin
44
from django.forms import ModelForm, FileField
55

6-
from files.helpers import FileAPI
6+
from files.service import CDN, SelectelSwiftStorage
77
from files.models import UserFile
88

99

@@ -40,6 +40,9 @@ class UserFileAdmin(admin.ModelAdmin):
4040
)
4141

4242
date_hierarchy = "datetime_uploaded"
43+
ordering = ("-datetime_uploaded",)
44+
45+
cdn = CDN(storage=SelectelSwiftStorage())
4346

4447
@admin.display(empty_value="Empty filename")
4548
def filename(self, obj):
@@ -61,9 +64,8 @@ def get_fieldsets(self, request, obj=None):
6164
return fieldsets
6265

6366
def save_model(self, request, obj, form, change):
64-
file_api = FileAPI(request.FILES["file"], request.user)
65-
url, info = file_api.upload()
66-
obj.link = url
67+
info = self.cdn.upload(request.FILES["file"], request.user)
68+
obj.link = info.url
6769
obj.user = request.user
6870
obj.name = info.name
6971
obj.size = info.size
@@ -72,5 +74,10 @@ def save_model(self, request, obj, form, change):
7274
super().save_model(request, obj, form, change)
7375

7476
def delete_model(self, request, obj):
75-
FileAPI.delete(obj.link)
77+
self.cdn.delete(obj.link)
7678
obj.delete()
79+
80+
def delete_queryset(self, request, queryset):
81+
for obj in queryset:
82+
self.cdn.delete(obj.link)
83+
queryset.delete()

files/constants.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import mimetypes
2+
3+
SUPPORTED_IMAGES_TYPES = (
4+
mimetypes.types_map[".jpg"],
5+
mimetypes.types_map[".png"],
6+
)

files/helpers.py

Lines changed: 7 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,115 +1,9 @@
1-
from typing import Union
1+
import webp
2+
from PIL import Image
23

3-
import requests
4-
import time
5-
import magic
6-
from django.core.files.uploadedfile import TemporaryUploadedFile, InMemoryUploadedFile
74

8-
from files.exceptions import SelectelUploadError
9-
from files.typings import UserFileInfo
10-
11-
from procollab.settings import (
12-
DEBUG,
13-
SELECTEL_ACCOUNT_ID,
14-
SELECTEL_CONTAINER_NAME,
15-
SELECTEL_CONTAINER_PASSWORD,
16-
SELECTEL_CONTAINER_USERNAME,
17-
)
18-
19-
20-
class FileAPI:
21-
# fixme: looks terrible
22-
def __init__(
23-
self, file: Union[TemporaryUploadedFile, InMemoryUploadedFile], user
24-
) -> None:
25-
self.file = file # it's TemporaryUploadedFile, and it will be
26-
# removed after first .close() call, so we must read this file only once
27-
self.user = user
28-
self.file_object = self.file.open(mode="rb")
29-
30-
@staticmethod
31-
def delete(url: str) -> int:
32-
"""Deletes file from selcdn"""
33-
token = FileAPI._get_selectel_swift_token()
34-
response = requests.delete(url, headers={"X-Auth-Token": token})
35-
return response.status_code
36-
37-
def upload(self) -> tuple[str, UserFileInfo]:
38-
url = self._upload_via_selectel_swift()
39-
info = self.get_file_info(self.file)
40-
self.file_object.close()
41-
return url, info
42-
43-
def get_file_info(
44-
self, file: Union[TemporaryUploadedFile, InMemoryUploadedFile]
45-
) -> UserFileInfo:
46-
name, ext = file.name.split(".")
47-
return UserFileInfo(
48-
size=file.size, name=name, extension=ext, mime_type=self.get_file_mime_type()
49-
)
50-
51-
def get_file_mime_type(self):
52-
if isinstance(self.file, InMemoryUploadedFile):
53-
return magic.from_buffer(self.file_object.read(), mime=True)
54-
else:
55-
return magic.from_file(self.file.temporary_file_path(), mime=True)
56-
57-
def _upload_via_selectel_swift(self) -> str:
58-
token = self._get_selectel_swift_token()
59-
url = self._generate_selectel_swift_file_url()
60-
61-
requests.put(
62-
url,
63-
headers={
64-
"X-Auth-Token": token,
65-
"Content-Type": self.file_object.content_type,
66-
},
67-
data=self.file_object.read(),
68-
)
69-
70-
return url
71-
72-
def _generate_selectel_swift_link(sefl):
73-
link = f"https://api.selcdn.ru/v1/SEL_{SELECTEL_ACCOUNT_ID}/{SELECTEL_CONTAINER_NAME}/"
74-
if DEBUG:
75-
link += "debug/"
76-
return link
77-
78-
@staticmethod
79-
def _get_selectel_swift_token():
80-
"""Returns auth token for selcdn"""
81-
data = {
82-
"auth": {
83-
"identity": {
84-
"methods": ["password"],
85-
"password": {
86-
"user": {
87-
"id": SELECTEL_CONTAINER_USERNAME,
88-
"password": SELECTEL_CONTAINER_PASSWORD,
89-
}
90-
},
91-
}
92-
}
93-
}
94-
response = requests.post("https://api.selcdn.ru/v3/auth/tokens", json=data)
95-
if response.status_code not in [200, 201]:
96-
raise SelectelUploadError("Couldn't generate a token for selcdn")
97-
return response.headers["x-subject-token"]
98-
99-
def _get_file_extension(self) -> str:
100-
if len(self.file.name.split(".")) > 1:
101-
return "." + self.file.name.split(".")[1]
102-
return ""
103-
104-
def _generate_selectel_swift_file_url(self) -> str:
105-
"""
106-
Generates url for selcdn
107-
Returns:
108-
url: str looks like /hashedEmail/hashedFilename_hashedTime.extension
109-
"""
110-
link = self._generate_selectel_swift_link()
111-
extension = self._get_file_extension()
112-
return (
113-
link
114-
+ f"{abs(hash(self.user.email))}/{abs(hash(self.file.name))}_{abs(hash(time.time()))}{extension}"
115-
)
5+
def convert_image_to_webp(image, quality: int = 70):
6+
config = webp.WebPConfig.new(preset=webp.WebPPreset.PHOTO, quality=quality)
7+
pil_image = Image.open(image.file)
8+
webp_image = webp.WebPPicture.from_pil(pil_image)
9+
return webp_image.encode(config)

files/models.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import reprlib
2+
13
from django.contrib.auth import get_user_model
24
from django.db import models
35

@@ -26,7 +28,8 @@ class UserFile(models.Model):
2628
size = models.PositiveBigIntegerField(null=False, blank=True, default=1)
2729

2830
def __str__(self):
29-
return f"UserFile by {self.user}, {self.link}"
31+
filename_with_extension = f"{self.name}.{self.extension}"
32+
return f"UserFile<{reprlib.repr(filename_with_extension)}>"
3033

3134
class Meta:
3235
verbose_name = "Файл"

files/service.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import time
2+
from abc import ABC, abstractmethod
3+
4+
import requests
5+
from django.conf import settings
6+
from django.contrib.auth import get_user_model
7+
from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile
8+
from requests import Response
9+
10+
from files.constants import SUPPORTED_IMAGES_TYPES
11+
from files.exceptions import SelectelUploadError
12+
from files.helpers import convert_image_to_webp
13+
from files.typings import FileInfo
14+
from procollab.settings import SELECTEL_SWIFT_URL
15+
16+
User = get_user_model()
17+
18+
19+
class File:
20+
def __init__(self, file: TemporaryUploadedFile | InMemoryUploadedFile):
21+
self.size = file.size
22+
self.name = File._get_name(file)
23+
self.extension = File._get_extension(file)
24+
self.buffer = file.open(mode="rb")
25+
self.content_type = file.content_type
26+
27+
# we can compress given type of image
28+
if self.content_type in SUPPORTED_IMAGES_TYPES:
29+
webp_image = convert_image_to_webp(file)
30+
self.buffer = webp_image.buffer()
31+
self.size = webp_image.size
32+
self.content_type = "image/webp"
33+
self.extension = "webp"
34+
35+
@staticmethod
36+
def _get_name(file) -> str:
37+
name_parts = file.name.split(".")
38+
if len(name_parts) == 1:
39+
return name_parts[0]
40+
return ".".join(name_parts[:-1])
41+
42+
@staticmethod
43+
def _get_extension(file) -> str:
44+
if len(file.name.split(".")) > 1:
45+
return file.name.split(".")[-1]
46+
return ""
47+
48+
49+
class Storage(ABC):
50+
@abstractmethod
51+
def delete(self, url: str) -> Response:
52+
pass
53+
54+
@abstractmethod
55+
def upload(self, file: File, user: User) -> FileInfo:
56+
pass
57+
58+
59+
class SelectelSwiftStorage(Storage):
60+
def delete(self, url: str) -> Response:
61+
token = self._get_auth_token()
62+
return requests.delete(url, headers={"X-Auth-Token": token})
63+
64+
def upload(self, file: File, user: User) -> FileInfo:
65+
url = self._upload(file, user)
66+
return FileInfo(
67+
url=url,
68+
name=file.name,
69+
extension=file.extension,
70+
mime_type=file.content_type,
71+
size=file.size,
72+
)
73+
74+
def _upload(self, file: File, user: User) -> str:
75+
token = self._get_auth_token()
76+
url = self._generate_url(file, user)
77+
78+
requests.put(
79+
url,
80+
headers={
81+
"X-Auth-Token": token,
82+
"Content-Type": file.content_type,
83+
},
84+
data=file.buffer,
85+
)
86+
87+
return url
88+
89+
def _generate_url(self, file: File, user: User) -> str:
90+
"""
91+
Generates url for selcdn
92+
Returns:
93+
url: str looks like /hashedEmail/hashedFilename_hashedTime.extension
94+
"""
95+
return (
96+
f"{SELECTEL_SWIFT_URL}"
97+
f"{abs(hash(user.email))}"
98+
f"/{abs(hash(file.name))}"
99+
f"_{abs(hash(time.time()))}"
100+
f".{file.extension}"
101+
)
102+
103+
@staticmethod
104+
def _get_auth_token():
105+
"""
106+
Returns auth token
107+
"""
108+
109+
data = {
110+
"auth": {
111+
"identity": {
112+
"methods": ["password"],
113+
"password": {
114+
"user": {
115+
"id": settings.SELECTEL_CONTAINER_USERNAME,
116+
"password": settings.SELECTEL_CONTAINER_PASSWORD,
117+
}
118+
},
119+
}
120+
}
121+
}
122+
response = requests.post(settings.SELECTEL_AUTH_TOKEN_URL, json=data)
123+
if response.status_code not in [200, 201]:
124+
raise SelectelUploadError(
125+
"Couldn't generate a token for Selectel Swift API (selcdn)"
126+
)
127+
return response.headers["x-subject-token"]
128+
129+
130+
class CDN:
131+
def __init__(self, storage: Storage) -> None:
132+
self.storage = storage
133+
134+
def delete(self, url: str) -> Response:
135+
return self.storage.delete(url)
136+
137+
def upload(
138+
self, file: TemporaryUploadedFile | InMemoryUploadedFile, user: User
139+
) -> FileInfo:
140+
return self.storage.upload(File(file), user)

files/typings.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66

77
@dataclass(slots=True, frozen=True)
8-
class UserFileInfo:
8+
class FileInfo:
9+
url: str
910
size: Bytes
1011
name: str
1112
extension: str

0 commit comments

Comments
 (0)