PhotosAPI/extensions/photos.py

582 lines
17 KiB
Python
Raw Normal View History

2023-06-23 12:25:27 +03:00
import logging
2023-06-22 14:17:53 +03:00
import re
from datetime import datetime, timedelta, timezone
from os import makedirs, path, remove, system
2023-06-23 11:51:42 +03:00
from pathlib import Path
2022-12-20 02:22:32 +02:00
from secrets import token_urlsafe
2023-01-05 17:38:00 +02:00
from shutil import move
from threading import Thread
2023-02-16 15:55:03 +02:00
from typing import Union
2023-02-18 01:47:00 +02:00
from uuid import uuid4
2023-02-18 01:19:46 +02:00
2023-06-22 14:17:53 +03:00
import aiofiles
from bson.errors import InvalidId
from bson.objectid import ObjectId
from fastapi import Security, UploadFile
from fastapi.responses import Response, UJSONResponse
from jose import JWTError, jwt
from magic import Magic
from plum.exceptions import UnpackError
2023-02-18 01:19:46 +02:00
from pydantic import ValidationError
2023-06-22 14:17:53 +03:00
from pymongo import DESCENDING
2023-06-22 14:26:01 +03:00
from starlette.status import HTTP_204_NO_CONTENT, HTTP_409_CONFLICT
2023-06-22 14:17:53 +03:00
2023-06-22 14:26:01 +03:00
from classes.exceptions import (
AccessTokenInvalidError,
AlbumNameNotFoundError,
PhotoNotFoundError,
PhotoSearchQueryEmptyError,
SearchPageInvalidError,
SearchTokenInvalidError,
)
2023-02-16 15:55:03 +02:00
from classes.models import Photo, PhotoPublic, SearchResultsPhoto
2023-06-22 14:17:53 +03:00
from modules.app import app
from modules.database import col_albums, col_photos, col_tokens
2023-01-02 16:08:46 +02:00
from modules.exif_reader import extract_location
2023-06-22 14:17:53 +03:00
from modules.hasher import get_duplicates, get_phash
2022-12-20 23:24:35 +02:00
from modules.scheduler import scheduler
2023-06-22 14:26:01 +03:00
from modules.security import (
ALGORITHM,
SECRET_KEY,
TokenData,
User,
create_access_token,
get_current_active_user,
get_user,
)
2023-06-23 12:25:27 +03:00
from modules.utils import configGet
logger = logging.getLogger(__name__)
2022-12-20 23:24:35 +02:00
2023-03-12 15:59:13 +02:00
async def compress_image(image_path: str):
2022-12-20 23:24:35 +02:00
image_type = Magic(mime=True).from_file(image_path)
if image_type not in ["image/jpeg", "image/png"]:
2023-06-23 12:25:27 +03:00
logger.info(
"Not compressing %s because its mime is '%s'", image_path, image_type
)
2022-12-20 23:24:35 +02:00
return
size_before = path.getsize(image_path) / 1024
if image_type == "image/jpeg":
2023-03-12 15:59:13 +02:00
task = Thread(
target=system,
kwargs={"command": f'jpegoptim "{image_path}" -o --max=55 -p --strip-none'},
)
2022-12-20 23:24:35 +02:00
elif image_type == "image/png":
task = Thread(target=system, kwargs={"command": f'optipng -o3 "{image_path}"'})
else:
return
task.start()
2023-06-23 12:25:27 +03:00
logger.info("Compressing '%s'...", Path(image_path).name)
task.join()
2022-12-20 23:24:35 +02:00
size_after = path.getsize(image_path) / 1024
2023-06-23 12:25:27 +03:00
logger.info(
"Compressed '%s' from %s Kb to %s Kb",
Path(image_path).name,
size_before,
size_after,
2023-03-12 15:59:13 +02:00
)
2022-12-20 23:24:35 +02:00
2023-02-16 16:44:54 +02:00
photo_post_responses = {
2023-02-16 15:55:03 +02:00
404: AlbumNameNotFoundError("name").openapi,
409: {
"description": "Image Duplicates Found",
"content": {
"application/json": {
"example": {
"detail": "Image duplicates found. Pass 'ignore_duplicates=true' to ignore.",
2023-03-12 15:59:13 +02:00
"duplicates": ["string"],
"access_token": "string",
2023-02-16 15:55:03 +02:00
}
}
2023-03-12 15:59:13 +02:00
},
},
2023-02-16 15:55:03 +02:00
}
2022-12-20 02:22:32 +02:00
2023-03-12 15:59:13 +02:00
@app.post(
"/albums/{album}/photos",
description="Upload a photo to album",
response_class=UJSONResponse,
response_model=Photo,
responses=photo_post_responses,
)
async def photo_upload(
file: UploadFile,
album: str,
ignore_duplicates: bool = False,
compress: bool = True,
caption: Union[str, None] = None,
current_user: User = Security(get_current_active_user, scopes=["photos.write"]),
):
if col_albums.find_one({"user": current_user.user, "name": album}) is None:
2023-02-16 15:55:03 +02:00
raise AlbumNameNotFoundError(album)
2022-12-20 02:22:32 +02:00
2023-06-23 11:51:42 +03:00
makedirs(Path(f"data/users/{current_user.user}/albums/{album}"), exist_ok=True)
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
filename = file.filename
2022-12-20 02:22:32 +02:00
2023-06-23 11:51:42 +03:00
if Path(f"data/users/{current_user.user}/albums/{album}/{file.filename}").exists():
2022-12-20 14:28:50 +02:00
base_name = file.filename.split(".")[:-1]
extension = file.filename.split(".")[-1]
2023-03-12 15:59:13 +02:00
filename = (
".".join(base_name) + f"_{int(datetime.now().timestamp())}." + extension
)
2022-12-20 02:22:32 +02:00
2023-06-22 14:16:12 +03:00
async with aiofiles.open(
2023-06-23 11:51:42 +03:00
Path(f"data/users/{current_user.user}/albums/{album}/{filename}"), "wb"
2023-03-12 15:59:13 +02:00
) as f:
2023-06-23 10:40:37 +03:00
await f.write(await file.read())
2022-12-20 02:22:32 +02:00
2023-03-12 15:59:13 +02:00
file_hash = await get_phash(
2023-06-23 11:51:42 +03:00
Path(f"data/users/{current_user.user}/albums/{album}/{filename}")
2023-03-12 15:59:13 +02:00
)
2022-12-20 14:28:50 +02:00
duplicates = await get_duplicates(file_hash, album)
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
if len(duplicates) > 0 and ignore_duplicates is False:
2023-02-18 12:07:46 +02:00
if configGet("media_token_access") is True:
duplicates_ids = []
for entry in duplicates:
duplicates_ids.append(entry["id"])
2023-03-12 15:59:13 +02:00
access_token = create_access_token(
data={
"sub": current_user.user,
"scopes": ["me", "photos.read"],
"allowed": duplicates_ids,
},
expires_delta=timedelta(hours=configGet("media_token_valid_hours")),
)
2023-02-18 12:07:46 +02:00
access_token_short = uuid4().hex[:12].lower()
2023-03-12 15:59:13 +02:00
col_tokens.insert_one(
{
"short": access_token_short,
"access_token": access_token,
"photos": duplicates_ids,
}
)
2023-02-18 12:07:46 +02:00
else:
access_token_short = None
2022-12-20 02:22:32 +02:00
return UJSONResponse(
{
2022-12-20 14:28:50 +02:00
"detail": "Image duplicates found. Pass 'ignore_duplicates=true' to ignore.",
2023-02-18 01:19:46 +02:00
"duplicates": duplicates,
2023-03-12 15:59:13 +02:00
"access_token": access_token_short,
2022-12-20 14:28:50 +02:00
},
2023-03-12 15:59:13 +02:00
status_code=HTTP_409_CONFLICT,
2022-12-20 02:22:32 +02:00
)
try:
2023-03-12 15:59:13 +02:00
coords = extract_location(
2023-06-23 11:51:42 +03:00
Path(f"data/users/{current_user.user}/albums/{album}/{filename}")
2023-03-12 15:59:13 +02:00
)
except (UnpackError, ValueError):
2023-03-12 15:59:13 +02:00
coords = {"lng": 0.0, "lat": 0.0, "alt": 0.0}
2023-01-10 16:23:49 +02:00
uploaded = col_photos.insert_one(
{
"user": current_user.user,
2023-03-12 15:59:13 +02:00
"album": album,
2023-01-10 16:23:49 +02:00
"hash": file_hash,
"filename": filename,
"dates": {
2023-01-25 17:02:28 +02:00
"uploaded": datetime.now(tz=timezone.utc),
2023-03-12 15:59:13 +02:00
"modified": datetime.now(tz=timezone.utc),
2023-01-10 16:23:49 +02:00
},
2023-03-12 15:59:13 +02:00
"location": [coords["lng"], coords["lat"], coords["alt"]],
"caption": caption,
2023-01-10 16:23:49 +02:00
}
)
2022-12-20 02:22:32 +02:00
2022-12-20 23:24:35 +02:00
if compress is True:
2023-03-12 15:59:13 +02:00
scheduler.add_job(
compress_image,
trigger="date",
run_date=datetime.now() + timedelta(seconds=1),
2023-06-23 11:51:42 +03:00
args=[Path(f"data/users/{current_user.user}/albums/{album}/{filename}")],
2023-03-12 15:59:13 +02:00
)
2022-12-20 23:24:35 +02:00
2022-12-20 14:28:50 +02:00
return UJSONResponse(
{
"id": uploaded.inserted_id.__str__(),
"album": album,
"hash": file_hash,
2023-03-12 15:59:13 +02:00
"filename": filename,
2022-12-20 14:28:50 +02:00
}
)
2022-12-20 02:22:32 +02:00
2023-03-12 15:59:13 +02:00
2023-02-18 12:07:46 +02:00
# Access to photos y token generated for example by
# upload method when duplicates are found. Is disabled
# by default and should remain so if not really needed.
if configGet("media_token_access") is True:
photo_get_token_responses = {
401: AccessTokenInvalidError().openapi,
2023-03-12 15:59:13 +02:00
404: PhotoNotFoundError("id").openapi,
2023-02-18 12:07:46 +02:00
}
2023-02-18 01:47:00 +02:00
2023-03-12 15:59:13 +02:00
@app.get(
"/token/photo/{token}",
description="Get a photo by its duplicate token",
responses=photo_get_token_responses,
)
2023-02-18 12:07:46 +02:00
async def photo_get_token(token: str, id: int):
db_entry = col_tokens.find_one({"short": token})
2023-02-18 01:19:46 +02:00
2023-02-18 12:07:46 +02:00
if db_entry is None:
2023-02-18 01:19:46 +02:00
raise AccessTokenInvalidError()
2023-02-18 12:07:46 +02:00
token = db_entry["access_token"]
id = db_entry["photos"][id]
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user: str = payload.get("sub")
if user is None:
raise AccessTokenInvalidError()
token_scopes = payload.get("scopes", [])
token_data = TokenData(scopes=token_scopes, user=user)
except (JWTError, ValidationError) as exp:
print(exp, flush=True)
raise AccessTokenInvalidError()
2023-03-12 15:59:13 +02:00
2023-02-18 12:07:46 +02:00
user = get_user(user=token_data.user)
2023-02-18 01:19:46 +02:00
2023-02-18 12:07:46 +02:00
if id not in payload.get("allowed", []):
raise AccessTokenInvalidError()
try:
2023-03-12 15:59:13 +02:00
image = col_photos.find_one({"_id": ObjectId(id)})
2023-02-18 12:07:46 +02:00
if image is None:
raise InvalidId(id)
except InvalidId:
raise PhotoNotFoundError(id)
2023-02-18 01:19:46 +02:00
2023-06-23 11:51:42 +03:00
image_path = Path(
f"data/users/{user.user}/albums/{image['album']}/{image['filename']}"
2023-03-12 15:59:13 +02:00
)
2023-02-18 01:19:46 +02:00
2023-02-18 12:07:46 +02:00
mime = Magic(mime=True).from_file(image_path)
2023-02-18 01:19:46 +02:00
2023-06-22 14:16:12 +03:00
async with aiofiles.open(image_path, "rb") as f:
image_file = await f.read()
2023-02-18 01:19:46 +02:00
2023-02-18 12:07:46 +02:00
return Response(image_file, media_type=mime)
2023-02-18 01:19:46 +02:00
2022-12-20 02:22:32 +02:00
2023-06-22 14:51:04 +03:00
photo_get_responses = {
2023-06-22 15:43:00 +03:00
200: {
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary",
"contentMediaType": "image/*",
}
}
}
},
2023-06-22 14:51:04 +03:00
404: PhotoNotFoundError("id").openapi,
}
2023-03-12 15:59:13 +02:00
2023-06-22 14:51:04 +03:00
@app.get(
"/photos/{id}",
description="Get a photo by id",
responses=photo_get_responses,
response_class=Response,
)
2023-03-12 15:59:13 +02:00
async def photo_get(
id: str,
current_user: User = Security(get_current_active_user, scopes=["photos.read"]),
):
2022-12-20 14:28:50 +02:00
try:
2023-03-12 15:59:13 +02:00
image = col_photos.find_one({"_id": ObjectId(id)})
2022-12-20 14:28:50 +02:00
if image is None:
raise InvalidId(id)
except InvalidId:
2023-02-16 15:55:03 +02:00
raise PhotoNotFoundError(id)
2022-12-20 02:22:32 +02:00
2023-06-23 11:51:42 +03:00
image_path = Path(
f"data/users/{current_user.user}/albums/{image['album']}/{image['filename']}"
2023-03-12 15:59:13 +02:00
)
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
mime = Magic(mime=True).from_file(image_path)
2022-12-20 02:22:32 +02:00
2023-06-22 14:16:12 +03:00
async with aiofiles.open(image_path, "rb") as f:
image_file = await f.read()
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
return Response(image_file, media_type=mime)
2022-12-20 02:22:32 +02:00
2023-01-05 17:38:00 +02:00
2023-03-12 15:59:13 +02:00
photo_move_responses = {404: PhotoNotFoundError("id").openapi}
@app.put(
"/photos/{id}",
description="Move a photo to another album",
response_model=PhotoPublic,
responses=photo_move_responses,
)
async def photo_move(
id: str,
album: str,
current_user: User = Security(get_current_active_user, scopes=["photos.write"]),
):
2023-01-05 17:38:00 +02:00
try:
2023-03-12 15:59:13 +02:00
image = col_photos.find_one({"_id": ObjectId(id)})
2023-01-05 17:38:00 +02:00
if image is None:
raise InvalidId(id)
except InvalidId:
2023-02-16 15:55:03 +02:00
raise PhotoNotFoundError(id)
2023-01-05 17:38:00 +02:00
2023-03-12 15:59:13 +02:00
if col_albums.find_one({"user": current_user.user, "name": album}) is None:
2023-02-16 15:55:03 +02:00
raise AlbumNameNotFoundError(album)
2023-01-05 17:38:00 +02:00
2023-06-23 11:51:42 +03:00
if Path(
f"data/users/{current_user.user}/albums/{album}/{image['filename']}"
).exists():
2023-01-05 17:38:00 +02:00
base_name = image["filename"].split(".")[:-1]
extension = image["filename"].split(".")[-1]
2023-03-12 15:59:13 +02:00
filename = (
".".join(base_name) + f"_{int(datetime.now().timestamp())}." + extension
)
2023-01-05 17:38:00 +02:00
else:
filename = image["filename"]
2023-03-12 15:59:13 +02:00
col_photos.find_one_and_update(
{"_id": ObjectId(id)},
{
"$set": {
"album": album,
"filename": filename,
"dates.modified": datetime.now(tz=timezone.utc),
}
},
)
2023-01-05 17:38:00 +02:00
move(
2023-06-23 11:51:42 +03:00
Path(
f"data/users/{current_user.user}/albums/{image['album']}/{image['filename']}"
2023-03-12 15:59:13 +02:00
),
2023-06-23 11:51:42 +03:00
Path(f"data/users/{current_user.user}/albums/{album}/{filename}"),
2023-01-05 17:38:00 +02:00
)
return UJSONResponse(
{
"id": image["_id"].__str__(),
2023-02-16 15:55:03 +02:00
"caption": image["caption"],
2023-03-12 15:59:13 +02:00
"filename": filename,
2023-01-05 17:38:00 +02:00
}
)
2023-01-17 15:39:21 +02:00
2023-03-12 15:59:13 +02:00
photo_patch_responses = {404: PhotoNotFoundError("id").openapi}
@app.patch(
"/photos/{id}",
description="Change properties of a photo",
response_model=PhotoPublic,
responses=photo_patch_responses,
)
async def photo_patch(
id: str,
caption: str,
current_user: User = Security(get_current_active_user, scopes=["photos.write"]),
):
2023-01-17 15:39:21 +02:00
try:
2023-03-12 15:59:13 +02:00
image = col_photos.find_one({"_id": ObjectId(id)})
2023-01-17 15:39:21 +02:00
if image is None:
raise InvalidId(id)
except InvalidId:
2023-02-16 15:55:03 +02:00
raise PhotoNotFoundError(id)
2023-01-17 15:39:21 +02:00
2023-03-12 15:59:13 +02:00
col_photos.find_one_and_update(
{"_id": ObjectId(id)},
{"$set": {"caption": caption, "dates.modified": datetime.now(tz=timezone.utc)}},
)
2023-01-17 15:39:21 +02:00
return UJSONResponse(
{
"id": image["_id"].__str__(),
2023-02-16 15:55:03 +02:00
"caption": caption,
2023-03-12 15:59:13 +02:00
"filename": image["filename"],
2023-01-17 15:39:21 +02:00
}
)
2022-12-20 02:22:32 +02:00
2023-03-12 15:59:13 +02:00
photo_delete_responses = {404: PhotoNotFoundError("id").openapi}
@app.delete(
"/photos/{id}",
description="Delete a photo by id",
status_code=HTTP_204_NO_CONTENT,
responses=photo_delete_responses,
)
async def photo_delete(
id: str,
current_user: User = Security(get_current_active_user, scopes=["photos.write"]),
):
2022-12-20 14:28:50 +02:00
try:
2023-03-12 15:59:13 +02:00
image = col_photos.find_one_and_delete({"_id": ObjectId(id)})
2022-12-20 14:28:50 +02:00
if image is None:
raise InvalidId(id)
except InvalidId:
2023-02-16 15:55:03 +02:00
raise PhotoNotFoundError(id)
2022-12-20 02:22:32 +02:00
2023-03-12 15:59:13 +02:00
album = col_albums.find_one({"name": image["album"]})
2022-12-21 00:59:47 +02:00
if album is not None and album["cover"] == image["_id"].__str__():
2023-03-12 15:59:13 +02:00
col_albums.update_one({"name": image["album"]}, {"$set": {"cover": None}})
remove(
2023-06-23 11:51:42 +03:00
Path(
f"data/users/{current_user.user}/albums/{image['album']}/{image['filename']}"
2023-03-12 15:59:13 +02:00
)
)
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
return Response(status_code=HTTP_204_NO_CONTENT)
2022-12-20 02:22:32 +02:00
2023-03-12 15:59:13 +02:00
2023-02-16 16:44:54 +02:00
photo_find_responses = {
2023-02-16 15:55:03 +02:00
400: SearchPageInvalidError().openapi,
2023-03-23 13:34:18 +02:00
401: SearchTokenInvalidError().openapi,
2023-02-16 16:44:54 +02:00
404: AlbumNameNotFoundError("name").openapi,
2023-03-12 15:59:13 +02:00
422: PhotoSearchQueryEmptyError().openapi,
2023-02-16 15:55:03 +02:00
}
2022-12-20 02:22:32 +02:00
2023-03-12 15:59:13 +02:00
@app.get(
"/albums/{album}/photos",
2023-03-23 13:34:18 +02:00
description="Find a photo by filename, caption, location or token",
2023-03-12 15:59:13 +02:00
response_class=UJSONResponse,
response_model=SearchResultsPhoto,
responses=photo_find_responses,
)
async def photo_find(
album: str,
q: Union[str, None] = None,
caption: Union[str, None] = None,
2023-03-23 13:34:18 +02:00
token: Union[str, None] = None,
2023-03-12 15:59:13 +02:00
page: int = 1,
page_size: int = 100,
lat: Union[float, None] = None,
lng: Union[float, None] = None,
radius: Union[int, None] = None,
current_user: User = Security(get_current_active_user, scopes=["photos.list"]),
):
2023-03-23 13:34:18 +02:00
if token is not None:
found_record = col_tokens.find_one({"token": token})
if found_record is None:
raise SearchTokenInvalidError()
return await photo_find(
album=album,
q=found_record["query"],
caption=found_record["caption"],
lat=found_record["lat"],
lng=found_record["lng"],
radius=found_record["radius"],
page=found_record["page"],
page_size=found_record["page_size"],
current_user=current_user,
)
2023-03-12 15:59:13 +02:00
if col_albums.find_one({"user": current_user.user, "name": album}) is None:
2023-02-16 15:55:03 +02:00
raise AlbumNameNotFoundError(album)
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
if page <= 0 or page_size <= 0:
2023-02-16 15:55:03 +02:00
raise SearchPageInvalidError()
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
output = {"results": []}
2023-03-12 15:59:13 +02:00
skip = (page - 1) * page_size
2023-01-02 16:08:46 +02:00
radius = 5000 if radius is None else radius
if (lat is not None) and (lng is not None):
2023-03-12 15:59:13 +02:00
db_query = {
"user": current_user.user,
"album": album,
"location": {
"$nearSphere": {
"$geometry": {"type": "Point", "coordinates": [lng, lat]},
"$maxDistance": radius,
}
},
}
db_query_count = {
"user": current_user.user,
"album": album,
"location": {"$geoWithin": {"$centerSphere": [[lng, lat], radius]}},
}
2023-01-17 15:39:21 +02:00
elif q is None and caption is None:
2023-02-16 16:44:54 +02:00
raise PhotoSearchQueryEmptyError()
2023-01-17 15:39:21 +02:00
elif q is None and caption is not None:
2023-03-12 15:59:13 +02:00
db_query = {
"user": current_user.user,
"album": album,
"caption": re.compile(caption),
}
db_query_count = {
"user": current_user.user,
"album": album,
"caption": re.compile(caption),
}
2023-01-17 15:39:21 +02:00
elif q is not None and caption is None:
2023-03-12 15:59:13 +02:00
db_query = {
"user": current_user.user,
"album": album,
"filename": re.compile(q),
}
db_query_count = {
"user": current_user.user,
"album": album,
"filename": re.compile(q),
}
2023-01-17 15:39:21 +02:00
else:
2023-03-12 15:59:13 +02:00
db_query = {"user": current_user.user, "album": album, "filename": re.compile(q), "caption": re.compile(caption)} # type: ignore
db_query_count = {"user": current_user.user, "album": album, "filename": re.compile(q), "caption": re.compile(caption)} # type: ignore
2023-01-02 16:08:46 +02:00
2023-03-12 15:59:13 +02:00
images = list(
col_photos.find(db_query, limit=page_size, skip=skip).sort(
"dates.uploaded", DESCENDING
)
)
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
for image in images:
2023-03-12 15:59:13 +02:00
output["results"].append(
{
"id": image["_id"].__str__(),
"filename": image["filename"],
"caption": image["caption"],
}
)
2022-12-20 02:22:32 +02:00
2023-03-12 15:59:13 +02:00
if col_photos.count_documents(db_query_count) > page * page_size:
2022-12-20 14:28:50 +02:00
token = str(token_urlsafe(32))
2023-03-12 15:59:13 +02:00
col_tokens.insert_one(
{
"token": token,
"query": q,
2023-03-23 13:34:18 +02:00
"caption": caption,
"lat": lat,
"lng": lng,
"radius": radius,
2023-03-12 15:59:13 +02:00
"page": page + 1,
"page_size": page_size,
}
)
2023-03-23 13:34:18 +02:00
output["next_page"] = f"/albums/{album}/photos/?token={token}" # type: ignore
2022-12-20 18:07:48 +02:00
else:
2023-03-12 15:59:13 +02:00
output["next_page"] = None # type: ignore
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
return UJSONResponse(output)