diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5e1f73c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.venv +.env +__pycache__/ +*.pyc +.idea +media +.pytest_cache/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1371db3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,376 @@ +### Django ### +*.log +*.pot +*.pyc +__pycache__/ +local_settings.py +db.sqlite3 +db.sqlite3-journal +media + +# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ +# in your Git repository. Update and uncomment the following line accordingly. +# /staticfiles/ + +### Django.Python Stack ### +# Byte-compiled / optimized / DLL files +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo + +# Django stuff: + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# 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/ + +### PyCharm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Python ### +# Byte-compiled / optimized / DLL files + +# C extensions + +# Distribution / packaging + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. + +# Installer logs + +# Unit test / coverage reports + +# Translations + +# Django stuff: + +# Flask stuff: + +# Scrapy stuff: + +# Sphinx documentation + +# PyBuilder + +# Jupyter Notebook + +# IPython + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm + +# Celery stuff + +# SageMath parsed files + +# Environments + +# Spyder project settings + +# Rope project settings + +# mkdocs documentation + +# mypy + +# Pyre type checker + +# pytype static type analyzer + +# Cython debug symbols + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# 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. + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# End of https://www.toptal.com/developers/gitignore/api/python,pycharm,django \ No newline at end of file diff --git a/Airport_app/management/__init__.py b/Airport_app/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Airport_app/management/commands/__init__.py b/Airport_app/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Airport_app/management/commands/wait_for_db.py b/Airport_app/management/commands/wait_for_db.py new file mode 100644 index 0000000..5c74fe6 --- /dev/null +++ b/Airport_app/management/commands/wait_for_db.py @@ -0,0 +1,19 @@ +import time +from django.core.management.base import BaseCommand +from django.db import connections +from django.db.utils import OperationalError + + +class Command(BaseCommand): + help = "Wait for database to be available" + + def handle(self, *args, **options): + self.stdout.write("Waiting for database...") + while True: + try: + connections["default"].cursor() + break + except OperationalError: + self.stdout.write("Database unavailable, waiting 1 second...") + time.sleep(1) + self.stdout.write(self.style.SUCCESS("Database available!")) diff --git a/Airport_app/migrations/0001_initial.py b/Airport_app/migrations/0001_initial.py new file mode 100644 index 0000000..7633afd --- /dev/null +++ b/Airport_app/migrations/0001_initial.py @@ -0,0 +1,85 @@ +# Generated by Django 6.0.4 on 2026-04-23 14:40 + +import airport_app.models +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + 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, unique=True)), + ], + ), + 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, unique=True)), + ('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)), + ], + ), + migrations.CreateModel( + name='Route', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('distance', models.PositiveIntegerField()), + ], + ), + migrations.CreateModel( + name='Ticket', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('row', models.PositiveIntegerField()), + ('seat', models.PositiveIntegerField()), + ], + options={ + 'ordering': ('seat',), + }, + ), + 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, unique=True)), + ('rows', models.PositiveIntegerField()), + ('seats_in_row', models.PositiveIntegerField()), + ('image', models.ImageField(null=True, upload_to=airport_app.models.airplane_image_path)), + ('airplane_type', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='airplanes', to='airport_app.airplanetype')), + ], + ), + 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.DO_NOTHING, related_name='flights_airplane', to='airport_app.airplane')), + ('crew', models.ManyToManyField(to='airport_app.crew')), + ], + ), + ] diff --git a/Airport_app/migrations/0002_initial.py b/Airport_app/migrations/0002_initial.py new file mode 100644 index 0000000..b388853 --- /dev/null +++ b/Airport_app/migrations/0002_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 6.0.4 on 2026-04-23 14:40 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('airport_app', '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.DO_NOTHING, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='route', + name='destination', + field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='routes_destination', to='airport_app.airport'), + ), + migrations.AddField( + model_name='route', + name='source', + field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='routes_source', to='airport_app.airport'), + ), + migrations.AddField( + model_name='flight', + name='route', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='flights_route', to='airport_app.route'), + ), + migrations.AddField( + model_name='ticket', + name='flight', + field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='tickets_flight', to='airport_app.flight'), + ), + migrations.AddField( + model_name='ticket', + name='order', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tickets_order', to='airport_app.order'), + ), + ] diff --git a/Airport_app/permissions.py b/Airport_app/permissions.py new file mode 100644 index 0000000..43f452c --- /dev/null +++ b/Airport_app/permissions.py @@ -0,0 +1,10 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +class IsAdminAllORIsAuthenticatedOReadOnly(BasePermission): + def has_permission(self, request, view): + return bool( + request.method in SAFE_METHODS or + request.user and + request.user.is_authenticated + ) or (request.user and request.user.is_staff) diff --git a/Airport_app/urls.py b/Airport_app/urls.py new file mode 100644 index 0000000..7289316 --- /dev/null +++ b/Airport_app/urls.py @@ -0,0 +1,26 @@ +from django.urls import path, include + +from rest_framework import routers + +from airport_app.views import (AirportViewSet, + RouteViewSet, + FlightViewSet, + AirplaneViewSet, + OrderViewSet, TicketViewSet, AirplaneTypeViewSet, CrewViewSet) + +router = routers.DefaultRouter() +router.register("airports", AirportViewSet) +router.register("routes", RouteViewSet) +router.register("flights", FlightViewSet) +router.register("airplanes", AirplaneViewSet) +router.register("tickets", TicketViewSet) +router.register("orders", OrderViewSet) +router.register("airplane_types", AirplaneTypeViewSet) +router.register("crews", CrewViewSet) + +urlpatterns = [ + path("", include(router.urls)) +] + +app_name = "airport_app" + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..40b32a7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim + +LABEL maintainer="airport" + +ENV PYTHONBUFFERED 1 + +WORKDIR app/ + +COPY requirements.txt . + +RUN apt-get update && apt-get install -y libpq-dev gcc && rm -rf /var/lib/apt/lists/* +RUN pip install --no-cache-dir -r requirements.txt + +RUN adduser \ + --disabled-password \ + --no-create-home \ + my_user + +RUN chown -R my_user:my_user /media +RUN chmod -R 755 /media + +COPY . . +RUN mkdir -p /vol/web/media + +USER my_user \ No newline at end of file diff --git a/README.md b/README.md index b7b5951..c88031c 100644 Binary files a/README.md and b/README.md differ diff --git a/airport/__init__.py b/airport/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/airport/asgi.py b/airport/asgi.py new file mode 100644 index 0000000..5e51ae2 --- /dev/null +++ b/airport/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for airport 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/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'airport.settings') + +application = get_asgi_application() diff --git a/airport/settings.py b/airport/settings.py new file mode 100644 index 0000000..dcb66af --- /dev/null +++ b/airport/settings.py @@ -0,0 +1,165 @@ +""" +Django settings for airport project. + +Generated by 'django-admin startproject' using Django 6.0.4. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/6.0/ref/settings/ +""" +import os +from datetime import timedelta +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/6.0/howto/deployment/checklist/ + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'user', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'airport_app', + 'rest_framework', + 'debug_toolbar', + 'rest_framework.authtoken', + 'drf_spectacular', +] + +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', + 'debug_toolbar.middleware.DebugToolbarMiddleware' +] + +ROOT_URLCONF = 'airport.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'] + , + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'airport.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/6.0/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": os.environ.get("POSTGRES_ENGINE", "django.db.backends.sqlite3"), + "NAME": os.environ.get("POSTGRES_DATABASE", BASE_DIR / "db.sqlite3"), + "USER": os.environ.get("POSTGRES_USER", "user"), + "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "password"), + "HOST": os.environ.get("POSTGRES_HOST", "localhost"), + "PORT": os.environ.get("POSTGRES_PORT", "5432"), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/6.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', + }, +] + +MEDIA_ROOT = "/vol/web/media" + +MEDIA_URL = "/media/" + +# Internationalization +# https://docs.djangoproject.com/en/6.0/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/6.0/howto/static-files/ + +STATIC_URL = 'static/' + +REST_FRAMEWORK = { + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", + "PAGE_SIZE": 5, + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ], + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_THROTTLE_CLASSES': [ + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.UserRateThrottle' + ], + 'DEFAULT_THROTTLE_RATES': { + 'anon': '100/day', + 'user': '1000/day' + } +} + +SPECTACULAR_SETTINGS = { + 'TITLE': 'Aiport-API', + 'DESCRIPTION': 'API project about ordering tickets in airport', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, +} + +INTERNAL_IPS = [ + "127.0.0.1", +] + +AUTH_USER_MODEL = 'user.User' + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=20), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ROTATE_REFRESH_TOKENS": False +} diff --git a/airport/urls.py b/airport/urls.py new file mode 100644 index 0000000..9eff520 --- /dev/null +++ b/airport/urls.py @@ -0,0 +1,33 @@ +""" +URL configuration for airport project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/6.0/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.conf.urls.static import static +from django.contrib import admin +from django.urls import path, include +from debug_toolbar.toolbar import debug_toolbar_urls +from django.conf import settings +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView + +app_name = "airport" + +urlpatterns = [ + path('admin/', admin.site.urls), + path("api/airport/", include("airport_app.urls", namespace="airport")), + path("api/user/", include("user.urls", namespace="user")), + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('api/swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), +] + debug_toolbar_urls() + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/airport/wsgi.py b/airport/wsgi.py new file mode 100644 index 0000000..a3f7993 --- /dev/null +++ b/airport/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for airport 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/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'airport.settings') + +application = get_wsgi_application() diff --git a/airport_app/__init__.py b/airport_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/airport_app/admin.py b/airport_app/admin.py new file mode 100644 index 0000000..efbb993 --- /dev/null +++ b/airport_app/admin.py @@ -0,0 +1,20 @@ +from django.contrib import admin + +from airport_app.models import (Airport, + Route, + Flight, + Airplane, + Crew, + AirplaneType, + Ticket, + Order) + + +admin.site.register(Airport) +admin.site.register(Route) +admin.site.register(Flight) +admin.site.register(Airplane) +admin.site.register(Crew) +admin.site.register(AirplaneType) +admin.site.register(Ticket) +admin.site.register(Order) diff --git a/airport_app/apps.py b/airport_app/apps.py new file mode 100644 index 0000000..a353df0 --- /dev/null +++ b/airport_app/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AirportAppConfig(AppConfig): + name = 'airport_app' diff --git a/airport_app/migrations/__init__.py b/airport_app/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/airport_app/models.py b/airport_app/models.py new file mode 100644 index 0000000..8bcc5d9 --- /dev/null +++ b/airport_app/models.py @@ -0,0 +1,96 @@ +import pathlib +import uuid + +from django.conf import settings +from django.db import models + + +class Airport(models.Model): + name = models.CharField(max_length=255, unique=True) + closest_big_city = models.CharField(max_length=255) + + def __str__(self): + return self.name + + +class Route(models.Model): + source = models.ForeignKey(Airport, on_delete=models.DO_NOTHING, related_name="routes_source") + destination = models.ForeignKey(Airport, on_delete=models.DO_NOTHING, related_name="routes_destination") + distance = models.PositiveIntegerField() + + def __str__(self): + return f"From '{self.source}' to '{self.destination}'" + + +class Flight(models.Model): + route = models.ForeignKey(Route, on_delete=models.CASCADE, related_name="flights_route") + airplane = models.ForeignKey("Airplane", on_delete=models.DO_NOTHING, related_name="flights_airplane") + departure_time = models.DateTimeField() + arrival_time = models.DateTimeField() + crew = models.ManyToManyField("Crew") + + def __str__(self): + return f"Flight: [{self.id}] - {self.route} [Departure: {self.departure_time.strftime('%Y-%m-%d %H:%M')}]" + + +def airplane_image_path(instance: "Airplane", filename: str): + filename = f"{instance.name}-{uuid.uuid4()}" + pathlib.Path(filename).suffix + return pathlib.Path("upload_to/airplanes/") / pathlib.Path(filename) + + +class Airplane(models.Model): + name = models.CharField(max_length=255, unique=True) + rows = models.PositiveIntegerField() + seats_in_row = models.PositiveIntegerField() + airplane_type = models.ForeignKey("AirplaneType", on_delete=models.DO_NOTHING, related_name="airplanes") + image = models.ImageField(null=True, upload_to=airplane_image_path) + + def __str__(self): + return f"{self.name}" + + def capacity(self): + return self.rows * self.seats_in_row + + +class Crew(models.Model): + first_name = models.CharField(max_length=255) + last_name = models.CharField(max_length=255) + + def __str__(self): + return f"{self.first_name} {self.last_name}" + + +class AirplaneType(models.Model): + name = models.CharField(max_length=255, unique=True) + + def __str__(self): + return self.name + + +class Ticket(models.Model): + row = models.PositiveIntegerField() + seat = models.PositiveIntegerField() + flight = models.ForeignKey(Flight, on_delete=models.DO_NOTHING, related_name="tickets_flight") + order = models.ForeignKey("Order", on_delete=models.CASCADE, + null=True, blank=True, related_name="available_tickets") + + class Meta: + ordering = ("seat",) + + def __str__(self): + return f"Ticket: [{self.id}] ({self.flight})" + + @staticmethod + def validate_seat(seat: int, capacity: int, error_to_raise): + if not (1 <= seat <= capacity): + raise error_to_raise( + f"Seat must be in range: 1 - {capacity}" + ) + + def validate(self): + Ticket.validate_seat(self.seat, self.flight.airplane.capacity(), ValueError) + + +class Order(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.DO_NOTHING) diff --git a/airport_app/serializers.py b/airport_app/serializers.py new file mode 100644 index 0000000..3bec60e --- /dev/null +++ b/airport_app/serializers.py @@ -0,0 +1,181 @@ +from rest_framework import serializers + +from airport_app.models import Airport, Route, Flight, Airplane, Crew, AirplaneType, Ticket, Order + + +class RouteSerializer(serializers.ModelSerializer): + source = serializers.SlugRelatedField( + slug_field="name", + queryset=Airport.objects.all() + ) + destination = serializers.SlugRelatedField( + slug_field="name", + queryset=Airport.objects.all() + ) + available_flights = serializers.SerializerMethodField() + + class Meta: + model = Route + fields = ("id", "source", "destination", "distance", "available_flights") + + def get_available_flights(self, obj): + return obj.flights_route.count() + + +class AirportSerializer(serializers.ModelSerializer): + routes = serializers.PrimaryKeyRelatedField( + source="routes_source", + many=True, + read_only=True + ) + + class Meta: + model = Airport + fields = ("id", "name", "closest_big_city", "routes") + + +class CrewSerializer(serializers.ModelSerializer): + full_name = serializers.CharField( + source="__str__", + read_only=True + ) + class Meta: + model = Crew + fields = ("id", "first_name", "last_name", "full_name") + + +class AirplaneSerializer(serializers.ModelSerializer): + airplane_type = serializers.SlugRelatedField( + slug_field="name", + queryset=AirplaneType.objects.all() + ) + class Meta: + model = Airplane + fields = ("id", "name", "rows", "seats_in_row", "airplane_type", "capacity") + + +class AirplaneImageSerializer(serializers.ModelSerializer): + class Meta: + model = Airplane + fields = ("id", "image") + + +class FlightSerializer(serializers.ModelSerializer): + departure_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M") + arrival_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M") + route = serializers.StringRelatedField() + airplane = serializers.StringRelatedField() + crew = serializers.StringRelatedField( + many=True, + ) + class Meta: + model = Flight + fields = ("id", "route", "airplane", "departure_time", "arrival_time", "crew") + + +class FlightRetrieveSerializer(FlightSerializer): + route = RouteSerializer() + airplane = AirplaneSerializer() + crew = serializers.StringRelatedField( + many=True + ) + + +class FlightCreateSerializer(FlightSerializer): + route = serializers.PrimaryKeyRelatedField( + queryset=Route.objects.all() + ) + airplane = serializers.SlugRelatedField( + slug_field="name", + queryset=Airplane.objects.all() + ) + crew = serializers.PrimaryKeyRelatedField( + many=True, + queryset=Crew.objects.all() + ) + + +class RouteRetrieveSerializer(RouteSerializer): + available_flights = FlightSerializer( + many=True, + source="flights_route", + read_only=True + ) + + +class AirportRetrieveSerializer(serializers.ModelSerializer): + routes = RouteRetrieveSerializer( + source="routes_source", + many=True, + read_only=True + ) + + class Meta: + model = Airport + fields = ("id", "name", "closest_big_city", "routes") + + +class AirportCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Airport + fields = ("id", "name", "closest_big_city") + + + +class AirplaneTypeSerializer(serializers.ModelSerializer): + class Meta: + model = AirplaneType + fields = ("id", "name") + + +class TicketSerializer(serializers.ModelSerializer): + is_sold = serializers.SerializerMethodField() + class Meta: + model = Ticket + fields = ("id", "row", "seat", "flight", "order", "is_sold") + + def validate(self, attrs): + Ticket.validate_seat( + attrs["seat"], + attrs["flight"].airplane.capacity(), + serializers.ValidationError + ) + return attrs + + def get_is_sold(self, obj): + if not obj.order: + return False + return True + + +class TicketListSerializer(TicketSerializer): + flight = serializers.StringRelatedField() + + +class OrderSerializer(serializers.ModelSerializer): + created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M") + class Meta: + model = Order + fields = ("id", "created_at") + + +class OrderCreateSerializer(serializers.ModelSerializer): + available_tickets = serializers.PrimaryKeyRelatedField( + many=True, + queryset=Ticket.objects.filter(order__isnull=True), + ) + class Meta: + model = Order + fields = ("id", "available_tickets") + + def create(self, validated_data): + tickets_data = validated_data.pop("available_tickets") + order = Order.objects.create(**validated_data) + order.available_tickets.set(tickets_data) + return order + + +class TicketRetrieveSerializer(TicketListSerializer): + order = OrderSerializer( + many=False, + read_only=True) diff --git a/airport_app/views.py b/airport_app/views.py new file mode 100644 index 0000000..e521854 --- /dev/null +++ b/airport_app/views.py @@ -0,0 +1,211 @@ +from drf_spectacular.utils import extend_schema, OpenApiParameter +from rest_framework import viewsets, status +from rest_framework.decorators import action, permission_classes +from rest_framework.permissions import IsAdminUser +from rest_framework.response import Response + +from airport_app.models import (Airport, + Route, + Flight, + Airplane, + Crew, + AirplaneType, + Ticket, + Order) +from airport_app.permissions import IsAdminAllORIsAuthenticatedOReadOnly +from airport_app.serializers import (AirportSerializer, + RouteSerializer, + FlightSerializer, + CrewSerializer, + AirplaneTypeSerializer, + TicketSerializer, + OrderSerializer, + AirplaneSerializer, + FlightCreateSerializer, + AirportRetrieveSerializer, + FlightRetrieveSerializer, + RouteRetrieveSerializer, + TicketListSerializer, + OrderCreateSerializer, + AirportCreateSerializer, + AirplaneImageSerializer, + TicketRetrieveSerializer) + + +class AirportViewSet(viewsets.ModelViewSet): + queryset = Airport.objects.all() + serializer_class = AirportSerializer + permission_classes = (IsAdminAllORIsAuthenticatedOReadOnly,) + + def get_serializer_class(self): + if self.action == "retrieve": + return AirportRetrieveSerializer + elif self.action == "create": + return AirportCreateSerializer + return AirportSerializer + + def _params_to_int(self, query_str: str) -> list: + return [int(i) for i in query_str.split(",")] + + def get_queryset(self): + route = self.request.query_params.get("route") + if route: + route_id = self._params_to_int(route) + return (self.queryset. + filter(routes_source__id__in=route_id) + .prefetch_related("routes_source")) + if self.action in ("list", "retrieve"): + return self.queryset.prefetch_related("routes_source") + return self.queryset + @extend_schema( + parameters=[ + OpenApiParameter( + name='route', + description='Filter by route id', + type={"type": "array", "items": {"type": "number"}})]) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + +class RouteViewSet(viewsets.ModelViewSet): + queryset = Route.objects.all() + serializer_class = RouteSerializer + permission_classes = (IsAdminAllORIsAuthenticatedOReadOnly,) + + def get_serializer_class(self): + if self.action == "retrieve": + return RouteRetrieveSerializer + return RouteSerializer + + def get_queryset(self): + if self.action in ("list", "retrieve"): + return (self.queryset + .select_related("source", "destination") + .prefetch_related("flights_route")) + return self.queryset + + +class FlightViewSet(viewsets.ModelViewSet): + queryset = Flight.objects.all() + serializer_class = FlightSerializer + permission_classes = (IsAdminAllORIsAuthenticatedOReadOnly,) + + def get_serializer_class(self): + if self.action == "retrieve": + return FlightRetrieveSerializer + elif self.action in ("create", "put", "patch"): + return FlightCreateSerializer + return FlightSerializer + + def get_queryset(self): + queryset = Flight.objects.all() + airplane = self.request.query_params.get("airplane") + route_source = self.request.query_params.get("route_source") + route_destination = self.request.query_params.get("route_destination") + + if airplane: + return queryset.filter( + airplane__name__icontains=airplane + ) + if route_source: + return queryset.filter( + route__source__name__icontains=route_source + ) + if route_destination: + return queryset.filter(route__destination__name__icontains=route_destination) + return (queryset. + select_related("route", "airplane", + "route__source", "route__destination", + "airplane__airplane_type"). + prefetch_related("crew")) + + @extend_schema( + parameters=[ + OpenApiParameter( + name='airplane', + description='Filter by airplane name', + type={"type": "array", "items": {"type": "string"}}), + OpenApiParameter( + name='route source', + description='Filter by route source', + type={"type": "array", "items": {"type": "string"}}), + OpenApiParameter( + name='route destination', + description='Filter by route destination', + type={"type": "array", "items": {"type": "string"}})]) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + +class AirplaneViewSet(viewsets.ModelViewSet): + queryset = Airplane.objects.all() + serializer_class = AirplaneSerializer + permission_classes = (IsAdminAllORIsAuthenticatedOReadOnly,) + + def get_serializer_class(self): + if self.action == "upload_image": + return AirplaneImageSerializer + return AirplaneSerializer + + @action( + methods=["POST"], + detail=True, + permission_classes=[IsAdminUser], + url_path="upload_image" + ) + def upload_image(self, request, pk=None): + airplane = self.get_object() + serializer = self.get_serializer(airplane, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class CrewViewSet(viewsets.ModelViewSet): + queryset = Crew.objects.all() + serializer_class = CrewSerializer + permission_classes = (IsAdminAllORIsAuthenticatedOReadOnly,) + + +class AirplaneTypeViewSet(viewsets.ModelViewSet): + queryset = AirplaneType.objects.all() + serializer_class = AirplaneTypeSerializer + permission_classes = (IsAdminAllORIsAuthenticatedOReadOnly,) + + +class TicketViewSet(viewsets.ModelViewSet): + queryset = Ticket.objects.all() + serializer_class = TicketSerializer + permission_classes = (IsAdminAllORIsAuthenticatedOReadOnly,) + + def get_serializer_class(self): + if self.action in "list": + return TicketListSerializer + elif self.action == "retrieve": + return TicketRetrieveSerializer + return TicketSerializer + + def get_queryset(self): + if self.action in ("list", "retrieve"): + return self.queryset.select_related("flight", "order") + return self.queryset + + +class OrderViewSet(viewsets.ModelViewSet): + queryset = Order.objects.all() + serializer_class = OrderSerializer + permission_classes = (IsAdminAllORIsAuthenticatedOReadOnly,) + + def get_serializer_class(self): + if self.action == "create": + return OrderCreateSerializer + return OrderSerializer + + def get_queryset(self): + return (self.queryset + .filter(user=self.request.user) + .prefetch_related("available_tickets")) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..86dd4ce --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + app: + build: + context: . + env_file: + - .env + command: > + sh -c "python manage.py wait_for_db && python manage.py migrate && + python manage.py runserver 0.0.0.0:8000" + volumes: + - media_data:/files/media + depends_on: + - db + ports: + - "8000:8000" + + db: + image: postgres:16.0-alpine3.17 + environment: + POSTGRES_USER: "ruslan" + POSTGRES_PASSWORD: "password" + POSTGRES_DB: "db" + restart: always + ports: + - "5432:5432" + env_file: + - .env + volumes: + - db_data:/var/lib/postgresql/data + +volumes: + db_data: + media_data: diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..242acc0 --- /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', 'airport.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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9225976 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..4e662c9 --- /dev/null +++ b/user/admin.py @@ -0,0 +1,29 @@ +"""Integrate with admin module.""" + +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin +from django.utils.translation import gettext as _ + +from .models import User + + +@admin.register(User) +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..35048d4 --- /dev/null +++ b/user/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UserConfig(AppConfig): + name = 'user' diff --git a/user/migrations/0001_initial.py b/user/migrations/0001_initial.py new file mode 100644 index 0000000..94fe91c --- /dev/null +++ b/user/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 6.0.4 on 2026-04-23 14:40 + +import django.utils.timezone +import user.models +from django.db import migrations, 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..ff8be2a --- /dev/null +++ b/user/models.py @@ -0,0 +1,49 @@ +from django.contrib.auth.models import AbstractUser, UserManager as DjangoUserManager +from django.db import models +from django.utils.translation import gettext as _ + + +class UserManager(DjangoUserManager): + """Define a model manager for User model with no username field.""" + + use_in_migrations = True + + def _create_user(self, email, password, **extra_fields): + """Create and save a User with the given email and password.""" + if not email: + raise ValueError('The given email must be set') + 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, password=None, **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, password, **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..9188a5e --- /dev/null +++ b/user/serializers.py @@ -0,0 +1,57 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers +from django.contrib.auth import authenticate +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 = ("id", "is_staff") + extra_kwargs = { + "password": {"write_only": True, + "min_length": 9, + "style": {"input_type": "password"}, + "label": _("password"), + } + } + + def create(self, validated_data): + return get_user_model().objects.create_user(**validated_data) + + def update(self, instance, validated_data): + 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"), + write_only=True + ) + password = serializers.CharField( + label=_("Password"), + style={'input_type': 'password'}, + trim_whitespace=False, + write_only=True + ) + token = serializers.CharField( + label=_("Token"), + read_only=True + ) + def validate(self, attrs): + email = attrs.get('email') + password = attrs.get('password') + try: + user1 = get_user_model() + user = user1.objects.get(email=email) + except User.DoesNotExist: + raise serializers.ValidationError('Unable to log in with provided credentials.') + if not user.check_password(password): + raise serializers.ValidationError('Unable to log in with provided credentials.') + attrs['user'] = user + return attrs diff --git a/user/urls.py b/user/urls.py new file mode 100644 index 0000000..4c78f53 --- /dev/null +++ b/user/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView + +from user.views import UserView, ManageUserView + +app_name = "user" + +urlpatterns = [ + path("register/", UserView.as_view(), name="register"), + 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_user") +] diff --git a/user/views.py b/user/views.py new file mode 100644 index 0000000..960d96e --- /dev/null +++ b/user/views.py @@ -0,0 +1,25 @@ +from rest_framework import generics +from rest_framework.authentication import TokenAuthentication +from user.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 UserView(generics.CreateAPIView): + serializer_class = UserSerializer + + +class LoginUserView(ObtainAuthToken): + renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + serializer_class = AuthTokenSerializer + + +class ManageUserView(generics.RetrieveAPIView): + serializer_class = UserSerializer + permission_classes = (IsAuthenticated,) + + def get_object(self): + return self.request.user