From ed53b55d3634f5af964b5b48923e54b523fb4c12 Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Thu, 15 Dec 2022 11:42:13 +0100 Subject: [PATCH 01/13] Setup API for wishlists and packages Ref: #30857 --- pyproject.toml | 1 + requirements/base.txt | 2 + requirements/dev.txt | 2 + santa_unchained/api_utils/__init__.py | 0 santa_unchained/api_utils/mixins.py | 15 +++ santa_unchained/settings/base.py | 9 ++ santa_unchained/urls.py | 4 +- santa_unchained/wishes/admin.py | 8 ++ santa_unchained/wishes/api_views.py | 85 +++++++++++++ santa_unchained/wishes/constants.py | 12 ++ ...ress_house_number_wishlist_age_and_more.py | 93 ++++++++++++++ santa_unchained/wishes/models.py | 49 +++++++- santa_unchained/wishes/serializers.py | 115 ++++++++++++++++++ santa_unchained/wishes/urls.py | 12 ++ 14 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 santa_unchained/api_utils/__init__.py create mode 100644 santa_unchained/api_utils/mixins.py create mode 100644 santa_unchained/wishes/api_views.py create mode 100644 santa_unchained/wishes/migrations/0004_address_house_number_wishlist_age_and_more.py create mode 100644 santa_unchained/wishes/serializers.py diff --git a/pyproject.toml b/pyproject.toml index 218c050..b6ee640 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "djangorestframework", "django-filter", "drf-spectacular", + "djangorestframework-camel-case", ] [project.optional-dependencies] diff --git a/requirements/base.txt b/requirements/base.txt index 5883b7b..3eb6426 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -39,6 +39,8 @@ djangorestframework==3.14.0 # via # drf-spectacular # santa-unchained (pyproject.toml) +djangorestframework-camel-case==1.3.0 + # via santa-unchained (pyproject.toml) drf-spectacular==0.24.2 # via santa-unchained (pyproject.toml) environs==9.5.0 diff --git a/requirements/dev.txt b/requirements/dev.txt index 8118f53..39b5d97 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -47,6 +47,8 @@ djangorestframework==3.14.0 # via # drf-spectacular # santa-unchained (pyproject.toml) +djangorestframework-camel-case==1.3.0 + # via santa-unchained (pyproject.toml) drf-spectacular==0.24.2 # via santa-unchained (pyproject.toml) environs==9.5.0 diff --git a/santa_unchained/api_utils/__init__.py b/santa_unchained/api_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/santa_unchained/api_utils/mixins.py b/santa_unchained/api_utils/mixins.py new file mode 100644 index 0000000..d34a89e --- /dev/null +++ b/santa_unchained/api_utils/mixins.py @@ -0,0 +1,15 @@ +from typing import Dict + +from rest_framework.serializers import Serializer + + +class ActionSerializerClassMixin: + action_serializer_class: Dict[str, Serializer] = {} + + def get_serializer_class(self): + if ( + self.action_serializer_class + and self.action in self.action_serializer_class # type: ignore + ): + return self.action_serializer_class[self.action] # type: ignore + return super().get_serializer_class() # type: ignore diff --git a/santa_unchained/settings/base.py b/santa_unchained/settings/base.py index 31718ad..54a4735 100644 --- a/santa_unchained/settings/base.py +++ b/santa_unchained/settings/base.py @@ -131,6 +131,15 @@ ), "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_RENDERER_CLASSES": ( + "djangorestframework_camel_case.render.CamelCaseJSONRenderer", + "djangorestframework_camel_case.render.CamelCaseBrowsableAPIRenderer", + ), + "DEFAULT_PARSER_CLASSES": ( + "djangorestframework_camel_case.parser.CamelCaseFormParser", + "djangorestframework_camel_case.parser.CamelCaseMultiPartParser", + "djangorestframework_camel_case.parser.CamelCaseJSONParser", + ), } # ------------- SWAGGER ------------ diff --git a/santa_unchained/urls.py b/santa_unchained/urls.py index 48b3c63..3409d9a 100644 --- a/santa_unchained/urls.py +++ b/santa_unchained/urls.py @@ -9,13 +9,13 @@ ) from santa_unchained.accounts import urls as accounts_urls -from santa_unchained.wishes import urls as wishes_urls +from santa_unchained.wishes.urls import router as wishes_router urlpatterns = [ path("admin/", admin.site.urls), path("__debug__/", include("debug_toolbar.urls")), path("", include(accounts_urls)), - path("wishes/", include(wishes_urls)), + path("api/", include(wishes_router.urls)), path("api/schema/", SpectacularAPIView.as_view(), name="schema"), path( "api/doc/", SpectacularRedocView.as_view(url_name="schema"), name="schema-redoc" diff --git a/santa_unchained/wishes/admin.py b/santa_unchained/wishes/admin.py index 55942a0..98af4a9 100644 --- a/santa_unchained/wishes/admin.py +++ b/santa_unchained/wishes/admin.py @@ -6,6 +6,7 @@ from santa_unchained.wishes.forms import WishListElfAdminForm from santa_unchained.wishes.models import ( Address, + Package, WishListAccepted, WishListDelivered, WishListItem, @@ -121,3 +122,10 @@ class WishListDeliveredAdmin(WishListBaseAdmin): @admin.register(Address) class AddressAdmin(admin.ModelAdmin): list_display = ("street", "city", "post_code", "city", "country", "lat", "lng") + + +@admin.register(Package) +class PackageAdmin(admin.ModelAdmin): + list_display = ("wish_list", "status") + list_filter = ("status",) + search_fields = ("wish_list__name", "wish_list__email") diff --git a/santa_unchained/wishes/api_views.py b/santa_unchained/wishes/api_views.py new file mode 100644 index 0000000..68cd72a --- /dev/null +++ b/santa_unchained/wishes/api_views.py @@ -0,0 +1,85 @@ +from django.db.transaction import atomic +from drf_spectacular.utils import extend_schema +from rest_framework import mixins, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response + +from santa_unchained.api_utils.mixins import ActionSerializerClassMixin +from santa_unchained.wishes.constants import PackageStatuses, WishListStatuses +from santa_unchained.wishes.models import Package, WishList +from santa_unchained.wishes.serializers import ( + PackageDetailSerializer, + PackageDistributionSerializer, + PackageSendBodySerializer, + PackageSerializer, + WishListDetailSerializer, + WishListSerializer, +) + + +class WishListViewSet( + ActionSerializerClassMixin, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + queryset = WishList.objects.all() + serializer_class = WishListSerializer + action_serializer_class = { + "retrieve": WishListDetailSerializer, + "accept": WishListDetailSerializer, + "reject": WishListDetailSerializer, + } + + @atomic + @extend_schema(request=None) + @action(detail=True, methods=["post"]) + def accept(self, request, pk=None): + wish_list = self.get_object() + wish_list.status = WishListStatuses.ACCEPTED + wish_list.save(update_fields=["status"]) + Package.objects.create(wish_list=wish_list) + return Response(self.get_serializer(wish_list).data) + + @atomic + @extend_schema(request=None) + @action(detail=True, methods=["post"]) + def reject(self, request, pk=None): + wish_list = self.get_object() + wish_list.status = WishListStatuses.REJECTED + wish_list.save(update_fields=["status"]) + return Response(self.get_serializer(wish_list).data) + + +class PackageViewSet( + ActionSerializerClassMixin, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + queryset = Package.objects.all() + serializer_class = PackageSerializer + action_serializer_class = { + "retrieve": PackageDetailSerializer, + "send": PackageDetailSerializer, + } + + @atomic + @extend_schema( + operation_id="packages_send", + request=PackageSendBodySerializer, + ) + @action(detail=True, methods=["post"]) + def send(self, request, pk=None): + package = self.get_object() + package.status = PackageStatuses.SENT + package.save(update_fields=["status"]) + return Response(self.get_serializer(package).data) + + +class PackageDistributionViewSet( + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + queryset = Package.objects.all() + serializer_class = PackageDistributionSerializer diff --git a/santa_unchained/wishes/constants.py b/santa_unchained/wishes/constants.py index b36e078..e6cf822 100644 --- a/santa_unchained/wishes/constants.py +++ b/santa_unchained/wishes/constants.py @@ -12,3 +12,15 @@ class WishListStatuses(models.TextChoices): @classmethod def for_elf(cls): return [cls.ACCEPTED, cls.READY_FOR_SHIPPING] + + +class PackageStatuses(models.TextChoices): + NEW = "new", _("New") + SENT = "sent", _("Sent") + DELIVERED = "delivered", _("Delivered") + + +class PackageSizes(models.TextChoices): + SMALL = "SMALL", _("Small") + MEDIUM = "MEDIUM", _("Medium") + LARGE = "LARGE", _("Large") diff --git a/santa_unchained/wishes/migrations/0004_address_house_number_wishlist_age_and_more.py b/santa_unchained/wishes/migrations/0004_address_house_number_wishlist_age_and_more.py new file mode 100644 index 0000000..4a7dce3 --- /dev/null +++ b/santa_unchained/wishes/migrations/0004_address_house_number_wishlist_age_and_more.py @@ -0,0 +1,93 @@ +# Generated by Django 4.1.3 on 2022-12-15 10:36 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("wishes", "0003_alter_wishlist_options_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="address", + name="house_number", + field=models.CharField( + blank=True, max_length=10, verbose_name="House number" + ), + ), + migrations.AddField( + model_name="wishlist", + name="age", + field=models.PositiveSmallIntegerField(default=10, verbose_name="Age"), + ), + migrations.AddField( + model_name="wishlist", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, + default=django.utils.timezone.now, + verbose_name="Created at", + ), + preserve_default=False, + ), + migrations.CreateModel( + name="Package", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("new", "New"), + ("sent", "Sent"), + ("delivered", "Delivered"), + ], + default="new", + help_text="Status of a package.", + max_length=20, + verbose_name="Status", + ), + ), + ( + "size", + models.CharField( + blank=True, + choices=[ + ("SMALL", "Small"), + ("MEDIUM", "Medium"), + ("LARGE", "Large"), + ], + max_length=20, + verbose_name="Size", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created at"), + ), + ( + "wish_list", + models.OneToOneField( + help_text="A relevant wish list the package belongs to.", + on_delete=django.db.models.deletion.CASCADE, + related_name="package", + related_query_name="package", + to="wishes.wishlist", + verbose_name="A wish list", + ), + ), + ], + ), + ] diff --git a/santa_unchained/wishes/models.py b/santa_unchained/wishes/models.py index 396d0ff..f07df20 100644 --- a/santa_unchained/wishes/models.py +++ b/santa_unchained/wishes/models.py @@ -1,9 +1,15 @@ +import random + import requests from django.db import models from django.utils.translation import gettext_lazy as _ from django_extensions.db.fields import AutoSlugField -from santa_unchained.wishes.constants import WishListStatuses +from santa_unchained.wishes.constants import ( + PackageSizes, + PackageStatuses, + WishListStatuses, +) from santa_unchained.wishes.managers import ( AcceptedWishListManager, DeliveredWishListManager, @@ -19,6 +25,8 @@ class Address(models.Model): """An address provided by a child where Santa should deliver presents.""" + house_number = models.CharField(_("House number"), max_length=10, blank=True) + street = models.CharField( max_length=100, verbose_name=_("Street"), @@ -79,6 +87,10 @@ def find_lat_lng(self): lng = data[0].get("lon", 0) return (lat, lng) + @property + def to_string(self): + return f"{self.street}, {self.post_code} {self.city}, {self.country}" + class WishList(models.Model): """A wish list sent by a child to Santa.""" @@ -88,6 +100,7 @@ class WishList(models.Model): verbose_name=_("Name"), help_text=_("Name of a child."), ) + age = models.PositiveSmallIntegerField(_("Age"), default=10) email = models.EmailField( verbose_name=_("Email address"), help_text=_("Email of a child."), @@ -108,6 +121,8 @@ class WishList(models.Model): help_text=_("An automatically generated slug (can be used to construct URLs)."), populate_from="name", ) + created_at = models.DateTimeField(_("Created at"), auto_now_add=True) + address = models.ForeignKey( Address, verbose_name=_("A child's address"), @@ -127,6 +142,10 @@ def __str__(self): def number_of_objects(cls): return cls.objects.count() + @property + def kindness(self): + return random.randint(1, 5) + class WishListNew(WishList): objects = NewWishListManager() @@ -197,3 +216,31 @@ class WishListItem(models.Model): def __str__(self): return _("Wish list item: {}").format(self.name) + + +class Package(models.Model): + """A package sent by Santa to a child.""" + + wish_list = models.OneToOneField( + WishList, + verbose_name=_("A wish list"), + help_text=_("A relevant wish list the package belongs to."), + on_delete=models.CASCADE, + related_name="package", + related_query_name="package", + ) + status = models.CharField( + max_length=20, + choices=PackageStatuses.choices, + default=PackageStatuses.NEW, + verbose_name=_("Status"), + help_text=_("Status of a package."), + ) + size = models.CharField( + _("Size"), max_length=20, choices=PackageSizes.choices, blank=True + ) + + created_at = models.DateTimeField(_("Created at"), auto_now_add=True) + + def __str__(self): + return _("Package for {}").format(self.wish_list.name) diff --git a/santa_unchained/wishes/serializers.py b/santa_unchained/wishes/serializers.py new file mode 100644 index 0000000..116b9b9 --- /dev/null +++ b/santa_unchained/wishes/serializers.py @@ -0,0 +1,115 @@ +import random + +from rest_framework import serializers + +from santa_unchained.wishes.constants import PackageSizes +from santa_unchained.wishes.models import Package, WishList, WishListItem + + +class WishListSerializer(serializers.ModelSerializer): + country = serializers.CharField(source="address.country") + city = serializers.CharField(source="address.city") + + class Meta: + model = WishList + fields = ("id", "name", "kindness", "country", "city", "created_at") + + +class WishListItemSerializer(serializers.ModelSerializer): + class Meta: + model = WishListItem + fields = ("id", "name") + + +class WishListDetailSerializer(WishListSerializer): + country = serializers.CharField(source="address.country") + city = serializers.CharField(source="address.city") + post_code = serializers.CharField(source="address.post_code") + address = serializers.SerializerMethodField() + items = WishListItemSerializer(many=True) + + class Meta: + model = WishList + fields = ( + "id", + "age", + "name", + "kindness", + "country", + "city", + "post_code", + "address", + "created_at", + "items", + ) + + def get_address(self, obj): + return f"{obj.address.street} {obj.address.house_number}" + + +class PackageSendBodySerializer(serializers.Serializer): + size = serializers.ChoiceField(PackageSizes.choices) + + +class PackageSerializer(serializers.ModelSerializer): + wish_list_id = serializers.IntegerField(source="wish_list.id") + name = serializers.CharField(source="wish_list.name") + kindness = serializers.IntegerField(source="wish_list.kindness") + country = serializers.CharField(source="wish_list.address.country") + city = serializers.CharField(source="wish_list.address.city") + + class Meta: + model = Package + fields = ( + "id", + "wish_list_id", + "name", + "kindness", + "status", + "country", + "city", + "created_at", + ) + + +class PackageDetailSerializer(PackageSerializer): + age = serializers.IntegerField(source="wish_list.age") + items = serializers.SerializerMethodField() + address = serializers.SerializerMethodField() + post_code = serializers.CharField(source="wish_list.address.post_code") + + class Meta: + model = Package + fields = ( + "wish_list_id", + "name", + "age", + "kindness", + "country", + "city", + "post_code", + "address", + ) + ("id", "items", "created_at", "status", "size") + + @staticmethod + def get_items(obj): + return WishListItemSerializer(obj.wish_list.items.all(), many=True).data + + @staticmethod + def get_address(obj): + return f"{obj.wish_list.address.street}, {obj.wish_list.address.house_number}" + + +class PackageDistributionSerializer(serializers.ModelSerializer): + name = serializers.CharField(source="wish_list.name") + address = serializers.SerializerMethodField() + postcode = serializers.CharField(source="wish_list.address.post_code") + city = serializers.CharField(source="wish_list.address.city") + country = serializers.CharField(source="wish_list.address.country") + + class Meta: + model = Package + fields = ("id", "name", "address", "postcode", "city", "country") + + def get_address(self, obj): + return f"{obj.wish_list.address.street}, {obj.wish_list.address.house_number}" diff --git a/santa_unchained/wishes/urls.py b/santa_unchained/wishes/urls.py index dda82b4..3429beb 100644 --- a/santa_unchained/wishes/urls.py +++ b/santa_unchained/wishes/urls.py @@ -1,5 +1,11 @@ from django.urls import path +from rest_framework.routers import DefaultRouter +from santa_unchained.wishes.api_views import ( + PackageDistributionViewSet, + PackageViewSet, + WishListViewSet, +) from santa_unchained.wishes.views import ( WishListDetailView, WishListFormView, @@ -7,6 +13,12 @@ ) app_name = "wishes" +router = DefaultRouter() +router.register(r"wishlists", WishListViewSet, basename="wishlist") +router.register(r"packages", PackageViewSet, basename="package") +router.register( + r"distribution", PackageDistributionViewSet, basename="package-distribution" +) urlpatterns = [ path("", WishListFormView.as_view(), name="wishlist"), From 46b4506aa577334086ecc5ba91a444ad3b2f07d5 Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Thu, 15 Dec 2022 20:22:15 +0100 Subject: [PATCH 02/13] Corrections after review Ref: #30857 --- pyproject.toml | 1 + requirements/base.txt | 4 ++++ requirements/dev.txt | 4 ++++ santa_unchained/wishes/api_views.py | 8 +++----- santa_unchained/wishes/models.py | 11 ++++++----- santa_unchained/wishes/serializers.py | 1 + 6 files changed, 19 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b6ee640..4cae48a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "django-filter", "drf-spectacular", "djangorestframework-camel-case", + "drf-mixin-tools", ] [project.optional-dependencies] diff --git a/requirements/base.txt b/requirements/base.txt index 3eb6426..dd97b04 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -25,6 +25,7 @@ django==4.1.3 # django-extensions # django-filter # djangorestframework + # drf-mixin-tools # drf-spectacular # santa-unchained (pyproject.toml) django-admin-display==1.3.0 @@ -37,10 +38,13 @@ django-filter==22.1 # via santa-unchained (pyproject.toml) djangorestframework==3.14.0 # via + # drf-mixin-tools # drf-spectacular # santa-unchained (pyproject.toml) djangorestframework-camel-case==1.3.0 # via santa-unchained (pyproject.toml) +drf-mixin-tools==0.0.3 + # via santa-unchained (pyproject.toml) drf-spectacular==0.24.2 # via santa-unchained (pyproject.toml) environs==9.5.0 diff --git a/requirements/dev.txt b/requirements/dev.txt index 39b5d97..2714e86 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -33,6 +33,7 @@ django==4.1.3 # django-extensions # django-filter # djangorestframework + # drf-mixin-tools # drf-spectacular # santa-unchained (pyproject.toml) django-admin-display==1.3.0 @@ -45,10 +46,13 @@ django-filter==22.1 # via santa-unchained (pyproject.toml) djangorestframework==3.14.0 # via + # drf-mixin-tools # drf-spectacular # santa-unchained (pyproject.toml) djangorestframework-camel-case==1.3.0 # via santa-unchained (pyproject.toml) +drf-mixin-tools==0.0.3 + # via santa-unchained (pyproject.toml) drf-spectacular==0.24.2 # via santa-unchained (pyproject.toml) environs==9.5.0 diff --git a/santa_unchained/wishes/api_views.py b/santa_unchained/wishes/api_views.py index 68cd72a..bb6d338 100644 --- a/santa_unchained/wishes/api_views.py +++ b/santa_unchained/wishes/api_views.py @@ -1,10 +1,10 @@ from django.db.transaction import atomic +from drf_mixin_tools.mixins import ActionSerializerClassMixin from drf_spectacular.utils import extend_schema from rest_framework import mixins, viewsets from rest_framework.decorators import action from rest_framework.response import Response -from santa_unchained.api_utils.mixins import ActionSerializerClassMixin from santa_unchained.wishes.constants import PackageStatuses, WishListStatuses from santa_unchained.wishes.models import Package, WishList from santa_unchained.wishes.serializers import ( @@ -36,8 +36,7 @@ class WishListViewSet( @action(detail=True, methods=["post"]) def accept(self, request, pk=None): wish_list = self.get_object() - wish_list.status = WishListStatuses.ACCEPTED - wish_list.save(update_fields=["status"]) + wish_list.set_status(WishListStatuses.ACCEPTED) Package.objects.create(wish_list=wish_list) return Response(self.get_serializer(wish_list).data) @@ -46,8 +45,7 @@ def accept(self, request, pk=None): @action(detail=True, methods=["post"]) def reject(self, request, pk=None): wish_list = self.get_object() - wish_list.status = WishListStatuses.REJECTED - wish_list.save(update_fields=["status"]) + wish_list.set_status(WishListStatuses.REJECTED) return Response(self.get_serializer(wish_list).data) diff --git a/santa_unchained/wishes/models.py b/santa_unchained/wishes/models.py index f07df20..04cd5a7 100644 --- a/santa_unchained/wishes/models.py +++ b/santa_unchained/wishes/models.py @@ -85,11 +85,7 @@ def find_lat_lng(self): return default_lat_lng lat = data[0].get("lat", 0) lng = data[0].get("lon", 0) - return (lat, lng) - - @property - def to_string(self): - return f"{self.street}, {self.post_code} {self.city}, {self.country}" + return lat, lng class WishList(models.Model): @@ -146,6 +142,11 @@ def number_of_objects(cls): def kindness(self): return random.randint(1, 5) + def set_status(self, status, save=True): + self.status = status + if save: + self.save(update_fields=["status"]) + class WishListNew(WishList): objects = NewWishListManager() diff --git a/santa_unchained/wishes/serializers.py b/santa_unchained/wishes/serializers.py index 116b9b9..8cb94d0 100644 --- a/santa_unchained/wishes/serializers.py +++ b/santa_unchained/wishes/serializers.py @@ -41,6 +41,7 @@ class Meta: "address", "created_at", "items", + "status", ) def get_address(self, obj): From 33b59cd11531999b77ed2974a8661deb3015eafd Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Thu, 15 Dec 2022 20:26:30 +0100 Subject: [PATCH 03/13] Add lat and lng to distribution serializer Ref: #30857 --- santa_unchained/wishes/serializers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/santa_unchained/wishes/serializers.py b/santa_unchained/wishes/serializers.py index 8cb94d0..df151a0 100644 --- a/santa_unchained/wishes/serializers.py +++ b/santa_unchained/wishes/serializers.py @@ -107,10 +107,12 @@ class PackageDistributionSerializer(serializers.ModelSerializer): postcode = serializers.CharField(source="wish_list.address.post_code") city = serializers.CharField(source="wish_list.address.city") country = serializers.CharField(source="wish_list.address.country") + lat = serializers.DecimalField(source="wish_list.address.lat", max_digits=9, decimal_places=6) + lng = serializers.DecimalField(source="wish_list.address.lng", max_digits=9, decimal_places=6) class Meta: model = Package - fields = ("id", "name", "address", "postcode", "city", "country") + fields = ("id", "name", "address", "postcode", "city", "country", "lat", "lng") def get_address(self, obj): return f"{obj.wish_list.address.street}, {obj.wish_list.address.house_number}" From af35598101863836fa67dc44b3e960e73eefe278 Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Fri, 16 Dec 2022 09:16:44 +0100 Subject: [PATCH 04/13] Add comment Ref: #30857 --- santa_unchained/wishes/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/santa_unchained/wishes/serializers.py b/santa_unchained/wishes/serializers.py index df151a0..72e10f8 100644 --- a/santa_unchained/wishes/serializers.py +++ b/santa_unchained/wishes/serializers.py @@ -81,7 +81,7 @@ class PackageDetailSerializer(PackageSerializer): class Meta: model = Package - fields = ( + fields = ( # user info + package info "wish_list_id", "name", "age", From f10d187d8e162ea15a6e8a3141712ebd39d8ec1a Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Fri, 16 Dec 2022 09:28:19 +0100 Subject: [PATCH 05/13] Create prod requirements Ref: #30857 --- Makefile | 1 + pyproject.toml | 3 ++ requirements/prod.txt | 118 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 requirements/prod.txt diff --git a/Makefile b/Makefile index f44b685..2f826ab 100644 --- a/Makefile +++ b/Makefile @@ -66,6 +66,7 @@ autoformatters: ## runs auto formatters pip-compile: python -m piptools compile --resolver=backtracking -o requirements/base.txt pyproject.toml python -m piptools compile --resolver=backtracking --extra dev -o requirements/dev.txt pyproject.toml + python -m piptools compile --resolver=backtracking --extra prod -o requirements/prod.txt pyproject.toml bootstrap: ## bootstrap project pip install -r requirements/dev.txt diff --git a/pyproject.toml b/pyproject.toml index 4cae48a..6a9426e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,9 @@ dev = [ "factory_boy", "pytest-factoryboy", ] +prod = [ +"gunicorn", +] [tool.black] max-line-length = 88 diff --git a/requirements/prod.txt b/requirements/prod.txt new file mode 100644 index 0000000..60f79a6 --- /dev/null +++ b/requirements/prod.txt @@ -0,0 +1,118 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --extra=prod --output-file=requirements/prod.txt --resolver=backtracking pyproject.toml +# +asgiref==3.5.2 + # via django +asttokens==2.2.1 + # via stack-data +attrs==22.1.0 + # via jsonschema +backcall==0.2.0 + # via ipython +certifi==2022.12.7 + # via requests +charset-normalizer==2.1.1 + # via requests +decorator==5.1.1 + # via ipython +django==4.1.4 + # via + # django-admin-display + # django-debug-toolbar + # django-extensions + # django-filter + # djangorestframework + # drf-mixin-tools + # drf-spectacular + # santa-unchained (pyproject.toml) +django-admin-display==1.3.0 + # via santa-unchained (pyproject.toml) +django-debug-toolbar==3.8.1 + # via santa-unchained (pyproject.toml) +django-extensions==3.2.1 + # via santa-unchained (pyproject.toml) +django-filter==22.1 + # via santa-unchained (pyproject.toml) +djangorestframework==3.14.0 + # via + # drf-mixin-tools + # drf-spectacular + # santa-unchained (pyproject.toml) +djangorestframework-camel-case==1.3.0 + # via santa-unchained (pyproject.toml) +drf-mixin-tools==0.0.3 + # via santa-unchained (pyproject.toml) +drf-spectacular==0.25.0 + # via santa-unchained (pyproject.toml) +environs==9.5.0 + # via santa-unchained (pyproject.toml) +executing==1.2.0 + # via stack-data +gunicorn==20.1.0 + # via santa-unchained (pyproject.toml) +idna==3.4 + # via requests +inflection==0.5.1 + # via drf-spectacular +ipython==8.7.0 + # via santa-unchained (pyproject.toml) +jedi==0.18.2 + # via ipython +jsonschema==4.17.3 + # via drf-spectacular +marshmallow==3.19.0 + # via environs +matplotlib-inline==0.1.6 + # via ipython +packaging==22.0 + # via marshmallow +parso==0.8.3 + # via jedi +pexpect==4.8.0 + # via ipython +pickleshare==0.7.5 + # via ipython +prompt-toolkit==3.0.36 + # via ipython +psycopg2-binary==2.9.5 + # via santa-unchained (pyproject.toml) +ptyprocess==0.7.0 + # via pexpect +pure-eval==0.2.2 + # via stack-data +pygments==2.13.0 + # via ipython +pyrsistent==0.19.2 + # via jsonschema +python-dotenv==0.21.0 + # via environs +pytz==2022.6 + # via djangorestframework +pyyaml==6.0 + # via drf-spectacular +requests==2.28.1 + # via santa-unchained (pyproject.toml) +six==1.16.0 + # via asttokens +sqlparse==0.4.3 + # via + # django + # django-debug-toolbar +stack-data==0.6.2 + # via ipython +traitlets==5.7.1 + # via + # ipython + # matplotlib-inline +uritemplate==4.1.1 + # via drf-spectacular +urllib3==1.26.13 + # via requests +wcwidth==0.2.5 + # via prompt-toolkit + +# The following packages are considered to be unsafe in a requirements file: +# setuptools From d221a9ffefd61bc6aabd321dc18985d0d7fc963a Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Fri, 16 Dec 2022 09:36:41 +0100 Subject: [PATCH 06/13] Adjust static root Ref: #30857 --- santa_unchained/settings/production.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/santa_unchained/settings/production.py b/santa_unchained/settings/production.py index 5702705..b632ceb 100644 --- a/santa_unchained/settings/production.py +++ b/santa_unchained/settings/production.py @@ -41,5 +41,5 @@ } # ------------- STATIC ------------- -STATIC_ROOT = BASE_DIR.parent.joinpath("public") +STATIC_ROOT = BASE_DIR.parent.joinpath("static") MEDIA_ROOT = BASE_DIR.parent.joinpath("media") From 754396908e40154d1dcd4828c5f5e4978f9d97a0 Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Fri, 16 Dec 2022 09:50:12 +0100 Subject: [PATCH 07/13] Adjust fixtures Ref: #30857 --- fixtures/wishes.json | 108 ++++++++++++++++++++++++------------------- 1 file changed, 61 insertions(+), 47 deletions(-) diff --git a/fixtures/wishes.json b/fixtures/wishes.json index aa3d42a..49cae32 100644 --- a/fixtures/wishes.json +++ b/fixtures/wishes.json @@ -1,77 +1,91 @@ [ -{ + { "model": "wishes.address", "pk": 1, "fields": { - "street": "Lea", - "post_code": "114", - "city": "Kraków", - "country": "Polska", - "lng": "19.902106", - "lat": "50.071832" + "street": "Lea", + "house_number": "114", + "post_code": "30-133", + "city": "Kraków", + "country": "Polska", + "lng": "19.902106", + "lat": "50.071832" } -}, -{ + }, + { "model": "wishes.address", "pk": 2, "fields": { - "street": "Jana Matejki 1/5", - "post_code": "00-481", - "city": "Warszawa", - "country": "Polska", - "lng": "21.025711", - "lat": "52.224361" + "street": "Jana Matejki", + "house_number": "1", + "post_code": "00-481", + "city": "Warszawa", + "country": "Polska", + "lng": "21.025711", + "lat": "52.224361" } -}, -{ + }, + { "model": "wishes.wishlist", "pk": 1, "fields": { - "name": "Joe Kernel", - "email": "kernel@example.com", - "content": "Dear Santa and helpers, \r\nI have been very good this year. I am expecting a little sister. I don’t want her. Momy says her will be fun. I heard girls stink. I will trade you my sister when she comes from the stork for a elf. I want a race car and a Garage set for Christmas. There will be sugar cookies and burritos waiting for you. \r\n\r\nThank you, Santa", - "status": "NEW", - "slug": "lisa-smith", - "address": 1 + "name": "Joe Kernel", + "email": "kernel@example.com", + "content": "Dear Santa and helpers, \r\nI have been very good this year. I am expecting a little sister. I don’t want her. Momy says her will be fun. I heard girls stink. I will trade you my sister when she comes from the stork for a elf. I want a race car and a Garage set for Christmas. There will be sugar cookies and burritos waiting for you. \r\n\r\nThank you, Santa", + "status": "ACCEPTED", + "slug": "lisa-smith", + "address": 1, + "created_at": "2022-12-15T09:08:52.796Z" } -}, -{ + }, + { "model": "wishes.wishlist", "pk": 2, "fields": { - "name": "Michael", - "email": "child@example.com", - "content": "Dear Santa, \r\nI want new car for my dad.\r\n\r\nThanks", - "status": "NEW", - "slug": "michael", - "address": 2 + "name": "Michael", + "email": "child@example.com", + "content": "Dear Santa, \r\nI want new car for my dad.\r\n\r\nThanks", + "status": "NEW", + "slug": "michael", + "address": 2, + "created_at": "2022-12-15T06:48:28.102Z" } -}, -{ + }, + { "model": "wishes.wishlistitem", "pk": 1, "fields": { - "wish_list": 1, - "name": "Lego blocks", - "approved": false + "wish_list": 1, + "name": "Lego blocks", + "approved": false } -}, -{ + }, + { "model": "wishes.wishlistitem", "pk": 2, "fields": { - "wish_list": 1, - "name": "Sister", - "approved": false + "wish_list": 1, + "name": "Sister", + "approved": false } -}, -{ + }, + { "model": "wishes.wishlistitem", "pk": 3, "fields": { - "wish_list": 2, - "name": "car", - "approved": true + "wish_list": 2, + "name": "car", + "approved": true } -} + }, + { + "model": "wishes.package", + "pk": 1, + "fields": { + "wish_list": 1, + "status": "sent", + "size": "LARGE", + "created_at": "2022-12-15T09:08:52.796Z" + } + } ] From 923c5c3ce50aa954e30b74ca4f31c8c05e03b48d Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Fri, 16 Dec 2022 13:52:11 +0100 Subject: [PATCH 08/13] Configure fabric Ref: #30857 --- fabconfig/__init__.py | 9 +++++ fabconfig/base.py | 56 ++++++++++++++++++++++++++++ fabconfig/santa_unchained.py | 21 +++++++++++ fabfile.py | 72 ++++++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 fabconfig/__init__.py create mode 100644 fabconfig/base.py create mode 100644 fabconfig/santa_unchained.py create mode 100644 fabfile.py diff --git a/fabconfig/__init__.py b/fabconfig/__init__.py new file mode 100644 index 0000000..e2b3a14 --- /dev/null +++ b/fabconfig/__init__.py @@ -0,0 +1,9 @@ +from fabconfig import santa_unchained +from fabconfig.base import LocalhostConfig + +CONFIG_MAP = { + "santa_unchained": { + "local": LocalhostConfig, + "stage": santa_unchained.StageConfig, + }, +} diff --git a/fabconfig/base.py b/fabconfig/base.py new file mode 100644 index 0000000..9f15f34 --- /dev/null +++ b/fabconfig/base.py @@ -0,0 +1,56 @@ +import os + +from fabric.utils import _AttributeDict + + +class BaseConfig: + def __init__(self) -> None: + self.env = _AttributeDict() + + self.env.git_server = "git@gitlab.deployed.pl" + self.env.git_repo = "spooler/spooler-service.git" + self.env.git_branch = "master" + + self.env.project_dir = "santa-unchained-api" + self.env.project_name = "santa_unchained" + + self.env.path = f"{self.env.project_dir}" + self.env.use_ssh_config = True + self.env.forward_agent = True + self.env.envname = "example" + self.env.pip_version = "22.2.2" + self.env.virtualenv_path = "~/venv" + self.env.virtualenv_args = "--python=python3.10" + self.env.pip_args = "" + self.env.project_path = self.env.project_name + self.env.requirements_file = "requirements/base.txt" + self.env.skip_rebuild_index_on_deploy = True + self.env.asyncworker = "celery" + self.env.warn_when_fixtures_fail = True + self.env.fixtures_format = "yaml" + + self.collectstatic_excluded = ["*.scss", "*.md", "*.less", "demo", "src"] + + self.env.excluded_files = self.get_excluded_files() + + def init_roles(self) -> None: + """by default all hosts have all roles""" + self.env.roledefs = { + "webserver": self.env.hosts, + "worker": self.env.hosts, + "extra": self.env.hosts, + } + + def get_excluded_files(self) -> str: + return " ".join("--ignore=%s" % rule for rule in self.collectstatic_excluded) + + +class LocalhostConfig(BaseConfig): + def __init__(self) -> None: + super(LocalhostConfig, self).__init__() + self.env.hosts = ["localhost"] + self.env.envname = "local" + self.env["virtualenv_path"] = os.environ.get("VIRTUAL_ENV") + self.init_roles() + if not self.env["virtualenv_path"]: + print("Make sure your virtualenv is activated (with VIRTUAL_ENV set)") diff --git a/fabconfig/santa_unchained.py b/fabconfig/santa_unchained.py new file mode 100644 index 0000000..49386f9 --- /dev/null +++ b/fabconfig/santa_unchained.py @@ -0,0 +1,21 @@ +from fabconfig.base import BaseConfig as DefaultConfig + + +class BaseConfig(DefaultConfig): + def __init__(self): + super(BaseConfig, self).__init__() + self.env.project_name = "santa_unchained" + + +class StageConfig(BaseConfig): + def __init__(self): + super().__init__() + self.env.envname = "stage" + self.env.settings = "santa_unchained.settings.production" + self.env.hosts = ["santa_unchained_stage@stage9.deployed.space:2222"] + self.env.vhost = "santa_unchained.deployed.space" + self.env.requirements_file = "requirements/prod.txt" + self.env.skip_dbbackup = True + self.init_roles() + self.env.roledefs["worker"] = [] + self.env.roledefs["extra"] = [] diff --git a/fabfile.py b/fabfile.py new file mode 100644 index 0000000..b32601d --- /dev/null +++ b/fabfile.py @@ -0,0 +1,72 @@ +# encoding: utf-8 + +# fabfile format v3.0 +from generix.deployment.base_fabfile import * +from generix.deployment.utils import extend_module_with_instance_methods + +from fabconfig import CONFIG_MAP + +# Example usages: +# +# First time run: +# fab <> install +# +# To update: +# fab <> deploy + + +def _update_config(project_name, instance_name, server): + """ + Update server settings + :param project_name: Project instance name. One of defined key from config_map. + :param instance_name: server instance name e.g. stage, stage2, stage3, prod, local + :param server: which server or group of servers to use e.g. `fab prod:santa_unchained,extra install` + """ + config = CONFIG_MAP[project_name][instance_name]() + env.update(config.env) + if server: + try: + env.hosts = getattr(env, "hosts", [])[int(server) - 1] + except (ValueError, IndexError): + env.hosts = env.roledefs[server] + + +def stage(project_name="santa_unchained", server=None): + """ + Use stage1 server settings + :param project_name: Project instance name. One of defined key from CONFIG_MAP. + :param server: which server or group of servers to use e.g. `fab prod:santa_unchained,extra install` + """ + _update_config(project_name=project_name, server=server, instance_name="stage") + + +def prod(project_name="santa_unchained", server=None): + """ + Use production server settings + :param project_name: Project instance name. One of defined key from CONFIG_MAP. + :param server: which server or group of servers to use e.g. `fab prod:santa_unchained,extra install` + """ + _update_config(project_name=project_name, server=server, instance_name="prod") + + +def localhost(project_name="santa_unchained", server=None): + print((yellow("Localhost"))) + + _update_config(project_name=project_name, server=server, instance_name="local") + virtualenv_activate() + + +instance = WithExtraDeployment(localhost=localhost) + +# trick that allows using class-based fabric scripts +# note possible ways to reuse fabric methods: +# +# 1) inherit from base class, override +# 2) write a wrapper +# 3) after extend_module_with_instance_methods call re-implement fabric task as a function + +extend_module_with_instance_methods(__name__, instance) + +# override by re-implementing a task +# def base_task1(): +# fab.run("echo 'directly from module'") From 05f29d8207bd57e3257ddf1448364178fbac8be3 Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Fri, 16 Dec 2022 14:17:38 +0100 Subject: [PATCH 09/13] Add timer API Ref: #30857 --- santa_unchained/wishes/api_views.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/santa_unchained/wishes/api_views.py b/santa_unchained/wishes/api_views.py index bb6d338..23dc622 100644 --- a/santa_unchained/wishes/api_views.py +++ b/santa_unchained/wishes/api_views.py @@ -1,9 +1,14 @@ +import datetime +import math + from django.db.transaction import atomic +from django.utils import timezone from drf_mixin_tools.mixins import ActionSerializerClassMixin -from drf_spectacular.utils import extend_schema -from rest_framework import mixins, viewsets +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import mixins, serializers, viewsets from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.views import APIView from santa_unchained.wishes.constants import PackageStatuses, WishListStatuses from santa_unchained.wishes.models import Package, WishList @@ -81,3 +86,10 @@ class PackageDistributionViewSet( ): queryset = Package.objects.all() serializer_class = PackageDistributionSerializer + + @extend_schema(responses={200: inline_serializer("TimerSerializer", {"seconds": serializers.IntegerField()})}) + @action(methods=["GET"], detail=False) + def timer(self, request): + now = timezone.localtime() + next_flight = now.replace(minute=math.ceil(now.minute / 15) * 15, second=0) + return Response({'seconds': (next_flight - now).seconds}) From e89aa417e6d701427d288e0e3979a89c87721bfb Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Fri, 16 Dec 2022 14:20:32 +0100 Subject: [PATCH 10/13] Add redirect for home page to api docs Ref: #30857 --- santa_unchained/urls.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/santa_unchained/urls.py b/santa_unchained/urls.py index 3409d9a..ab90cc9 100644 --- a/santa_unchained/urls.py +++ b/santa_unchained/urls.py @@ -1,7 +1,8 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin -from django.urls import include, path +from django.urls import include, path, reverse_lazy +from django.views.generic import RedirectView from drf_spectacular.views import ( SpectacularAPIView, SpectacularRedocView, @@ -12,6 +13,7 @@ from santa_unchained.wishes.urls import router as wishes_router urlpatterns = [ + path("", RedirectView.as_view(url=reverse_lazy("schema-swagger")), ), path("admin/", admin.site.urls), path("__debug__/", include("debug_toolbar.urls")), path("", include(accounts_urls)), From 74e520327fa0e47662f601912463dae3991edb5c Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Fri, 16 Dec 2022 19:12:05 +0100 Subject: [PATCH 11/13] Configure CORS Ref: #30857 --- pyproject.toml | 1 + requirements/base.txt | 3 +++ requirements/dev.txt | 3 +++ requirements/prod.txt | 3 +++ santa_unchained/settings/base.py | 5 +++++ 5 files changed, 15 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 6a9426e..c7385ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "drf-spectacular", "djangorestframework-camel-case", "drf-mixin-tools", + "django-cors-headers", ] [project.optional-dependencies] diff --git a/requirements/base.txt b/requirements/base.txt index dd97b04..ee0ecb5 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -21,6 +21,7 @@ decorator==5.1.1 django==4.1.3 # via # django-admin-display + # django-cors-headers # django-debug-toolbar # django-extensions # django-filter @@ -30,6 +31,8 @@ django==4.1.3 # santa-unchained (pyproject.toml) django-admin-display==1.3.0 # via santa-unchained (pyproject.toml) +django-cors-headers==3.13.0 + # via santa-unchained (pyproject.toml) django-debug-toolbar==3.7.0 # via santa-unchained (pyproject.toml) django-extensions==3.2.1 diff --git a/requirements/dev.txt b/requirements/dev.txt index 2714e86..e87a6cf 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -29,6 +29,7 @@ decorator==5.1.1 django==4.1.3 # via # django-admin-display + # django-cors-headers # django-debug-toolbar # django-extensions # django-filter @@ -38,6 +39,8 @@ django==4.1.3 # santa-unchained (pyproject.toml) django-admin-display==1.3.0 # via santa-unchained (pyproject.toml) +django-cors-headers==3.13.0 + # via santa-unchained (pyproject.toml) django-debug-toolbar==3.7.0 # via santa-unchained (pyproject.toml) django-extensions==3.2.1 diff --git a/requirements/prod.txt b/requirements/prod.txt index 60f79a6..12c4324 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -21,6 +21,7 @@ decorator==5.1.1 django==4.1.4 # via # django-admin-display + # django-cors-headers # django-debug-toolbar # django-extensions # django-filter @@ -30,6 +31,8 @@ django==4.1.4 # santa-unchained (pyproject.toml) django-admin-display==1.3.0 # via santa-unchained (pyproject.toml) +django-cors-headers==3.13.0 + # via santa-unchained (pyproject.toml) django-debug-toolbar==3.8.1 # via santa-unchained (pyproject.toml) django-extensions==3.2.1 diff --git a/santa_unchained/settings/base.py b/santa_unchained/settings/base.py index 54a4735..4483563 100644 --- a/santa_unchained/settings/base.py +++ b/santa_unchained/settings/base.py @@ -30,6 +30,7 @@ "django_extensions", "rest_framework", "drf_spectacular", + "corsheaders", ] LOCAL_APPS = [ @@ -43,6 +44,7 @@ # ------------- MIDDLEWARES ------------- MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "debug_toolbar.middleware.DebugToolbarMiddleware", @@ -149,3 +151,6 @@ "CONTACT": {"email": "deployed.pl@gmail.com"}, "SCHEMA_PATH_PREFIX": "/api/", } + +# ------------- CORS ------------ +CORS_ORIGIN_ALLOW_ALL = True From ae683ae8862a7b947374415f572e8119908fade0 Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Sat, 17 Dec 2022 12:12:25 +0100 Subject: [PATCH 12/13] Fix timer API Ref: #30857 --- santa_unchained/wishes/api_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/santa_unchained/wishes/api_views.py b/santa_unchained/wishes/api_views.py index 23dc622..f18f6c0 100644 --- a/santa_unchained/wishes/api_views.py +++ b/santa_unchained/wishes/api_views.py @@ -91,5 +91,5 @@ class PackageDistributionViewSet( @action(methods=["GET"], detail=False) def timer(self, request): now = timezone.localtime() - next_flight = now.replace(minute=math.ceil(now.minute / 15) * 15, second=0) + next_flight = now.replace(minute=(math.ceil(now.minute / 15) * 15) % 60, second=0) return Response({'seconds': (next_flight - now).seconds}) From 7400a40dbcbd24e6ce3fbb15e1564aa98ebd6e35 Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Sat, 17 Dec 2022 12:26:35 +0100 Subject: [PATCH 13/13] Add possibility to create wishlists from the admin panel Ref: #30857 --- santa_unchained/wishes/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/santa_unchained/wishes/admin.py b/santa_unchained/wishes/admin.py index 98af4a9..1d09388 100644 --- a/santa_unchained/wishes/admin.py +++ b/santa_unchained/wishes/admin.py @@ -60,7 +60,7 @@ def has_delete_permission(self, request, obj=None): @admin.register(WishListNew) -class WishListNewAdmin(WishListBaseAdmin): +class WishListNewAdmin(admin.ModelAdmin): actions = ["move_to_accept", "move_to_reject"] @admin.action(description=_("Accept"))