diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c90eda3 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +DJANGO_SECRET_KEY=change-me +DJANGO_DEBUG=1 +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5434/watertrek diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4105ff9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + PATH="/root/.local/bin:${PATH}" + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -LsSf https://astral.sh/uv/install.sh | sh + +COPY pyproject.toml /app/ + +RUN uv sync --no-dev + +COPY . /app + +EXPOSE 8000 + +ENTRYPOINT ["sh", "/app/scripts/entrypoint.sh"] diff --git a/README.md b/README.md index ad6f839..6fcc2b4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,46 @@ -# booking_backend +# WaterTrek Backend -backend for the booking platform \ No newline at end of file +Skeleton Django backend for WaterTrek, using `uv` for package management and Docker for local development. + +## Stack + +- Django project: `WaterTrek` +- First app: `booking` +- Package manager: `uv` +- Database: PostgreSQL (Docker service) + +## Run with Docker + +1. Optional: copy env defaults + + ```bash + cp .env.example .env + ``` + +2. Build and start: + + ```bash + docker compose up --build + ``` + +3. Visit the health endpoint: + + - `http://localhost:8000/booking/health/` + +## Run locally with uv + +If your machine has internet access available for dependency install: + +```bash +uv sync +uv run python manage.py migrate +uv run python manage.py runserver +``` + +## Production-style server in Docker + +The `web` container runs Gunicorn: + +```bash +uv run gunicorn WaterTrek.wsgi:application --bind 0.0.0.0:8000 --workers 3 +``` \ No newline at end of file diff --git a/WaterTrek/__init__.py b/WaterTrek/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/WaterTrek/__init__.py @@ -0,0 +1 @@ + diff --git a/WaterTrek/asgi.py b/WaterTrek/asgi.py new file mode 100644 index 0000000..5bb1f94 --- /dev/null +++ b/WaterTrek/asgi.py @@ -0,0 +1,11 @@ +""" +ASGI config for WaterTrek project. +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "WaterTrek.settings") + +application = get_asgi_application() diff --git a/WaterTrek/settings.py b/WaterTrek/settings.py new file mode 100644 index 0000000..93f37eb --- /dev/null +++ b/WaterTrek/settings.py @@ -0,0 +1,95 @@ +"""Django settings for WaterTrek project.""" +from pathlib import Path +import os + +import dj_database_url + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "django-insecure-change-me") +DEBUG = os.getenv("DJANGO_DEBUG", "1") == "1" + +ALLOWED_HOSTS = [host for host in os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") if host] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "accounts", + "booking", + "equipment", + "adventrues", + "payment", + "marketing", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "WaterTrek.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "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 = "WaterTrek.wsgi.application" +ASGI_APPLICATION = "WaterTrek.asgi.application" + +DATABASES = { + "default": dj_database_url.parse( + os.getenv("DATABASE_URL", "postgresql://postgres:postgres@127.0.0.1:5434/watertrek") + ) +} + +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"}, +] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +AUTH_USER_MODEL = "accounts.User" +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), + "DEFAULT_PERMISSION_CLASSES": ( + "rest_framework.permissions.IsAuthenticatedOrReadOnly", + ), +} diff --git a/WaterTrek/urls.py b/WaterTrek/urls.py new file mode 100644 index 0000000..c98847d --- /dev/null +++ b/WaterTrek/urls.py @@ -0,0 +1,14 @@ +"""WaterTrek URL Configuration.""" +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("booking/", include("booking.urls")), + path("api/v1/accounts/", include("accounts.urls")), + path("api/v1/equipment/", include("equipment.urls")), + path("api/v1/adventrues/", include("adventrues.urls")), + path("api/v1/booking/", include("booking.urls")), + path("api/v1/payment/", include("payment.urls")), + path("api/v1/marketing/", include("marketing.urls")), +] diff --git a/WaterTrek/wsgi.py b/WaterTrek/wsgi.py new file mode 100644 index 0000000..6785843 --- /dev/null +++ b/WaterTrek/wsgi.py @@ -0,0 +1,11 @@ +""" +WSGI config for WaterTrek project. +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "WaterTrek.settings") + +application = get_wsgi_application() diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/accounts/__init__.py @@ -0,0 +1 @@ + diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..5d163b8 --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,50 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin + +from .models import CustomerProfile, User, VendorProfile + + +@admin.register(User) +class UserAdmin(DjangoUserAdmin): + ordering = ("email",) + list_display = ("email", "first_name", "last_name", "is_vendor", "is_customer", "is_staff") + search_fields = ("email", "first_name", "last_name", "phone_number") + list_filter = ("is_vendor", "is_customer", "is_staff", "is_superuser", "is_active") + + fieldsets = ( + (None, {"fields": ("email", "password")}), + ("Personal info", {"fields": ("first_name", "last_name", "phone_number")}), + ( + "Roles", + {"fields": ("is_vendor", "is_customer")}, + ), + ( + "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", "is_vendor", "is_customer"), + }, + ), + ) + + +@admin.register(VendorProfile) +class VendorProfileAdmin(admin.ModelAdmin): + list_display = ("business_name", "user", "slug", "city", "country", "updated_at") + search_fields = ("business_name", "user__email", "contact_email") + list_filter = ("country", "state") + readonly_fields = ("created_at", "updated_at") + + +@admin.register(CustomerProfile) +class CustomerProfileAdmin(admin.ModelAdmin): + list_display = ("user", "preferred_contact_method", "updated_at") + search_fields = ("user__email", "emergency_contact_name") + readonly_fields = ("created_at", "updated_at") 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..304828f --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,79 @@ +# Generated by Django 6.0.4 on 2026-04-08 16:53 + +import django.contrib.auth.models +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +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)), + ('phone_number', models.CharField(blank=True, max_length=32)), + ('is_vendor', models.BooleanField(default=False)), + ('is_customer', models.BooleanField(default=True)), + ('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', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='CustomerProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('preferred_contact_method', models.CharField(blank=True, max_length=32)), + ('emergency_contact_name', models.CharField(blank=True, max_length=255)), + ('emergency_contact_phone', models.CharField(blank=True, max_length=32)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='customer_profile', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='VendorProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('business_name', models.CharField(max_length=255)), + ('slug', models.SlugField(blank=True, max_length=255, unique=True)), + ('description', models.TextField(blank=True)), + ('contact_phone', models.CharField(blank=True, max_length=32)), + ('contact_email', models.EmailField(blank=True, max_length=254)), + ('address_line1', models.CharField(blank=True, max_length=255)), + ('address_line2', models.CharField(blank=True, max_length=255)), + ('city', models.CharField(blank=True, max_length=100)), + ('state', models.CharField(blank=True, max_length=100)), + ('postal_code', models.CharField(blank=True, max_length=32)), + ('country', models.CharField(blank=True, max_length=100)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='vendor_profile', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/accounts/migrations/0002_alter_user_managers.py b/accounts/migrations/0002_alter_user_managers.py new file mode 100644 index 0000000..65ce0f2 --- /dev/null +++ b/accounts/migrations/0002_alter_user_managers.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.4 on 2026-04-08 17:02 + +import accounts.models +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ('objects', accounts.models.UserManager()), + ], + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/accounts/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000..421dbcb --- /dev/null +++ b/accounts/models.py @@ -0,0 +1,81 @@ +from django.contrib.auth.base_user import BaseUserManager +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.utils.text import slugify + + +class UserManager(BaseUserManager): + use_in_migrations = True + + def create_user(self, email, password=None, **extra_fields): + if not email: + raise ValueError("The Email field 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_superuser(self, email, password=None, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + extra_fields.setdefault("is_active", 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): + username = None + email = models.EmailField(unique=True) + phone_number = models.CharField(max_length=32, blank=True) + is_vendor = models.BooleanField(default=False) + is_customer = models.BooleanField(default=True) + + USERNAME_FIELD = "email" + REQUIRED_FIELDS: list[str] = [] + objects = UserManager() + + def __str__(self) -> str: + return self.email + + +class VendorProfile(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="vendor_profile") + business_name = models.CharField(max_length=255) + slug = models.SlugField(max_length=255, unique=True, blank=True) + description = models.TextField(blank=True) + contact_phone = models.CharField(max_length=32, blank=True) + contact_email = models.EmailField(blank=True) + address_line1 = models.CharField(max_length=255, blank=True) + address_line2 = models.CharField(max_length=255, blank=True) + city = models.CharField(max_length=100, blank=True) + state = models.CharField(max_length=100, blank=True) + postal_code = models.CharField(max_length=32, blank=True) + country = models.CharField(max_length=100, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.business_name) + super().save(*args, **kwargs) + + def __str__(self) -> str: + return self.business_name + + +class CustomerProfile(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="customer_profile") + preferred_contact_method = models.CharField(max_length=32, blank=True) + emergency_contact_name = models.CharField(max_length=255, blank=True) + emergency_contact_phone = models.CharField(max_length=32, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self) -> str: + return f"Customer profile: {self.user.email}" diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..95e8a39 --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,91 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from .models import VendorProfile + +User = get_user_model() + + +class VendorProfileSerializer(serializers.ModelSerializer): + class Meta: + model = VendorProfile + fields = ( + "business_name", + "slug", + "description", + "contact_phone", + "contact_email", + "address_line1", + "address_line2", + "city", + "state", + "postal_code", + "country", + "created_at", + "updated_at", + ) + read_only_fields = ("slug", "created_at", "updated_at") + + +class VendorRegistrationSerializer(serializers.Serializer): + email = serializers.EmailField() + password = serializers.CharField(write_only=True, min_length=8) + first_name = serializers.CharField(required=False, allow_blank=True, max_length=150) + last_name = serializers.CharField(required=False, allow_blank=True, max_length=150) + phone_number = serializers.CharField(required=False, allow_blank=True, max_length=32) + + business_name = serializers.CharField(max_length=255) + description = serializers.CharField(required=False, allow_blank=True) + contact_phone = serializers.CharField(required=False, allow_blank=True, max_length=32) + contact_email = serializers.EmailField(required=False, allow_blank=True) + address_line1 = serializers.CharField(required=False, allow_blank=True, max_length=255) + address_line2 = serializers.CharField(required=False, allow_blank=True, max_length=255) + city = serializers.CharField(required=False, allow_blank=True, max_length=100) + state = serializers.CharField(required=False, allow_blank=True, max_length=100) + postal_code = serializers.CharField(required=False, allow_blank=True, max_length=32) + country = serializers.CharField(required=False, allow_blank=True, max_length=100) + + def validate_email(self, value): + if User.objects.filter(email__iexact=value).exists(): + raise serializers.ValidationError("A user with this email already exists.") + return value + + def create(self, validated_data): + profile_data = { + "business_name": validated_data.pop("business_name"), + "description": validated_data.pop("description", ""), + "contact_phone": validated_data.pop("contact_phone", ""), + "contact_email": validated_data.pop("contact_email", ""), + "address_line1": validated_data.pop("address_line1", ""), + "address_line2": validated_data.pop("address_line2", ""), + "city": validated_data.pop("city", ""), + "state": validated_data.pop("state", ""), + "postal_code": validated_data.pop("postal_code", ""), + "country": validated_data.pop("country", ""), + } + password = validated_data.pop("password") + user = User.objects.create_user( + password=password, + is_vendor=True, + is_customer=False, + **validated_data, + ) + VendorProfile.objects.create(user=user, **profile_data) + return user + + +class UserMeSerializer(serializers.ModelSerializer): + vendor_profile = VendorProfileSerializer(read_only=True) + + class Meta: + model = User + fields = ( + "id", + "email", + "first_name", + "last_name", + "phone_number", + "is_vendor", + "is_customer", + "vendor_profile", + ) diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..6b29d0f --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,65 @@ +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from .models import VendorProfile + +User = get_user_model() + + +class AccountModelTests(APITestCase): + def test_custom_user_creation_and_roles(self): + user = User.objects.create_user( + email="customer@example.com", + password="Pass123456!", + is_customer=True, + is_vendor=False, + ) + self.assertEqual(user.email, "customer@example.com") + self.assertTrue(user.check_password("Pass123456!")) + self.assertTrue(user.is_customer) + self.assertFalse(user.is_vendor) + + +class AccountApiTests(APITestCase): + def test_vendor_registration_and_jwt_flow(self): + register_payload = { + "email": "vendor@example.com", + "password": "Pass123456!", + "business_name": "Ocean Rentals", + } + register_res = self.client.post(reverse("register_vendor"), register_payload, format="json") + self.assertEqual(register_res.status_code, status.HTTP_201_CREATED) + self.assertTrue(User.objects.filter(email="vendor@example.com", is_vendor=True).exists()) + self.assertTrue(VendorProfile.objects.filter(user__email="vendor@example.com").exists()) + + token_res = self.client.post( + reverse("token_obtain_pair"), + {"email": "vendor@example.com", "password": "Pass123456!"}, + format="json", + ) + self.assertEqual(token_res.status_code, status.HTTP_200_OK) + self.assertIn("access", token_res.data) + self.assertIn("refresh", token_res.data) + + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token_res.data['access']}") + me_res = self.client.get(reverse("me")) + self.assertEqual(me_res.status_code, status.HTTP_200_OK) + self.assertEqual(me_res.data["email"], "vendor@example.com") + + def test_vendor_profile_endpoint_forbidden_for_customer(self): + customer = User.objects.create_user( + email="customer@example.com", + password="Pass123456!", + is_customer=True, + is_vendor=False, + ) + token_res = self.client.post( + reverse("token_obtain_pair"), + {"email": customer.email, "password": "Pass123456!"}, + format="json", + ) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token_res.data['access']}") + res = self.client.get(reverse("vendor_profile_me")) + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..e4a1691 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView + +from .views import MeView, VendorProfileMeView, VendorRegistrationView + +urlpatterns = [ + path("register/vendor/", VendorRegistrationView.as_view(), name="register_vendor"), + path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("me/", MeView.as_view(), name="me"), + path("vendor-profile/me/", VendorProfileMeView.as_view(), name="vendor_profile_me"), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..3d2c513 --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,36 @@ +from rest_framework import generics, permissions +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import VendorProfile +from .serializers import UserMeSerializer, VendorProfileSerializer, VendorRegistrationSerializer + + +class VendorRegistrationView(generics.CreateAPIView): + serializer_class = VendorRegistrationSerializer + permission_classes = (permissions.AllowAny,) + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.save() + return Response(UserMeSerializer(user).data, status=201) + + +class MeView(APIView): + permission_classes = (permissions.IsAuthenticated,) + + def get(self, request): + return Response(UserMeSerializer(request.user).data) + + +class VendorProfileMeView(generics.RetrieveUpdateAPIView): + serializer_class = VendorProfileSerializer + permission_classes = (permissions.IsAuthenticated,) + + def get_object(self): + user = self.request.user + if not user.is_vendor: + raise PermissionDenied("Only vendors can access vendor profile setup.") + return VendorProfile.objects.get(user=user) diff --git a/adventrues/__init__.py b/adventrues/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/adventrues/__init__.py @@ -0,0 +1 @@ + diff --git a/adventrues/admin.py b/adventrues/admin.py new file mode 100644 index 0000000..dd87cde --- /dev/null +++ b/adventrues/admin.py @@ -0,0 +1,32 @@ +from django.contrib import admin + +from .models import AdventureCategory, AdventureImage, AdventureOffering + + +class AdventureImageInline(admin.TabularInline): + model = AdventureImage + extra = 0 + + +@admin.register(AdventureCategory) +class AdventureCategoryAdmin(admin.ModelAdmin): + list_display = ("name", "slug") + search_fields = ("name", "slug") + + +@admin.register(AdventureOffering) +class AdventureOfferingAdmin(admin.ModelAdmin): + list_display = ( + "title", + "public_id", + "vendor", + "category", + "duration_minutes", + "capacity", + "price_per_person", + "is_active", + ) + list_filter = ("is_active", "category", "vendor") + search_fields = ("title", "public_id", "vendor__business_name") + readonly_fields = ("created_at", "updated_at") + inlines = [AdventureImageInline] diff --git a/adventrues/apps.py b/adventrues/apps.py new file mode 100644 index 0000000..7d6afef --- /dev/null +++ b/adventrues/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AdventruesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "adventrues" diff --git a/adventrues/migrations/0001_initial.py b/adventrues/migrations/0001_initial.py new file mode 100644 index 0000000..a388783 --- /dev/null +++ b/adventrues/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 6.0.4 on 2026-04-08 16:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='AdventureCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=120, unique=True)), + ('description', models.TextField(blank=True)), + ], + ), + migrations.CreateModel( + name='AdventureOffering', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('public_id', models.CharField(max_length=64, unique=True)), + ('description', models.TextField(blank=True)), + ('meeting_point', models.CharField(blank=True, max_length=255)), + ('duration_minutes', models.PositiveIntegerField()), + ('capacity', models.PositiveIntegerField(default=1)), + ('price_per_person', models.DecimalField(decimal_places=2, max_digits=10)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='offerings', to='adventrues.adventurecategory')), + ('vendor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='adventure_offerings', to='accounts.vendorprofile')), + ], + options={ + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='AdventureImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.ImageField(upload_to='adventure_images/')), + ('alt_text', models.CharField(blank=True, max_length=255)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('is_primary', models.BooleanField(default=False)), + ('offering', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='adventrues.adventureoffering')), + ], + options={ + 'ordering': ('sort_order', 'id'), + }, + ), + ] diff --git a/adventrues/migrations/__init__.py b/adventrues/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/adventrues/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/adventrues/models.py b/adventrues/models.py new file mode 100644 index 0000000..7cfe668 --- /dev/null +++ b/adventrues/models.py @@ -0,0 +1,45 @@ +from django.db import models + + +class AdventureCategory(models.Model): + name = models.CharField(max_length=100, unique=True) + slug = models.SlugField(max_length=120, unique=True) + description = models.TextField(blank=True) + + def __str__(self) -> str: + return self.name + + +class AdventureOffering(models.Model): + vendor = models.ForeignKey("accounts.VendorProfile", on_delete=models.CASCADE, related_name="adventure_offerings") + category = models.ForeignKey(AdventureCategory, on_delete=models.PROTECT, related_name="offerings") + title = models.CharField(max_length=255) + public_id = models.CharField(max_length=64, unique=True) + description = models.TextField(blank=True) + meeting_point = models.CharField(max_length=255, blank=True) + duration_minutes = models.PositiveIntegerField() + capacity = models.PositiveIntegerField(default=1) + price_per_person = models.DecimalField(max_digits=10, decimal_places=2) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("-created_at",) + + def __str__(self) -> str: + return f"{self.title} ({self.public_id})" + + +class AdventureImage(models.Model): + offering = models.ForeignKey(AdventureOffering, on_delete=models.CASCADE, related_name="images") + image = models.ImageField(upload_to="adventure_images/") + alt_text = models.CharField(max_length=255, blank=True) + sort_order = models.PositiveIntegerField(default=0) + is_primary = models.BooleanField(default=False) + + class Meta: + ordering = ("sort_order", "id") + + def __str__(self) -> str: + return f"Image for {self.offering.public_id}" diff --git a/adventrues/serializers.py b/adventrues/serializers.py new file mode 100644 index 0000000..07c6caa --- /dev/null +++ b/adventrues/serializers.py @@ -0,0 +1,59 @@ +from rest_framework import serializers + +from .models import AdventureCategory, AdventureImage, AdventureOffering + + +class AdventureCategorySerializer(serializers.ModelSerializer): + class Meta: + model = AdventureCategory + fields = ("id", "name", "slug", "description") + + +class AdventureImageSerializer(serializers.ModelSerializer): + image_url = serializers.SerializerMethodField() + + class Meta: + model = AdventureImage + fields = ("id", "image_url", "alt_text", "sort_order", "is_primary") + + def get_image_url(self, obj): + if not obj.image: + return "" + request = self.context.get("request") + if request: + return request.build_absolute_uri(obj.image.url) + return obj.image.url + + +class AdventureOfferingSerializer(serializers.ModelSerializer): + category = AdventureCategorySerializer(read_only=True) + category_id = serializers.PrimaryKeyRelatedField( + queryset=AdventureCategory.objects.all(), + source="category", + write_only=True, + ) + vendor_slug = serializers.CharField(source="vendor.slug", read_only=True) + vendor_business_name = serializers.CharField(source="vendor.business_name", read_only=True) + images = AdventureImageSerializer(many=True, read_only=True) + + class Meta: + model = AdventureOffering + fields = ( + "id", + "public_id", + "title", + "description", + "meeting_point", + "duration_minutes", + "capacity", + "price_per_person", + "is_active", + "category", + "category_id", + "vendor_slug", + "vendor_business_name", + "images", + "created_at", + "updated_at", + ) + read_only_fields = ("created_at", "updated_at") diff --git a/adventrues/tests.py b/adventrues/tests.py new file mode 100644 index 0000000..14d4632 --- /dev/null +++ b/adventrues/tests.py @@ -0,0 +1,104 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APITestCase + +from accounts.models import VendorProfile +from adventrues.models import AdventureCategory, AdventureOffering +from booking.models import Booking + +User = get_user_model() + + +class AdventureApiTests(APITestCase): + def setUp(self): + self.vendor_user = User.objects.create_user( + email="vendor2@example.com", + password="Pass123456!", + is_vendor=True, + is_customer=False, + ) + self.vendor_profile = VendorProfile.objects.create(user=self.vendor_user, business_name="Kayak Co") + self.customer_user = User.objects.create_user( + email="customer2@example.com", + password="Pass123456!", + is_vendor=False, + is_customer=True, + ) + self.category = AdventureCategory.objects.create(name="Kayak", slug="kayak") + self.offering = AdventureOffering.objects.create( + vendor=self.vendor_profile, + category=self.category, + title="Sunset Kayak", + public_id="kayak-001", + meeting_point="Biscayne Bay", + duration_minutes=120, + capacity=8, + price_per_person="75.00", + is_active=True, + ) + + def test_public_adventure_list_detail_and_filter(self): + list_res = self.client.get("/api/v1/adventrues/offerings/") + self.assertEqual(list_res.status_code, status.HTTP_200_OK) + self.assertEqual(len(list_res.data), 1) + + filter_res = self.client.get("/api/v1/adventrues/offerings/?category=kayak&location=biscayne") + self.assertEqual(filter_res.status_code, status.HTTP_200_OK) + self.assertEqual(len(filter_res.data), 1) + + detail_res = self.client.get("/api/v1/adventrues/offerings/kayak-001/") + self.assertEqual(detail_res.status_code, status.HTTP_200_OK) + self.assertEqual(detail_res.data["public_id"], "kayak-001") + + def test_vendor_adventure_crud_access_control(self): + self.client.force_authenticate(self.customer_user) + forbidden = self.client.post( + "/api/v1/adventrues/vendor/offerings/", + { + "public_id": "cust-adv", + "title": "No Access", + "duration_minutes": 90, + "capacity": 3, + "price_per_person": "20.00", + "category_id": self.category.id, + }, + format="json", + ) + self.assertEqual(forbidden.status_code, status.HTTP_403_FORBIDDEN) + + self.client.force_authenticate(self.vendor_user) + created = self.client.post( + "/api/v1/adventrues/vendor/offerings/", + { + "public_id": "vendor-adv", + "title": "Vendor Adventure", + "duration_minutes": 90, + "capacity": 5, + "price_per_person": "55.00", + "category_id": self.category.id, + "meeting_point": "Harbor", + }, + format="json", + ) + self.assertEqual(created.status_code, status.HTTP_201_CREATED) + + def test_public_adventure_list_excludes_unavailable_range(self): + starts = timezone.now() + timedelta(days=4) + ends = starts + timedelta(hours=2) + Booking.objects.create( + customer=self.customer_user, + vendor=self.vendor_profile, + adventure_offering=self.offering, + starts_at=starts, + ends_at=ends, + status=Booking.Status.APPROVED, + total_price="150.00", + ) + res = self.client.get( + f"/api/v1/adventrues/offerings/?available_from={starts.isoformat()}&available_to={ends.isoformat()}" + ) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(len(res.data), 0) diff --git a/adventrues/urls.py b/adventrues/urls.py new file mode 100644 index 0000000..27c3623 --- /dev/null +++ b/adventrues/urls.py @@ -0,0 +1,19 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import ( + PublicAdventureDetailView, + PublicAdventureListView, + VendorAdventureStorefrontView, + VendorAdventureViewSet, +) + +router = DefaultRouter() +router.register("vendor/offerings", VendorAdventureViewSet, basename="vendor-adventure-offering") + +urlpatterns = [ + path("", include(router.urls)), + path("offerings/", PublicAdventureListView.as_view(), name="adventure_public_list"), + path("offerings//", PublicAdventureDetailView.as_view(), name="adventure_public_detail"), + path("storefront//", VendorAdventureStorefrontView.as_view(), name="adventure_vendor_storefront"), +] diff --git a/adventrues/views.py b/adventrues/views.py new file mode 100644 index 0000000..d915f15 --- /dev/null +++ b/adventrues/views.py @@ -0,0 +1,117 @@ +from django.utils.dateparse import parse_datetime +from rest_framework import generics, permissions, viewsets +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rest_framework.views import APIView + +from booking.models import Booking +from marketing.mixins import AdventureListingClickTrackingMixin + +from .models import AdventureOffering +from .serializers import AdventureOfferingSerializer + + +class PublicAdventureListView(generics.ListAPIView): + serializer_class = AdventureOfferingSerializer + permission_classes = (permissions.AllowAny,) + + def get_queryset(self): + queryset = AdventureOffering.objects.filter(is_active=True).select_related("category", "vendor").prefetch_related("images") + params = self.request.query_params + + category = params.get("category") + if category: + if category.isdigit(): + queryset = queryset.filter(category_id=category) + else: + queryset = queryset.filter(category__slug=category) + + location = params.get("location") + if location: + queryset = queryset.filter(meeting_point__icontains=location) + + vendor_slug = params.get("vendor_slug") + if vendor_slug: + queryset = queryset.filter(vendor__slug=vendor_slug) + + min_price = params.get("min_price") + if min_price: + queryset = queryset.filter(price_per_person__gte=min_price) + + max_price = params.get("max_price") + if max_price: + queryset = queryset.filter(price_per_person__lte=max_price) + + start = parse_datetime(params.get("available_from", "")) if params.get("available_from") else None + end = parse_datetime(params.get("available_to", "")) if params.get("available_to") else None + if start and end: + blocked_offering_ids = ( + Booking.objects.filter( + adventure_offering__isnull=False, + status__in=[Booking.Status.REQUESTED, Booking.Status.APPROVED, Booking.Status.CONFIRMED], + ) + .filter(starts_at__lt=end, ends_at__gt=start) + .values_list("adventure_offering_id", flat=True) + ) + queryset = queryset.exclude(id__in=blocked_offering_ids) + + return queryset + + +class PublicAdventureDetailView(AdventureListingClickTrackingMixin, generics.RetrieveAPIView): + serializer_class = AdventureOfferingSerializer + permission_classes = (permissions.AllowAny,) + lookup_field = "public_id" + + def get_queryset(self): + return AdventureOffering.objects.filter(is_active=True).select_related("category", "vendor").prefetch_related("images") + + +class VendorAdventureViewSet(viewsets.ModelViewSet): + serializer_class = AdventureOfferingSerializer + permission_classes = (permissions.IsAuthenticated,) + + def get_queryset(self): + user = self.request.user + if not user.is_vendor or not hasattr(user, "vendor_profile"): + return AdventureOffering.objects.none() + return ( + AdventureOffering.objects.filter(vendor=user.vendor_profile) + .select_related("category", "vendor") + .prefetch_related("images") + ) + + def perform_create(self, serializer): + user = self.request.user + if not user.is_vendor or not hasattr(user, "vendor_profile"): + raise PermissionDenied("Only vendors can create adventure offerings.") + serializer.save(vendor=user.vendor_profile) + + +class VendorAdventureStorefrontView(APIView): + permission_classes = (permissions.AllowAny,) + + def get(self, request, slug): + offerings = ( + AdventureOffering.objects.filter(vendor__slug=slug, is_active=True) + .select_related("category", "vendor") + .prefetch_related("images") + ) + if not offerings.exists(): + return Response({"detail": "Vendor adventure storefront not found."}, status=404) + + vendor = offerings.first().vendor + return Response( + { + "vendor": { + "business_name": vendor.business_name, + "slug": vendor.slug, + "description": vendor.description, + "contact_email": vendor.contact_email, + "contact_phone": vendor.contact_phone, + "city": vendor.city, + "country": vendor.country, + }, + "offerings": AdventureOfferingSerializer(offerings, many=True, context={"request": request}).data, + } + ) diff --git a/api.md b/api.md new file mode 100644 index 0000000..d3f7748 --- /dev/null +++ b/api.md @@ -0,0 +1,451 @@ +# WaterTrek API (Frontend Handoff) + +Base URL: `/api/v1/` +Auth: JWT Bearer token in `Authorization: Bearer ` + +## Auth + Accounts + +### Register Vendor +- `POST /api/v1/accounts/register/vendor/` +- Public endpoint + +Request: +```json +{ + "email": "vendor@example.com", + "password": "StrongPassword123", + "first_name": "Nora", + "last_name": "Smith", + "phone_number": "+1-555-1234", + "business_name": "Nora Rentals", + "description": "Jet ski and boat rentals", + "contact_phone": "+1-555-1234", + "contact_email": "hello@norarentals.com", + "address_line1": "1 Harbor Way", + "city": "Miami", + "state": "FL", + "postal_code": "33101", + "country": "USA" +} +``` + +Response `201`: +```json +{ + "id": 12, + "email": "vendor@example.com", + "first_name": "Nora", + "last_name": "Smith", + "phone_number": "+1-555-1234", + "is_vendor": true, + "is_customer": false, + "vendor_profile": { + "business_name": "Nora Rentals", + "slug": "nora-rentals", + "description": "Jet ski and boat rentals", + "contact_phone": "+1-555-1234", + "contact_email": "hello@norarentals.com", + "address_line1": "1 Harbor Way", + "address_line2": "", + "city": "Miami", + "state": "FL", + "postal_code": "33101", + "country": "USA", + "created_at": "2026-04-08T17:00:00Z", + "updated_at": "2026-04-08T17:00:00Z" + } +} +``` + +### Login (JWT) +- `POST /api/v1/accounts/token/` + +Request: +```json +{ + "email": "vendor@example.com", + "password": "StrongPassword123" +} +``` + +Response `200`: +```json +{ + "refresh": "", + "access": "" +} +``` + +### Refresh JWT +- `POST /api/v1/accounts/token/refresh/` + +Request: +```json +{ + "refresh": "" +} +``` + +### Current User +- `GET /api/v1/accounts/me/` +- Requires JWT + +### Vendor Profile Setup / Update +- `GET /api/v1/accounts/vendor-profile/me/` +- `PATCH /api/v1/accounts/vendor-profile/me/` +- Requires vendor JWT + +Patch example: +```json +{ + "business_name": "Nora Rentals LLC", + "description": "Premium water equipment rentals", + "city": "Fort Lauderdale" +} +``` + +## Equipment APIs + +### Public Equipment List +- `GET /api/v1/equipment/items/` +- Public endpoint +- Query params: + - `category` (category id or slug) + - `location` (icontains match) + - `vendor_slug` + - `min_price` + - `max_price` + - `available_from` (ISO datetime) + - `available_to` (ISO datetime) + +Example: +`GET /api/v1/equipment/items/?location=miami&min_price=100&max_price=300` + +### Public Equipment Detail +- `GET /api/v1/equipment/items/{public_id}/` +- Public endpoint +- **Marketing / click-through:** Each successful detail response appends: + - `marketing_click_id` (integer): pass this on `POST /api/v1/booking/bookings/request/` as `marketing_click_id` so bookings can be attributed to the visit (organic or campaign). + - `click_traffic_type`: `"organic"` or `"marketing"` (derived from query params below). +- Optional **query params** (standard UTM + common ad IDs) are read and stored on the click row for vendor reporting: + - `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` + - `gclid`, `fbclid` +- If **any** of those params is non-empty, `click_traffic_type` is `marketing`; otherwise `organic`. + +Example: +`GET /api/v1/equipment/items/jetski-001/?utm_source=google&utm_medium=cpc&utm_campaign=spring_sale` + +### Vendor Inventory CRUD +- `GET /api/v1/equipment/vendor/items/` +- `POST /api/v1/equipment/vendor/items/` +- `GET /api/v1/equipment/vendor/items/{id}/` +- `PUT /api/v1/equipment/vendor/items/{id}/` +- `PATCH /api/v1/equipment/vendor/items/{id}/` +- `DELETE /api/v1/equipment/vendor/items/{id}/` +- Requires vendor JWT + +Create payload: +```json +{ + "public_id": "jetski-001", + "title": "Yamaha Jet Ski", + "description": "Fast and stable for 2 riders", + "details": { + "engine_cc": 998, + "seats": 2 + }, + "location": "Miami Beach", + "price_per_day": "180.00", + "is_active": true, + "category_id": 1 +} +``` + +### Vendor Storefront by Slug +- `GET /api/v1/equipment/storefront/{slug}/` +- Public endpoint +- Returns vendor profile + active inventory list + +## Adventrues APIs + +### Public Adventure List +- `GET /api/v1/adventrues/offerings/` +- Public endpoint +- Query params: + - `category` (category id or slug) + - `location` (matches `meeting_point`) + - `vendor_slug` + - `min_price` + - `max_price` + - `available_from` (ISO datetime) + - `available_to` (ISO datetime) + +### Public Adventure Detail +- `GET /api/v1/adventrues/offerings/{public_id}/` +- Public endpoint +- Same **marketing / click-through** behavior as equipment detail: response includes `marketing_click_id` and `click_traffic_type`; UTM / `gclid` / `fbclid` query params are captured when present. + +### Vendor Adventure CRUD +- `GET /api/v1/adventrues/vendor/offerings/` +- `POST /api/v1/adventrues/vendor/offerings/` +- `GET /api/v1/adventrues/vendor/offerings/{id}/` +- `PUT /api/v1/adventrues/vendor/offerings/{id}/` +- `PATCH /api/v1/adventrues/vendor/offerings/{id}/` +- `DELETE /api/v1/adventrues/vendor/offerings/{id}/` +- Requires vendor JWT + +Create payload: +```json +{ + "public_id": "sunset-kayak-001", + "title": "Sunset Kayak Tour", + "description": "Guided paddle with sunset views", + "meeting_point": "Biscayne Bay Marina", + "duration_minutes": 120, + "capacity": 10, + "price_per_person": "75.00", + "is_active": true, + "category_id": 1 +} +``` + +### Vendor Adventure Storefront by Slug +- `GET /api/v1/adventrues/storefront/{slug}/` +- Public endpoint +- Returns vendor profile + active adventure offerings + +## Booking APIs + +### Availability Check +- `GET /api/v1/booking/availability/` +- Public endpoint +- Required query params: + - exactly one of `equipment_item_id` or `adventure_offering_id` + - `starts_at` (ISO datetime) + - `ends_at` (ISO datetime) +- Rule notes: + - If availability slots exist for the target, booking requires at least one `is_available=true` slot fully covering the requested range. + - Any overlapping `is_available=false` slot blocks the request. + - If no slots exist yet, target is treated as bookable by default (subject to overlap checks). + +Example: +`GET /api/v1/booking/availability/?equipment_item_id=5&starts_at=2026-04-12T10:00:00Z&ends_at=2026-04-13T10:00:00Z` + +Response: +```json +{ + "equipment_item_id": 5, + "adventure_offering_id": null, + "starts_at": "2026-04-12T10:00:00Z", + "ends_at": "2026-04-13T10:00:00Z", + "is_available": true, + "conflicts": 0 +} +``` + +### Create Booking Request (Customer) +- `POST /api/v1/booking/bookings/request/` +- Requires customer JWT + +Request: +```json +{ + "equipment_item_id": 5, + "starts_at": "2026-04-12T10:00:00Z", + "ends_at": "2026-04-14T10:00:00Z", + "customer_notes": "Need two life jackets", + "marketing_click_id": 9042 +} +``` + +- Optional `marketing_click_id`: integer from the latest `GET` detail response (`marketing_click_id`) for **the same** equipment item or adventure offering. Must be within **90 days** of the click. Omit for unattributed bookings (still allowed). + +Response includes booking with status `requested` and `listing_click` (nullable FK id) when attribution was applied. + +Adventure booking request example: +```json +{ + "adventure_offering_id": 9, + "starts_at": "2026-05-10T15:00:00Z", + "ends_at": "2026-05-10T17:00:00Z", + "participants_count": 3, + "customer_notes": "First-time kayakers" +} +``` + +### List My Bookings +- `GET /api/v1/booking/bookings/` +- Requires JWT +- If caller is vendor: returns bookings for their vendor profile +- Else: returns bookings where caller is customer + +### Booking Detail +- `GET /api/v1/booking/bookings/{id}/` +- Requires JWT with ownership (customer or vendor on that booking) + +### Vendor Approve Booking +- `POST /api/v1/booking/bookings/{id}/approve/` +- Requires vendor JWT (own booking only) + +Payload (optional): +```json +{ + "vendor_notes": "Approved, pickup after 9AM", + "note": "Ready for pickup window" +} +``` + +### Vendor Decline Booking +- `POST /api/v1/booking/bookings/{id}/decline/` +- Requires vendor JWT (own booking only) + +### Customer Cancel Booking +- `POST /api/v1/booking/bookings/{id}/cancel/` +- Requires customer JWT (own booking only) + +Payload (optional): +```json +{ + "note": "Trip postponed to next month" +} +``` + +## Payment APIs (Mock Stripe) + +### Create Payment Intent (Mock) +- `POST /api/v1/payment/intents/` +- Requires customer JWT (must own booking) +- Booking must be in `approved` state + +Request: +```json +{ + "booking_id": 42, + "currency": "usd" +} +``` + +Response `201`: +```json +{ + "payment": { + "id": 10, + "booking_id": 42, + "stripe_payment_intent_id": "pi_mock_123abc...", + "stripe_charge_id": "", + "amount": "360.00", + "currency": "usd", + "status": "requires_payment", + "created_at": "2026-04-08T18:00:00Z", + "updated_at": "2026-04-08T18:00:00Z" + }, + "client_secret": "pi_secret_mock_123abc...", + "mocked": true +} +``` + +### Payment Status +- `GET /api/v1/payment/{payment_id}/status/` +- Requires JWT (booking customer or booking vendor owner) + +### Stripe Webhook (Mocked) +- `POST /api/v1/payment/webhooks/stripe/` +- Public endpoint for local/mock integration +- Idempotent by `stripe_event_id` + +Request: +```json +{ + "stripe_event_id": "evt_mock_001", + "event_type": "payment_intent.succeeded", + "stripe_payment_intent_id": "pi_mock_123abc...", + "payload": { + "source": "frontend-test" + } +} +``` + +Supported `event_type` values: +- `payment_intent.processing` +- `payment_intent.succeeded` +- `payment_intent.payment_failed` +- `charge.refunded` + +Behavior: +- Updates `PaymentRecord.status` +- On `payment_intent.succeeded`, booking auto-transitions `approved -> confirmed` +- Stores webhook in `WebhookEvent` and enforces idempotency + +## Marketing APIs (UTM, click-through, vendor analytics) + +Base path: `/api/v1/marketing/` + +### Track listing click (explicit) +- `POST /api/v1/marketing/track/click/` +- Public (optional JWT if the user is logged in; stored on the click when present) +- Use when the SPA opens a listing **without** calling the public detail endpoint (e.g. client-side route from cache). Otherwise prefer relying on `GET` equipment/adventure detail, which records the click automatically. + +Request: +```json +{ + "listing_type": "equipment", + "public_id": "jetski-001", + "utm_source": "newsletter", + "utm_medium": "email", + "utm_campaign": "april_promo", + "utm_term": "", + "utm_content": "hero", + "gclid": "", + "fbclid": "", + "referrer": "https://mail.example.com/" +} +``` + +- `listing_type`: `"equipment"` or `"adventure"` (must match `public_id`). + +Response `201`: +```json +{ + "marketing_click_id": 9042, + "traffic_type": "marketing" +} +``` + +### Vendor: list listing clicks +- `GET /api/v1/marketing/vendor/clicks/` +- Requires vendor JWT +- **Required query params:** `from`, `to` — ISO-8601 datetimes (UTC), half-open range `[from, to)`. +- Optional filters: `traffic_type` (`organic` | `marketing`), `listing_type` (`equipment` | `adventure`), `utm_campaign` (exact match). + +### Vendor: marketing summary (conversions) +- `GET /api/v1/marketing/vendor/summary/` +- Requires vendor JWT +- **Required query params:** `from`, `to` — same as above. + +Returns (shape): +- `clicks.organic` / `clicks.marketing` / `clicks.total` — listing detail or track-click events in range. +- `bookings_attributed.organic` — bookings in range whose `listing_click.traffic_type` is `organic`. +- `bookings_attributed.marketing` — bookings in range whose `listing_click.traffic_type` is `marketing`. +- `bookings_attributed.unattributed` — bookings in range with no `listing_click`. +- `conversion_rate_click_to_booking.organic` — `organic` bookings ÷ `organic` clicks (omitted / `null` if clicks = 0). +- `conversion_rate_click_to_booking.marketing` — same for marketing. +- `campaigns` — up to 50 rows with non-empty `utm_campaign` on clicks: `utm_source`, `utm_medium`, `utm_campaign`, `clicks`, `bookings` (bookings that referenced a click with that triple), `conversion_rate`. + +**Interpreting organic vs marketing:** Traffic is classified **per click** from UTM / `gclid` / `fbclid`. To compare conversion rates fairly, the client should send `marketing_click_id` on checkout for both organic and paid visits when possible (so bookings are not left in `unattributed`). + +## Notes for React Integration + +- All datetimes are ISO-8601 and UTC. +- Booking overlap protection is implemented for `requested`, `approved`, and `confirmed` windows. +- `requested` works as a soft hold and blocks overlapping requests until resolved. +- `total_price` is computed server-side: + - equipment: rounded-up day count x `price_per_day` + - adventure: `participants_count` x `price_per_person` +- Payment APIs are currently mocked (Stripe-style IDs and webhook event flow) to unblock frontend integration before live Stripe keys are wired. +- Use `public_id` for equipment-facing detail pages and numeric `id` for vendor CRUD. +- Booking domain services are implemented server-side for rules and transitions: + - `is_bookable(...)` + - `quote_booking(...)` + - `create_booking_request(...)` + - `transition_booking_status(...)` +- Persist `marketing_click_id` from listing detail (or `POST .../marketing/track/click/`) and send it with `POST .../booking/bookings/request/` so vendors can tie bookings to campaigns and compare organic vs marketing conversion in `GET .../marketing/vendor/summary/`. diff --git a/booking/__init__.py b/booking/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/booking/__init__.py @@ -0,0 +1 @@ + diff --git a/booking/admin.py b/booking/admin.py new file mode 100644 index 0000000..d856001 --- /dev/null +++ b/booking/admin.py @@ -0,0 +1,79 @@ +from django.contrib import admin +from django.core.exceptions import ValidationError + +from .models import AvailabilitySlot, Booking, BookingEventLog +from .services import transition_booking_status + + +@admin.action(description="Mark selected bookings as approved") +def mark_approved(modeladmin, request, queryset): + for booking in queryset: + if booking.status == Booking.Status.REQUESTED: + try: + transition_booking_status( + booking=booking, + to_status=Booking.Status.APPROVED, + actor=request.user, + note="Booking approved via admin action.", + vendor_notes=booking.vendor_notes, + ) + except ValidationError: + continue + + +@admin.action(description="Mark selected bookings as declined") +def mark_declined(modeladmin, request, queryset): + for booking in queryset: + if booking.status == Booking.Status.REQUESTED: + try: + transition_booking_status( + booking=booking, + to_status=Booking.Status.DECLINED, + actor=request.user, + note="Booking declined via admin action.", + vendor_notes=booking.vendor_notes, + ) + except ValidationError: + continue + + +@admin.action(description="Mark selected bookings as confirmed") +def mark_confirmed(modeladmin, request, queryset): + for booking in queryset: + if booking.status == Booking.Status.APPROVED: + try: + transition_booking_status( + booking=booking, + to_status=Booking.Status.CONFIRMED, + actor=request.user, + note="Booking confirmed via admin action.", + vendor_notes=booking.vendor_notes, + ) + except ValidationError: + continue + + +@admin.register(AvailabilitySlot) +class AvailabilitySlotAdmin(admin.ModelAdmin): + list_display = ("id", "equipment_item", "adventure_offering", "starts_at", "ends_at", "is_available") + list_filter = ("is_available",) + search_fields = ("equipment_item__public_id", "adventure_offering__public_id") + readonly_fields = ("created_at", "updated_at") + + +@admin.register(Booking) +class BookingAdmin(admin.ModelAdmin): + list_display = ("id", "status", "vendor", "customer", "starts_at", "ends_at", "total_price", "created_at") + list_filter = ("status", "vendor") + search_fields = ("id", "customer__email", "vendor__business_name") + readonly_fields = ("created_at", "updated_at", "total_price") + actions = (mark_approved, mark_declined, mark_confirmed) + + +@admin.register(BookingEventLog) +class BookingEventLogAdmin(admin.ModelAdmin): + list_display = ("booking", "from_status", "to_status", "actor", "created_at") + list_filter = ("to_status",) + search_fields = ("booking__id", "actor__email") + readonly_fields = ("booking", "from_status", "to_status", "note", "actor", "created_at") + diff --git a/booking/apps.py b/booking/apps.py new file mode 100644 index 0000000..c354548 --- /dev/null +++ b/booking/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BookingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "booking" diff --git a/booking/migrations/0001_initial.py b/booking/migrations/0001_initial.py new file mode 100644 index 0000000..3f13fb9 --- /dev/null +++ b/booking/migrations/0001_initial.py @@ -0,0 +1,81 @@ +# Generated by Django 6.0.4 on 2026-04-08 16:53 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0001_initial'), + ('adventrues', '0001_initial'), + ('equipment', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Booking', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('starts_at', models.DateTimeField()), + ('ends_at', models.DateTimeField()), + ('status', models.CharField(choices=[('requested', 'Requested'), ('approved', 'Approved'), ('declined', 'Declined'), ('cancelled', 'Cancelled'), ('confirmed', 'Confirmed')], default='requested', max_length=16)), + ('total_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)), + ('customer_notes', models.TextField(blank=True)), + ('vendor_notes', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('adventure_offering', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bookings', to='adventrues.adventureoffering')), + ('customer', models.ForeignKey(limit_choices_to={'is_customer': True}, on_delete=django.db.models.deletion.CASCADE, related_name='bookings', to=settings.AUTH_USER_MODEL)), + ('equipment_item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bookings', to='equipment.equipmentitem')), + ('vendor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookings', to='accounts.vendorprofile')), + ], + options={ + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='BookingEventLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('from_status', models.CharField(blank=True, choices=[('requested', 'Requested'), ('approved', 'Approved'), ('declined', 'Declined'), ('cancelled', 'Cancelled'), ('confirmed', 'Confirmed')], max_length=16)), + ('to_status', models.CharField(choices=[('requested', 'Requested'), ('approved', 'Approved'), ('declined', 'Declined'), ('cancelled', 'Cancelled'), ('confirmed', 'Confirmed')], max_length=16)), + ('note', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('booking', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='booking.booking')), + ], + options={ + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='AvailabilitySlot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('starts_at', models.DateTimeField()), + ('ends_at', models.DateTimeField()), + ('is_available', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('adventure_offering', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='availability_slots', to='adventrues.adventureoffering')), + ('equipment_item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='availability_slots', to='equipment.equipmentitem')), + ], + options={ + 'ordering': ('starts_at',), + 'constraints': [models.CheckConstraint(condition=models.Q(models.Q(('adventure_offering__isnull', True), ('equipment_item__isnull', False)), models.Q(('adventure_offering__isnull', False), ('equipment_item__isnull', True)), _connector='OR'), name='availability_slot_exactly_one_target'), models.CheckConstraint(condition=models.Q(('ends_at__gt', models.F('starts_at'))), name='availability_slot_valid_range')], + }, + ), + migrations.AddConstraint( + model_name='booking', + constraint=models.CheckConstraint(condition=models.Q(models.Q(('adventure_offering__isnull', True), ('equipment_item__isnull', False)), models.Q(('adventure_offering__isnull', False), ('equipment_item__isnull', True)), _connector='OR'), name='booking_exactly_one_target'), + ), + migrations.AddConstraint( + model_name='booking', + constraint=models.CheckConstraint(condition=models.Q(('ends_at__gt', models.F('starts_at'))), name='booking_valid_range'), + ), + ] diff --git a/booking/migrations/0002_booking_listing_click.py b/booking/migrations/0002_booking_listing_click.py new file mode 100644 index 0000000..8590bef --- /dev/null +++ b/booking/migrations/0002_booking_listing_click.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.4 on 2026-04-11 01:49 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('booking', '0001_initial'), + ('marketing', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='booking', + name='listing_click', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bookings', to='marketing.listingclick'), + ), + ] diff --git a/booking/migrations/__init__.py b/booking/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/booking/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/booking/models.py b/booking/models.py new file mode 100644 index 0000000..8c937fb --- /dev/null +++ b/booking/models.py @@ -0,0 +1,104 @@ +from django.conf import settings +from django.db import models +from django.db.models import Q + + +class AvailabilitySlot(models.Model): + equipment_item = models.ForeignKey( + "equipment.EquipmentItem", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="availability_slots", + ) + adventure_offering = models.ForeignKey( + "adventrues.AdventureOffering", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="availability_slots", + ) + starts_at = models.DateTimeField() + ends_at = models.DateTimeField() + is_available = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=Q(equipment_item__isnull=False, adventure_offering__isnull=True) + | Q(equipment_item__isnull=True, adventure_offering__isnull=False), + name="availability_slot_exactly_one_target", + ), + models.CheckConstraint(condition=Q(ends_at__gt=models.F("starts_at")), name="availability_slot_valid_range"), + ] + ordering = ("starts_at",) + + def __str__(self) -> str: + target = self.equipment_item or self.adventure_offering + return f"{target} | {self.starts_at} - {self.ends_at}" + + +class Booking(models.Model): + class Status(models.TextChoices): + REQUESTED = "requested", "Requested" + APPROVED = "approved", "Approved" + DECLINED = "declined", "Declined" + CANCELLED = "cancelled", "Cancelled" + CONFIRMED = "confirmed", "Confirmed" + + customer = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="bookings", limit_choices_to={"is_customer": True} + ) + vendor = models.ForeignKey("accounts.VendorProfile", on_delete=models.CASCADE, related_name="bookings") + equipment_item = models.ForeignKey( + "equipment.EquipmentItem", on_delete=models.SET_NULL, null=True, blank=True, related_name="bookings" + ) + adventure_offering = models.ForeignKey( + "adventrues.AdventureOffering", on_delete=models.SET_NULL, null=True, blank=True, related_name="bookings" + ) + starts_at = models.DateTimeField() + ends_at = models.DateTimeField() + status = models.CharField(max_length=16, choices=Status.choices, default=Status.REQUESTED) + total_price = models.DecimalField(max_digits=10, decimal_places=2, default=0) + customer_notes = models.TextField(blank=True) + vendor_notes = models.TextField(blank=True) + listing_click = models.ForeignKey( + "marketing.ListingClick", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="bookings", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=Q(equipment_item__isnull=False, adventure_offering__isnull=True) + | Q(equipment_item__isnull=True, adventure_offering__isnull=False), + name="booking_exactly_one_target", + ), + models.CheckConstraint(condition=Q(ends_at__gt=models.F("starts_at")), name="booking_valid_range"), + ] + ordering = ("-created_at",) + + def __str__(self) -> str: + return f"Booking #{self.id} ({self.status})" + + +class BookingEventLog(models.Model): + booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name="events") + from_status = models.CharField(max_length=16, choices=Booking.Status.choices, blank=True) + to_status = models.CharField(max_length=16, choices=Booking.Status.choices) + note = models.TextField(blank=True) + actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ("-created_at",) + + def __str__(self) -> str: + return f"Booking {self.booking_id}: {self.from_status} -> {self.to_status}" diff --git a/booking/serializers.py b/booking/serializers.py new file mode 100644 index 0000000..2b220a2 --- /dev/null +++ b/booking/serializers.py @@ -0,0 +1,114 @@ +from rest_framework import serializers + +from adventrues.models import AdventureOffering +from equipment.models import EquipmentItem +from marketing.models import ListingClick +from marketing.services import listing_click_valid_for_booking + +from .models import Booking, BookingEventLog +from .services import create_booking_request, quote_booking + + +class BookingEventLogSerializer(serializers.ModelSerializer): + actor_email = serializers.EmailField(source="actor.email", read_only=True) + + class Meta: + model = BookingEventLog + fields = ("id", "from_status", "to_status", "note", "actor_email", "created_at") + + +class BookingSerializer(serializers.ModelSerializer): + customer_email = serializers.EmailField(source="customer.email", read_only=True) + vendor_slug = serializers.CharField(source="vendor.slug", read_only=True) + equipment_public_id = serializers.CharField(source="equipment_item.public_id", read_only=True) + adventure_public_id = serializers.CharField(source="adventure_offering.public_id", read_only=True) + events = BookingEventLogSerializer(many=True, read_only=True) + + class Meta: + model = Booking + fields = ( + "id", + "customer_email", + "vendor_slug", + "equipment_item", + "equipment_public_id", + "adventure_public_id", + "starts_at", + "ends_at", + "status", + "total_price", + "customer_notes", + "vendor_notes", + "events", + "listing_click", + "created_at", + "updated_at", + ) + read_only_fields = ("status", "total_price", "created_at", "updated_at", "events", "listing_click") + + +class BookingCreateSerializer(serializers.Serializer): + equipment_item_id = serializers.PrimaryKeyRelatedField( + queryset=EquipmentItem.objects.filter(is_active=True), + required=False, + allow_null=True, + ) + adventure_offering_id = serializers.PrimaryKeyRelatedField( + queryset=AdventureOffering.objects.filter(is_active=True), + required=False, + allow_null=True, + ) + starts_at = serializers.DateTimeField() + ends_at = serializers.DateTimeField() + customer_notes = serializers.CharField(required=False, allow_blank=True) + participants_count = serializers.IntegerField(required=False, min_value=1, default=1) + marketing_click_id = serializers.IntegerField(required=False, allow_null=True) + + def validate(self, attrs): + equipment_item = attrs.get("equipment_item_id") + adventure_offering = attrs.get("adventure_offering_id") + if bool(equipment_item) == bool(adventure_offering): + raise serializers.ValidationError("Provide exactly one target: equipment_item_id or adventure_offering_id.") + click_id = attrs.get("marketing_click_id") + listing_click = None + if click_id is not None: + listing_click = ListingClick.objects.filter(pk=click_id).first() + if not listing_click: + raise serializers.ValidationError({"marketing_click_id": "Invalid marketing_click_id."}) + if not listing_click_valid_for_booking( + click=listing_click, + equipment_item=equipment_item, + adventure_offering=adventure_offering, + ): + raise serializers.ValidationError( + {"marketing_click_id": "Click does not match this listing or is outside the attribution window."} + ) + attrs["_listing_click"] = listing_click + try: + quote_booking( + equipment_item=equipment_item, + adventure_offering=adventure_offering, + starts_at=attrs["starts_at"], + ends_at=attrs["ends_at"], + participants_count=attrs.get("participants_count", 1), + ) + except Exception as exc: + raise serializers.ValidationError(str(exc)) from exc + return attrs + + def create(self, validated_data): + try: + booking = create_booking_request( + customer=self.context["request"].user, + starts_at=validated_data["starts_at"], + ends_at=validated_data["ends_at"], + equipment_item=validated_data.get("equipment_item_id"), + adventure_offering=validated_data.get("adventure_offering_id"), + participants_count=validated_data.get("participants_count", 1), + customer_notes=validated_data.get("customer_notes", ""), + listing_click=validated_data.get("_listing_click"), + ) + except Exception as exc: + raise serializers.ValidationError({"non_field_errors": [str(exc)]}) from exc + + return booking diff --git a/booking/services.py b/booking/services.py new file mode 100644 index 0000000..ec1a998 --- /dev/null +++ b/booking/services.py @@ -0,0 +1,159 @@ +from decimal import Decimal +from datetime import timezone as dt_timezone + +from django.core.exceptions import ValidationError +from django.db.models import Q +from django.utils import timezone + +from .models import AvailabilitySlot, Booking, BookingEventLog + +ACTIVE_BOOKING_STATUSES = [Booking.Status.REQUESTED, Booking.Status.APPROVED, Booking.Status.CONFIRMED] + + +def _normalize_utc(dt): + if timezone.is_naive(dt): + raise ValidationError("Datetime values must include timezone information (UTC ISO-8601).") + return dt.astimezone(dt_timezone.utc) + + +def is_bookable(*, equipment_item=None, adventure_offering=None, starts_at, ends_at, exclude_booking_id=None): + starts_at = _normalize_utc(starts_at) + ends_at = _normalize_utc(ends_at) + if starts_at >= ends_at: + return False + + filters = Q(starts_at__lt=ends_at, ends_at__gt=starts_at, status__in=ACTIVE_BOOKING_STATUSES) + if equipment_item is not None: + filters &= Q(equipment_item=equipment_item) + elif adventure_offering is not None: + filters &= Q(adventure_offering=adventure_offering) + else: + return False + + queryset = Booking.objects.filter(filters) + if exclude_booking_id is not None: + queryset = queryset.exclude(id=exclude_booking_id) + has_overlap = queryset.exists() + if has_overlap: + return False + + slot_filters = Q(starts_at__lt=ends_at, ends_at__gt=starts_at) + if equipment_item is not None: + slot_filters &= Q(equipment_item=equipment_item) + else: + slot_filters &= Q(adventure_offering=adventure_offering) + + target_slots = AvailabilitySlot.objects.filter(slot_filters) + if not target_slots.exists(): + # If no slot schedule exists yet, keep backward-compatible "bookable by default" behavior. + return True + + has_blocking_unavailable = target_slots.filter(is_available=False).exists() + if has_blocking_unavailable: + return False + + has_covering_available_slot = target_slots.filter(is_available=True, starts_at__lte=starts_at, ends_at__gte=ends_at).exists() + return has_covering_available_slot + + +def quote_booking(*, equipment_item=None, adventure_offering=None, starts_at, ends_at, participants_count=1): + starts_at = _normalize_utc(starts_at) + ends_at = _normalize_utc(ends_at) + if ends_at <= starts_at: + raise ValidationError("ends_at must be greater than starts_at.") + if starts_at < timezone.now(): + raise ValidationError("starts_at must be in the future.") + + if equipment_item is not None and adventure_offering is not None: + raise ValidationError("Only one target can be quoted at a time.") + if equipment_item is None and adventure_offering is None: + raise ValidationError("A booking target is required.") + + if equipment_item is not None: + duration_days = (ends_at - starts_at).total_seconds() / 86400 + billable_days = max(1, int(duration_days) if duration_days.is_integer() else int(duration_days) + 1) + return Decimal(billable_days) * equipment_item.price_per_day + + if participants_count < 1: + raise ValidationError("participants_count must be at least 1.") + if participants_count > adventure_offering.capacity: + raise ValidationError("participants_count exceeds adventure capacity.") + return Decimal(participants_count) * adventure_offering.price_per_person + + +def create_booking_request( + *, + customer, + starts_at, + ends_at, + equipment_item=None, + adventure_offering=None, + customer_notes="", + participants_count=1, + listing_click=None, +): + starts_at = _normalize_utc(starts_at) + ends_at = _normalize_utc(ends_at) + if not is_bookable( + equipment_item=equipment_item, + adventure_offering=adventure_offering, + starts_at=starts_at, + ends_at=ends_at, + ): + raise ValidationError("This item/offering is not available for this range.") + + total_price = quote_booking( + equipment_item=equipment_item, + adventure_offering=adventure_offering, + starts_at=starts_at, + ends_at=ends_at, + participants_count=participants_count, + ) + + vendor = equipment_item.vendor if equipment_item else adventure_offering.vendor + booking = Booking.objects.create( + customer=customer, + vendor=vendor, + equipment_item=equipment_item, + adventure_offering=adventure_offering, + starts_at=starts_at, + ends_at=ends_at, + status=Booking.Status.REQUESTED, + total_price=total_price, + customer_notes=customer_notes, + listing_click=listing_click, + ) + BookingEventLog.objects.create( + booking=booking, + from_status="", + to_status=Booking.Status.REQUESTED, + note="Booking requested.", + actor=customer, + ) + return booking + + +def transition_booking_status(*, booking, to_status, actor, note="", vendor_notes=None): + allowed_transitions = { + Booking.Status.REQUESTED: {Booking.Status.APPROVED, Booking.Status.DECLINED, Booking.Status.CANCELLED}, + Booking.Status.APPROVED: {Booking.Status.CONFIRMED, Booking.Status.CANCELLED}, + Booking.Status.CONFIRMED: {Booking.Status.CANCELLED}, + Booking.Status.DECLINED: set(), + Booking.Status.CANCELLED: set(), + } + current = booking.status + if to_status not in allowed_transitions[current]: + raise ValidationError(f"Invalid booking status transition: {current} -> {to_status}") + + booking.status = to_status + if vendor_notes is not None: + booking.vendor_notes = vendor_notes + booking.save(update_fields=["status", "vendor_notes", "updated_at"]) + BookingEventLog.objects.create( + booking=booking, + from_status=current, + to_status=to_status, + note=note, + actor=actor, + ) + return booking diff --git a/booking/tests.py b/booking/tests.py new file mode 100644 index 0000000..6c470a0 --- /dev/null +++ b/booking/tests.py @@ -0,0 +1,256 @@ +from datetime import timedelta + +from django.contrib.admin.sites import AdminSite +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import ValidationError +from django.test import RequestFactory, TestCase +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APITestCase + +from accounts.models import VendorProfile +from adventrues.models import AdventureCategory, AdventureOffering +from equipment.models import EquipmentCategory, EquipmentItem + +from .admin import BookingAdmin, mark_approved +from .models import AvailabilitySlot, Booking +from .services import is_bookable, quote_booking, transition_booking_status + +User = get_user_model() + + +class BookingServiceTests(TestCase): + def setUp(self): + self.vendor_user = User.objects.create_user( + email="vendor-booking@example.com", + password="Pass123456!", + is_vendor=True, + is_customer=False, + ) + self.vendor_profile = VendorProfile.objects.create(user=self.vendor_user, business_name="Wave Rentals") + self.customer = User.objects.create_user( + email="customer-booking@example.com", + password="Pass123456!", + is_vendor=False, + is_customer=True, + ) + category = EquipmentCategory.objects.create(name="Jet Ski", slug="jetski") + self.item = EquipmentItem.objects.create( + vendor=self.vendor_profile, + category=category, + title="Jet Ski 1", + public_id="jet-001", + price_per_day="120.00", + is_active=True, + ) + adv_category = AdventureCategory.objects.create(name="Tour", slug="tour") + self.offering = AdventureOffering.objects.create( + vendor=self.vendor_profile, + category=adv_category, + title="Harbor Tour", + public_id="tour-001", + duration_minutes=90, + capacity=6, + price_per_person="60.00", + is_active=True, + ) + + def test_availability_overlap_validation(self): + starts = timezone.now() + timedelta(days=1) + ends = starts + timedelta(days=1) + Booking.objects.create( + customer=self.customer, + vendor=self.vendor_profile, + equipment_item=self.item, + starts_at=starts, + ends_at=ends, + status=Booking.Status.APPROVED, + total_price="120.00", + ) + self.assertFalse( + is_bookable( + equipment_item=self.item, + starts_at=starts + timedelta(hours=1), + ends_at=ends + timedelta(hours=1), + ) + ) + + def test_booking_quote_and_state_transition_validation(self): + starts = timezone.now() + timedelta(days=2) + ends = starts + timedelta(days=2) + quote = quote_booking(equipment_item=self.item, starts_at=starts, ends_at=ends) + self.assertEqual(str(quote), "240.00") + + booking = Booking.objects.create( + customer=self.customer, + vendor=self.vendor_profile, + equipment_item=self.item, + starts_at=starts, + ends_at=ends, + status=Booking.Status.REQUESTED, + total_price=quote, + ) + transition_booking_status(booking=booking, to_status=Booking.Status.APPROVED, actor=self.vendor_user, note="ok") + booking.refresh_from_db() + self.assertEqual(booking.status, Booking.Status.APPROVED) + + with self.assertRaises(ValidationError): + transition_booking_status(booking=booking, to_status=Booking.Status.DECLINED, actor=self.vendor_user) + + def test_slot_rule_blocks_when_unavailable_slot_overlaps(self): + starts = timezone.now() + timedelta(days=3) + ends = starts + timedelta(hours=4) + AvailabilitySlot.objects.create( + equipment_item=self.item, + starts_at=starts - timedelta(hours=1), + ends_at=ends + timedelta(hours=1), + is_available=False, + ) + self.assertFalse(is_bookable(equipment_item=self.item, starts_at=starts, ends_at=ends)) + + +class BookingApiTests(APITestCase): + def setUp(self): + self.vendor_user = User.objects.create_user( + email="vendor-api@example.com", + password="Pass123456!", + is_vendor=True, + is_customer=False, + ) + self.vendor_profile = VendorProfile.objects.create(user=self.vendor_user, business_name="Vendor API") + self.customer = User.objects.create_user( + email="customer-api@example.com", + password="Pass123456!", + is_vendor=False, + is_customer=True, + ) + self.other_customer = User.objects.create_user( + email="other@example.com", + password="Pass123456!", + is_vendor=False, + is_customer=True, + ) + category = EquipmentCategory.objects.create(name="Board", slug="board") + self.item = EquipmentItem.objects.create( + vendor=self.vendor_profile, + category=category, + title="Board 1", + public_id="board-001", + price_per_day="80.00", + is_active=True, + ) + + def test_booking_request_happy_path_and_overlap_rejection(self): + starts = timezone.now() + timedelta(days=5) + ends = starts + timedelta(days=1) + self.client.force_authenticate(self.customer) + first = self.client.post( + "/api/v1/booking/bookings/request/", + { + "equipment_item_id": self.item.id, + "starts_at": starts.isoformat(), + "ends_at": ends.isoformat(), + }, + format="json", + ) + self.assertEqual(first.status_code, status.HTTP_201_CREATED) + + overlap = self.client.post( + "/api/v1/booking/bookings/request/", + { + "equipment_item_id": self.item.id, + "starts_at": (starts + timedelta(hours=1)).isoformat(), + "ends_at": (ends + timedelta(hours=1)).isoformat(), + }, + format="json", + ) + self.assertEqual(overlap.status_code, status.HTTP_400_BAD_REQUEST) + + def test_booking_invalid_range_rejected(self): + starts = timezone.now() + timedelta(days=2) + self.client.force_authenticate(self.customer) + res = self.client.post( + "/api/v1/booking/bookings/request/", + { + "equipment_item_id": self.item.id, + "starts_at": (starts + timedelta(hours=2)).isoformat(), + "ends_at": starts.isoformat(), + }, + format="json", + ) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_vendor_approve_and_decline_permissions(self): + starts = timezone.now() + timedelta(days=6) + ends = starts + timedelta(days=1) + booking = Booking.objects.create( + customer=self.customer, + vendor=self.vendor_profile, + equipment_item=self.item, + starts_at=starts, + ends_at=ends, + status=Booking.Status.REQUESTED, + total_price="80.00", + ) + + self.client.force_authenticate(self.other_customer) + forbidden = self.client.post(f"/api/v1/booking/bookings/{booking.id}/approve/", {}, format="json") + self.assertEqual(forbidden.status_code, status.HTTP_404_NOT_FOUND) + + self.client.force_authenticate(self.vendor_user) + approved = self.client.post(f"/api/v1/booking/bookings/{booking.id}/approve/", {}, format="json") + self.assertEqual(approved.status_code, status.HTTP_200_OK) + booking.refresh_from_db() + self.assertEqual(booking.status, Booking.Status.APPROVED) + + +class BookingAdminTests(TestCase): + def setUp(self): + self.site = AdminSite() + self.factory = RequestFactory() + self.admin_user = User.objects.create_superuser(email="admin@example.com", password="Pass123456!") + vendor_user = User.objects.create_user( + email="vendor-admin@example.com", + password="Pass123456!", + is_vendor=True, + is_customer=False, + ) + self.vendor_profile = VendorProfile.objects.create(user=vendor_user, business_name="Admin Vendor") + customer = User.objects.create_user( + email="customer-admin@example.com", + password="Pass123456!", + is_vendor=False, + is_customer=True, + ) + category = EquipmentCategory.objects.create(name="Sail", slug="sail") + item = EquipmentItem.objects.create( + vendor=self.vendor_profile, + category=category, + title="Sail Boat", + public_id="sail-001", + price_per_day="90.00", + is_active=True, + ) + starts = timezone.now() + timedelta(days=7) + ends = starts + timedelta(days=1) + self.booking = Booking.objects.create( + customer=customer, + vendor=self.vendor_profile, + equipment_item=item, + starts_at=starts, + ends_at=ends, + status=Booking.Status.REQUESTED, + total_price="90.00", + ) + + def test_admin_model_registration_smoke(self): + admin_obj = BookingAdmin(Booking, self.site) + self.assertIsNotNone(admin_obj) + + def test_admin_action_status_update(self): + request = self.factory.post("/admin/booking/booking/") + request.user = self.admin_user + mark_approved(None, request, Booking.objects.filter(id=self.booking.id)) + self.booking.refresh_from_db() + self.assertEqual(self.booking.status, Booking.Status.APPROVED) diff --git a/booking/urls.py b/booking/urls.py new file mode 100644 index 0000000..9076b93 --- /dev/null +++ b/booking/urls.py @@ -0,0 +1,23 @@ +from django.urls import path + +from .views import ( + AvailabilityView, + BookingCreateView, + BookingDetailView, + BookingListView, + CustomerCancelBookingView, + VendorApproveBookingView, + VendorDeclineBookingView, + health_check, +) + +urlpatterns = [ + path("health/", health_check, name="health_check"), + path("availability/", AvailabilityView.as_view(), name="availability"), + path("bookings/", BookingListView.as_view(), name="booking_list"), + path("bookings/request/", BookingCreateView.as_view(), name="booking_request"), + path("bookings//", BookingDetailView.as_view(), name="booking_detail"), + path("bookings//approve/", VendorApproveBookingView.as_view(), name="booking_approve"), + path("bookings//decline/", VendorDeclineBookingView.as_view(), name="booking_decline"), + path("bookings//cancel/", CustomerCancelBookingView.as_view(), name="booking_cancel"), +] diff --git a/booking/views.py b/booking/views.py new file mode 100644 index 0000000..9ee0b9a --- /dev/null +++ b/booking/views.py @@ -0,0 +1,169 @@ +from django.http import JsonResponse +from django.shortcuts import get_object_or_404 +from rest_framework import generics, permissions, status +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import Booking +from .serializers import BookingCreateSerializer, BookingSerializer +from .services import is_bookable, transition_booking_status + + +def health_check(request): + return JsonResponse({"status": "ok", "service": "WaterTrek"}) + + +class AvailabilityView(APIView): + permission_classes = (permissions.AllowAny,) + + def get(self, request): + equipment_item_id = request.query_params.get("equipment_item_id") + adventure_offering_id = request.query_params.get("adventure_offering_id") + starts_at = request.query_params.get("starts_at") + ends_at = request.query_params.get("ends_at") + if not starts_at or not ends_at: + return Response( + {"detail": "starts_at and ends_at are required query params."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if bool(equipment_item_id) == bool(adventure_offering_id): + return Response( + {"detail": "Provide exactly one target: equipment_item_id or adventure_offering_id."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + payload = {"starts_at": starts_at, "ends_at": ends_at} + if equipment_item_id: + payload["equipment_item_id"] = equipment_item_id + if adventure_offering_id: + payload["adventure_offering_id"] = adventure_offering_id + serializer = BookingCreateSerializer(data=payload) + serializer.is_valid(raise_exception=True) + + equipment_item = serializer.validated_data.get("equipment_item_id") + adventure_offering = serializer.validated_data.get("adventure_offering_id") + starts_at_value = serializer.validated_data["starts_at"] + ends_at_value = serializer.validated_data["ends_at"] + conflict_filter = { + "starts_at__lt": ends_at_value, + "ends_at__gt": starts_at_value, + "status__in": [Booking.Status.REQUESTED, Booking.Status.APPROVED, Booking.Status.CONFIRMED], + } + if equipment_item: + conflict_filter["equipment_item"] = equipment_item + else: + conflict_filter["adventure_offering"] = adventure_offering + conflicts = Booking.objects.filter(**conflict_filter).count() + available = is_bookable( + equipment_item=equipment_item, + adventure_offering=adventure_offering, + starts_at=starts_at_value, + ends_at=ends_at_value, + ) + return Response( + { + "equipment_item_id": equipment_item.id if equipment_item else None, + "adventure_offering_id": adventure_offering.id if adventure_offering else None, + "starts_at": starts_at_value, + "ends_at": ends_at_value, + "is_available": available, + "conflicts": conflicts, + } + ) + + +class BookingCreateView(generics.CreateAPIView): + serializer_class = BookingCreateSerializer + permission_classes = (permissions.IsAuthenticated,) + + def perform_create(self, serializer): + if not self.request.user.is_customer: + raise PermissionDenied("Only customers can request bookings.") + self.instance = serializer.save() + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + return Response(BookingSerializer(self.instance).data, status=status.HTTP_201_CREATED) + + +class BookingListView(generics.ListAPIView): + serializer_class = BookingSerializer + permission_classes = (permissions.IsAuthenticated,) + + def get_queryset(self): + user = self.request.user + if user.is_vendor and hasattr(user, "vendor_profile"): + return Booking.objects.filter(vendor=user.vendor_profile).select_related( + "customer", "vendor", "equipment_item", "adventure_offering" + ) + return Booking.objects.filter(customer=user).select_related("customer", "vendor", "equipment_item", "adventure_offering") + + +class BookingDetailView(generics.RetrieveAPIView): + serializer_class = BookingSerializer + permission_classes = (permissions.IsAuthenticated,) + + def get_queryset(self): + user = self.request.user + if user.is_vendor and hasattr(user, "vendor_profile"): + return Booking.objects.filter(vendor=user.vendor_profile).select_related( + "customer", "vendor", "equipment_item", "adventure_offering" + ) + return Booking.objects.filter(customer=user).select_related("customer", "vendor", "equipment_item", "adventure_offering") + + +class VendorApproveBookingView(APIView): + permission_classes = (permissions.IsAuthenticated,) + + def post(self, request, pk): + if not request.user.is_vendor or not hasattr(request.user, "vendor_profile"): + raise PermissionDenied("Only vendors can approve bookings.") + booking = get_object_or_404(Booking, pk=pk, vendor=request.user.vendor_profile) + if booking.status != Booking.Status.REQUESTED: + return Response({"detail": "Only requested bookings can be approved."}, status=status.HTTP_400_BAD_REQUEST) + transition_booking_status( + booking=booking, + to_status=Booking.Status.APPROVED, + actor=request.user, + note=request.data.get("note", "Booking approved."), + vendor_notes=request.data.get("vendor_notes", booking.vendor_notes), + ) + return Response(BookingSerializer(booking).data) + + +class VendorDeclineBookingView(APIView): + permission_classes = (permissions.IsAuthenticated,) + + def post(self, request, pk): + if not request.user.is_vendor or not hasattr(request.user, "vendor_profile"): + raise PermissionDenied("Only vendors can decline bookings.") + booking = get_object_or_404(Booking, pk=pk, vendor=request.user.vendor_profile) + if booking.status != Booking.Status.REQUESTED: + return Response({"detail": "Only requested bookings can be declined."}, status=status.HTTP_400_BAD_REQUEST) + transition_booking_status( + booking=booking, + to_status=Booking.Status.DECLINED, + actor=request.user, + note=request.data.get("note", "Booking declined."), + vendor_notes=request.data.get("vendor_notes", booking.vendor_notes), + ) + return Response(BookingSerializer(booking).data) + + +class CustomerCancelBookingView(APIView): + permission_classes = (permissions.IsAuthenticated,) + + def post(self, request, pk): + booking = get_object_or_404(Booking, pk=pk, customer=request.user) + if booking.status in [Booking.Status.CANCELLED, Booking.Status.DECLINED]: + return Response({"detail": "This booking cannot be cancelled."}, status=status.HTTP_400_BAD_REQUEST) + transition_booking_status( + booking=booking, + to_status=Booking.Status.CANCELLED, + actor=request.user, + note=request.data.get("note", "Booking cancelled by customer."), + ) + return Response(BookingSerializer(booking).data) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..757a75d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + web: + build: . + container_name: watertrek-web + command: uv run gunicorn WaterTrek.wsgi:application --bind 0.0.0.0:8000 --workers 3 + volumes: + - .:/app + ports: + - "8000:8000" + environment: + DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-change-me} + DJANGO_DEBUG: 1 + DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1} + DATABASE_URL: ${DATABASE_URL:-postgresql://postgres:postgres@db:5432/watertrek} + depends_on: + db: + condition: service_healthy + + db: + image: postgres:16-alpine + container_name: watertrek-db + environment: + POSTGRES_DB: watertrek + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5434:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d watertrek"] + interval: 5s + timeout: 5s + retries: 20 + +volumes: + postgres_data: diff --git a/equipment/__init__.py b/equipment/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/equipment/__init__.py @@ -0,0 +1 @@ + diff --git a/equipment/admin.py b/equipment/admin.py new file mode 100644 index 0000000..8e827a0 --- /dev/null +++ b/equipment/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin + +from .models import EquipmentCategory, EquipmentImage, EquipmentItem + + +class EquipmentImageInline(admin.TabularInline): + model = EquipmentImage + extra = 0 + + +@admin.register(EquipmentCategory) +class EquipmentCategoryAdmin(admin.ModelAdmin): + list_display = ("name", "slug") + search_fields = ("name", "slug") + + +@admin.register(EquipmentItem) +class EquipmentItemAdmin(admin.ModelAdmin): + list_display = ("title", "public_id", "vendor", "category", "price_per_day", "is_active", "created_at") + list_filter = ("is_active", "category", "vendor") + search_fields = ("title", "public_id", "vendor__business_name") + readonly_fields = ("created_at", "updated_at") + inlines = [EquipmentImageInline] diff --git a/equipment/apps.py b/equipment/apps.py new file mode 100644 index 0000000..3b845ab --- /dev/null +++ b/equipment/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class EquipmentConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "equipment" diff --git a/equipment/migrations/0001_initial.py b/equipment/migrations/0001_initial.py new file mode 100644 index 0000000..d9c6e2c --- /dev/null +++ b/equipment/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 6.0.4 on 2026-04-08 16:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='EquipmentCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=64, unique=True)), + ('slug', models.SlugField(max_length=64, unique=True)), + ('description', models.TextField(blank=True)), + ], + ), + migrations.CreateModel( + name='EquipmentItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('public_id', models.CharField(max_length=64, unique=True)), + ('description', models.TextField(blank=True)), + ('details', models.JSONField(blank=True, default=dict)), + ('location', models.CharField(blank=True, max_length=255)), + ('price_per_day', models.DecimalField(decimal_places=2, max_digits=10)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='items', to='equipment.equipmentcategory')), + ('vendor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='equipment_items', to='accounts.vendorprofile')), + ], + options={ + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='EquipmentImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.ImageField(upload_to='equipment_images/')), + ('alt_text', models.CharField(blank=True, max_length=255)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('is_primary', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='equipment.equipmentitem')), + ], + options={ + 'ordering': ('sort_order', 'id'), + }, + ), + ] diff --git a/equipment/migrations/__init__.py b/equipment/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/equipment/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/equipment/models.py b/equipment/models.py new file mode 100644 index 0000000..0ad34cb --- /dev/null +++ b/equipment/models.py @@ -0,0 +1,45 @@ +from django.db import models + + +class EquipmentCategory(models.Model): + name = models.CharField(max_length=64, unique=True) + slug = models.SlugField(max_length=64, unique=True) + description = models.TextField(blank=True) + + def __str__(self) -> str: + return self.name + + +class EquipmentItem(models.Model): + vendor = models.ForeignKey("accounts.VendorProfile", on_delete=models.CASCADE, related_name="equipment_items") + category = models.ForeignKey(EquipmentCategory, on_delete=models.PROTECT, related_name="items") + title = models.CharField(max_length=255) + public_id = models.CharField(max_length=64, unique=True) + description = models.TextField(blank=True) + details = models.JSONField(default=dict, blank=True) + location = models.CharField(max_length=255, blank=True) + price_per_day = models.DecimalField(max_digits=10, decimal_places=2) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("-created_at",) + + def __str__(self) -> str: + return f"{self.title} ({self.public_id})" + + +class EquipmentImage(models.Model): + item = models.ForeignKey(EquipmentItem, on_delete=models.CASCADE, related_name="images") + image = models.ImageField(upload_to="equipment_images/") + alt_text = models.CharField(max_length=255, blank=True) + sort_order = models.PositiveIntegerField(default=0) + is_primary = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ("sort_order", "id") + + def __str__(self) -> str: + return f"Image for {self.item.public_id}" diff --git a/equipment/serializers.py b/equipment/serializers.py new file mode 100644 index 0000000..d088770 --- /dev/null +++ b/equipment/serializers.py @@ -0,0 +1,58 @@ +from rest_framework import serializers + +from .models import EquipmentCategory, EquipmentImage, EquipmentItem + + +class EquipmentCategorySerializer(serializers.ModelSerializer): + class Meta: + model = EquipmentCategory + fields = ("id", "name", "slug", "description") + + +class EquipmentImageSerializer(serializers.ModelSerializer): + image_url = serializers.SerializerMethodField() + + class Meta: + model = EquipmentImage + fields = ("id", "image_url", "alt_text", "sort_order", "is_primary") + + def get_image_url(self, obj): + if not obj.image: + return "" + request = self.context.get("request") + if request: + return request.build_absolute_uri(obj.image.url) + return obj.image.url + + +class EquipmentItemSerializer(serializers.ModelSerializer): + category = EquipmentCategorySerializer(read_only=True) + category_id = serializers.PrimaryKeyRelatedField( + queryset=EquipmentCategory.objects.all(), + source="category", + write_only=True, + ) + vendor_slug = serializers.CharField(source="vendor.slug", read_only=True) + vendor_business_name = serializers.CharField(source="vendor.business_name", read_only=True) + images = EquipmentImageSerializer(many=True, read_only=True) + + class Meta: + model = EquipmentItem + fields = ( + "id", + "public_id", + "title", + "description", + "details", + "location", + "price_per_day", + "is_active", + "category", + "category_id", + "vendor_slug", + "vendor_business_name", + "images", + "created_at", + "updated_at", + ) + read_only_fields = ("created_at", "updated_at") diff --git a/equipment/tests.py b/equipment/tests.py new file mode 100644 index 0000000..9d52c74 --- /dev/null +++ b/equipment/tests.py @@ -0,0 +1,111 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APITestCase + +from accounts.models import VendorProfile +from booking.models import Booking +from equipment.models import EquipmentCategory, EquipmentItem + +User = get_user_model() + + +class EquipmentApiTests(APITestCase): + def setUp(self): + self.vendor_user = User.objects.create_user( + email="vendor@example.com", + password="Pass123456!", + is_vendor=True, + is_customer=False, + ) + self.vendor_profile = VendorProfile.objects.create(user=self.vendor_user, business_name="Blue Boats") + self.customer_user = User.objects.create_user( + email="customer@example.com", + password="Pass123456!", + is_vendor=False, + is_customer=True, + ) + self.category = EquipmentCategory.objects.create(name="Boat", slug="boat") + self.item = EquipmentItem.objects.create( + vendor=self.vendor_profile, + category=self.category, + title="Speed Boat", + public_id="speed-001", + location="Miami", + price_per_day="200.00", + is_active=True, + ) + + def test_public_equipment_list_detail_and_filter(self): + list_res = self.client.get("/api/v1/equipment/items/") + self.assertEqual(list_res.status_code, status.HTTP_200_OK) + self.assertEqual(len(list_res.data), 1) + + filter_res = self.client.get("/api/v1/equipment/items/?location=miami&category=boat") + self.assertEqual(filter_res.status_code, status.HTTP_200_OK) + self.assertEqual(len(filter_res.data), 1) + + detail_res = self.client.get("/api/v1/equipment/items/speed-001/") + self.assertEqual(detail_res.status_code, status.HTTP_200_OK) + self.assertEqual(detail_res.data["public_id"], "speed-001") + + def test_vendor_crud_access_control(self): + unauth_create = self.client.post( + "/api/v1/equipment/vendor/items/", + { + "public_id": "unauth-1", + "title": "No Access", + "price_per_day": "10.00", + "category_id": self.category.id, + }, + format="json", + ) + self.assertEqual(unauth_create.status_code, status.HTTP_401_UNAUTHORIZED) + + self.client.force_authenticate(self.customer_user) + customer_create = self.client.post( + "/api/v1/equipment/vendor/items/", + { + "public_id": "cust-1", + "title": "Customer Create", + "price_per_day": "10.00", + "category_id": self.category.id, + }, + format="json", + ) + self.assertEqual(customer_create.status_code, status.HTTP_403_FORBIDDEN) + + self.client.force_authenticate(self.vendor_user) + vendor_create = self.client.post( + "/api/v1/equipment/vendor/items/", + { + "public_id": "vendor-1", + "title": "Vendor Create", + "price_per_day": "150.00", + "category_id": self.category.id, + "location": "Miami Beach", + }, + format="json", + ) + self.assertEqual(vendor_create.status_code, status.HTTP_201_CREATED) + self.assertEqual(vendor_create.data["vendor_slug"], self.vendor_profile.slug) + + def test_public_list_excludes_unavailable_range(self): + starts = timezone.now() + timedelta(days=2) + ends = starts + timedelta(days=1) + Booking.objects.create( + customer=self.customer_user, + vendor=self.vendor_profile, + equipment_item=self.item, + starts_at=starts, + ends_at=ends, + status=Booking.Status.REQUESTED, + total_price="200.00", + ) + res = self.client.get( + f"/api/v1/equipment/items/?available_from={starts.isoformat()}&available_to={ends.isoformat()}" + ) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(len(res.data), 0) diff --git a/equipment/urls.py b/equipment/urls.py new file mode 100644 index 0000000..8458f3c --- /dev/null +++ b/equipment/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import PublicEquipmentDetailView, PublicEquipmentListView, VendorEquipmentViewSet, VendorStorefrontView + +router = DefaultRouter() +router.register("vendor/items", VendorEquipmentViewSet, basename="vendor-equipment-item") + +urlpatterns = [ + path("", include(router.urls)), + path("items/", PublicEquipmentListView.as_view(), name="equipment_public_list"), + path("items//", PublicEquipmentDetailView.as_view(), name="equipment_public_detail"), + path("storefront//", VendorStorefrontView.as_view(), name="vendor_storefront"), +] diff --git a/equipment/views.py b/equipment/views.py new file mode 100644 index 0000000..3fca213 --- /dev/null +++ b/equipment/views.py @@ -0,0 +1,118 @@ +from django.db.models import Q +from django.utils.dateparse import parse_datetime +from rest_framework import generics, permissions, viewsets +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rest_framework.views import APIView + +from booking.models import Booking +from marketing.mixins import EquipmentListingClickTrackingMixin + +from .models import EquipmentItem +from .serializers import EquipmentItemSerializer + + +class PublicEquipmentListView(generics.ListAPIView): + serializer_class = EquipmentItemSerializer + permission_classes = (permissions.AllowAny,) + + def get_queryset(self): + queryset = EquipmentItem.objects.filter(is_active=True).select_related("category", "vendor").prefetch_related("images") + params = self.request.query_params + + category = params.get("category") + if category: + if category.isdigit(): + queryset = queryset.filter(category_id=category) + else: + queryset = queryset.filter(category__slug=category) + + location = params.get("location") + if location: + queryset = queryset.filter(location__icontains=location) + + vendor_slug = params.get("vendor_slug") + if vendor_slug: + queryset = queryset.filter(vendor__slug=vendor_slug) + + min_price = params.get("min_price") + if min_price: + queryset = queryset.filter(price_per_day__gte=min_price) + + max_price = params.get("max_price") + if max_price: + queryset = queryset.filter(price_per_day__lte=max_price) + + start = parse_datetime(params.get("available_from", "")) if params.get("available_from") else None + end = parse_datetime(params.get("available_to", "")) if params.get("available_to") else None + if start and end: + blocked_item_ids = ( + Booking.objects.filter( + equipment_item__isnull=False, + status__in=[Booking.Status.REQUESTED, Booking.Status.APPROVED, Booking.Status.CONFIRMED], + ) + .filter(starts_at__lt=end, ends_at__gt=start) + .values_list("equipment_item_id", flat=True) + ) + queryset = queryset.exclude(id__in=blocked_item_ids) + + return queryset + + +class PublicEquipmentDetailView(EquipmentListingClickTrackingMixin, generics.RetrieveAPIView): + serializer_class = EquipmentItemSerializer + permission_classes = (permissions.AllowAny,) + lookup_field = "public_id" + + def get_queryset(self): + return EquipmentItem.objects.filter(is_active=True).select_related("category", "vendor").prefetch_related("images") + + +class VendorEquipmentViewSet(viewsets.ModelViewSet): + serializer_class = EquipmentItemSerializer + permission_classes = (permissions.IsAuthenticated,) + + def get_queryset(self): + user = self.request.user + if not user.is_vendor or not hasattr(user, "vendor_profile"): + return EquipmentItem.objects.none() + return ( + EquipmentItem.objects.filter(vendor=user.vendor_profile) + .select_related("category", "vendor") + .prefetch_related("images") + ) + + def perform_create(self, serializer): + user = self.request.user + if not user.is_vendor or not hasattr(user, "vendor_profile"): + raise PermissionDenied("Only vendors can create equipment.") + serializer.save(vendor=user.vendor_profile) + + +class VendorStorefrontView(APIView): + permission_classes = (permissions.AllowAny,) + + def get(self, request, slug): + items = ( + EquipmentItem.objects.filter(vendor__slug=slug, is_active=True) + .select_related("category", "vendor") + .prefetch_related("images") + ) + if not items.exists(): + return Response({"detail": "Vendor storefront not found."}, status=404) + + vendor = items.first().vendor + return Response( + { + "vendor": { + "business_name": vendor.business_name, + "slug": vendor.slug, + "description": vendor.description, + "contact_email": vendor.contact_email, + "contact_phone": vendor.contact_phone, + "city": vendor.city, + "country": vendor.country, + }, + "items": EquipmentItemSerializer(items, many=True, context={"request": request}).data, + } + ) diff --git a/implementation.md b/implementation.md new file mode 100644 index 0000000..d327415 --- /dev/null +++ b/implementation.md @@ -0,0 +1,240 @@ +# Booking Platform Implementation Plan + +This plan defines the backend architecture and delivery path for a DRF-based booking platform supporting equipment rentals now and adventures/events later. + +## Project Status + +- [x] Initial Django project scaffold exists (`WaterTrek`, `booking`, Docker/dev setup) +- [x] DRF + authentication foundation fully configured +- [x] Domain apps created and wired (`accounts`, `equipment`, `adventrues`, `payment`) +- [x] Core data models and migrations implemented +- [x] Public browsing/filter APIs implemented (equipment + adventrues) +- [x] Booking/availability workflow implemented (equipment + adventrues booking flow) +- [x] Stripe payment workflow implemented (mocked Stripe flow for development) +- [x] Admin panel configured for operations +- [x] Unit and API test coverage established (baseline model/service/api/admin tests) + +## Architecture and App Boundaries + +- [x] Keep project configuration in `WaterTrek` +- [x] Use dedicated apps with strict ownership: + - [x] `accounts`: custom user model, roles, profiles + - [x] `equipment`: vendor storefronts and rental inventory + - [x] `adventrues`: tours/excursions/events (non-equipment bookings) + - [x] `booking`: availability calendars, booking requests, lifecycle + - [x] `payment`: Stripe intents/charges/refunds/webhooks +- [x] Use DRF-only API endpoints (no HTML templates) +- [x] Version API routes under `/api/v1/` + +## Data Model Plan + +### Accounts + +- [x] Implement custom `User` model (email login, password, phone) +- [x] Add role flags or role enum (`vendor`, `customer`) +- [x] Create `VendorProfile` (business identity, contact info, slug/store metadata) +- [x] Create `CustomerProfile` for renter-specific data + +### Equipment + +- [x] Create `EquipmentCategory` (boat, car, bike, motorcycle, extensible) +- [x] Create `EquipmentItem` (vendor, title, item ID, details, location, pricing, active status) +- [x] Create `EquipmentImage` (image, primary image, ordering, alt text) +- [ ] Add extensible attributes (JSON) for future filtering metadata + +### Adventrues + +- [x] Create `AdventureCategory` (wine tour, hiking, excursion, etc.) +- [x] Create `AdventureOffering` (vendor, title, details, location, duration, capacity, pricing) +- [x] Create `AdventureImage` + +### Booking + +- [x] Create availability models (slot/rule-based) +- [x] Link availability to `EquipmentItem` and `AdventureOffering` +- [x] Create `Booking` model with lifecycle statuses: + - [x] `requested` + - [x] `approved` + - [x] `declined` + - [x] `cancelled` + - [x] `confirmed` +- [x] Add booking notes, total price snapshot, and timestamp fields +- [x] Add audit log/event history model for booking transitions + +### Payment + +- [x] Create `PaymentRecord` linked to bookings and Stripe IDs +- [x] Create `WebhookEvent` model for idempotent Stripe event processing + +## Runtime and Infrastructure (Implemented) + +- [x] Dockerized Django service with `uv` dependency management +- [x] PostgreSQL service in `docker-compose` +- [x] Gunicorn configured as the web server for container runtime +- [x] WhiteNoise static file serving configured for admin/static assets +- [x] Container entrypoint runs migrations and `collectstatic` +- [x] Custom user manager added so `createsuperuser` works with email login + +## API Plan (DRF) + +### Auth and Accounts + +- [x] Registration endpoint (vendor registration) +- [x] Login + token refresh endpoints (JWT) +- [x] Current user endpoint (`/me`) +- [~] Vendor/customer profile endpoints (vendor profile endpoint implemented) + +### Equipment APIs + +- [x] Public equipment list/detail endpoints +- [x] Filters: type/category, location, date range, price range +- [x] Vendor CRUD for own inventory +- [x] Vendor storefront endpoint by slug + +### Adventrues APIs + +- [x] Public adventure list/detail endpoints +- [x] Filters: category, location, date range, price range +- [x] Vendor CRUD for own adventures + +### Booking APIs + +- [x] Availability query endpoint +- [x] Booking request endpoint +- [x] Vendor approve/decline endpoints +- [x] Customer cancel endpoint +- [x] Booking detail/list endpoints for both customer and vendor dashboards + +### Payment APIs + +- [x] Create Stripe PaymentIntent for approved bookings (mocked) +- [x] Payment status endpoint +- [x] Stripe webhook endpoint for payment lifecycle updates (mocked) + +## Booking and Availability Rules + +- [x] Enforce no overlap for approved/confirmed booking windows +- [x] Define soft-hold behavior for `requested` bookings +- [x] Normalize all datetimes to UTC in persistence +- [x] Return ISO-8601 datetime strings to frontend +- [x] Implement booking domain services: + - [x] `is_bookable(...)` + - [x] `quote_booking(...)` + - [x] `create_booking_request(...)` + - [x] `transition_booking_status(...)` + +## Permissions and Security + +- [x] Public read access for browse endpoints +- [x] Auth required for booking operations +- [x] Vendors can only edit their own resources +- [ ] Object-level permission checks for update/delete actions +- [ ] Rate limiting for auth and booking-request endpoints +- [ ] Media upload validation (type and size) + +## Admin Panel Plan + +- [x] Register all core models in Django admin +- [x] Configure list views, filters, search, and inlines: + - [x] `EquipmentItemAdmin` with image inlines + - [x] `AdventureOfferingAdmin` with image inlines + - [x] `BookingAdmin` with status/date/vendor/customer filters + - [x] `PaymentRecordAdmin` with Stripe identifiers and statuses + - [x] `VendorProfileAdmin` searchable by business and email +- [x] Add safe admin actions (approve/decline/confirm bookings) +- [x] Mark system-managed fields as read-only where appropriate + +## Unit Testing and API Testing Plan + +### Model Tests + +- [x] Custom user creation and role rules +- [x] Availability overlap validation +- [x] Booking state transition validation +- [x] Payment idempotency constraints + +### API Tests (DRF) + +- [x] Auth and token flows +- [x] Role-based permissions and object access control +- [x] Equipment/adventure list/detail/filter behavior +- [x] Booking request happy path +- [x] Booking overlap and invalid-range rejection +- [x] Vendor approval/decline actions +- [x] Payment API behavior with Stripe mocked + +### Admin Tests + +- [x] Admin model registration smoke tests +- [x] Admin actions for booking status updates + +### Service Tests + +- [x] Availability calculation correctness +- [x] Booking quote calculations +- [x] Booking lifecycle transitions and guards + +## Stripe Integration Phases + +- [ ] Phase A: payment models + local service abstraction +- [ ] Phase B: Stripe key/config wiring and PaymentIntent creation +- [ ] Phase C: webhook signature verification + idempotent processing +- [ ] Phase D: refunds and cancellation payment handling + +## Delivery Phases and Milestones + +### Phase 0: Foundation + +- [x] Create and wire `accounts`, `equipment`, `adventrues`, `payment` +- [x] Set custom user model +- [x] Configure DRF, auth, and base permissions +- [ ] Establish shared utilities and base test setup + +### Phase 1: Equipment Marketplace MVP + +- [x] Equipment models, migrations, admin +- [x] Vendor inventory CRUD APIs +- [x] Public browse/filter APIs +- [x] Vendor storefront endpoint +- [x] Unit/API tests for MVP behavior + +### Phase 2: Booking Engine + +- [x] Availability models and query APIs +- [x] Booking request and status transition flows +- [x] Overlap protections and lifecycle rules +- [x] Unit/API/admin tests for booking workflows + +### Phase 3: Adventrues + +- [x] Adventure models, APIs, and admin +- [x] Adventure availability integration +- [x] Adventure booking requests through shared booking flow +- [x] Test coverage for adventure-specific cases + +### Phase 4: Payments + +- [x] Stripe PaymentIntent endpoint integration (mocked) +- [x] Payment state synchronization with booking status +- [x] Webhook handlers and retries (idempotent event processing) +- [x] Payment-focused unit/integration tests (mocked Stripe flow) + +### Phase 5: Hardening + +- [ ] Permission audit and security checks +- [ ] Performance optimization and indexing +- [ ] Expanded tests and reliability tooling +- [x] API documentation and handoff notes + +## Immediate Next Steps (Recommended) + +- [x] Complete DRF foundation: + - [x] Add `djangorestframework` and JWT auth package + - [x] Wire `REST_FRAMEWORK` defaults and auth classes in settings + - [x] Create versioned API router under `/api/v1/` +- [~] Build first API slice: + - [x] Read-only public list/detail endpoints for equipment and adventures + - [x] Authenticated booking request endpoint +- [ ] Raise quality baseline: + - [x] Add model tests for user manager + booking constraints + - [x] Add API smoke tests for auth and public catalog endpoints diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..5075321 --- /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() -> None: + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "WaterTrek.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/marketing/__init__.py b/marketing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/marketing/admin.py b/marketing/admin.py new file mode 100644 index 0000000..bf42b11 --- /dev/null +++ b/marketing/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from .models import ListingClick + + +@admin.register(ListingClick) +class ListingClickAdmin(admin.ModelAdmin): + list_display = ("id", "vendor", "listing_type", "traffic_type", "utm_campaign", "utm_source", "created_at") + list_filter = ("listing_type", "traffic_type") + search_fields = ("utm_campaign", "utm_source", "utm_medium", "vendor__business_name") + raw_id_fields = ("vendor", "equipment_item", "adventure_offering", "user") diff --git a/marketing/apps.py b/marketing/apps.py new file mode 100644 index 0000000..a014548 --- /dev/null +++ b/marketing/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class MarketingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "marketing" + verbose_name = "Marketing" diff --git a/marketing/migrations/0001_initial.py b/marketing/migrations/0001_initial.py new file mode 100644 index 0000000..7e4a842 --- /dev/null +++ b/marketing/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 6.0.4 on 2026-04-11 01:49 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0002_alter_user_managers'), + ('adventrues', '0001_initial'), + ('equipment', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ListingClick', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('listing_type', models.CharField(choices=[('equipment', 'Equipment'), ('adventure', 'Adventure')], max_length=16)), + ('traffic_type', models.CharField(choices=[('organic', 'Organic'), ('marketing', 'Marketing')], max_length=16)), + ('utm_source', models.CharField(blank=True, max_length=255)), + ('utm_medium', models.CharField(blank=True, max_length=255)), + ('utm_campaign', models.CharField(blank=True, max_length=255)), + ('utm_term', models.CharField(blank=True, max_length=255)), + ('utm_content', models.CharField(blank=True, max_length=255)), + ('gclid', models.CharField(blank=True, max_length=255)), + ('fbclid', models.CharField(blank=True, max_length=255)), + ('referrer', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('adventure_offering', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='marketing_clicks', to='adventrues.adventureoffering')), + ('equipment_item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='marketing_clicks', to='equipment.equipmentitem')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='listing_clicks', to=settings.AUTH_USER_MODEL)), + ('vendor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='listing_clicks', to='accounts.vendorprofile')), + ], + options={ + 'ordering': ('-created_at',), + 'indexes': [models.Index(fields=['vendor', 'created_at'], name='marketing_l_vendor__341231_idx'), models.Index(fields=['vendor', 'traffic_type', 'created_at'], name='marketing_l_vendor__d03035_idx'), models.Index(fields=['utm_campaign', 'created_at'], name='marketing_l_utm_cam_f608ac_idx')], + 'constraints': [models.CheckConstraint(condition=models.Q(models.Q(('adventure_offering__isnull', True), ('equipment_item__isnull', False), ('listing_type', 'equipment')), models.Q(('adventure_offering__isnull', False), ('equipment_item__isnull', True), ('listing_type', 'adventure')), _connector='OR'), name='listing_click_exactly_one_listing')], + }, + ), + ] diff --git a/marketing/migrations/__init__.py b/marketing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/marketing/mixins.py b/marketing/mixins.py new file mode 100644 index 0000000..ab74c66 --- /dev/null +++ b/marketing/mixins.py @@ -0,0 +1,25 @@ +from rest_framework.response import Response + +from .services import record_adventure_listing_click, record_equipment_listing_click + + +class EquipmentListingClickTrackingMixin: + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + click = record_equipment_listing_click(request, instance) + serializer = self.get_serializer(instance) + data = dict(serializer.data) + data["marketing_click_id"] = click.id + data["click_traffic_type"] = click.traffic_type + return Response(data) + + +class AdventureListingClickTrackingMixin: + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + click = record_adventure_listing_click(request, instance) + serializer = self.get_serializer(instance) + data = dict(serializer.data) + data["marketing_click_id"] = click.id + data["click_traffic_type"] = click.traffic_type + return Response(data) diff --git a/marketing/models.py b/marketing/models.py new file mode 100644 index 0000000..d332df9 --- /dev/null +++ b/marketing/models.py @@ -0,0 +1,67 @@ +from django.conf import settings +from django.db import models +from django.db.models import Q + + +class ListingClick(models.Model): + """Server-side record of a public listing detail view with optional UTM / ad parameters.""" + + class ListingType(models.TextChoices): + EQUIPMENT = "equipment", "Equipment" + ADVENTURE = "adventure", "Adventure" + + class TrafficType(models.TextChoices): + ORGANIC = "organic", "Organic" + MARKETING = "marketing", "Marketing" + + vendor = models.ForeignKey("accounts.VendorProfile", on_delete=models.CASCADE, related_name="listing_clicks") + listing_type = models.CharField(max_length=16, choices=ListingType.choices) + equipment_item = models.ForeignKey( + "equipment.EquipmentItem", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="marketing_clicks", + ) + adventure_offering = models.ForeignKey( + "adventrues.AdventureOffering", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="marketing_clicks", + ) + traffic_type = models.CharField(max_length=16, choices=TrafficType.choices) + utm_source = models.CharField(max_length=255, blank=True) + utm_medium = models.CharField(max_length=255, blank=True) + utm_campaign = models.CharField(max_length=255, blank=True) + utm_term = models.CharField(max_length=255, blank=True) + utm_content = models.CharField(max_length=255, blank=True) + gclid = models.CharField(max_length=255, blank=True) + fbclid = models.CharField(max_length=255, blank=True) + referrer = models.TextField(blank=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="listing_clicks", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ("-created_at",) + indexes = [ + models.Index(fields=["vendor", "created_at"]), + models.Index(fields=["vendor", "traffic_type", "created_at"]), + models.Index(fields=["utm_campaign", "created_at"]), + ] + constraints = [ + models.CheckConstraint( + condition=Q(listing_type="equipment", equipment_item__isnull=False, adventure_offering__isnull=True) + | Q(listing_type="adventure", equipment_item__isnull=True, adventure_offering__isnull=False), + name="listing_click_exactly_one_listing", + ), + ] + + def __str__(self) -> str: + return f"ListingClick #{self.pk} ({self.listing_type}, {self.traffic_type})" diff --git a/marketing/serializers.py b/marketing/serializers.py new file mode 100644 index 0000000..f33cbf6 --- /dev/null +++ b/marketing/serializers.py @@ -0,0 +1,55 @@ +from rest_framework import serializers + +from adventrues.models import AdventureOffering +from equipment.models import EquipmentItem + +from .models import ListingClick + + +class ListingClickSerializer(serializers.ModelSerializer): + class Meta: + model = ListingClick + fields = ( + "id", + "listing_type", + "traffic_type", + "utm_source", + "utm_medium", + "utm_campaign", + "utm_term", + "utm_content", + "gclid", + "fbclid", + "equipment_item", + "adventure_offering", + "created_at", + ) + read_only_fields = fields + + +class TrackListingClickSerializer(serializers.Serializer): + listing_type = serializers.ChoiceField(choices=ListingClick.ListingType.choices) + public_id = serializers.CharField(max_length=64) + utm_source = serializers.CharField(required=False, allow_blank=True, default="", max_length=255) + utm_medium = serializers.CharField(required=False, allow_blank=True, default="", max_length=255) + utm_campaign = serializers.CharField(required=False, allow_blank=True, default="", max_length=255) + utm_term = serializers.CharField(required=False, allow_blank=True, default="", max_length=255) + utm_content = serializers.CharField(required=False, allow_blank=True, default="", max_length=255) + gclid = serializers.CharField(required=False, allow_blank=True, default="", max_length=255) + fbclid = serializers.CharField(required=False, allow_blank=True, default="", max_length=255) + referrer = serializers.CharField(required=False, allow_blank=True, default="", max_length=2048) + + def validate(self, attrs): + listing_type = attrs["listing_type"] + public_id = attrs["public_id"] + if listing_type == ListingClick.ListingType.EQUIPMENT: + item = EquipmentItem.objects.filter(public_id=public_id, is_active=True).select_related("vendor").first() + if not item: + raise serializers.ValidationError({"public_id": "No active equipment listing with this public_id."}) + attrs["_equipment_item"] = item + else: + offering = AdventureOffering.objects.filter(public_id=public_id, is_active=True).select_related("vendor").first() + if not offering: + raise serializers.ValidationError({"public_id": "No active adventure offering with this public_id."}) + attrs["_adventure_offering"] = offering + return attrs diff --git a/marketing/services.py b/marketing/services.py new file mode 100644 index 0000000..e758089 --- /dev/null +++ b/marketing/services.py @@ -0,0 +1,182 @@ +from datetime import timedelta + +from django.utils import timezone + +from .models import ListingClick + + +def _clean_param(params, key: str, max_len: int = 255) -> str: + raw = params.get(key) + if raw is None: + return "" + s = str(raw).strip() + return s[:max_len] + + +def classify_traffic( + *, + utm_source: str, + utm_medium: str, + utm_campaign: str, + utm_term: str, + utm_content: str, + gclid: str, + fbclid: str, +) -> str: + if any( + ( + utm_source, + utm_medium, + utm_campaign, + utm_term, + utm_content, + gclid, + fbclid, + ) + ): + return ListingClick.TrafficType.MARKETING + return ListingClick.TrafficType.ORGANIC + + +def extract_attribution_from_request(request) -> dict: + p = request.query_params if hasattr(request, "query_params") else request.GET + utm_source = _clean_param(p, "utm_source") + utm_medium = _clean_param(p, "utm_medium") + utm_campaign = _clean_param(p, "utm_campaign") + utm_term = _clean_param(p, "utm_term") + utm_content = _clean_param(p, "utm_content") + gclid = _clean_param(p, "gclid") + fbclid = _clean_param(p, "fbclid") + referrer = "" + if hasattr(request, "META"): + ref = request.META.get("HTTP_REFERER") or "" + referrer = ref[:2048] + traffic_type = classify_traffic( + utm_source=utm_source, + utm_medium=utm_medium, + utm_campaign=utm_campaign, + utm_term=utm_term, + utm_content=utm_content, + gclid=gclid, + fbclid=fbclid, + ) + return { + "utm_source": utm_source, + "utm_medium": utm_medium, + "utm_campaign": utm_campaign, + "utm_term": utm_term, + "utm_content": utm_content, + "gclid": gclid, + "fbclid": fbclid, + "referrer": referrer, + "traffic_type": traffic_type, + } + + +def record_equipment_listing_click(request, equipment_item) -> ListingClick: + attrs = extract_attribution_from_request(request) + user = request.user if getattr(request, "user", None) and request.user.is_authenticated else None + return ListingClick.objects.create( + vendor=equipment_item.vendor, + listing_type=ListingClick.ListingType.EQUIPMENT, + equipment_item=equipment_item, + adventure_offering=None, + user=user, + **attrs, + ) + + +def record_adventure_listing_click(request, adventure_offering) -> ListingClick: + attrs = extract_attribution_from_request(request) + user = request.user if getattr(request, "user", None) and request.user.is_authenticated else None + return ListingClick.objects.create( + vendor=adventure_offering.vendor, + listing_type=ListingClick.ListingType.ADVENTURE, + equipment_item=None, + adventure_offering=adventure_offering, + user=user, + **attrs, + ) + + +def record_listing_click_from_payload( + *, + listing_type: str, + equipment_item=None, + adventure_offering=None, + utm_source: str = "", + utm_medium: str = "", + utm_campaign: str = "", + utm_term: str = "", + utm_content: str = "", + gclid: str = "", + fbclid: str = "", + referrer: str = "", + user=None, +) -> ListingClick: + traffic_type = classify_traffic( + utm_source=utm_source or "", + utm_medium=utm_medium or "", + utm_campaign=utm_campaign or "", + utm_term=utm_term or "", + utm_content=utm_content or "", + gclid=gclid or "", + fbclid=fbclid or "", + ) + if listing_type == ListingClick.ListingType.EQUIPMENT: + return ListingClick.objects.create( + vendor=equipment_item.vendor, + listing_type=ListingClick.ListingType.EQUIPMENT, + equipment_item=equipment_item, + adventure_offering=None, + traffic_type=traffic_type, + utm_source=utm_source[:255], + utm_medium=utm_medium[:255], + utm_campaign=utm_campaign[:255], + utm_term=utm_term[:255], + utm_content=utm_content[:255], + gclid=gclid[:255], + fbclid=fbclid[:255], + referrer=referrer[:2048], + user=user, + ) + return ListingClick.objects.create( + vendor=adventure_offering.vendor, + listing_type=ListingClick.ListingType.ADVENTURE, + equipment_item=None, + adventure_offering=adventure_offering, + traffic_type=traffic_type, + utm_source=utm_source[:255], + utm_medium=utm_medium[:255], + utm_campaign=utm_campaign[:255], + utm_term=utm_term[:255], + utm_content=utm_content[:255], + gclid=gclid[:255], + fbclid=fbclid[:255], + referrer=referrer[:2048], + user=user, + ) + + +ATTRIBUTION_MAX_AGE_DAYS = 90 + + +def listing_click_valid_for_booking(*, click, equipment_item=None, adventure_offering=None) -> bool: + if click is None: + return False + cutoff = timezone.now() - timedelta(days=ATTRIBUTION_MAX_AGE_DAYS) + if click.created_at < cutoff: + return False + if equipment_item is not None: + return ( + click.listing_type == ListingClick.ListingType.EQUIPMENT + and click.equipment_item_id == equipment_item.id + and click.vendor_id == equipment_item.vendor_id + ) + if adventure_offering is not None: + return ( + click.listing_type == ListingClick.ListingType.ADVENTURE + and click.adventure_offering_id == adventure_offering.id + and click.vendor_id == adventure_offering.vendor_id + ) + return False diff --git a/marketing/tests.py b/marketing/tests.py new file mode 100644 index 0000000..9ea9c11 --- /dev/null +++ b/marketing/tests.py @@ -0,0 +1,91 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APITestCase + +from accounts.models import VendorProfile +from booking.models import Booking +from equipment.models import EquipmentCategory, EquipmentItem +from marketing.models import ListingClick + +User = get_user_model() + + +class MarketingTrackingTests(APITestCase): + def setUp(self): + self.vendor_user = User.objects.create_user( + email="vendor-mkt@example.com", + password="Pass123456!", + is_vendor=True, + is_customer=False, + ) + self.vendor_profile = VendorProfile.objects.create(user=self.vendor_user, business_name="Mkt Vendor") + self.customer = User.objects.create_user( + email="customer-mkt@example.com", + password="Pass123456!", + is_vendor=False, + is_customer=True, + ) + category = EquipmentCategory.objects.create(name="Kayak", slug="kayak") + self.item = EquipmentItem.objects.create( + vendor=self.vendor_profile, + category=category, + title="Kayak 1", + public_id="kayak-mkt-001", + price_per_day="50.00", + is_active=True, + ) + + def test_equipment_detail_logs_click_and_returns_ids(self): + res = self.client.get(f"/api/v1/equipment/items/{self.item.public_id}/") + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertIn("marketing_click_id", res.data) + self.assertIn("click_traffic_type", res.data) + self.assertEqual(res.data["click_traffic_type"], ListingClick.TrafficType.ORGANIC) + self.assertEqual(ListingClick.objects.count(), 1) + + def test_equipment_detail_utm_classified_marketing(self): + res = self.client.get( + f"/api/v1/equipment/items/{self.item.public_id}/", + {"utm_source": "google", "utm_medium": "cpc", "utm_campaign": "spring"}, + ) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(res.data["click_traffic_type"], ListingClick.TrafficType.MARKETING) + click = ListingClick.objects.get(pk=res.data["marketing_click_id"]) + self.assertEqual(click.utm_campaign, "spring") + + def test_track_click_endpoint(self): + res = self.client.post( + "/api/v1/marketing/track/click/", + {"listing_type": "equipment", "public_id": self.item.public_id, "utm_campaign": "email"}, + format="json", + ) + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + self.assertIn("marketing_click_id", res.data) + + def test_booking_with_marketing_click_id(self): + detail = self.client.get(f"/api/v1/equipment/items/{self.item.public_id}/") + click_id = detail.data["marketing_click_id"] + starts = timezone.now() + timedelta(days=10) + ends = starts + timedelta(days=1) + self.client.force_authenticate(self.customer) + res = self.client.post( + "/api/v1/booking/bookings/request/", + { + "equipment_item_id": self.item.id, + "starts_at": starts.isoformat(), + "ends_at": ends.isoformat(), + "marketing_click_id": click_id, + }, + format="json", + ) + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + booking = Booking.objects.get(pk=res.data["id"]) + self.assertEqual(booking.listing_click_id, click_id) + + def test_vendor_summary_requires_range(self): + self.client.force_authenticate(self.vendor_user) + res = self.client.get("/api/v1/marketing/vendor/summary/") + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/marketing/urls.py b/marketing/urls.py new file mode 100644 index 0000000..febe90f --- /dev/null +++ b/marketing/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import TrackListingClickView, VendorListingClickListView, VendorMarketingSummaryView + +urlpatterns = [ + path("track/click/", TrackListingClickView.as_view(), name="marketing-track-click"), + path("vendor/clicks/", VendorListingClickListView.as_view(), name="marketing-vendor-clicks"), + path("vendor/summary/", VendorMarketingSummaryView.as_view(), name="marketing-vendor-summary"), +] diff --git a/marketing/views.py b/marketing/views.py new file mode 100644 index 0000000..7a49da2 --- /dev/null +++ b/marketing/views.py @@ -0,0 +1,204 @@ +from django.db.models import Count +from django.utils import timezone +from django.utils.dateparse import parse_datetime +from rest_framework import generics, permissions, status +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rest_framework.views import APIView + +from booking.models import Booking + +from .models import ListingClick +from .serializers import ListingClickSerializer, TrackListingClickSerializer +from .services import record_adventure_listing_click, record_equipment_listing_click, record_listing_click_from_payload + + +def _parse_range(request): + from_raw = request.query_params.get("from") + to_raw = request.query_params.get("to") + if not from_raw or not to_raw: + return None, None, Response( + {"detail": "Query params 'from' and 'to' are required (ISO-8601 datetimes, UTC)."}, + status=status.HTTP_400_BAD_REQUEST, + ) + start = parse_datetime(from_raw) + end = parse_datetime(to_raw) + if not start or not end: + return None, None, Response({"detail": "Invalid 'from' or 'to' datetime."}, status=status.HTTP_400_BAD_REQUEST) + if timezone.is_naive(start): + start = timezone.make_aware(start, timezone.utc) + if timezone.is_naive(end): + end = timezone.make_aware(end, timezone.utc) + if end <= start: + return None, None, Response({"detail": "'to' must be after 'from'."}, status=status.HTTP_400_BAD_REQUEST) + return start, end, None + + +class TrackListingClickView(APIView): + """Explicit click-through when the client does not refetch the public detail endpoint.""" + + permission_classes = (permissions.AllowAny,) + + def post(self, request): + ser = TrackListingClickSerializer(data=request.data) + ser.is_valid(raise_exception=True) + listing_type = ser.validated_data["listing_type"] + user = request.user if request.user.is_authenticated else None + if listing_type == ListingClick.ListingType.EQUIPMENT: + item = ser.validated_data["_equipment_item"] + click = record_listing_click_from_payload( + listing_type=listing_type, + equipment_item=item, + utm_source=ser.validated_data.get("utm_source", ""), + utm_medium=ser.validated_data.get("utm_medium", ""), + utm_campaign=ser.validated_data.get("utm_campaign", ""), + utm_term=ser.validated_data.get("utm_term", ""), + utm_content=ser.validated_data.get("utm_content", ""), + gclid=ser.validated_data.get("gclid", ""), + fbclid=ser.validated_data.get("fbclid", ""), + referrer=ser.validated_data.get("referrer", ""), + user=user, + ) + else: + offering = ser.validated_data["_adventure_offering"] + click = record_listing_click_from_payload( + listing_type=listing_type, + adventure_offering=offering, + utm_source=ser.validated_data.get("utm_source", ""), + utm_medium=ser.validated_data.get("utm_medium", ""), + utm_campaign=ser.validated_data.get("utm_campaign", ""), + utm_term=ser.validated_data.get("utm_term", ""), + utm_content=ser.validated_data.get("utm_content", ""), + gclid=ser.validated_data.get("gclid", ""), + fbclid=ser.validated_data.get("fbclid", ""), + referrer=ser.validated_data.get("referrer", ""), + user=user, + ) + return Response({"marketing_click_id": click.id, "traffic_type": click.traffic_type}, status=status.HTTP_201_CREATED) + + +class VendorListingClickListView(generics.ListAPIView): + serializer_class = ListingClickSerializer + permission_classes = (permissions.IsAuthenticated,) + + def get_queryset(self): + user = self.request.user + if not user.is_vendor or not hasattr(user, "vendor_profile"): + raise PermissionDenied("Only vendors can list listing clicks.") + vp = user.vendor_profile + qs = ListingClick.objects.filter(vendor=vp).select_related("equipment_item", "adventure_offering") + start, end, err = _parse_range(self.request) + if err: + return ListingClick.objects.none() + qs = qs.filter(created_at__gte=start, created_at__lt=end) + tt = self.request.query_params.get("traffic_type") + if tt in (ListingClick.TrafficType.ORGANIC, ListingClick.TrafficType.MARKETING): + qs = qs.filter(traffic_type=tt) + lt = self.request.query_params.get("listing_type") + if lt in (ListingClick.ListingType.EQUIPMENT, ListingClick.ListingType.ADVENTURE): + qs = qs.filter(listing_type=lt) + campaign = self.request.query_params.get("utm_campaign") + if campaign: + qs = qs.filter(utm_campaign=campaign) + return qs + + def list(self, request, *args, **kwargs): + start, end, err = _parse_range(request) + if err: + return err + return super().list(request, *args, **kwargs) + + +class VendorMarketingSummaryView(APIView): + permission_classes = (permissions.IsAuthenticated,) + + def get(self, request): + user = request.user + if not user.is_vendor or not hasattr(user, "vendor_profile"): + raise PermissionDenied("Only vendors can view marketing summary.") + start, end, err = _parse_range(request) + if err: + return err + vp = user.vendor_profile + + clicks_qs = ListingClick.objects.filter(vendor=vp, created_at__gte=start, created_at__lt=end) + click_counts = clicks_qs.values("traffic_type").annotate(c=Count("id")) + clicks_by_traffic = {row["traffic_type"]: row["c"] for row in click_counts} + organic_clicks = clicks_by_traffic.get(ListingClick.TrafficType.ORGANIC, 0) + marketing_clicks = clicks_by_traffic.get(ListingClick.TrafficType.MARKETING, 0) + + bookings_qs = Booking.objects.filter(vendor=vp, created_at__gte=start, created_at__lt=end) + attributed = bookings_qs.filter(listing_click__isnull=False) + unattributed_bookings = bookings_qs.filter(listing_click__isnull=True).count() + + organic_bookings = attributed.filter(listing_click__traffic_type=ListingClick.TrafficType.ORGANIC).count() + marketing_bookings = attributed.filter(listing_click__traffic_type=ListingClick.TrafficType.MARKETING).count() + + def rate(bookings: int, clicks: int) -> float | None: + if clicks == 0: + return None + return round(bookings / clicks, 6) + + campaign_rows = ( + clicks_qs.exclude(utm_campaign="") + .values("utm_source", "utm_medium", "utm_campaign") + .annotate(clicks=Count("id")) + .order_by("-clicks")[:50] + ) + campaign_bookings = ( + attributed.filter(listing_click__utm_campaign__gt="") + .values( + "listing_click__utm_source", + "listing_click__utm_medium", + "listing_click__utm_campaign", + ) + .annotate(bookings=Count("id")) + ) + booking_key_counts = { + ( + row["listing_click__utm_source"] or "", + row["listing_click__utm_medium"] or "", + row["listing_click__utm_campaign"] or "", + ): row["bookings"] + for row in campaign_bookings + } + + campaigns = [] + for row in campaign_rows: + key = (row["utm_source"] or "", row["utm_medium"] or "", row["utm_campaign"] or "") + c_clicks = row["clicks"] + c_bookings = booking_key_counts.get(key, 0) + campaigns.append( + { + "utm_source": row["utm_source"], + "utm_medium": row["utm_medium"], + "utm_campaign": row["utm_campaign"], + "clicks": c_clicks, + "bookings": c_bookings, + "conversion_rate": rate(c_bookings, c_clicks), + } + ) + + return Response( + { + "from": start, + "to": end, + "clicks": { + "organic": organic_clicks, + "marketing": marketing_clicks, + "total": organic_clicks + marketing_clicks, + }, + "bookings_attributed": { + "organic": organic_bookings, + "marketing": marketing_bookings, + "total_attributed": organic_bookings + marketing_bookings, + "unattributed": unattributed_bookings, + "total_all": organic_bookings + marketing_bookings + unattributed_bookings, + }, + "conversion_rate_click_to_booking": { + "organic": rate(organic_bookings, organic_clicks), + "marketing": rate(marketing_bookings, marketing_clicks), + }, + "campaigns": campaigns, + } + ) diff --git a/payment/__init__.py b/payment/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/payment/__init__.py @@ -0,0 +1 @@ + diff --git a/payment/admin.py b/payment/admin.py new file mode 100644 index 0000000..591b6b7 --- /dev/null +++ b/payment/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin + +from .models import PaymentRecord, WebhookEvent + + +@admin.register(PaymentRecord) +class PaymentRecordAdmin(admin.ModelAdmin): + list_display = ("id", "booking", "stripe_payment_intent_id", "amount", "currency", "status", "created_at") + list_filter = ("status", "currency") + search_fields = ("stripe_payment_intent_id", "stripe_charge_id", "booking__id") + readonly_fields = ("created_at", "updated_at") + + +@admin.register(WebhookEvent) +class WebhookEventAdmin(admin.ModelAdmin): + list_display = ("stripe_event_id", "event_type", "processed", "processed_at", "created_at") + list_filter = ("processed", "event_type") + search_fields = ("stripe_event_id", "event_type") + readonly_fields = ("created_at",) diff --git a/payment/apps.py b/payment/apps.py new file mode 100644 index 0000000..ab29d31 --- /dev/null +++ b/payment/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PaymentConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "payment" diff --git a/payment/migrations/0001_initial.py b/payment/migrations/0001_initial.py new file mode 100644 index 0000000..d40e4e3 --- /dev/null +++ b/payment/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 6.0.4 on 2026-04-08 16:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('booking', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='WebhookEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('stripe_event_id', models.CharField(max_length=255, unique=True)), + ('event_type', models.CharField(max_length=120)), + ('payload', models.JSONField(default=dict)), + ('processed', models.BooleanField(default=False)), + ('processed_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='PaymentRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('stripe_payment_intent_id', models.CharField(max_length=255, unique=True)), + ('stripe_charge_id', models.CharField(blank=True, max_length=255)), + ('amount', models.DecimalField(decimal_places=2, max_digits=10)), + ('currency', models.CharField(default='usd', max_length=8)), + ('status', models.CharField(choices=[('requires_payment', 'Requires Payment'), ('processing', 'Processing'), ('succeeded', 'Succeeded'), ('failed', 'Failed'), ('refunded', 'Refunded')], default='requires_payment', max_length=32)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('booking', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='booking.booking')), + ], + options={ + 'ordering': ('-created_at',), + }, + ), + ] diff --git a/payment/migrations/__init__.py b/payment/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/payment/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/payment/models.py b/payment/models.py new file mode 100644 index 0000000..947b4ab --- /dev/null +++ b/payment/models.py @@ -0,0 +1,40 @@ +from django.db import models + + +class PaymentRecord(models.Model): + class Status(models.TextChoices): + REQUIRES_PAYMENT = "requires_payment", "Requires Payment" + PROCESSING = "processing", "Processing" + SUCCEEDED = "succeeded", "Succeeded" + FAILED = "failed", "Failed" + REFUNDED = "refunded", "Refunded" + + booking = models.ForeignKey("booking.Booking", on_delete=models.CASCADE, related_name="payments") + stripe_payment_intent_id = models.CharField(max_length=255, unique=True) + stripe_charge_id = models.CharField(max_length=255, blank=True) + amount = models.DecimalField(max_digits=10, decimal_places=2) + currency = models.CharField(max_length=8, default="usd") + status = models.CharField(max_length=32, choices=Status.choices, default=Status.REQUIRES_PAYMENT) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("-created_at",) + + def __str__(self) -> str: + return f"Payment {self.stripe_payment_intent_id} ({self.status})" + + +class WebhookEvent(models.Model): + stripe_event_id = models.CharField(max_length=255, unique=True) + event_type = models.CharField(max_length=120) + payload = models.JSONField(default=dict) + processed = models.BooleanField(default=False) + processed_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ("-created_at",) + + def __str__(self) -> str: + return f"{self.event_type} ({self.stripe_event_id})" diff --git a/payment/serializers.py b/payment/serializers.py new file mode 100644 index 0000000..0291444 --- /dev/null +++ b/payment/serializers.py @@ -0,0 +1,46 @@ +from rest_framework import serializers + +from .models import PaymentRecord, WebhookEvent + + +class PaymentRecordSerializer(serializers.ModelSerializer): + booking_id = serializers.IntegerField(source="booking.id", read_only=True) + + class Meta: + model = PaymentRecord + fields = ( + "id", + "booking_id", + "stripe_payment_intent_id", + "stripe_charge_id", + "amount", + "currency", + "status", + "created_at", + "updated_at", + ) + + +class CreatePaymentIntentSerializer(serializers.Serializer): + booking_id = serializers.IntegerField() + currency = serializers.CharField(required=False, default="usd", max_length=8) + + +class MockWebhookSerializer(serializers.Serializer): + stripe_event_id = serializers.CharField(max_length=255) + event_type = serializers.ChoiceField( + choices=( + "payment_intent.processing", + "payment_intent.succeeded", + "payment_intent.payment_failed", + "charge.refunded", + ) + ) + stripe_payment_intent_id = serializers.CharField(max_length=255) + payload = serializers.JSONField(required=False, default=dict) + + +class WebhookEventSerializer(serializers.ModelSerializer): + class Meta: + model = WebhookEvent + fields = ("id", "stripe_event_id", "event_type", "processed", "processed_at", "created_at") diff --git a/payment/services.py b/payment/services.py new file mode 100644 index 0000000..09fef45 --- /dev/null +++ b/payment/services.py @@ -0,0 +1,45 @@ +import uuid +from decimal import Decimal + +from .models import PaymentRecord + + +def _generate_id(prefix): + return f"{prefix}_{uuid.uuid4().hex[:24]}" + + +def create_mock_payment_intent(*, booking, amount: Decimal, currency: str = "usd"): + payment = PaymentRecord.objects.create( + booking=booking, + stripe_payment_intent_id=_generate_id("pi_mock"), + amount=amount, + currency=currency, + status=PaymentRecord.Status.REQUIRES_PAYMENT, + ) + client_secret = _generate_id("pi_secret_mock") + return payment, client_secret + + +def mark_payment_processing(payment): + payment.status = PaymentRecord.Status.PROCESSING + payment.save(update_fields=["status", "updated_at"]) + return payment + + +def mark_payment_succeeded(payment): + payment.status = PaymentRecord.Status.SUCCEEDED + payment.stripe_charge_id = _generate_id("ch_mock") + payment.save(update_fields=["status", "stripe_charge_id", "updated_at"]) + return payment + + +def mark_payment_failed(payment): + payment.status = PaymentRecord.Status.FAILED + payment.save(update_fields=["status", "updated_at"]) + return payment + + +def mark_payment_refunded(payment): + payment.status = PaymentRecord.Status.REFUNDED + payment.save(update_fields=["status", "updated_at"]) + return payment diff --git a/payment/tests.py b/payment/tests.py new file mode 100644 index 0000000..9c02b71 --- /dev/null +++ b/payment/tests.py @@ -0,0 +1,159 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.db import IntegrityError +from django.test import TestCase +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APITestCase + +from accounts.models import VendorProfile +from booking.models import Booking +from equipment.models import EquipmentCategory, EquipmentItem + +from .models import PaymentRecord, WebhookEvent + +User = get_user_model() + + +class PaymentModelTests(TestCase): + def setUp(self): + vendor_user = User.objects.create_user( + email="vendor-payment@example.com", + password="Pass123456!", + is_vendor=True, + is_customer=False, + ) + vendor = VendorProfile.objects.create(user=vendor_user, business_name="Payment Vendor") + customer = User.objects.create_user( + email="customer-payment@example.com", + password="Pass123456!", + is_vendor=False, + is_customer=True, + ) + category = EquipmentCategory.objects.create(name="Canoe", slug="canoe") + item = EquipmentItem.objects.create( + vendor=vendor, + category=category, + title="Canoe", + public_id="canoe-001", + price_per_day="45.00", + is_active=True, + ) + starts = timezone.now() + timedelta(days=3) + ends = starts + timedelta(days=1) + self.booking = Booking.objects.create( + customer=customer, + vendor=vendor, + equipment_item=item, + starts_at=starts, + ends_at=ends, + status=Booking.Status.APPROVED, + total_price="45.00", + ) + + def test_payment_idempotency_constraints(self): + PaymentRecord.objects.create( + booking=self.booking, + stripe_payment_intent_id="pi_mock_dup", + amount="45.00", + currency="usd", + ) + with self.assertRaises(IntegrityError): + PaymentRecord.objects.create( + booking=self.booking, + stripe_payment_intent_id="pi_mock_dup", + amount="45.00", + currency="usd", + ) + + WebhookEvent.objects.create( + stripe_event_id="evt_mock_dup", + event_type="payment_intent.succeeded", + payload={}, + ) + with self.assertRaises(IntegrityError): + WebhookEvent.objects.create( + stripe_event_id="evt_mock_dup", + event_type="payment_intent.succeeded", + payload={}, + ) + + +class PaymentApiTests(APITestCase): + def setUp(self): + self.vendor_user = User.objects.create_user( + email="vendor-api-payment@example.com", + password="Pass123456!", + is_vendor=True, + is_customer=False, + ) + self.vendor = VendorProfile.objects.create(user=self.vendor_user, business_name="Webhook Vendor") + self.customer = User.objects.create_user( + email="customer-api-payment@example.com", + password="Pass123456!", + is_vendor=False, + is_customer=True, + ) + category = EquipmentCategory.objects.create(name="Raft", slug="raft") + item = EquipmentItem.objects.create( + vendor=self.vendor, + category=category, + title="River Raft", + public_id="raft-001", + price_per_day="110.00", + is_active=True, + ) + starts = timezone.now() + timedelta(days=2) + ends = starts + timedelta(days=1) + self.booking = Booking.objects.create( + customer=self.customer, + vendor=self.vendor, + equipment_item=item, + starts_at=starts, + ends_at=ends, + status=Booking.Status.APPROVED, + total_price="110.00", + ) + + def test_mock_payment_intent_status_and_webhook_flow(self): + self.client.force_authenticate(self.customer) + create_res = self.client.post( + "/api/v1/payment/intents/", + {"booking_id": self.booking.id, "currency": "usd"}, + format="json", + ) + self.assertEqual(create_res.status_code, status.HTTP_201_CREATED) + payment_id = create_res.data["payment"]["id"] + intent_id = create_res.data["payment"]["stripe_payment_intent_id"] + + status_res = self.client.get(f"/api/v1/payment/{payment_id}/status/") + self.assertEqual(status_res.status_code, status.HTTP_200_OK) + self.assertEqual(status_res.data["status"], PaymentRecord.Status.REQUIRES_PAYMENT) + + webhook_res = self.client.post( + "/api/v1/payment/webhooks/stripe/", + { + "stripe_event_id": "evt_mock_1001", + "event_type": "payment_intent.succeeded", + "stripe_payment_intent_id": intent_id, + "payload": {"source": "test"}, + }, + format="json", + ) + self.assertEqual(webhook_res.status_code, status.HTTP_200_OK) + self.booking.refresh_from_db() + self.assertEqual(self.booking.status, Booking.Status.CONFIRMED) + + idempotent_res = self.client.post( + "/api/v1/payment/webhooks/stripe/", + { + "stripe_event_id": "evt_mock_1001", + "event_type": "payment_intent.succeeded", + "stripe_payment_intent_id": intent_id, + "payload": {}, + }, + format="json", + ) + self.assertEqual(idempotent_res.status_code, status.HTTP_200_OK) + self.assertTrue(idempotent_res.data["idempotent"]) diff --git a/payment/urls.py b/payment/urls.py new file mode 100644 index 0000000..3b09342 --- /dev/null +++ b/payment/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import CreateMockPaymentIntentView, MockStripeWebhookView, PaymentStatusView + +urlpatterns = [ + path("intents/", CreateMockPaymentIntentView.as_view(), name="payment_intent_create"), + path("/status/", PaymentStatusView.as_view(), name="payment_status"), + path("webhooks/stripe/", MockStripeWebhookView.as_view(), name="stripe_webhook_mock"), +] diff --git a/payment/views.py b/payment/views.py new file mode 100644 index 0000000..aeb90dc --- /dev/null +++ b/payment/views.py @@ -0,0 +1,137 @@ +from django.utils import timezone +from rest_framework import permissions, status +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rest_framework.views import APIView + +from booking.models import Booking +from booking.services import transition_booking_status + +from .models import PaymentRecord, WebhookEvent +from .serializers import ( + CreatePaymentIntentSerializer, + MockWebhookSerializer, + PaymentRecordSerializer, + WebhookEventSerializer, +) +from .services import ( + create_mock_payment_intent, + mark_payment_failed, + mark_payment_processing, + mark_payment_refunded, + mark_payment_succeeded, +) + + +class CreateMockPaymentIntentView(APIView): + permission_classes = (permissions.IsAuthenticated,) + + def post(self, request): + serializer = CreatePaymentIntentSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + booking = Booking.objects.select_related("vendor").filter(id=serializer.validated_data["booking_id"]).first() + if not booking: + return Response({"detail": "Booking not found."}, status=status.HTTP_404_NOT_FOUND) + + # Only the booking customer can initiate payment. + if booking.customer_id != request.user.id: + raise PermissionDenied("Only the booking customer can create payment intents.") + if booking.status != Booking.Status.APPROVED: + return Response({"detail": "Payment intent can only be created for approved bookings."}, status=400) + + existing = PaymentRecord.objects.filter(booking=booking).order_by("-created_at").first() + if existing and existing.status in [PaymentRecord.Status.REQUIRES_PAYMENT, PaymentRecord.Status.PROCESSING]: + return Response( + { + "detail": "An active payment already exists for this booking.", + "payment": PaymentRecordSerializer(existing).data, + "client_secret": f"reuse_{existing.stripe_payment_intent_id}", + "mocked": True, + }, + status=200, + ) + + payment, client_secret = create_mock_payment_intent( + booking=booking, + amount=booking.total_price, + currency=serializer.validated_data["currency"].lower(), + ) + return Response( + { + "payment": PaymentRecordSerializer(payment).data, + "client_secret": client_secret, + "mocked": True, + }, + status=201, + ) + + +class PaymentStatusView(APIView): + permission_classes = (permissions.IsAuthenticated,) + + def get(self, request, payment_id): + payment = PaymentRecord.objects.select_related("booking").filter(id=payment_id).first() + if not payment: + return Response({"detail": "Payment not found."}, status=404) + if payment.booking.customer_id != request.user.id and payment.booking.vendor.user_id != request.user.id: + raise PermissionDenied("You do not have access to this payment.") + return Response(PaymentRecordSerializer(payment).data) + + +class MockStripeWebhookView(APIView): + permission_classes = (permissions.AllowAny,) + + def post(self, request): + serializer = MockWebhookSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + event_id = serializer.validated_data["stripe_event_id"] + + existing_event = WebhookEvent.objects.filter(stripe_event_id=event_id).first() + if existing_event: + return Response( + { + "detail": "Event already processed.", + "event": WebhookEventSerializer(existing_event).data, + "idempotent": True, + } + ) + + event = WebhookEvent.objects.create( + stripe_event_id=event_id, + event_type=serializer.validated_data["event_type"], + payload=serializer.validated_data.get("payload", {}), + ) + + payment = PaymentRecord.objects.filter( + stripe_payment_intent_id=serializer.validated_data["stripe_payment_intent_id"] + ).select_related("booking").first() + if not payment: + return Response({"detail": "Payment intent not found for webhook event."}, status=404) + + if event.event_type == "payment_intent.processing": + mark_payment_processing(payment) + elif event.event_type == "payment_intent.succeeded": + mark_payment_succeeded(payment) + if payment.booking.status == Booking.Status.APPROVED: + transition_booking_status( + booking=payment.booking, + to_status=Booking.Status.CONFIRMED, + actor=None, + note="Auto-confirmed by mock payment success webhook.", + ) + elif event.event_type == "payment_intent.payment_failed": + mark_payment_failed(payment) + elif event.event_type == "charge.refunded": + mark_payment_refunded(payment) + + event.processed = True + event.processed_at = timezone.now() + event.save(update_fields=["processed", "processed_at"]) + return Response( + { + "detail": "Mock webhook processed.", + "event": WebhookEventSerializer(event).data, + "payment": PaymentRecordSerializer(payment).data, + "mocked": True, + } + ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..38f379b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "watertrek" +version = "0.1.0" +description = "WaterTrek Django backend service" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "django>=5.2.0", + "djangorestframework>=3.16.0", + "djangorestframework-simplejwt>=5.5.0", + "dj-database-url>=2.2.0", + "gunicorn>=23.0.0", + "psycopg[binary]>=3.2.0", + "pillow>=12.2.0", + "whitenoise>=6.9.0", +] diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100644 index 0000000..771faa7 --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +set -e + +uv run python manage.py migrate +uv run python manage.py collectstatic --noinput +exec "$@" diff --git a/scripts/makemigrations.sh b/scripts/makemigrations.sh new file mode 100755 index 0000000..eee0dab --- /dev/null +++ b/scripts/makemigrations.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +set -e + +sudo docker compose run --rm web uv run python manage.py makemigrations "$@" diff --git a/scripts/migrate.sh b/scripts/migrate.sh new file mode 100755 index 0000000..f08c89d --- /dev/null +++ b/scripts/migrate.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +set -e + +sudo docker compose run --rm web uv run python manage.py migrate diff --git a/scripts/run_dev.sh b/scripts/run_dev.sh new file mode 100755 index 0000000..1decec6 --- /dev/null +++ b/scripts/run_dev.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +set -e + +sudo docker compose down +sudo docker compose up --build diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..d580bb0 --- /dev/null +++ b/uv.lock @@ -0,0 +1,284 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + +[[package]] +name = "dj-database-url" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/f6/00b625e9d371b980aa261011d0dc906a16444cb688f94215e0dc86996eb5/dj_database_url-3.1.2.tar.gz", hash = "sha256:63c20e4bbaa51690dfd4c8d189521f6bf6bc9da9fcdb23d95d2ee8ee87f9ec62", size = 11490, upload-time = "2026-02-19T15:30:23.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/a9/57c66006373381f1d3e5bd94216f1d371228a89f443d3030e010f73dd198/dj_database_url-3.1.2-py3-none-any.whl", hash = "sha256:544e015fee3efa5127a1eb1cca465f4ace578265b3671fe61d0ed7dbafb5ec8a", size = 8953, upload-time = "2026-02-19T15:30:39.37Z" }, +] + +[[package]] +name = "django" +version = "6.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/b9/4155091ad1788b38563bd77a7258c0834e8c12a7f56f6975deaf54f8b61d/django-6.0.4.tar.gz", hash = "sha256:8cfa2572b3f2768b2e84983cf3c4811877a01edb64e817986ec5d60751c113ac", size = 10907407, upload-time = "2026-04-07T13:55:44.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/47/3d61d611609764aa71a37f7037b870e7bfb22937366974c4fd46cada7bab/django-6.0.4-py3-none-any.whl", hash = "sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da", size = 8368342, upload-time = "2026-04-07T13:55:37.999Z" }, +] + +[[package]] +name = "djangorestframework" +version = "3.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" }, +] + +[[package]] +name = "djangorestframework-simplejwt" +version = "5.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, + { name = "pyjwt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" }, +] + +[[package]] +name = "gunicorn" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" }, + { url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" }, + { url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" }, + { url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" }, + { url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" }, + { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" }, + { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" }, + { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" }, + { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" }, + { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" }, + { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" }, + { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" }, + { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, +] + +[[package]] +name = "watertrek" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "dj-database-url" }, + { name = "django" }, + { name = "djangorestframework" }, + { name = "djangorestframework-simplejwt" }, + { name = "gunicorn" }, + { name = "pillow" }, + { name = "psycopg", extra = ["binary"] }, + { name = "whitenoise" }, +] + +[package.metadata] +requires-dist = [ + { name = "dj-database-url", specifier = ">=2.2.0" }, + { name = "django", specifier = ">=5.2.0" }, + { name = "djangorestframework", specifier = ">=3.16.0" }, + { name = "djangorestframework-simplejwt", specifier = ">=5.5.0" }, + { name = "gunicorn", specifier = ">=23.0.0" }, + { name = "pillow", specifier = ">=12.2.0" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" }, + { name = "whitenoise", specifier = ">=6.9.0" }, +] + +[[package]] +name = "whitenoise" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" }, +]