diff --git a/.gitignore b/.gitignore index 049f5bd..7a8a224 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ instance pnogo_api.egg-info venv __pycache__ +staticfiles diff --git a/Dockerfile b/Dockerfile index 1f0fec1..0da99d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,9 +22,12 @@ FROM python:3.10-slim-bookworm COPY --from=builder --chown=app:app /app /app ENV PATH="/app/.venv/bin:$PATH" +ENV DJANGO_SETTINGS_MODULE=pnogo.settings WORKDIR /app -EXPOSE 8000 +RUN python manage.py collectstatic --noinput 2>/dev/null || true -CMD ["granian", "--interface", "wsgi", "--workers", "2", "--host", "0.0.0.0", "--port", "8080", "pnogo_api.run:app"] +EXPOSE 8080 + +CMD ["granian", "--interface", "wsgi", "--workers", "2", "--host", "0.0.0.0", "--port", "8080", "pnogo.wsgi:application"] diff --git a/docker-compose.yml b/docker-compose.yml index 893e9ec..ee5c808 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,5 +10,18 @@ services: volumes: - postgres_data:/var/lib/postgresql/data + minio: + image: minio/minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + volumes: postgres_data: + minio_data: diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..bc679f2 --- /dev/null +++ b/manage.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pnogo.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/pnogo/__init__.py b/pnogo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pnogo/admin.py b/pnogo/admin.py new file mode 100644 index 0000000..30c144a --- /dev/null +++ b/pnogo/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin + +from .models import Cndr, Picture + + +@admin.register(Picture) +class PictureAdmin(admin.ModelAdmin): + list_display = ("id", "file", "description", "cndr", "points", "sent", "daily_date") + list_filter = ("cndr", "daily_date") + search_fields = ("file", "description") + raw_id_fields = ("cndr",) + + +@admin.register(Cndr) +class CndrAdmin(admin.ModelAdmin): + list_display = ("id", "name") + search_fields = ("name",) diff --git a/pnogo/authentication.py b/pnogo/authentication.py new file mode 100644 index 0000000..485a386 --- /dev/null +++ b/pnogo/authentication.py @@ -0,0 +1,33 @@ +from rest_framework.authentication import TokenAuthentication +from rest_framework.authtoken.models import Token +from rest_framework.exceptions import AuthenticationFailed + + +class TokenQueryParamAuthentication(TokenAuthentication): + """ + DRF TokenAuthentication extended to also accept: + - X-API-Key header + - ?key= query param (legacy) + + Falls back to the standard Authorization: Token header. + """ + + def authenticate(self, request): + # Try X-API-Key header or ?key= query param first + key = request.META.get("HTTP_X_API_KEY") or request.query_params.get("key") + if key: + return self.authenticate_credentials(key) + + # Fall back to standard "Authorization: Token " header + return super().authenticate(request) + + def authenticate_credentials(self, key): + try: + token = Token.objects.select_related("user").get(key=key) + except Token.DoesNotExist: + raise AuthenticationFailed("Invalid API key.") + + if not token.user.is_active: + raise AuthenticationFailed("User inactive.") + + return (token.user, token) diff --git a/pnogo/legacy.py b/pnogo/legacy.py new file mode 100644 index 0000000..ca281ca --- /dev/null +++ b/pnogo/legacy.py @@ -0,0 +1,322 @@ +""" +Legacy adapter views that map old Flask-style endpoints to new Django views. + +TODO: Remove this file (and legacy_urls.py) once all clients have been updated. +""" + +import json + +from django.http import HttpResponse +from markupsafe import escape +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import Cndr, Picture +from .serializers import PictureSerializer +from .services import storage +from .views import ( + PictureBitmapView, + PictureCountView, + PictureDailyView, + PictureImageView, + PictureListView, + PictureOriginalView, + PictureRandomView, + PictureStretchedView, + PictureUploadView, + VersionView, +) + +# --- Helpers --- + + +def _id_from_query(request): + """Extract ?id= and return as int, or None.""" + pnid = request.query_params.get("id") + return int(pnid) if pnid is not None else None + + +# --- /getall, /getall/, /getallpnoghi --- + + +class LegacyGetAllView(PictureListView): + """GET /getall and GET /getall/""" + + def get_queryset(self): + qs = Picture.objects.select_related("cndr").all() + cndr = self.kwargs.get("cndr") or self.request.query_params.get("cndr") + if cndr: + qs = qs.filter(cndr__name__iexact=cndr) + return qs + + def list(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = self.get_serializer(queryset, many=True) + # Old API used "name" instead of "cndr" + data = serializer.data + for item in data: + if "cndr" in item: + item["name"] = item.pop("cndr") + return HttpResponse(json.dumps(data), content_type="application/json") + + +class LegacyGetAllPnoghiView(LegacyGetAllView): + """GET /getallpnoghi — hardcoded alias for cndr=pongo.""" + + def get_queryset(self): + return Picture.objects.select_related("cndr").filter(cndr__name__iexact="pongo") + + +# --- /info, /infopnogo --- + + +class LegacyInfoView(APIView): + """GET /info?id=""" + + def get(self, request): + pk = _id_from_query(request) + if pk is None: + return Response(status=400) + try: + picture = Picture.objects.select_related("cndr").get(pk=pk) + except Picture.DoesNotExist: + return Response(status=404) + data = PictureSerializer(picture).data + # Old API used "name" instead of "cndr" + data["name"] = data.pop("cndr") + return Response(data) + + +# --- /desc, /descpnogo --- + + +class LegacyDescView(APIView): + """GET /desc?id=&description=""" + + def get(self, request): + pk = _id_from_query(request) + desc = request.query_params.get("description", "") + if pk is None: + return Response(status=400) + updated = Picture.objects.filter(pk=pk).update(description=desc) + if not updated: + return Response(status=404) + return HttpResponse(f"done! set desc of {pk} to: {desc}") + + +# --- /kill, /killpnogo --- + + +class LegacyKillView(APIView): + """GET /kill?id=""" + + def get(self, request): + pk = _id_from_query(request) + if pk is None: + return Response(status=400) + try: + picture = Picture.objects.get(pk=pk) + except Picture.DoesNotExist: + return Response(status=404) + storage.delete_object(picture.file) + picture.delete() + return HttpResponse(f"success!
il pongo numero {pk} è stato abbattuto, pace all'anima sua") + + +# --- /get, /getpnogo --- + + +class LegacyGetView(PictureImageView): + """GET /get?id=&width=...&height=...&maxsize=...""" + + def get(self, request): + pk = _id_from_query(request) + if pk is None: + return Response(status=400) + return super().get(request, pk=pk) + + +# --- /getstretched, /getstretchedpnogo --- + + +class LegacyGetStretchedView(PictureStretchedView): + """GET /getstretched?id=&maxsize=...""" + + def get(self, request): + pk = _id_from_query(request) + if pk is None: + return Response(status=400) + return super().get(request, pk=pk) + + +# --- /getbitmap --- + + +class LegacyGetBitmapView(PictureBitmapView): + """GET /getbitmap?id=&width=...&height=...""" + + def get(self, request): + pk = _id_from_query(request) + if pk is None: + return Response(status=400) + return super().get(request, pk=pk) + + +# --- /getoriginal, /getpnogoriginal --- + + +class LegacyGetOriginalView(PictureOriginalView): + """GET /getoriginal?id=""" + + def get(self, request): + pk = _id_from_query(request) + if pk is None: + return Response(status=400) + return super().get(request, pk=pk) + + +# --- /random, /random/, /randompnogo --- + + +class LegacyRandomView(PictureRandomView): + """GET /random and GET /random/""" + + def get(self, request, cndr=None): + if cndr: + request.query_params._mutable = True + request.query_params["cndr"] = cndr + request.query_params._mutable = False + response = super().get(request) + if hasattr(response, "data") and "cndr" in response.data: + response.data["name"] = response.data.pop("cndr") + return response + + +class LegacyRandomPnogoView(LegacyRandomView): + """GET /randompnogo""" + + def get(self, request): + return super().get(request, cndr="pongo") + + +# --- /daily, /dailypnogo --- + + +class LegacyDailyView(PictureDailyView): + """GET /daily — same logic, just remap 'cndr' → 'name' in response.""" + + def get(self, request): + response = super().get(request) + if hasattr(response, "data") and "cndr" in response.data: + response.data["name"] = response.data.pop("cndr") + return response + + +# --- /count, /count/, /countpnogo --- + + +class LegacyCountView(PictureCountView): + """GET /count and GET /count/""" + + def get(self, request, cndr=None): + if cndr: + request.query_params._mutable = True + request.query_params["cndr"] = cndr + request.query_params._mutable = False + return super().get(request) + + +class LegacyCountPnogoView(LegacyCountView): + """GET /countpnogo""" + + def get(self, request): + return super().get(request, cndr="pongo") + + +# --- /add/, /addpnogo --- + + +class LegacyAddView(PictureUploadView): + """GET+POST /add/ — GET returns HTML form, POST uploads.""" + + def get(self, request, cndr="pongo"): + return HttpResponse( + f""" + + Upload new {escape(cndr)} +

