diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..03a268b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/go/build-context-dockerignore/ + +**/.DS_Store +**/__pycache__ +**/.venv +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose.y*ml +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..caa1227 --- /dev/null +++ b/.env.sample @@ -0,0 +1,10 @@ +SECRET_KEY=SECRET_KEY +REDIS_URL=REDIS_URL +PGDATA=PGDATA + +# DB +POSTGRES_DB=POSTGRES_DB +POSTGRES_USER=POSTGRES_USER +POSTGRES_PASSWORD=POSTGRES_PASSWORD +POSTGRES_HOST=POSTGRES_HOST +POSTGRES_PORT=POSTGRES_PORT diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b74c87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea/ +.venv +.vscode +db.sqlite3 +__pycache__ +.env \ No newline at end of file diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..3dd6d50 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,7 @@ +[settings] +profile = pycharm +line_length = 88 +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +combine_as_imports = true \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3b18e14 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-alpine + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt requirements.txt +RUN pip install --upgrade pip +RUN pip install -r requirements.txt + +COPY . . + +RUN adduser \ + --disabled-password \ + --gecos "" \ + --home "/nonexistent" \ + --shell "/sbin/nologin" \ + --no-create-home \ + appuser + +USER appuser diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..aec0227 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +.PHONY: test run migrate docker docker-superuser docker-test + +test: + python manage.py test + +run: + python manage.py runserver + +migrate: + python manage.py makemigrations + python manage.py migrate + +docker: + docker-compose up --build + +docker-superuser: + docker-compose run app sh -c "python manage.py createsuperuser" + +docker-test: + docker-compose run app sh -c "python manage.py test" diff --git a/README.md b/README.md index 1501cd0..0590c06 100644 --- a/README.md +++ b/README.md @@ -1 +1,118 @@ -# planetarium-api-service \ No newline at end of file +# Planetarium API Service + +The Planetarium API Service is a Django REST Framework-based application that allows managing planetarium show bookings, +ticket reservations, and viewing information about astronomy shows, available domes, and show themes. + +## Key Features + +- Manage show themes. +- View, create, update, and delete astronomy shows. +- Manage planetarium domes (seats, halls, etc.). +- Advanced caching using **Redis**. +- Ticket reservation system for shows. +- Authentication using **JWT tokens**. +- Support for custom user model with `UUID` as the primary key. + +## Prerequisites + +Make sure you have the following tools installed on your system: + +- Docker and Docker Compose +- Python 3.12+ +- Redis for caching +- PostgreSQL + +## Running the Project with Docker-Compose + +1. Clone the repository: + ```bash + git clone + cd planetarium-api-service + ``` + +2. Create a `.env` file in the root directory of the project: + ```env + SECRET_KEY='' + ``` + +3. Start the application using Docker-Compose: + ```bash + make docker + ``` + or + ```bash + docker-compose up --build + ``` + +4. All API caching is managed using **Redis**. Configure `REDIS_URL` in the `.env` file: + +``` env +REDIS_URL=redis://redis:6379/0 +``` + +Redis is automatically started via `docker-compose`. + +5. The API will be available at: [http://localhost:8000](http://localhost:8000). + +### Docker-Compose Configuration + +- **app**: The Django application. +- **redis**: Caching service for API responses. +- **redisinsight**: A tool for monitoring Redis. + +### Database Management + +The project uses **PostgreSQL** as the database backend, +configured automatically with `docker-compose.yaml`. + +## User Management + +### Creating a Superuser + +To access the admin panel: + +```bash +make docker-superuser +``` + +You can then access the admin panel at: [http://localhost:8000/admin](http://localhost:8000/admin). + +## API Documentation + +The project provides interactive API documentation using `drf-spectacular`: + +- Swagger: [http://localhost:8000/api/schema/swagger/](http://localhost:8000/api/schema/swagger/) +- Redoc: [http://localhost:8000/api/schema/redoc/](http://localhost:8000/api/schema/redoc/) + +## Testing + +Unit tests cover the core API logic. To run tests, execute: + +```bash +make docker-test +``` + +## Project Structure + +- **planetarium/** + - API logic for managing planetarium shows, domes, and tickets. + - Serializers, models, and views. +- **accounts/** + - Custom user model implementation based on email authentication. + +## Key Dependencies + +- Django==5.1.4 +- Django REST Framework +- Django Filters +- Redis +- drf-spectacular +- django-debug-toolbar +- psycopg2-binary +- JWT (JSON Web Token) + +## Future Enhancements + +- Add Celery for background tasks like delayed booking confirmations. +- Real-time WebSocket Updates +- Payment Management diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..a02656e --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,38 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin +from django.utils.translation import gettext_lazy as _ + +from accounts.models import User + + +@admin.register(User) +class UserAdmin(DjangoUserAdmin): + 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/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..0cb51e6 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..be2cbb0 --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,117 @@ +# Generated by Django 5.1.4 on 2024-12-27 14:47 + +import uuid + +import django.utils.timezone +from django.db import migrations, models + +import accounts.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ("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" + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "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={ + "abstract": False, + }, + managers=[ + ("objects", accounts.models.UserManager()), + ], + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000..f9d9c78 --- /dev/null +++ b/accounts/models.py @@ -0,0 +1,46 @@ +from django.contrib.auth.base_user import BaseUserManager +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from base.models import UUIDBaseModel + + +class UserManager(BaseUserManager): + use_in_migrations = True + + def _create_user(self, email, password, **extra_fields): + 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): + 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): + 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(UUIDBaseModel, AbstractUser): + username = None + email = models.EmailField(_("email address"), unique=True) + + USERNAME_FIELD = "email" + REQUIRED_FIELDS = [] + + objects = UserManager() diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..6b7ec17 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,15 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = get_user_model() + fields = ("id", "username", "email", "first_name", "last_name", "password") + read_only_fields = ("id",) + extra_kwargs = { + "password": {"write_only": True, "min_length": 8}, + } + + def create(self, validated_data): + return get_user_model().objects.create_user(**validated_data) diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/tests/tests_user.py b/accounts/tests/tests_user.py new file mode 100644 index 0000000..334458c --- /dev/null +++ b/accounts/tests/tests_user.py @@ -0,0 +1,60 @@ +from django.contrib.auth import get_user_model +from rest_framework.test import APITestCase + +from accounts.serializers import UserSerializer + + +class AccountsUserTest(APITestCase): + def setUp(self): + self.email = "email@email.com" + self.password = "password123" + self.user = get_user_model().objects.create_user( + email=self.email, + password=self.password, + ) + + def test_create_user(self): + """Test creating a user with an email is successful""" + self.assertEqual(self.user.email, self.email) + self.assertTrue(self.user.check_password(self.password)) + self.assertFalse(self.user.is_superuser) + self.assertFalse(self.user.is_staff) + + def test_email_unique(self): + """Test that email is unique""" + with self.assertRaises(Exception): + get_user_model().objects.create_user( + email=self.email, + password="password123", + ) + + def test_create_user_without_email(self): + """Test creating a user without an email raises an error""" + with self.assertRaises(ValueError): + get_user_model().objects.create_user(email=None, password="password123") + + +class AccountsUserSerializerTest(APITestCase): + def test_serializer_user_create(self): + payload = { + "email": "email@email.com", + "password": "password123", + "first_name": "first_name", + "last_name": "last_name", + } + serializer = UserSerializer(data=payload) + self.assertTrue(serializer.is_valid()) + user = serializer.save() + self.assertEqual(user.email, payload["email"]) + self.assertTrue(user.check_password(payload["password"])) + self.assertEqual(user.first_name, payload["first_name"]) + self.assertEqual(user.last_name, payload["last_name"]) + + def test_serializer_user_password_too_short(self): + payload = { + "email": "email@email.com", + "password": "1234567", + } + serializer = UserSerializer(data=payload) + self.assertFalse(serializer.is_valid()) + self.assertEqual(len(serializer.errors["password"]), 1) diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..931609a --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,18 @@ +from django.urls import path +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, + TokenVerifyView, +) + +from .views import CreateUserView + + +app_name = "accounts" + +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"), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..628ecd0 --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,9 @@ +from rest_framework import generics + +from accounts.models import User +from accounts.serializers import UserSerializer + + +class CreateUserView(generics.CreateAPIView): + queryset = User.objects.all() + serializer_class = UserSerializer diff --git a/base/__init__.py b/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/base/migrations/__init__.py b/base/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/base/models.py b/base/models.py new file mode 100644 index 0000000..3e4c84f --- /dev/null +++ b/base/models.py @@ -0,0 +1,10 @@ +import uuid + +from django.db import models + + +class UUIDBaseModel(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + class Meta: + abstract = True diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..ba2224a --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,51 @@ +services: + app: + build: + context: . + command: > + sh -c "python manage.py wait_for_db && + python manage.py migrate && + python manage.py runserver 0.0.0.0:8000" + ports: + - "8000:8000" + volumes: + - ./:/app + env_file: + - .env + depends_on: + - redis + - db + restart: always + + redis: + image: redis:alpine + + redisinsight: + image: redislabs/redisinsight:latest + ports: + - "5540:5540" + depends_on: + - redis + + db: + image: postgres:16.0-alpine + restart: always + env_file: + - .env + environment: + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + ports: + - "5432:5432" + volumes: + - my_db:$PGDATA + healthcheck: + test: curl --fail http://localhost:5432 || exit 1 + interval: 40s + retries: 3 + start_period: 30s + timeout: 30s + +volumes: + my_db: diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..783e3f7 --- /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", "planetarium_service.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/planetarium/__init__.py b/planetarium/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/planetarium/admin.py b/planetarium/admin.py new file mode 100644 index 0000000..337be14 --- /dev/null +++ b/planetarium/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin + +from planetarium.models import ( + AstronomyShow, + PlanetariumDome, + Reservation, + ShowSession, + ShowTheme, + Ticket, +) + + +admin.site.register(ShowTheme) +admin.site.register(AstronomyShow) +admin.site.register(PlanetariumDome) +admin.site.register(ShowSession) +admin.site.register(Reservation) +admin.site.register(Ticket) diff --git a/planetarium/apps.py b/planetarium/apps.py new file mode 100644 index 0000000..b2d8c28 --- /dev/null +++ b/planetarium/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class PlanetariumConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "planetarium" + + def ready(self): + import planetarium.signals diff --git a/planetarium/management/commands/wait_for_db.py b/planetarium/management/commands/wait_for_db.py new file mode 100644 index 0000000..bead53d --- /dev/null +++ b/planetarium/management/commands/wait_for_db.py @@ -0,0 +1,20 @@ +import time + +from django.core.management.base import BaseCommand +from django.db import connections +from django.db.utils import OperationalError + + +class Command(BaseCommand): + def handle(self, *args, **options): + self.stdout.write("Waiting for database...") + db_connection = None + while not db_connection: + try: + db_connection = connections["default"] + self.stdout.write(self.style.SUCCESS("Connected to database")) + except OperationalError: + self.stdout.write( + self.style.ERROR("Database unavailable, wait 1 second...") + ) + time.sleep(1) diff --git a/planetarium/migrations/0001_initial.py b/planetarium/migrations/0001_initial.py new file mode 100644 index 0000000..3b6091f --- /dev/null +++ b/planetarium/migrations/0001_initial.py @@ -0,0 +1,162 @@ +# Generated by Django 5.1.4 on 2024-12-27 14:47 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="AstronomyShow", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255)), + ("description", models.TextField()), + ], + ), + migrations.CreateModel( + name="PlanetariumDome", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("rows", models.IntegerField()), + ("seats_in_row", models.IntegerField()), + ], + ), + migrations.CreateModel( + name="ShowTheme", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=127)), + ], + ), + migrations.CreateModel( + name="Reservation", + 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="reservations", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="ShowSession", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("show_time", models.DateTimeField()), + ( + "astronomy_show", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="show_sessions", + to="planetarium.astronomyshow", + ), + ), + ( + "planetarium_dome", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="show_sessions", + to="planetarium.planetariumdome", + ), + ), + ], + ), + migrations.AddField( + model_name="astronomyshow", + name="show_theme", + field=models.ManyToManyField( + blank=True, related_name="astronomy_shows", to="planetarium.showtheme" + ), + ), + 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()), + ( + "reservation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tickets", + to="planetarium.reservation", + ), + ), + ( + "show_session", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tickets", + to="planetarium.showsession", + ), + ), + ], + options={ + "ordering": ["row", "seat"], + "unique_together": {("row", "seat", "show_session")}, + }, + ), + ] diff --git a/planetarium/migrations/__init__.py b/planetarium/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/planetarium/models.py b/planetarium/models.py new file mode 100644 index 0000000..eff812a --- /dev/null +++ b/planetarium/models.py @@ -0,0 +1,79 @@ +from django.conf import settings +from django.db import models + + +class ShowTheme(models.Model): + name = models.CharField(max_length=127) + + def __str__(self): + return self.name + + +class AstronomyShow(models.Model): + title = models.CharField(max_length=255) + description = models.TextField() + show_theme = models.ManyToManyField( + ShowTheme, blank=True, related_name="astronomy_shows" + ) + + def __str__(self): + return self.title + + +class PlanetariumDome(models.Model): + name = models.CharField(max_length=255) + rows = models.IntegerField() + seats_in_row = models.IntegerField() + + @property + def capacity(self): + return self.rows * self.seats_in_row + + def __str__(self): + return f"{self.name} ({self.capacity} seats)" + + +class ShowSession(models.Model): + astronomy_show = models.ForeignKey( + AstronomyShow, on_delete=models.CASCADE, related_name="show_sessions" + ) + planetarium_dome = models.ForeignKey( + PlanetariumDome, on_delete=models.CASCADE, related_name="show_sessions" + ) + show_time = models.DateTimeField() + + def __str__(self): + return f"{self.astronomy_show.title} - {self.planetarium_dome.name}" + + +class Reservation(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reservations" + ) + + def __str__(self): + return f"{self.user.email} - (created: {self.created_at})" + + +class Ticket(models.Model): + row = models.IntegerField() + seat = models.IntegerField() + show_session = models.ForeignKey( + ShowSession, on_delete=models.CASCADE, related_name="tickets" + ) + reservation = models.ForeignKey( + Reservation, on_delete=models.CASCADE, related_name="tickets" + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["row", "seat", "show_session"], + name="unique_row_seat_show_session", + ) + ] + ordering = ["row", "seat"] + + def __str__(self): + return f"{self.show_session.astronomy_show.title} - Row {self.row} Seat {self.seat}" diff --git a/planetarium/permissions.py b/planetarium/permissions.py new file mode 100644 index 0000000..778d3f6 --- /dev/null +++ b/planetarium/permissions.py @@ -0,0 +1,10 @@ +from rest_framework.permissions import SAFE_METHODS, BasePermission + + +class IsAdminAllORIsAuthenticatedOrReadOnly(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/planetarium/serializers.py b/planetarium/serializers.py new file mode 100644 index 0000000..a06b6c9 --- /dev/null +++ b/planetarium/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers +from rest_framework.validators import UniqueTogetherValidator + +from planetarium.models import ( + AstronomyShow, + PlanetariumDome, + Reservation, + ShowSession, + ShowTheme, + Ticket, +) + + +class ShowThemeSerializer(serializers.ModelSerializer): + class Meta: + model = ShowTheme + fields = ["id", "name"] + + +class AstronomyShowSerializer(serializers.ModelSerializer): + class Meta: + model = AstronomyShow + fields = ["id", "title", "description", "show_theme"] + + +class AstronomyShowListSerializer(AstronomyShowSerializer): + show_theme = serializers.SlugRelatedField( + many=True, read_only=True, slug_field="name" + ) + + +class AstronomyShowRetrieveSerializer(AstronomyShowSerializer): + show_theme = ShowThemeSerializer(many=True) + + +class PlanetariumDomeSerializer(serializers.ModelSerializer): + class Meta: + model = PlanetariumDome + fields = ["id", "name", "rows", "seats_in_row", "capacity"] + + +class ShowSessionSerializer(serializers.ModelSerializer): + class Meta: + model = ShowSession + fields = ["id", "astronomy_show", "planetarium_dome", "show_time"] + + +class ShowSessionListSerializer(ShowSessionSerializer): + astronomy_show = serializers.CharField( + source="astronomy_show.title", read_only=True + ) + planetarium_dome = serializers.CharField( + source="planetarium_dome.name", read_only=True + ) + + +class ShowSessionRetrieveSerializer(ShowSessionSerializer): + astronomy_show = AstronomyShowRetrieveSerializer() + planetarium_dome = PlanetariumDomeSerializer() + + +class ReservationSerializer(serializers.ModelSerializer): + user = serializers.CharField(source="user.email", read_only=True) + created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S") + + class Meta: + model = Reservation + fields = ["id", "created_at", "user"] + + +class TicketSerializer(serializers.ModelSerializer): + class Meta: + model = Ticket + fields = ["id", "row", "seat", "show_session", "reservation"] + validators = [ + UniqueTogetherValidator( + queryset=Ticket.objects.all(), + fields=["row", "seat", "show_session"], + message="This seat is already taken.", + ) + ] + + def validate(self, data): + row = data.get("row") + seat = data.get("seat") + show_session = data.get("show_session") + dome = show_session.planetarium_dome + if not (0 < row < dome.rows): + raise serializers.ValidationError({"row": f"Row {row} is out of range."}) + if not (0 < seat < dome.seats_in_row): + raise serializers.ValidationError({"seat": f"Seat {seat} is out of range."}) + if Ticket.objects.filter(row=row, seat=seat).exists(): + raise serializers.ValidationError( + {"seat": f"Seat {seat} is already taken."} + ) + return data + + +class TicketListSerializer(serializers.ModelSerializer): + user = serializers.CharField(source="reservation.user.email", read_only=True) + astronomy_show = serializers.CharField( + source="show_session.astronomy_show.title", read_only=True + ) + + class Meta: + model = Ticket + fields = ["id", "row", "seat", "user", "astronomy_show"] + validators = [ + UniqueTogetherValidator( + queryset=Ticket.objects.all(), + fields=["row", "seat", "show_session"], + message="This seat is already taken.", + ) + ] + + +class TickerRetrieveSerializer(TicketSerializer): + show_session = ShowSessionRetrieveSerializer() + reservation = ReservationSerializer() diff --git a/planetarium/signals.py b/planetarium/signals.py new file mode 100644 index 0000000..4f30287 --- /dev/null +++ b/planetarium/signals.py @@ -0,0 +1,10 @@ +from django.core.cache import cache +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver + +from planetarium.models import Ticket + + +@receiver([post_delete, post_save], sender=Ticket) +def invalidate_ticket_cache(sender, instance, **kwargs): + cache.delete_pattern("ticket_view:*") diff --git a/planetarium/tests/__init__.py b/planetarium/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/planetarium/tests/tests_api_ticket.py b/planetarium/tests/tests_api_ticket.py new file mode 100644 index 0000000..b484a8d --- /dev/null +++ b/planetarium/tests/tests_api_ticket.py @@ -0,0 +1,227 @@ +import uuid +from datetime import datetime + +from django.contrib.auth import get_user_model +from django.core.cache import cache +from rest_framework import status +from rest_framework.reverse import reverse +from rest_framework.test import APITestCase + +from planetarium.models import ( + AstronomyShow, + PlanetariumDome, + Reservation, + ShowSession, + ShowTheme, + Ticket, +) +from planetarium.serializers import TickerRetrieveSerializer, TicketListSerializer + + +TICKET_URL = reverse("planetarium:ticket-list") + + +def detail_url(ticket_id): + return reverse("planetarium:ticket-detail", args=[ticket_id]) + + +def sample_show_theme(): + return ShowTheme.objects.create(name=f"Test theme{uuid.uuid4().hex}") + + +def sample_astronomy_show(**params): + defaults = { + "title": f"Test show {uuid.uuid4().hex}", + "description": "Test description", + } + defaults.update(params) + astronomy_show = AstronomyShow.objects.create(**defaults) + astronomy_show.show_theme.add(sample_show_theme().id) + astronomy_show.save() + return astronomy_show + + +def sample_planetarium_dome(**params): + defaults = { + "name": f"Test dome {uuid.uuid4().hex}", + "rows": 2, + "seats_in_row": 2, + } + defaults.update(params) + return PlanetariumDome.objects.create(**defaults) + + +def sample_show_session(**params): + defaults = { + "astronomy_show": sample_astronomy_show(), + "planetarium_dome": sample_planetarium_dome(), + "show_time": datetime.now(), + } + defaults.update(params) + return ShowSession.objects.create(**defaults) + + +def sample_user(**params): + defaults = { + "email": f"user_{uuid.uuid4().hex}@user_test.com", + "password": "password1", + } + defaults.update(params) + return get_user_model().objects.create_user(**defaults) + + +def sample_reservation(**params): + defaults = { + "user": sample_user(), + } + defaults.update(params) + return Reservation.objects.create(**defaults) + + +def sample_ticket(**params): + defaults = { + "row": 1, + "seat": 1, + "show_session": sample_show_session(), + "reservation": sample_reservation(), + } + defaults.update(params) + return Ticket.objects.create(**defaults) + + +class BaseApiTests(APITestCase): + """This class for cleaning cache after each test""" + + def tearDown(self): + cache.clear() + + +class UnauthenticatedTicketTests(BaseApiTests): + """Test the ticket API for unauthenticated users""" + + def test_auth_required(self): + res = self.client.get(TICKET_URL) + self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) + + +class AuthenticatedBusApiTests(BaseApiTests): + def setUp(self): + self.user = get_user_model().objects.create_user( + email="test@test.com", + password="TestPass123", + ) + self.client.force_authenticate(user=self.user) + + def test_ticket_list(self): + """Test get list of tickets for authenticated user""" + sample_ticket() + sample_ticket() + res = self.client.get(TICKET_URL) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(len(res.data["results"]), 2) + + def test_filter_ticket_by_show_session_title(self): + """Test filter tickets by show session title""" + show_session = sample_show_session() + ticket1 = sample_ticket(show_session=show_session) + ticket2 = sample_ticket() + res = self.client.get( + TICKET_URL, + {"title": show_session.astronomy_show.title}, + ) + serializer1 = TicketListSerializer(ticket1) + serializer2 = TicketListSerializer(ticket2) + + self.assertIn(serializer1.data, res.data["results"]) + self.assertNotIn(serializer2.data, res.data["results"]) + + def test_filter_ticket_by_user_email(self): + """Test filter tickets by user email""" + reservation = sample_reservation() + ticket1 = sample_ticket(reservation=reservation) + ticket2 = sample_ticket() + res = self.client.get( + TICKET_URL, + {"email": reservation.user.email}, + ) + serializer1 = TicketListSerializer(ticket1) + serializer2 = TicketListSerializer(ticket2) + self.assertIn(serializer1.data, res.data["results"]) + self.assertNotIn(serializer2.data, res.data["results"]) + + def test_retrieve_ticket(self): + ticket = sample_ticket() + url = detail_url(ticket.id) + res = self.client.get(url) + + serializer = TickerRetrieveSerializer(ticket) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(serializer.data, res.data) + + def test_retrieve_ticket_with_cache(self): + ticket1 = sample_ticket() + cache.set("ticket_view1", ticket1) + res = self.client.get(TICKET_URL) + self.assertEqual(res.status_code, status.HTTP_200_OK) + + cache_data = cache.get("ticket_view1") + self.assertIsNotNone(cache_data) + + ticket2 = sample_ticket() + cache.set("ticket_view2", ticket2) + cache_data = cache.get("ticket_view2") + self.assertIsNotNone(cache_data) + + def test_create_ticket_forbidden(self): + """Test that creating a ticket is forbidden for authenticated users""" + payload = { + "row": 1, + "seat": 1, + "show_session": sample_show_session().id, + "reservation": sample_reservation().id, + } + res = self.client.post(TICKET_URL, payload) + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + + +class AdminUserTicketTests(BaseApiTests): + def setUp(self): + self.user = get_user_model().objects.create_user( + email="admin@admin.com", + password="TestPass123", + is_staff=True, + ) + self.client.force_authenticate(user=self.user) + + def test_create_ticket_successful(self): + """Test that creating a ticket is successful for admin users""" + show_session = sample_show_session() + reservation = sample_reservation() + payload = { + "row": 1, + "seat": 1, + "show_session": show_session.id, + "reservation": reservation.id, + } + res = self.client.post(TICKET_URL, payload) + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + + def test_create_ticket_with_invalid_data(self): + """Test that creating a ticket is successful for admin users""" + show_session = sample_show_session() + reservation = sample_reservation() + payload = { + "row": 1, + "seat": 1, + "show_session": show_session.id, + } + res = self.client.post(TICKET_URL, payload) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_delete_ticket(self): + """Test that deleting a ticket is successful for admin users""" + ticket = sample_ticket() + url = detail_url(ticket.id) + res = self.client.delete(url) + self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(Ticket.objects.filter(id=ticket.id).exists()) diff --git a/planetarium/urls.py b/planetarium/urls.py new file mode 100644 index 0000000..bc11efd --- /dev/null +++ b/planetarium/urls.py @@ -0,0 +1,23 @@ +from rest_framework import routers + +from planetarium.views import ( + AstronomyShowViewSet, + PlanetariumDomeViewSet, + ReservationViewSet, + ShowSessionViewSet, + ShowThemeViewSet, + TicketViewSet, +) + + +router = routers.DefaultRouter() +router.register("show-themes", ShowThemeViewSet) +router.register("astronomy-shows", AstronomyShowViewSet) +router.register("planetarium-domes", PlanetariumDomeViewSet) +router.register("show-sessions", ShowSessionViewSet) +router.register("reservations", ReservationViewSet) +router.register("tickets", TicketViewSet) + +urlpatterns = router.urls + +app_name = "planetarium" diff --git a/planetarium/views.py b/planetarium/views.py new file mode 100644 index 0000000..c97ec25 --- /dev/null +++ b/planetarium/views.py @@ -0,0 +1,147 @@ +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page +from rest_framework import filters, viewsets + +from planetarium.models import ( + AstronomyShow, + PlanetariumDome, + Reservation, + ShowSession, + ShowTheme, + Ticket, +) +from planetarium.serializers import ( + AstronomyShowListSerializer, + AstronomyShowRetrieveSerializer, + AstronomyShowSerializer, + PlanetariumDomeSerializer, + ReservationSerializer, + ShowSessionListSerializer, + ShowSessionRetrieveSerializer, + ShowSessionSerializer, + ShowThemeSerializer, + TickerRetrieveSerializer, + TicketListSerializer, + TicketSerializer, +) + + +class ShowThemeViewSet(viewsets.ModelViewSet): + queryset = ShowTheme.objects.all() + serializer_class = ShowThemeSerializer + + +class AstronomyShowViewSet(viewsets.ModelViewSet): + queryset = AstronomyShow.objects.all() + serializer_class = AstronomyShowSerializer + filter_backends = [filters.SearchFilter] + search_fields = ["title", "description"] + + def get_queryset(self): + queryset = self.queryset + if self.action in ["list", "retrieve"]: + return queryset.prefetch_related("show_theme") + return queryset + + def get_serializer_class(self): + if self.action == "list": + return AstronomyShowListSerializer + if self.action == "retrieve": + return AstronomyShowRetrieveSerializer + return AstronomyShowSerializer + + +class PlanetariumDomeViewSet(viewsets.ModelViewSet): + queryset = PlanetariumDome.objects.all() + serializer_class = PlanetariumDomeSerializer + + +class ShowSessionViewSet(viewsets.ModelViewSet): + queryset = ShowSession.objects.all() + serializer_class = ShowSessionSerializer + + def get_queryset(self): + queryset = self.queryset + if self.action == "list": + return queryset.prefetch_related("astronomy_show", "planetarium_dome") + if self.action == "retrieve": + return queryset.prefetch_related( + "astronomy_show__show_theme", "planetarium_dome" + ) + return queryset + + def get_serializer_class(self): + if self.action == "list": + return ShowSessionListSerializer + if self.action == "retrieve": + return ShowSessionRetrieveSerializer + return ShowSessionSerializer + + +class ReservationViewSet(viewsets.ModelViewSet): + queryset = Reservation.objects.all() + serializer_class = ReservationSerializer + filter_backends = [filters.SearchFilter] + search_fields = ["user__email"] + + def get_queryset(self): + queryset = self.queryset + if self.request.user.is_authenticated: + queryset = queryset.filter(user=self.request.user) + if self.action == "list": + return queryset.prefetch_related("user") + return queryset + + def perform_create(self, serializer): + if self.request.user: + serializer.save(user=self.request.user) + + +class TicketViewSet(viewsets.ModelViewSet): + queryset = Ticket.objects.all() + serializer_class = TicketSerializer + filter_backends = [filters.SearchFilter] + search_fields = [ + "row", + "seat", + "reservation__user__email", + "show_session__astronomy_show__title", + ] + + @staticmethod + def _params_to_ints(self, query_string): + return [int(str_id) for str_id in query_string.split(",")] + + def get_queryset(self): + queryset = self.queryset + + title = self.request.query_params.get("title") + email = self.request.query_params.get("email") + + if title: + queryset = queryset.filter( + show_session__astronomy_show__title__icontains=title + ) + + if email: + queryset = queryset.filter( + reservation__user__email__icontains=email + ) + + if self.action == "list": + return queryset.select_related( + "show_session__astronomy_show", + "reservation__user", + ) + return queryset.distinct() + + def get_serializer_class(self): + if self.action == "list": + return TicketListSerializer + if self.action == "retrieve": + return TickerRetrieveSerializer + return TicketSerializer + + @method_decorator(cache_page(60 * 5, key_prefix="ticket_view")) + def dispatch(self, request, *args, **kwargs): + return super().dispatch(request, *args, **kwargs) diff --git a/planetarium_service/__init__.py b/planetarium_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/planetarium_service/asgi.py b/planetarium_service/asgi.py new file mode 100644 index 0000000..21085f0 --- /dev/null +++ b/planetarium_service/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for planetarium_service 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/5.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "planetarium_service.settings") + +application = get_asgi_application() diff --git a/planetarium_service/settings.py b/planetarium_service/settings.py new file mode 100644 index 0000000..4899a99 --- /dev/null +++ b/planetarium_service/settings.py @@ -0,0 +1,188 @@ +""" +Django settings for planetarium_service project. + +Generated by 'django-admin startproject' using Django 5.1.4. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" + +import os +from datetime import timedelta +from pathlib import Path + +from dotenv import load_dotenv + + +load_dotenv() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.1/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 = True + +ALLOWED_HOSTS = [] + +# Application definition + +INSTALLED_APPS = [ + "debug_toolbar", + "jazzmin", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "django_filters", + "drf_spectacular", + "rest_framework_simplejwt", + "accounts", + "planetarium", + "base", +] + +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 = "planetarium_service.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 = "planetarium_service.wsgi.application" + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv("POSTGRES_DB"), + "USER": os.getenv("POSTGRES_USER"), + "PASSWORD": os.getenv("POSTGRES_PASSWORD"), + "HOST": os.getenv("POSTGRES_HOST"), + "PORT": os.getenv("POSTGRES_PORT"), + } +} + +# Password validation +# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +# Internationalization +# https://docs.djangoproject.com/en/5.1/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/5.1/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +AUTH_USER_MODEL = "accounts.User" + +INTERNAL_IPS = [ + "127.0.0.1", +] + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), + "DEFAULT_THROTTLE_CLASSES": ( + "rest_framework.throttling.AnonRateThrottle", + "rest_framework.throttling.UserRateThrottle", + ), + "DEFAULT_THROTTLE_RATES": {"anon": "100/day", "user": "1000/day"}, + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", + "PAGE_SIZE": 10, + "DEFAULT_PERMISSION_CLASSES": ( + "planetarium.permissions.IsAdminAllORIsAuthenticatedOrReadOnly", + ), +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(days=1), + "REFRESH_TOKEN_LIFETIME": timedelta(days=7), + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, + "UPDATE_LAST_LOGIN": True, +} + +SPECTACULAR_SETTINGS = { + "TITLE": "Planetarium API", + "DESCRIPTION": "Book tickets for the planetarium.", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, +} + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": os.getenv("REDIS_URL"), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + } +} diff --git a/planetarium_service/urls.py b/planetarium_service/urls.py new file mode 100644 index 0000000..a0c3a16 --- /dev/null +++ b/planetarium_service/urls.py @@ -0,0 +1,42 @@ +""" +URL configuration for planetarium_service project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.contrib import admin +from django.urls import path, include +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularSwaggerView, + SpectacularRedocView, +) + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/planetarium/", include("planetarium.urls"), name="planetarium"), + path("api/accounts/", include("accounts.urls"), name="accounts"), + 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", + ), + path("__debug__/", include("debug_toolbar.urls")), +] diff --git a/planetarium_service/wsgi.py b/planetarium_service/wsgi.py new file mode 100644 index 0000000..ccc9cdd --- /dev/null +++ b/planetarium_service/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for planetarium_service 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/5.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "planetarium_service.settings") + +application = get_wsgi_application() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f9ad87a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,34 @@ +asgiref==3.8.1 +attrs==24.3.0 +black==24.10.0 +click==8.1.8 +colorama==0.4.6 +Django==5.1.4 +django-debug-toolbar==4.4.6 +django-filter==24.3 +django-jazzmin==3.0.1 +django-redis==5.4.0 +djangorestframework==3.15.2 +djangorestframework-simplejwt==5.3.1 +docopt==0.6.2 +drf-spectacular==0.28.0 +inflection==0.5.1 +jsonschema==4.23.0 +jsonschema-specifications==2024.10.1 +makefile==1.1.0 +Markdown==3.7 +mypy-extensions==1.0.0 +packaging==24.2 +pathspec==0.12.1 +platformdirs==4.3.6 +psycopg2-binary==2.9.10 +py-make==0.1.2 +PyJWT==2.10.1 +python-dotenv==1.0.1 +PyYAML==6.0.2 +redis==5.0.1 +referencing==0.35.1 +rpds-py==0.22.3 +sqlparse==0.5.3 +tzdata==2024.2 +uritemplate==4.1.1