inital commit
This commit is contained in:
4
.env.example
Normal file
4
.env.example
Normal file
@@ -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
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -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"]
|
||||
47
README.md
47
README.md
@@ -1,3 +1,46 @@
|
||||
# booking_backend
|
||||
# WaterTrek Backend
|
||||
|
||||
backend for the booking platform
|
||||
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
|
||||
```
|
||||
1
WaterTrek/__init__.py
Normal file
1
WaterTrek/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
11
WaterTrek/asgi.py
Normal file
11
WaterTrek/asgi.py
Normal file
@@ -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()
|
||||
95
WaterTrek/settings.py
Normal file
95
WaterTrek/settings.py
Normal file
@@ -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",
|
||||
),
|
||||
}
|
||||
14
WaterTrek/urls.py
Normal file
14
WaterTrek/urls.py
Normal file
@@ -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")),
|
||||
]
|
||||
11
WaterTrek/wsgi.py
Normal file
11
WaterTrek/wsgi.py
Normal file
@@ -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()
|
||||
1
accounts/__init__.py
Normal file
1
accounts/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
50
accounts/admin.py
Normal file
50
accounts/admin.py
Normal file
@@ -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")
|
||||
6
accounts/apps.py
Normal file
6
accounts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "accounts"
|
||||
79
accounts/migrations/0001_initial.py
Normal file
79
accounts/migrations/0001_initial.py
Normal file
@@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
20
accounts/migrations/0002_alter_user_managers.py
Normal file
20
accounts/migrations/0002_alter_user_managers.py
Normal file
@@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
1
accounts/migrations/__init__.py
Normal file
1
accounts/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
81
accounts/models.py
Normal file
81
accounts/models.py
Normal file
@@ -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}"
|
||||
91
accounts/serializers.py
Normal file
91
accounts/serializers.py
Normal file
@@ -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",
|
||||
)
|
||||
65
accounts/tests.py
Normal file
65
accounts/tests.py
Normal file
@@ -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)
|
||||
12
accounts/urls.py
Normal file
12
accounts/urls.py
Normal file
@@ -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"),
|
||||
]
|
||||
36
accounts/views.py
Normal file
36
accounts/views.py
Normal file
@@ -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)
|
||||
1
adventrues/__init__.py
Normal file
1
adventrues/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
32
adventrues/admin.py
Normal file
32
adventrues/admin.py
Normal file
@@ -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]
|
||||
6
adventrues/apps.py
Normal file
6
adventrues/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AdventruesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "adventrues"
|
||||
60
adventrues/migrations/0001_initial.py
Normal file
60
adventrues/migrations/0001_initial.py
Normal file
@@ -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'),
|
||||
},
|
||||
),
|
||||
]
|
||||
1
adventrues/migrations/__init__.py
Normal file
1
adventrues/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
45
adventrues/models.py
Normal file
45
adventrues/models.py
Normal file
@@ -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}"
|
||||
59
adventrues/serializers.py
Normal file
59
adventrues/serializers.py
Normal file
@@ -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")
|
||||
104
adventrues/tests.py
Normal file
104
adventrues/tests.py
Normal file
@@ -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)
|
||||
19
adventrues/urls.py
Normal file
19
adventrues/urls.py
Normal file
@@ -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/<str:public_id>/", PublicAdventureDetailView.as_view(), name="adventure_public_detail"),
|
||||
path("storefront/<slug:slug>/", VendorAdventureStorefrontView.as_view(), name="adventure_vendor_storefront"),
|
||||
]
|
||||
117
adventrues/views.py
Normal file
117
adventrues/views.py
Normal file
@@ -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,
|
||||
}
|
||||
)
|
||||
451
api.md
Normal file
451
api.md
Normal file
@@ -0,0 +1,451 @@
|
||||
# WaterTrek API (Frontend Handoff)
|
||||
|
||||
Base URL: `/api/v1/`
|
||||
Auth: JWT Bearer token in `Authorization: Bearer <access_token>`
|
||||
|
||||
## 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": "<refresh_token>",
|
||||
"access": "<access_token>"
|
||||
}
|
||||
```
|
||||
|
||||
### Refresh JWT
|
||||
- `POST /api/v1/accounts/token/refresh/`
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"refresh": "<refresh_token>"
|
||||
}
|
||||
```
|
||||
|
||||
### 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/`.
|
||||
1
booking/__init__.py
Normal file
1
booking/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
79
booking/admin.py
Normal file
79
booking/admin.py
Normal file
@@ -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")
|
||||
|
||||
6
booking/apps.py
Normal file
6
booking/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BookingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "booking"
|
||||
81
booking/migrations/0001_initial.py
Normal file
81
booking/migrations/0001_initial.py
Normal file
@@ -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'),
|
||||
),
|
||||
]
|
||||
20
booking/migrations/0002_booking_listing_click.py
Normal file
20
booking/migrations/0002_booking_listing_click.py
Normal file
@@ -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'),
|
||||
),
|
||||
]
|
||||
1
booking/migrations/__init__.py
Normal file
1
booking/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
104
booking/models.py
Normal file
104
booking/models.py
Normal file
@@ -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}"
|
||||
114
booking/serializers.py
Normal file
114
booking/serializers.py
Normal file
@@ -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
|
||||
159
booking/services.py
Normal file
159
booking/services.py
Normal file
@@ -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
|
||||
256
booking/tests.py
Normal file
256
booking/tests.py
Normal file
@@ -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)
|
||||
23
booking/urls.py
Normal file
23
booking/urls.py
Normal file
@@ -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/<int:pk>/", BookingDetailView.as_view(), name="booking_detail"),
|
||||
path("bookings/<int:pk>/approve/", VendorApproveBookingView.as_view(), name="booking_approve"),
|
||||
path("bookings/<int:pk>/decline/", VendorDeclineBookingView.as_view(), name="booking_decline"),
|
||||
path("bookings/<int:pk>/cancel/", CustomerCancelBookingView.as_view(), name="booking_cancel"),
|
||||
]
|
||||
169
booking/views.py
Normal file
169
booking/views.py
Normal file
@@ -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)
|
||||
37
docker-compose.yml
Normal file
37
docker-compose.yml
Normal file
@@ -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:
|
||||
1
equipment/__init__.py
Normal file
1
equipment/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
23
equipment/admin.py
Normal file
23
equipment/admin.py
Normal file
@@ -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]
|
||||
6
equipment/apps.py
Normal file
6
equipment/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class EquipmentConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "equipment"
|
||||
60
equipment/migrations/0001_initial.py
Normal file
60
equipment/migrations/0001_initial.py
Normal file
@@ -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'),
|
||||
},
|
||||
),
|
||||
]
|
||||
1
equipment/migrations/__init__.py
Normal file
1
equipment/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
45
equipment/models.py
Normal file
45
equipment/models.py
Normal file
@@ -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}"
|
||||
58
equipment/serializers.py
Normal file
58
equipment/serializers.py
Normal file
@@ -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")
|
||||
111
equipment/tests.py
Normal file
111
equipment/tests.py
Normal file
@@ -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)
|
||||
14
equipment/urls.py
Normal file
14
equipment/urls.py
Normal file
@@ -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/<str:public_id>/", PublicEquipmentDetailView.as_view(), name="equipment_public_detail"),
|
||||
path("storefront/<slug:slug>/", VendorStorefrontView.as_view(), name="vendor_storefront"),
|
||||
]
|
||||
118
equipment/views.py
Normal file
118
equipment/views.py
Normal file
@@ -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,
|
||||
}
|
||||
)
|
||||
240
implementation.md
Normal file
240
implementation.md
Normal file
@@ -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
|
||||
22
manage.py
Normal file
22
manage.py
Normal file
@@ -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()
|
||||
0
marketing/__init__.py
Normal file
0
marketing/__init__.py
Normal file
11
marketing/admin.py
Normal file
11
marketing/admin.py
Normal file
@@ -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")
|
||||
7
marketing/apps.py
Normal file
7
marketing/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MarketingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "marketing"
|
||||
verbose_name = "Marketing"
|
||||
46
marketing/migrations/0001_initial.py
Normal file
46
marketing/migrations/0001_initial.py
Normal file
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
marketing/migrations/__init__.py
Normal file
0
marketing/migrations/__init__.py
Normal file
25
marketing/mixins.py
Normal file
25
marketing/mixins.py
Normal file
@@ -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)
|
||||
67
marketing/models.py
Normal file
67
marketing/models.py
Normal file
@@ -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})"
|
||||
55
marketing/serializers.py
Normal file
55
marketing/serializers.py
Normal file
@@ -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
|
||||
182
marketing/services.py
Normal file
182
marketing/services.py
Normal file
@@ -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
|
||||
91
marketing/tests.py
Normal file
91
marketing/tests.py
Normal file
@@ -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)
|
||||
9
marketing/urls.py
Normal file
9
marketing/urls.py
Normal file
@@ -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"),
|
||||
]
|
||||
204
marketing/views.py
Normal file
204
marketing/views.py
Normal file
@@ -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,
|
||||
}
|
||||
)
|
||||
1
payment/__init__.py
Normal file
1
payment/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
19
payment/admin.py
Normal file
19
payment/admin.py
Normal file
@@ -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",)
|
||||
6
payment/apps.py
Normal file
6
payment/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PaymentConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "payment"
|
||||
48
payment/migrations/0001_initial.py
Normal file
48
payment/migrations/0001_initial.py
Normal file
@@ -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',),
|
||||
},
|
||||
),
|
||||
]
|
||||
1
payment/migrations/__init__.py
Normal file
1
payment/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
40
payment/models.py
Normal file
40
payment/models.py
Normal file
@@ -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})"
|
||||
46
payment/serializers.py
Normal file
46
payment/serializers.py
Normal file
@@ -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")
|
||||
45
payment/services.py
Normal file
45
payment/services.py
Normal file
@@ -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
|
||||
159
payment/tests.py
Normal file
159
payment/tests.py
Normal file
@@ -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"])
|
||||
9
payment/urls.py
Normal file
9
payment/urls.py
Normal file
@@ -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("<int:payment_id>/status/", PaymentStatusView.as_view(), name="payment_status"),
|
||||
path("webhooks/stripe/", MockStripeWebhookView.as_view(), name="stripe_webhook_mock"),
|
||||
]
|
||||
137
payment/views.py
Normal file
137
payment/views.py
Normal file
@@ -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,
|
||||
}
|
||||
)
|
||||
16
pyproject.toml
Normal file
16
pyproject.toml
Normal file
@@ -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",
|
||||
]
|
||||
6
scripts/entrypoint.sh
Normal file
6
scripts/entrypoint.sh
Normal file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
uv run python manage.py migrate
|
||||
uv run python manage.py collectstatic --noinput
|
||||
exec "$@"
|
||||
4
scripts/makemigrations.sh
Executable file
4
scripts/makemigrations.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
sudo docker compose run --rm web uv run python manage.py makemigrations "$@"
|
||||
4
scripts/migrate.sh
Executable file
4
scripts/migrate.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
sudo docker compose run --rm web uv run python manage.py migrate
|
||||
5
scripts/run_dev.sh
Executable file
5
scripts/run_dev.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
sudo docker compose down
|
||||
sudo docker compose up --build
|
||||
284
uv.lock
generated
Normal file
284
uv.lock
generated
Normal file
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user