Upload new {escape(cndr)}

+
+ + +
+ """, + content_type="text/html", + ) + + def post(self, request, cndr="pongo"): + # Inject cndr into request data so the parent serializer picks it up + request.data._mutable = True + request.data["cndr"] = cndr + request.data._mutable = False + response = super().post(request) + if response.status_code == 201: + return HttpResponse("done!") + return response + + +# --- /create --- + + +class LegacyCreateCndrView(APIView): + """GET /create?name=""" + + def get(self, request): + name = request.query_params.get("name", "") + if not name: + return Response(status=400) + _, created = Cndr.objects.get_or_create(name=escape(name)) + return HttpResponse("done" if created else f"morte: {escape(name)} already present in db") + + +# --- /remove --- + + +class LegacyRemoveCndrView(APIView): + """GET /remove?name=""" + + def get(self, request): + name = request.query_params.get("name", "") + if not name: + return Response(status=400) + try: + cndr = Cndr.objects.get(name__iexact=name) + except Cndr.DoesNotExist: + return Response(status=404) + if cndr.pictures.exists(): + return HttpResponse(f"morte: some pictures of {escape(name)} are still in the db") + cndr.delete() + return HttpResponse("done") + + +# --- /list --- + + +class LegacyListCndrView(APIView): + """GET /list — returns JSON array of {id, name}.""" + + def get(self, request): + cndrs = list(Cndr.objects.values("id", "name")) + return HttpResponse(json.dumps(cndrs), content_type="application/json") + + +# --- /version --- + +LegacyVersionView = VersionView + + +# --- /update (stub, was already marked useless) --- + + +class LegacyUpdateView(APIView): + def get(self, request): + return Response({"added": 0, "removed": 0}) diff --git a/pnogo/legacy_urls.py b/pnogo/legacy_urls.py new file mode 100644 index 0000000..a04e903 --- /dev/null +++ b/pnogo/legacy_urls.py @@ -0,0 +1,56 @@ +""" +Legacy URL mappings for backwards compatibility with the old Flask API. + +TODO: Remove this file (and legacy.py) once all clients have been updated. + Then remove the include("pnogo.legacy_urls") line from pnogo/urls.py. +""" + +from django.urls import path + +from . import legacy + +urlpatterns = [ + # List + path("getall", legacy.LegacyGetAllView.as_view()), + path("getall/", legacy.LegacyGetAllView.as_view()), + path("getallpnoghi", legacy.LegacyGetAllPnoghiView.as_view()), + # Info + path("info", legacy.LegacyInfoView.as_view()), + path("infopnogo", legacy.LegacyInfoView.as_view()), + # Description + path("desc", legacy.LegacyDescView.as_view()), + path("descpnogo", legacy.LegacyDescView.as_view()), + # Delete + path("kill", legacy.LegacyKillView.as_view()), + path("killpnogo", legacy.LegacyKillView.as_view()), + # Image serving + path("get", legacy.LegacyGetView.as_view()), + path("getpnogo", legacy.LegacyGetView.as_view()), + path("getstretched", legacy.LegacyGetStretchedView.as_view()), + path("getstretchedpnogo", legacy.LegacyGetStretchedView.as_view()), + path("getbitmap", legacy.LegacyGetBitmapView.as_view()), + path("getoriginal", legacy.LegacyGetOriginalView.as_view()), + path("getpnogoriginal", legacy.LegacyGetOriginalView.as_view()), + # Random + path("random", legacy.LegacyRandomView.as_view()), + path("random/", legacy.LegacyRandomView.as_view()), + path("randompnogo", legacy.LegacyRandomPnogoView.as_view()), + # Daily + path("daily", legacy.LegacyDailyView.as_view()), + path("dailypnogo", legacy.LegacyDailyView.as_view()), + # Count + path("count", legacy.LegacyCountView.as_view()), + path("count/", legacy.LegacyCountView.as_view()), + path("countpnogo", legacy.LegacyCountPnogoView.as_view()), + # Upload + path("add/", legacy.LegacyAddView.as_view()), + path("addpnogo", legacy.LegacyAddView.as_view()), + # Cndr management + path("create", legacy.LegacyCreateCndrView.as_view()), + path("remove", legacy.LegacyRemoveCndrView.as_view()), + path("list", legacy.LegacyListCndrView.as_view()), + # Version + path("version", legacy.LegacyVersionView.as_view()), + # Update (stub, was already useless) + path("update", legacy.LegacyUpdateView.as_view()), +] diff --git a/pnogo/migrations/0001_initial.py b/pnogo/migrations/0001_initial.py new file mode 100644 index 0000000..a0bca78 --- /dev/null +++ b/pnogo/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# Generated by Django 5.1.15 on 2026-03-18 23:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Cndr', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), + ], + options={ + 'db_table': 'cndr', + }, + ), + migrations.CreateModel( + name='Picture', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, default='')), + ('points', models.IntegerField(default=0)), + ('sent', models.IntegerField(default=0)), + ('daily_date', models.DateField(blank=True, null=True)), + ('cndr', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='pictures', to='pnogo.cndr')), + ], + options={ + 'db_table': 'pictures', + }, + ), + ] diff --git a/pnogo/migrations/0002_migrate_api_keys.py b/pnogo/migrations/0002_migrate_api_keys.py new file mode 100644 index 0000000..2b0259d --- /dev/null +++ b/pnogo/migrations/0002_migrate_api_keys.py @@ -0,0 +1,67 @@ +""" +Data migration: transfer API keys from the old Flask `auth` table to +Django User + DRF Token records, then drop the old table. + +Each old key becomes a service-account User (unusable password, can't log in) +with a matching Token. +""" + +from django.db import migrations + + +def forwards(apps, schema_editor): + connection = schema_editor.connection + with connection.cursor() as cursor: + # Check if the old auth table exists (it won't on fresh installs) + cursor.execute( + "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'auth' AND table_schema = 'public')" + ) + if not cursor.fetchone()[0]: + return + + # Check it's actually the old Flask table (has 'key' and 'name' columns, not Django's) + cursor.execute( + "SELECT column_name FROM information_schema.columns WHERE table_name = 'auth' AND table_schema = 'public'" + ) + columns = {row[0] for row in cursor.fetchall()} + if columns != {"key", "name"}: + return + + cursor.execute("SELECT key, name FROM auth") + old_keys = cursor.fetchall() + + if not old_keys: + return + + User = apps.get_model("auth", "User") + Token = apps.get_model("authtoken", "Token") + + for key, name in old_keys: + username = f"svc-{name}" + user, _ = User.objects.get_or_create( + username=username, + defaults={"is_active": True, "password": "!"}, # unusable password marker + ) + Token.objects.get_or_create(user=user, defaults={"key": key}) + + # Drop the old table + with connection.cursor() as cursor: + cursor.execute("DROP TABLE auth") + + +def backwards(apps, schema_editor): + # No reverse — the old table is gone + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("pnogo", "0001_initial"), + ("auth", "0012_alter_user_first_name_max_length"), + ("authtoken", "0004_alter_tokenproxy_options"), + ] + + operations = [ + migrations.RunPython(forwards, backwards, elidable=True), + ] diff --git a/pnogo/migrations/__init__.py b/pnogo/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pnogo/models.py b/pnogo/models.py new file mode 100644 index 0000000..e95a1f2 --- /dev/null +++ b/pnogo/models.py @@ -0,0 +1,26 @@ +from django.db import models + + +class Cndr(models.Model): + name = models.CharField(max_length=255, unique=True) + + class Meta: + db_table = "cndr" + + def __str__(self): + return self.name + + +class Picture(models.Model): + file = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + points = models.IntegerField(default=0) + sent = models.IntegerField(default=0) + daily_date = models.DateField(null=True, blank=True) + cndr = models.ForeignKey(Cndr, on_delete=models.PROTECT, related_name="pictures") + + class Meta: + db_table = "pictures" + + def __str__(self): + return f"#{self.pk} — {self.file}" diff --git a/pnogo/serializers.py b/pnogo/serializers.py new file mode 100644 index 0000000..0721477 --- /dev/null +++ b/pnogo/serializers.py @@ -0,0 +1,27 @@ +from rest_framework import serializers + +from .models import Cndr, Picture + + +class CndrSerializer(serializers.ModelSerializer): + class Meta: + model = Cndr + fields = ["id", "name"] + + +class PictureSerializer(serializers.ModelSerializer): + cndr = serializers.CharField(source="cndr.name", read_only=True) + + class Meta: + model = Picture + fields = ["id", "file", "description", "points", "sent", "daily_date", "cndr"] + + +class PictureUploadSerializer(serializers.Serializer): + picture = serializers.ImageField() + cndr = serializers.CharField() + description = serializers.CharField(required=False, default="") + + +class PictureUpdateSerializer(serializers.Serializer): + description = serializers.CharField() diff --git a/pnogo/services/__init__.py b/pnogo/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pnogo/services/images.py b/pnogo/services/images.py new file mode 100644 index 0000000..9d92d45 --- /dev/null +++ b/pnogo/services/images.py @@ -0,0 +1,65 @@ +import random +from io import BytesIO + +from PIL import Image, ImageOps + +JPEG_QUALITY = 85 + + +def resize_image(image_data, width=None, height=None, maxsize=1280): + """Resize image preserving aspect ratio. Returns a BytesIO with JPEG data.""" + img = Image.open(image_data).convert("RGB") + img = ImageOps.exif_transpose(img) + overscale = True + + if width is None and height is None: + overscale = False + if img.size[0] > img.size[1]: + width = int(maxsize) + else: + height = int(maxsize) + + if height is None: + width = int(width) + ratio = width / float(img.size[0]) + height = int(float(img.size[1]) * ratio) + elif width is None: + height = int(height) + ratio = height / float(img.size[1]) + width = int(float(img.size[0]) * ratio) + else: + width = int(width) + height = int(height) + + if overscale or img.size[0] > width or img.size[1] > height: + img = img.resize((width, height), Image.LANCZOS) + + buf = BytesIO() + img.save(buf, "JPEG", optimize=True, quality=JPEG_QUALITY) + buf.seek(0) + return buf + + +def stretch_image(image_data, maxsize=1920): + """Stretch image to random distorted dimensions. Returns a BytesIO with JPEG data.""" + img = Image.open(image_data).convert("RGB") + + otherside = int(random.uniform(1 / 20, 1) * maxsize) + horizontal = random.random() < 0.5 + width = otherside if horizontal else maxsize + height = maxsize if horizontal else otherside + + img = img.resize((width, height), Image.LANCZOS) + + buf = BytesIO() + img.save(buf, "JPEG", optimize=True, quality=JPEG_QUALITY) + buf.seek(0) + return buf + + +def to_bitmap(image_data, width=128, height=64): + """Convert image to 1-bit bitmap and return hex-encoded string.""" + img = Image.open(image_data).convert("RGB") + img = img.resize((int(width), int(height)), Image.LANCZOS) + img = img.convert("1") + return "".join("0x%02x," % b for b in img.tobytes()) diff --git a/pnogo/services/storage.py b/pnogo/services/storage.py new file mode 100644 index 0000000..bf0634e --- /dev/null +++ b/pnogo/services/storage.py @@ -0,0 +1,27 @@ +from django.conf import settings +from minio import Minio + +_client = None + + +def get_client(): + global _client + if _client is None: + _client = Minio( + settings.S3_URL, + access_key=settings.S3_ACCESS, + secret_key=settings.S3_SECRET, + ) + return _client + + +def get_object(key): + return get_client().get_object(settings.S3_BUCKET, key) + + +def put_object(key, data): + get_client().put_object(settings.S3_BUCKET, key, data, -1, part_size=10 * 1024 * 1024) + + +def delete_object(key): + get_client().remove_object(settings.S3_BUCKET, key) diff --git a/pnogo/settings.py b/pnogo/settings.py new file mode 100644 index 0000000..f71deeb --- /dev/null +++ b/pnogo/settings.py @@ -0,0 +1,103 @@ +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me-in-production") + +DEBUG = os.getenv("DJANGO_DEBUG", "false").lower() in ("true", "1", "yes") + +ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "*").split(",") + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "rest_framework.authtoken", + "corsheaders", + "pnogo", +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "pnogo.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "pnogo.wsgi.application" + +# Database +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv("DB_NAME", "pnogo"), + "USER": os.getenv("DB_USER", "pnogo"), + "PASSWORD": os.getenv("DB_PASSWORD", "pnogo"), + "HOST": os.getenv("DB_HOST", "localhost"), + "PORT": os.getenv("DB_PORT", "5432"), + } +} + +# S3 / MinIO +S3_URL = os.getenv("S3_URL", "localhost:9000") +S3_BUCKET = os.getenv("S3_BUCKET", "pnogo") +S3_ACCESS = os.getenv("S3_ACCESS", "") +S3_SECRET = os.getenv("S3_SECRET", "") + +# CORS +CORS_ALLOW_ALL_ORIGINS = True + +# DRF +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "pnogo.authentication.TokenQueryParamAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], +} + +# Static files +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Internationalization +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = False +USE_TZ = True diff --git a/pnogo/urls.py b/pnogo/urls.py new file mode 100644 index 0000000..bf3595a --- /dev/null +++ b/pnogo/urls.py @@ -0,0 +1,37 @@ +from django.contrib import admin +from django.http import HttpResponse +from django.urls import include, path + +from . import views + + +def health(request): + return HttpResponse("OK") + + +api_urlpatterns = [ + # Pictures CRUD + path("pictures/", views.PictureListView.as_view(), name="picture-list"), + path("pictures/upload/", views.PictureUploadView.as_view(), name="picture-upload"), + path("pictures/count/", views.PictureCountView.as_view(), name="picture-count"), + path("pictures/random/", views.PictureRandomView.as_view(), name="picture-random"), + path("pictures/daily/", views.PictureDailyView.as_view(), name="picture-daily"), + path("pictures//", views.PictureDetailView.as_view(), name="picture-detail"), + path("pictures//image/", views.PictureImageView.as_view(), name="picture-image"), + path("pictures//stretched/", views.PictureStretchedView.as_view(), name="picture-stretched"), + path("pictures//bitmap/", views.PictureBitmapView.as_view(), name="picture-bitmap"), + path("pictures//original/", views.PictureOriginalView.as_view(), name="picture-original"), + # Cndr + path("cndr/", views.CndrListView.as_view(), name="cndr-list"), + path("cndr//", views.CndrDetailView.as_view(), name="cndr-detail"), + # Version + path("version/", views.VersionView.as_view(), name="version"), +] + +urlpatterns = [ + path("", health), + path("admin/", admin.site.urls), + path("api/", include((api_urlpatterns, "api"))), + # TODO: Remove legacy URLs once all clients have been updated. + path("", include("pnogo.legacy_urls")), +] diff --git a/pnogo/views.py b/pnogo/views.py new file mode 100644 index 0000000..ac49268 --- /dev/null +++ b/pnogo/views.py @@ -0,0 +1,224 @@ +import importlib.metadata +from datetime import date + +from django.db.models import F +from django.http import FileResponse, HttpResponse +from django.utils.text import get_valid_filename +from rest_framework import generics, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import Cndr, Picture +from .serializers import CndrSerializer, PictureSerializer, PictureUpdateSerializer, PictureUploadSerializer +from .services import images, storage + +ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg"} + + +def allowed_file(filename): + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS + + +# --- Pictures CRUD --- + + +class PictureListView(generics.ListAPIView): + serializer_class = PictureSerializer + + def get_queryset(self): + qs = Picture.objects.select_related("cndr").all() + cndr = self.request.query_params.get("cndr") + if cndr: + qs = qs.filter(cndr__name__iexact=cndr) + return qs + + +class PictureDetailView(APIView): + def get(self, request, pk): + try: + picture = Picture.objects.select_related("cndr").get(pk=pk) + except Picture.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + return Response(PictureSerializer(picture).data) + + def patch(self, request, pk): + serializer = PictureUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + updated = Picture.objects.filter(pk=pk).update(description=serializer.validated_data["description"]) + if not updated: + return Response(status=status.HTTP_404_NOT_FOUND) + return Response({"detail": "description updated"}) + + def delete(self, request, pk): + try: + picture = Picture.objects.get(pk=pk) + except Picture.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + storage.delete_object(picture.file) + picture.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class PictureUploadView(APIView): + def post(self, request): + serializer = PictureUploadSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + uploaded = serializer.validated_data["picture"] + cndr_name = serializer.validated_data["cndr"] + description = serializer.validated_data.get("description", "") + + filename = get_valid_filename(uploaded.name) + if not allowed_file(filename): + return Response({"detail": "file type not allowed"}, status=status.HTTP_400_BAD_REQUEST) + + if Picture.objects.filter(file=filename).exists(): + return Response({"detail": f"{filename} already present"}, status=status.HTTP_409_CONFLICT) + + try: + cndr = Cndr.objects.get(name__iexact=cndr_name) + except Cndr.DoesNotExist: + return Response({"detail": f"cndr '{cndr_name}' not found"}, status=status.HTTP_404_NOT_FOUND) + + storage.put_object(filename, uploaded) + picture = Picture.objects.create(file=filename, cndr=cndr, description=description) + return Response(PictureSerializer(picture).data, status=status.HTTP_201_CREATED) + + +class PictureCountView(APIView): + def get(self, request): + qs = Picture.objects.all() + cndr = request.query_params.get("cndr") + if cndr: + qs = qs.filter(cndr__name__iexact=cndr) + return Response({"count": qs.count()}) + + +# --- Image serving --- + + +class PictureImageView(APIView): + def get(self, request, pk): + try: + picture = Picture.objects.get(pk=pk) + except Picture.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + width = request.query_params.get("width") + height = request.query_params.get("height") + maxsize = request.query_params.get("maxsize", 1280) + + obj = storage.get_object(picture.file) + buf = images.resize_image(obj, width=width, height=height, maxsize=int(maxsize)) + Picture.objects.filter(pk=pk).update(sent=F("sent") + 1) + return FileResponse(buf, content_type="image/jpeg") + + +class PictureStretchedView(APIView): + def get(self, request, pk): + try: + picture = Picture.objects.get(pk=pk) + except Picture.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + maxsize = int(request.query_params.get("maxsize", 1920)) + obj = storage.get_object(picture.file) + buf = images.stretch_image(obj, maxsize=maxsize) + Picture.objects.filter(pk=pk).update(sent=F("sent") + 1) + return FileResponse(buf, content_type="image/jpeg") + + +class PictureBitmapView(APIView): + def get(self, request, pk): + try: + picture = Picture.objects.get(pk=pk) + except Picture.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + width = request.query_params.get("width", 128) + height = request.query_params.get("height", 64) + obj = storage.get_object(picture.file) + data = images.to_bitmap(obj, width=int(width), height=int(height)) + Picture.objects.filter(pk=pk).update(sent=F("sent") + 1) + return HttpResponse(data, content_type="text/plain") + + +class PictureOriginalView(APIView): + def get(self, request, pk): + try: + picture = Picture.objects.get(pk=pk) + except Picture.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + obj = storage.get_object(picture.file) + return FileResponse(obj, content_type="image/jpeg") + + +# --- Special endpoints --- + + +class PictureRandomView(APIView): + def get(self, request): + qs = Picture.objects.select_related("cndr").all() + cndr = request.query_params.get("cndr") + if cndr: + qs = qs.filter(cndr__name__iexact=cndr) + picture = qs.order_by("?").first() + if not picture: + return Response(status=status.HTTP_404_NOT_FOUND) + return Response(PictureSerializer(picture).data) + + +class PictureDailyView(APIView): + def get(self, request): + today = date.today() + + picture = Picture.objects.filter(daily_date=today).order_by("?").first() + if picture is None: + picture = Picture.objects.filter(daily_date__isnull=True).order_by("?").first() + if picture is None: + Picture.objects.all().update(daily_date=None) + picture = Picture.objects.filter(daily_date__isnull=True).order_by("?").first() + + if picture is None: + return Response(status=status.HTTP_404_NOT_FOUND) + + Picture.objects.filter(pk=picture.pk).update(daily_date=today) + picture.refresh_from_db() + return Response(PictureSerializer(picture).data) + + +# --- Cndr --- + + +class CndrListView(generics.ListCreateAPIView): + queryset = Cndr.objects.all() + serializer_class = CndrSerializer + + +class CndrDetailView(APIView): + def delete(self, request, pk): + try: + cndr = Cndr.objects.get(pk=pk) + except Cndr.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + if cndr.pictures.exists(): + return Response( + {"detail": "cannot delete: cndr still has pictures"}, + status=status.HTTP_409_CONFLICT, + ) + cndr.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +# --- Version --- + + +class VersionView(APIView): + def get(self, request): + try: + version = importlib.metadata.version("pnogo-api") + except importlib.metadata.PackageNotFoundError: + version = "dev" + return Response({"version": version}) diff --git a/pnogo/wsgi.py b/pnogo/wsgi.py new file mode 100644 index 0000000..f541328 --- /dev/null +++ b/pnogo/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pnogo.settings") + +application = get_wsgi_application() diff --git a/pnogo_api/__init__.py b/pnogo_api/__init__.py deleted file mode 100644 index 36684b8..0000000 --- a/pnogo_api/__init__.py +++ /dev/null @@ -1,51 +0,0 @@ -import os - -from flask import Flask -from flask_cors import CORS - - -def create_app(test_config=None): - # create and configure the app - app = Flask(__name__, instance_relative_config=True) - app.config.from_mapping( - DATABASE=os.getenv("DB_URI"), - S3_URL=os.getenv("S3_URL"), - S3_BUCKET=os.getenv("S3_BUCKET"), - S3_ACCESS=os.getenv("S3_ACCESS"), - S3_SECRET=os.getenv("S3_SECRET"), - ) - - if test_config is None: - # load the instance config, if it exists, when not testing - app.config.from_pyfile("config.py", silent=True) - else: - # load the test config if passed in - app.config.from_mapping(test_config) - - # ensure the instance folder exists - try: - os.makedirs(app.instance_path) - except OSError: - pass - - from . import db - - db.init_app(app) - - from pnogo_api.auth import require_app_key - - from . import auth - - app.register_blueprint(auth.bp) - - from . import common - - app.register_blueprint(common.bp) - - from . import api - - app.register_blueprint(api.bp) - - CORS(app) - - return app diff --git a/pnogo_api/api.py b/pnogo_api/api.py deleted file mode 100644 index 23efbeb..0000000 --- a/pnogo_api/api.py +++ /dev/null @@ -1,388 +0,0 @@ -import json -import random -from io import BytesIO - -from flask import Blueprint, abort, request, send_file -from markupsafe import escape -from PIL import Image, ImageOps -from werkzeug.utils import secure_filename - -from pnogo_api.auth import require_app_key -from pnogo_api.db import execute_db, query_db -from pnogo_api.s3 import delete_object, get_object, put_object - -bp = Blueprint("api", __name__) - - -@bp.route("/getall") -@require_app_key -def getall_all(): - pnogos = query_db( - "SELECT p.id, p.file, p.description, p.points, p.sent, p.daily_date, c.name FROM pictures p JOIN cndr c ON p.cndr_id=c.id", - multi=True, - ) - keys = ["id", "file", "description", "points", "sent", "daily_date", "name"] - out = [dict(zip(keys, pong)) for pong in pnogos] if pnogos else [] - return json.dumps(out) - - -@bp.route("/getall/") -@require_app_key -def getall(cndr): - pnogos = query_db( - "SELECT p.id, p.file, p.description, p.points, p.sent, CAST(p.daily_date AS TEXT) FROM pictures p JOIN cndr c ON p.cndr_id=c.id WHERE c.name LIKE %s", - [escape(cndr)], - multi=True, - ) - keys = ["id", "file", "description", "points", "sent", "daily_date"] - out = [dict(zip(keys, pong)) for pong in pnogos] if pnogos else [] - return json.dumps(out) - - -@bp.route("/getallpnoghi") -@require_app_key -def getallpnoghi(): - return getall("pongo") - - -@bp.route("/desc") -@bp.route("/descpnogo") -@require_app_key -def descpnogo(): - pnid = request.args.get("id") - desc = request.args.get("description") - execute_db( - "UPDATE pictures SET description = %s WHERE id = %s", - ( - desc, - pnid, - ), - ) - return f"done! set desc of {pnid} to: {desc}" - - -@bp.route("/info") -@bp.route("/infopnogo") -@require_app_key -def infopnogo(): - pnid = request.args.get("id") - pongo = query_db( - "SELECT p.file, p.description, p.points, p.sent, CAST(p.daily_date AS TEXT), c.name FROM pictures p JOIN cndr c ON p.cndr_id=c.id WHERE p.id = %s", - [pnid], - ) - return ( - { - "file": pongo[0], - "description": pongo[1], - "points": pongo[2], - "sent": pongo[3], - "daily_date": pongo[4], - "name": pongo[5], - } - if pongo - else abort(404) - ) - - -@bp.route("/count") -@require_app_key -def count_all(): - res = query_db("SELECT count(*) FROM pictures") - return {"count": res[0]} if res else abort(404) - - -@bp.route("/count/") -@require_app_key -def count(cndr): - res = query_db("SELECT count(*) FROM pictures JOIN cndr c ON cndr_id=c.id WHERE c.name ILIKE %s", [escape(cndr)]) - return {"count": res[0]} if res else abort(404) - - -@bp.route("/countpnogo") -@require_app_key -def countpnogo(): - return count("pongo") - - -@bp.route("/kill") -@bp.route("/killpnogo") -@require_app_key -def killpnogo(): - pnid = request.args.get("id") - morte = query_db("SELECT file FROM pictures WHERE id = %s", (pnid,)) - if morte: - execute_db("DELETE FROM pictures WHERE id = %s", (pnid,)) - delete_object(morte[0]) - return "success!
il pongo numero " + pnid + " è stato abbattuto, pace all'anima sua" - else: - return abort(404) - - -@bp.route("/get") -@bp.route("/getpnogo") -@require_app_key -def getpnogo(): - pnid = request.args.get("id") - width = request.args.get("width") - height = request.args.get("height") - maxsize = request.args.get("maxsize") or 1280 - pongo = query_db("SELECT file FROM pictures WHERE id = %s", (pnid,)) - - if pongo: - img = Image.open(get_object(pongo[0])).convert("RGB") - img = ImageOps.exif_transpose(img) - overscale = True - - if width is None and height is None: - overscale = False - if img.size[0] > img.size[1]: - width = int(maxsize) - else: - height = int(maxsize) - - if height is None: - width = int(width) - percent = int(width) / float(img.size[0]) - height = int((float(img.size[1]) * float(percent))) - elif width is None: - height = int(height) - percent = int(height) / float(img.size[1]) - width = int((float(img.size[0]) * float(percent))) - else: - width = int(width) - height = int(height) - - if overscale or img.size[0] > width or img.size[1] > height: - img = img.resize((width, height), Image.LANCZOS) - - img_io = BytesIO() - img.save(img_io, "JPEG", optimize=True, quality=85) - img_io.seek(0) - - execute_db("UPDATE pictures SET sent = sent + 1 WHERE id = %s", (pnid,)) - - return send_file(img_io, mimetype="image/jpeg") - else: - return abort(404) - - -@bp.route("/getstretched") -@bp.route("/getstretchedpnogo") -@require_app_key -def getstretchedpnogo(): - pnid = request.args.get("id") - maxsize = int(request.args.get("maxsize") or 1920) - otherside = int(random.uniform(1 / 20, 1) * maxsize) - direc = random.random() < 0.5 - width = otherside if direc else maxsize - height = maxsize if direc else otherside - pongo = query_db("SELECT file FROM pictures WHERE id = %s", (pnid,)) - - if pongo: - img = Image.open(get_object(pongo[0])).convert("RGB") - - img = img.resize((width, height), Image.LANCZOS) - - img_io = BytesIO() - img.save(img_io, "JPEG", optimize=True, quality=85) - img_io.seek(0) - - execute_db("UPDATE pictures SET sent = sent + 1 WHERE id = %s", (pnid,)) - - return send_file(img_io, mimetype="image/jpeg") - else: - return abort(404) - - -@bp.route("/getbitmap") -@require_app_key -def getbitmap(): - pnid = request.args.get("id") - width = request.args.get("width") or 128 - height = request.args.get("height") or 64 - pongo = query_db("SELECT file FROM pictures WHERE id = %s", (pnid,)) - - if pongo: - img = Image.open(get_object(pongo[0])).convert("RGB") - img = img.resize((width, height), Image.LANCZOS) - img = img.convert("1") - out = "".join("0x%02x," % b for b in img.tobytes()) - - execute_db("UPDATE pictures SET sent = sent + 1 WHERE id = %s", (pnid,)) - - return out - else: - return abort(404) - - -@bp.route("/getoriginal") -@bp.route("/getpnogoriginal") -@require_app_key -def getpnogoriginal(): - pnid = request.args.get("id") - pongo = query_db("SELECT file FROM pictures WHERE id = %s", [pnid]) - return send_file(get_object(pongo[0]), mimetype="image/jpeg") if pongo else abort(404) - - -@bp.route("/random") -@require_app_key -def random_all(): - pongo = query_db( - "SELECT p.id, p.description, p.points, c.name FROM pictures p JOIN cndr c ON cndr_id=c.id WHERE p.id IN (SELECT id FROM pictures ORDER BY RANDOM() LIMIT 1)" - ) - return ( - { - "id": pongo[0], - "description": pongo[1], - "points": pongo[2], - "name": pongo[3], - } - if pongo - else abort(404) - ) - - -@bp.route("/random/") -@require_app_key -def random_cndr(cndr): - pongo = query_db( - "SELECT id, description, points FROM pictures WHERE id IN (SELECT p.id FROM pictures p JOIN cndr c ON cndr_id=c.id WHERE c.name ILIKE %s ORDER BY RANDOM() LIMIT 1)", - [escape(cndr)], - ) - return ( - { - "id": pongo[0], - "description": pongo[1], - "points": pongo[2], - } - if pongo - else abort(404) - ) - - -@bp.route("/randompnogo") -@require_app_key -def randompnogo(): - return random_cndr("pongo") - - -@bp.route("/dailypnogo") -@bp.route("/daily") -@require_app_key -def daily(): - pnid = query_db("SELECT id FROM pictures WHERE daily_date=now()::date ORDER BY RANDOM() LIMIT 1") - if pnid is None: - pnid = query_db("SELECT id FROM pictures WHERE daily_date is null ORDER BY RANDOM() LIMIT 1") - if pnid is None: - execute_db("UPDATE pictures SET daily_date=null") - pnid = query_db("SELECT id FROM pictures WHERE daily_date is null ORDER BY RANDOM() LIMIT 1") - if pnid is not None: - execute_db("UPDATE pictures SET daily_date=now()::date WHERE id = %s", pnid) - pongo = query_db( - "SELECT p.id, p.description, p.points, c.name FROM pictures p JOIN cndr c ON cndr_id=c.id WHERE p.id=%s", - pnid, - ) - return { - "id": pongo[0], - "description": pongo[1], - "points": pongo[2], - "name": pongo[3], - } - else: - abort(404) - - -@bp.route("/update") -@require_app_key -def update(): # function now useless! - return { - "added": 0, - "removed": 0, - } - - -ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg"} - - -def allowed_file(filename): - return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS - - -@bp.route("/add/", methods=["GET", "POST"]) -@require_app_key -def add(cndr): - if request.method == "POST": - # check if the post request has the file part - if "picture" not in request.files: - return "morte: no parameter" - file = request.files["picture"] - # if user does not select file, browser also - # submit an empty part without filename - if file is None or file.filename == "": - return "morte: no file" - filename = secure_filename(file.filename) - if not allowed_file(filename): - return "morte: file type not allowed" - pnid = query_db("SELECT id FROM pictures WHERE file = %s", [filename]) - if pnid is not None: - return f"morte: {filename} already present in db with id {pnid[0]}" - cndr_id = query_db("SELECT id FROM cndr WHERE name ILIKE %s", [escape(cndr)]) - if cndr_id is None: - return f"morte: {escape(cndr)} non è presente nel db" - put_object(filename, file.stream) - execute_db("INSERT INTO pictures (file, cndr_id) VALUES (%s,%s)", (filename, cndr_id[0])) - return "done!" - - return f""" - - Upload new {cndr} -

