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/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'") 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" + } + } ] diff --git a/pyproject.toml b/pyproject.toml index 218c050..c7385ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,9 @@ dependencies = [ "djangorestframework", "django-filter", "drf-spectacular", + "djangorestframework-camel-case", + "drf-mixin-tools", + "django-cors-headers", ] [project.optional-dependencies] @@ -28,6 +31,9 @@ dev = [ "factory_boy", "pytest-factoryboy", ] +prod = [ +"gunicorn", +] [tool.black] max-line-length = 88 diff --git a/requirements/base.txt b/requirements/base.txt index 5883b7b..ee0ecb5 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -21,14 +21,18 @@ decorator==5.1.1 django==4.1.3 # via # django-admin-display + # django-cors-headers # 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-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 @@ -37,8 +41,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 8118f53..e87a6cf 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -29,14 +29,18 @@ decorator==5.1.1 django==4.1.3 # via # django-admin-display + # django-cors-headers # 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-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 @@ -45,8 +49,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/prod.txt b/requirements/prod.txt new file mode 100644 index 0000000..12c4324 --- /dev/null +++ b/requirements/prod.txt @@ -0,0 +1,121 @@ +# +# 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-cors-headers + # 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-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 + # 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 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..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", @@ -131,6 +133,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 ------------ @@ -140,3 +151,6 @@ "CONTACT": {"email": "deployed.pl@gmail.com"}, "SCHEMA_PATH_PREFIX": "/api/", } + +# ------------- CORS ------------ +CORS_ORIGIN_ALLOW_ALL = True 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") diff --git a/santa_unchained/urls.py b/santa_unchained/urls.py index 48b3c63..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, @@ -9,13 +10,14 @@ ) 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("", RedirectView.as_view(url=reverse_lazy("schema-swagger")), ), 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..1d09388 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, @@ -59,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")) @@ -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..f18f6c0 --- /dev/null +++ b/santa_unchained/wishes/api_views.py @@ -0,0 +1,95 @@ +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, 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 +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.set_status(WishListStatuses.ACCEPTED) + 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.set_status(WishListStatuses.REJECTED) + 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 + + @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) % 60, second=0) + return Response({'seconds': (next_flight - now).seconds}) 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..04cd5a7 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"), @@ -77,7 +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) + return lat, lng class WishList(models.Model): @@ -88,6 +96,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 +117,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 +138,15 @@ def __str__(self): def number_of_objects(cls): return cls.objects.count() + @property + 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() @@ -197,3 +217,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..72e10f8 --- /dev/null +++ b/santa_unchained/wishes/serializers.py @@ -0,0 +1,118 @@ +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", + "status", + ) + + 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 = ( # user info + package info + "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") + 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", "lat", "lng") + + 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"),