diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b51c82d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +venv +.env +.idea +static 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 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/ 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/README.md b/README.md index e1832b8..4ceb1c4 100644 --- a/README.md +++ b/README.md @@ -1 +1,113 @@ -# airport_api_service \ No newline at end of file +# Airport-API-Service + +Welcome to the Airport Service API! Best solution for managing flight orders, routes, planes, tickets ordering and more. Airport Service API suites both developers integrating airport management infrastructure or airline company in need of efficient system to handle reservations and flights . + +___ + +## Airport Service DB Structure + +![Airport Service DB Structure](media/readme_images/img.png) + +## Features + + +* ___Tickets Ordering ___: Users can easily order tickets for their desired flights. +* ___Flight Management___: Administrators can add new flights to the system and manage existing ones. +* ___Authentication and Permissions___: The API is secured with JWT authentication using the Django Rest Framework (DRF). +* ___Filtering and Pagination___: The API supports filtering of flight, airports, airplanes and crew. Even more, pagination is implemented to efficiently manage large sets of data. +* ___API Documentation___: The API documentation is automatically generated using DRF Spectacular. It provides a clear overview of available endpoints, request parameters, response formats, and authentication requirements. +* ___User Order Management___: Users can only view and manage their own orders. The API enforces this restriction in order to ensure privacy and security of data. +* ___Preloaded data for better first time experience___: API is presented with preloaded data. It will allow you to test all functionality of API. +___ + +## Prerequisites +Following prerequisites need to be installed on your system: + +* ___Docker___: You can download and install Docker from the official website: https://www.docker.com/. +* ___ModHeader___: You can download and install ModHeader from the official website: https://modheader.com/. + + +## How to use + +### Easy and quick way + +Follow the link [Airport Service API](https://airport-service-api-v9qf.onrender.com) + +### More complex but full of fun way + +#### Working with git repository + +To set up the Airport Service API, follow these steps: + +1. Clone the repository: + ``` + git clone https://github.com/Naz-iv/airport_api_service.git + ``` +2. Navigate to Project Directory: Change into the project directory using the following command: + ``` + cd airport_api_service + ``` +3. Build and Run Docker Container: Use Docker Compose to build the API's Docker container and start the API server: + ``` + docker-compose build + docker-compose up + ``` + +#### Working with Docker Hub + +To set up the Airport Service API, follow these steps: + +1. Pull image from Docker Hub: + ``` + docker pull nivankiv/airport_api_service-app:latest + ``` +2. Run Docker Container: + ``` + docker-compose up + ``` + +##### When Git API project or Docker Hub image is ready for user: + +You need to generate access token. Do to: ``127.0.0.1:8000/api/users/token`` + +To use servie as admin enter following credentials: +- login: ``admin@admin.com`` +- password: ``1qazcde3`` + +To use servie as user enter following credentials: +- login: ``user@user.com`` +- password: ``1qazcde3`` + +After logging in with provided credentials you will receive two tokens: access token and refresh token. +Add new request headed and enter information: + +Name: ``Authorization`` + +Value: ``Bearer_space_*your_access_token*`` + +Access token example. ``Bearer dmwkejeowk.ewkjeoqdowkjefowfejwefjwef.efhnwefowhefojw`` + +![ModHeader interface](media/readme_images/img_1.png) + +Go to ``127.0.0.1:8000/api/flight_service/`` and discover all hidden gems + + +## Conclusion + +Thank you for using the Airport Service API! + +## Screenshots + +![API main](media/readme_images/img_2.png) +![Crew List](media/readme_images/img_3.png) +![Airport List](media/readme_images/img_4.png) +![Order List](media/readme_images/img_5.png) +![Airplane Types List](media/readme_images/img_6.png) +![Airplane List](media/readme_images/img_7.png) +![Flight List](media/readme_images/img_8.png) +![Ticket List](media/readme_images/img_9.png) +![Route List](media/readme_images/img_10.png) +![Order Details](media/readme_images/img_11.png) +![Flight Details](media/readme_images/img_12.png) +![Ticket Details](media/readme_images/img_13.png) +![Route Details](media/readme_images/img_14.png) 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..0e26c75 --- /dev/null +++ b/core/settings.py @@ -0,0 +1,170 @@ +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 + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ["SECRET_KEY"] + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.environ["DEBUG"] + +ALLOWED_HOSTS = [] + +INTERNAL_IPS = [ + "127.0.0.1", +] + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "drf_spectacular", + "debug_toolbar", + "flight_service", + "user", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "debug_toolbar.middleware.DebugToolbarMiddleware", + "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.0/ref/settings/#databases + +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"] + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.0/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", + }, +] + +AUTH_USER_MODEL = "user.User" + +# Internationalization +# https://docs.djangoproject.com/en/4.0/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = False + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.0/howto/static-files/ + +STATIC_URL = "static/" + +MEDIA_URL = "/media/" +MEDIA_ROOT = "/vol/web/media" + +# Default primary key field type +# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +REST_FRAMEWORK = { + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + "rest_framework.throttling.UserRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": {"anon": "10/day", "user": "30/day"}, + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", + "PAGE_SIZE": 4 +} + +SPECTACULAR_SETTINGS = { + "TITLE": "Airport Service API", + "DESCRIPTION": "Order flight tickets and check flights", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "SWAGGER_UI_SETTINGS": { + "deepLinking": True, + "defaultModelRendering": "model", + "defaultModelsExpandDepth": 2, + "defaultModelExpandDepth": 2, + }, +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=50), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ROTATE_REFRESH_TOKENS": False, +} diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 0000000..cae552b --- /dev/null +++ b/core/urls.py @@ -0,0 +1,21 @@ +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")), + 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"), +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ece8109 --- /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: + - "5432:5432" + env_file: + - .env 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..200bc67 --- /dev/null +++ b/flight_service/admin.py @@ -0,0 +1,22 @@ +from django.contrib import admin + +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/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/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")) diff --git a/flight_service/migrations/0001_initial.py b/flight_service/migrations/0001_initial.py new file mode 100644 index 0000000..92d7055 --- /dev/null +++ b/flight_service/migrations/0001_initial.py @@ -0,0 +1,236 @@ +# 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 + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + 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)), + ("closest_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="Order", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("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="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", + ), + ), + ], + ), + 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()), + ( + "airplane", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="flights", + to="flight_service.airplane", + ), + ), + ( + "crew", + models.ManyToManyField( + related_name="flights", to="flight_service.crew" + ), + ), + ( + "route", + 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.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"], + "unique_together": {("flight", "row", "seat")}, + }, + ), + ] 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',)}, + ), + ] 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..aa5327a --- /dev/null +++ b/flight_service/models.py @@ -0,0 +1,126 @@ +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 + + +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) + closest_big_city = models.CharField(max_length=255) + + def __str__(self): + return self.name + + class Meta: + ordering = ("name",) + + +class Order(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey( + get_user_model(), on_delete=models.CASCADE, related_name="orders" + ) + + class Meta: + ordering = ("-created_at",) + + def __str__(self): + return f"Order #{self.id}" + + +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) + rows = models.IntegerField(validators=[MinValueValidator(1)]) + seats = models.IntegerField(validators=[MinValueValidator(1)]) + airplane_type = models.ForeignKey( + 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) + 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") + + def __str__(self): + return f"Flight #{self.id}" + + +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 not 1 <= row <= airplane.rows: + raise error_to_raise( + f"Row should be in range (1, {airplane.rows}), not {row}" + ) + if not 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.airplane, + 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 + ) + + def __str__(self): + return f"Ticker #{self.id}: row #{self.row}, seat #{self.seat}" + + +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.name} to {self.destination.name}" 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) + ) diff --git a/flight_service/serializers.py b/flight_service/serializers.py new file mode 100644 index 0000000..d53b6c3 --- /dev/null +++ b/flight_service/serializers.py @@ -0,0 +1,188 @@ +from django.db import transaction +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 + fields = "__all__" + + +class AirportSerializer(serializers.ModelSerializer): + class Meta: + model = Airport + fields = "__all__" + + +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 = ("id", "source", "destination") + + +class RouteDetailSerializer(RouteSerializer): + source = AirportSerializer(read_only=False, many=False) + destination = AirportSerializer(read_only=False, many=False) + + +class FlightSerializer(serializers.ModelSerializer): + class Meta: + model = Flight + fields = "__all__" + + +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) + + class Meta: + model = Flight + fields = ( + "id", + "route", + "airplane", + "crew", + "tickets_available", + "departure_time", + "arrival_time", + ) + + +class TicketSerializer(serializers.ModelSerializer): + class Meta: + model = Ticket + fields = "__all__" + + 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 TicketFlightDetailSerializer(FlightListSerializer): + route = RouteListSerializer(read_only=True, many=False) + + class Meta: + model = Flight + fields = ("route", "airplane", "departure_time", "arrival_time") + + +class TicketListSerializer(TicketSerializer): + flight = TicketFlightDetailSerializer(many=False, read_only=True) + + +class TicketDetailSerializer(TicketListSerializer): + flight = TicketFlightDetailSerializer(many=False, read_only=True) + + class Meta: + model = Ticket + fields = ( + "id", + "flight", + "row", + "seat", + ) + + +class TicketSeatSerializer(TicketDetailSerializer): + class Meta: + model = Ticket + fields = ("row", "seat") + + +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") + + +class FlightDetailSerializer(FlightSerializer): + route = RouteDetailSerializer(many=False, read_only=True) + airplane = AirplaneDetailSerializer(many=False, read_only=True) + + seats_taken = TicketSeatSerializer(source="tickets", many=True, read_only=True) + + class Meta: + model = Flight + fields = ( + "id", + "route", + "airplane", + "crew", + "departure_time", + "arrival_time", + "seats_taken", + ) + + +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 = TicketDetailSerializer(many=True, read_only=True) + + class Meta: + model = Order + fields = "__all__" + + +class AirplaneTypeSerializer(serializers.ModelSerializer): + class Meta: + model = AirplaneType + fields = "__all__" 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/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) diff --git a/flight_service/urls.py b/flight_service/urls.py new file mode 100644 index 0000000..cc8bb1f --- /dev/null +++ b/flight_service/urls.py @@ -0,0 +1,27 @@ +from django.urls import include, path +from rest_framework import routers +from flight_service.views import ( + CrewViewSet, + AirportViewSet, + OrderViewSet, + AirplaneTypeViewSet, + AirplaneViewSet, + FlightViewSet, + 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", FlightViewSet) +router.register("tickets", TicketViewSet) +router.register("routes", RouteViewSet) + +urlpatterns = [path("", include(router.urls))] + +app_name = "flight-service" diff --git a/flight_service/views.py b/flight_service/views.py new file mode 100644 index 0000000..104e31a --- /dev/null +++ b/flight_service/views.py @@ -0,0 +1,279 @@ +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.pagination import PageNumberPagination +from rest_framework.permissions import IsAdminUser, IsAuthenticated + +from flight_service.models import ( + Crew, + Airport, + Order, + AirplaneType, + Airplane, + Flight, + Ticket, + Route, +) +from flight_service.permissions import IsAdminOrAuthenticatedReadOnly +from flight_service.serializers import ( + CrewSerializer, + AirportSerializer, + OrderSerializer, + AirplaneTypeSerializer, + AirplaneSerializer, + FlightSerializer, + TicketSerializer, + RouteSerializer, + RouteListSerializer, + TicketListSerializer, + FlightListSerializer, + OrderListSerializer, + OrderDetailSerializer, + AirplaneDetailSerializer, + FlightDetailSerializer, + TicketDetailSerializer, + 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 = (IsAdminUser,) + + 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 + + @extend_schema( + parameters=[ + OpenApiParameter( + name="search crew member", + type={"type": "string", "items": {"type": "string"}}, + 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() + serializer_class = AirportSerializer + permission_classes = (IsAdminOrAuthenticatedReadOnly,) + + 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 + + @extend_schema( + parameters=[ + OpenApiParameter( + name="name", + type={"type": "string", "items": {"type": "string"}}, + 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( + "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): + if self.action == "list": + return OrderListSerializer + if self.action == "retrieve": + return OrderDetailSerializer + + return OrderSerializer + + def get_queryset(self): + 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 = (IsAdminOrAuthenticatedReadOnly,) + + +class AirplaneViewSet(viewsets.ModelViewSet): + queryset = Airplane.objects.all() + permission_classes = (IsAdminOrAuthenticatedReadOnly,) + + def get_serializer_class(self): + if self.action == "retrieve": + return AirplaneDetailSerializer + + 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 + + @extend_schema( + parameters=[ + OpenApiParameter( + name="type", + type={"type": "string", "items": {"type": "string"}}, + description="Search airplane by type id (ex. ?type=1)" + ), + OpenApiParameter( + name="name", + type={"type": "string", "items": {"type": "string"}}, + 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() + permission_classes = (IsAdminOrAuthenticatedReadOnly,) + + def get_serializer_class(self): + if self.action == "list": + return FlightListSerializer + if self.action == "retrieve": + return FlightDetailSerializer + return FlightSerializer + + def get_queryset(self): + """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" + ) + + 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") + ) + ) + + @extend_schema( + parameters=[ + OpenApiParameter( + name="date", + type={"type": "string", "items": {"type": "string"}}, + description="Search flight by departure date (ex. ?date=2023-12-27)" + ), + OpenApiParameter( + name="source", + type={"type": "string", "items": {"type": "string"}}, + description="Search flight by source airport name (ex. ?source=Heathrow)" + ), + OpenApiParameter( + name="destination", + type={"type": "string", "items": {"type": "string"}}, + 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( + "flight", + "order__user", + "flight__route", + "flight__route__source", + "flight__route__destination", + "flight__airplane", + ) + permission_classes = (IsAuthenticated,) + pagination_class = CustomPagination + + def get_serializer_class(self): + if self.action == "list": + return TicketListSerializer + if self.action == "retrieve": + return TicketDetailSerializer + return TicketSerializer + + def get_queryset(self): + return self.queryset.filter(order__user=self.request.user) + + +class RouteViewSet(viewsets.ModelViewSet): + queryset = Route.objects.all() + permission_classes = (IsAdminOrAuthenticatedReadOnly,) + + 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") diff --git a/flight_service_data.json b/flight_service_data.json new file mode 100644 index 0000000..84535d0 --- /dev/null +++ b/flight_service_data.json @@ -0,0 +1,1319 @@ +[ + { + "model": "auth.permission", + "pk": 1, + "fields": { + "name": "Can add log entry", + "content_type": 1, + "codename": "add_logentry" + } + }, + { + "model": "auth.permission", + "pk": 2, + "fields": { + "name": "Can change log entry", + "content_type": 1, + "codename": "change_logentry" + } + }, + { + "model": "auth.permission", + "pk": 3, + "fields": { + "name": "Can delete log entry", + "content_type": 1, + "codename": "delete_logentry" + } + }, + { + "model": "auth.permission", + "pk": 4, + "fields": { + "name": "Can view log entry", + "content_type": 1, + "codename": "view_logentry" + } + }, + { + "model": "auth.permission", + "pk": 5, + "fields": { + "name": "Can add permission", + "content_type": 2, + "codename": "add_permission" + } + }, + { + "model": "auth.permission", + "pk": 6, + "fields": { + "name": "Can change permission", + "content_type": 2, + "codename": "change_permission" + } + }, + { + "model": "auth.permission", + "pk": 7, + "fields": { + "name": "Can delete permission", + "content_type": 2, + "codename": "delete_permission" + } + }, + { + "model": "auth.permission", + "pk": 8, + "fields": { + "name": "Can view permission", + "content_type": 2, + "codename": "view_permission" + } + }, + { + "model": "auth.permission", + "pk": 9, + "fields": { + "name": "Can add group", + "content_type": 3, + "codename": "add_group" + } + }, + { + "model": "auth.permission", + "pk": 10, + "fields": { + "name": "Can change group", + "content_type": 3, + "codename": "change_group" + } + }, + { + "model": "auth.permission", + "pk": 11, + "fields": { + "name": "Can delete group", + "content_type": 3, + "codename": "delete_group" + } + }, + { + "model": "auth.permission", + "pk": 12, + "fields": { + "name": "Can view group", + "content_type": 3, + "codename": "view_group" + } + }, + { + "model": "auth.permission", + "pk": 13, + "fields": { + "name": "Can add content type", + "content_type": 4, + "codename": "add_contenttype" + } + }, + { + "model": "auth.permission", + "pk": 14, + "fields": { + "name": "Can change content type", + "content_type": 4, + "codename": "change_contenttype" + } + }, + { + "model": "auth.permission", + "pk": 15, + "fields": { + "name": "Can delete content type", + "content_type": 4, + "codename": "delete_contenttype" + } + }, + { + "model": "auth.permission", + "pk": 16, + "fields": { + "name": "Can view content type", + "content_type": 4, + "codename": "view_contenttype" + } + }, + { + "model": "auth.permission", + "pk": 17, + "fields": { + "name": "Can add session", + "content_type": 5, + "codename": "add_session" + } + }, + { + "model": "auth.permission", + "pk": 18, + "fields": { + "name": "Can change session", + "content_type": 5, + "codename": "change_session" + } + }, + { + "model": "auth.permission", + "pk": 19, + "fields": { + "name": "Can delete session", + "content_type": 5, + "codename": "delete_session" + } + }, + { + "model": "auth.permission", + "pk": 20, + "fields": { + "name": "Can view session", + "content_type": 5, + "codename": "view_session" + } + }, + { + "model": "auth.permission", + "pk": 21, + "fields": { + "name": "Can add airplane", + "content_type": 6, + "codename": "add_airplane" + } + }, + { + "model": "auth.permission", + "pk": 22, + "fields": { + "name": "Can change airplane", + "content_type": 6, + "codename": "change_airplane" + } + }, + { + "model": "auth.permission", + "pk": 23, + "fields": { + "name": "Can delete airplane", + "content_type": 6, + "codename": "delete_airplane" + } + }, + { + "model": "auth.permission", + "pk": 24, + "fields": { + "name": "Can view airplane", + "content_type": 6, + "codename": "view_airplane" + } + }, + { + "model": "auth.permission", + "pk": 25, + "fields": { + "name": "Can add airplane type", + "content_type": 7, + "codename": "add_airplanetype" + } + }, + { + "model": "auth.permission", + "pk": 26, + "fields": { + "name": "Can change airplane type", + "content_type": 7, + "codename": "change_airplanetype" + } + }, + { + "model": "auth.permission", + "pk": 27, + "fields": { + "name": "Can delete airplane type", + "content_type": 7, + "codename": "delete_airplanetype" + } + }, + { + "model": "auth.permission", + "pk": 28, + "fields": { + "name": "Can view airplane type", + "content_type": 7, + "codename": "view_airplanetype" + } + }, + { + "model": "auth.permission", + "pk": 29, + "fields": { + "name": "Can add airport", + "content_type": 8, + "codename": "add_airport" + } + }, + { + "model": "auth.permission", + "pk": 30, + "fields": { + "name": "Can change airport", + "content_type": 8, + "codename": "change_airport" + } + }, + { + "model": "auth.permission", + "pk": 31, + "fields": { + "name": "Can delete airport", + "content_type": 8, + "codename": "delete_airport" + } + }, + { + "model": "auth.permission", + "pk": 32, + "fields": { + "name": "Can view airport", + "content_type": 8, + "codename": "view_airport" + } + }, + { + "model": "auth.permission", + "pk": 33, + "fields": { + "name": "Can add crew", + "content_type": 9, + "codename": "add_crew" + } + }, + { + "model": "auth.permission", + "pk": 34, + "fields": { + "name": "Can change crew", + "content_type": 9, + "codename": "change_crew" + } + }, + { + "model": "auth.permission", + "pk": 35, + "fields": { + "name": "Can delete crew", + "content_type": 9, + "codename": "delete_crew" + } + }, + { + "model": "auth.permission", + "pk": 36, + "fields": { + "name": "Can view crew", + "content_type": 9, + "codename": "view_crew" + } + }, + { + "model": "auth.permission", + "pk": 37, + "fields": { + "name": "Can add order", + "content_type": 10, + "codename": "add_order" + } + }, + { + "model": "auth.permission", + "pk": 38, + "fields": { + "name": "Can change order", + "content_type": 10, + "codename": "change_order" + } + }, + { + "model": "auth.permission", + "pk": 39, + "fields": { + "name": "Can delete order", + "content_type": 10, + "codename": "delete_order" + } + }, + { + "model": "auth.permission", + "pk": 40, + "fields": { + "name": "Can view order", + "content_type": 10, + "codename": "view_order" + } + }, + { + "model": "auth.permission", + "pk": 41, + "fields": { + "name": "Can add route", + "content_type": 11, + "codename": "add_route" + } + }, + { + "model": "auth.permission", + "pk": 42, + "fields": { + "name": "Can change route", + "content_type": 11, + "codename": "change_route" + } + }, + { + "model": "auth.permission", + "pk": 43, + "fields": { + "name": "Can delete route", + "content_type": 11, + "codename": "delete_route" + } + }, + { + "model": "auth.permission", + "pk": 44, + "fields": { + "name": "Can view route", + "content_type": 11, + "codename": "view_route" + } + }, + { + "model": "auth.permission", + "pk": 45, + "fields": { + "name": "Can add flight", + "content_type": 12, + "codename": "add_flight" + } + }, + { + "model": "auth.permission", + "pk": 46, + "fields": { + "name": "Can change flight", + "content_type": 12, + "codename": "change_flight" + } + }, + { + "model": "auth.permission", + "pk": 47, + "fields": { + "name": "Can delete flight", + "content_type": 12, + "codename": "delete_flight" + } + }, + { + "model": "auth.permission", + "pk": 48, + "fields": { + "name": "Can view flight", + "content_type": 12, + "codename": "view_flight" + } + }, + { + "model": "auth.permission", + "pk": 49, + "fields": { + "name": "Can add ticket", + "content_type": 13, + "codename": "add_ticket" + } + }, + { + "model": "auth.permission", + "pk": 50, + "fields": { + "name": "Can change ticket", + "content_type": 13, + "codename": "change_ticket" + } + }, + { + "model": "auth.permission", + "pk": 51, + "fields": { + "name": "Can delete ticket", + "content_type": 13, + "codename": "delete_ticket" + } + }, + { + "model": "auth.permission", + "pk": 52, + "fields": { + "name": "Can view ticket", + "content_type": 13, + "codename": "view_ticket" + } + }, + { + "model": "auth.permission", + "pk": 53, + "fields": { + "name": "Can add user", + "content_type": 14, + "codename": "add_user" + } + }, + { + "model": "auth.permission", + "pk": 54, + "fields": { + "name": "Can change user", + "content_type": 14, + "codename": "change_user" + } + }, + { + "model": "auth.permission", + "pk": 55, + "fields": { + "name": "Can delete user", + "content_type": 14, + "codename": "delete_user" + } + }, + { + "model": "auth.permission", + "pk": 56, + "fields": { + "name": "Can view user", + "content_type": 14, + "codename": "view_user" + } + }, + { + "model": "auth.permission", + "pk": 57, + "fields": { + "name": "Can add Token", + "content_type": 15, + "codename": "add_token" + } + }, + { + "model": "auth.permission", + "pk": 58, + "fields": { + "name": "Can change Token", + "content_type": 15, + "codename": "change_token" + } + }, + { + "model": "auth.permission", + "pk": 59, + "fields": { + "name": "Can delete Token", + "content_type": 15, + "codename": "delete_token" + } + }, + { + "model": "auth.permission", + "pk": 60, + "fields": { + "name": "Can view Token", + "content_type": 15, + "codename": "view_token" + } + }, + { + "model": "auth.permission", + "pk": 61, + "fields": { + "name": "Can add token", + "content_type": 16, + "codename": "add_tokenproxy" + } + }, + { + "model": "auth.permission", + "pk": 62, + "fields": { + "name": "Can change token", + "content_type": 16, + "codename": "change_tokenproxy" + } + }, + { + "model": "auth.permission", + "pk": 63, + "fields": { + "name": "Can delete token", + "content_type": 16, + "codename": "delete_tokenproxy" + } + }, + { + "model": "auth.permission", + "pk": 64, + "fields": { + "name": "Can view token", + "content_type": 16, + "codename": "view_tokenproxy" + } + }, + { + "model": "contenttypes.contenttype", + "pk": 1, + "fields": { + "app_label": "admin", + "model": "logentry" + } + }, + { + "model": "contenttypes.contenttype", + "pk": 2, + "fields": { + "app_label": "auth", + "model": "permission" + } + }, + { + "model": "contenttypes.contenttype", + "pk": 3, + "fields": { + "app_label": "auth", + "model": "group" + } + }, + { + "model": "contenttypes.contenttype", + "pk": 4, + "fields": { + "app_label": "contenttypes", + "model": "contenttype" + } + }, + { + "model": "contenttypes.contenttype", + "pk": 5, + "fields": { + "app_label": "sessions", + "model": "session" + } + }, + { + "model": "contenttypes.contenttype", + "pk": 6, + "fields": { + "app_label": "flight_service", + "model": "airplane" + } + }, + { + "model": "contenttypes.contenttype", + "pk": 7, + "fields": { + "app_label": "flight_service", + "model": "airplanetype" + } + }, + { + "model": "contenttypes.contenttype", + "pk": 8, + "fields": { + "app_label": "flight_service", + "model": "airport" + } + }, + { + "model": "contenttypes.contenttype", + "pk": 9, + "fields": { + "app_label": "flight_service", + "model": "crew" + } + }, + { + "model": "contenttypes.contenttype", + "pk": 10, + "fields": { + "app_label": "flight_service", + "model": "order" + } + }, + { + "model": "contenttypes.contenttype", + "pk": 11, + "fields": { + "app_label": "flight_service", + "model": "route" + } + }, + { + "model": "contenttypes.contenttype", + "pk": 12, + "fields": { + "app_label": "flight_service", + "model": "flight" + } + }, + { + "model": "contenttypes.contenttype", + "pk": 13, + "fields": { + "app_label": "flight_service", + "model": "ticket" + } + }, + { + "model": "contenttypes.contenttype", + "pk": 14, + "fields": { + "app_label": "user", + "model": "user" + } + }, + { + "model": "contenttypes.contenttype", + "pk": 15, + "fields": { + "app_label": "authtoken", + "model": "token" + } + }, + { + "model": "contenttypes.contenttype", + "pk": 16, + "fields": { + "app_label": "authtoken", + "model": "tokenproxy" + } + }, + { + "model": "flight_service.crew", + "pk": 1, + "fields": { + "first_name": "Anna", + "last_name": "Matias" + } + }, + { + "model": "flight_service.crew", + "pk": 2, + "fields": { + "first_name": "Joe", + "last_name": "Matias" + } + }, + { + "model": "flight_service.crew", + "pk": 3, + "fields": { + "first_name": "Joe", + "last_name": "Mien" + } + }, + { + "model": "flight_service.crew", + "pk": 4, + "fields": { + "first_name": "Ela", + "last_name": "Mien" + } + }, + { + "model": "flight_service.crew", + "pk": 5, + "fields": { + "first_name": "Ela", + "last_name": "Will" + } + }, + { + "model": "flight_service.airport", + "pk": 1, + "fields": { + "name": "Los Angeles International Airport", + "closest_big_city": "Los Angeles" + } + }, + { + "model": "flight_service.airport", + "pk": 2, + "fields": { + "name": "London Heathrow Airport", + "closest_big_city": "London" + } + }, + { + "model": "flight_service.airport", + "pk": 3, + "fields": { + "name": "Tokyo Haneda Airport", + "closest_big_city": "Tokyo" + } + }, + { + "model": "flight_service.airport", + "pk": 4, + "fields": { + "name": "Dubai International Airport", + "closest_big_city": "Dubai" + } + }, + { + "model": "flight_service.airport", + "pk": 5, + "fields": { + "name": "Sydney Kingsford Smith Airport", + "closest_big_city": "Sydney" + } + }, + { + "model": "flight_service.order", + "pk": 1, + "fields": { + "created_at": "2023-12-11T19:05:11.723Z", + "user": 1 + } + }, + { + "model": "flight_service.order", + "pk": 2, + "fields": { + "created_at": "2023-12-11T19:05:22.436Z", + "user": 1 + } + }, + { + "model": "flight_service.order", + "pk": 3, + "fields": { + "created_at": "2023-12-11T19:05:32.240Z", + "user": 1 + } + }, + { + "model": "flight_service.order", + "pk": 4, + "fields": { + "created_at": "2023-12-11T19:05:57.042Z", + "user": 1 + } + }, + { + "model": "flight_service.order", + "pk": 5, + "fields": { + "created_at": "2023-12-11T19:06:04.741Z", + "user": 1 + } + }, + { + "model": "flight_service.order", + "pk": 6, + "fields": { + "created_at": "2023-12-11T19:06:30.025Z", + "user": 1 + } + }, + { + "model": "flight_service.airplanetype", + "pk": 1, + "fields": { + "name": "Commercial" + } + }, + { + "model": "flight_service.airplanetype", + "pk": 2, + "fields": { + "name": "Cargo" + } + }, + { + "model": "flight_service.airplanetype", + "pk": 3, + "fields": { + "name": "Military" + } + }, + { + "model": "flight_service.airplanetype", + "pk": 4, + "fields": { + "name": "Private" + } + }, + { + "model": "flight_service.airplanetype", + "pk": 5, + "fields": { + "name": "Regional" + } + }, + { + "model": "flight_service.airplane", + "pk": 1, + "fields": { + "name": "Boeing 747", + "rows": 50, + "seats": 8, + "airplane_type": 2 + } + }, + { + "model": "flight_service.airplane", + "pk": 2, + "fields": { + "name": "Lockheed C-130 Hercules", + "rows": 20, + "seats": 8, + "airplane_type": 3 + } + }, + { + "model": "flight_service.airplane", + "pk": 3, + "fields": { + "name": "Gulfstream G650", + "rows": 5, + "seats": 3, + "airplane_type": 4 + } + }, + { + "model": "flight_service.airplane", + "pk": 4, + "fields": { + "name": "Embraer E-Jet", + "rows": 25, + "seats": 3, + "airplane_type": 5 + } + }, + { + "model": "flight_service.airplane", + "pk": 5, + "fields": { + "name": "Airbus A380", + "rows": 60, + "seats": 14, + "airplane_type": 2 + } + }, + { + "model": "flight_service.airplane", + "pk": 6, + "fields": { + "name": "Cessna 172", + "rows": 4, + "seats": 1, + "airplane_type": 4 + } + }, + { + "model": "flight_service.airplane", + "pk": 7, + "fields": { + "name": "Antonov An-225 Mriya", + "rows": 88, + "seats": 7, + "airplane_type": 3 + } + }, + { + "model": "flight_service.airplane", + "pk": 8, + "fields": { + "name": "Bombardier Challenger 300", + "rows": 18, + "seats": 3, + "airplane_type": 5 + } + }, + { + "model": "flight_service.flight", + "pk": 1, + "fields": { + "route": 2, + "airplane": 2, + "departure_time": "2023-12-12T09:30:00Z", + "arrival_time": "2023-12-12T11:45:00Z", + "crew": [ + 1, + 2 + ] + } + }, + { + "model": "flight_service.flight", + "pk": 2, + "fields": { + "route": 3, + "airplane": 1, + "departure_time": "2023-12-13T12:00:00Z", + "arrival_time": "2023-12-13T15:00:00Z", + "crew": [ + 2, + 3 + ] + } + }, + { + "model": "flight_service.flight", + "pk": 3, + "fields": { + "route": 4, + "airplane": 2, + "departure_time": "2023-12-14T14:30:00Z", + "arrival_time": "2023-12-14T15:30:00Z", + "crew": [ + 1, + 3 + ] + } + }, + { + "model": "flight_service.flight", + "pk": 4, + "fields": { + "route": 5, + "airplane": 1, + "departure_time": "2023-12-15T08:00:00Z", + "arrival_time": "2023-12-15T09:30:00Z", + "crew": [ + 2, + 3 + ] + } + }, + { + "model": "flight_service.flight", + "pk": 5, + "fields": { + "route": 6, + "airplane": 2, + "departure_time": "2023-12-16T16:00:00Z", + "arrival_time": "2023-12-16T17:00:00Z", + "crew": [ + 1, + 2 + ] + } + }, + { + "model": "flight_service.flight", + "pk": 6, + "fields": { + "route": 7, + "airplane": 1, + "departure_time": "2023-12-17T11:30:00Z", + "arrival_time": "2023-12-17T13:30:00Z", + "crew": [ + 2, + 3 + ] + } + }, + { + "model": "flight_service.flight", + "pk": 7, + "fields": { + "route": 8, + "airplane": 2, + "departure_time": "2023-12-18T09:00:00Z", + "arrival_time": "2023-12-18T10:30:00Z", + "crew": [ + 1, + 3 + ] + } + }, + { + "model": "flight_service.flight", + "pk": 8, + "fields": { + "route": 9, + "airplane": 1, + "departure_time": "2023-12-19T15:30:00Z", + "arrival_time": "2023-12-19T17:30:00Z", + "crew": [ + 2, + 3 + ] + } + }, + { + "model": "flight_service.flight", + "pk": 9, + "fields": { + "route": 10, + "airplane": 2, + "departure_time": "2023-12-20T13:00:00Z", + "arrival_time": "2023-12-20T14:00:00Z", + "crew": [ + 1, + 2 + ] + } + }, + { + "model": "flight_service.ticket", + "pk": 1, + "fields": { + "row": 15, + "seat": 8, + "flight": 2, + "order": 1 + } + }, + { + "model": "flight_service.ticket", + "pk": 2, + "fields": { + "row": 10, + "seat": 3, + "flight": 5, + "order": 1 + } + }, + { + "model": "flight_service.ticket", + "pk": 3, + "fields": { + "row": 20, + "seat": 5, + "flight": 8, + "order": 2 + } + }, + { + "model": "flight_service.ticket", + "pk": 4, + "fields": { + "row": 12, + "seat": 1, + "flight": 3, + "order": 2 + } + }, + { + "model": "flight_service.ticket", + "pk": 5, + "fields": { + "row": 18, + "seat": 6, + "flight": 7, + "order": 3 + } + }, + { + "model": "flight_service.ticket", + "pk": 6, + "fields": { + "row": 8, + "seat": 2, + "flight": 4, + "order": 3 + } + }, + { + "model": "flight_service.ticket", + "pk": 7, + "fields": { + "row": 19, + "seat": 8, + "flight": 3, + "order": 4 + } + }, + { + "model": "flight_service.ticket", + "pk": 8, + "fields": { + "row": 9, + "seat": 3, + "flight": 8, + "order": 4 + } + }, + { + "model": "flight_service.ticket", + "pk": 9, + "fields": { + "row": 21, + "seat": 7, + "flight": 2, + "order": 5 + } + }, + { + "model": "flight_service.ticket", + "pk": 10, + "fields": { + "row": 13, + "seat": 4, + "flight": 5, + "order": 5 + } + }, + { + "model": "flight_service.ticket", + "pk": 11, + "fields": { + "row": 11, + "seat": 3, + "flight": 7, + "order": 6 + } + }, + { + "model": "flight_service.ticket", + "pk": 12, + "fields": { + "row": 10, + "seat": 2, + "flight": 4, + "order": 6 + } + }, + { + "model": "flight_service.route", + "pk": 1, + "fields": { + "source": 2, + "destination": 3, + "distance": 850 + } + }, + { + "model": "flight_service.route", + "pk": 2, + "fields": { + "source": 1, + "destination": 4, + "distance": 1200 + } + }, + { + "model": "flight_service.route", + "pk": 3, + "fields": { + "source": 4, + "destination": 2, + "distance": 950 + } + }, + { + "model": "flight_service.route", + "pk": 4, + "fields": { + "source": 3, + "destination": 1, + "distance": 800 + } + }, + { + "model": "flight_service.route", + "pk": 5, + "fields": { + "source": 2, + "destination": 5, + "distance": 600 + } + }, + { + "model": "flight_service.route", + "pk": 6, + "fields": { + "source": 5, + "destination": 1, + "distance": 1100 + } + }, + { + "model": "flight_service.route", + "pk": 7, + "fields": { + "source": 4, + "destination": 5, + "distance": 700 + } + }, + { + "model": "flight_service.route", + "pk": 8, + "fields": { + "source": 3, + "destination": 2, + "distance": 1000 + } + }, + { + "model": "flight_service.route", + "pk": 9, + "fields": { + "source": 5, + "destination": 3, + "distance": 850 + } + }, + { + "model": "flight_service.route", + "pk": 10, + "fields": { + "source": 1, + "destination": 5, + "distance": 1300 + } + }, + { + "model": "user.user", + "pk": 1, + "fields": { + "password": "pbkdf2_sha256$390000$hqMW94wpSSciR1JRNZ2cgM$dIwuT69o7/szGEhgbtBm8easJTOh6ZOv9SX3AHMp8qU=", + "last_login": null, + "is_superuser": true, + "first_name": "", + "last_name": "", + "is_staff": true, + "is_active": true, + "date_joined": "2023-12-11T18:24:28.970Z", + "email": "admin@admin.com", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "user.user", + "pk": 2, + "fields": { + "password": "pbkdf2_sha256$390000$mPkF8WHSmXiwJtXNOhpTt5$cWdZFnXzutHjY7daUZqUJLOms2evTSg/PEZQ1gQzkKU=", + "last_login": null, + "is_superuser": false, + "first_name": "", + "last_name": "", + "is_staff": false, + "is_active": true, + "date_joined": "2023-12-11T18:25:16.860Z", + "email": "user@user.com", + "groups": [], + "user_permissions": [] + } + } +] 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() diff --git a/media/.gitkeep b/media/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/media/readme_images/img.png b/media/readme_images/img.png new file mode 100644 index 0000000..02ee760 Binary files /dev/null and b/media/readme_images/img.png differ diff --git a/media/readme_images/img_1.png b/media/readme_images/img_1.png new file mode 100644 index 0000000..88cefda Binary files /dev/null and b/media/readme_images/img_1.png differ diff --git a/media/readme_images/img_10.png b/media/readme_images/img_10.png new file mode 100644 index 0000000..c60f50f Binary files /dev/null and b/media/readme_images/img_10.png differ diff --git a/media/readme_images/img_11.png b/media/readme_images/img_11.png new file mode 100644 index 0000000..322b4f9 Binary files /dev/null and b/media/readme_images/img_11.png differ diff --git a/media/readme_images/img_12.png b/media/readme_images/img_12.png new file mode 100644 index 0000000..2c29158 Binary files /dev/null and b/media/readme_images/img_12.png differ diff --git a/media/readme_images/img_13.png b/media/readme_images/img_13.png new file mode 100644 index 0000000..f086190 Binary files /dev/null and b/media/readme_images/img_13.png differ diff --git a/media/readme_images/img_14.png b/media/readme_images/img_14.png new file mode 100644 index 0000000..4444e5b Binary files /dev/null and b/media/readme_images/img_14.png differ diff --git a/media/readme_images/img_2.png b/media/readme_images/img_2.png new file mode 100644 index 0000000..77c5ed4 Binary files /dev/null and b/media/readme_images/img_2.png differ diff --git a/media/readme_images/img_3.png b/media/readme_images/img_3.png new file mode 100644 index 0000000..336c08d Binary files /dev/null and b/media/readme_images/img_3.png differ diff --git a/media/readme_images/img_4.png b/media/readme_images/img_4.png new file mode 100644 index 0000000..5949bab Binary files /dev/null and b/media/readme_images/img_4.png differ diff --git a/media/readme_images/img_5.png b/media/readme_images/img_5.png new file mode 100644 index 0000000..5eec703 Binary files /dev/null and b/media/readme_images/img_5.png differ diff --git a/media/readme_images/img_6.png b/media/readme_images/img_6.png new file mode 100644 index 0000000..7e0d9fb Binary files /dev/null and b/media/readme_images/img_6.png differ diff --git a/media/readme_images/img_7.png b/media/readme_images/img_7.png new file mode 100644 index 0000000..a7cd8d3 Binary files /dev/null and b/media/readme_images/img_7.png differ diff --git a/media/readme_images/img_8.png b/media/readme_images/img_8.png new file mode 100644 index 0000000..feaa8b6 Binary files /dev/null and b/media/readme_images/img_8.png differ diff --git a/media/readme_images/img_9.png b/media/readme_images/img_9.png new file mode 100644 index 0000000..99d7ca4 Binary files /dev/null and b/media/readme_images/img_9.png differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cbbb504 Binary files /dev/null and b/requirements.txt differ 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..1600c30 --- /dev/null +++ b/user/admin.py @@ -0,0 +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 _ + + +@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",) 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/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()), + ], + ), + ] 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..d3dec8d --- /dev/null +++ b/user/models.py @@ -0,0 +1,51 @@ +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(using=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/serializers.py b/user/serializers.py new file mode 100644 index 0000000..5f9e3da --- /dev/null +++ b/user/serializers.py @@ -0,0 +1,53 @@ +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 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/urls.py b/user/urls.py new file mode 100644 index 0000000..c09a362 --- /dev/null +++ b/user/urls.py @@ -0,0 +1,18 @@ +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"), +] diff --git a/user/views.py b/user/views.py new file mode 100644 index 0000000..6fcc95d --- /dev/null +++ b/user/views.py @@ -0,0 +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 + +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