From d9c141a34f0fb4f93c5231c88f5175ce19c9daf3 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 08:51:56 +0200 Subject: [PATCH 01/62] Initialized Django project --- core/__init__.py | 0 core/asgi.py | 16 ++++ core/settings.py | 126 ++++++++++++++++++++++++++ core/urls.py | 22 +++++ core/wsgi.py | 16 ++++ flight_service/__init__.py | 0 flight_service/admin.py | 3 + flight_service/apps.py | 6 ++ flight_service/migrations/__init__.py | 0 flight_service/models.py | 3 + flight_service/tests.py | 3 + flight_service/views.py | 3 + manage.py | 22 +++++ 13 files changed, 220 insertions(+) create mode 100644 core/__init__.py create mode 100644 core/asgi.py create mode 100644 core/settings.py create mode 100644 core/urls.py create mode 100644 core/wsgi.py create mode 100644 flight_service/__init__.py create mode 100644 flight_service/admin.py create mode 100644 flight_service/apps.py create mode 100644 flight_service/migrations/__init__.py create mode 100644 flight_service/models.py create mode 100644 flight_service/tests.py create mode 100644 flight_service/views.py create mode 100644 manage.py diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/asgi.py b/core/asgi.py new file mode 100644 index 0000000..66c1231 --- /dev/null +++ b/core/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for core project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") + +application = get_asgi_application() diff --git a/core/settings.py b/core/settings.py new file mode 100644 index 0000000..21fdd1a --- /dev/null +++ b/core/settings.py @@ -0,0 +1,126 @@ +""" +Django settings for core project. + +Generated by 'django-admin startproject' using Django 4.2.7. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-1z*wi&15ys#xgzx(ak4a%n-(k#h*j1&p^rk-0ji)98p_i&xn=c" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + + # User defined apps + "flight_service" +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "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 = "core.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 = "core.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 0000000..43856b0 --- /dev/null +++ b/core/urls.py @@ -0,0 +1,22 @@ +""" +URL configuration for core project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path("admin/", admin.site.urls), +] diff --git a/core/wsgi.py b/core/wsgi.py new file mode 100644 index 0000000..a08c895 --- /dev/null +++ b/core/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for core project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") + +application = get_wsgi_application() diff --git a/flight_service/__init__.py b/flight_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/flight_service/admin.py b/flight_service/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/flight_service/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/flight_service/apps.py b/flight_service/apps.py new file mode 100644 index 0000000..9357456 --- /dev/null +++ b/flight_service/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FlightServiceConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "flight_service" diff --git a/flight_service/migrations/__init__.py b/flight_service/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/flight_service/models.py b/flight_service/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/flight_service/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/flight_service/tests.py b/flight_service/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/flight_service/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/flight_service/views.py b/flight_service/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/flight_service/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..4e20ce5 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.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() From 9ce30ee9c5b8adebfdd574b2be8b795ce3e19a06 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 08:52:32 +0200 Subject: [PATCH 02/62] updated .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 68bc17f..2dc53ca 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,4 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ From 6e45295eb0d302a953211e9f2fc3f8015c2f30b5 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 08:53:20 +0200 Subject: [PATCH 03/62] added flake8 rules for project --- .flake8 | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .flake8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..b5381af --- /dev/null +++ b/.flake8 @@ -0,0 +1,10 @@ +[flake8] +inline-quotes = " +ignore = E203, E266, W503, N807, N818, F401 +max-line-length = 79 +max-complexity = 18 +select = B,C,E,F,W,T4,B9,Q0,N8,VNE +exclude = + **migrations + venv + tests \ No newline at end of file From c23bf1aa44b96b5883add0951db33c3ee917704a Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 08:53:50 +0200 Subject: [PATCH 04/62] initial commit for requirements.txt --- requirements.txt | Bin 0 -> 478 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..e18c6314d2e382de548fd1e8b0e7cbe5b510acf7 GIT binary patch literal 478 zcmZ`$OAdlC6r8n*N2vjXL>Jz{D}X2gQLuvCUY&WUAt9!DEO_&6U(XyBQe07>!Hy@! z3L%zQ;Dnl+GmhBUCgCRF#Ivo#4L$iCv3J9+iPOxL93E8lDEZCkAcmuVip!5J7foVL zE&sR7@I{rDR#}A!%_lPCw2MPdUdCJnnH|$Lbj!?JT%}XjV8-;8qho*nNi~q`c@tmW z=s9+qdj4lRwk+AOwZ;_&CX$-&G@7LFbQM;e55%PW_Vy0m@M;_xV|3qBwdQH4D7`KB Ip^aYf4ORS0-2eap literal 0 HcmV?d00001 From a86fbe7f5c9bf9a5c9305fcee0fab674864109f4 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 10:19:49 +0200 Subject: [PATCH 05/62] implemented dflight-servce models --- flight_service/models.py | 85 +++++++++++++++++++++++++++++++++++++++- user/serializers.py | 0 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 user/serializers.py diff --git a/flight_service/models.py b/flight_service/models.py index 71a8362..f88e36d 100644 --- a/flight_service/models.py +++ b/flight_service/models.py @@ -1,3 +1,86 @@ +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator from django.db import models -# Create your models here. + +class Crew(models.Model): + first_name = models.CharField(max_length=255) + last_name = models.CharField(max_length=255) + + +class Airport(models.Model): + name = models.CharField(max_length=255) + closes_big_city = models.CharField(max_length=255) + + +class Order(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey(get_user_model(), on_delete=models.SET_NULL, related_name="orders") + + +class AirplaneType(models.Model): + name = models.CharField(max_length=255) + + +class Airplane(models.Model): + name = models.CharField(max_length=255) + rows = models.IntegerField(validators=MinValueValidator(1)) + seats = models.IntegerField(validators=MinValueValidator(1)) + airplane_type = models.ForeignKey(AirplaneType, related_name="airplanes", on_delete=models.SET_NULL) + + +class Flight(models.Model): + route = models.ForeignKey("Route", related_name="flights", on_delete=models.CASCADE) + airplane = models.ForeignKey(Airplane, related_name="flights", on_delete=models.SET_NULL) + departure_time = models.DateTimeField() + arrival_time = models.DateTimeField() + crew = models.ManyToManyField(Crew, related_name="flights") + + +class Ticket(models.Model): + row = models.IntegerField() + seat = models.IntegerField() + flight = models.ForeignKey(Flight, on_delete=models.CASCADE, related_name="tickets") + order = models.ForeignKey("Order", on_delete=models.CASCADE, related_name="tickets") + + class Meta: + unique_together = ("flight", "row", "seat") + ordering = ["row", "seat"] + + @staticmethod + def validate_ticket(row, seat, airplane, error_to_raise): + if 1 < row < airplane.rows: + raise error_to_raise( + f"Row should be in range (1, {airplane.rows}), not {row}" + ) + if 1 < seat < airplane.seats: + raise error_to_raise( + f"Seat should be in range (1, {airplane.seats}), not {seat}" + ) + + def clean(self): + Ticket.validate_ticket( + self.row, + self.seat, + self.flight.airport, + ValidationError, + ) + + def save( + self, + force_insert=False, + force_update=False, + using=None, + update_fields=None + ): + self.full_clean() + return super(Ticket, self).save( + force_insert, force_update, using, update_fields + ) + + +class Route(models.Model): + source = models.ForeignKey(Airport, on_delete=models.CASCADE) + destination = models.ForeignKey(Airport, on_delete=models.CASCADE) + distance = models.IntegerField(validators=MinValueValidator(1)) diff --git a/user/serializers.py b/user/serializers.py new file mode 100644 index 0000000..e69de29 From b049e5bff17e1388d2ee9f3bb95c29b4ea22becc Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 10:20:20 +0200 Subject: [PATCH 06/62] implemented custom user model --- user/__init__.py | 0 user/admin.py | 3 +++ user/apps.py | 6 +++++ user/migrations/__init__.py | 0 user/models.py | 50 +++++++++++++++++++++++++++++++++++++ user/tests.py | 3 +++ user/views.py | 3 +++ 7 files changed, 65 insertions(+) create mode 100644 user/__init__.py create mode 100644 user/admin.py create mode 100644 user/apps.py create mode 100644 user/migrations/__init__.py create mode 100644 user/models.py create mode 100644 user/tests.py create mode 100644 user/views.py diff --git a/user/__init__.py b/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/user/admin.py b/user/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/user/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/user/apps.py b/user/apps.py new file mode 100644 index 0000000..578292c --- /dev/null +++ b/user/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UserConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "user" diff --git a/user/migrations/__init__.py b/user/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/user/models.py b/user/models.py new file mode 100644 index 0000000..860ae65 --- /dev/null +++ b/user/models.py @@ -0,0 +1,50 @@ +from django.contrib.auth.models import AbstractUser, BaseUserManager +from django.utils.translation import gettext as _ +from django.db import models + + +class UserManager(BaseUserManager): + """Define a model manager for User model with no username field.""" + use_in_migrations = True + + def _create_user(self, email: str, password: str, **extra_fields): + """Create and save a User with the given email and password.""" + + if not email: + raise ValueError("Email is required field") + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(uning=self._db) + return user + + def create_user(self, email: str, password: str, **extra_fields): + """Create and save a regular User with the given email and password.""" + + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_superuser", False) + return self._create_user(email, password, **extra_fields) + + def create_superuser(self, email: str, password: str, **extra_fields): + """Create and save a SuperUser with the given email and password.""" + + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + if extra_fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True.") + + return self._create_user(email, password, **extra_fields) + + +class User(AbstractUser): + """user model.""" + + username = None + email = models.EmailField(_("email address"), unique=True) + USERNAME_FIELD = "email" + REQUIRED_FIELDS = [] + + objects = UserManager() diff --git a/user/tests.py b/user/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/user/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/user/views.py b/user/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/user/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. From beaa2457eb487009a39ba593dff3c7c3477547c1 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 10:32:13 +0200 Subject: [PATCH 07/62] added user serizliers --- core/settings.py | 21 +++++++++---- flight_service/serializers.py | 0 user/serializers.py | 56 +++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 flight_service/serializers.py diff --git a/core/settings.py b/core/settings.py index 21fdd1a..411f90b 100644 --- a/core/settings.py +++ b/core/settings.py @@ -39,11 +39,17 @@ "django.contrib.staticfiles", # User defined apps - "flight_service" + "flight_service", + "user", + "rest_framework", + "rest_framework.authtoken", + "debug_toolbar", + "drf_spectacular", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + "debug_toolbar.middleware.DebugToolbarMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -89,19 +95,24 @@ AUTH_PASSWORD_VALIDATORS = [ { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + "NAME": "django.contrib.auth.password_validation." + "UserAttributeSimilarityValidator", }, { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + "NAME": "django.contrib.auth.password_validation." + "MinimumLengthValidator", }, { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + "NAME": "django.contrib.auth.password_validation." + "CommonPasswordValidator", }, { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + "NAME": "django.contrib.auth.password_validation." + "NumericPasswordValidator", }, ] +AUTH_USER_MODEL = "user.User" # Internationalization # https://docs.djangoproject.com/en/4.2/topics/i18n/ diff --git a/flight_service/serializers.py b/flight_service/serializers.py new file mode 100644 index 0000000..e69de29 diff --git a/user/serializers.py b/user/serializers.py index e69de29..b755dc4 100644 --- a/user/serializers.py +++ b/user/serializers.py @@ -0,0 +1,56 @@ +from django.contrib.auth import get_user_model, authenticate +from rest_framework import serializers +from django.utils.translation import gettext as _ + + +class UserSerializer(serializers.ModelSerializer): + + class Meta: + model = get_user_model() + fields = ("id", "email", "password", "is_staff") + read_only_fields = ("is_staff",) + extra_kwargs = {"password": {"write_only": True, "min_length": 5}} + + def create(self, validated_data): + """Create a new user with encrypted password and return it""" + return get_user_model().objects.create_user(**validated_data) + + def update(self, instance, validated_data): + """Update a user, set the password correctly and return it""" + password = validated_data.pop("password", None) + user = super().update(instance, validated_data) + if password: + user.set_password(password) + user.save() + + return user + + +class AuthTokenSerializer(serializers.Serializer): + email = serializers.CharField(label=_("Email")) + password = serializers.CharField( + label=_("Password"), style={"input_type": "password"} + ) + + def validate(self, attrs): + email = attrs.get("email") + password = attrs.get("password") + + if email and password: + user = authenticate(email=email, password=password) + + if user: + if not user.is_active: + msg = _("User account is disabled.") + raise serializers.ValidationError( + msg, code="authorization" + ) + else: + msg = _("Unable to log in with provided credentials.") + raise serializers.ValidationError(msg, code="authorization") + else: + msg = _("Must include 'username' and 'password'.") + raise serializers.ValidationError(msg, code="authorization") + + attrs["user"] = user + return attrs From 5dd56884dc1ae6cd3538a39e0218e4253ea885db Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 10:33:53 +0200 Subject: [PATCH 08/62] added user views --- user/views.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/user/views.py b/user/views.py index 91ea44a..6fcc95d 100644 --- a/user/views.py +++ b/user/views.py @@ -1,3 +1,27 @@ from django.shortcuts import render +from rest_framework import generics +from rest_framework.authentication import TokenAuthentication +from rest_framework.authtoken.serializers import AuthTokenSerializer +from rest_framework.authtoken.views import ObtainAuthToken +from rest_framework.permissions import IsAuthenticated +from rest_framework.settings import api_settings -# Create your views here. +from user.serializers import UserSerializer + + +class CreateUserView(generics.CreateAPIView): + serializer_class = UserSerializer + + +class CreateTokenView(ObtainAuthToken): + renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + serializer_class = AuthTokenSerializer + + +class ManageUserView(generics.RetrieveUpdateAPIView): + serializer_class = UserSerializer + authentication_classes = (TokenAuthentication,) + permission_classes = (IsAuthenticated,) + + def get_object(self): + return self.request.user From 0b5f85d5b08baa6980f4330cfd63c555e81c3aeb Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 10:49:34 +0200 Subject: [PATCH 09/62] updated urls for project with routesa and JWT --- core/urls.py | 21 ++++----------------- flight_service/urls.py | 29 +++++++++++++++++++++++++++++ user/urls.py | 21 +++++++++++++++++++++ 3 files changed, 54 insertions(+), 17 deletions(-) create mode 100644 flight_service/urls.py create mode 100644 user/urls.py diff --git a/core/urls.py b/core/urls.py index 43856b0..69c5d15 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,22 +1,9 @@ -""" -URL configuration for core project. - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/4.2/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" from django.contrib import admin -from django.urls import path +from django.urls import path, include urlpatterns = [ path("admin/", admin.site.urls), + path("api/user/", include("user.urls", namespace="user")), + path("api/flight_service/", include("flight_service.urls", namespace="flight_service")), + path("__debug__/", include("debug_toolbar.urls")), ] diff --git a/flight_service/urls.py b/flight_service/urls.py new file mode 100644 index 0000000..2498d33 --- /dev/null +++ b/flight_service/urls.py @@ -0,0 +1,29 @@ +from django.urls import include, path +from rest_framework import routers +from flight_service.serializers import ( + CrewViewSet, + AirportViewSet, + OrderViewSet, + AirplaneTypeViewSet, + AirplaneViewSet, + FlightsViewSet, + TicketViewSet, + RouteViewSet, +) + + +router = routers.DefaultRouter() +router.register("crews", CrewViewSet) +router.register("airports", AirportViewSet) +router.register("orders", OrderViewSet) +router.register("airplane_types", AirplaneTypeViewSet) +router.register("airplanes", AirplaneViewSet) +router.register("flights", FlightsViewSet) +router.register("ticket", TicketViewSet) +router.register("routes", RouteViewSet) + +urlpatterns = [ + path("", include(router.urls)) +] + +app_name = "flight_service" diff --git a/user/urls.py b/user/urls.py new file mode 100644 index 0000000..6dd795d --- /dev/null +++ b/user/urls.py @@ -0,0 +1,21 @@ +from django.urls import path +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, + TokenVerifyView +) + +from user.views import ( + CreateUserView, + ManageUserView +) + +app_name = "user" + +urlpatterns = [ + path("register/", CreateUserView.as_view(), name="create"), + path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("token/verify/", TokenVerifyView.as_view(), name="token_verify"), + path("me/", ManageUserView.as_view(), name="manage"), +] From a3610034f51d26c3f20dbe515680e652ebcc5ef2 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 12:07:55 +0200 Subject: [PATCH 10/62] updated models.py on_delete settings and related names for Route fields --- flight_service/models.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/flight_service/models.py b/flight_service/models.py index f88e36d..eb05eed 100644 --- a/flight_service/models.py +++ b/flight_service/models.py @@ -16,7 +16,9 @@ class Airport(models.Model): class Order(models.Model): created_at = models.DateTimeField(auto_now_add=True) - user = models.ForeignKey(get_user_model(), on_delete=models.SET_NULL, related_name="orders") + user = models.ForeignKey( + get_user_model(), on_delete=models.CASCADE, related_name="orders" + ) class AirplaneType(models.Model): @@ -25,14 +27,18 @@ class AirplaneType(models.Model): class Airplane(models.Model): name = models.CharField(max_length=255) - rows = models.IntegerField(validators=MinValueValidator(1)) - seats = models.IntegerField(validators=MinValueValidator(1)) - airplane_type = models.ForeignKey(AirplaneType, related_name="airplanes", on_delete=models.SET_NULL) + rows = models.IntegerField(validators=[MinValueValidator(1)]) + seats = models.IntegerField(validators=[MinValueValidator(1)]) + airplane_type = models.ForeignKey( + AirplaneType, related_name="airplanes", on_delete=models.CASCADE + ) class Flight(models.Model): route = models.ForeignKey("Route", related_name="flights", on_delete=models.CASCADE) - airplane = models.ForeignKey(Airplane, related_name="flights", on_delete=models.SET_NULL) + airplane = models.ForeignKey( + Airplane, related_name="flights", on_delete=models.CASCADE + ) departure_time = models.DateTimeField() arrival_time = models.DateTimeField() crew = models.ManyToManyField(Crew, related_name="flights") @@ -63,16 +69,12 @@ def clean(self): Ticket.validate_ticket( self.row, self.seat, - self.flight.airport, + self.flight.airplane, ValidationError, ) def save( - self, - force_insert=False, - force_update=False, - using=None, - update_fields=None + self, force_insert=False, force_update=False, using=None, update_fields=None ): self.full_clean() return super(Ticket, self).save( @@ -81,6 +83,6 @@ def save( class Route(models.Model): - source = models.ForeignKey(Airport, on_delete=models.CASCADE) - destination = models.ForeignKey(Airport, on_delete=models.CASCADE) - distance = models.IntegerField(validators=MinValueValidator(1)) + source = models.ForeignKey(Airport, on_delete=models.CASCADE, related_name="route_sources") + destination = models.ForeignKey(Airport, on_delete=models.CASCADE, related_name="route_destinations") + distance = models.IntegerField(validators=[MinValueValidator(1)]) From f415b8f7655f8226eb1a1ff79937ad70590b1b82 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 12:08:13 +0200 Subject: [PATCH 11/62] fixed naming in URLs router --- flight_service/urls.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/flight_service/urls.py b/flight_service/urls.py index 2498d33..905d5ca 100644 --- a/flight_service/urls.py +++ b/flight_service/urls.py @@ -1,12 +1,12 @@ from django.urls import include, path from rest_framework import routers -from flight_service.serializers import ( +from flight_service.views import ( CrewViewSet, AirportViewSet, OrderViewSet, AirplaneTypeViewSet, AirplaneViewSet, - FlightsViewSet, + FlightViewSet, TicketViewSet, RouteViewSet, ) @@ -18,12 +18,10 @@ router.register("orders", OrderViewSet) router.register("airplane_types", AirplaneTypeViewSet) router.register("airplanes", AirplaneViewSet) -router.register("flights", FlightsViewSet) +router.register("flights", FlightViewSet) router.register("ticket", TicketViewSet) router.register("routes", RouteViewSet) -urlpatterns = [ - path("", include(router.urls)) -] +urlpatterns = [path("", include(router.urls))] app_name = "flight_service" From bbdd8314a6b7b00204acdd7aae344f23d0502678 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 12:08:44 +0200 Subject: [PATCH 12/62] implemented basic serializers.py --- flight_service/serializers.py | 61 +++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/flight_service/serializers.py b/flight_service/serializers.py index e69de29..f5b7728 100644 --- a/flight_service/serializers.py +++ b/flight_service/serializers.py @@ -0,0 +1,61 @@ +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from flight_service.models import ( + Crew, + Airport, + Order, + AirplaneType, + Airplane, + Flight, + Ticket, + Route, +) + + +class CrewSerializer(serializers.ModelSerializer): + class Meta: + model = Crew + + +class AirportSerializer(serializers.ModelSerializer): + class Meta: + model = Airport + + +class OrderSerializer(serializers.ModelSerializer): + class Meta: + model = Order + + +class AirplaneTypeSerializer(serializers.ModelSerializer): + class Meta: + model = AirplaneType + + +class AirplaneSerializer(serializers.ModelSerializer): + class Meta: + model = Airplane + + +class FlightSerializer(serializers.ModelSerializer): + class Meta: + model = Flight + + +class TicketSerializer(serializers.ModelSerializer): + def validate(self, attrs): + data = super(TicketSerializer, self).validate(attrs=attrs) + Ticket.validate_ticket( + attrs["row"], attrs["seat"], attrs["flight"].airplane, ValidationError + ) + return data + + class Meta: + model = Ticket + fields = ("id", "row", "seat", "flight") + + +class RouteSerializer(serializers.ModelSerializer): + class Meta: + model = Route From 3ceb944ec8b224d51411b533a0b5827e4eaf6d43 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 12:09:48 +0200 Subject: [PATCH 13/62] implemented basic viewsets for all models --- flight_service/views.py | 72 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/flight_service/views.py b/flight_service/views.py index 91ea44a..a8be22b 100644 --- a/flight_service/views.py +++ b/flight_service/views.py @@ -1,3 +1,71 @@ -from django.shortcuts import render +from rest_framework import viewsets +from rest_framework.permissions import AllowAny -# Create your views here. +from flight_service.models import ( + Crew, + Airport, + Order, + AirplaneType, + Airplane, + Flight, + Ticket, + Route, +) +from flight_service.serializers import ( + CrewSerializer, + AirportSerializer, + OrderSerializer, + AirplaneTypeSerializer, + AirplaneSerializer, + FlightSerializer, + TicketSerializer, + RouteSerializer, +) + + +class CrewViewSet(viewsets.ModelViewSet): + queryset = Crew.objects.all() + serializer_class = CrewSerializer + permission_classes = (AllowAny,) + + +class AirportViewSet(viewsets.ModelViewSet): + queryset = Airport.objects.all() + serializer_class = AirportSerializer + permission_classes = (AllowAny,) + + +class OrderViewSet(viewsets.ModelViewSet): + queryset = Order.objects.all() + serializer_class = OrderSerializer + permission_classes = (AllowAny,) + + +class AirplaneTypeViewSet(viewsets.ModelViewSet): + queryset = AirplaneType.objects.all() + serializer_class = AirplaneTypeSerializer + permission_classes = (AllowAny,) + + +class AirplaneViewSet(viewsets.ModelViewSet): + queryset = Airplane.objects.all() + serializer_class = AirplaneSerializer + permission_classes = (AllowAny,) + + +class FlightViewSet(viewsets.ModelViewSet): + queryset = Flight.objects.all() + serializer_class = FlightSerializer + permission_classes = (AllowAny,) + + +class TicketViewSet(viewsets.ModelViewSet): + queryset = Ticket.objects.all() + serializer_class = TicketSerializer + permission_classes = (AllowAny,) + + +class RouteViewSet(viewsets.ModelViewSet): + queryset = Route.objects.all() + serializer_class = RouteSerializer + permission_classes = (AllowAny,) From 0f27987b68bc9090c2ca21bcf0c5245f723e7f27 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 12:10:07 +0200 Subject: [PATCH 14/62] run migrations for models --- flight_service/migrations/0001_initial.py | 190 ++++++++++++++++++++++ flight_service/migrations/0002_initial.py | 64 ++++++++ user/migrations/0001_initial.py | 115 +++++++++++++ 3 files changed, 369 insertions(+) create mode 100644 flight_service/migrations/0001_initial.py create mode 100644 flight_service/migrations/0002_initial.py create mode 100644 user/migrations/0001_initial.py diff --git a/flight_service/migrations/0001_initial.py b/flight_service/migrations/0001_initial.py new file mode 100644 index 0000000..8e200c2 --- /dev/null +++ b/flight_service/migrations/0001_initial.py @@ -0,0 +1,190 @@ +# Generated by Django 4.2.8 on 2023-12-11 10:06 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Airplane", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ( + "rows", + models.IntegerField( + validators=[django.core.validators.MinValueValidator(1)] + ), + ), + ( + "seats", + models.IntegerField( + validators=[django.core.validators.MinValueValidator(1)] + ), + ), + ], + ), + migrations.CreateModel( + name="AirplaneType", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name="Airport", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("closes_big_city", models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name="Crew", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("first_name", models.CharField(max_length=255)), + ("last_name", models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name="Flight", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("departure_time", models.DateTimeField()), + ("arrival_time", models.DateTimeField()), + ], + ), + migrations.CreateModel( + name="Order", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name="Ticket", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("row", models.IntegerField()), + ("seat", models.IntegerField()), + ( + "flight", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tickets", + to="flight_service.flight", + ), + ), + ( + "order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tickets", + to="flight_service.order", + ), + ), + ], + options={ + "ordering": ["row", "seat"], + }, + ), + migrations.CreateModel( + name="Route", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "distance", + models.IntegerField( + validators=[django.core.validators.MinValueValidator(1)] + ), + ), + ( + "destination", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="route_destinations", + to="flight_service.airport", + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="route_sources", + to="flight_service.airport", + ), + ), + ], + ), + ] diff --git a/flight_service/migrations/0002_initial.py b/flight_service/migrations/0002_initial.py new file mode 100644 index 0000000..360da2d --- /dev/null +++ b/flight_service/migrations/0002_initial.py @@ -0,0 +1,64 @@ +# Generated by Django 4.2.8 on 2023-12-11 10:06 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("flight_service", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="order", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="orders", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="flight", + name="airplane", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="flights", + to="flight_service.airplane", + ), + ), + migrations.AddField( + model_name="flight", + name="crew", + field=models.ManyToManyField( + related_name="flights", to="flight_service.crew" + ), + ), + migrations.AddField( + model_name="flight", + name="route", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="flights", + to="flight_service.route", + ), + ), + migrations.AddField( + model_name="airplane", + name="airplane_type", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="airplanes", + to="flight_service.airplanetype", + ), + ), + migrations.AlterUniqueTogether( + name="ticket", + unique_together={("flight", "row", "seat")}, + ), + ] diff --git a/user/migrations/0001_initial.py b/user/migrations/0001_initial.py new file mode 100644 index 0000000..0d2e570 --- /dev/null +++ b/user/migrations/0001_initial.py @@ -0,0 +1,115 @@ +# Generated by Django 4.2.8 on 2023-12-11 10:06 + +from django.db import migrations, models +import django.utils.timezone +import user.models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "email", + models.EmailField( + max_length=254, unique=True, verbose_name="email address" + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[ + ("objects", user.models.UserManager()), + ], + ), + ] From 41801bbfffb56571577a640385ab45a7697259a9 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 12:14:24 +0200 Subject: [PATCH 15/62] minot corrections to serializer (added fields in Meta), urls (updated namespace) --- core/urls.py | 2 +- flight_service/serializers.py | 6 ++++++ flight_service/urls.py | 4 ++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/core/urls.py b/core/urls.py index 69c5d15..ab09345 100644 --- a/core/urls.py +++ b/core/urls.py @@ -4,6 +4,6 @@ urlpatterns = [ path("admin/", admin.site.urls), path("api/user/", include("user.urls", namespace="user")), - path("api/flight_service/", include("flight_service.urls", namespace="flight_service")), + path("api/flight_service/", include("flight_service.urls", namespace="flight-service")), path("__debug__/", include("debug_toolbar.urls")), ] diff --git a/flight_service/serializers.py b/flight_service/serializers.py index f5b7728..dab4c09 100644 --- a/flight_service/serializers.py +++ b/flight_service/serializers.py @@ -16,26 +16,31 @@ class CrewSerializer(serializers.ModelSerializer): class Meta: model = Crew + fields = "__all__" class AirportSerializer(serializers.ModelSerializer): class Meta: model = Airport + fields = "__all__" class OrderSerializer(serializers.ModelSerializer): class Meta: model = Order + fields = "__all__" class AirplaneTypeSerializer(serializers.ModelSerializer): class Meta: model = AirplaneType + fields = "__all__" class AirplaneSerializer(serializers.ModelSerializer): class Meta: model = Airplane + fields = "__all__" class FlightSerializer(serializers.ModelSerializer): @@ -59,3 +64,4 @@ class Meta: class RouteSerializer(serializers.ModelSerializer): class Meta: model = Route + fields = "__all__" diff --git a/flight_service/urls.py b/flight_service/urls.py index 905d5ca..eec38c5 100644 --- a/flight_service/urls.py +++ b/flight_service/urls.py @@ -16,7 +16,7 @@ router.register("crews", CrewViewSet) router.register("airports", AirportViewSet) router.register("orders", OrderViewSet) -router.register("airplane_types", AirplaneTypeViewSet) +router.register("airplane-types", AirplaneTypeViewSet) router.register("airplanes", AirplaneViewSet) router.register("flights", FlightViewSet) router.register("ticket", TicketViewSet) @@ -24,4 +24,4 @@ urlpatterns = [path("", include(router.urls))] -app_name = "flight_service" +app_name = "flight-service" From 4ec5235c7c7ea45cdf19d762e46567d18b0baafe Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 15:03:42 +0200 Subject: [PATCH 16/62] updated admin panel --- flight_service/admin.py | 21 ++++++++++++++++++++- user/admin.py | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/flight_service/admin.py b/flight_service/admin.py index 8c38f3f..200bc67 100644 --- a/flight_service/admin.py +++ b/flight_service/admin.py @@ -1,3 +1,22 @@ from django.contrib import admin -# Register your models here. +from flight_service.models import ( + Crew, + Airport, + Order, + AirplaneType, + Airplane, + Flight, + Ticket, + Route, +) + + +admin.site.register(Crew) +admin.site.register(Airport) +admin.site.register(Order) +admin.site.register(AirplaneType) +admin.site.register(Airplane) +admin.site.register(Flight) +admin.site.register(Ticket) +admin.site.register(Route) diff --git a/user/admin.py b/user/admin.py index 8c38f3f..1600c30 100644 --- a/user/admin.py +++ b/user/admin.py @@ -1,3 +1,39 @@ from django.contrib import admin +from django.contrib.auth import get_user_model +from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin +from django.utils.translation import gettext as _ -# Register your models here. + +@admin.register(get_user_model()) +class UserAdmin(DjangoUserAdmin): + """Define admin model for custom User model with no email field.""" + + fieldsets = ( + (None, {"fields": ("email", "password")}), + (_("Personal info"), {"fields": ("first_name", "last_name")}), + ( + _("Permissions"), + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + (_("Important dates"), {"fields": ("last_login", "date_joined")}), + ) + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("email", "password1", "password2"), + }, + ), + ) + list_display = ("email", "first_name", "last_name", "is_staff") + search_fields = ("email", "first_name", "last_name") + ordering = ("email",) From 2e33721676d4b613e44009899e0f74334c075fc1 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 15:04:20 +0200 Subject: [PATCH 17/62] minor corrections to models --- flight_service/models.py | 34 +++++++++++++++++++++++++++++++--- user/models.py | 4 ++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/flight_service/models.py b/flight_service/models.py index eb05eed..b60de6c 100644 --- a/flight_service/models.py +++ b/flight_service/models.py @@ -8,10 +8,16 @@ class Crew(models.Model): first_name = models.CharField(max_length=255) last_name = models.CharField(max_length=255) + def __str__(self): + return self.first_name + " " + self.last_name + class Airport(models.Model): name = models.CharField(max_length=255) - closes_big_city = models.CharField(max_length=255) + closest_big_city = models.CharField(max_length=255) + + def __str__(self): + return self.name class Order(models.Model): @@ -20,10 +26,16 @@ class Order(models.Model): get_user_model(), on_delete=models.CASCADE, related_name="orders" ) + def __str__(self): + return f"Order #{self.id} created by {self.user}" + class AirplaneType(models.Model): name = models.CharField(max_length=255) + def __str__(self): + return self.name + class Airplane(models.Model): name = models.CharField(max_length=255) @@ -33,6 +45,13 @@ class Airplane(models.Model): AirplaneType, related_name="airplanes", on_delete=models.CASCADE ) + @property + def capacity(self) -> int: + return self.rows * self.seats + + def __str__(self): + return self.name + class Flight(models.Model): route = models.ForeignKey("Route", related_name="flights", on_delete=models.CASCADE) @@ -43,6 +62,9 @@ class Flight(models.Model): arrival_time = models.DateTimeField() crew = models.ManyToManyField(Crew, related_name="flights") + def __str__(self): + return f"Flight #{self.id}: {self.route}. Airplane: {self.airplane}" + class Ticket(models.Model): row = models.IntegerField() @@ -56,11 +78,11 @@ class Meta: @staticmethod def validate_ticket(row, seat, airplane, error_to_raise): - if 1 < row < airplane.rows: + if not 1 <= row <= airplane.rows: raise error_to_raise( f"Row should be in range (1, {airplane.rows}), not {row}" ) - if 1 < seat < airplane.seats: + if not 1 <= seat <= airplane.seats: raise error_to_raise( f"Seat should be in range (1, {airplane.seats}), not {seat}" ) @@ -81,8 +103,14 @@ def save( force_insert, force_update, using, update_fields ) + def __str__(self): + return f"Ticker #{self.id}: seat #{self.seat}, row #{self.row}" + class Route(models.Model): source = models.ForeignKey(Airport, on_delete=models.CASCADE, related_name="route_sources") destination = models.ForeignKey(Airport, on_delete=models.CASCADE, related_name="route_destinations") distance = models.IntegerField(validators=[MinValueValidator(1)]) + + def __str__(self): + return f"from {self.source} to {self.destination}" diff --git a/user/models.py b/user/models.py index 860ae65..3a2a381 100644 --- a/user/models.py +++ b/user/models.py @@ -11,11 +11,11 @@ def _create_user(self, email: str, password: str, **extra_fields): """Create and save a User with the given email and password.""" if not email: - raise ValueError("Email is required field") + raise ValueError("Email is required field") email = self.normalize_email(email) user = self.model(email=email, **extra_fields) user.set_password(password) - user.save(uning=self._db) + user.save(using=self._db) return user def create_user(self, email: str, password: str, **extra_fields): From 3275e1976ae5703815be0194ea8f62091cc3b6bc Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 15:04:38 +0200 Subject: [PATCH 18/62] realized serializers for list and details view --- flight_service/serializers.py | 142 +++++++++++++++++++++++++++++++--- 1 file changed, 130 insertions(+), 12 deletions(-) diff --git a/flight_service/serializers.py b/flight_service/serializers.py index dab4c09..5b35dae 100644 --- a/flight_service/serializers.py +++ b/flight_service/serializers.py @@ -1,3 +1,4 @@ +from django.db import transaction from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -25,30 +26,52 @@ class Meta: fields = "__all__" -class OrderSerializer(serializers.ModelSerializer): +class RouteSerializer(serializers.ModelSerializer): class Meta: - model = Order + model = Route fields = "__all__" -class AirplaneTypeSerializer(serializers.ModelSerializer): - class Meta: - model = AirplaneType - fields = "__all__" - +class RouteListSerializer(serializers.ModelSerializer): + source = serializers.SlugField( + source="source.name", + ) + destination = serializers.SlugField( + source="destination.name", + ) -class AirplaneSerializer(serializers.ModelSerializer): class Meta: - model = Airplane + model = Route fields = "__all__" class FlightSerializer(serializers.ModelSerializer): class Meta: model = Flight + fields = "__all__" + + +class FlightListSerializer(FlightSerializer): + route = serializers.StringRelatedField( + read_only=True, + many=False + ) + airplane = serializers.StringRelatedField( + read_only=True, + many=False + ) + crew = serializers.StringRelatedField( + read_only=True, + many=True + ) + tickets_available = serializers.IntegerField(read_only=True) class TicketSerializer(serializers.ModelSerializer): + class Meta: + model = Ticket + fields = "__all__" + def validate(self, attrs): data = super(TicketSerializer, self).validate(attrs=attrs) Ticket.validate_ticket( @@ -56,12 +79,107 @@ def validate(self, attrs): ) return data + +class TicketListSerializer(TicketSerializer): + flight = FlightListSerializer(many=False, read_only=True) + route = RouteListSerializer(many=False, read_only=True) + + +class TicketDetailSerializer(TicketSerializer): + flight = FlightListSerializer(many=False, read_only=True) + route = RouteListSerializer(many=False, read_only=True) + class Meta: model = Ticket - fields = ("id", "row", "seat", "flight") + fields = "__all__" -class RouteSerializer(serializers.ModelSerializer): +class TicketFlightDetailSerializer(FlightListSerializer): class Meta: - model = Route + model = Flight + fields = ( + "route", + "airplane", + "departure_time", + "arrival_time" + ) + + +class TicketOrderDetailSerializer(TicketDetailSerializer): + flight = TicketFlightDetailSerializer(many=False, read_only=True) + + class Meta: + model = Ticket + fields = ( + "id", + "flight", + "row", + "seat" + ) + + +class OrderTicketSerializer(TicketSerializer): + class Meta: + model = Ticket + fields = ( + "flight", + "row", + "seat" + ) + + +class OrderSerializer(serializers.ModelSerializer): + tickets = OrderTicketSerializer(many=True, read_only=False, allow_empty=False) + + class Meta: + model = Order + fields = "__all__" + + def create(self, validated_data): + with transaction.atomic(): + tickets_data = validated_data.pop("tickets") + order = Order.objects.create(**validated_data) + for ticket_data in tickets_data: + Ticket.objects.create(order=order, **ticket_data) + return order + + +class OrderListSerializer(OrderSerializer): + tickets = TicketListSerializer(many=True, read_only=True) + + +class OrderDetailSerializer(OrderSerializer): + tickets = TicketOrderDetailSerializer(many=True, read_only=True) + + class Meta: + model = Order fields = "__all__" + + +class AirplaneTypeSerializer(serializers.ModelSerializer): + class Meta: + model = AirplaneType + fields = "__all__" + + +class AirplaneSerializer(serializers.ModelSerializer): + class Meta: + model = Airplane + fields = "__all__" + + +class AirplaneDetailSerializer(AirplaneSerializer): + airplane_type = serializers.SlugField( + source="airplane_type.name" + ) + + class Meta: + model = Airplane + fields = ( + "id", + "name", + "rows", + "seats", + "capacity", + "airplane_type" + ) From 77a3b91256cbf7645f099fa39fbdf2418a8213f1 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 15:04:55 +0200 Subject: [PATCH 19/62] realized view for all models --- flight_service/views.py | 47 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/flight_service/views.py b/flight_service/views.py index a8be22b..ed94af4 100644 --- a/flight_service/views.py +++ b/flight_service/views.py @@ -1,3 +1,4 @@ +from django.db.models import F, Count from rest_framework import viewsets from rest_framework.permissions import AllowAny @@ -20,6 +21,10 @@ FlightSerializer, TicketSerializer, RouteSerializer, + RouteListSerializer, + TicketListSerializer, + FlightListSerializer, + OrderListSerializer, OrderDetailSerializer, AirplaneDetailSerializer, ) @@ -37,9 +42,16 @@ class AirportViewSet(viewsets.ModelViewSet): class OrderViewSet(viewsets.ModelViewSet): queryset = Order.objects.all() - serializer_class = OrderSerializer permission_classes = (AllowAny,) + def get_serializer_class(self): + if self.action == "list": + return OrderListSerializer + if self.action == "retrieve": + return OrderDetailSerializer + + return OrderSerializer + class AirplaneTypeViewSet(viewsets.ModelViewSet): queryset = AirplaneType.objects.all() @@ -49,23 +61,48 @@ class AirplaneTypeViewSet(viewsets.ModelViewSet): class AirplaneViewSet(viewsets.ModelViewSet): queryset = Airplane.objects.all() - serializer_class = AirplaneSerializer permission_classes = (AllowAny,) + def get_serializer_class(self): + if self.action == "retrieve": + return AirplaneDetailSerializer + + return AirplaneSerializer + class FlightViewSet(viewsets.ModelViewSet): queryset = Flight.objects.all() - serializer_class = FlightSerializer permission_classes = (AllowAny,) + def get_serializer_class(self): + if self.action == "list": + return FlightListSerializer + return FlightSerializer + + def get_queryset(self): + return self.queryset.annotate( + tickets_available=( + F("airplane__rows") * F("airplane__seats") + - Count("tickets") + ) + ) + class TicketViewSet(viewsets.ModelViewSet): queryset = Ticket.objects.all() - serializer_class = TicketSerializer permission_classes = (AllowAny,) + def get_serializer_class(self): + if self.action == "list": + return TicketListSerializer + return TicketSerializer + class RouteViewSet(viewsets.ModelViewSet): queryset = Route.objects.all() - serializer_class = RouteSerializer permission_classes = (AllowAny,) + + def get_serializer_class(self): + if self.action == "list": + return RouteListSerializer + return RouteSerializer From b4ca3f6a66f85b0b1cb26255b75d034730e31e9e Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 15:05:08 +0200 Subject: [PATCH 20/62] updated migrations after chnaging models --- ..._closes_big_city_airport_closest_big_city.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 flight_service/migrations/0003_rename_closes_big_city_airport_closest_big_city.py diff --git a/flight_service/migrations/0003_rename_closes_big_city_airport_closest_big_city.py b/flight_service/migrations/0003_rename_closes_big_city_airport_closest_big_city.py new file mode 100644 index 0000000..a962718 --- /dev/null +++ b/flight_service/migrations/0003_rename_closes_big_city_airport_closest_big_city.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.8 on 2023-12-11 10:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("flight_service", "0002_initial"), + ] + + operations = [ + migrations.RenameField( + model_name="airport", + old_name="closes_big_city", + new_name="closest_big_city", + ), + ] From 5c71ec6b3fd36fae70534ba2c1bb09fcd6330d9f Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 17:13:21 +0200 Subject: [PATCH 21/62] updated ordering for models --- flight_service/models.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/flight_service/models.py b/flight_service/models.py index b60de6c..c5dc149 100644 --- a/flight_service/models.py +++ b/flight_service/models.py @@ -26,6 +26,9 @@ class Order(models.Model): get_user_model(), on_delete=models.CASCADE, related_name="orders" ) + class Meta: + ordering = ("-created_at",) + def __str__(self): return f"Order #{self.id} created by {self.user}" @@ -108,8 +111,12 @@ def __str__(self): class Route(models.Model): - source = models.ForeignKey(Airport, on_delete=models.CASCADE, related_name="route_sources") - destination = models.ForeignKey(Airport, on_delete=models.CASCADE, related_name="route_destinations") + source = models.ForeignKey( + Airport, on_delete=models.CASCADE, related_name="route_sources" + ) + destination = models.ForeignKey( + Airport, on_delete=models.CASCADE, related_name="route_destinations" + ) distance = models.IntegerField(validators=[MinValueValidator(1)]) def __str__(self): From bb572556f19b5c21b0346ef17daa5dba5cc93f21 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 17:13:43 +0200 Subject: [PATCH 22/62] renamed URL from ticket to tickets --- flight_service/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flight_service/urls.py b/flight_service/urls.py index eec38c5..cc8bb1f 100644 --- a/flight_service/urls.py +++ b/flight_service/urls.py @@ -19,7 +19,7 @@ router.register("airplane-types", AirplaneTypeViewSet) router.register("airplanes", AirplaneViewSet) router.register("flights", FlightViewSet) -router.register("ticket", TicketViewSet) +router.register("tickets", TicketViewSet) router.register("routes", RouteViewSet) urlpatterns = [path("", include(router.urls))] From a7df86ad16b7aa2bd9627aac4680788cf22b9e20 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 17:14:26 +0200 Subject: [PATCH 23/62] implemented complex logic serrializers class retrival --- flight_service/views.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/flight_service/views.py b/flight_service/views.py index ed94af4..57b6079 100644 --- a/flight_service/views.py +++ b/flight_service/views.py @@ -24,7 +24,11 @@ RouteListSerializer, TicketListSerializer, FlightListSerializer, - OrderListSerializer, OrderDetailSerializer, AirplaneDetailSerializer, + OrderListSerializer, + OrderDetailSerializer, + AirplaneDetailSerializer, + FlightDetailSerializer, + TicketDetailSerializer, ) @@ -77,13 +81,14 @@ class FlightViewSet(viewsets.ModelViewSet): def get_serializer_class(self): if self.action == "list": return FlightListSerializer + if self.action == "retrieve": + return FlightDetailSerializer return FlightSerializer def get_queryset(self): return self.queryset.annotate( tickets_available=( - F("airplane__rows") * F("airplane__seats") - - Count("tickets") + F("airplane__rows") * F("airplane__seats") - Count("tickets") ) ) @@ -95,6 +100,8 @@ class TicketViewSet(viewsets.ModelViewSet): def get_serializer_class(self): if self.action == "list": return TicketListSerializer + if self.action == "retrieve": + return TicketDetailSerializer return TicketSerializer From c31ae988afa04689300ee0371204d6bdc60ee298 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 17:14:48 +0200 Subject: [PATCH 24/62] updated serializers.py --- flight_service/serializers.py | 94 ++++++++++++++++------------------- 1 file changed, 44 insertions(+), 50 deletions(-) diff --git a/flight_service/serializers.py b/flight_service/serializers.py index 5b35dae..d74c161 100644 --- a/flight_service/serializers.py +++ b/flight_service/serializers.py @@ -46,24 +46,16 @@ class Meta: class FlightSerializer(serializers.ModelSerializer): + route = serializers.StringRelatedField(read_only=True, many=False) + airplane = serializers.StringRelatedField(read_only=True, many=False) + crew = serializers.StringRelatedField(read_only=True, many=True) + class Meta: model = Flight fields = "__all__" class FlightListSerializer(FlightSerializer): - route = serializers.StringRelatedField( - read_only=True, - many=False - ) - airplane = serializers.StringRelatedField( - read_only=True, - many=False - ) - crew = serializers.StringRelatedField( - read_only=True, - many=True - ) tickets_available = serializers.IntegerField(read_only=True) @@ -75,61 +67,72 @@ class Meta: def validate(self, attrs): data = super(TicketSerializer, self).validate(attrs=attrs) Ticket.validate_ticket( - attrs["row"], attrs["seat"], attrs["flight"].airplane, ValidationError + attrs["row"], + attrs["seat"], + attrs["flight"].airplane, + ValidationError ) return data +class TicketFlightDetailSerializer(FlightListSerializer): + class Meta: + model = Flight + fields = ("route", "airplane", "departure_time", "arrival_time") + + class TicketListSerializer(TicketSerializer): - flight = FlightListSerializer(many=False, read_only=True) + flight = TicketFlightDetailSerializer(many=False, read_only=True) route = RouteListSerializer(many=False, read_only=True) -class TicketDetailSerializer(TicketSerializer): +class TicketDetailSerializer(TicketListSerializer): flight = FlightListSerializer(many=False, read_only=True) - route = RouteListSerializer(many=False, read_only=True) class Meta: model = Ticket - fields = "__all__" + fields = ( + "id", + "flight", + "row", + "seat", + ) -class TicketFlightDetailSerializer(FlightListSerializer): +class TicketSeatSerializer(TicketDetailSerializer): class Meta: - model = Flight - fields = ( - "route", - "airplane", - "departure_time", - "arrival_time" - ) + model = Ticket + fields = ("row", "seat") -class TicketOrderDetailSerializer(TicketDetailSerializer): - flight = TicketFlightDetailSerializer(many=False, read_only=True) +class FlightDetailSerializer(FlightSerializer): + seats_taken = TicketSeatSerializer( + source="tickets", many=True, read_only=True + ) class Meta: - model = Ticket + model = Flight fields = ( "id", - "flight", - "row", - "seat" + "route", + "airplane", + "crew", + "departure_time", + "arrival_time", + "seats_taken", ) class OrderTicketSerializer(TicketSerializer): class Meta: model = Ticket - fields = ( - "flight", - "row", - "seat" - ) + fields = ("flight", "row", "seat") class OrderSerializer(serializers.ModelSerializer): - tickets = OrderTicketSerializer(many=True, read_only=False, allow_empty=False) + tickets = OrderTicketSerializer( + many=True, read_only=False, allow_empty=False + ) class Meta: model = Order @@ -145,11 +148,11 @@ def create(self, validated_data): class OrderListSerializer(OrderSerializer): - tickets = TicketListSerializer(many=True, read_only=True) + tickets = TicketDetailSerializer(many=True, read_only=True) class OrderDetailSerializer(OrderSerializer): - tickets = TicketOrderDetailSerializer(many=True, read_only=True) + tickets = TicketDetailSerializer(many=True, read_only=True) class Meta: model = Order @@ -169,17 +172,8 @@ class Meta: class AirplaneDetailSerializer(AirplaneSerializer): - airplane_type = serializers.SlugField( - source="airplane_type.name" - ) + airplane_type = serializers.SlugField(source="airplane_type.name") class Meta: model = Airplane - fields = ( - "id", - "name", - "rows", - "seats", - "capacity", - "airplane_type" - ) + fields = ("id", "name", "rows", "seats", "capacity", "airplane_type") From 571d52f88c26fd012e91b3b0f9079ce13690aaf9 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 18:39:13 +0200 Subject: [PATCH 25/62] changed models Meta classes --- .../migrations/0004_alter_order_options.py | 16 ++++++++++++++++ .../migrations/0005_alter_order_options.py | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 flight_service/migrations/0004_alter_order_options.py create mode 100644 flight_service/migrations/0005_alter_order_options.py diff --git a/flight_service/migrations/0004_alter_order_options.py b/flight_service/migrations/0004_alter_order_options.py new file mode 100644 index 0000000..02a7b1e --- /dev/null +++ b/flight_service/migrations/0004_alter_order_options.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.8 on 2023-12-11 15:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("flight_service", "0003_rename_closes_big_city_airport_closest_big_city"), + ] + + operations = [ + migrations.AlterModelOptions( + name="order", + options={"ordering": ("created_at",)}, + ), + ] diff --git a/flight_service/migrations/0005_alter_order_options.py b/flight_service/migrations/0005_alter_order_options.py new file mode 100644 index 0000000..6747221 --- /dev/null +++ b/flight_service/migrations/0005_alter_order_options.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.8 on 2023-12-11 16:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("flight_service", "0004_alter_order_options"), + ] + + operations = [ + migrations.AlterModelOptions( + name="order", + options={"ordering": ("-created_at",)}, + ), + ] From 979264f54ae76c74c8db5dfb0bd2cd1f63c2fa17 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 18:39:17 +0200 Subject: [PATCH 26/62] changed models Meta classes --- core/settings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/settings.py b/core/settings.py index 411f90b..795a5d0 100644 --- a/core/settings.py +++ b/core/settings.py @@ -27,6 +27,9 @@ ALLOWED_HOSTS = [] +INTERNAL_IPS = [ + "127.0.0.1", +] # Application definition From 3742d0179509c45602a57fd559ed48ea43ad20e3 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 18:39:23 +0200 Subject: [PATCH 27/62] changed models Meta classes --- flight_service/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flight_service/models.py b/flight_service/models.py index c5dc149..3f6856d 100644 --- a/flight_service/models.py +++ b/flight_service/models.py @@ -30,7 +30,7 @@ class Meta: ordering = ("-created_at",) def __str__(self): - return f"Order #{self.id} created by {self.user}" + return f"Order #{self.id}" class AirplaneType(models.Model): @@ -66,7 +66,7 @@ class Flight(models.Model): crew = models.ManyToManyField(Crew, related_name="flights") def __str__(self): - return f"Flight #{self.id}: {self.route}. Airplane: {self.airplane}" + return f"Flight #{self.id}" class Ticket(models.Model): @@ -120,4 +120,4 @@ class Route(models.Model): distance = models.IntegerField(validators=[MinValueValidator(1)]) def __str__(self): - return f"from {self.source} to {self.destination}" + return f"from {self.source.name} to {self.destination.name}" From acbed26371c02807ee708c6e3c432953239c63b1 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 18:39:46 +0200 Subject: [PATCH 28/62] updated serializers --- flight_service/serializers.py | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/flight_service/serializers.py b/flight_service/serializers.py index d74c161..740fee9 100644 --- a/flight_service/serializers.py +++ b/flight_service/serializers.py @@ -27,12 +27,6 @@ class Meta: class RouteSerializer(serializers.ModelSerializer): - class Meta: - model = Route - fields = "__all__" - - -class RouteListSerializer(serializers.ModelSerializer): source = serializers.SlugField( source="source.name", ) @@ -45,8 +39,24 @@ class Meta: fields = "__all__" +class RouteListSerializer(RouteSerializer): + class Meta: + model = Route + fields = ("id", "source", "destination") + + +class RouteDetailSerializer(RouteSerializer): + source = AirportSerializer( + read_only=True, many=False + + ) + destination = AirportSerializer( + read_only=True, many=False + ) + + class FlightSerializer(serializers.ModelSerializer): - route = serializers.StringRelatedField(read_only=True, many=False) + route = RouteListSerializer(read_only=True, many=False) airplane = serializers.StringRelatedField(read_only=True, many=False) crew = serializers.StringRelatedField(read_only=True, many=True) @@ -76,6 +86,10 @@ def validate(self, attrs): class TicketFlightDetailSerializer(FlightListSerializer): + route = RouteDetailSerializer( + read_only=True, many=False + ) + class Meta: model = Flight fields = ("route", "airplane", "departure_time", "arrival_time") @@ -83,11 +97,10 @@ class Meta: class TicketListSerializer(TicketSerializer): flight = TicketFlightDetailSerializer(many=False, read_only=True) - route = RouteListSerializer(many=False, read_only=True) class TicketDetailSerializer(TicketListSerializer): - flight = FlightListSerializer(many=False, read_only=True) + flight = TicketFlightDetailSerializer(many=False, read_only=True) class Meta: model = Ticket @@ -106,6 +119,9 @@ class Meta: class FlightDetailSerializer(FlightSerializer): + route = RouteSerializer( + many=False, read_only=True + ) seats_taken = TicketSeatSerializer( source="tickets", many=True, read_only=True ) From 8989a3e8fda7f62a9c55c11b64ea4d82b7d6b92a Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 18:40:03 +0200 Subject: [PATCH 29/62] optimized queries for viewsets --- flight_service/views.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/flight_service/views.py b/flight_service/views.py index 57b6079..de33c02 100644 --- a/flight_service/views.py +++ b/flight_service/views.py @@ -29,6 +29,7 @@ AirplaneDetailSerializer, FlightDetailSerializer, TicketDetailSerializer, + RouteDetailSerializer, ) @@ -56,6 +57,16 @@ def get_serializer_class(self): return OrderSerializer + def get_queryset(self): + return self.queryset.prefetch_related( + "tickets", + "tickets__flight", + "tickets__flight__crew", + "tickets__flight__airplane", + "tickets__flight__route__source", + "tickets__flight__route__destination", + ) + class AirplaneTypeViewSet(viewsets.ModelViewSet): queryset = AirplaneType.objects.all() @@ -86,7 +97,9 @@ def get_serializer_class(self): return FlightSerializer def get_queryset(self): - return self.queryset.annotate( + return self.queryset.prefetch_related( + "airplane", "route__source", "route__destination", "crew" + ).annotate( tickets_available=( F("airplane__rows") * F("airplane__seats") - Count("tickets") ) @@ -104,6 +117,16 @@ def get_serializer_class(self): return TicketDetailSerializer return TicketSerializer + def get_queryset(self): + return self.queryset.prefetch_related( + "flight", + "order__user", + "flight__route", + "flight__route__source", + "flight__route__destination", + "flight__airplane", + ) + class RouteViewSet(viewsets.ModelViewSet): queryset = Route.objects.all() @@ -112,4 +135,10 @@ class RouteViewSet(viewsets.ModelViewSet): def get_serializer_class(self): if self.action == "list": return RouteListSerializer + if self.action == "retrieve": + return RouteDetailSerializer + return RouteSerializer + + def get_queryset(self): + return self.queryset.select_related("source", "destination") From 2e6670a270df27b7629803e3f56ca59c6fb2e4f4 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 19:05:12 +0200 Subject: [PATCH 30/62] customized order serializer --- flight_service/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flight_service/serializers.py b/flight_service/serializers.py index 740fee9..be96d98 100644 --- a/flight_service/serializers.py +++ b/flight_service/serializers.py @@ -86,7 +86,7 @@ def validate(self, attrs): class TicketFlightDetailSerializer(FlightListSerializer): - route = RouteDetailSerializer( + route = RouteListSerializer( read_only=True, many=False ) @@ -164,7 +164,7 @@ def create(self, validated_data): class OrderListSerializer(OrderSerializer): - tickets = TicketDetailSerializer(many=True, read_only=True) + tickets = TicketListSerializer(many=True, read_only=True) class OrderDetailSerializer(OrderSerializer): From d8cc0e469a691f8ba54210770c76e3886aa21494 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 19:05:27 +0200 Subject: [PATCH 31/62] added default and custom pagination --- core/settings.py | 17 +++++++++++++++++ flight_service/views.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/core/settings.py b/core/settings.py index 795a5d0..33cedcd 100644 --- a/core/settings.py +++ b/core/settings.py @@ -138,3 +138,20 @@ # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +REST_FRAMEWORK = { + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", + "PAGE_SIZE": 4, + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + "rest_framework.throttling.UserRateThrottle" + ], + "DEFAULT_THROTTLE_RATES": { + "anon": "10/minute", + "user": "30/minute" + }, + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ) +} diff --git a/flight_service/views.py b/flight_service/views.py index de33c02..ccfebad 100644 --- a/flight_service/views.py +++ b/flight_service/views.py @@ -1,5 +1,6 @@ -from django.db.models import F, Count +from django.db.models import F, Count, Q from rest_framework import viewsets +from rest_framework.pagination import PageNumberPagination from rest_framework.permissions import AllowAny from flight_service.models import ( @@ -38,16 +39,42 @@ class CrewViewSet(viewsets.ModelViewSet): serializer_class = CrewSerializer permission_classes = (AllowAny,) + def get_queryset(self): + """Get crew member by name or surname""" + search_query = self.request.query_params.get("search") + + if search_query: + return self.queryset.filter( + Q(first_name__icontains=search_query) + | Q(last_name__icontains=search_query)) + return self.queryset + class AirportViewSet(viewsets.ModelViewSet): queryset = Airport.objects.all() serializer_class = AirportSerializer permission_classes = (AllowAny,) + def get_queryset(self): + """Get airport by name""" + name = self.request.query_params.get("name") + + if name: + return self.queryset.filter(name__icontains=name) + + return self.queryset + + +class OrderPagination(PageNumberPagination): + page_size = 2 + page_size_query_param = "page_size" + max_page_size = 100 + class OrderViewSet(viewsets.ModelViewSet): queryset = Order.objects.all() permission_classes = (AllowAny,) + pagination_class = OrderPagination def get_serializer_class(self): if self.action == "list": From 4a7443b89843b80563d1344816556eba6c7f7e6b Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 19:06:00 +0200 Subject: [PATCH 32/62] added default and custom pagination --- flight_service/views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flight_service/views.py b/flight_service/views.py index ccfebad..7b292dd 100644 --- a/flight_service/views.py +++ b/flight_service/views.py @@ -65,7 +65,7 @@ def get_queryset(self): return self.queryset -class OrderPagination(PageNumberPagination): +class CustomPagination(PageNumberPagination): page_size = 2 page_size_query_param = "page_size" max_page_size = 100 @@ -74,7 +74,7 @@ class OrderPagination(PageNumberPagination): class OrderViewSet(viewsets.ModelViewSet): queryset = Order.objects.all() permission_classes = (AllowAny,) - pagination_class = OrderPagination + pagination_class = CustomPagination def get_serializer_class(self): if self.action == "list": @@ -136,6 +136,7 @@ def get_queryset(self): class TicketViewSet(viewsets.ModelViewSet): queryset = Ticket.objects.all() permission_classes = (AllowAny,) + pagination_class = CustomPagination def get_serializer_class(self): if self.action == "list": From 47554dfcc5b97b2fce5d36fc22e1016c6c08c705 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 19:50:42 +0200 Subject: [PATCH 33/62] added custom permission --- flight_service/permissions.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 flight_service/permissions.py diff --git a/flight_service/permissions.py b/flight_service/permissions.py new file mode 100644 index 0000000..4bc5d19 --- /dev/null +++ b/flight_service/permissions.py @@ -0,0 +1,13 @@ +from rest_framework.permissions import SAFE_METHODS, BasePermission + + +class IsAdminOrAuthenticatedReadOnly(BasePermission): + def has_permission(self, request, view): + return bool( + ( + request.method in SAFE_METHODS + and request.user + and request.user.is_authenticated + ) + or (request.user and request.user.is_staff) + ) From 0f15e627927df125360dcac47fa1d0a4e1a6ac5c Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 19:50:59 +0200 Subject: [PATCH 34/62] updated viewsets permission classes --- flight_service/views.py | 121 ++++++++++++++++++++++++++++------------ 1 file changed, 86 insertions(+), 35 deletions(-) diff --git a/flight_service/views.py b/flight_service/views.py index 7b292dd..8986c14 100644 --- a/flight_service/views.py +++ b/flight_service/views.py @@ -1,7 +1,12 @@ +from datetime import datetime + from django.db.models import F, Count, Q from rest_framework import viewsets from rest_framework.pagination import PageNumberPagination -from rest_framework.permissions import AllowAny +from rest_framework.permissions import ( + IsAdminUser, + IsAuthenticatedOrReadOnly, IsAuthenticated +) from flight_service.models import ( Crew, @@ -13,6 +18,7 @@ Ticket, Route, ) +from flight_service.permissions import IsAdminOrAuthenticatedReadOnly from flight_service.serializers import ( CrewSerializer, AirportSerializer, @@ -33,11 +39,16 @@ RouteDetailSerializer, ) +class CustomPagination(PageNumberPagination): + page_size = 2 + page_size_query_param = "page_size" + max_page_size = 100 + class CrewViewSet(viewsets.ModelViewSet): queryset = Crew.objects.all() serializer_class = CrewSerializer - permission_classes = (AllowAny,) + permission_classes = (IsAdminUser,) def get_queryset(self): """Get crew member by name or surname""" @@ -53,7 +64,7 @@ def get_queryset(self): class AirportViewSet(viewsets.ModelViewSet): queryset = Airport.objects.all() serializer_class = AirportSerializer - permission_classes = (AllowAny,) + permission_classes = (IsAdminOrAuthenticatedReadOnly,) def get_queryset(self): """Get airport by name""" @@ -65,15 +76,16 @@ def get_queryset(self): return self.queryset -class CustomPagination(PageNumberPagination): - page_size = 2 - page_size_query_param = "page_size" - max_page_size = 100 - - class OrderViewSet(viewsets.ModelViewSet): - queryset = Order.objects.all() - permission_classes = (AllowAny,) + queryset = Order.objects.prefetch_related( + "tickets", + "tickets__flight", + "tickets__flight__crew", + "tickets__flight__airplane", + "tickets__flight__route__source", + "tickets__flight__route__destination", + ) + permission_classes = (IsAuthenticated,) pagination_class = CustomPagination def get_serializer_class(self): @@ -85,25 +97,23 @@ def get_serializer_class(self): return OrderSerializer def get_queryset(self): - return self.queryset.prefetch_related( - "tickets", - "tickets__flight", - "tickets__flight__crew", - "tickets__flight__airplane", - "tickets__flight__route__source", - "tickets__flight__route__destination", + return self.queryset.filter( + user=self.request.user ) + def perform_create(self, serializer): + serializer.save(user=self.request.user) + class AirplaneTypeViewSet(viewsets.ModelViewSet): queryset = AirplaneType.objects.all() serializer_class = AirplaneTypeSerializer - permission_classes = (AllowAny,) + permission_classes = (IsAdminOrAuthenticatedReadOnly,) class AirplaneViewSet(viewsets.ModelViewSet): queryset = Airplane.objects.all() - permission_classes = (AllowAny,) + permission_classes = (IsAdminOrAuthenticatedReadOnly,) def get_serializer_class(self): if self.action == "retrieve": @@ -111,10 +121,25 @@ def get_serializer_class(self): return AirplaneSerializer + def get_queryset(self): + """Search airplane by type or name""" + + type_id = self.request.query_params.get("type") + name = self.request.query_params.get("name") + + if type_id: + self.queryset = self.queryset.select_related( + "airplane_type").filter(airplane_type__id=int(type_id)) + + if name: + self.queryset = self.queryset.filter(name__icontains=name) + + return self.queryset + class FlightViewSet(viewsets.ModelViewSet): queryset = Flight.objects.all() - permission_classes = (AllowAny,) + permission_classes = (IsAdminOrAuthenticatedReadOnly,) def get_serializer_class(self): if self.action == "list": @@ -124,18 +149,49 @@ def get_serializer_class(self): return FlightSerializer def get_queryset(self): - return self.queryset.prefetch_related( + """Search flight by departure date, source or destination""" + + date = self.request.query_params.get("date") + source = self.request.query_params.get("source") + destination = self.request.query_params.get("destination") + + self.queryset = self.queryset.prefetch_related( "airplane", "route__source", "route__destination", "crew" - ).annotate( + ) + + if date: + date = datetime.strptime(date, "%Y-%m-%d").date() + self.queryset = self.queryset.filter( + departure_time__date=date + ) + + if source: + self.queryset = self.queryset.filter( + route__source__name__icontains=source + ) + + if destination: + self.queryset = self.queryset.filter( + route__destination__name__icontains=destination + ) + + return self.queryset.annotate( tickets_available=( - F("airplane__rows") * F("airplane__seats") - Count("tickets") + F("airplane__rows") * F("airplane__seats") - Count("tickets") ) ) -class TicketViewSet(viewsets.ModelViewSet): - queryset = Ticket.objects.all() - permission_classes = (AllowAny,) +class TicketViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Ticket.objects.prefetch_related( + "flight", + "order__user", + "flight__route", + "flight__route__source", + "flight__route__destination", + "flight__airplane", + ) + permission_classes = (IsAuthenticated,) pagination_class = CustomPagination def get_serializer_class(self): @@ -146,19 +202,14 @@ def get_serializer_class(self): return TicketSerializer def get_queryset(self): - return self.queryset.prefetch_related( - "flight", - "order__user", - "flight__route", - "flight__route__source", - "flight__route__destination", - "flight__airplane", + return self.queryset.filter( + order__user=self.request.user ) class RouteViewSet(viewsets.ModelViewSet): queryset = Route.objects.all() - permission_classes = (AllowAny,) + permission_classes = (IsAdminOrAuthenticatedReadOnly,) def get_serializer_class(self): if self.action == "list": From 15afa407717cf59254389b1984a027f545db04de Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 21:09:49 +0200 Subject: [PATCH 35/62] loaded database with instances and prepared fixture --- flight_service/migrations/0001_initial.py | 112 +++++++++++++++------- flight_service_data.json | Bin 0 -> 35778 bytes 2 files changed, 79 insertions(+), 33 deletions(-) create mode 100644 flight_service_data.json diff --git a/flight_service/migrations/0001_initial.py b/flight_service/migrations/0001_initial.py index 8e200c2..92d7055 100644 --- a/flight_service/migrations/0001_initial.py +++ b/flight_service/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 4.2.8 on 2023-12-11 10:06 +# Generated by Django 4.2.8 on 2023-12-11 18:17 +from django.conf import settings import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -8,7 +9,9 @@ class Migration(migrations.Migration): initial = True - dependencies = [] + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] operations = [ migrations.CreateModel( @@ -66,7 +69,7 @@ class Migration(migrations.Migration): ), ), ("name", models.CharField(max_length=255)), - ("closes_big_city", models.CharField(max_length=255)), + ("closest_big_city", models.CharField(max_length=255)), ], ), migrations.CreateModel( @@ -86,7 +89,7 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name="Flight", + name="Order", fields=[ ( "id", @@ -97,12 +100,22 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("departure_time", models.DateTimeField()), - ("arrival_time", models.DateTimeField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="orders", + to=settings.AUTH_USER_MODEL, + ), + ), ], + options={ + "ordering": ("-created_at",), + }, ), migrations.CreateModel( - name="Order", + name="Route", fields=[ ( "id", @@ -113,11 +126,32 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "distance", + models.IntegerField( + validators=[django.core.validators.MinValueValidator(1)] + ), + ), + ( + "destination", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="route_destinations", + to="flight_service.airport", + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="route_sources", + to="flight_service.airport", + ), + ), ], ), migrations.CreateModel( - name="Ticket", + name="Flight", fields=[ ( "id", @@ -128,31 +162,43 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("row", models.IntegerField()), - ("seat", models.IntegerField()), + ("departure_time", models.DateTimeField()), + ("arrival_time", models.DateTimeField()), ( - "flight", + "airplane", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - related_name="tickets", - to="flight_service.flight", + related_name="flights", + to="flight_service.airplane", ), ), ( - "order", + "crew", + models.ManyToManyField( + related_name="flights", to="flight_service.crew" + ), + ), + ( + "route", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - related_name="tickets", - to="flight_service.order", + related_name="flights", + to="flight_service.route", ), ), ], - options={ - "ordering": ["row", "seat"], - }, + ), + migrations.AddField( + model_name="airplane", + name="airplane_type", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="airplanes", + to="flight_service.airplanetype", + ), ), migrations.CreateModel( - name="Route", + name="Ticket", fields=[ ( "id", @@ -163,28 +209,28 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), + ("row", models.IntegerField()), + ("seat", models.IntegerField()), ( - "distance", - models.IntegerField( - validators=[django.core.validators.MinValueValidator(1)] - ), - ), - ( - "destination", + "flight", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - related_name="route_destinations", - to="flight_service.airport", + related_name="tickets", + to="flight_service.flight", ), ), ( - "source", + "order", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - related_name="route_sources", - to="flight_service.airport", + related_name="tickets", + to="flight_service.order", ), ), ], + options={ + "ordering": ["row", "seat"], + "unique_together": {("flight", "row", "seat")}, + }, ), ] diff --git a/flight_service_data.json b/flight_service_data.json new file mode 100644 index 0000000000000000000000000000000000000000..a77a177d1f2d1b7be7aeed9d206702f036e4eed4 GIT binary patch literal 35778 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 F{2y(X80Y{1 literal 0 HcmV?d00001 From 8b73fb4590e947476b08f9e48f12312befd61148 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 21:10:05 +0200 Subject: [PATCH 36/62] run migrations to recreate db --- flight_service/migrations/0002_initial.py | 64 ------------------- ...loses_big_city_airport_closest_big_city.py | 17 ----- .../migrations/0004_alter_order_options.py | 16 ----- .../migrations/0005_alter_order_options.py | 16 ----- 4 files changed, 113 deletions(-) delete mode 100644 flight_service/migrations/0002_initial.py delete mode 100644 flight_service/migrations/0003_rename_closes_big_city_airport_closest_big_city.py delete mode 100644 flight_service/migrations/0004_alter_order_options.py delete mode 100644 flight_service/migrations/0005_alter_order_options.py diff --git a/flight_service/migrations/0002_initial.py b/flight_service/migrations/0002_initial.py deleted file mode 100644 index 360da2d..0000000 --- a/flight_service/migrations/0002_initial.py +++ /dev/null @@ -1,64 +0,0 @@ -# Generated by Django 4.2.8 on 2023-12-11 10:06 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - ("flight_service", "0001_initial"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name="order", - name="user", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="orders", - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AddField( - model_name="flight", - name="airplane", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="flights", - to="flight_service.airplane", - ), - ), - migrations.AddField( - model_name="flight", - name="crew", - field=models.ManyToManyField( - related_name="flights", to="flight_service.crew" - ), - ), - migrations.AddField( - model_name="flight", - name="route", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="flights", - to="flight_service.route", - ), - ), - migrations.AddField( - model_name="airplane", - name="airplane_type", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="airplanes", - to="flight_service.airplanetype", - ), - ), - migrations.AlterUniqueTogether( - name="ticket", - unique_together={("flight", "row", "seat")}, - ), - ] diff --git a/flight_service/migrations/0003_rename_closes_big_city_airport_closest_big_city.py b/flight_service/migrations/0003_rename_closes_big_city_airport_closest_big_city.py deleted file mode 100644 index a962718..0000000 --- a/flight_service/migrations/0003_rename_closes_big_city_airport_closest_big_city.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.8 on 2023-12-11 10:33 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("flight_service", "0002_initial"), - ] - - operations = [ - migrations.RenameField( - model_name="airport", - old_name="closes_big_city", - new_name="closest_big_city", - ), - ] diff --git a/flight_service/migrations/0004_alter_order_options.py b/flight_service/migrations/0004_alter_order_options.py deleted file mode 100644 index 02a7b1e..0000000 --- a/flight_service/migrations/0004_alter_order_options.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 4.2.8 on 2023-12-11 15:09 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("flight_service", "0003_rename_closes_big_city_airport_closest_big_city"), - ] - - operations = [ - migrations.AlterModelOptions( - name="order", - options={"ordering": ("created_at",)}, - ), - ] diff --git a/flight_service/migrations/0005_alter_order_options.py b/flight_service/migrations/0005_alter_order_options.py deleted file mode 100644 index 6747221..0000000 --- a/flight_service/migrations/0005_alter_order_options.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 4.2.8 on 2023-12-11 16:38 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("flight_service", "0004_alter_order_options"), - ] - - operations = [ - migrations.AlterModelOptions( - name="order", - options={"ordering": ("-created_at",)}, - ), - ] From de80c0ef86a00daff21cd483680df79a9d27d73d Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 21:10:51 +0200 Subject: [PATCH 37/62] updated route serializer to improve route instance creation process --- flight_service/serializers.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/flight_service/serializers.py b/flight_service/serializers.py index be96d98..3fb4826 100644 --- a/flight_service/serializers.py +++ b/flight_service/serializers.py @@ -27,19 +27,21 @@ class Meta: class RouteSerializer(serializers.ModelSerializer): + class Meta: + model = Route + fields = "__all__" + + +class RouteListSerializer(RouteSerializer): source = serializers.SlugField( source="source.name", + read_only=True ) destination = serializers.SlugField( source="destination.name", + read_only=True ) - class Meta: - model = Route - fields = "__all__" - - -class RouteListSerializer(RouteSerializer): class Meta: model = Route fields = ("id", "source", "destination") @@ -47,18 +49,15 @@ class Meta: class RouteDetailSerializer(RouteSerializer): source = AirportSerializer( - read_only=True, many=False + read_only=False, many=False ) destination = AirportSerializer( - read_only=True, many=False + read_only=False, many=False ) class FlightSerializer(serializers.ModelSerializer): - route = RouteListSerializer(read_only=True, many=False) - airplane = serializers.StringRelatedField(read_only=True, many=False) - crew = serializers.StringRelatedField(read_only=True, many=True) class Meta: model = Flight @@ -66,6 +65,9 @@ class Meta: class FlightListSerializer(FlightSerializer): + route = RouteListSerializer(read_only=True, many=False) + airplane = serializers.StringRelatedField(read_only=True, many=False) + crew = serializers.StringRelatedField(read_only=True, many=True) tickets_available = serializers.IntegerField(read_only=True) From f350525e90ef5891553af911ac2c8710a66ac981 Mon Sep 17 00:00:00 2001 From: naiv Date: Mon, 11 Dec 2023 21:11:20 +0200 Subject: [PATCH 38/62] changed access token lifetime for easier development process --- core/settings.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/core/settings.py b/core/settings.py index 33cedcd..325d7e2 100644 --- a/core/settings.py +++ b/core/settings.py @@ -9,7 +9,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.2/ref/settings/ """ - +from datetime import timedelta from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -153,5 +153,11 @@ }, "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework_simplejwt.authentication.JWTAuthentication", + ) } + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5000), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), +} From f8a762f7c4259ac36f34bb72f68ad0cf27872ed5 Mon Sep 17 00:00:00 2001 From: naiv Date: Tue, 12 Dec 2023 14:07:13 +0200 Subject: [PATCH 39/62] updated db fixture --- flight_service_data.json | Bin 35778 -> 24141 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/flight_service_data.json b/flight_service_data.json index a77a177d1f2d1b7be7aeed9d206702f036e4eed4..84535d07c0e0716d33df9ed9da9de1d6f3d89bab 100644 GIT binary patch literal 24141 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 W>0mys9Z#zF&rEdN(R}gk#s2}*OtW|Z literal 35778 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 F{2y(X80Y{1 From 2719b2708cf9d49bdcb60981a2d97064b6ca9f52 Mon Sep 17 00:00:00 2001 From: naiv Date: Tue, 12 Dec 2023 14:07:53 +0200 Subject: [PATCH 40/62] added swagger to project for documentation purpose --- core/settings.py | 55 +++++++++++++++++++++++++++++++++++------------- core/urls.py | 11 +++++++++- user/urls.py | 7 ++---- 3 files changed, 52 insertions(+), 21 deletions(-) diff --git a/core/settings.py b/core/settings.py index 325d7e2..b1129c9 100644 --- a/core/settings.py +++ b/core/settings.py @@ -9,8 +9,12 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.2/ref/settings/ """ +import os from datetime import timedelta from pathlib import Path +from dotenv import load_dotenv + +load_dotenv() # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -85,6 +89,19 @@ # Database # https://docs.djangoproject.com/en/4.2/ref/settings/#databases +# Production database settings +# DATABASES = { +# "default": { +# "ENGINE": "django.db.backends.postgresql", +# "HOST": os.environ["POSTGRES_HOST"], +# "NAME": os.environ["POSTGRES_DB"], +# "USER": os.environ["POSTGRES_USER"], +# "PASSWORD": os.environ["POSTGRES_PASSWORD"], +# "PORT": os.environ["POSTGRES_PORT"] +# } +# } + +# Development database setting DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", @@ -99,19 +116,16 @@ AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation." - "UserAttributeSimilarityValidator", + "UserAttributeSimilarityValidator", }, { - "NAME": "django.contrib.auth.password_validation." - "MinimumLengthValidator", + "NAME": "django.contrib.auth.password_validation." "MinimumLengthValidator", }, { - "NAME": "django.contrib.auth.password_validation." - "CommonPasswordValidator", + "NAME": "django.contrib.auth.password_validation." "CommonPasswordValidator", }, { - "NAME": "django.contrib.auth.password_validation." - "NumericPasswordValidator", + "NAME": "django.contrib.auth.password_validation." "NumericPasswordValidator", }, ] @@ -145,19 +159,30 @@ "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_THROTTLE_CLASSES": [ "rest_framework.throttling.AnonRateThrottle", - "rest_framework.throttling.UserRateThrottle" + "rest_framework.throttling.UserRateThrottle", ], - "DEFAULT_THROTTLE_RATES": { - "anon": "10/minute", - "user": "30/minute" - }, + "DEFAULT_THROTTLE_RATES": {"anon": "10/minute", "user": "30/minute"}, "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework_simplejwt.authentication.JWTAuthentication", - - ) + ), } SIMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5000), + "ACCESS_TOKEN_LIFETIME": timedelta(days=500000), "REFRESH_TOKEN_LIFETIME": timedelta(days=1), } + +SPECTACULAR_SETTINGS = { + "TITLE": "Flight Service API", + "DESCRIPTION": "API for handling flights. Functionality includes flights, airports and crews " + "management as well as ticket ordering process for customers", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "SWAGGER_UI_SETTING": { + "deepLinking": True, + "defaultModelRendering": "model", + "defaultModelsExpandDepth": 2, + "defaultModelExpandDepth": 2, + } + +} diff --git a/core/urls.py b/core/urls.py index ab09345..0d1d997 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,9 +1,18 @@ from django.contrib import admin from django.urls import path, include +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView urlpatterns = [ path("admin/", admin.site.urls), path("api/user/", include("user.urls", namespace="user")), - path("api/flight_service/", include("flight_service.urls", namespace="flight-service")), + path( + "api/flight_service/", + include("flight_service.urls", namespace="flight-service"), + ), + path("__debug__/", include("debug_toolbar.urls")), + + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path("api/schema/swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger"), + path("api/schema/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), ] diff --git a/user/urls.py b/user/urls.py index 6dd795d..c09a362 100644 --- a/user/urls.py +++ b/user/urls.py @@ -2,13 +2,10 @@ from rest_framework_simplejwt.views import ( TokenObtainPairView, TokenRefreshView, - TokenVerifyView + TokenVerifyView, ) -from user.views import ( - CreateUserView, - ManageUserView -) +from user.views import CreateUserView, ManageUserView app_name = "user" From a6acb08eb06a2213009e63b6d0b80ea0134aefa2 Mon Sep 17 00:00:00 2001 From: naiv Date: Tue, 12 Dec 2023 14:08:36 +0200 Subject: [PATCH 41/62] improved formatting of models --- flight_service/models.py | 5 ++++- user/models.py | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/flight_service/models.py b/flight_service/models.py index 3f6856d..aa5327a 100644 --- a/flight_service/models.py +++ b/flight_service/models.py @@ -19,6 +19,9 @@ class Airport(models.Model): def __str__(self): return self.name + class Meta: + ordering = ("name",) + class Order(models.Model): created_at = models.DateTimeField(auto_now_add=True) @@ -107,7 +110,7 @@ def save( ) def __str__(self): - return f"Ticker #{self.id}: seat #{self.seat}, row #{self.row}" + return f"Ticker #{self.id}: row #{self.row}, seat #{self.seat}" class Route(models.Model): diff --git a/user/models.py b/user/models.py index 3a2a381..d3dec8d 100644 --- a/user/models.py +++ b/user/models.py @@ -5,6 +5,7 @@ class UserManager(BaseUserManager): """Define a model manager for User model with no username field.""" + use_in_migrations = True def _create_user(self, email: str, password: str, **extra_fields): From 6acd632d5993d994aa9e118c66ebf4143a9d6af9 Mon Sep 17 00:00:00 2001 From: naiv Date: Tue, 12 Dec 2023 14:09:04 +0200 Subject: [PATCH 42/62] created tests for serializers, viewsets, endpoints and models --- flight_service/tests/sample_functions.py | 84 ++++ .../tests/test_airport_api_models.py | 117 +++++ flight_service/tests/test_airport_api_view.py | 417 ++++++++++++++++++ 3 files changed, 618 insertions(+) create mode 100644 flight_service/tests/sample_functions.py create mode 100644 flight_service/tests/test_airport_api_models.py create mode 100644 flight_service/tests/test_airport_api_view.py diff --git a/flight_service/tests/sample_functions.py b/flight_service/tests/sample_functions.py new file mode 100644 index 0000000..24a8056 --- /dev/null +++ b/flight_service/tests/sample_functions.py @@ -0,0 +1,84 @@ +from datetime import timedelta + +from django.utils import timezone + + +from flight_service.models import ( + Crew, + Airport, + Order, + AirplaneType, + Route, + Flight, + Ticket, + Airplane, +) + + +def sample_crew(**params): + defaults = { + "first_name": "Sample name", + "last_name": "Sample surname", + } + defaults.update(params) + return Crew.objects.create(**defaults) + + +def sample_airport(**params): + defaults = { + "name": "Sample name", + "closest_big_city": "Sample City", + } + defaults.update(params) + return Airport.objects.create(**defaults) + + +def sample_ticket(**params): + defaults = { + "row": Ticket.objects.count() + 1, + "seat": Ticket.objects.count() + 1, + "flight_id": 1, + "order_id": 1, + } + defaults.update(params) + return Ticket.objects.create(**defaults) + + +def sample_order(**params): + defaults = { + "created_at": timezone.now(), + "user_id": 1, + } + defaults.update(params) + return Order.objects.create(**defaults) + + +def sample_airplane_type(**params): + defaults = { + "name": "Sample name", + } + defaults.update(params) + return AirplaneType.objects.create(**defaults) + + +def sample_airplane(**params): + defaults = {"name": "Sample name", "rows": 20, "seats": 6, "airplane_type_id": 1} + defaults.update(params) + return Airplane.objects.create(**defaults) + + +def sample_route(**params): + defaults = {"source_id": 1, "destination_id": 2, "distance": 100} + defaults.update(params) + return Route.objects.create(**defaults) + + +def sample_flight(**params): + defaults = { + "route_id": 1, + "airplane_id": 1, + "departure_time": timezone.now(), + "arrival_time": timezone.now() + timedelta(hours=3), + } + defaults.update(params) + return Flight.objects.create(**defaults) diff --git a/flight_service/tests/test_airport_api_models.py b/flight_service/tests/test_airport_api_models.py new file mode 100644 index 0000000..2851742 --- /dev/null +++ b/flight_service/tests/test_airport_api_models.py @@ -0,0 +1,117 @@ +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.test import TestCase +from rest_framework.test import APIClient + +from flight_service.models import Airport, Order, Ticket +from flight_service.tests.sample_functions import ( + sample_crew, + sample_airplane_type, + sample_airplane, + sample_airport, + sample_route, + sample_order, + sample_flight, + sample_ticket, +) + + +class ModelsTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_superuser( + email="admin@myproject.com", password="password" + ) + self.client.force_authenticate(self.user) + self.crew = sample_crew() + self.airplane_type = sample_airplane_type() + self.airplane = sample_airplane() + self.airport1 = sample_airport() + self.airport2 = sample_airport() + self.route = sample_route() + self.order = sample_order(user_id=self.user.id) + self.flight = sample_flight() + self.flight.crew.add(self.crew) + self.ticket = sample_ticket() + + def test_crew_str_method(self): + self.assertEqual( + str(self.crew), f"{self.crew.first_name} {self.crew.last_name}" + ) + + def test_airport_str_method(self): + self.assertEqual(str(self.airport1), self.airport1.name) + + def test_airport_ordering(self): + self.airport1.name = "A test name" + self.airport2.name = "B test name" + airports = list(Airport.objects.all()) + + self.assertEqual(airports, [self.airport1, self.airport2]) + + def test_order_str_method(self): + self.assertEqual(str(self.order), f"Order #{self.order.id}") + + def test_orders_ordering(self): + order = sample_order() + orders = list(Order.objects.all()) + + self.assertEqual(orders, [order, self.order]) + + def test_airplane_type_str_method(self): + self.assertEqual(str(self.airplane_type), self.airplane_type.name) + + def test_airplane_str_method(self): + self.assertEqual(str(self.airplane), self.airplane.name) + + def test_airplane_capacity_method(self): + self.assertEqual( + self.airplane.capacity, self.airplane.rows * self.airplane.seats + ) + + def test_flight_str_method(self): + self.assertEqual(str(self.flight), f"Flight #{self.flight.id}") + + def test_route_str_method(self): + self.assertEqual( + str(self.route), + f"from {self.route.source.name} to {self.route.destination.name}", + ) + + def test_ticket_str_method(self): + self.assertEqual( + str(self.ticket), + f"Ticker #{self.ticket.id}: row #{self.ticket.row}, " + f"seat #{self.ticket.seat}", + ) + + def test_tickets_ordering(self): + ticket1 = sample_ticket(row=2, seat=2) + ticket2 = sample_ticket(row=1, seat=4) + ticket3 = sample_ticket(row=12, seat=1) + ticket4 = sample_ticket(row=13, seat=1) + + tickets = list(Ticket.objects.all()) + + self.assertEqual(tickets, [self.ticket, ticket2, ticket1, ticket3, ticket4]) + + def test_tickets_unique_flight_sear_row_validation(self): + sample_ticket(row=2, seat=2, flight_id=1) + with self.assertRaises(ValidationError): + sample_ticket(row=2, seat=2, flight_id=1) + + def test_tickets_seat_row_validation(self): + self.airplane.rows = 100 + self.airplane.seats = 10 + + with self.assertRaises(ValidationError): + sample_ticket(row=0, seat=3, flight_id=1) + + with self.assertRaises(ValidationError): + sample_ticket(row=3, seat=0, flight_id=1) + + with self.assertRaises(ValidationError): + sample_ticket(row=101, seat=5, flight_id=1) + + with self.assertRaises(ValidationError): + sample_ticket(row=4, seat=13, flight_id=1) diff --git a/flight_service/tests/test_airport_api_view.py b/flight_service/tests/test_airport_api_view.py new file mode 100644 index 0000000..dd7a430 --- /dev/null +++ b/flight_service/tests/test_airport_api_view.py @@ -0,0 +1,417 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.db.models import F, Count +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient + +from flight_service.models import Crew, Airport, Order, Route, Flight, Ticket +from flight_service.serializers import ( + AirportSerializer, + FlightListSerializer, + OrderListSerializer, + FlightDetailSerializer, + OrderDetailSerializer, + RouteListSerializer, + TicketListSerializer, + RouteDetailSerializer, + TicketDetailSerializer, + CrewSerializer, +) +from flight_service.tests.sample_functions import ( + sample_crew, + sample_airplane_type, + sample_airplane, + sample_airport, + sample_route, + sample_order, + sample_flight, + sample_ticket, +) + + +FLIGHT_URL = reverse("flight-service:flight-list") +CREW_URL = reverse("flight-service:crew-list") +AIRPORT_URL = reverse("flight-service:airport-list") +ORDER_URL = reverse("flight-service:order-list") +AIRPLANE_TYPE_URL = reverse("flight-service:airplanetype-list") +AIRPLANE_URL = reverse("flight-service:airplane-list") +TICKET_URL = reverse("flight-service:ticket-list") +ROUTE_URL = reverse("flight-service:route-list") + + +class UnauthenticatedApiTests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_flight_auth_required(self): + resp = self.client.get(FLIGHT_URL) + + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_crew_auth_required(self): + resp = self.client.get(CREW_URL) + + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_airport_auth_required(self): + resp = self.client.get(AIRPORT_URL) + + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_order_auth_required(self): + resp = self.client.get(ORDER_URL) + + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_airplanetype_auth_required(self): + resp = self.client.get(AIRPLANE_TYPE_URL) + + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_airplane_auth_required(self): + resp = self.client.get(AIRPLANE_URL) + + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_ticket_auth_required(self): + resp = self.client.get(TICKET_URL) + + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_route_auth_required(self): + resp = self.client.get(ROUTE_URL) + + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) + + +class AuthenticatedApiTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create( + email="admin@myproject.com", password="password" + ) + self.client.force_authenticate(self.user) + self.crew = sample_crew() + self.airplane_type = sample_airplane_type() + self.airplane = sample_airplane() + self.airport1 = sample_airport() + self.airport2 = sample_airport() + self.route = sample_route() + self.order = sample_order(user_id=self.user.id) + self.flight = sample_flight() + self.flight.crew.add(self.crew) + self.ticket = sample_ticket() + + def test_list_flights(self): + sample_flight() + + flights = Flight.objects.annotate( + tickets_available=( + F("airplane__rows") * F("airplane__seats") - Count("tickets") + ) + ) + serializer = FlightListSerializer(flights, many=True) + + resp = self.client.get(FLIGHT_URL) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.data["results"], serializer.data) + + def test_retrieve_flight(self): + sample_flight() + + flight = Flight.objects.filter(id=self.flight.id).first() + serializer = FlightDetailSerializer(flight, many=False) + + resp = self.client.get( + reverse("flight-service:flight-detail", args=[self.flight.id]) + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.data, serializer.data) + + def test_list_flights_filter_by_date(self): + flight = sample_flight(departure_time=timezone.now() - timedelta(days=100)) + + serializer = FlightListSerializer( + Flight.objects.filter(id=flight.id) + .annotate( + tickets_available=( + F("airplane__rows") * F("airplane__seats") - Count("tickets") + ) + ) + .first() + ) + + resp = self.client.get( + FLIGHT_URL, {"data": f"{(timezone.now() - timedelta(days=100)).date()}"} + ) + self.assertIn(serializer.data, resp.data["results"]) + + def test_list_flights_filter_by_source(self): + airport = sample_airport(name="Correct source") + route = sample_route(source_id=airport.id) + flight = sample_flight( + departure_time=timezone.now() - timedelta(days=100), route_id=route.id + ) + + serializer = FlightListSerializer( + Flight.objects.filter(id=flight.id) + .annotate( + tickets_available=( + F("airplane__rows") * F("airplane__seats") - Count("tickets") + ) + ) + .first() + ) + + resp = self.client.get(FLIGHT_URL, {"source": "Correct"}) + self.assertIn(serializer.data, resp.data["results"]) + + def test_list_flights_filter_by_destination(self): + airport = sample_airport(name="Correct destination") + route = sample_route(destination_id=airport.id) + flight = sample_flight( + departure_time=timezone.now() - timedelta(days=100), route_id=route.id + ) + + serializer = FlightListSerializer( + Flight.objects.filter(id=flight.id) + .annotate( + tickets_available=( + F("airplane__rows") * F("airplane__seats") - Count("tickets") + ) + ) + .first() + ) + + resp = self.client.get(FLIGHT_URL, {"destination": "Correct"}) + self.assertIn(serializer.data, resp.data["results"]) + + def test_flight_admin_required(self): + payload = { + "route": self.route.id, + "airplane": self.airplane.id, + "departure_time": timezone.now(), + "arrival_time": timezone.now() + timedelta(hours=3), + "crew": [1], + } + + resp = self.client.post(FLIGHT_URL, payload) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + + def test_crew_admin_required(self): + payload = { + "first_name": "Test name", + "last_name": "Test surname", + } + + resp = self.client.post(CREW_URL, payload) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + + def test_airports_list(self): + sample_airport() + airports = Airport.objects.all() + serializer = AirportSerializer(airports, many=True) + + resp = self.client.get(AIRPORT_URL) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + + self.assertEqual(resp.data["results"], serializer.data) + + def test_airports_filter_by_name(self): + airport = sample_airport(name="Correct name") + + serializer = AirportSerializer(Airport.objects.get(id=airport.id)) + + resp = self.client.get(AIRPORT_URL, {"name": "Correct"}) + + self.assertIn(serializer.data, resp.data["results"]) + + def test_airport_admin_required(self): + payload = { + "name": "Test name", + "closest_big_city": "Test city", + } + + resp = self.client.post(AIRPORT_URL, payload) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + + def test_orders_list(self): + sample_order() + orders = Order.objects.all() + serializer = OrderListSerializer(orders, many=True) + + resp = self.client.get(ORDER_URL) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.data["results"], serializer.data) + + def test_retrieve_order(self): + order = Order.objects.filter(id=self.order.id).first() + serializer = OrderDetailSerializer(order, many=False) + + resp = self.client.get( + reverse("flight-service:order-detail", args=[self.order.id]) + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.data, serializer.data) + + def test_rote_list(self): + sample_route() + routes = Route.objects.all() + serializer = RouteListSerializer(routes, many=True) + + resp = self.client.get(ROUTE_URL) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + + self.assertEqual(resp.data["results"], serializer.data) + + def test_retrieve_route(self): + sample_route() + + route = Route.objects.filter(id=self.route.id).first() + serializer = RouteDetailSerializer(route) + + resp = self.client.get( + reverse("flight-service:route-detail", args=[self.route.id]) + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.data, serializer.data) + + def test_route_admin_required(self): + payload = {} + + resp = self.client.post(ROUTE_URL, payload) + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + + def test_ticket_list(self): + sample_ticket() + tickets = Ticket.objects.all() + serializer = TicketListSerializer(tickets, many=True) + + resp = self.client.get(TICKET_URL) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + + self.assertEqual(resp.data["results"], serializer.data) + + def test_retrieve_ticket(self): + sample_ticket() + + ticket = Ticket.objects.filter(id=self.ticket.id).first() + serializer = TicketDetailSerializer(ticket, many=False) + + resp = self.client.get( + reverse("flight-service:ticket-detail", args=[self.flight.id]) + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.data, serializer.data) + + def test_ticket_viewset_read_only(self): + payload = {} + + resp = self.client.post(TICKET_URL, payload) + self.assertEqual(resp.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + +class AdminApiTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_superuser( + email="admin@myproject.com", password="password" + ) + self.client.force_authenticate(self.user) + self.crew = sample_crew() + self.airplane_type = sample_airplane_type() + self.airplane = sample_airplane() + self.airport1 = sample_airport() + self.airport2 = sample_airport() + self.route = sample_route() + self.order = sample_order(user_id=self.user.id) + self.flight = sample_flight() + self.flight.crew.add(self.crew) + self.ticket = sample_ticket() + + def test_list_crew(self): + sample_crew() + + crews = Crew.objects.all() + serializer = CrewSerializer(crews, many=True) + + resp = self.client.get(CREW_URL) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.data["results"], serializer.data) + + def test_crew_creat_instance(self): + payload = {"first_name": "Test", "last_name": "Test"} + + resp = self.client.post(CREW_URL, payload) + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + + def test_airport_creat_instance(self): + payload = {"name": "Test", "closest_big_city": "Test"} + + resp = self.client.post(AIRPORT_URL, payload) + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + + def test_airplane_type_creat_instance(self): + payload = {"name": "Test"} + + resp = self.client.post(AIRPLANE_TYPE_URL, payload) + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + + def test_airplane_creat_instance(self): + payload = { + "name": "Test", + "rows": 12, + "seats": 3, + "airplane_type": self.airplane_type.id, + } + + resp = self.client.post(AIRPLANE_URL, payload) + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + + def test_flight_creat_instance(self): + payload = { + "route": self.route.id, + "airplane": self.airplane.id, + "departure_time": timezone.now(), + "arrival_time": timezone.now() + timedelta(hours=3), + "crew": [self.crew.id], + } + + resp = self.client.post(FLIGHT_URL, payload) + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + + def test_route_creat_instance(self): + payload = { + "source": self.airport1.id, + "destination": self.airport2.id, + "distance": 100, + } + + resp = self.client.post(ROUTE_URL, payload) + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + + def test_retrieve_flight(self): + sample_flight() + + flight = Flight.objects.filter(id=self.flight.id).first() + serializer = FlightDetailSerializer(flight, many=False) + + resp = self.client.get( + reverse("flight-service:flight-detail", args=[self.flight.id]) + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.data, serializer.data) From a7797463e278a5a81302847f52bf0ecf57f4b641 Mon Sep 17 00:00:00 2001 From: naiv Date: Tue, 12 Dec 2023 14:09:37 +0200 Subject: [PATCH 43/62] re-formatted serializer files --- flight_service/serializers.py | 53 ++++++++++++++--------------------- user/serializers.py | 5 +--- 2 files changed, 22 insertions(+), 36 deletions(-) diff --git a/flight_service/serializers.py b/flight_service/serializers.py index 3fb4826..22bbedb 100644 --- a/flight_service/serializers.py +++ b/flight_service/serializers.py @@ -33,14 +33,8 @@ class Meta: class RouteListSerializer(RouteSerializer): - source = serializers.SlugField( - source="source.name", - read_only=True - ) - destination = serializers.SlugField( - source="destination.name", - read_only=True - ) + source = serializers.SlugField(source="source.name", read_only=True) + destination = serializers.SlugField(source="destination.name", read_only=True) class Meta: model = Route @@ -48,17 +42,11 @@ class Meta: class RouteDetailSerializer(RouteSerializer): - source = AirportSerializer( - read_only=False, many=False - - ) - destination = AirportSerializer( - read_only=False, many=False - ) + source = AirportSerializer(read_only=False, many=False) + destination = AirportSerializer(read_only=False, many=False) class FlightSerializer(serializers.ModelSerializer): - class Meta: model = Flight fields = "__all__" @@ -70,6 +58,18 @@ class FlightListSerializer(FlightSerializer): crew = serializers.StringRelatedField(read_only=True, many=True) tickets_available = serializers.IntegerField(read_only=True) + class Meta: + model = Flight + fields = ( + "id", + "route", + "airplane", + "crew", + "tickets_available", + "departure_time", + "arrival_time", + ) + class TicketSerializer(serializers.ModelSerializer): class Meta: @@ -79,18 +79,13 @@ class Meta: def validate(self, attrs): data = super(TicketSerializer, self).validate(attrs=attrs) Ticket.validate_ticket( - attrs["row"], - attrs["seat"], - attrs["flight"].airplane, - ValidationError + attrs["row"], attrs["seat"], attrs["flight"].airplane, ValidationError ) return data class TicketFlightDetailSerializer(FlightListSerializer): - route = RouteListSerializer( - read_only=True, many=False - ) + route = RouteListSerializer(read_only=True, many=False) class Meta: model = Flight @@ -121,12 +116,8 @@ class Meta: class FlightDetailSerializer(FlightSerializer): - route = RouteSerializer( - many=False, read_only=True - ) - seats_taken = TicketSeatSerializer( - source="tickets", many=True, read_only=True - ) + route = RouteSerializer(many=False, read_only=True) + seats_taken = TicketSeatSerializer(source="tickets", many=True, read_only=True) class Meta: model = Flight @@ -148,9 +139,7 @@ class Meta: class OrderSerializer(serializers.ModelSerializer): - tickets = OrderTicketSerializer( - many=True, read_only=False, allow_empty=False - ) + tickets = OrderTicketSerializer(many=True, read_only=False, allow_empty=False) class Meta: model = Order diff --git a/user/serializers.py b/user/serializers.py index b755dc4..5f9e3da 100644 --- a/user/serializers.py +++ b/user/serializers.py @@ -4,7 +4,6 @@ class UserSerializer(serializers.ModelSerializer): - class Meta: model = get_user_model() fields = ("id", "email", "password", "is_staff") @@ -42,9 +41,7 @@ def validate(self, attrs): if user: if not user.is_active: msg = _("User account is disabled.") - raise serializers.ValidationError( - msg, code="authorization" - ) + raise serializers.ValidationError(msg, code="authorization") else: msg = _("Unable to log in with provided credentials.") raise serializers.ValidationError(msg, code="authorization") From 5b64490bec0005c35a644c95d35bf964993c917d Mon Sep 17 00:00:00 2001 From: naiv Date: Tue, 12 Dec 2023 14:10:07 +0200 Subject: [PATCH 44/62] extended schema for viewsets to improve documentation --- flight_service/tests.py | 3 - flight_service/tests/__init__.py | 0 flight_service/views.py | 97 +++++++++++++++++++++++++------- 3 files changed, 77 insertions(+), 23 deletions(-) delete mode 100644 flight_service/tests.py create mode 100644 flight_service/tests/__init__.py diff --git a/flight_service/tests.py b/flight_service/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/flight_service/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/flight_service/tests/__init__.py b/flight_service/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/flight_service/views.py b/flight_service/views.py index 8986c14..41be46c 100644 --- a/flight_service/views.py +++ b/flight_service/views.py @@ -1,12 +1,11 @@ from datetime import datetime from django.db.models import F, Count, Q +from drf_spectacular.utils import extend_schema, OpenApiParameter from rest_framework import viewsets +from rest_framework.decorators import action from rest_framework.pagination import PageNumberPagination -from rest_framework.permissions import ( - IsAdminUser, - IsAuthenticatedOrReadOnly, IsAuthenticated -) +from rest_framework.permissions import IsAdminUser, IsAuthenticated from flight_service.models import ( Crew, @@ -39,6 +38,7 @@ RouteDetailSerializer, ) + class CustomPagination(PageNumberPagination): page_size = 2 page_size_query_param = "page_size" @@ -57,9 +57,22 @@ def get_queryset(self): if search_query: return self.queryset.filter( Q(first_name__icontains=search_query) - | Q(last_name__icontains=search_query)) + | Q(last_name__icontains=search_query) + ) return self.queryset + @extend_schema( + parameters=[ + OpenApiParameter( + name="search crew member", + type={"type": str}, + description="Search crew member by first_name or last_name (ex. ?search=Joe)" + ) + ] + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + class AirportViewSet(viewsets.ModelViewSet): queryset = Airport.objects.all() @@ -75,6 +88,18 @@ def get_queryset(self): return self.queryset + @extend_schema( + parameters=[ + OpenApiParameter( + name="name", + type={"type": str}, + description="Search airport by name (ex. ?name=Heathrow)" + ) + ] + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + class OrderViewSet(viewsets.ModelViewSet): queryset = Order.objects.prefetch_related( @@ -97,9 +122,7 @@ def get_serializer_class(self): return OrderSerializer def get_queryset(self): - return self.queryset.filter( - user=self.request.user - ) + return self.queryset.filter(user=self.request.user) def perform_create(self, serializer): serializer.save(user=self.request.user) @@ -128,14 +151,32 @@ def get_queryset(self): name = self.request.query_params.get("name") if type_id: - self.queryset = self.queryset.select_related( - "airplane_type").filter(airplane_type__id=int(type_id)) + self.queryset = self.queryset.select_related("airplane_type").filter( + airplane_type__id=int(type_id) + ) if name: self.queryset = self.queryset.filter(name__icontains=name) return self.queryset + @extend_schema( + parameters=[ + OpenApiParameter( + name="type", + type={"type": str}, + description="Search airplane by type id (ex. ?type=1)" + ), + OpenApiParameter( + name="name", + type={"type": str}, + description="Search airplane by name (ex. ?name=Airbus)" + ) + ] + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + class FlightViewSet(viewsets.ModelViewSet): queryset = Flight.objects.all() @@ -161,14 +202,10 @@ def get_queryset(self): if date: date = datetime.strptime(date, "%Y-%m-%d").date() - self.queryset = self.queryset.filter( - departure_time__date=date - ) + self.queryset = self.queryset.filter(departure_time__date=date) if source: - self.queryset = self.queryset.filter( - route__source__name__icontains=source - ) + self.queryset = self.queryset.filter(route__source__name__icontains=source) if destination: self.queryset = self.queryset.filter( @@ -177,10 +214,32 @@ def get_queryset(self): return self.queryset.annotate( tickets_available=( - F("airplane__rows") * F("airplane__seats") - Count("tickets") + F("airplane__rows") * F("airplane__seats") - Count("tickets") ) ) + @extend_schema( + parameters=[ + OpenApiParameter( + name="date", + type={"type": str}, + description="Search flight by departure date (ex. ?date=2023-12-27)" + ), + OpenApiParameter( + name="source", + type={"type": str}, + description="Search flight by source airport name (ex. ?source=Heathrow)" + ), + OpenApiParameter( + name="destination", + type={"type": str}, + description="Search flight by destination airport name (ex. ?destination=Kingsford)" + ) + ] + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + class TicketViewSet(viewsets.ReadOnlyModelViewSet): queryset = Ticket.objects.prefetch_related( @@ -202,9 +261,7 @@ def get_serializer_class(self): return TicketSerializer def get_queryset(self): - return self.queryset.filter( - order__user=self.request.user - ) + return self.queryset.filter(order__user=self.request.user) class RouteViewSet(viewsets.ModelViewSet): From 90e993a93320af3cad166598f9a87af7338756a6 Mon Sep 17 00:00:00 2001 From: naiv Date: Tue, 12 Dec 2023 15:07:45 +0200 Subject: [PATCH 45/62] corrected impports --- core/settings.py | 9 +++++---- core/urls.py | 5 ++++- flight_service/views.py | 15 +++++++-------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/core/settings.py b/core/settings.py index b1129c9..894c8e8 100644 --- a/core/settings.py +++ b/core/settings.py @@ -32,7 +32,7 @@ ALLOWED_HOSTS = [] INTERNAL_IPS = [ - "127.0.0.1", + "127.0.0.1" ] # Application definition @@ -49,7 +49,6 @@ "flight_service", "user", "rest_framework", - "rest_framework.authtoken", "debug_toolbar", "drf_spectacular", ] @@ -136,7 +135,7 @@ LANGUAGE_CODE = "en-us" -TIME_ZONE = "UTC" +TIME_ZONE = "Europe/Kiev" USE_I18N = True @@ -148,6 +147,9 @@ STATIC_URL = "static/" +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" + # Default primary key field type # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field @@ -184,5 +186,4 @@ "defaultModelsExpandDepth": 2, "defaultModelExpandDepth": 2, } - } diff --git a/core/urls.py b/core/urls.py index 0d1d997..cae552b 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,7 +1,10 @@ +from django.conf.urls.static import static from django.contrib import admin from django.urls import path, include from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView +from core import settings + urlpatterns = [ path("admin/", admin.site.urls), path("api/user/", include("user.urls", namespace="user")), @@ -15,4 +18,4 @@ path("api/schema/", SpectacularAPIView.as_view(), name="schema"), path("api/schema/swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger"), path("api/schema/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), -] +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/flight_service/views.py b/flight_service/views.py index 41be46c..104e31a 100644 --- a/flight_service/views.py +++ b/flight_service/views.py @@ -3,7 +3,6 @@ from django.db.models import F, Count, Q from drf_spectacular.utils import extend_schema, OpenApiParameter from rest_framework import viewsets -from rest_framework.decorators import action from rest_framework.pagination import PageNumberPagination from rest_framework.permissions import IsAdminUser, IsAuthenticated @@ -65,7 +64,7 @@ def get_queryset(self): parameters=[ OpenApiParameter( name="search crew member", - type={"type": str}, + type={"type": "string", "items": {"type": "string"}}, description="Search crew member by first_name or last_name (ex. ?search=Joe)" ) ] @@ -92,7 +91,7 @@ def get_queryset(self): parameters=[ OpenApiParameter( name="name", - type={"type": str}, + type={"type": "string", "items": {"type": "string"}}, description="Search airport by name (ex. ?name=Heathrow)" ) ] @@ -164,12 +163,12 @@ def get_queryset(self): parameters=[ OpenApiParameter( name="type", - type={"type": str}, + type={"type": "string", "items": {"type": "string"}}, description="Search airplane by type id (ex. ?type=1)" ), OpenApiParameter( name="name", - type={"type": str}, + type={"type": "string", "items": {"type": "string"}}, description="Search airplane by name (ex. ?name=Airbus)" ) ] @@ -222,17 +221,17 @@ def get_queryset(self): parameters=[ OpenApiParameter( name="date", - type={"type": str}, + type={"type": "string", "items": {"type": "string"}}, description="Search flight by departure date (ex. ?date=2023-12-27)" ), OpenApiParameter( name="source", - type={"type": str}, + type={"type": "string", "items": {"type": "string"}}, description="Search flight by source airport name (ex. ?source=Heathrow)" ), OpenApiParameter( name="destination", - type={"type": str}, + type={"type": "string", "items": {"type": "string"}}, description="Search flight by destination airport name (ex. ?destination=Kingsford)" ) ] From aeaba7b15227c992b8b48fa574f64df6de79b4ef Mon Sep 17 00:00:00 2001 From: naiv Date: Tue, 12 Dec 2023 15:24:27 +0200 Subject: [PATCH 46/62] added docker settings --- .dockerignore | 3 +++ Dockerfile | 26 ++++++++++++++++++++++++++ docker-compose.yml | 26 ++++++++++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..54ced3e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +venv +.env +.idea diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..159db2d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.9.18-alpine3.18 +LABEL maintainer="nazarivankiv1@gmail.com" + +ENV PYTHONUNBUFFERED 1 + +WORKDIR app/ + +COPY requirements.txt requirements.txt + +RUN pip install -r requirements.txt + +COPY . . + +RUN mkdir -p /vol/web/media + +RUN adduser \ + --disabled-password \ + --no-create-home \ + django-user + +RUN chown -R django-user:django-user /vol/ +RUN chmod -R 755 /vol/web/ + +RUN apk add --no-cache bash + +USER django-user diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..07006b2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: "3" + +services: + app: + build: + context: . + ports: + - "8000:8000" + volumes: + - ./:/app + command: > + sh -c "python manage.py wait_for_db && + python manage.py migrate && + python manage.py loaddata flight_service_data.json && + python manage.py runserver 0.0.0.0:8000" + env_file: + - .env + depends_on: + - db + + db: + image: postgres:14-alpine + ports: + - "5433:5432" + env_file: + - .env From 5207b8f96c25d04a1a0ceff95905a0ca0b5b3362 Mon Sep 17 00:00:00 2001 From: naiv Date: Tue, 12 Dec 2023 15:26:03 +0200 Subject: [PATCH 47/62] changed setting to use postgres SQL --- core/settings.py | 39 ++++++++++++++------------------------- 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/core/settings.py b/core/settings.py index 894c8e8..19211df 100644 --- a/core/settings.py +++ b/core/settings.py @@ -1,14 +1,3 @@ -""" -Django settings for core project. - -Generated by 'django-admin startproject' using Django 4.2.7. - -For more information on this file, see -https://docs.djangoproject.com/en/4.2/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/4.2/ref/settings/ -""" import os from datetime import timedelta from pathlib import Path @@ -89,25 +78,25 @@ # https://docs.djangoproject.com/en/4.2/ref/settings/#databases # Production database settings -# DATABASES = { -# "default": { -# "ENGINE": "django.db.backends.postgresql", -# "HOST": os.environ["POSTGRES_HOST"], -# "NAME": os.environ["POSTGRES_DB"], -# "USER": os.environ["POSTGRES_USER"], -# "PASSWORD": os.environ["POSTGRES_PASSWORD"], -# "PORT": os.environ["POSTGRES_PORT"] -# } -# } - -# Development database setting DATABASES = { "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", + "ENGINE": "django.db.backends.postgresql", + "HOST": os.environ["POSTGRES_HOST"], + "NAME": os.environ["POSTGRES_DB"], + "USER": os.environ["POSTGRES_USER"], + "PASSWORD": os.environ["POSTGRES_PASSWORD"], + "PORT": os.environ["POSTGRES_PORT"] } } +# Development database setting +# DATABASES = { +# "default": { +# "ENGINE": "django.db.backends.sqlite3", +# "NAME": BASE_DIR / "db.sqlite3", +# } +# } + # Password validation # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators From f741a6e60355e2abbf9024ed9018ec03f3a5cf65 Mon Sep 17 00:00:00 2001 From: naiv Date: Tue, 12 Dec 2023 15:27:56 +0200 Subject: [PATCH 48/62] added wait for db command to project --- flight_service/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/wait_for_db.py | 19 +++++++++++++++++++ 3 files changed, 19 insertions(+) create mode 100644 flight_service/management/__init__.py create mode 100644 flight_service/management/commands/__init__.py create mode 100644 flight_service/management/commands/wait_for_db.py diff --git a/flight_service/management/__init__.py b/flight_service/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/flight_service/management/commands/__init__.py b/flight_service/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/flight_service/management/commands/wait_for_db.py b/flight_service/management/commands/wait_for_db.py new file mode 100644 index 0000000..43dfd3e --- /dev/null +++ b/flight_service/management/commands/wait_for_db.py @@ -0,0 +1,19 @@ +import time + +from django.core.management import BaseCommand +from django.db import connections +from django.db.utils import OperationalError + + +class Command(BaseCommand): + def handle(self, *args, **kwargs): + self.stdout.write("Waiting for database...") + connection_to_db = None + + while not connection_to_db: + try: + connection_to_db = connections["default"] + except OperationalError: + self.stdout.write("Database unavailable, waiting 2 second...") + time.sleep(2) + self.stdout.write(self.style.SUCCESS("Database available")) From 41ff37548f9b3fc95d6b6fe0712f4ec31cbb6fcf Mon Sep 17 00:00:00 2001 From: naiv Date: Tue, 12 Dec 2023 17:57:37 +0200 Subject: [PATCH 49/62] fixed setting file to solve OSErro 500 --- core/settings.py | 74 +++++++++---------- .../migrations/0002_alter_airport_options.py | 17 +++++ 2 files changed, 50 insertions(+), 41 deletions(-) create mode 100644 flight_service/migrations/0002_alter_airport_options.py diff --git a/core/settings.py b/core/settings.py index 19211df..2b84a97 100644 --- a/core/settings.py +++ b/core/settings.py @@ -10,10 +10,12 @@ # Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ +# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-1z*wi&15ys#xgzx(ak4a%n-(k#h*j1&p^rk-0ji)98p_i&xn=c" +SECRET_KEY = ( + "django-insecure-6vubhk2$++agnctay_4pxy_8cq)mosmn(*-#2b^v4cgsh-^!i3" +) # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -21,7 +23,7 @@ ALLOWED_HOSTS = [] INTERNAL_IPS = [ - "127.0.0.1" + "127.0.0.1", ] # Application definition @@ -33,13 +35,11 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - - # User defined apps - "flight_service", - "user", "rest_framework", - "debug_toolbar", "drf_spectacular", + "debug_toolbar", + "flight_service", + "user", ] MIDDLEWARE = [ @@ -75,9 +75,8 @@ # Database -# https://docs.djangoproject.com/en/4.2/ref/settings/#databases +# https://docs.djangoproject.com/en/4.0/ref/settings/#databases -# Production database settings DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", @@ -89,17 +88,9 @@ } } -# Development database setting -# DATABASES = { -# "default": { -# "ENGINE": "django.db.backends.sqlite3", -# "NAME": BASE_DIR / "db.sqlite3", -# } -# } - # Password validation -# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators +# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { @@ -107,72 +98,73 @@ "UserAttributeSimilarityValidator", }, { - "NAME": "django.contrib.auth.password_validation." "MinimumLengthValidator", + "NAME": "django.contrib.auth.password_validation." + "MinimumLengthValidator", }, { - "NAME": "django.contrib.auth.password_validation." "CommonPasswordValidator", + "NAME": "django.contrib.auth.password_validation." + "CommonPasswordValidator", }, { - "NAME": "django.contrib.auth.password_validation." "NumericPasswordValidator", + "NAME": "django.contrib.auth.password_validation." + "NumericPasswordValidator", }, ] AUTH_USER_MODEL = "user.User" # Internationalization -# https://docs.djangoproject.com/en/4.2/topics/i18n/ +# https://docs.djangoproject.com/en/4.0/topics/i18n/ LANGUAGE_CODE = "en-us" -TIME_ZONE = "Europe/Kiev" +TIME_ZONE = "UTC" USE_I18N = True -USE_TZ = True +USE_TZ = False # Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/4.2/howto/static-files/ +# https://docs.djangoproject.com/en/4.0/howto/static-files/ STATIC_URL = "static/" MEDIA_URL = "/media/" -MEDIA_ROOT = BASE_DIR / "media" +MEDIA_ROOT = "/vol/web/media" # Default primary key field type -# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field +# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" REST_FRAMEWORK = { - "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", - "PAGE_SIZE": 4, "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_THROTTLE_CLASSES": [ "rest_framework.throttling.AnonRateThrottle", "rest_framework.throttling.UserRateThrottle", ], - "DEFAULT_THROTTLE_RATES": {"anon": "10/minute", "user": "30/minute"}, + "DEFAULT_THROTTLE_RATES": {"anon": "10/day", "user": "30/day"}, "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework_simplejwt.authentication.JWTAuthentication", ), } -SIMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(days=500000), - "REFRESH_TOKEN_LIFETIME": timedelta(days=1), -} - SPECTACULAR_SETTINGS = { - "TITLE": "Flight Service API", - "DESCRIPTION": "API for handling flights. Functionality includes flights, airports and crews " - "management as well as ticket ordering process for customers", + "TITLE": "Airport Service API", + "DESCRIPTION": "Order flight tickets and check flights", "VERSION": "1.0.0", "SERVE_INCLUDE_SCHEMA": False, - "SWAGGER_UI_SETTING": { + "SWAGGER_UI_SETTINGS": { "deepLinking": True, "defaultModelRendering": "model", "defaultModelsExpandDepth": 2, "defaultModelExpandDepth": 2, - } + }, +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5000), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ROTATE_REFRESH_TOKENS": False, } diff --git a/flight_service/migrations/0002_alter_airport_options.py b/flight_service/migrations/0002_alter_airport_options.py new file mode 100644 index 0000000..aa7da77 --- /dev/null +++ b/flight_service/migrations/0002_alter_airport_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.8 on 2023-12-12 14:47 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('flight_service', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='airport', + options={'ordering': ('name',)}, + ), + ] From 0176dfabd5a61ebeba6aab4b1de627ffb167607e Mon Sep 17 00:00:00 2001 From: naiv Date: Tue, 12 Dec 2023 19:18:05 +0200 Subject: [PATCH 50/62] finalized project --- core/settings.py | 6 +++--- docker-compose.yml | 2 +- requirements.txt | Bin 478 -> 930 bytes 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/settings.py b/core/settings.py index 2b84a97..dedd818 100644 --- a/core/settings.py +++ b/core/settings.py @@ -13,9 +13,7 @@ # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = ( - "django-insecure-6vubhk2$++agnctay_4pxy_8cq)mosmn(*-#2b^v4cgsh-^!i3" -) +SECRET_KEY = os.environ["SECRET_KEY"] # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -148,6 +146,8 @@ "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework_simplejwt.authentication.JWTAuthentication", ), + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", + "PAGE_SIZE": 4 } SPECTACULAR_SETTINGS = { diff --git a/docker-compose.yml b/docker-compose.yml index 07006b2..ece8109 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,6 @@ services: db: image: postgres:14-alpine ports: - - "5433:5432" + - "5432:5432" env_file: - .env diff --git a/requirements.txt b/requirements.txt index e18c6314d2e382de548fd1e8b0e7cbe5b510acf7..cbbb5042a5bfcc2a2634c244203da1d0a3aa6265 100644 GIT binary patch literal 930 zcmZ{i%TB^j5Qb-M;-l0+0RsycCa#PoEKH2cqJZVn)^ZCzy!!oTXsIG;T5_2GHgo3t z>&|ADSY}hp?S`joSJt*myRf%LCE_%;k!`?5wg#Kqf^%YJV0B=L**amC@K!J;AQM}1 z+QBVtUh~=}_NnK2W`tsjRzZd_DR<~Adk>wsk)p&^*aFlmn1t^JPL;Y9@M5_8B-G}h z`?a1h-OyFD8rcU*IhyKD-s<7l$8YR5IHr6@A@Pb-87ED{*>cg_3r;CjDR}A>pDcJd zqDni1tICS5-oY+WtZpz4YJMC4;f_;DaY|80=*68pkrd)XM{SbDA(w zTQdR8LVN1BlG!$3<>}MaPCNGVpi*_Hdn@2+Pd%UfUhLK$?IB>V4=P^OW=?qZo6*JZ WDRde;SnY5G{_p;u+u~{$e2zc1$&d^H delta 43 xcmZ3)eveuB|GyN5EQUmeJce|Jd Date: Tue, 12 Dec 2023 19:42:45 +0200 Subject: [PATCH 51/62] updated debug setting in setting.py --- core/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/settings.py b/core/settings.py index dedd818..e88b717 100644 --- a/core/settings.py +++ b/core/settings.py @@ -16,7 +16,7 @@ SECRET_KEY = os.environ["SECRET_KEY"] # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = os.environ["DEBUG"] ALLOWED_HOSTS = [] From a7d97fd2032e11c87b41c28dd92a450a04f21ffd Mon Sep 17 00:00:00 2001 From: naiv Date: Tue, 12 Dec 2023 20:30:03 +0200 Subject: [PATCH 52/62] added media files for README.MD --- media/readme_images/img.png | Bin 0 -> 100576 bytes media/readme_images/img_1.png | Bin 0 -> 17775 bytes media/readme_images/img_10.png | Bin 0 -> 62624 bytes media/readme_images/img_11.png | Bin 0 -> 64078 bytes media/readme_images/img_12.png | Bin 0 -> 61235 bytes media/readme_images/img_13.png | Bin 0 -> 47916 bytes media/readme_images/img_14.png | Bin 0 -> 57728 bytes media/readme_images/img_2.png | Bin 0 -> 54180 bytes media/readme_images/img_3.png | Bin 0 -> 58357 bytes media/readme_images/img_4.png | Bin 0 -> 61278 bytes media/readme_images/img_5.png | Bin 0 -> 65725 bytes media/readme_images/img_6.png | Bin 0 -> 59055 bytes media/readme_images/img_7.png | Bin 0 -> 61644 bytes media/readme_images/img_8.png | Bin 0 -> 63638 bytes media/readme_images/img_9.png | Bin 0 -> 64478 bytes 15 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 media/readme_images/img.png create mode 100644 media/readme_images/img_1.png create mode 100644 media/readme_images/img_10.png create mode 100644 media/readme_images/img_11.png create mode 100644 media/readme_images/img_12.png create mode 100644 media/readme_images/img_13.png create mode 100644 media/readme_images/img_14.png create mode 100644 media/readme_images/img_2.png create mode 100644 media/readme_images/img_3.png create mode 100644 media/readme_images/img_4.png create mode 100644 media/readme_images/img_5.png create mode 100644 media/readme_images/img_6.png create mode 100644 media/readme_images/img_7.png create mode 100644 media/readme_images/img_8.png create mode 100644 media/readme_images/img_9.png diff --git a/media/readme_images/img.png b/media/readme_images/img.png new file mode 100644 index 0000000000000000000000000000000000000000..02ee760463d3b0339ab37a0b6f3b354f29017e26 GIT binary patch literal 100576 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