Upload new {cndr}

-
- - -
- """ - - -@bp.route("/addpnogo", methods=["GET", "POST"]) -@require_app_key -def addpnogo(): - return add("pongo") - - -@bp.route("/create") -@require_app_key -def create(): - name = escape(request.args.get("name")) - success = execute_db("INSERT INTO cndr (name) VALUES (%s)", (name,)) - return "done" if success else f"morte: {name} already present in db" - - -@bp.route("/remove") -@require_app_key -def remove(): - name = escape(request.args.get("name")) - cnt = query_db("SELECT COUNT(p.id) FROM pictures p JOIN cndr c ON cndr_id=c.id WHERE c.name ILIKE %s", (name,)) - if cnt[0] > 0: - return f"morte: some pictures of {name} are still in the db" - - execute_db("DELETE FROM cndr WHERE name=%s", (name,)) - return "done" - - -@bp.route("/list") -@require_app_key -def listcndr(): - cndrs = query_db("SELECT id, name FROM cndr", multi=True) - keys = ["id", "name"] - out = [dict(zip(keys, cndr)) for cndr in cndrs] if cndrs else [] - return json.dumps(out) - - -@bp.route("/version") -@require_app_key -def version(): - import pkg_resources - - vrs = pkg_resources.require("pnogo_api")[0].version - return {"version": vrs} diff --git a/pnogo_api/auth.py b/pnogo_api/auth.py deleted file mode 100644 index 63c1a15..0000000 --- a/pnogo_api/auth.py +++ /dev/null @@ -1,23 +0,0 @@ -import functools - -from flask import Blueprint, abort, request - -from pnogo_api.db import query_db - -bp = Blueprint("auth", __name__) - - -def match_api_keys(key): - out = query_db("select name from auth where key = %s", [key]) - return out is not None - - -def require_app_key(f): - @functools.wraps(f) - def decorated(*args, **kwargs): - if match_api_keys(request.args.get("key")): - return f(*args, **kwargs) - else: - abort(401) - - return decorated diff --git a/pnogo_api/common.py b/pnogo_api/common.py deleted file mode 100644 index 225912f..0000000 --- a/pnogo_api/common.py +++ /dev/null @@ -1,17 +0,0 @@ -import os - -from flask import Blueprint, current_app, send_from_directory - -bp = Blueprint("common", __name__) - - -@bp.route("/favicon.ico") -def favicon(): - return send_from_directory( - os.path.join(current_app.root_path, "static"), "favicon.ico", mimetype="image/vnd.microsoft.icon" - ) - - -@bp.route("/") -def hello_world(): - return "Hello World!" diff --git a/pnogo_api/db.py b/pnogo_api/db.py deleted file mode 100644 index 409083d..0000000 --- a/pnogo_api/db.py +++ /dev/null @@ -1,45 +0,0 @@ -import psycopg -from flask import current_app, g - - -def init_app(app): - app.teardown_appcontext(close_db) - - -def get_db(): - pg_uri = current_app.config["DATABASE"] - if "db" not in g: - create = 0 # todo reimplement creation - g.db = psycopg.connect(pg_uri) - if create: - with current_app.open_resource("schema.sql") as f: - g.db.executescript(f.read().decode("utf8")) - - return g.db - - -def close_db(e=None): - db = g.pop("db", None) - - if db is not None: - db.close() - - -def query_db(query, args=(), multi=False): - cur = get_db().execute(query, args) - res = cur.fetchone() if not multi else cur.fetchall() - cur.close() - return res or None - - -def execute_db(query, args=()): - db = get_db() - try: - if type(args) is tuple: - db.execute(query, args) - else: - db.executemany(query, args) - db.commit() - except psycopg.Error: - return False - return True diff --git a/pnogo_api/run.py b/pnogo_api/run.py deleted file mode 100644 index 3dbe4a0..0000000 --- a/pnogo_api/run.py +++ /dev/null @@ -1,6 +0,0 @@ -from pnogo_api import create_app - -app = create_app() - -if __name__ == "__main__": - app.run() diff --git a/pnogo_api/s3.py b/pnogo_api/s3.py deleted file mode 100644 index 9f40b66..0000000 --- a/pnogo_api/s3.py +++ /dev/null @@ -1,25 +0,0 @@ -from flask import current_app, g -from minio import Minio - - -def get_s3(): - if "obj" not in g: - g.obj = Minio( - current_app.config["S3_URL"], - access_key=current_app.config["S3_ACCESS"], - secret_key=current_app.config["S3_SECRET"], - ) - - return g.obj - - -def get_object(key): - return get_s3().get_object(current_app.config["S3_BUCKET"], key) - - -def put_object(key, obj): - get_s3().put_object(current_app.config["S3_BUCKET"], key, obj, -1, part_size=10 * 1024 * 1024) - - -def delete_object(key): - get_s3().remove_object(current_app.config["S3_BUCKET"], key) diff --git a/pnogo_api/schema.sql b/pnogo_api/schema.sql deleted file mode 100644 index 6657568..0000000 --- a/pnogo_api/schema.sql +++ /dev/null @@ -1,22 +0,0 @@ -CREATE TABLE "pictures" ( - "id" INTEGER, - "file" TEXT NOT NULL, - "description" TEXT, - "points" INTEGER DEFAULT 0, - "sent" INTEGER DEFAULT 0, - "daily_date" TEXT, - "cndr_id" INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY("id" AUTOINCREMENT) -); - -CREATE TABLE "cndr" ( - "id" INTEGER, - "name" TEXT NOT NULL UNIQUE, - PRIMARY KEY("id" AUTOINCREMENT) -); - -CREATE TABLE "auth" ( - "key" TEXT, - "name" TEXT NOT NULL, - PRIMARY KEY("key") -); diff --git a/pnogo_api/static/favicon.ico b/pnogo_api/static/favicon.ico deleted file mode 100644 index d005da7..0000000 Binary files a/pnogo_api/static/favicon.ico and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index b189de8..c1ca83c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,20 @@ [project] name = "pnogo-api" -dynamic = ["version"] +version = "2.0.0" description = "Pnogo API" readme = "README.md" requires-python = ">=3.10" dependencies = [ - "flask==3.0.2", - "flask-cors==4.0.0", - "granian==2.7.0", - "itsdangerous==2.1.2", - "markupsafe==2.1.5", - "minio==7.2.5", - "pillow==10.2.0", - "psycopg[binary]==3.1.18", - "werkzeug==3.0.1", + "django>=5.1,<5.2", + "djangorestframework>=3.15,<3.16", + "django-cors-headers>=4.6,<5", + "django-storages[boto3]>=1.14,<2", + "granian>=2.7,<3", + "minio>=7.2,<8", + "pillow>=10.2,<11", + "psycopg[binary]>=3.1,<4", + "markupsafe>=2.1,<3", + "whitenoise>=6.8,<7", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 409fa64..f7d6ef0 100644 --- a/uv.lock +++ b/uv.lock @@ -36,12 +36,43 @@ wheels = [ ] [[package]] -name = "blinker" -version = "1.7.0" +name = "asgiref" +version = "3.11.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/13/6df5fc090ff4e5d246baf1f45fe9e5623aa8565757dfa5bd243f6a545f9e/blinker-1.7.0.tar.gz", hash = "sha256:e6820ff6fa4e4d1d8e2747c2283749c3f547e4fee112b98555cdcdae32996182", size = 28134, upload-time = "2023-11-01T22:06:01.588Z" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + +[[package]] +name = "boto3" +version = "1.42.71" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/39/774ff22347856ebbe9da350045ad5851aa0524ee6e4832fdc98b27981801/boto3-1.42.71.tar.gz", hash = "sha256:500edd2699a3f479053bbfb407b06c231d1ff1e574f7c90d269d605a6a1f8160", size = 112773, upload-time = "2026-03-18T19:44:37.551Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/2a/7f3714cbc6356a0efec525ce7a0613d581072ed6eb53eb7b9754f33db807/blinker-1.7.0-py3-none-any.whl", hash = "sha256:c3f865d4d54db7abc53758a01601cf343fe55b84c1de4e3fa910e420b438d5b9", size = 13068, upload-time = "2023-11-01T22:06:00.162Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b6/b0b93090cfc3fdbdb21a0b18961508678a2b36a42e2b3a90994ac34e102c/boto3-1.42.71-py3-none-any.whl", hash = "sha256:a89fae01c4bc948671e99440ddc0f10bc73cc72d83218656057f730df0898eab", size = 140553, upload-time = "2026-03-18T19:44:35.317Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.71" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/65/a76ced7e1c7f61880ec474e301cb63c27fd47c09ae0b7e4ccaa3cd3b04c6/botocore-1.42.71.tar.gz", hash = "sha256:6b3796c76edeb78afee325a54e23508bbd57624faea1e4aeb8f6e9c1e1e79a0f", size = 14998263, upload-time = "2026-03-18T19:44:25.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/b3/b970b62963b2391d10cd29402d3338bf49730e083aa9b2a688c8bf76af08/botocore-1.42.71-py3-none-any.whl", hash = "sha256:e6b7c611eeacbfa6a5a08cd56889b7a63eced48e99f8db9b270eeaf2c0b62796", size = 14671820, upload-time = "2026-03-18T19:44:20.997Z" }, ] [[package]] @@ -118,31 +149,59 @@ wheels = [ ] [[package]] -name = "flask" -version = "3.0.2" +name = "django" +version = "5.1.15" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "blinker" }, - { name = "click" }, - { name = "itsdangerous" }, - { name = "jinja2" }, - { name = "werkzeug" }, + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/45/1ac68964193cfcc0b0912a0f68025d5bdb54f71ba7b8716e85b959874bd0/django-5.1.15.tar.gz", hash = "sha256:46a356b5ff867bece73fc6365e081f21c569973403ee7e9b9a0316f27d0eb947", size = 10719662, upload-time = "2025-12-02T14:01:31.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/79/372e091f0eba4ddb8228245ccd1baaa140e9658711f5e3a0056e540b4c1e/django-5.1.15-py3-none-any.whl", hash = "sha256:117871e58d6eda37f09870b7d73a3d66567b03aecd515b386b1751177c413432", size = 8260901, upload-time = "2025-12-02T14:01:27.352Z" }, +] + +[[package]] +name = "django-cors-headers" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/e0/a89e8120faea1edbfca1a9b171cff7f2bf62ec860bbafcb2c2387c0317be/flask-3.0.2.tar.gz", hash = "sha256:822c03f4b799204250a7ee84b1eddc40665395333973dfb9deebfe425fefcb7d", size = 675248, upload-time = "2024-02-03T21:11:44.79Z" } + +[[package]] +name = "django-storages" +version = "1.14.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/d6/2e50e378fff0408d558f36c4acffc090f9a641fd6e084af9e54d45307efa/django_storages-1.14.6.tar.gz", hash = "sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9", size = 87587, upload-time = "2025-04-02T02:34:55.103Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/a6/aa98bfe0eb9b8b15d36cdfd03c8ca86a03968a87f27ce224fb4f766acb23/flask-3.0.2-py3-none-any.whl", hash = "sha256:3232e0e9c850d781933cf0207523d1ece087eb8d87b23777ae38456e2fbe7c6e", size = 101300, upload-time = "2024-02-03T21:11:42.661Z" }, + { url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" }, +] + +[package.optional-dependencies] +boto3 = [ + { name = "boto3" }, ] [[package]] -name = "flask-cors" -version = "4.0.0" +name = "djangorestframework" +version = "3.15.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "flask" }, + { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c8/b0/bd7130837a921497520f62023c7ba754e441dcedf959a43e6d1fd86e5451/Flask-Cors-4.0.0.tar.gz", hash = "sha256:f268522fcb2f73e2ecdde1ef45e2fd5c71cc48fe03cffb4b441c6d1b40684eb0", size = 29934, upload-time = "2023-06-26T05:38:46.364Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ce/31482eb688bdb4e271027076199e1aa8d02507e530b6d272ab8b4481557c/djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad", size = 1067420, upload-time = "2024-06-19T07:59:32.891Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/69/1e6cfb87117568a9de088c32d6258219e9d1ff7c131abf74249ef2031279/Flask_Cors-4.0.0-py2.py3-none-any.whl", hash = "sha256:bc3492bfd6368d27cfe79c7821df5a8a319e1a6d5eab277a3794be19bdc51783", size = 14273, upload-time = "2023-06-26T05:38:44.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b6/fa99d8f05eff3a9310286ae84c4059b08c301ae4ab33ae32e46e8ef76491/djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20", size = 1071235, upload-time = "2024-06-19T07:59:26.106Z" }, ] [[package]] @@ -235,24 +294,12 @@ wheels = [ ] [[package]] -name = "itsdangerous" -version = "2.1.2" +name = "jmespath" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7f/a1/d3fb83e7a61fa0c0d3d08ad0a94ddbeff3731c05212617dff3a94e097f08/itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a", size = 56143, upload-time = "2022-03-24T15:12:15.102Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/5f/447e04e828f47465eeab35b5d408b7ebaaaee207f48b7136c5a7267a30ae/itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44", size = 15749, upload-time = "2022-03-24T15:12:13.2Z" }, -] - -[[package]] -name = "jinja2" -version = "3.1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b2/5e/3a21abf3cd467d7876045335e681d276ac32492febe6d98ad89562d1a7e1/Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90", size = 268261, upload-time = "2024-01-10T23:12:21.133Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/30/6d/6de6be2d02603ab56e72997708809e8a5b0fbfee080735109b40a3564843/Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa", size = 133236, upload-time = "2024-01-10T23:12:19.504Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] [[package]] @@ -358,17 +405,19 @@ wheels = [ [[package]] name = "pnogo-api" +version = "2.0.0" source = { virtual = "." } dependencies = [ - { name = "flask" }, - { name = "flask-cors" }, + { name = "django" }, + { name = "django-cors-headers" }, + { name = "django-storages", extra = ["boto3"] }, + { name = "djangorestframework" }, { name = "granian" }, - { name = "itsdangerous" }, { name = "markupsafe" }, { name = "minio" }, { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, - { name = "werkzeug" }, + { name = "whitenoise" }, ] [package.dev-dependencies] @@ -378,15 +427,16 @@ dev = [ [package.metadata] requires-dist = [ - { name = "flask", specifier = "==3.0.2" }, - { name = "flask-cors", specifier = "==4.0.0" }, - { name = "granian", specifier = "==2.7.0" }, - { name = "itsdangerous", specifier = "==2.1.2" }, - { name = "markupsafe", specifier = "==2.1.5" }, - { name = "minio", specifier = "==7.2.5" }, - { name = "pillow", specifier = "==10.2.0" }, - { name = "psycopg", extras = ["binary"], specifier = "==3.1.18" }, - { name = "werkzeug", specifier = "==3.0.1" }, + { name = "django", specifier = ">=5.1,<5.2" }, + { name = "django-cors-headers", specifier = ">=4.6,<5" }, + { name = "django-storages", extras = ["boto3"], specifier = ">=1.14,<2" }, + { name = "djangorestframework", specifier = ">=3.15,<3.16" }, + { name = "granian", specifier = ">=2.7,<3" }, + { name = "markupsafe", specifier = ">=2.1,<3" }, + { name = "minio", specifier = ">=7.2,<8" }, + { name = "pillow", specifier = ">=10.2,<11" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.1,<4" }, + { name = "whitenoise", specifier = ">=6.8,<7" }, ] [package.metadata.requires-dev] @@ -481,6 +531,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/a7/5aa0596f7fc710fd55b4e6bbb025fedacfec929465a618f20e61ebf7df76/pycryptodome-3.20.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c18b381553638414b38705f07d1ef0a7cf301bc78a5f9bc17a957eb19446834b", size = 1741193, upload-time = "2024-01-10T11:30:48.084Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "ruff" version = "0.15.0" @@ -506,6 +568,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, ] +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + [[package]] name = "typing-extensions" version = "4.10.0" @@ -534,13 +626,10 @@ wheels = [ ] [[package]] -name = "werkzeug" -version = "3.0.1" +name = "whitenoise" +version = "6.12.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0d/cc/ff1904eb5eb4b455e442834dabf9427331ac0fa02853bf83db817a7dd53d/werkzeug-3.0.1.tar.gz", hash = "sha256:507e811ecea72b18a404947aded4b3390e1db8f826b494d76550ef45bb3b1dcc", size = 801436, upload-time = "2023-10-24T20:57:50.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/fc/254c3e9b5feb89ff5b9076a23218dafbc99c96ac5941e900b71206e6313b/werkzeug-3.0.1-py3-none-any.whl", hash = "sha256:90a285dc0e42ad56b34e696398b8122ee4c681833fb35b8334a095d82c56da10", size = 226669, upload-time = "2023-10-24T20:57:47.326Z" }, + { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" }, ]