diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f333baa --- /dev/null +++ b/.env.example @@ -0,0 +1,85 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Python Togo API v2.1.0 — Configuration +# Copier ce fichier vers .env et remplir les valeurs +# cp .env.example .env +# ───────────────────────────────────────────────────────────────────────────── + +# ── Application ─────────────────────────────────────────────────────────────── +ENV=development # development | production +APP_NAME=Python Togo API V2.1.0 +DEBUG=False +LOG_LEVEL=info + +# ── Supabase / PostgreSQL ───────────────────────────────────────────────────── +# +# Récupérer ces infos dans : Supabase Dashboard → Settings → Database +# +# Option 1 — Connexion directe (recommandé pour FastAPI avec pool persistant) +# Format : postgresql://postgres:[PASSWORD]@db.[PROJECT-REF].supabase.co:5432/postgres +# +# Option 2 — Transaction Pooler Supabase (port 6543, compatible préparation désactivée) +# Format : postgresql://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres +# +# Option 3 — Session Pooler Supabase (port 5432) +# Format : postgresql://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:5432/postgres +# +DB_URL=postgresql://postgres:[PASSWORD]@db.[PROJECT-REF].supabase.co:5432/postgres + +# Paramètres de connexion séparés (utilisés par migrations.py) +DB_NAME=postgres +DB_USER=postgres +DB_PASSWORD=your_db_password +DB_HOST=db.[PROJECT-REF].supabase.co +DB_PORT=5432 + +# SSL — Supabase exige SSL en production (require | disable | prefer) +# Laisser à "require" pour Supabase, mettre "disable" pour Supabase local (localhost) +DB_SSL_MODE=require + +# Pool de connexions +DB_POOL_MIN_SIZE=1 # Minimum de connexions maintenues ouvertes +DB_POOL_MAX_SIZE=5 # Maximum (Supabase free tier = 60 connexions max totales) +DB_POOL_TIMEOUT=10 # Secondes avant timeout si toutes les connexions sont occupées + +# ── Redis (Upstash recommandé avec Supabase) ────────────────────────────────── +# +# Upstash Redis (serverless Redis, compatible avec Supabase) : +# Format : rediss://:[PASSWORD]@[ENDPOINT].upstash.io:6379 +# +# Redis local : +# Format : redis://localhost:6379/0 +# +REDIS_URL=redis://localhost:6379/2 + +# ── JWT / Sécurité ──────────────────────────────────────────────────────────── +SECRET_KEY=change_this_to_a_very_long_random_secret_key_in_production +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# ── Super Admin (créé automatiquement au démarrage via le seed) ─────────────── +SUPERADMIN_EMAIL=superadmin@pytogo.org +SUPERADMIN_USERNAME=superadmin +SUPERADMIN_PASSWORD=ChangeMe!2025 +SUPERADMIN_FULL_NAME=Super Admin + +# ── Email SMTP ──────────────────────────────────────────────────────────────── +# +# Exemples de fournisseurs compatibles : +# Gmail : smtp.gmail.com:587 (activer "App Password" dans les paramètres Google) +# Resend : smtp.resend.com:465 +# Brevo : smtp-relay.brevo.com:587 +# Mailgun : smtp.mailgun.org:587 +# Postmark : smtp.postmarkapp.com:587 +# +SMTP_SERVER=smtp.example.com +SMTP_PORT=587 +SMTP_USER=your_smtp_username +SMTP_PASSWORD=your_smtp_password +SMTP_FROM_EMAIL=no-reply@pytogo.org +SMTP_FROM_NAME=Python Togo + +# ── Cloudinary (upload photos speakers) ────────────────────────────────────── +CLOUDINARY_CLOUD_NAME= +CLOUDINARY_API_KEY= +CLOUDINARY_API_SECRET= +CLOUDINARY_FOLDER=pythontogo diff --git a/SHOP_2026.md b/SHOP_2026.md new file mode 100644 index 0000000..c1525e8 --- /dev/null +++ b/SHOP_2026.md @@ -0,0 +1,152 @@ +# Python Togo — Shop Module 2026 +**Statut :** En cours de développement +**Année :** 2026 +**Responsable :** Python Software Community Togo + +--- + +## Décisions architecturales actées + +| Décision | Valeur | +|---|---| +| Produits avec variantes | **Oui** (taille, couleur, etc.) | +| `event_id` sur les produits | **Obligatoire** | +| Image produit | Champ `image_url` (URL externe) | +| Slug produit | Auto-généré, unique | +| Auth requise | JWT (`role = admin / staff`) | + +--- + +## Schéma de données + +``` +events ──< products ──< product_variants + ──< orders ──< order_items + ──< coupons + +categories ──< products (global, sans event_id) +users ──< orders +coupons ──< orders (optionnel) +payments ──< orders +``` + +--- + +## Tables à créer + +### `categories` +```sql +id, name, slug, description, parent_id (nullable), is_active, created_at, updated_at +``` + +### `products` +```sql +id, event_id (FK NOT NULL), category_id (FK), name, slug (UNIQUE), +description, image_url, base_price, is_active, created_at, updated_at +``` + +### `product_variants` +```sql +id, product_id (FK), name, sku (UNIQUE), price_override (nullable), +stock_quantity, attributes (JSONB), is_active, created_at, updated_at +``` +> `attributes` ex: `{"size": "L", "color": "blue"}` + +### `orders` +```sql +id, event_id (FK NOT NULL), user_id (FK NOT NULL), coupon_id (FK nullable), +status (enum), total_amount, discount_amount, shipping_address (JSONB), +created_at, updated_at +``` +> Status enum : `pending / paid / shipped / delivered / cancelled` + +### `order_items` +```sql +id, order_id (FK), product_variant_id (FK), quantity, unit_price, created_at +``` + +### `payments` +```sql +id, order_id (FK UNIQUE), amount, status (enum), method, reference, created_at, updated_at +``` +> Status enum : `pending / succeeded / failed / refunded` + +### `coupons` +```sql +id, event_id (FK nullable), code (UNIQUE), type (enum: percentage / fixed_amount), +value, max_uses, uses_count, expires_at, is_active, created_at, updated_at +``` + +--- + +## Endpoints Admin — `/api/v2/admin/shop` + +### Dashboard +| Méthode | Route | Description | +|---|---|---| +| `GET` | `/dashboard` | Stats globales (filtrables par event) | + +### Catégories +| Méthode | Route | Description | +|---|---|---| +| `GET` | `/categories` | Lister toutes | +| `POST` | `/categories` | Créer | +| `PUT` | `/categories/{id}` | Modifier | +| `DELETE` | `/categories/{id}` | Supprimer | + +### Produits +| Méthode | Route | Description | +|---|---|---| +| `GET` | `/products` | Lister (filtrable par event, catégorie, statut) | +| `POST` | `/products` | Créer | +| `GET` | `/products/{id}` | Détail | +| `PUT` | `/products/{id}` | Modifier | +| `DELETE` | `/products/{id}` | Supprimer | +| `PATCH` | `/products/{id}/toggle` | Activer / désactiver | +| `GET` | `/products/{id}/variants` | Lister variantes | +| `POST` | `/products/{id}/variants` | Ajouter variante | +| `PUT` | `/products/{id}/variants/{vid}` | Modifier variante | +| `DELETE` | `/products/{id}/variants/{vid}` | Supprimer variante | + +### Commandes +| Méthode | Route | Description | +|---|---|---| +| `GET` | `/orders` | Lister (filtrable par event, statut) | +| `GET` | `/orders/{id}` | Détail commande | +| `PATCH` | `/orders/{id}/status` | Mettre à jour statut | + +### Clients +| Méthode | Route | Description | +|---|---|---| +| `GET` | `/customers` | Lister les clients | +| `GET` | `/customers/{id}` | Détail client | +| `GET` | `/customers/{id}/orders` | Commandes d'un client | +| `PATCH` | `/customers/{id}/toggle` | Bloquer / débloquer | + +### Paiements +| Méthode | Route | Description | +|---|---|---| +| `GET` | `/payments` | Historique paiements | +| `GET` | `/payments/{id}` | Détail paiement | + +### Coupons +| Méthode | Route | Description | +|---|---|---| +| `GET` | `/coupons` | Lister | +| `POST` | `/coupons` | Créer | +| `PUT` | `/coupons/{id}` | Modifier | +| `DELETE` | `/coupons/{id}` | Supprimer | + +--- + +## Ordre d'implémentation + +- [ ] 1. Migrations (tables + enums) +- [ ] 2. Schemas Pydantic +- [ ] 3. Categories CRUD +- [ ] 4. Products + Variants CRUD +- [ ] 5. Orders (listing + statut) +- [ ] 6. Customers +- [ ] 7. Payments +- [ ] 8. Coupons +- [ ] 9. Dashboard (agrégations SQL) diff --git a/app/core/cloudinary.py b/app/core/cloudinary.py new file mode 100644 index 0000000..8796c18 --- /dev/null +++ b/app/core/cloudinary.py @@ -0,0 +1,48 @@ +import cloudinary +import cloudinary.uploader +from fastapi import HTTPException, UploadFile + +from app.core.settings import settings + +cloudinary.config( + cloud_name=settings.cloudinary_cloud_name, + api_key=settings.cloudinary_api_key, + api_secret=settings.cloudinary_api_secret, + secure=True, +) + +ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/svg+xml"} +MAX_SIZE_BYTES = 5 * 1024 * 1024 # 5 MB + + +def _subfolder(sub: str) -> str: + return f"{settings.cloudinary_folder}/{sub}" + + +async def upload_image(file: UploadFile, subfolder: str, public_id: str | None = None) -> str: + """Upload an image to Cloudinary and return the secure URL.""" + if file.content_type not in ALLOWED_TYPES: + raise HTTPException( + status_code=400, + detail=f"Unsupported file type '{file.content_type}'. Allowed: JPEG, PNG, WEBP, SVG", + ) + + contents = await file.read() + if len(contents) > MAX_SIZE_BYTES: + raise HTTPException(status_code=400, detail="File exceeds the 5 MB size limit") + + options: dict = { + "folder": _subfolder(subfolder), + "overwrite": True, + "resource_type": "image", + } + if public_id: + options["public_id"] = public_id + + result = cloudinary.uploader.upload(contents, **options) + return result["secure_url"] + + +async def delete_image(public_id: str) -> None: + """Delete an image from Cloudinary by its public_id.""" + cloudinary.uploader.destroy(public_id, resource_type="image") diff --git a/app/core/security.py b/app/core/security.py index c56c43a..a2ecb89 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -1,46 +1,239 @@ from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPBearer, HTTPBasicCredentials -from typing import Annotated +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from typing import Annotated, Callable from app.database.connection import get_db_connection, get_redis_client from app.database.orm import select from json import dumps, loads -from app.schemas.models import APIKeyResponse, APIKeyVerificationResponse +from app.schemas.models import ( + APIKeyResponse, + APIKeyVerificationResponse, + AuthenticatedUser, + TokenData, + UserSummary, + UserRole, +) +from app.core.settings import settings +from passlib.context import CryptContext +from datetime import datetime, timedelta, timezone +import jwt +from psycopg.rows import dict_row security = HTTPBearer() +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(data: dict) -> str: + """Build an access JWT. + + Expected keys in `data`: + - sub : user UUID (str) + - email : user email + - role : legacy UserRole enum value + - is_admin : bool — True if user has RBAC roles + - permissions : list[str] — e.g. ["users:read", "events:create"] + """ + payload = data.copy() + payload["exp"] = datetime.now(timezone.utc) + timedelta( + minutes=settings.access_token_expire_minutes + ) + payload["type"] = "access" + return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) + + +def create_refresh_token(data: dict) -> str: + payload = data.copy() + payload["exp"] = datetime.now(timezone.utc) + timedelta(days=7) + payload["type"] = "refresh" + return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) + + +def decode_token(token: str) -> dict: + try: + return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") + except jwt.InvalidTokenError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + + +def extract_token_payload(token: str) -> dict: + """Public helper to inspect JWT claims without raising HTTP errors. + + Returns the decoded payload dict or an empty dict on failure. + """ + try: + return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + except Exception: + return {} + + +async def get_user_permissions_from_db(user_id: str, db) -> list[str]: + """Fetch all permission names for a user via their assigned roles. + + Called at login/refresh time to embed permissions in the JWT. + Not called on every authenticated request — JWT is the source of truth. + """ + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute( + """ + SELECT DISTINCT p.name + FROM user_roles ur + JOIN role_permissions rp ON rp.role_id = ur.role_id + JOIN permissions p ON p.id = rp.permission_id + WHERE ur.user_id = %s + ORDER BY p.name + """, + (user_id,), + ) + rows = await cur.fetchall() + return [row["name"] for row in rows] + + +async def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)], + db=Depends(get_db_connection), +) -> AuthenticatedUser: + """Decode JWT, verify the user is still active, return AuthenticatedUser. + + Permissions and is_admin come directly from the JWT — no extra DB query. + """ + payload = decode_token(credentials.credentials) + if payload.get("type") != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type" + ) + user_id = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" + ) + + rows = await select(db, "users", filter={"id": user_id}) + if not rows: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" + ) + user = rows[0] + if not user["is_active"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" + ) + + permissions: list[str] = payload.get("permissions", []) + is_admin: bool = payload.get("is_admin", False) + + return AuthenticatedUser( + **user, + permissions=permissions, + is_admin=is_admin, + ) + + +# --------------------------------------------------------------------------- +# Authorization helpers +# --------------------------------------------------------------------------- + +async def require_admin( + current_user: Annotated[AuthenticatedUser, Depends(get_current_user)], +) -> AuthenticatedUser: + """Backward-compatible guard: user must be admin or staff (legacy role).""" + if current_user.role not in (UserRole.ADMIN, UserRole.STAFF): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required" + ) + return current_user -def generate_api_key(): - from nanoid import generate - genrated_key = generate( - alphabet="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", size=40) - api_key = f"PYTOGO_SK_{genrated_key}" - return api_key +def require_permission(permission: str) -> Callable: + """Dependency factory for granular permission checks. + Usage:: -async def verify_api_key(credentials: Annotated[HTTPBasicCredentials, Depends(security)], db=Depends(get_db_connection), redis=Depends(get_redis_client)): + @router.post("/something") + async def endpoint(_=Depends(require_permission("events:create"))): + ... + The check requires **both**: + 1. ``is_admin = True`` in the JWT (user has at least one RBAC role) + 2. The requested permission is present in ``JWT.permissions`` + """ + async def _check( + current_user: Annotated[AuthenticatedUser, Depends(get_current_user)], + ) -> AuthenticatedUser: + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required", + ) + if permission not in current_user.permissions: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing permission: {permission}", + ) + return current_user + + return _check + + +# --------------------------------------------------------------------------- +# API key helpers +# --------------------------------------------------------------------------- + +def generate_api_key(): + from nanoid import generate + generated_key = generate( + alphabet="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + size=40, + ) + return f"PYTOGO_SK_{generated_key}" + + +async def verify_api_key( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)], + db=Depends(get_db_connection), + redis=Depends(get_redis_client), +): api_key_value = credentials.credentials if not api_key_value.startswith("PYTOGO_SK_") or len(api_key_value) != 50: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key format") + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key format", + ) + expected_api_key = await redis.get(f"PYTOGO_API_KEY:{credentials.credentials}") if not expected_api_key: - expected_api_key = await select(db, "api_keys", filter={"key_value": credentials.credentials}) + expected_api_key = await select( + db, "api_keys", filter={"key_value": credentials.credentials} + ) if not expected_api_key: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="API key not found") + status_code=status.HTTP_401_UNAUTHORIZED, detail="API key not found" + ) expected_api_key = expected_api_key[0] - # Cache for 1 hour api_key_data = { "name": expected_api_key["name"], "key_value": expected_api_key["key_value"], } - await redis.set(f"PYTOGO_API_KEY:{credentials.credentials}", dumps(api_key_data), ex=3600) + await redis.set( + f"PYTOGO_API_KEY:{credentials.credentials}", dumps(api_key_data), ex=3600 + ) - expected_api_key_data = loads(expected_api_key) if isinstance( - expected_api_key, bytes) else expected_api_key + expected_api_key_data = ( + loads(expected_api_key) + if isinstance(expected_api_key, bytes) + else expected_api_key + ) if expected_api_key_data["key_value"] != credentials.credentials: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key" + ) return APIKeyVerificationResponse(is_valid=True, message="API key is valid") diff --git a/app/core/settings.py b/app/core/settings.py index 969545d..b9cd6f9 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -26,5 +26,19 @@ smtp_server=config("SMTP_SERVER", default="smtp.example.com"), smtp_port=config("SMTP_PORT", default=587, cast=int), smtp_user=config("SMTP_USER", default="user"), - smtp_password=config("SMTP_PASSWORD", default="password") + smtp_password=config("SMTP_PASSWORD", default="password"), + smtp_from_email=config("SMTP_FROM_EMAIL", default="no-reply@example.com"), + smtp_from_name=config("SMTP_FROM_NAME", default="Python Togo"), + db_pool_min_size=config("DB_POOL_MIN_SIZE", default=1, cast=int), + db_pool_max_size=config("DB_POOL_MAX_SIZE", default=5, cast=int), + db_pool_timeout=config("DB_POOL_TIMEOUT", default=10, cast=int), + db_ssl_mode=config("DB_SSL_MODE", default="require"), + cloudinary_cloud_name=config("CLOUDINARY_CLOUD_NAME", default=""), + cloudinary_api_key=config("CLOUDINARY_API_KEY", default=""), + cloudinary_api_secret=config("CLOUDINARY_API_SECRET", default=""), + cloudinary_folder=config("CLOUDINARY_FOLDER", default="pythontogo"), + superadmin_email=config("SUPERADMIN_EMAIL", default="superadmin@pytogo.org"), + superadmin_username=config("SUPERADMIN_USERNAME", default="superadmin"), + superadmin_password=config("SUPERADMIN_PASSWORD", default="ChangeMe!2025"), + superadmin_full_name=config("SUPERADMIN_FULL_NAME", default="Super Admin"), ) diff --git a/app/database/migrations.py b/app/database/migrations.py index 9d25b14..aab2411 100644 --- a/app/database/migrations.py +++ b/app/database/migrations.py @@ -86,6 +86,24 @@ 'manual_correction' ); END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_role_enum') THEN + CREATE TYPE user_role_enum AS ENUM ('admin', 'member', 'staff'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'order_status_enum') THEN + CREATE TYPE order_status_enum AS ENUM ( + 'pending', + 'paid', + 'shipped', + 'delivered', + 'cancelled' + ); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'coupon_type_enum') THEN + CREATE TYPE coupon_type_enum AS ENUM ('percentage', 'fixed_amount'); + END IF; END $$; """ @@ -259,6 +277,20 @@ ON DELETE SET NULL );""", + """ + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(100) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + full_name VARCHAR(255), + password_hash TEXT NOT NULL, + role user_role_enum NOT NULL DEFAULT 'member', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + """, + """ CREATE TABLE IF NOT EXISTS sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -292,17 +324,307 @@ ON DELETE SET NULL, CHECK (ends_at > starts_at) );""", + + """ + CREATE TABLE IF NOT EXISTS registrations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL, + user_id UUID, + full_name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + phone VARCHAR(40), + organization VARCHAR(255), + ticket_type VARCHAR(100) NOT NULL DEFAULT 'general', + status registration_status_enum NOT NULL DEFAULT 'pending', + checked_in_at TIMESTAMPTZ, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_registrations_event + FOREIGN KEY (event_id) + REFERENCES events(id) + ON DELETE CASCADE, + CONSTRAINT fk_registrations_user + FOREIGN KEY (user_id) + REFERENCES users(id) + ON DELETE SET NULL, + CONSTRAINT uq_registrations_event_email UNIQUE (event_id, email) + ); + """, + + """ + CREATE TABLE IF NOT EXISTS sponsor_packages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL, + name VARCHAR(255) NOT NULL, + tier package_tier_enum NOT NULL, + description TEXT, + price NUMERIC(10, 2) NOT NULL DEFAULT 0, + benefits JSONB DEFAULT '[]', + max_slots INT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_sponsor_packages_event + FOREIGN KEY (event_id) + REFERENCES events(id) + ON DELETE CASCADE, + CONSTRAINT uq_sponsor_packages_event_tier UNIQUE (event_id, tier) + ); + """, + + """ + CREATE TABLE IF NOT EXISTS permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + resource VARCHAR(50) NOT NULL, + action VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_permissions_resource_action UNIQUE (resource, action) + ); + """, + + """ + CREATE TABLE IF NOT EXISTS roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + """, + + """ + CREATE TABLE IF NOT EXISTS role_permissions ( + role_id UUID NOT NULL, + permission_id UUID NOT NULL, + PRIMARY KEY (role_id, permission_id), + CONSTRAINT fk_rp_role + FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, + CONSTRAINT fk_rp_permission + FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE + ); + """, + + """ + CREATE TABLE IF NOT EXISTS user_roles ( + user_id UUID NOT NULL, + role_id UUID NOT NULL, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + assigned_by UUID, + PRIMARY KEY (user_id, role_id), + CONSTRAINT fk_ur_user + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_ur_role + FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, + CONSTRAINT fk_ur_assigned_by + FOREIGN KEY (assigned_by) REFERENCES users(id) ON DELETE SET NULL + ); + """, + + """ + CREATE TABLE IF NOT EXISTS talk_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + proposal_id UUID NOT NULL, + reviewer_id UUID NOT NULL, + score SMALLINT NOT NULL CHECK (score BETWEEN 1 AND 5), + comment TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_tr_proposal + FOREIGN KEY (proposal_id) REFERENCES proposals(id) ON DELETE CASCADE, + CONSTRAINT fk_tr_reviewer + FOREIGN KEY (reviewer_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT uq_tr_proposal_reviewer UNIQUE (proposal_id, reviewer_id) + ); + """, + + """ + CREATE TABLE IF NOT EXISTS categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + parent_id UUID, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_categories_parent + FOREIGN KEY (parent_id) + REFERENCES categories(id) + ON DELETE SET NULL + ); + """, + + """ + CREATE TABLE IF NOT EXISTS products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL, + category_id UUID, + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + image_url TEXT, + base_price NUMERIC(10, 2) NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_products_event + FOREIGN KEY (event_id) + REFERENCES events(id) + ON DELETE CASCADE, + CONSTRAINT fk_products_category + FOREIGN KEY (category_id) + REFERENCES categories(id) + ON DELETE SET NULL + ); + """, + + """ + CREATE TABLE IF NOT EXISTS product_variants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + product_id UUID NOT NULL, + name VARCHAR(255) NOT NULL, + sku VARCHAR(100) NOT NULL UNIQUE, + price_override NUMERIC(10, 2), + stock_quantity INT NOT NULL DEFAULT 0, + attributes JSONB DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_variants_product + FOREIGN KEY (product_id) + REFERENCES products(id) + ON DELETE CASCADE + ); + """, + + """ + CREATE TABLE IF NOT EXISTS coupons ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID, + code VARCHAR(50) NOT NULL UNIQUE, + type coupon_type_enum NOT NULL, + value NUMERIC(10, 2) NOT NULL, + max_uses INT, + uses_count INT NOT NULL DEFAULT 0, + expires_at TIMESTAMPTZ, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_coupons_event + FOREIGN KEY (event_id) + REFERENCES events(id) + ON DELETE SET NULL + ); + """, + + """ + CREATE TABLE IF NOT EXISTS shop_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL, + user_id UUID NOT NULL, + coupon_id UUID, + status order_status_enum NOT NULL DEFAULT 'pending', + total_amount NUMERIC(10, 2) NOT NULL DEFAULT 0, + discount_amount NUMERIC(10, 2) NOT NULL DEFAULT 0, + shipping_address JSONB DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_orders_event + FOREIGN KEY (event_id) + REFERENCES events(id) + ON DELETE RESTRICT, + CONSTRAINT fk_orders_user + FOREIGN KEY (user_id) + REFERENCES users(id) + ON DELETE RESTRICT, + CONSTRAINT fk_orders_coupon + FOREIGN KEY (coupon_id) + REFERENCES coupons(id) + ON DELETE SET NULL + ); + """, + + """ + CREATE TABLE IF NOT EXISTS order_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL, + product_variant_id UUID NOT NULL, + quantity INT NOT NULL DEFAULT 1, + unit_price NUMERIC(10, 2) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_items_order + FOREIGN KEY (order_id) + REFERENCES shop_orders(id) + ON DELETE CASCADE, + CONSTRAINT fk_items_variant + FOREIGN KEY (product_variant_id) + REFERENCES product_variants(id) + ON DELETE RESTRICT + ); + """, + + """ + CREATE TABLE IF NOT EXISTS shop_payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL UNIQUE, + amount NUMERIC(10, 2) NOT NULL, + status payment_status_enum NOT NULL DEFAULT 'pending', + method VARCHAR(100), + reference VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_payments_order + FOREIGN KEY (order_id) + REFERENCES shop_orders(id) + ON DELETE CASCADE + ); + """, ] CREATE_INDEX_QUERIES = [ + "CREATE INDEX IF NOT EXISTS idx_sponsor_packages_event_id ON sponsor_packages(event_id);", + "CREATE INDEX IF NOT EXISTS idx_registrations_event_id ON registrations(event_id);", + "CREATE INDEX IF NOT EXISTS idx_registrations_email ON registrations(email);", "CREATE INDEX IF NOT EXISTS idx_sponsors_partners_event_id ON sponsors_partners(event_id);", "CREATE INDEX IF NOT EXISTS idx_api_keys_event_id ON api_keys(event_id);", + "CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);", + "CREATE INDEX IF NOT EXISTS idx_products_event_id ON products(event_id);", + "CREATE INDEX IF NOT EXISTS idx_products_slug ON products(slug);", + "CREATE INDEX IF NOT EXISTS idx_variants_product_id ON product_variants(product_id);", + "CREATE INDEX IF NOT EXISTS idx_orders_event_id ON shop_orders(event_id);", + "CREATE INDEX IF NOT EXISTS idx_orders_user_id ON shop_orders(user_id);", + "CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id);", + "CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id);", + "CREATE INDEX IF NOT EXISTS idx_role_permissions_permission_id ON role_permissions(permission_id);", + "CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);", + "CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON user_roles(role_id);", + "CREATE INDEX IF NOT EXISTS idx_permissions_resource ON permissions(resource);", + "CREATE INDEX IF NOT EXISTS idx_talk_reviews_proposal_id ON talk_reviews(proposal_id);", + "CREATE INDEX IF NOT EXISTS idx_talk_reviews_reviewer_id ON talk_reviews(reviewer_id);", +] + + +CREATE_VIEW_QUERIES = [ + """ + CREATE OR REPLACE VIEW talk_avg_scores AS + SELECT + proposal_id, + ROUND(AVG(score)::numeric, 2) AS avg_score, + COUNT(*) AS review_count + FROM talk_reviews + GROUP BY proposal_id; + """, ] ALTER_TABLE_QUERIES = [ "ALTER TABLE sponsors_partners ADD COLUMN IF NOT EXISTS package_tier package_tier_enum;", + "ALTER TABLE sponsors_partners ADD COLUMN IF NOT EXISTS package_id UUID REFERENCES sponsor_packages(id) ON DELETE SET NULL;", ] @@ -318,6 +640,8 @@ def create_tables(): cur.execute(query) for query in CREATE_INDEX_QUERIES: cur.execute(query) + for query in CREATE_VIEW_QUERIES: + cur.execute(query) conn.commit() return ( CREATE_EXTENSIONS_QUERY @@ -329,6 +653,8 @@ def create_tables(): + "\n".join(ALTER_TABLE_QUERIES) + "\n" + "\n".join(CREATE_INDEX_QUERIES) + + "\n" + + "\n".join(CREATE_VIEW_QUERIES) ) diff --git a/app/database/seed.py b/app/database/seed.py new file mode 100644 index 0000000..1660d05 --- /dev/null +++ b/app/database/seed.py @@ -0,0 +1,252 @@ +"""Idempotent seed: permissions, system roles, and super admin user. + +Run automatically on server startup via the lifespan hook in main.py. +Every INSERT uses ON CONFLICT DO NOTHING — safe to call multiple times. + +Super admin credentials are read from env vars: + SUPERADMIN_EMAIL, SUPERADMIN_USERNAME, SUPERADMIN_PASSWORD, SUPERADMIN_FULL_NAME +""" + +from app.core.settings import logger, settings +from app.core.security import hash_password + +# --------------------------------------------------------------------------- +# Permissions catalogue (resource, action, description) +# --------------------------------------------------------------------------- + +PERMISSIONS = [ + # dashboard + ("dashboard", "read", "View global admin overview"), + + # users + ("users", "read", "List and view users"), + ("users", "update", "Edit user role or status"), + ("users", "delete", "Delete a user account"), + + # events + ("events", "read", "View events and their overview"), + ("events", "create", "Create a new event"), + ("events", "update", "Edit an existing event"), + ("events", "delete", "Delete an event"), + + # proposals + ("proposals", "read", "View CFP proposals"), + ("proposals", "review", "Submit a review score for a CFP proposal"), + ("proposals", "update", "Change proposal status"), + + # registrations + ("registrations", "read", "View registrations"), + ("registrations", "create", "Register an attendee manually"), + ("registrations", "update", "Edit or check-in a registration"), + ("registrations", "delete", "Delete a registration"), + + # speakers + ("speakers", "read", "View speakers"), + ("speakers", "create", "Add a new speaker"), + ("speakers", "update", "Edit speaker info or photo"), + ("speakers", "delete", "Remove a speaker"), + + # sessions (conference schedule) + ("sessions", "read", "View scheduled sessions"), + ("sessions", "create", "Create a session"), + ("sessions", "update", "Edit a session"), + ("sessions", "delete", "Delete a session"), + + # outreach (contacts + partners) + ("outreach", "read", "View contact messages and partners"), + ("outreach", "update", "Mark messages resolved / confirm partners"), + + # sponsor packages + ("sponsor_packages", "read", "View sponsorship packages"), + ("sponsor_packages", "create", "Create a sponsorship package"), + ("sponsor_packages", "update", "Edit a sponsorship package"), + ("sponsor_packages", "delete", "Delete a sponsorship package"), + + # security (API keys + sessions) + ("security", "read", "View API keys and active sessions"), + ("security", "create", "Generate a new API key"), + ("security", "delete", "Revoke an API key or session"), + + # shop — products & variants + ("shop_products", "read", "View shop products and variants"), + ("shop_products", "create", "Add a product or variant"), + ("shop_products", "update", "Edit a product or variant"), + ("shop_products", "delete", "Delete a product or variant"), + + # shop — orders + ("shop_orders", "read", "View shop orders"), + ("shop_orders", "update", "Update an order status"), + + # shop — categories + ("shop_categories", "read", "View product categories"), + ("shop_categories", "create", "Create a category"), + ("shop_categories", "update", "Edit a category"), + ("shop_categories", "delete", "Delete a category"), + + # shop — coupons + ("shop_coupons", "read", "View discount coupons"), + ("shop_coupons", "create", "Create a coupon"), + ("shop_coupons", "update", "Edit a coupon"), + ("shop_coupons", "delete", "Delete a coupon"), + + # shop — customers + ("shop_customers", "read", "View shop customers"), + ("shop_customers", "update", "Toggle customer active status"), + + # shop — dashboard / analytics + ("shop_dashboard", "read", "View shop dashboard and analytics"), + + # roles (RBAC self-management) + ("roles", "read", "View roles and their permissions"), + ("roles", "create", "Create a new role"), + ("roles", "update", "Edit a role or its permission set"), + ("roles", "delete", "Delete a non-system role"), +] + +ALL_PERMISSIONS = {f"{r}:{a}" for r, a, _ in PERMISSIONS} + +# --------------------------------------------------------------------------- +# System roles +# --------------------------------------------------------------------------- + +SYSTEM_ROLES = { + "super_admin": { + "description": "Unrestricted access to everything", + "permissions": ALL_PERMISSIONS, + }, + "admin": { + "description": "Full access except deleting system roles", + "permissions": ALL_PERMISSIONS - {"roles:delete"}, + }, + "staff": { + "description": "Read-only access plus event operations", + "permissions": { + "dashboard:read", + "users:read", + "events:read", + "proposals:read", "proposals:update", + "registrations:read", "registrations:create", "registrations:update", + "speakers:read", "speakers:create", "speakers:update", + "sessions:read", + "outreach:read", + "sponsor_packages:read", + "security:read", + "shop_products:read", + "shop_orders:read", "shop_orders:update", + "shop_categories:read", + "shop_coupons:read", + "shop_customers:read", + "shop_dashboard:read", + "roles:read", + }, + }, + "reviewer": { + "description": "CFP reviewer — can score and comment on submitted proposals", + "permissions": {"proposals:read", "proposals:review"}, + }, + "member": { + "description": "Regular community member — no admin permissions", + "permissions": set(), + }, +} + + +# --------------------------------------------------------------------------- +# Seed runner +# --------------------------------------------------------------------------- + +async def run_seed(db_pool) -> None: + """Insert missing permissions, system roles, and super admin. Idempotent.""" + async with db_pool.connection() as conn: + async with conn.cursor() as cur: + + # 1. Upsert permissions + for resource, action, description in PERMISSIONS: + await cur.execute( + """ + INSERT INTO permissions (name, description, resource, action) + VALUES (%s, %s, %s, %s) + ON CONFLICT (name) DO NOTHING + """, + (f"{resource}:{action}", description, resource, action), + ) + + # 2. Build permission name → id map + await cur.execute("SELECT id, name FROM permissions") + rows = await cur.fetchall() + perm_id_by_name: dict[str, str] = {row[1]: str(row[0]) for row in rows} + + # 3. Upsert system roles and their permissions + role_ids: dict[str, str] = {} + for role_name, role_cfg in SYSTEM_ROLES.items(): + await cur.execute( + """ + INSERT INTO roles (name, description, is_system) + VALUES (%s, %s, TRUE) + ON CONFLICT (name) DO UPDATE + SET description = EXCLUDED.description, + updated_at = NOW() + RETURNING id + """, + (role_name, role_cfg["description"]), + ) + role_ids[role_name] = str((await cur.fetchone())[0]) + + for perm_name in role_cfg["permissions"]: + perm_id = perm_id_by_name.get(perm_name) + if not perm_id: + continue + await cur.execute( + """ + INSERT INTO role_permissions (role_id, permission_id) + VALUES (%s, %s) + ON CONFLICT DO NOTHING + """, + (role_ids[role_name], perm_id), + ) + + # 4. Create super admin user (unique — skip if already exists) + await cur.execute( + "SELECT id FROM users WHERE email = %s OR username = %s", + (settings.superadmin_email, settings.superadmin_username), + ) + existing_sa = await cur.fetchone() + + if not existing_sa: + await cur.execute( + """ + INSERT INTO users + (username, email, full_name, password_hash, role, is_active) + VALUES (%s, %s, %s, %s, 'admin', TRUE) + RETURNING id + """, + ( + settings.superadmin_username, + settings.superadmin_email, + settings.superadmin_full_name, + hash_password(settings.superadmin_password), + ), + ) + sa_user_id = str((await cur.fetchone())[0]) + logger.info("Super admin user created: %s", settings.superadmin_email) + else: + sa_user_id = str(existing_sa[0]) + + # 5. Assign super_admin role to the super admin user + await cur.execute( + """ + INSERT INTO user_roles (user_id, role_id) + VALUES (%s, %s) + ON CONFLICT DO NOTHING + """, + (sa_user_id, role_ids["super_admin"]), + ) + + await conn.commit() + + logger.info( + "Seed completed — %d permissions | %d system roles | super admin: %s", + len(PERMISSIONS), + len(SYSTEM_ROLES), + settings.superadmin_email, + ) diff --git a/app/main.py b/app/main.py index 8e3bfb8..3ccfde4 100644 --- a/app/main.py +++ b/app/main.py @@ -1,11 +1,18 @@ from fastapi.responses import FileResponse from fastapi.middleware.cors import CORSMiddleware -from fastapi import FastAPI, Request +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError from contextlib import asynccontextmanager from psycopg_pool import AsyncConnectionPool import redis.asyncio as redis -from app.core.settings import settings +from app.core.settings import logger, settings from app.routers.api import api_routers +from app.routers.auth import api_router as auth_router +from app.routers.shop.api import shop_router, client_shop_router +from app.routers.admin.api import admin_router +from app.routers.registrations import api_router as registrations_router +from app.database.seed import run_seed +from app.utils.responses import error as error_response, success origins = [] if settings.env == "development": @@ -22,23 +29,34 @@ "https://tg.pycon.org", "https://api.pytogo.org", "https://api.pycontg.pytogo.org" - # "http://127.0.0.1:8080/", - # "http://localhost:8080/" - ] @asynccontextmanager async def lifespan(app: FastAPI): async with AsyncConnectionPool( - conninfo=settings.db_url, - min_size=1, max_size=5, timeout=10, - kwargs={"prepare_threshold": None}) as db_pool: + conninfo=settings.db_url, + min_size=settings.db_pool_min_size, + max_size=settings.db_pool_max_size, + timeout=settings.db_pool_timeout, + kwargs={ + "prepare_threshold": None, + "sslmode": settings.db_ssl_mode, + }, + ) as db_pool: app.state.db_pool = db_pool + logger.info( + "Database pool ready (min=%d max=%d ssl=%s)", + settings.db_pool_min_size, + settings.db_pool_max_size, + settings.db_ssl_mode, + ) redis_client = redis.from_url(settings.redis_url) app.state.redis_client = redis_client + await run_seed(db_pool) + yield await app.state.redis_client.close() @@ -63,20 +81,85 @@ async def lifespan(app: FastAPI): ) +# --------------------------------------------------------------------------- +# Global exception handlers — ensure every error follows the standard envelope +# --------------------------------------------------------------------------- + +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException): + return error_response(message=str(exc.detail), code=exc.status_code) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + return error_response(message="Validation error", code=422, details=exc.errors()) + + +@app.exception_handler(Exception) +async def generic_exception_handler(request: Request, exc: Exception): + return error_response(message="Internal server error", code=500) + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + @app.get("/") async def welcome(request: Request): docs_url = f"{request.base_url}docs" - message = { + return success({ "message": "Welcome to Python Togo official api", "version": "2.1.0", "author": "Python Software Community Togo", - "documentations": docs_url - } - return message + "documentations": docs_url, + }) @app.get("/favicon.ico", include_in_schema=False) async def favicon(): return FileResponse("app/static/favicon.ico") + +@app.get("/api/v2/health", tags=["health"]) +async def health_check(request: Request): + """Vérification de la connectivité base de données et Redis.""" + report: dict = { + "status": "healthy", + "version": "2.1.0", + "database": {"status": "unknown", "detail": None}, + "redis": {"status": "unknown", "detail": None}, + } + + # ── Database ────────────────────────────────────────────────────────────── + try: + async with request.app.state.db_pool.connection() as conn: + async with conn.cursor() as cur: + await cur.execute("SELECT version()") + row = await cur.fetchone() + report["database"] = {"status": "connected", "detail": row[0] if row else None} + except Exception as exc: + report["database"] = {"status": "error", "detail": str(exc)} + report["status"] = "degraded" + logger.error("Health check — DB error: %s", exc) + + # ── Redis ───────────────────────────────────────────────────────────────── + try: + pong = await request.app.state.redis_client.ping() + report["redis"] = {"status": "connected" if pong else "no-response", "detail": None} + if not pong: + report["status"] = "degraded" + except Exception as exc: + report["redis"] = {"status": "error", "detail": str(exc)} + report["status"] = "degraded" + logger.error("Health check — Redis error: %s", exc) + + http_code = 200 if report["status"] == "healthy" else 503 + return success(report, code=http_code) + + +app.include_router(auth_router, prefix="/api/v2") +app.include_router(shop_router, prefix="/api/v2") +app.include_router(client_shop_router, prefix="/api/v2") +app.include_router(admin_router, prefix="/api/v2") +app.include_router(registrations_router, prefix="/api/v2") app.include_router(api_routers) diff --git a/app/routers/admin/__init__.py b/app/routers/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/admin/api.py b/app/routers/admin/api.py new file mode 100644 index 0000000..c26594a --- /dev/null +++ b/app/routers/admin/api.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter +from app.routers.admin.overview import api_router as overview_router +from app.routers.admin.security import api_router as security_router +from app.routers.admin.users import api_router as users_router +from app.routers.admin.outreach import api_router as outreach_router +from app.routers.admin.proposals import api_router as proposals_router +from app.routers.admin.events import api_router as events_router +from app.routers.admin.registrations import api_router as registrations_router +from app.routers.admin.speakers import api_router as speakers_router +from app.routers.sessions import router as sessions_router +from app.routers.admin.sponsor_packages import api_router as sponsor_packages_router +from app.routers.admin.rbac import api_router as rbac_router +from app.routers.admin.cfp_review import api_router as cfp_review_router + +admin_router = APIRouter(prefix="/admin") +admin_router.include_router(overview_router) +admin_router.include_router(security_router) +admin_router.include_router(users_router) +admin_router.include_router(outreach_router) +admin_router.include_router(proposals_router) +admin_router.include_router(events_router) +admin_router.include_router(registrations_router) +admin_router.include_router(speakers_router) +admin_router.include_router(sessions_router) +admin_router.include_router(sponsor_packages_router) +admin_router.include_router(rbac_router) +admin_router.include_router(cfp_review_router) diff --git a/app/routers/admin/cfp_review.py b/app/routers/admin/cfp_review.py new file mode 100644 index 0000000..5181b67 --- /dev/null +++ b/app/routers/admin/cfp_review.py @@ -0,0 +1,291 @@ +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status + +from app.core.security import get_current_user, require_permission +from app.database.connection import get_db_connection +from app.schemas.models import ( + AuthenticatedUser, + TalkReviewCreate, + TalkStatusUpdate, +) +from app.core.settings import logger +from app.utils.responses import success +from app.utils.send_email import build_talk_decision_email, send_email +from app.utils.pagination import paginate + +api_router = APIRouter(prefix="/cfp", tags=["CFP Review"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +async def _get_proposal_or_404(db, proposal_id: str) -> dict: + async with db.cursor() as cur: + await cur.execute( + """ + SELECT p.id, p.title, p.status, p.speaker_full_name, p.speaker_email, + e.title AS event_title + FROM proposals p + JOIN events e ON e.id = p.event_id + WHERE p.id = %s + """, + (proposal_id,), + ) + row = await cur.fetchone() + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Proposal not found") + cols = ["id", "title", "status", "speaker_full_name", "speaker_email", "event_title"] + return dict(zip(cols, row)) + + +async def _has_reviewed(db, proposal_id: str, reviewer_id: str) -> bool: + async with db.cursor() as cur: + await cur.execute( + "SELECT 1 FROM talk_reviews WHERE proposal_id = %s AND reviewer_id = %s", + (proposal_id, reviewer_id), + ) + return (await cur.fetchone()) is not None + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +@api_router.get("/talks") +async def list_talks_with_scores( + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + _: AuthenticatedUser = Depends(require_permission("proposals:read")), + db=Depends(get_db_connection), +): + """List all proposals enriched with their average review score.""" + try: + rows, total = await paginate( + db, + """ + SELECT + p.id, p.title, p.status, p.session_type, p.language, + p.speaker_full_name, p.speaker_email, p.created_at, + e.code AS event_code, e.title AS event_title, + COALESCE(s.avg_score, NULL)::float AS avg_score, + COALESCE(s.review_count, 0)::int AS review_count + FROM proposals p + JOIN events e ON e.id = p.event_id + LEFT JOIN talk_avg_scores s ON s.proposal_id = p.id + ORDER BY p.created_at DESC + """, + (), + page, per_page, + ) + cols = [ + "id", "title", "status", "session_type", "language", + "speaker_full_name", "speaker_email", "created_at", + "event_code", "event_title", "avg_score", "review_count", + ] + talks = [dict(zip(cols, row)) for row in rows] + return success(talks, total=total, page=page, per_page=per_page) + except HTTPException: + raise + except Exception as e: + logger.error("Error listing talks with scores: %s", e) + raise HTTPException(status_code=500, detail="Error listing talks") + + +@api_router.get("/talks/{proposal_id}") +async def get_talk_detail( + proposal_id: str, + current_user: AuthenticatedUser = Depends(require_permission("proposals:read")), + db=Depends(get_db_connection), +): + """Full proposal detail. Reviews are masked if the caller hasn't voted yet (unless admin).""" + try: + proposal = await _get_proposal_or_404(db, proposal_id) + + async with db.cursor() as cur: + # Avg score + await cur.execute( + "SELECT avg_score, review_count FROM talk_avg_scores WHERE proposal_id = %s", + (str(proposal["id"]),), + ) + score_row = await cur.fetchone() + proposal["avg_score"] = float(score_row[0]) if score_row else None + proposal["review_count"] = int(score_row[1]) if score_row else 0 + + # Reviews — masked unless admin or already reviewed + already_reviewed = await _has_reviewed(db, str(proposal["id"]), str(current_user.id)) + can_see_all = current_user.is_admin or already_reviewed + + if can_see_all: + await cur.execute( + """ + SELECT tr.id, tr.reviewer_id, u.username AS reviewer_username, + tr.score, tr.comment, tr.created_at + FROM talk_reviews tr + JOIN users u ON u.id = tr.reviewer_id + WHERE tr.proposal_id = %s + ORDER BY tr.created_at + """, + (str(proposal["id"]),), + ) + rev_rows = await cur.fetchall() + rev_cols = ["id", "reviewer_id", "reviewer_username", "score", "comment", "created_at"] + proposal["reviews"] = [dict(zip(rev_cols, r)) for r in rev_rows] + proposal["reviews_masked"] = False + else: + await cur.execute( + "SELECT COUNT(*) FROM talk_reviews WHERE proposal_id = %s", + (str(proposal["id"]),), + ) + count_row = await cur.fetchone() + proposal["reviews"] = [] + proposal["reviews_masked"] = True + proposal["total_reviews"] = int(count_row[0]) + + return success(proposal) + except HTTPException: + raise + except Exception as e: + logger.error("Error retrieving talk detail %s: %s", proposal_id, e) + raise HTTPException(status_code=500, detail="Error retrieving talk detail") + + +@api_router.post("/talks/{proposal_id}/reviews", status_code=status.HTTP_201_CREATED) +async def submit_review( + proposal_id: str, + payload: TalkReviewCreate, + current_user: AuthenticatedUser = Depends(require_permission("proposals:review")), + db=Depends(get_db_connection), +): + """Submit a score + optional comment for a proposal. One review per reviewer.""" + try: + await _get_proposal_or_404(db, proposal_id) + + async with db.cursor() as cur: + try: + await cur.execute( + """ + INSERT INTO talk_reviews (proposal_id, reviewer_id, score, comment) + VALUES (%s, %s, %s, %s) + RETURNING id, proposal_id, reviewer_id, score, comment, created_at, updated_at + """, + (proposal_id, str(current_user.id), payload.score, payload.comment), + ) + except Exception as db_exc: + if "uq_tr_proposal_reviewer" in str(db_exc): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="You have already submitted a review for this proposal", + ) + raise + row = await cur.fetchone() + await db.commit() + + cols = ["id", "proposal_id", "reviewer_id", "score", "comment", "created_at", "updated_at"] + return success(dict(zip(cols, row)), code=201) + except HTTPException: + raise + except Exception as e: + logger.error("Error submitting review for %s: %s", proposal_id, e) + raise HTTPException(status_code=500, detail="Error submitting review") + + +@api_router.get("/talks/{proposal_id}/reviews") +async def get_reviews( + proposal_id: str, + current_user: AuthenticatedUser = Depends(require_permission("proposals:read")), + db=Depends(get_db_connection), +): + """ + Return reviews for a proposal. + - Admin: always sees all reviews. + - Reviewer: sees all reviews only after submitting their own; otherwise sees only aggregate count. + """ + try: + await _get_proposal_or_404(db, proposal_id) + already_reviewed = await _has_reviewed(db, proposal_id, str(current_user.id)) + can_see_all = current_user.is_admin or already_reviewed + + async with db.cursor() as cur: + if can_see_all: + await cur.execute( + """ + SELECT tr.id, tr.reviewer_id, u.username AS reviewer_username, + tr.score, tr.comment, tr.created_at, tr.updated_at + FROM talk_reviews tr + JOIN users u ON u.id = tr.reviewer_id + WHERE tr.proposal_id = %s + ORDER BY tr.created_at + """, + (proposal_id,), + ) + rows = await cur.fetchall() + cols = ["id", "reviewer_id", "reviewer_username", "score", "comment", "created_at", "updated_at"] + return success({"reviews": [dict(zip(cols, r)) for r in rows], "masked": False}) + else: + await cur.execute( + "SELECT COUNT(*) FROM talk_reviews WHERE proposal_id = %s", + (proposal_id,), + ) + count_row = await cur.fetchone() + return success({ + "masked": True, + "has_reviewed": False, + "total_reviews": int(count_row[0]), + "message": "Submit your review to unlock all scores.", + }) + except HTTPException: + raise + except Exception as e: + logger.error("Error fetching reviews for %s: %s", proposal_id, e) + raise HTTPException(status_code=500, detail="Error fetching reviews") + + +@api_router.patch("/talks/{proposal_id}/status") +async def update_talk_status( + proposal_id: str, + payload: TalkStatusUpdate, + background_tasks: BackgroundTasks, + _: AuthenticatedUser = Depends(require_permission("proposals:update")), + db=Depends(get_db_connection), +): + """Change proposal status and notify the speaker by email (background task).""" + try: + proposal = await _get_proposal_or_404(db, proposal_id) + new_status = payload.status.value + + async with db.cursor() as cur: + await cur.execute( + """ + UPDATE proposals + SET status = %s, updated_at = NOW() + WHERE id = %s + RETURNING id, title, status, updated_at + """, + (new_status, proposal_id), + ) + row = await cur.fetchone() + await db.commit() + + # Trigger email notification to speaker + if new_status in ("accepted", "rejected", "waitlisted"): + subject, text_body, html_body = build_talk_decision_email( + speaker_name=proposal["speaker_full_name"], + talk_title=proposal["title"], + status=new_status, + event_title=proposal["event_title"], + ) + background_tasks.add_task( + send_email, + to=proposal["speaker_email"], + subject=subject, + body_html=html_body, + body_text=text_body, + ) + + cols = ["id", "title", "status", "updated_at"] + return success(dict(zip(cols, row))) + except HTTPException: + raise + except Exception as e: + logger.error("Error updating status for proposal %s: %s", proposal_id, e) + raise HTTPException(status_code=500, detail="Error updating talk status") diff --git a/app/routers/admin/events.py b/app/routers/admin/events.py new file mode 100644 index 0000000..4ef9247 --- /dev/null +++ b/app/routers/admin/events.py @@ -0,0 +1,85 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends +from psycopg.rows import dict_row + +from app.core.security import require_admin +from app.database.connection import get_db_connection +from app.schemas.models import EventDashboardItem, EventsDashboardOverview +from app.utils.responses import success + +api_router = APIRouter(prefix="/events", tags=["admin-events"]) + + +@api_router.get("/overview") +async def get_events_overview( + db=Depends(get_db_connection), + _=Depends(require_admin), +): + now = datetime.now(timezone.utc) + + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute( + """ + SELECT + e.id, e.code, e.title, e.start_date, e.end_date, e.is_active, + e.cfp_open_at, e.cfp_close_at, + COUNT(DISTINCT p.id) AS total_proposals, + COUNT(DISTINCT p.id) FILTER (WHERE p.status = 'accepted') AS accepted_proposals, + COUNT(DISTINCT sp.id) FILTER (WHERE sp.is_confirmed = true) AS confirmed_sponsors, + COUNT(DISTINCT spk.id) AS total_speakers, + COUNT(DISTINCT s.id) AS total_sessions + FROM events e + LEFT JOIN proposals p ON p.event_id = e.id + LEFT JOIN sponsors_partners sp ON sp.event_id = e.id + LEFT JOIN speakers spk ON spk.event_id = e.id + LEFT JOIN sessions s ON s.event_id = e.id + GROUP BY e.id + ORDER BY e.start_date DESC + """ + ) + rows = await cur.fetchall() + + await cur.execute("SELECT COUNT(*) AS total FROM events") + total_events = (await cur.fetchone())["total"] + + await cur.execute("SELECT COUNT(*) AS total FROM events WHERE is_active = true") + active_events = (await cur.fetchone())["total"] + + items = [] + for r in rows: + total_p = r["total_proposals"] or 0 + accepted_p = r["accepted_proposals"] or 0 + cfp_open_at = r["cfp_open_at"] + cfp_close_at = r["cfp_close_at"] + + if cfp_open_at and cfp_open_at.tzinfo is None: + cfp_open_at = cfp_open_at.replace(tzinfo=timezone.utc) + if cfp_close_at and cfp_close_at.tzinfo is None: + cfp_close_at = cfp_close_at.replace(tzinfo=timezone.utc) + + cfp_is_open = bool(cfp_open_at and cfp_close_at and cfp_open_at <= now <= cfp_close_at) + acceptance_rate = round(accepted_p / total_p * 100, 1) if total_p else 0.0 + + items.append(EventDashboardItem( + id=r["id"], + code=r["code"], + title=r["title"], + start_date=r["start_date"], + end_date=r["end_date"], + is_active=r["is_active"], + cfp_is_open=cfp_is_open, + total_proposals=total_p, + accepted_proposals=accepted_p, + acceptance_rate=acceptance_rate, + confirmed_sponsors=r["confirmed_sponsors"] or 0, + total_speakers=r["total_speakers"] or 0, + total_sessions=r["total_sessions"] or 0, + )) + + data = EventsDashboardOverview( + total_events=total_events, + active_events=active_events, + events=items, + ) + return success(data) diff --git a/app/routers/admin/outreach.py b/app/routers/admin/outreach.py new file mode 100644 index 0000000..15fadf4 --- /dev/null +++ b/app/routers/admin/outreach.py @@ -0,0 +1,77 @@ +from fastapi import APIRouter, Depends, Query +from psycopg.rows import dict_row + +from app.core.security import require_admin +from app.database.connection import get_db_connection +from app.schemas.models import ContactMessageSummary, OutreachOverview, PartnerSponsorSummary +from app.utils.responses import success +from app.utils.pagination import paginate + +api_router = APIRouter(prefix="/outreach", tags=["admin-outreach"]) + + +@api_router.get("/overview") +async def get_outreach_overview(db=Depends(get_db_connection), _=Depends(require_admin)): + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute("SELECT COUNT(*) AS total FROM contact_messages") + total_contacts = (await cur.fetchone())["total"] + + await cur.execute("SELECT COUNT(*) AS total FROM contact_messages WHERE is_resolved = false") + unresolved = (await cur.fetchone())["total"] + + await cur.execute("SELECT COUNT(*) AS total FROM sponsors_partners") + total_partners = (await cur.fetchone())["total"] + + await cur.execute("SELECT COUNT(*) AS total FROM sponsors_partners WHERE is_confirmed = false") + unconfirmed = (await cur.fetchone())["total"] + + await cur.execute("SELECT partner_type, COUNT(*) AS cnt FROM sponsors_partners GROUP BY partner_type") + by_type_rows = await cur.fetchall() + + await cur.execute( + "SELECT package_tier, COUNT(*) AS cnt FROM sponsors_partners " + "WHERE package_tier IS NOT NULL GROUP BY package_tier" + ) + by_tier_rows = await cur.fetchall() + + data = OutreachOverview( + total_contacts=total_contacts, + unresolved_contacts=unresolved, + total_partners=total_partners, + unconfirmed_partners=unconfirmed, + partners_by_type={r["partner_type"]: r["cnt"] for r in by_type_rows}, + partners_by_tier={r["package_tier"]: r["cnt"] for r in by_tier_rows}, + ) + return success(data) + + +@api_router.get("/contacts/unresolved") +async def list_unresolved_contacts( + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + rows, total = await paginate( + db, + "SELECT * FROM contact_messages WHERE is_resolved = false ORDER BY created_at ASC", + (), + page, per_page, + ) + return success([ContactMessageSummary(**r) for r in rows], total=total, page=page, per_page=per_page) + + +@api_router.get("/partners/unconfirmed") +async def list_unconfirmed_partners( + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + rows, total = await paginate( + db, + "SELECT * FROM sponsors_partners WHERE is_confirmed = false ORDER BY created_at ASC", + (), + page, per_page, + ) + return success([PartnerSponsorSummary(**r) for r in rows], total=total, page=page, per_page=per_page) diff --git a/app/routers/admin/overview.py b/app/routers/admin/overview.py new file mode 100644 index 0000000..3f23b10 --- /dev/null +++ b/app/routers/admin/overview.py @@ -0,0 +1,115 @@ +from datetime import date +from decimal import Decimal + +from fastapi import APIRouter, Depends +from psycopg.rows import dict_row + +from app.core.security import require_admin +from app.database.connection import get_db_connection, get_redis_client +from app.schemas.models import GlobalOverview +from app.utils.responses import success + +api_router = APIRouter(prefix="/overview", tags=["admin-overview"]) + + +@api_router.get("") +async def get_global_overview( + db=Depends(get_db_connection), + redis=Depends(get_redis_client), + _=Depends(require_admin), +): + today = date.today() + + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute( + "SELECT COUNT(*) AS total, " + "COUNT(*) FILTER (WHERE is_active = true) AS active " + "FROM users" + ) + u = await cur.fetchone() + total_users, active_users = u["total"], u["active"] + + await cur.execute( + "SELECT COUNT(*) AS total FROM users " + "WHERE created_at >= NOW() - INTERVAL '7 days'" + ) + new_users = (await cur.fetchone())["total"] + + await cur.execute("SELECT role, COUNT(*) AS cnt FROM users GROUP BY role") + users_by_role = {r["role"]: r["cnt"] for r in await cur.fetchall()} + + await cur.execute( + "SELECT COUNT(*) AS total, " + "COUNT(*) FILTER (WHERE is_active = true) AS active, " + "COUNT(*) FILTER (WHERE end_date < %s) AS past " + "FROM events", + (today,), + ) + ev = await cur.fetchone() + total_events, active_events, past_events = ev["total"], ev["active"], ev["past"] + + await cur.execute( + "SELECT COUNT(*) AS total, " + "COUNT(*) FILTER (WHERE status IN ('draft', 'submitted')) AS pending " + "FROM proposals" + ) + pr = await cur.fetchone() + total_proposals, pending_proposals = pr["total"], pr["pending"] + + await cur.execute( + "SELECT COUNT(*) AS total, " + "COUNT(*) FILTER (WHERE status = 'confirmed') AS confirmed " + "FROM registrations" + ) + reg = await cur.fetchone() + total_registrations, confirmed_registrations = reg["total"], reg["confirmed"] + + await cur.execute( + "SELECT COUNT(*) FILTER (WHERE is_resolved = false) AS unresolved_contacts, " + " (SELECT COUNT(*) FROM sponsors_partners WHERE is_confirmed = false) AS unconfirmed_partners " + "FROM contact_messages" + ) + out = await cur.fetchone() + unresolved_contacts = out["unresolved_contacts"] + unconfirmed_partners = out["unconfirmed_partners"] + + await cur.execute("SELECT status, COUNT(*) AS cnt FROM shop_orders GROUP BY status") + orders_by_status = {r["status"]: r["cnt"] for r in await cur.fetchall()} + total_orders = sum(orders_by_status.values()) + + await cur.execute( + "SELECT COALESCE(SUM(total_amount), 0) AS revenue FROM shop_orders " + "WHERE status IN ('paid', 'shipped', 'delivered')" + ) + total_revenue = Decimal(str((await cur.fetchone())["revenue"])) + + await cur.execute( + "SELECT COALESCE(SUM(total_amount), 0) AS revenue FROM shop_orders " + "WHERE status IN ('paid', 'shipped', 'delivered') " + "AND DATE_TRUNC('month', created_at) = DATE_TRUNC('month', NOW())" + ) + revenue_month = Decimal(str((await cur.fetchone())["revenue"])) + + active_sessions = len(await redis.keys("PYTOGO_REFRESH:*")) + + data = GlobalOverview( + total_users=total_users, + active_users=active_users, + new_users_last_7_days=new_users, + users_by_role=users_by_role, + total_events=total_events, + active_events=active_events, + past_events=past_events, + total_proposals=total_proposals, + pending_proposals=pending_proposals, + total_registrations=total_registrations, + confirmed_registrations=confirmed_registrations, + unresolved_contacts=unresolved_contacts, + unconfirmed_partners=unconfirmed_partners, + total_orders=total_orders, + orders_by_status=orders_by_status, + total_revenue=total_revenue, + revenue_current_month=revenue_month, + active_sessions=active_sessions, + ) + return success(data) diff --git a/app/routers/admin/proposals.py b/app/routers/admin/proposals.py new file mode 100644 index 0000000..93c17c3 --- /dev/null +++ b/app/routers/admin/proposals.py @@ -0,0 +1,70 @@ +from fastapi import APIRouter, Depends, Query +from psycopg.rows import dict_row + +from app.core.security import require_admin +from app.database.connection import get_db_connection +from app.schemas.models import ProposalsDashboardOverview, ProposalSummary +from app.utils.responses import success +from app.utils.pagination import paginate + +api_router = APIRouter(prefix="/proposals", tags=["admin-proposals"]) + + +@api_router.get("/overview") +async def get_proposals_overview( + db=Depends(get_db_connection), + _=Depends(require_admin), +): + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute("SELECT COUNT(*) AS total FROM proposals") + total = (await cur.fetchone())["total"] + + await cur.execute("SELECT status, COUNT(*) AS cnt FROM proposals GROUP BY status") + by_status = {r["status"]: r["cnt"] for r in await cur.fetchall()} + + await cur.execute("SELECT session_type, COUNT(*) AS cnt FROM proposals GROUP BY session_type") + by_session_type = {r["session_type"]: r["cnt"] for r in await cur.fetchall()} + + await cur.execute("SELECT COUNT(*) AS total FROM proposals WHERE track_id IS NULL") + without_track = (await cur.fetchone())["total"] + + data = ProposalsDashboardOverview( + total_proposals=total, + by_status=by_status, + by_session_type=by_session_type, + without_track=without_track, + ) + return success(data) + + +@api_router.get("/by-status/{status}") +async def list_proposals_by_status( + status: str, + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + rows, total = await paginate( + db, + "SELECT * FROM proposals WHERE status = %s ORDER BY created_at DESC", + (status,), + page, per_page, + ) + return success([ProposalSummary(**r) for r in rows], total=total, page=page, per_page=per_page) + + +@api_router.get("/without-track") +async def list_proposals_without_track( + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + rows, total = await paginate( + db, + "SELECT * FROM proposals WHERE track_id IS NULL ORDER BY created_at ASC", + (), + page, per_page, + ) + return success([ProposalSummary(**r) for r in rows], total=total, page=page, per_page=per_page) diff --git a/app/routers/admin/rbac.py b/app/routers/admin/rbac.py new file mode 100644 index 0000000..4ba5253 --- /dev/null +++ b/app/routers/admin/rbac.py @@ -0,0 +1,249 @@ +"""Admin RBAC management — roles, permissions, user-role assignments. + +All operations are performed via UUIDs. +""" + +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from psycopg.rows import dict_row + +from app.core.security import require_permission +from app.database.connection import get_db_connection +from app.schemas.models import AssignPermissionsRequest, AssignRoleRequest, PermissionSummary, RoleCreate, RoleDetail, RoleSummary, RoleUpdate, UserRoleAssignment +from app.utils.responses import success + +api_router = APIRouter(tags=["admin-rbac"]) + + +# --------------------------------------------------------------------------- +# Permissions +# --------------------------------------------------------------------------- + +@api_router.get("/permissions") +async def list_permissions(db=Depends(get_db_connection), _=Depends(require_permission("roles:read"))): + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute("SELECT * FROM permissions ORDER BY resource, action") + rows = await cur.fetchall() + return success([PermissionSummary(**r) for r in rows]) + + +# --------------------------------------------------------------------------- +# Roles +# --------------------------------------------------------------------------- + +@api_router.get("/roles") +async def list_roles(db=Depends(get_db_connection), _=Depends(require_permission("roles:read"))): + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute("SELECT * FROM roles ORDER BY name") + rows = await cur.fetchall() + return success([RoleSummary(**r) for r in rows]) + + +@api_router.get("/roles/{role_id}") +async def get_role(role_id: UUID, db=Depends(get_db_connection), _=Depends(require_permission("roles:read"))): + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute("SELECT * FROM roles WHERE id = %s", (str(role_id),)) + role = await cur.fetchone() + if not role: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found") + + await cur.execute( + """ + SELECT p.* + FROM permissions p + JOIN role_permissions rp ON rp.permission_id = p.id + WHERE rp.role_id = %s + ORDER BY p.resource, p.action + """, + (str(role_id),), + ) + perms = await cur.fetchall() + + return success(RoleDetail(**role, permissions=[PermissionSummary(**p) for p in perms])) + + +@api_router.post("/roles", status_code=status.HTTP_201_CREATED) +async def create_role(body: RoleCreate, db=Depends(get_db_connection), _=Depends(require_permission("roles:create"))): + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute( + """ + INSERT INTO roles (name, description, is_system) + VALUES (%s, %s, FALSE) + ON CONFLICT (name) DO NOTHING + RETURNING * + """, + (body.name, body.description), + ) + row = await cur.fetchone() + if not row: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Role '{body.name}' already exists") + await db.commit() + return success(RoleSummary(**row), code=201) + + +@api_router.put("/roles/{role_id}") +async def update_role(role_id: UUID, body: RoleUpdate, db=Depends(get_db_connection), _=Depends(require_permission("roles:update"))): + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute("SELECT * FROM roles WHERE id = %s", (str(role_id),)) + role = await cur.fetchone() + if not role: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found") + + updates = {k: v for k, v in body.model_dump().items() if v is not None} + if not updates: + return success(RoleSummary(**role)) + + set_clause = ", ".join(f"{k} = %s" for k in updates) + values = list(updates.values()) + [str(role_id)] + await cur.execute( + f"UPDATE roles SET {set_clause}, updated_at = NOW() WHERE id = %s RETURNING *", + values, + ) + updated = await cur.fetchone() + await db.commit() + return success(RoleSummary(**updated)) + + +@api_router.delete("/roles/{role_id}") +async def delete_role(role_id: UUID, db=Depends(get_db_connection), _=Depends(require_permission("roles:delete"))): + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute("SELECT is_system FROM roles WHERE id = %s", (str(role_id),)) + role = await cur.fetchone() + if not role: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found") + if role["is_system"]: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="System roles cannot be deleted") + await cur.execute("DELETE FROM roles WHERE id = %s", (str(role_id),)) + await db.commit() + return success({"message": "Role deleted"}) + + +# --------------------------------------------------------------------------- +# Role ↔ Permission +# --------------------------------------------------------------------------- + +@api_router.post("/roles/{role_id}/permissions") +async def assign_permissions_to_role( + role_id: UUID, + body: AssignPermissionsRequest, + db=Depends(get_db_connection), + _=Depends(require_permission("roles:update")), +): + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute("SELECT id FROM roles WHERE id = %s", (str(role_id),)) + if not await cur.fetchone(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found") + + for perm_id in body.permission_ids: + await cur.execute("SELECT id FROM permissions WHERE id = %s", (str(perm_id),)) + if not await cur.fetchone(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Permission {perm_id} not found") + await cur.execute( + "INSERT INTO role_permissions (role_id, permission_id) VALUES (%s, %s) ON CONFLICT DO NOTHING", + (str(role_id), str(perm_id)), + ) + await db.commit() + + # return updated role detail + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute("SELECT * FROM roles WHERE id = %s", (str(role_id),)) + role = await cur.fetchone() + await cur.execute( + "SELECT p.* FROM permissions p JOIN role_permissions rp ON rp.permission_id = p.id WHERE rp.role_id = %s ORDER BY p.resource, p.action", + (str(role_id),), + ) + perms = await cur.fetchall() + return success(RoleDetail(**role, permissions=[PermissionSummary(**p) for p in perms])) + + +@api_router.delete("/roles/{role_id}/permissions/{permission_id}") +async def remove_permission_from_role( + role_id: UUID, + permission_id: UUID, + db=Depends(get_db_connection), + _=Depends(require_permission("roles:update")), +): + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute( + "DELETE FROM role_permissions WHERE role_id = %s AND permission_id = %s", + (str(role_id), str(permission_id)), + ) + await db.commit() + return success({"message": "Permission removed from role"}) + + +# --------------------------------------------------------------------------- +# User ↔ Role +# --------------------------------------------------------------------------- + +@api_router.get("/users/{user_id}/roles") +async def get_user_roles(user_id: UUID, db=Depends(get_db_connection), _=Depends(require_permission("users:read"))): + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute( + """ + SELECT ur.user_id, ur.role_id, r.name AS role_name, ur.assigned_at + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + WHERE ur.user_id = %s + ORDER BY ur.assigned_at + """, + (str(user_id),), + ) + rows = await cur.fetchall() + return success([UserRoleAssignment(**r) for r in rows]) + + +@api_router.post("/users/{user_id}/roles", status_code=status.HTTP_201_CREATED) +async def assign_role_to_user( + user_id: UUID, + body: AssignRoleRequest, + db=Depends(get_db_connection), + current_user=Depends(require_permission("users:update")), +): + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute("SELECT id FROM users WHERE id = %s", (str(user_id),)) + if not await cur.fetchone(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + await cur.execute("SELECT id, name FROM roles WHERE id = %s", (str(body.role_id),)) + role = await cur.fetchone() + if not role: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found") + + await cur.execute( + "INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING RETURNING assigned_at", + (str(user_id), str(body.role_id), str(current_user.id)), + ) + row = await cur.fetchone() + + if not row: + await cur.execute( + "SELECT assigned_at FROM user_roles WHERE user_id = %s AND role_id = %s", + (str(user_id), str(body.role_id)), + ) + row = await cur.fetchone() + + await db.commit() + return success(UserRoleAssignment( + user_id=user_id, + role_id=body.role_id, + role_name=role["name"], + assigned_at=row["assigned_at"], + ), code=201) + + +@api_router.delete("/users/{user_id}/roles/{role_id}") +async def remove_role_from_user( + user_id: UUID, + role_id: UUID, + db=Depends(get_db_connection), + _=Depends(require_permission("users:update")), +): + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute( + "DELETE FROM user_roles WHERE user_id = %s AND role_id = %s", + (str(user_id), str(role_id)), + ) + await db.commit() + return success({"message": "Role removed from user"}) diff --git a/app/routers/admin/registrations.py b/app/routers/admin/registrations.py new file mode 100644 index 0000000..88e3d4b --- /dev/null +++ b/app/routers/admin/registrations.py @@ -0,0 +1,105 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status + +from app.core.security import require_admin +from app.database.connection import get_db_connection +from app.schemas.models import RegistrationCreate, RegistrationsDashboard, RegistrationStatusUpdate, RegistrationSummary, RegistrationUpdate +from app.utils.registrations import ( + delete_registration, + get_registration_by_id, + get_registrations_dashboard, + register_participant, + update_registration, + update_registration_status, +) +from app.utils.responses import success +from app.utils.pagination import paginate + +api_router = APIRouter(prefix="/registrations", tags=["admin-registrations"]) + + +@api_router.get("/{event_code}/dashboard") +async def registrations_dashboard( + event_code: str, + db=Depends(get_db_connection), + _=Depends(require_admin), +): + data = await get_registrations_dashboard(db, event_code) + return success(data) + + +@api_router.get("/{event_code}") +async def list_registrations( + event_code: str, + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + rows, total = await paginate( + db, + """ + SELECT r.* + FROM registrations r + JOIN events e ON r.event_id = e.id + WHERE e.code = %s + ORDER BY r.created_at DESC + """, + (event_code.strip().upper(),), + page, per_page, + ) + return success(rows, total=total, page=page, per_page=per_page) + + +@api_router.post("/{event_code}", status_code=status.HTTP_201_CREATED) +async def create_registration( + event_code: str, + payload: RegistrationCreate, + db=Depends(get_db_connection), + _=Depends(require_admin), +): + data = await register_participant(db, event_code, payload.model_dump()) + return success(data, code=201) + + +@api_router.get("/detail/{registration_id}") +async def get_registration( + registration_id: UUID, + db=Depends(get_db_connection), + _=Depends(require_admin), +): + data = await get_registration_by_id(db, str(registration_id)) + return success(data) + + +@api_router.put("/{registration_id}") +async def update_registration_details( + registration_id: UUID, + payload: RegistrationUpdate, + db=Depends(get_db_connection), + _=Depends(require_admin), +): + data = await update_registration(db, str(registration_id), payload.model_dump(exclude_none=True)) + return success(data) + + +@api_router.patch("/{registration_id}/status") +async def change_registration_status( + registration_id: UUID, + payload: RegistrationStatusUpdate, + db=Depends(get_db_connection), + _=Depends(require_admin), +): + data = await update_registration_status(db, str(registration_id), payload.status.value) + return success(data) + + +@api_router.delete("/{registration_id}") +async def remove_registration( + registration_id: UUID, + db=Depends(get_db_connection), + _=Depends(require_admin), +): + await delete_registration(db, str(registration_id)) + return success({"message": "Registration deleted"}) diff --git a/app/routers/admin/security.py b/app/routers/admin/security.py new file mode 100644 index 0000000..6e47e8a --- /dev/null +++ b/app/routers/admin/security.py @@ -0,0 +1,93 @@ +from fastapi import APIRouter, Depends +from psycopg.rows import dict_row + +from app.core.security import require_admin, decode_token +from app.database.connection import get_db_connection, get_redis_client +from app.schemas.models import APIKeySummaryAdmin, ActiveSession, SecurityOverview +from app.utils.responses import success + +api_router = APIRouter(prefix="/security", tags=["admin-security"]) + + +def _mask_key(key: str) -> str: + return f"PYTOGO_SK_{'*' * 28}{key[-4:]}" + + +@api_router.get("/overview") +async def get_security_overview( + db=Depends(get_db_connection), + redis=Depends(get_redis_client), + _=Depends(require_admin), +): + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute("SELECT COUNT(*) AS total FROM api_keys") + total_api_keys = (await cur.fetchone())["total"] + + cached_keys = await redis.keys("PYTOGO_API_KEY:*") + active_sessions = await redis.keys("PYTOGO_REFRESH:*") + active_carts = await redis.keys("PYTOGO_CART:*") + + data = SecurityOverview( + total_api_keys=total_api_keys, + active_sessions=len(active_sessions), + cached_api_keys=len(cached_keys), + active_carts=len(active_carts), + ) + return success(data) + + +@api_router.get("/api-keys") +async def list_api_keys( + db=Depends(get_db_connection), + redis=Depends(get_redis_client), + _=Depends(require_admin), +): + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute( + """ + SELECT k.id, k.name, k.key_value, k.event_id, k.created_at, + e.code AS event_code + FROM api_keys k + LEFT JOIN events e ON e.id = k.event_id + ORDER BY k.created_at DESC + """ + ) + rows = await cur.fetchall() + + result = [] + for row in rows: + cached = await redis.exists(f"PYTOGO_API_KEY:{row['key_value']}") + result.append(APIKeySummaryAdmin( + id=row["id"], + name=row["name"], + key_masked=_mask_key(row["key_value"]), + event_id=row["event_id"], + event_code=row["event_code"], + created_at=row["created_at"], + is_cached=bool(cached), + )) + return success(result) + + +@api_router.get("/sessions") +async def list_active_sessions( + redis=Depends(get_redis_client), + _=Depends(require_admin), +): + keys = await redis.keys("PYTOGO_REFRESH:*") + sessions = [] + for key in keys: + ttl = await redis.ttl(key) + if ttl <= 0: + continue + token = await redis.get(key) + email = None + user_id = key.decode().removeprefix("PYTOGO_REFRESH:") + if token: + try: + payload = decode_token(token.decode()) + email = payload.get("email") + except Exception: + pass + sessions.append(ActiveSession(user_id=user_id, email=email, expires_in_seconds=ttl)) + return success(sessions) diff --git a/app/routers/admin/speakers.py b/app/routers/admin/speakers.py new file mode 100644 index 0000000..2c5f1db --- /dev/null +++ b/app/routers/admin/speakers.py @@ -0,0 +1,102 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, File, Query, UploadFile, status + +from app.core.cloudinary import upload_image +from app.core.security import require_admin +from app.database.connection import get_db_connection +from app.schemas.models import SpeakerCreate, SpeakerSummary, SpeakerUpdate +from app.utils.speaker import ( + add_speaker, + delete_speaker, + get_speaker_by_id, + update_speaker, + update_speaker_photo, +) +from app.utils.responses import success +from app.utils.pagination import paginate + +api_router = APIRouter(prefix="/speakers", tags=["admin-speakers"]) + + +@api_router.get("") +async def list_all_speakers( + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + rows, total = await paginate( + db, + "SELECT * FROM speakers ORDER BY created_at DESC", + (), + page, per_page, + ) + return success(rows, total=total, page=page, per_page=per_page) + + +@api_router.get("/event/{event_code}") +async def list_speakers_by_event( + event_code: str, + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + rows, total = await paginate( + db, + """ + SELECT s.* + FROM speakers s + JOIN events e ON s.event_id = e.id + WHERE e.code = %s + ORDER BY s.created_at DESC + """, + (event_code.strip().upper(),), + page, per_page, + ) + return success(rows, total=total, page=page, per_page=per_page) + + +@api_router.post("/event/{event_code}", status_code=status.HTTP_201_CREATED) +async def create_speaker( + event_code: str, + payload: SpeakerCreate, + db=Depends(get_db_connection), + _=Depends(require_admin), +): + data = payload.model_dump(mode="json", exclude_none=True) + return success(await add_speaker(db, event_code, data), code=201) + + +@api_router.get("/{speaker_id}") +async def get_speaker(speaker_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + return success(await get_speaker_by_id(db, str(speaker_id))) + + +@api_router.put("/{speaker_id}") +async def update_speaker_details( + speaker_id: UUID, + payload: SpeakerUpdate, + db=Depends(get_db_connection), + _=Depends(require_admin), +): + data = payload.model_dump(mode="json", exclude_none=True) + return success(await update_speaker(db, str(speaker_id), data)) + + +@api_router.post("/{speaker_id}/photo") +async def upload_speaker_photo( + speaker_id: UUID, + file: UploadFile = File(...), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + photo_url = await upload_image(file, subfolder="speakers", public_id=str(speaker_id)) + return success(await update_speaker_photo(db, str(speaker_id), photo_url)) + + +@api_router.delete("/{speaker_id}") +async def delete_speaker_by_id(speaker_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + await delete_speaker(db, str(speaker_id)) + return success({"message": "Speaker deleted"}) diff --git a/app/routers/admin/sponsor_packages.py b/app/routers/admin/sponsor_packages.py new file mode 100644 index 0000000..e4fc6f1 --- /dev/null +++ b/app/routers/admin/sponsor_packages.py @@ -0,0 +1,67 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, status + +from app.core.security import require_admin +from app.database.connection import get_db_connection +from app.schemas.models import PartnerSponsorSummary, SponsorPackageCreate, SponsorPackageSummary, SponsorPackageUpdate +from app.utils.sponsor_packages import ( + assign_package_to_sponsor, + create_package, + delete_package, + get_package_by_id, + get_packages_by_event, + update_package, +) +from app.utils.responses import success + +api_router = APIRouter(prefix="/sponsor-packages", tags=["admin-sponsor-packages"]) + + +@api_router.get("/event/{event_code}") +async def list_packages(event_code: str, db=Depends(get_db_connection), _=Depends(require_admin)): + return success(await get_packages_by_event(db, event_code)) + + +@api_router.post("/event/{event_code}", status_code=status.HTTP_201_CREATED) +async def create_sponsor_package( + event_code: str, + payload: SponsorPackageCreate, + db=Depends(get_db_connection), + _=Depends(require_admin), +): + data = await create_package(db, event_code, payload.model_dump(mode="json")) + return success(data, code=201) + + +@api_router.get("/{package_id}") +async def get_package(package_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + return success(await get_package_by_id(db, str(package_id))) + + +@api_router.put("/{package_id}") +async def update_sponsor_package( + package_id: UUID, + payload: SponsorPackageUpdate, + db=Depends(get_db_connection), + _=Depends(require_admin), +): + data = await update_package(db, str(package_id), payload.model_dump(mode="json", exclude_none=True)) + return success(data) + + +@api_router.delete("/{package_id}") +async def delete_sponsor_package(package_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + await delete_package(db, str(package_id)) + return success({"message": "Package deleted"}) + + +@api_router.patch("/sponsors/{sponsor_id}/assign") +async def assign_package( + sponsor_id: UUID, + package_id: UUID | None = None, + db=Depends(get_db_connection), + _=Depends(require_admin), +): + data = await assign_package_to_sponsor(db, str(sponsor_id), str(package_id) if package_id else None) + return success(data) diff --git a/app/routers/admin/users.py b/app/routers/admin/users.py new file mode 100644 index 0000000..458c404 --- /dev/null +++ b/app/routers/admin/users.py @@ -0,0 +1,100 @@ +from fastapi import APIRouter, Depends, Query +from psycopg.rows import dict_row + +from app.core.security import require_admin +from app.database.connection import get_db_connection +from app.schemas.models import UserSummary, UsersDashboardOverview +from app.utils.responses import success +from app.utils.pagination import paginate + +api_router = APIRouter(prefix="/users", tags=["admin-users"]) + + +@api_router.get("/overview") +async def get_users_overview( + db=Depends(get_db_connection), + _=Depends(require_admin), +): + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute("SELECT COUNT(*) AS total FROM users") + total = (await cur.fetchone())["total"] + + await cur.execute("SELECT COUNT(*) AS total FROM users WHERE is_active = true") + active = (await cur.fetchone())["total"] + + await cur.execute("SELECT COUNT(*) AS total FROM users WHERE is_active = false") + inactive = (await cur.fetchone())["total"] + + await cur.execute( + "SELECT COUNT(*) AS total FROM users " + "WHERE created_at >= NOW() - INTERVAL '7 days'" + ) + new_last_7 = (await cur.fetchone())["total"] + + await cur.execute("SELECT role, COUNT(*) AS cnt FROM users GROUP BY role") + role_rows = await cur.fetchall() + + by_role = {row["role"]: row["cnt"] for row in role_rows} + + data = UsersDashboardOverview( + total_users=total, + active_users=active, + inactive_users=inactive, + new_last_7_days=new_last_7, + by_role=by_role, + ) + return success(data) + + +@api_router.get("/inactive") +async def list_inactive_users( + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + rows, total = await paginate( + db, + "SELECT * FROM users WHERE is_active = false ORDER BY created_at DESC", + (), + page, per_page, + ) + return success([UserSummary(**r) for r in rows], total=total, page=page, per_page=per_page) + + +@api_router.get("/new") +async def list_new_users( + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + rows, total = await paginate( + db, + "SELECT * FROM users WHERE created_at >= NOW() - INTERVAL '7 days' ORDER BY created_at DESC", + (), + page, per_page, + ) + return success([UserSummary(**r) for r in rows], total=total, page=page, per_page=per_page) + + +@api_router.get("/no-orders") +async def list_users_without_orders( + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + rows, total = await paginate( + db, + """ + SELECT u.* FROM users u + WHERE NOT EXISTS ( + SELECT 1 FROM shop_orders o WHERE o.user_id = u.id + ) + ORDER BY u.created_at DESC + """, + (), + page, per_page, + ) + return success([UserSummary(**r) for r in rows], total=total, page=page, per_page=per_page) diff --git a/app/routers/auth.py b/app/routers/auth.py index e69de29..e26ac60 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -0,0 +1,119 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import JSONResponse +from typing import Annotated +from app.database.connection import get_db_connection, get_redis_client +from app.database.orm import select, insert +from app.schemas.models import AuthenticatedUser, UserCreate, UserLogin, UserSummary +from app.core.security import ( + hash_password, + verify_password, + create_access_token, + create_refresh_token, + decode_token, + get_current_user, + get_user_permissions_from_db, +) +from app.utils.responses import success + +api_router = APIRouter(prefix="/auth", tags=["auth"]) + +_REFRESH_TTL = 7 * 24 * 3600 + + +async def _build_token_data(user: dict, db) -> dict: + user_id = str(user["id"]) + permissions = await get_user_permissions_from_db(user_id, db) + return { + "sub": user_id, + "email": user["email"], + "role": user["role"], + "is_admin": len(permissions) > 0, + "permissions": permissions, + } + + +@api_router.post("/register", status_code=status.HTTP_201_CREATED) +async def register(payload: UserCreate, db=Depends(get_db_connection)): + existing = await select(db, "users", filter={"email": payload.email}) + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered") + + existing_username = await select(db, "users", filter={"username": payload.username}) + if existing_username: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken") + + data = { + "username": payload.username, + "email": payload.email, + "full_name": payload.full_name, + "password_hash": hash_password(payload.password), + "role": "member", + "is_active": True, + } + await insert(db, "users", data) + + rows = await select(db, "users", filter={"email": payload.email}) + return success(UserSummary(**rows[0]), code=201) + + +@api_router.post("/login") +async def login(payload: UserLogin, db=Depends(get_db_connection), redis=Depends(get_redis_client)): + rows = await select(db, "users", filter={"email": payload.email}) + if not rows or not verify_password(payload.password, rows[0]["password_hash"]): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + + user = rows[0] + if not user["is_active"]: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user") + + token_data = await _build_token_data(user, db) + access_token = create_access_token(token_data) + refresh_token = create_refresh_token({"sub": token_data["sub"], "email": token_data["email"]}) + + await redis.set(f"PYTOGO_REFRESH:{user['id']}", refresh_token, ex=_REFRESH_TTL) + + return success({ + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + }) + + +@api_router.post("/refresh") +async def refresh(refresh_token: str, db=Depends(get_db_connection), redis=Depends(get_redis_client)): + payload = decode_token(refresh_token) + if payload.get("type") != "refresh": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type") + + user_id = payload.get("sub") + stored = await redis.get(f"PYTOGO_REFRESH:{user_id}") + if not stored or stored.decode() != refresh_token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token revoked or invalid") + + rows = await select(db, "users", filter={"id": user_id}) + if not rows or not rows[0]["is_active"]: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive") + + user = rows[0] + token_data = await _build_token_data(user, db) + new_access = create_access_token(token_data) + new_refresh = create_refresh_token({"sub": token_data["sub"], "email": token_data["email"]}) + + await redis.set(f"PYTOGO_REFRESH:{user_id}", new_refresh, ex=_REFRESH_TTL) + + return success({ + "access_token": new_access, + "refresh_token": new_refresh, + "token_type": "bearer", + }) + + +@api_router.post("/logout") +async def logout(current_user: Annotated[AuthenticatedUser, Depends(get_current_user)], redis=Depends(get_redis_client)): + await redis.delete(f"PYTOGO_REFRESH:{current_user.id}") + return success({"message": "Logged out successfully"}) + + +@api_router.get("/me") +async def me(current_user: Annotated[AuthenticatedUser, Depends(get_current_user)]): + return success(current_user) diff --git a/app/routers/contacts.py b/app/routers/contacts.py index dc2c3cc..c52219e 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -1,93 +1,84 @@ -from fastapi import APIRouter, BackgroundTasks, Depends, status, HTTPException +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status -from app.utils.contact import ( - add_contact, get_contact_by_id, get_all_contacts, update_contact, delete_contact) - -from app.schemas.models import ( - ContactMessageSummary, - MessageResponse, - ContactMessageUpdate, - ContactBase, - -) +from app.utils.contact import add_contact, get_contact_by_id, update_contact, delete_contact +from app.schemas.models import ContactBase, ContactMessageUpdate from app.database.connection import get_db_connection from app.core.settings import logger - +from app.utils.responses import success +from app.utils.pagination import paginate api_router = APIRouter(prefix="/contacts", tags=["contacts"]) -@api_router.get("/", response_model=list[ContactMessageSummary]) -async def _get_all_contacts(db=Depends(get_db_connection)): +@api_router.get("/") +async def _get_all_contacts( + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), +): try: - contacts = await get_all_contacts(db) - if not contacts: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, - detail="No contacts found") - return contacts + rows, total = await paginate( + db, + "SELECT * FROM contact_messages ORDER BY created_at DESC", + (), + page, per_page, + ) + if not rows and page == 1: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No contacts found") + return success(rows, total=total, page=page, per_page=per_page) + except HTTPException: + raise except Exception as e: logger.error(f"Error retrieving contacts: {str(e)}") - if isinstance(e, HTTPException): - raise e - raise HTTPException( - status_code=500, detail="Error retrieving contacts") + raise HTTPException(status_code=500, detail="Error retrieving contacts") -@api_router.get("/{contact_id}", response_model=ContactMessageSummary) +@api_router.get("/{contact_id}") async def _get_contact_by_id(contact_id: str, db=Depends(get_db_connection)): try: contact = await get_contact_by_id(db, contact_id) if not contact: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, - detail=f"Contact with id {contact_id} not found") - return contact + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Contact with id {contact_id} not found") + return success(contact) + except HTTPException: + raise except Exception as e: - logger.error( - f"Error retrieving contact with id {contact_id}: {str(e)}") - if isinstance(e, HTTPException): - raise e - raise HTTPException( - status_code=500, detail="Error retrieving contact") + logger.error(f"Error retrieving contact with id {contact_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Error retrieving contact") -@api_router.post("/send", response_model=MessageResponse, status_code=status.HTTP_201_CREATED) +@api_router.post("/send", status_code=status.HTTP_201_CREATED) async def add_contact_message(payload: ContactBase, background_tasks: BackgroundTasks, db=Depends(get_db_connection)): - """Add a new contact message.""" try: result = await add_contact(db, payload.model_dump(mode="json"), background_tasks) - return result + return success(result, code=201) + except HTTPException: + raise except Exception as e: logger.error(f"Error adding contact message: {str(e)}") - if isinstance(e, HTTPException): - raise e - raise HTTPException( - status_code=500, detail="Error adding contact message") + raise HTTPException(status_code=500, detail="Error adding contact message") -@api_router.put("/{contact_id}", response_model=MessageResponse) +@api_router.put("/{contact_id}") async def _update_contact(contact_id: str, payload: ContactMessageUpdate, background_tasks: BackgroundTasks, db=Depends(get_db_connection)): try: - data_to_update = {k: v for k, - v in payload.model_dump(mode="json").items() if v is not None} - + data_to_update = {k: v for k, v in payload.model_dump(mode="json").items() if v is not None} result = await update_contact(db, contact_id, data_to_update, background_tasks) - return result + return success(result) + except HTTPException: + raise except Exception as e: logger.error(f"Error updating contact: {str(e)}") - if isinstance(e, HTTPException): - raise e - raise HTTPException( - status_code=500, detail="Error updating contact") + raise HTTPException(status_code=500, detail="Error updating contact") -@api_router.delete("/{contact_id}", response_model=MessageResponse) +@api_router.delete("/{contact_id}") async def _delete_contact(contact_id: str, background_tasks: BackgroundTasks, db=Depends(get_db_connection)): try: result = await delete_contact(db, contact_id, background_tasks) - return result + return success(result) + except HTTPException: + raise except Exception as e: logger.error(f"Error deleting contact: {str(e)}") - if isinstance(e, HTTPException): - raise e - raise HTTPException( - status_code=500, detail="Error deleting contact") + raise HTTPException(status_code=500, detail="Error deleting contact") diff --git a/app/routers/events.py b/app/routers/events.py index 622be05..60e8b5b 100644 --- a/app/routers/events.py +++ b/app/routers/events.py @@ -1,91 +1,87 @@ -from fastapi import APIRouter, BackgroundTasks, Depends, status, HTTPException -from app.utils.event import ( - add_event, get_events, get_event_by_code, update_event, delete_event) -from app.schemas.models import ( - EventBase, EventSummary, EventUpdate, MessageResponse) -from app.core.settings import logger - +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status +from app.utils.event import add_event, get_event_by_code, update_event, delete_event +from app.schemas.models import EventBase, EventUpdate +from app.core.settings import logger from app.database.connection import get_db_connection +from app.utils.responses import success +from app.utils.pagination import paginate api_router = APIRouter(prefix="/events", tags=["events"]) -@api_router.post("/create", response_model=MessageResponse, status_code=status.HTTP_201_CREATED) +@api_router.post("/create", status_code=status.HTTP_201_CREATED) async def create_event(event: EventBase, background_tasks: BackgroundTasks, db=Depends(get_db_connection)): try: - result = await add_event(db, event.model_dump(mode="json"), background_tasks) - return result + return success(result, code=201) + except HTTPException: + raise except Exception as e: logger.error(f"Error adding event: {str(e)}") - # TODO - logging the error can be done here - if isinstance(e, HTTPException): - raise e - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error creating event") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Error creating event") -@api_router.get("/list", response_model=list[EventSummary]) -async def list_events(db=Depends(get_db_connection)): +@api_router.get("/list") +async def list_events( + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), +): try: - events = await get_events(db) - if not events: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, - detail="No events found") - return events + rows, total = await paginate( + db, + "SELECT * FROM events ORDER BY created_at DESC", + (), + page, per_page, + ) + if not rows and page == 1: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No events found") + return success(rows, total=total, page=page, per_page=per_page) + except HTTPException: + raise except Exception as e: logger.error(f"Error retrieving events: {str(e)}") - if isinstance(e, HTTPException): - raise e - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error retrieving events") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Error retrieving events") -@api_router.get("/get/{event_code}", response_model=EventSummary) +@api_router.get("/get/{event_code}") async def get_event(event_code: str, db=Depends(get_db_connection)): try: event_code = event_code.strip().upper() event = await get_event_by_code(db, event_code) if not event: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, - detail=f"Event with code {event_code} not found") - return event + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Event with code {event_code} not found") + return success(event) + except HTTPException: + raise except Exception as e: logger.error(f"Error retrieving event: {str(e)}") - if isinstance(e, HTTPException): - raise e - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error retrieving event") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Error retrieving event") -@api_router.put("/update/{event_code}", response_model=MessageResponse) -async def update_event_details(event_code: str, event_update: EventUpdate, background_tasks: BackgroundTasks, db=Depends(get_db_connection),): +@api_router.put("/update/{event_code}") +async def update_event_details(event_code: str, event_update: EventUpdate, background_tasks: BackgroundTasks, db=Depends(get_db_connection)): try: event_code = event_code.strip().upper() - event_data_to_update = { - k: v for k, v in event_update.model_dump(mode="json").items() if v is not None} - result = await update_event(db, event_code, event_data_to_update, background_tasks) - return result + data = {k: v for k, v in event_update.model_dump(mode="json").items() if v is not None} + result = await update_event(db, event_code, data, background_tasks) + return success(result) + except HTTPException: + raise except Exception as e: logger.error(f"Error updating event: {str(e)}") - # TODO - logging the error can be done here - if isinstance(e, HTTPException): - raise e - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error updating event") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Error updating event") -@api_router.delete("/delete/{event_code}", response_model=MessageResponse) +@api_router.delete("/delete/{event_code}") async def delete_event_by_code(event_code: str, background_tasks: BackgroundTasks, db=Depends(get_db_connection)): try: event_code = event_code.strip().upper() result = await delete_event(db, event_code, background_tasks) - return result + return success(result) + except HTTPException: + raise except Exception as e: logger.error(f"Error deleting event: {str(e)}") - - if isinstance(e, HTTPException): - raise e - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error deleting event") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Error deleting event") diff --git a/app/routers/partners_sponsors.py b/app/routers/partners_sponsors.py index c39526b..1f3e0f4 100644 --- a/app/routers/partners_sponsors.py +++ b/app/routers/partners_sponsors.py @@ -1,100 +1,110 @@ -from fastapi import APIRouter, BackgroundTasks, Depends, status, HTTPException -from app.utils.sponsor_partner import (add_sponsor_partner, get_sponsors_partners_by_event, - get_sponsors_partners, _update_partner_sponsor, delete_sponsor_partner) +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status - -from app.schemas.models import ( - HealthResponse, - MessageResponse, - PartnerSponsorSummary, - PartnerSponsorUpdate, - - PartnershipSponsorshipInquiry, - SponsorsPartnersList, - - -) +from app.utils.sponsor_partner import add_sponsor_partner, _update_partner_sponsor, delete_sponsor_partner +from app.schemas.models import PartnerSponsorUpdate, PartnershipSponsorshipInquiry from app.database.connection import get_db_connection from app.core.settings import logger +from app.utils.responses import success +from app.utils.pagination import paginate -api_router = APIRouter(prefix="/partners-sponsors", - tags=["Partners & Sponsors"]) +api_router = APIRouter(prefix="/partners-sponsors", tags=["Partners & Sponsors"]) -@api_router.post("/inquiry/{event_code}", response_model=MessageResponse, status_code=status.HTTP_202_ACCEPTED) -async def partnership_sponsorship_inquiry(event_code: str, payload: PartnershipSponsorshipInquiry, background_tasks: BackgroundTasks, db=Depends(get_db_connection)): +@api_router.post("/inquiry/{event_code}", status_code=status.HTTP_202_ACCEPTED) +async def partnership_sponsorship_inquiry( + event_code: str, + payload: PartnershipSponsorshipInquiry, + background_tasks: BackgroundTasks, + db=Depends(get_db_connection), +): try: - event_code = event_code.strip().upper() - payload_dict = payload.model_dump(mode="json") - result = await add_sponsor_partner(db, payload_dict, event_code, background_tasks) - return result + result = await add_sponsor_partner(db, payload.model_dump(mode="json"), event_code.strip().upper(), background_tasks) + return success(result, code=202) + except HTTPException: + raise except Exception as e: - logger.error( - f"Error processing partnership/sponsorship inquiry: {str(e)}") - if isinstance(e, HTTPException): - raise e - raise HTTPException( - status_code=500, detail=f"Error processing partnership/sponsorship request: {str(e)}") + logger.error(f"Error processing partnership/sponsorship inquiry: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error processing partnership/sponsorship request: {str(e)}") -@api_router.get("/all", response_model=list[PartnerSponsorSummary]) -async def get_all_partners_sponsors(db=Depends(get_db_connection)): +@api_router.get("/all") +async def get_all_partners_sponsors( + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), +): try: - partners_sponsors = await get_sponsors_partners(db) - if not partners_sponsors: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, - detail="No partners/sponsors found") - - return partners_sponsors + rows, total = await paginate( + db, + "SELECT * FROM sponsors_partners ORDER BY created_at DESC", + (), + page, per_page, + ) + if not rows and page == 1: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No partners/sponsors found") + return success(rows, total=total, page=page, per_page=per_page) + except HTTPException: + raise except Exception as e: logger.error(f"Error retrieving partners/sponsors: {str(e)}") - if isinstance(e, HTTPException): - raise e - raise HTTPException( - status_code=500, detail="Error retrieving partners/sponsors") + raise HTTPException(status_code=500, detail="Error retrieving partners/sponsors") -@api_router.get("/all/{event_code}", response_model=list[PartnerSponsorSummary]) -async def get_partners_sponsors(event_code: str, db=Depends(get_db_connection)): +@api_router.get("/all/{event_code}") +async def get_partners_sponsors( + event_code: str, + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), +): try: - event_code = event_code.strip().upper() - partners_sponsors = await get_sponsors_partners_by_event(db, event_code=event_code) - if not partners_sponsors: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, - detail=f"No partners/sponsors found for event {event_code}") - return partners_sponsors + code = event_code.strip().upper() + rows, total = await paginate( + db, + """ + SELECT sp.* + FROM sponsors_partners sp + JOIN events e ON sp.event_id = e.id + WHERE e.code = %s + ORDER BY sp.created_at DESC + """, + (code,), + page, per_page, + ) + if not rows and page == 1: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No partners/sponsors found for event {event_code}") + return success(rows, total=total, page=page, per_page=per_page) + except HTTPException: + raise except Exception as e: logger.error(f"Error retrieving partners/sponsors: {str(e)}") - if isinstance(e, HTTPException): - raise e - raise HTTPException( - status_code=500, detail="Error retrieving partners/sponsors") + raise HTTPException(status_code=500, detail="Error retrieving partners/sponsors") -@api_router.put("/{partner_id}", response_model=MessageResponse) -async def update_partner_sponsor(partner_id: str, payload: PartnerSponsorUpdate, background_tasks: BackgroundTasks, db=Depends(get_db_connection)): +@api_router.put("/{partner_id}") +async def update_partner_sponsor( + partner_id: str, + payload: PartnerSponsorUpdate, + background_tasks: BackgroundTasks, + db=Depends(get_db_connection), +): try: - data_to_update = payload.model_dump(mode="json") - - result = await _update_partner_sponsor(db, partner_id, data_to_update, background_tasks) - return result + result = await _update_partner_sponsor(db, partner_id, payload.model_dump(mode="json"), background_tasks) + return success(result) + except HTTPException: + raise except Exception as e: logger.error(f"Error updating partner/sponsor: {str(e)}") - if isinstance(e, HTTPException): - raise e - raise HTTPException( - status_code=500, detail="Error updating partner/sponsor") + raise HTTPException(status_code=500, detail="Error updating partner/sponsor") -@api_router.delete("/{partner_id}", response_model=MessageResponse) +@api_router.delete("/{partner_id}") async def delete_partner_sponsor(partner_id: str, background_tasks: BackgroundTasks, db=Depends(get_db_connection)): try: result = await delete_sponsor_partner(db, partner_id=partner_id, background_tasks=background_tasks) - return result + return success(result) + except HTTPException: + raise except Exception as e: - # TODO - logging the error can be done here - - if isinstance(e, HTTPException): - raise e - raise HTTPException( - status_code=500, detail="Error deleting partner/sponsor") + logger.error(f"Error deleting partner/sponsor: {str(e)}") + raise HTTPException(status_code=500, detail="Error deleting partner/sponsor") diff --git a/app/routers/proposals.py b/app/routers/proposals.py index aa2c408..fb215a0 100644 --- a/app/routers/proposals.py +++ b/app/routers/proposals.py @@ -1,113 +1,109 @@ -from fastapi import APIRouter, BackgroundTasks, Depends, status, HTTPException -from app.database.connection import get_db_connection -from app.utils.proposals import (add_proposal, get_proposals_by_event, - get_proposal_by_id, get_all_proposals, update_proposal, delete_proposal) -from app.schemas.models import ( - MessageResponse, - ProposalCreate, - ProposalSummary, - ProposalUpdate +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status -) +from app.database.connection import get_db_connection +from app.utils.proposals import add_proposal, get_proposal_by_id, update_proposal, delete_proposal +from app.schemas.models import ProposalCreate, ProposalUpdate from app.core.settings import logger - +from app.utils.responses import success +from app.utils.pagination import paginate api_router = APIRouter(prefix="/proposals", tags=["proposals"]) -@api_router.post("/create/{event_code}", response_model=MessageResponse, status_code=status.HTTP_201_CREATED) +@api_router.post("/create/{event_code}", status_code=status.HTTP_201_CREATED) async def create_proposal(proposal: ProposalCreate, event_code: str, background_tasks: BackgroundTasks, db=Depends(get_db_connection)): - """ - Create a new proposal for an event. - """ try: - event_code = event_code.strip().upper() - result = await add_proposal(db, proposal, event_code, background_tasks=background_tasks) - return result - except Exception as e: - if isinstance(e, HTTPException): - raise e + result = await add_proposal(db, proposal, event_code.strip().upper(), background_tasks=background_tasks) + return success(result, code=201) + except HTTPException: + raise + except Exception: raise HTTPException(status_code=500, detail="Internal server error") -@api_router.get("/list/{event_code}", response_model=list[ProposalSummary]) -async def list_proposals(event_code: str, db=Depends(get_db_connection)): - """ - List all proposals for a specific event. - """ +@api_router.get("/list/{event_code}") +async def list_proposals( + event_code: str, + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), +): try: - event_code = event_code.strip().upper() - proposals = await get_proposals_by_event(db, event_code) - if not proposals: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, - detail="No proposals found for this event") - return proposals + rows, total = await paginate( + db, + """ + SELECT p.* + FROM proposals p + JOIN events e ON p.event_id = e.id + WHERE e.code = %s + ORDER BY p.created_at DESC + """, + (event_code.strip().upper(),), + page, per_page, + ) + if not rows and page == 1: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No proposals found for this event") + return success(rows, total=total, page=page, per_page=per_page) + except HTTPException: + raise except Exception as e: - if isinstance(e, HTTPException): - raise e logger.error(f"Error retrieving proposals: {str(e)}") raise HTTPException(status_code=500, detail="Internal server error") -@api_router.get("/list", response_model=list[ProposalSummary]) -async def list_all_proposals(db=Depends(get_db_connection)): - """ - List all proposals across all events. - """ +@api_router.get("/list") +async def list_all_proposals( + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), +): try: - proposals = await get_all_proposals(db) - if not proposals: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, - detail="No proposals found") - return proposals - except Exception as e: - if isinstance(e, HTTPException): - raise e + rows, total = await paginate( + db, + "SELECT * FROM proposals ORDER BY created_at DESC", + (), + page, per_page, + ) + if not rows and page == 1: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No proposals found") + return success(rows, total=total, page=page, per_page=per_page) + except HTTPException: + raise + except Exception: raise HTTPException(status_code=500, detail="Internal server error") -@api_router.get("/{proposal_id}", response_model=ProposalSummary) +@api_router.get("/{proposal_id}") async def get_proposal(proposal_id: str, db=Depends(get_db_connection)): - """ - Retrieve a proposal by its ID. - """ try: proposal = await get_proposal_by_id(db, proposal_id) if not proposal: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, - detail=f"Proposal with id {proposal_id} not found") - return proposal - except Exception as e: - if isinstance(e, HTTPException): - raise e + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Proposal with id {proposal_id} not found") + return success(proposal) + except HTTPException: + raise + except Exception: raise HTTPException(status_code=500, detail="Internal server error") -@api_router.put("/update/{proposal_id}", response_model=MessageResponse) +@api_router.put("/update/{proposal_id}") async def update_proposal_details(proposal_id: str, proposal_update: ProposalUpdate, background_tasks: BackgroundTasks, db=Depends(get_db_connection)): - """ - Update a proposal's details. - """ try: result = await update_proposal(db, proposal_id, proposal_update, background_tasks) - return result + return success(result) + except HTTPException: + raise except Exception as e: logger.error(f"Error updating proposal: {str(e)}") - if isinstance(e, HTTPException): - raise e raise HTTPException(status_code=500, detail="Internal server error") -@api_router.delete("/delete/{proposal_id}", response_model=MessageResponse) +@api_router.delete("/delete/{proposal_id}") async def delete_proposal_by_id(proposal_id: str, background_tasks: BackgroundTasks, db=Depends(get_db_connection)): - """ - Delete a proposal by its ID. - """ try: result = await delete_proposal(db, proposal_id, background_tasks) - return result - except Exception as e: - - if isinstance(e, HTTPException): - raise e + return success(result) + except HTTPException: + raise + except Exception: raise HTTPException(status_code=500, detail="Internal server error") diff --git a/app/routers/registrations.py b/app/routers/registrations.py index e69de29..fce0dc1 100644 --- a/app/routers/registrations.py +++ b/app/routers/registrations.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, Depends, status +from typing import Annotated + +from app.core.security import get_current_user +from app.database.connection import get_db_connection +from app.schemas.models import AuthenticatedUser, RegistrationCreate +from app.utils.registrations import register_participant +from app.utils.responses import success + +api_router = APIRouter(prefix="/registrations", tags=["registrations"]) + + +@api_router.post("/{event_code}", status_code=status.HTTP_201_CREATED) +async def register_for_event( + event_code: str, + payload: RegistrationCreate, + db=Depends(get_db_connection), + current_user: AuthenticatedUser = Depends(get_current_user), +): + data = await register_participant(db, event_code, payload.model_dump(), user_id=str(current_user.id)) + return success(data, code=201) diff --git a/app/routers/sessions.py b/app/routers/sessions.py index 71292c8..b843423 100644 --- a/app/routers/sessions.py +++ b/app/routers/sessions.py @@ -1,12 +1,95 @@ -from fastapi import APIRouter, BackgroundTasks, Depends, status, HTTPException -from app.utils.sessions import (add_session, get_sessions_by_event, - get_session_by_id, get_all_sessions, update_session, delete_session) -from app.schemas.models import ( - MessageResponse, - SessionBase, - SessionUpdate, - SessionSummary, - SessionCreate +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status + +from app.core.security import require_admin +from app.database.connection import get_db_connection +from app.schemas.models import SessionCreate, SessionSummary, SessionUpdate +from app.utils.sessions import ( + add_session, + delete_session, + get_session_by_id, + get_sessions_schedule, + update_session, ) +from app.utils.responses import success +from app.utils.pagination import paginate + +router = APIRouter(prefix="/sessions", tags=["admin-sessions"]) + + +@router.get("") +async def list_all_sessions( + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + rows, total = await paginate( + db, + "SELECT * FROM sessions ORDER BY created_at DESC", + (), + page, per_page, + ) + return success(rows, total=total, page=page, per_page=per_page) + + +@router.get("/event/{event_code}") +async def list_sessions_by_event( + event_code: str, + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + rows, total = await paginate( + db, + """ + SELECT s.* + FROM sessions s + JOIN events e ON s.event_id = e.id + WHERE e.code = %s + ORDER BY s.start_time ASC + """, + (event_code.strip().upper(),), + page, per_page, + ) + return success(rows, total=total, page=page, per_page=per_page) + + +@router.get("/event/{event_code}/schedule") +async def get_event_schedule(event_code: str, db=Depends(get_db_connection), _=Depends(require_admin)): + return success(await get_sessions_schedule(db, event_code)) + + +@router.post("/event/{event_code}", status_code=status.HTTP_201_CREATED) +async def create_session( + event_code: str, + payload: SessionCreate, + db=Depends(get_db_connection), + _=Depends(require_admin), +): + data = payload.model_dump(mode="json", exclude_none=True) + return success(await add_session(db, event_code, data), code=201) + + +@router.get("/{session_id}") +async def get_session(session_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + return success(await get_session_by_id(db, str(session_id))) + + +@router.put("/{session_id}") +async def update_session_details( + session_id: UUID, + payload: SessionUpdate, + db=Depends(get_db_connection), + _=Depends(require_admin), +): + data = payload.model_dump(mode="json", exclude_none=True) + return success(await update_session(db, str(session_id), data)) + -router = APIRouter(prefix="/sessions", tags=["sessions"]) +@router.delete("/{session_id}") +async def delete_session_by_id(session_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + await delete_session(db, str(session_id)) + return success({"message": "Session deleted"}) diff --git a/app/routers/shop/__init__.py b/app/routers/shop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/shop/api.py b/app/routers/shop/api.py new file mode 100644 index 0000000..2d94e48 --- /dev/null +++ b/app/routers/shop/api.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter + +from app.routers.shop.categories import api_router as categories_router +from app.routers.shop.products import api_router as products_router +from app.routers.shop.orders import api_router as orders_router +from app.routers.shop.customers import api_router as customers_router +from app.routers.shop.payments import api_router as payments_router +from app.routers.shop.coupons import api_router as coupons_router +from app.routers.shop.dashboard import api_router as dashboard_router +from app.routers.shop.store import api_router as store_router + +# Admin routes — JWT + role admin/staff +shop_router = APIRouter(prefix="/admin/shop") +shop_router.include_router(dashboard_router) +shop_router.include_router(categories_router) +shop_router.include_router(products_router) +shop_router.include_router(orders_router) +shop_router.include_router(customers_router) +shop_router.include_router(payments_router) +shop_router.include_router(coupons_router) + +# Client routes — public + JWT pour cart/orders +client_shop_router = APIRouter(prefix="/shop") +client_shop_router.include_router(store_router) diff --git a/app/routers/shop/categories.py b/app/routers/shop/categories.py new file mode 100644 index 0000000..075e3d4 --- /dev/null +++ b/app/routers/shop/categories.py @@ -0,0 +1,64 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from uuid import UUID +from datetime import datetime, timezone + +from app.database.connection import get_db_connection +from app.database.orm import select, insert, update, delete +from app.schemas.shop import CategoryCreate, CategoryUpdate, CategorySummary +from app.core.security import require_admin +from app.utils.responses import success + +api_router = APIRouter(prefix="/categories", tags=["shop-categories"]) + + +@api_router.get("") +async def list_categories(db=Depends(get_db_connection), _=Depends(require_admin)): + return success(await select(db, "categories") or []) + + +@api_router.post("", status_code=status.HTTP_201_CREATED) +async def create_category(payload: CategoryCreate, db=Depends(get_db_connection), _=Depends(require_admin)): + if await select(db, "categories", filter={"slug": payload.slug}): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Slug already used") + data = payload.model_dump() + data = {k: str(v) if hasattr(v, '__str__') and not isinstance(v, (bool, int, float, type(None))) else v for k, v in data.items()} + await insert(db, "categories", {k: v for k, v in data.items() if v is not None}) + rows = await select(db, "categories", filter={"slug": payload.slug}) + return success(CategorySummary(**rows[0]), code=201) + + +@api_router.get("/{category_id}") +async def get_category(category_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "categories", filter={"id": str(category_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found") + return success(CategorySummary(**rows[0])) + + +@api_router.put("/{category_id}") +async def update_category(category_id: UUID, payload: CategoryUpdate, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "categories", filter={"id": str(category_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found") + await update(db, "categories", payload.model_dump(exclude_none=True), {"id": str(category_id)}) + rows = await select(db, "categories", filter={"id": str(category_id)}) + return success(CategorySummary(**rows[0])) + + +@api_router.patch("/{category_id}/toggle") +async def toggle_category(category_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "categories", filter={"id": str(category_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found") + await update(db, "categories", {"is_active": not rows[0]["is_active"], "updated_at": datetime.now(timezone.utc)}, {"id": str(category_id)}) + rows = await select(db, "categories", filter={"id": str(category_id)}) + return success(CategorySummary(**rows[0])) + + +@api_router.delete("/{category_id}") +async def delete_category(category_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "categories", filter={"id": str(category_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found") + await delete(db, "categories", {"id": str(category_id)}) + return success({"message": "Category deleted"}) diff --git a/app/routers/shop/coupons.py b/app/routers/shop/coupons.py new file mode 100644 index 0000000..c1705e5 --- /dev/null +++ b/app/routers/shop/coupons.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from uuid import UUID + +from app.database.connection import get_db_connection +from app.database.orm import select, insert, update, delete +from app.schemas.shop import CouponCreate, CouponUpdate, CouponSummary +from app.core.security import require_admin +from app.utils.responses import success +from app.utils.pagination import paginate + +api_router = APIRouter(prefix="/coupons", tags=["shop-coupons"]) + + +@api_router.get("") +async def list_coupons( + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + rows, total = await paginate( + db, + "SELECT * FROM coupons ORDER BY created_at DESC", + (), + page, per_page, + ) + return success(rows, total=total, page=page, per_page=per_page) + + +@api_router.post("", status_code=status.HTTP_201_CREATED) +async def create_coupon(payload: CouponCreate, db=Depends(get_db_connection), _=Depends(require_admin)): + if await select(db, "coupons", filter={"code": payload.code}): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Coupon code already exists") + data = {k: str(v) if v is not None and not isinstance(v, (bool, int, float)) else v for k, v in payload.model_dump().items()} + data = {k: v for k, v in data.items() if v is not None} + data["uses_count"] = 0 + await insert(db, "coupons", data) + rows = await select(db, "coupons", filter={"code": payload.code}) + return success(CouponSummary(**rows[0]), code=201) + + +@api_router.put("/{coupon_id}") +async def update_coupon(coupon_id: UUID, payload: CouponUpdate, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "coupons", filter={"id": str(coupon_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Coupon not found") + await update(db, "coupons", payload.model_dump(exclude_none=True), {"id": str(coupon_id)}) + rows = await select(db, "coupons", filter={"id": str(coupon_id)}) + return success(CouponSummary(**rows[0])) + + +@api_router.delete("/{coupon_id}") +async def delete_coupon(coupon_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "coupons", filter={"id": str(coupon_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Coupon not found") + await delete(db, "coupons", {"id": str(coupon_id)}) + return success({"message": "Coupon deleted"}) diff --git a/app/routers/shop/customers.py b/app/routers/shop/customers.py new file mode 100644 index 0000000..851f44f --- /dev/null +++ b/app/routers/shop/customers.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from uuid import UUID +from datetime import datetime, timezone + +from app.database.connection import get_db_connection +from app.database.orm import select, update +from app.schemas.models import UserSummary +from app.schemas.shop import OrderSummary +from app.core.security import require_admin +from app.utils.responses import success +from app.utils.pagination import paginate + +api_router = APIRouter(prefix="/customers", tags=["shop-customers"]) + + +@api_router.get("") +async def list_customers( + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + rows, total = await paginate( + db, + "SELECT * FROM users ORDER BY created_at DESC", + (), + page, per_page, + ) + return success(rows, total=total, page=page, per_page=per_page) + + +@api_router.get("/{customer_id}") +async def get_customer(customer_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "users", filter={"id": str(customer_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found") + return success(UserSummary(**rows[0])) + + +@api_router.get("/{customer_id}/orders") +async def get_customer_orders(customer_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "users", filter={"id": str(customer_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found") + return success(await select(db, "shop_orders", filter={"user_id": str(customer_id)}) or []) + + +@api_router.patch("/{customer_id}/toggle") +async def toggle_customer(customer_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "users", filter={"id": str(customer_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found") + await update(db, "users", {"is_active": not rows[0]["is_active"], "updated_at": datetime.now(timezone.utc)}, {"id": str(customer_id)}) + rows = await select(db, "users", filter={"id": str(customer_id)}) + return success(UserSummary(**rows[0])) diff --git a/app/routers/shop/dashboard.py b/app/routers/shop/dashboard.py new file mode 100644 index 0000000..bd0860a --- /dev/null +++ b/app/routers/shop/dashboard.py @@ -0,0 +1,119 @@ +from fastapi import APIRouter, Depends, Query +from psycopg.rows import dict_row +from decimal import Decimal + +from app.database.connection import get_db_connection +from app.schemas.shop import CouponUsageSummary, DashboardStats, LowStockVariant, OrderSummary, PendingOrderAlert, ShopAnalyticsOverview, TopProduct +from app.core.security import require_admin +from app.utils.responses import success + +api_router = APIRouter(prefix="/dashboard", tags=["shop-dashboard"]) + + +@api_router.get("") +async def get_dashboard(db=Depends(get_db_connection), _=Depends(require_admin)): + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute("SELECT COUNT(*) AS total FROM users") + total_users = (await cur.fetchone())["total"] + + await cur.execute("SELECT COUNT(*) AS total FROM shop_orders") + total_orders = (await cur.fetchone())["total"] + + await cur.execute( + "SELECT COALESCE(SUM(total_amount), 0) AS revenue FROM shop_orders " + "WHERE status IN ('paid', 'shipped', 'delivered')" + ) + total_revenue = (await cur.fetchone())["revenue"] + + await cur.execute("SELECT * FROM shop_orders ORDER BY created_at DESC LIMIT 10") + recent_rows = await cur.fetchall() + + data = DashboardStats( + total_users=total_users, + total_orders=total_orders, + total_revenue=Decimal(str(total_revenue)), + recent_orders=[OrderSummary(**r) for r in recent_rows], + ) + return success(data) + + +@api_router.get("/analytics") +async def get_shop_analytics( + low_stock_threshold: int = Query(default=5, ge=0), + pending_days_threshold: int = Query(default=3, ge=1), + top_n: int = Query(default=10, ge=1, le=50), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute("SELECT status, COUNT(*) AS cnt FROM shop_orders GROUP BY status") + orders_by_status = {r["status"]: r["cnt"] for r in await cur.fetchall()} + + await cur.execute( + "SELECT COALESCE(SUM(total_amount), 0) AS revenue FROM shop_orders " + "WHERE status IN ('paid', 'shipped', 'delivered') " + "AND DATE_TRUNC('month', created_at) = DATE_TRUNC('month', NOW())" + ) + revenue_month = Decimal(str((await cur.fetchone())["revenue"])) + + await cur.execute( + """ + SELECT p.id AS product_id, p.name AS product_name, + SUM(oi.quantity) AS total_sold, + SUM(oi.quantity * oi.unit_price) AS total_revenue + FROM order_items oi + JOIN product_variants pv ON pv.id = oi.product_variant_id + JOIN products p ON p.id = pv.product_id + JOIN shop_orders o ON o.id = oi.order_id + WHERE o.status IN ('paid', 'shipped', 'delivered') + GROUP BY p.id, p.name + ORDER BY total_sold DESC + LIMIT %s + """, + (top_n,), + ) + top_products = [TopProduct(**r) for r in await cur.fetchall()] + + await cur.execute( + """ + SELECT pv.id AS variant_id, pv.product_id, p.name AS product_name, + pv.name AS variant_name, pv.sku, pv.stock_quantity + FROM product_variants pv + JOIN products p ON p.id = pv.product_id + WHERE pv.stock_quantity <= %s AND pv.is_active = true + ORDER BY pv.stock_quantity ASC + """, + (low_stock_threshold,), + ) + low_stock = [LowStockVariant(**r) for r in await cur.fetchall()] + + await cur.execute( + "SELECT *, " + "CASE WHEN max_uses > 0 THEN ROUND(uses_count::numeric / max_uses * 100, 1) " + " ELSE 0 END AS usage_rate " + "FROM coupons WHERE is_active = true ORDER BY uses_count DESC" + ) + coupons = [CouponUsageSummary(**r) for r in await cur.fetchall()] + + await cur.execute( + """ + SELECT id AS order_id, user_id, total_amount, created_at, + EXTRACT(DAY FROM NOW() - created_at)::int AS pending_since_days + FROM shop_orders + WHERE status = 'pending' + AND created_at <= NOW() - (%s || ' days')::interval + ORDER BY created_at ASC + """, + (str(pending_days_threshold),), + ) + pending_alerts = [PendingOrderAlert(**r) for r in await cur.fetchall()] + + data = ShopAnalyticsOverview( + orders_by_status=orders_by_status, + revenue_current_month=revenue_month, + top_products=top_products, + low_stock_variants=low_stock, + coupons=coupons, + pending_alerts=pending_alerts, + ) + return success(data) diff --git a/app/routers/shop/orders.py b/app/routers/shop/orders.py new file mode 100644 index 0000000..5c88d75 --- /dev/null +++ b/app/routers/shop/orders.py @@ -0,0 +1,62 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from uuid import UUID +from datetime import datetime, timezone +from psycopg.rows import dict_row + +from app.database.connection import get_db_connection +from app.database.orm import select, update +from app.schemas.shop import OrderSummary, OrderDetail, OrderStatusUpdate, OrderItemSummary, OrderStatus +from app.core.security import require_admin +from app.utils.responses import success +from app.utils.pagination import paginate + +api_router = APIRouter(prefix="/orders", tags=["shop-orders"]) + + +@api_router.get("") +async def list_orders( + event_id: UUID | None = Query(default=None), + status_filter: OrderStatus | None = Query(default=None, alias="status"), + user_id: UUID | None = Query(default=None), + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + conditions: list[str] = [] + params: list = [] + if event_id: + conditions.append("event_id = %s") + params.append(str(event_id)) + if status_filter: + conditions.append("status = %s") + params.append(status_filter.value) + if user_id: + conditions.append("user_id = %s") + params.append(str(user_id)) + + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + sql = f"SELECT * FROM shop_orders {where} ORDER BY created_at DESC" + rows, total = await paginate(db, sql, tuple(params), page, per_page) + return success(rows, total=total, page=page, per_page=per_page) + + +@api_router.get("/{order_id}") +async def get_order(order_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "shop_orders", filter={"id": str(order_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Order not found") + items = await select(db, "order_items", filter={"order_id": str(order_id)}) or [] + order = OrderDetail(**rows[0]) + order.items = [OrderItemSummary(**i) for i in items] + return success(order) + + +@api_router.patch("/{order_id}/status") +async def update_order_status(order_id: UUID, payload: OrderStatusUpdate, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "shop_orders", filter={"id": str(order_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Order not found") + await update(db, "shop_orders", {"status": payload.status.value, "updated_at": datetime.now(timezone.utc)}, {"id": str(order_id)}) + rows = await select(db, "shop_orders", filter={"id": str(order_id)}) + return success(OrderSummary(**rows[0])) diff --git a/app/routers/shop/payments.py b/app/routers/shop/payments.py new file mode 100644 index 0000000..ddcf3ae --- /dev/null +++ b/app/routers/shop/payments.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from uuid import UUID + +from app.database.connection import get_db_connection +from app.database.orm import select +from app.schemas.shop import PaymentSummary +from app.core.security import require_admin +from app.utils.responses import success +from app.utils.pagination import paginate + +api_router = APIRouter(prefix="/payments", tags=["shop-payments"]) + + +@api_router.get("") +async def list_payments( + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + rows, total = await paginate( + db, + "SELECT * FROM shop_payments ORDER BY created_at DESC", + (), + page, per_page, + ) + return success(rows, total=total, page=page, per_page=per_page) + + +@api_router.get("/{payment_id}") +async def get_payment(payment_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "shop_payments", filter={"id": str(payment_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Payment not found") + return success(PaymentSummary(**rows[0])) diff --git a/app/routers/shop/products.py b/app/routers/shop/products.py new file mode 100644 index 0000000..a773c1c --- /dev/null +++ b/app/routers/shop/products.py @@ -0,0 +1,134 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from uuid import UUID +from datetime import datetime, timezone + +from app.database.connection import get_db_connection +from app.database.orm import select, insert, update, delete +from app.schemas.shop import ProductCreate, ProductUpdate, ProductSummary, VariantCreate, VariantUpdate, VariantSummary +from app.core.security import require_admin +from app.utils.responses import success +from app.utils.pagination import paginate + +api_router = APIRouter(prefix="/products", tags=["shop-products"]) + + +@api_router.get("") +async def list_products( + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), + _=Depends(require_admin), +): + rows, total = await paginate( + db, + "SELECT * FROM products ORDER BY created_at DESC", + (), + page, per_page, + ) + return success(rows, total=total, page=page, per_page=per_page) + + +@api_router.post("", status_code=status.HTTP_201_CREATED) +async def create_product(payload: ProductCreate, db=Depends(get_db_connection), _=Depends(require_admin)): + existing = await select(db, "products", filter={"slug": payload.slug}) + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Slug already used") + + data = {k: str(v) if v is not None else None for k, v in payload.model_dump().items()} + data = {k: v for k, v in data.items() if v is not None} + await insert(db, "products", data) + + rows = await select(db, "products", filter={"slug": payload.slug}) + return success(ProductSummary(**rows[0]), code=201) + + +@api_router.get("/{product_id}") +async def get_product(product_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "products", filter={"id": str(product_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found") + return success(ProductSummary(**rows[0])) + + +@api_router.put("/{product_id}") +async def update_product(product_id: UUID, payload: ProductUpdate, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "products", filter={"id": str(product_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found") + await update(db, "products", payload.model_dump(exclude_none=True), {"id": str(product_id)}) + rows = await select(db, "products", filter={"id": str(product_id)}) + return success(ProductSummary(**rows[0])) + + +@api_router.delete("/{product_id}") +async def delete_product(product_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "products", filter={"id": str(product_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found") + await delete(db, "products", {"id": str(product_id)}) + return success({"message": "Product deleted"}) + + +@api_router.patch("/{product_id}/toggle") +async def toggle_product(product_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "products", filter={"id": str(product_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found") + await update(db, "products", {"is_active": not rows[0]["is_active"], "updated_at": datetime.now(timezone.utc)}, {"id": str(product_id)}) + rows = await select(db, "products", filter={"id": str(product_id)}) + return success(ProductSummary(**rows[0])) + + +@api_router.get("/{product_id}/variants") +async def list_variants(product_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "products", filter={"id": str(product_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found") + return success(await select(db, "product_variants", filter={"product_id": str(product_id)}) or []) + + +@api_router.post("/{product_id}/variants", status_code=status.HTTP_201_CREATED) +async def add_variant(product_id: UUID, payload: VariantCreate, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "products", filter={"id": str(product_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found") + if await select(db, "product_variants", filter={"sku": payload.sku}): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU already used") + + data = payload.model_dump() + data["product_id"] = str(product_id) + data = {k: str(v) if v is not None and not isinstance(v, (bool, int, float, dict)) else v for k, v in data.items()} + data = {k: v for k, v in data.items() if v is not None} + await insert(db, "product_variants", data) + + rows = await select(db, "product_variants", filter={"sku": payload.sku}) + return success(VariantSummary(**rows[0]), code=201) + + +@api_router.put("/{product_id}/variants/{variant_id}") +async def update_variant(product_id: UUID, variant_id: UUID, payload: VariantUpdate, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "product_variants", filter={"id": str(variant_id), "product_id": str(product_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Variant not found") + await update(db, "product_variants", payload.model_dump(exclude_none=True), {"id": str(variant_id)}) + rows = await select(db, "product_variants", filter={"id": str(variant_id)}) + return success(VariantSummary(**rows[0])) + + +@api_router.patch("/{product_id}/variants/{variant_id}/toggle") +async def toggle_variant(product_id: UUID, variant_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "product_variants", filter={"id": str(variant_id), "product_id": str(product_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Variant not found") + await update(db, "product_variants", {"is_active": not rows[0]["is_active"], "updated_at": datetime.now(timezone.utc)}, {"id": str(variant_id)}) + rows = await select(db, "product_variants", filter={"id": str(variant_id)}) + return success(VariantSummary(**rows[0])) + + +@api_router.delete("/{product_id}/variants/{variant_id}") +async def delete_variant(product_id: UUID, variant_id: UUID, db=Depends(get_db_connection), _=Depends(require_admin)): + rows = await select(db, "product_variants", filter={"id": str(variant_id), "product_id": str(product_id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Variant not found") + await delete(db, "product_variants", {"id": str(variant_id)}) + return success({"message": "Variant deleted"}) diff --git a/app/routers/shop/store.py b/app/routers/shop/store.py new file mode 100644 index 0000000..c4d52ff --- /dev/null +++ b/app/routers/shop/store.py @@ -0,0 +1,289 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from typing import Annotated +from uuid import UUID +from decimal import Decimal +from datetime import datetime, timezone +from json import dumps, loads +from psycopg.rows import dict_row + +from app.database.connection import get_db_connection, get_redis_client +from app.database.orm import select +from app.schemas.shop import CategorySummary, ProductSummary, ProductDetail, VariantSummary, CartItem, CartItemUpdate, CartLineDetail, CartSummary, CheckoutPayload, OrderSummary, OrderDetail, OrderItemSummary +from app.core.security import get_current_user +from app.schemas.models import AuthenticatedUser +from app.utils.responses import success +from app.utils.pagination import paginate + +api_router = APIRouter(tags=["shop"]) + +_CART_TTL = 7 * 24 * 3600 + + +def _cart_key(user_id) -> str: + return f"PYTOGO_CART:{user_id}" + + +async def _get_raw_cart(redis, user_id: UUID) -> dict: + raw = await redis.get(_cart_key(user_id)) + if not raw: + return {"items": {}, "coupon_code": None} + return loads(raw) + + +async def _save_cart(redis, user_id: UUID, cart: dict): + await redis.set(_cart_key(user_id), dumps(cart), ex=_CART_TTL) + + +async def _build_cart_summary(cart: dict, db) -> CartSummary: + lines: list[CartLineDetail] = [] + for variant_id, qty in cart["items"].items(): + rows = await select(db, "product_variants", filter={"id": variant_id}) + if not rows or not rows[0]["is_active"]: + continue + v = rows[0] + product_rows = await select(db, "products", filter={"id": str(v["product_id"])}) + if not product_rows or not product_rows[0]["is_active"]: + continue + price = Decimal(str(v["price_override"] or product_rows[0]["base_price"])) + lines.append(CartLineDetail( + variant_id=variant_id, quantity=qty, sku=v["sku"], + name=f"{product_rows[0]['name']} — {v['name']}", + unit_price=price, subtotal=price * qty, + )) + + subtotal = sum(l.subtotal for l in lines) + coupon_code = cart.get("coupon_code") + return CartSummary(items=lines, coupon_code=coupon_code, subtotal=subtotal, discount_amount=Decimal("0.00"), total=subtotal) + + +# ── Public ──────────────────────────────────────────────────────────────────── + +@api_router.get("/categories") +async def list_categories(db=Depends(get_db_connection)): + return success(await select(db, "categories", filter={"is_active": True}) or []) + + +@api_router.get("/categories/{category_id}") +async def get_category(category_id: UUID, db=Depends(get_db_connection)): + rows = await select(db, "categories", filter={"id": str(category_id), "is_active": True}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found") + return success(CategorySummary(**rows[0])) + + +@api_router.get("/products") +async def list_products( + event_id: UUID | None = Query(default=None), + category_id: UUID | None = Query(default=None), + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), +): + conditions = ["is_active = true"] + params: list = [] + if event_id: + conditions.append("event_id = %s") + params.append(str(event_id)) + if category_id: + conditions.append("category_id = %s") + params.append(str(category_id)) + sql = f"SELECT * FROM products WHERE {' AND '.join(conditions)} ORDER BY created_at DESC" + rows, total = await paginate(db, sql, tuple(params), page, per_page) + return success(rows, total=total, page=page, per_page=per_page) + + +@api_router.get("/products/{product_id}") +async def get_product(product_id: UUID, db=Depends(get_db_connection)): + rows = await select(db, "products", filter={"id": str(product_id), "is_active": True}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found") + variants = await select(db, "product_variants", filter={"product_id": str(product_id), "is_active": True}) or [] + product = ProductDetail(**rows[0]) + product.variants = [VariantSummary(**v) for v in variants] + return success(product) + + +# ── Cart ────────────────────────────────────────────────────────────────────── + +@api_router.get("/cart") +async def get_cart( + current_user: Annotated[AuthenticatedUser, Depends(get_current_user)], + db=Depends(get_db_connection), + redis=Depends(get_redis_client), +): + cart = await _get_raw_cart(redis, current_user.id) + return success(await _build_cart_summary(cart, db)) + + +@api_router.post("/cart/items", status_code=status.HTTP_201_CREATED) +async def add_to_cart( + payload: CartItem, + current_user: Annotated[AuthenticatedUser, Depends(get_current_user)], + db=Depends(get_db_connection), + redis=Depends(get_redis_client), +): + variant_rows = await select(db, "product_variants", filter={"id": str(payload.variant_id), "is_active": True}) + if not variant_rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Variant not found or inactive") + + variant = variant_rows[0] + cart = await _get_raw_cart(redis, current_user.id) + new_qty = cart["items"].get(str(payload.variant_id), 0) + payload.quantity + + if new_qty > variant["stock_quantity"]: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Stock insuffisant — disponible : {variant['stock_quantity']}") + + cart["items"][str(payload.variant_id)] = new_qty + await _save_cart(redis, current_user.id, cart) + return success(await _build_cart_summary(cart, db), code=201) + + +@api_router.put("/cart/items/{variant_id}") +async def update_cart_item( + variant_id: UUID, + payload: CartItemUpdate, + current_user: Annotated[AuthenticatedUser, Depends(get_current_user)], + db=Depends(get_db_connection), + redis=Depends(get_redis_client), +): + cart = await _get_raw_cart(redis, current_user.id) + if str(variant_id) not in cart["items"]: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not in cart") + variant_rows = await select(db, "product_variants", filter={"id": str(variant_id)}) + if not variant_rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Variant not found") + if payload.quantity > variant_rows[0]["stock_quantity"]: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Stock insuffisant — disponible : {variant_rows[0]['stock_quantity']}") + cart["items"][str(variant_id)] = payload.quantity + await _save_cart(redis, current_user.id, cart) + return success(await _build_cart_summary(cart, db)) + + +@api_router.delete("/cart/items/{variant_id}") +async def remove_cart_item( + variant_id: UUID, + current_user: Annotated[AuthenticatedUser, Depends(get_current_user)], + db=Depends(get_db_connection), + redis=Depends(get_redis_client), +): + cart = await _get_raw_cart(redis, current_user.id) + cart["items"].pop(str(variant_id), None) + await _save_cart(redis, current_user.id, cart) + return success(await _build_cart_summary(cart, db)) + + +@api_router.delete("/cart") +async def clear_cart( + current_user: Annotated[AuthenticatedUser, Depends(get_current_user)], + redis=Depends(get_redis_client), +): + await redis.delete(_cart_key(current_user.id)) + return success({"message": "Cart cleared"}) + + +# ── Checkout ────────────────────────────────────────────────────────────────── + +@api_router.post("/cart/checkout", status_code=status.HTTP_201_CREATED) +async def checkout( + payload: CheckoutPayload, + current_user: Annotated[AuthenticatedUser, Depends(get_current_user)], + db=Depends(get_db_connection), + redis=Depends(get_redis_client), +): + cart = await _get_raw_cart(redis, current_user.id) + if not cart["items"]: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cart is empty") + + coupon_code = payload.coupon_code or cart.get("coupon_code") + coupon = None + discount_amount = Decimal("0.00") + + if coupon_code: + coupon_rows = await select(db, "coupons", filter={"code": coupon_code, "is_active": True}) + if not coupon_rows: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Coupon invalide ou inactif") + coupon = coupon_rows[0] + now = datetime.now(timezone.utc) + if coupon["expires_at"] and coupon["expires_at"] < now: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Coupon expiré") + if coupon["max_uses"] and coupon["uses_count"] >= coupon["max_uses"]: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Coupon épuisé") + + items_data = [] + subtotal = Decimal("0.00") + + for variant_id, qty in cart["items"].items(): + variant_rows = await select(db, "product_variants", filter={"id": variant_id, "is_active": True}) + if not variant_rows: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Variante {variant_id} introuvable ou désactivée") + v = variant_rows[0] + if not await select(db, "products", filter={"id": str(v["product_id"]), "is_active": True}): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Le produit lié à la variante {variant_id} est désactivé") + if v["stock_quantity"] < qty: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Stock insuffisant pour {v['sku']} — disponible : {v['stock_quantity']}") + product_rows = await select(db, "products", filter={"id": str(v["product_id"])}) + unit_price = Decimal(str(v["price_override"] or product_rows[0]["base_price"])) + subtotal += unit_price * qty + items_data.append({"variant_id": variant_id, "quantity": qty, "unit_price": unit_price}) + + if coupon: + if coupon["type"] == "percentage": + discount_amount = (subtotal * Decimal(str(coupon["value"])) / 100).quantize(Decimal("0.01")) + else: + discount_amount = min(Decimal(str(coupon["value"])), subtotal) + + total_amount = subtotal - discount_amount + + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute( + "INSERT INTO shop_orders (event_id, user_id, coupon_id, status, total_amount, discount_amount, shipping_address) " + "VALUES (%s, %s, %s, 'pending', %s, %s, %s) RETURNING *", + (str(payload.event_id), str(current_user.id), str(coupon["id"]) if coupon else None, + str(total_amount), str(discount_amount), dumps(payload.shipping_address)), + ) + order_row = await cur.fetchone() + + for item in items_data: + await cur.execute( + "INSERT INTO order_items (order_id, product_variant_id, quantity, unit_price) VALUES (%s, %s, %s, %s)", + (str(order_row["id"]), item["variant_id"], item["quantity"], str(item["unit_price"])), + ) + await cur.execute( + "UPDATE product_variants SET stock_quantity = stock_quantity - %s WHERE id = %s", + (item["quantity"], item["variant_id"]), + ) + if coupon: + await cur.execute("UPDATE coupons SET uses_count = uses_count + 1 WHERE id = %s", (str(coupon["id"]),)) + + await db.commit() + await redis.delete(_cart_key(current_user.id)) + return success(OrderSummary(**order_row), code=201) + + +# ── My orders ───────────────────────────────────────────────────────────────── + +@api_router.get("/orders/me") +async def my_orders( + current_user: Annotated[AuthenticatedUser, Depends(get_current_user)], + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), +): + rows, total = await paginate( + db, + "SELECT * FROM shop_orders WHERE user_id = %s ORDER BY created_at DESC", + (str(current_user.id),), + page, per_page, + ) + return success(rows, total=total, page=page, per_page=per_page) + + +@api_router.get("/orders/me/{order_id}") +async def my_order_detail(order_id: UUID, current_user: Annotated[AuthenticatedUser, Depends(get_current_user)], db=Depends(get_db_connection)): + rows = await select(db, "shop_orders", filter={"id": str(order_id), "user_id": str(current_user.id)}) + if not rows: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Order not found") + items = await select(db, "order_items", filter={"order_id": str(order_id)}) or [] + order = OrderDetail(**rows[0]) + order.items = [OrderItemSummary(**i) for i in items] + return success(order) diff --git a/app/routers/tracks.py b/app/routers/tracks.py index 598e952..abde965 100644 --- a/app/routers/tracks.py +++ b/app/routers/tracks.py @@ -1,107 +1,108 @@ -from fastapi import APIRouter, BackgroundTasks, Depends, status, HTTPException +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status + from app.database.connection import get_db_connection -from app.utils.tracks import (add_track, get_tracks_by_event, - get_track_by_id, get_all_tracks, update_track, delete_track) -from app.schemas.models import ( - MessageResponse, - TrackCreate, - TrackSummary, - TrackUpdate, -) +from app.utils.tracks import add_track, get_track_by_id, update_track, delete_track +from app.schemas.models import TrackCreate, TrackUpdate from app.core.settings import logger +from app.utils.responses import success +from app.utils.pagination import paginate api_router = APIRouter(prefix="/tracks", tags=["tracks"]) -@api_router.post("/create/{event_code}", response_model=MessageResponse, status_code=status.HTTP_201_CREATED) +@api_router.post("/create/{event_code}", status_code=status.HTTP_201_CREATED) async def create_track(track: TrackCreate, event_code: str, background_tasks: BackgroundTasks, db=Depends(get_db_connection)): - """ - Create a new track for an event. - """ try: result = await add_track(db, track, event_code, background_tasks) - return result - except Exception as e: - if isinstance(e, HTTPException): - raise e + return success(result, code=201) + except HTTPException: + raise + except Exception: raise HTTPException(status_code=500, detail="Internal server error") -@api_router.get("/list/{event_code}", response_model=list[TrackSummary]) -async def list_tracks(event_code: str, db=Depends(get_db_connection)): - """ - List all tracks for a specific event. - """ +@api_router.get("/list/{event_code}") +async def list_tracks( + event_code: str, + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), +): try: - tracks = await get_tracks_by_event(db, event_code) - if not tracks: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, - detail="No tracks found for this event") - return tracks - except Exception as e: - if isinstance(e, HTTPException): - raise e + rows, total = await paginate( + db, + """ + SELECT t.* + FROM tracks t + JOIN events e ON t.event_id = e.id + WHERE e.code = %s + ORDER BY t.created_at DESC + """, + (event_code.strip().upper(),), + page, per_page, + ) + if not rows and page == 1: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No tracks found for this event") + return success(rows, total=total, page=page, per_page=per_page) + except HTTPException: + raise + except Exception: raise HTTPException(status_code=500, detail="Internal server error") -@api_router.get("/list", response_model=list[TrackSummary]) -async def list_all_tracks(db=Depends(get_db_connection)): - """ - List all tracks across all events. - """ +@api_router.get("/list") +async def list_all_tracks( + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + db=Depends(get_db_connection), +): try: - tracks = await get_all_tracks(db) - if not tracks: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, - detail="No tracks found") - return tracks + rows, total = await paginate( + db, + "SELECT * FROM tracks ORDER BY created_at DESC", + (), + page, per_page, + ) + if not rows and page == 1: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No tracks found") + return success(rows, total=total, page=page, per_page=per_page) + except HTTPException: + raise except Exception as e: logger.error(f"Error retrieving tracks: {str(e)}") - if isinstance(e, HTTPException): - raise e raise HTTPException(status_code=500, detail="Internal server error") -@api_router.get("/{track_id}", response_model=TrackSummary) +@api_router.get("/{track_id}") async def get_track(track_id: str, db=Depends(get_db_connection)): - """ - Get a track by its ID. - """ try: track = await get_track_by_id(db, track_id) if not track: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, - detail=f"Track with id {track_id} not found") - return track - except Exception as e: - if isinstance(e, HTTPException): - raise e + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Track with id {track_id} not found") + return success(track) + except HTTPException: + raise + except Exception: raise HTTPException(status_code=500, detail="Internal server error") -@api_router.put("/update/{track_id}", response_model=MessageResponse) +@api_router.put("/update/{track_id}") async def update_track_details(track_id: str, track_update: TrackUpdate, background_tasks: BackgroundTasks, db=Depends(get_db_connection)): - """ - Update details of an existing track. - """ try: result = await update_track(db, track_id, track_update, background_tasks) - return result - except Exception as e: - if isinstance(e, HTTPException): - raise e + return success(result) + except HTTPException: + raise + except Exception: raise HTTPException(status_code=500, detail="Internal server error") -@api_router.delete("/delete/{track_id}", response_model=MessageResponse) +@api_router.delete("/delete/{track_id}") async def delete_track_by_id(track_id: str, background_tasks: BackgroundTasks, db=Depends(get_db_connection)): - """ - Delete a track by its ID. - """ try: result = await delete_track(db, track_id, background_tasks) - return result - except Exception as e: - if isinstance(e, HTTPException): - raise e + return success(result) + except HTTPException: + raise + except Exception: raise HTTPException(status_code=500, detail="Internal server error") diff --git a/app/schemas/config.py b/app/schemas/config.py index 33c91a2..9b3c5f7 100644 --- a/app/schemas/config.py +++ b/app/schemas/config.py @@ -22,3 +22,17 @@ class Config(BaseModel): smtp_port: int = 587 smtp_user: str = "user" smtp_password: str = "password" + smtp_from_email: str = "no-reply@example.com" + smtp_from_name: str = "Python Togo" + cloudinary_cloud_name: str = "" + cloudinary_api_key: str = "" + cloudinary_api_secret: str = "" + cloudinary_folder: str = "pythontogo" + db_pool_min_size: int = 1 + db_pool_max_size: int = 5 + db_pool_timeout: int = 10 + db_ssl_mode: str = "require" + superadmin_email: str = "superadmin@pytogo.org" + superadmin_username: str = "superadmin" + superadmin_password: str = "ChangeMe!2025" + superadmin_full_name: str = "Super Admin" diff --git a/app/schemas/models.py b/app/schemas/models.py index dcf0532..dce178f 100644 --- a/app/schemas/models.py +++ b/app/schemas/models.py @@ -1,4 +1,5 @@ from datetime import date, datetime, timezone +from decimal import Decimal from enum import Enum from uuid import UUID @@ -61,6 +62,7 @@ class PartnerSponsorSummary(SponsorPartnerBase): website_url: str = None contact_email: str package_tier: PackageTier | None = None + package_id: UUID | None = None is_confirmed: bool = False created_at: datetime updated_at: datetime @@ -81,7 +83,45 @@ class PartnerSponsorUpdate(BaseModel): logo_url: str | None = None partner_type: PartnerType | None = None package_tier: PackageTier | None = None + package_id: UUID | None = None is_confirmed: bool | None = None + + +# ── Sponsor packages ────────────────────────────────────────────────────────── + +class SponsorPackageCreate(BaseModel): + name: str + tier: PackageTier + description: str | None = None + price: Decimal = Decimal("0.00") + benefits: list[str] = Field(default_factory=list) + max_slots: int | None = None + is_active: bool = True + + +class SponsorPackageUpdate(BaseModel): + name: str | None = None + description: str | None = None + price: Decimal | None = None + benefits: list[str] | None = None + max_slots: int | None = None + is_active: bool | None = None + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class SponsorPackageSummary(BaseModel): + id: UUID + event_id: UUID + name: str + tier: PackageTier + description: str | None = None + price: Decimal + benefits: list[str] + max_slots: int | None = None + slots_used: int = 0 + is_active: bool + created_at: datetime + updated_at: datetime updated_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc)) @@ -118,6 +158,51 @@ class ContactMessageUpdate(BaseModel): default_factory=lambda: datetime.now(timezone.utc)) +class UserRole(str, Enum): + ADMIN = "admin" + MEMBER = "member" + STAFF = "staff" + + +class UserCreate(BaseModel): + username: str + email: EmailStr + password: str + full_name: str | None = None + + +class UserLogin(BaseModel): + email: EmailStr + password: str + + +class UserSummary(BaseModel): + id: UUID + username: str + email: str + full_name: str | None = None + role: UserRole + is_active: bool + created_at: datetime + + +class AuthenticatedUser(UserSummary): + """UserSummary enriched with RBAC claims extracted from the JWT.""" + is_admin: bool = False + permissions: list[str] = Field(default_factory=list) + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class TokenData(BaseModel): + user_id: str | None = None + email: str | None = None + + class APIKeyResponse(BaseModel): api_key: str @@ -127,6 +212,168 @@ class APIKeyVerificationResponse(BaseModel): message: str | None = None +# ── Security dashboard ──────────────────────────────────────────────────────── + +class APIKeySummaryAdmin(BaseModel): + id: UUID + name: str + key_masked: str + event_id: UUID | None + event_code: str | None + created_at: datetime + is_cached: bool + + +class ActiveSession(BaseModel): + user_id: str + email: str | None + expires_in_seconds: int + + +class SecurityOverview(BaseModel): + total_api_keys: int + active_sessions: int + cached_api_keys: int + active_carts: int + + +# ── Outreach dashboard ──────────────────────────────────────────────────────── + +class OutreachOverview(BaseModel): + unresolved_contacts: int + unconfirmed_partners: int + total_contacts: int + total_partners: int + partners_by_type: dict[str, int] + partners_by_tier: dict[str, int] + + +# ── Events dashboard ────────────────────────────────────────────────────────── + +class EventDashboardItem(BaseModel): + id: UUID + code: str + title: str + start_date: date + end_date: date + is_active: bool + cfp_is_open: bool + total_proposals: int + accepted_proposals: int + acceptance_rate: float + confirmed_sponsors: int + total_speakers: int + total_sessions: int + + +class EventsDashboardOverview(BaseModel): + total_events: int + active_events: int + events: list[EventDashboardItem] + + +# ── Proposals dashboard ─────────────────────────────────────────────────────── + +class ProposalsDashboardOverview(BaseModel): + total_proposals: int + by_status: dict[str, int] + by_session_type: dict[str, int] + without_track: int + + +# ── Users dashboard ─────────────────────────────────────────────────────────── + +class UsersDashboardOverview(BaseModel): + total_users: int + active_users: int + inactive_users: int + new_last_7_days: int + by_role: dict[str, int] + + +# ── Global overview ─────────────────────────────────────────────────────────── + +# ── Registrations ───────────────────────────────────────────────────────────── + +class RegistrationStatus(str, Enum): + PENDING = "pending" + CONFIRMED = "confirmed" + CANCELLED = "cancelled" + CHECKED_IN = "checked_in" + + +class RegistrationBase(BaseModel): + full_name: str + email: EmailStr + phone: str | None = None + organization: str | None = None + ticket_type: str = "general" + + +class RegistrationCreate(RegistrationBase): + pass + + +class RegistrationUpdate(BaseModel): + full_name: str | None = None + phone: str | None = None + organization: str | None = None + ticket_type: str | None = None + notes: str | None = None + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class RegistrationStatusUpdate(BaseModel): + status: RegistrationStatus + + +class RegistrationSummary(RegistrationBase): + id: UUID + event_id: UUID + user_id: UUID | None = None + email: str + status: RegistrationStatus + checked_in_at: datetime | None = None + notes: str | None = None + created_at: datetime + updated_at: datetime + + +class RegistrationsDashboard(BaseModel): + total: int + by_status: dict[str, int] + by_ticket_type: dict[str, int] + checked_in_today: int + + +class GlobalOverview(BaseModel): + # users + total_users: int + active_users: int + new_users_last_7_days: int + users_by_role: dict[str, int] + # events + total_events: int + active_events: int + past_events: int + # proposals + total_proposals: int + pending_proposals: int + # participants + total_registrations: int + confirmed_registrations: int + # outreach + unresolved_contacts: int + unconfirmed_partners: int + # shop + total_orders: int + orders_by_status: dict[str, int] + total_revenue: Decimal + revenue_current_month: Decimal + # security + active_sessions: int + + # event @@ -361,21 +608,21 @@ class SpeakerUpdate(BaseModel): class SessionBase(BaseModel): - event_id: UUID | None = None track_id: UUID | None = None - venue_id: UUID | None = None + venue_id: UUID proposal_id: UUID | None = None + speaker_id: UUID | None = None title: str slug: str session_type: SessionType starts_at: datetime ends_at: datetime - summary: str | None = None - capacity: int | None = None + description: str | None = None class SessionSummary(SessionBase): id: UUID + event_id: UUID created_at: datetime updated_at: datetime @@ -388,12 +635,94 @@ class SessionUpdate(BaseModel): track_id: UUID | None = None venue_id: UUID | None = None proposal_id: UUID | None = None + speaker_id: UUID | None = None title: str | None = None slug: str | None = None session_type: SessionType | None = None starts_at: datetime | None = None ends_at: datetime | None = None - summary: str | None = None - capacity: int | None = None + description: str | None = None updated_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc)) + + +# ── RBAC ────────────────────────────────────────────────────────────────────── + +class PermissionSummary(BaseModel): + id: UUID + name: str + description: str | None + resource: str + action: str + created_at: datetime + + +class RoleSummary(BaseModel): + id: UUID + name: str + description: str | None + is_system: bool + created_at: datetime + updated_at: datetime + + +class RoleDetail(RoleSummary): + permissions: list[PermissionSummary] = Field(default_factory=list) + + +class RoleCreate(BaseModel): + name: str + description: str | None = None + + +class RoleUpdate(BaseModel): + name: str | None = None + description: str | None = None + + +class AssignPermissionsRequest(BaseModel): + permission_ids: list[UUID] + + +class AssignRoleRequest(BaseModel): + role_id: UUID + + +class UserRoleAssignment(BaseModel): + user_id: UUID + role_id: UUID + role_name: str + assigned_at: datetime + + +# ── CFP Review ──────────────────────────────────────────────────────────────── + +class TalkReviewCreate(BaseModel): + score: int = Field(..., ge=1, le=5, description="Rating from 1 (poor) to 5 (excellent)") + comment: str | None = None + + +class TalkReviewSummary(BaseModel): + id: UUID + proposal_id: UUID + reviewer_id: UUID + score: int + comment: str | None + created_at: datetime + updated_at: datetime + + +class TalkReviewMasked(BaseModel): + """Returned to reviewers who have not yet voted — hides individual scores.""" + proposal_id: UUID + has_reviewed: bool + total_reviews: int + + +class TalkStatusUpdate(BaseModel): + status: SubmissionStatus + + +class ProposalWithScore(ProposalSummary): + avg_score: float | None = None + review_count: int = 0 diff --git a/app/schemas/shop.py b/app/schemas/shop.py new file mode 100644 index 0000000..be2010f --- /dev/null +++ b/app/schemas/shop.py @@ -0,0 +1,296 @@ +from datetime import datetime +from decimal import Decimal +from enum import Enum +from uuid import UUID + +from pydantic import BaseModel, Field + + +class OrderStatus(str, Enum): + PENDING = "pending" + PAID = "paid" + SHIPPED = "shipped" + DELIVERED = "delivered" + CANCELLED = "cancelled" + + +class PaymentStatus(str, Enum): + PENDING = "pending" + SUCCEEDED = "succeeded" + FAILED = "failed" + REFUNDED = "refunded" + + +class CouponType(str, Enum): + PERCENTAGE = "percentage" + FIXED_AMOUNT = "fixed_amount" + + +# ── Categories ──────────────────────────────────────────────────────────────── + +class CategoryCreate(BaseModel): + name: str + slug: str + description: str | None = None + parent_id: UUID | None = None + is_active: bool = True + + +class CategoryUpdate(BaseModel): + name: str | None = None + slug: str | None = None + description: str | None = None + parent_id: UUID | None = None + is_active: bool | None = None + updated_at: datetime = Field(default_factory=datetime.utcnow) + + +class CategorySummary(BaseModel): + id: UUID + name: str + slug: str + description: str | None = None + parent_id: UUID | None = None + is_active: bool + created_at: datetime + updated_at: datetime + + +# ── Products ────────────────────────────────────────────────────────────────── + +class ProductCreate(BaseModel): + event_id: UUID + category_id: UUID | None = None + name: str + slug: str + description: str | None = None + image_url: str | None = None + base_price: Decimal = Decimal("0.00") + is_active: bool = True + + +class ProductUpdate(BaseModel): + category_id: UUID | None = None + name: str | None = None + slug: str | None = None + description: str | None = None + image_url: str | None = None + base_price: Decimal | None = None + is_active: bool | None = None + updated_at: datetime = Field(default_factory=datetime.utcnow) + + +class ProductSummary(BaseModel): + id: UUID + event_id: UUID + category_id: UUID | None = None + name: str + slug: str + description: str | None = None + image_url: str | None = None + base_price: Decimal + is_active: bool + created_at: datetime + updated_at: datetime + + +# ── Product Variants ────────────────────────────────────────────────────────── + +class VariantCreate(BaseModel): + name: str + sku: str + price_override: Decimal | None = None + stock_quantity: int = 0 + attributes: dict = Field(default_factory=dict) + is_active: bool = True + + +class VariantUpdate(BaseModel): + name: str | None = None + sku: str | None = None + price_override: Decimal | None = None + stock_quantity: int | None = None + attributes: dict | None = None + is_active: bool | None = None + updated_at: datetime = Field(default_factory=datetime.utcnow) + + +class VariantSummary(BaseModel): + id: UUID + product_id: UUID + name: str + sku: str + price_override: Decimal | None = None + stock_quantity: int + attributes: dict + is_active: bool + created_at: datetime + updated_at: datetime + + +# ── Orders ──────────────────────────────────────────────────────────────────── + +class OrderStatusUpdate(BaseModel): + status: OrderStatus + + +class OrderItemSummary(BaseModel): + id: UUID + order_id: UUID + product_variant_id: UUID + quantity: int + unit_price: Decimal + created_at: datetime + + +class OrderSummary(BaseModel): + id: UUID + event_id: UUID + user_id: UUID + coupon_id: UUID | None = None + status: OrderStatus + total_amount: Decimal + discount_amount: Decimal + shipping_address: dict + created_at: datetime + updated_at: datetime + + +class OrderDetail(OrderSummary): + items: list[OrderItemSummary] = Field(default_factory=list) + + +# ── Payments ────────────────────────────────────────────────────────────────── + +class PaymentSummary(BaseModel): + id: UUID + order_id: UUID + amount: Decimal + status: PaymentStatus + method: str | None = None + reference: str | None = None + created_at: datetime + updated_at: datetime + + +# ── Coupons ─────────────────────────────────────────────────────────────────── + +class CouponCreate(BaseModel): + event_id: UUID | None = None + code: str + type: CouponType + value: Decimal + max_uses: int | None = None + expires_at: datetime | None = None + is_active: bool = True + + +class CouponUpdate(BaseModel): + event_id: UUID | None = None + code: str | None = None + type: CouponType | None = None + value: Decimal | None = None + max_uses: int | None = None + expires_at: datetime | None = None + is_active: bool | None = None + updated_at: datetime = Field(default_factory=datetime.utcnow) + + +class CouponSummary(BaseModel): + id: UUID + event_id: UUID | None = None + code: str + type: CouponType + value: Decimal + max_uses: int | None = None + uses_count: int + expires_at: datetime | None = None + is_active: bool + created_at: datetime + updated_at: datetime + + +# ── Dashboard ───────────────────────────────────────────────────────────────── + +class DashboardStats(BaseModel): + total_users: int + total_orders: int + total_revenue: Decimal + recent_orders: list[OrderSummary] = Field(default_factory=list) + + +class TopProduct(BaseModel): + product_id: UUID + product_name: str + total_sold: int + total_revenue: Decimal + + +class LowStockVariant(BaseModel): + variant_id: UUID + product_id: UUID + product_name: str + variant_name: str + sku: str + stock_quantity: int + + +class CouponUsageSummary(CouponSummary): + usage_rate: float + + +class PendingOrderAlert(BaseModel): + order_id: UUID + user_id: UUID + total_amount: Decimal + pending_since_days: int + created_at: datetime + + +class ShopAnalyticsOverview(BaseModel): + orders_by_status: dict[str, int] + revenue_current_month: Decimal + top_products: list[TopProduct] + low_stock_variants: list[LowStockVariant] + coupons: list[CouponUsageSummary] + pending_alerts: list[PendingOrderAlert] + + +# ── Cart (Redis session) ────────────────────────────────────────────────────── + +class CartItem(BaseModel): + variant_id: UUID + quantity: int = Field(ge=1) + + +class CartItemUpdate(BaseModel): + quantity: int = Field(ge=1) + + +class CartLineDetail(BaseModel): + variant_id: UUID + quantity: int + sku: str + name: str + unit_price: Decimal + subtotal: Decimal + + +class CartSummary(BaseModel): + items: list[CartLineDetail] = Field(default_factory=list) + coupon_code: str | None = None + subtotal: Decimal = Decimal("0.00") + discount_amount: Decimal = Decimal("0.00") + total: Decimal = Decimal("0.00") + + +class CheckoutPayload(BaseModel): + event_id: UUID + shipping_address: dict = Field(default_factory=dict) + coupon_code: str | None = None + + +# ── Public product detail (with variants) ───────────────────────────────────── + +class ProductDetail(ProductSummary): + variants: list[VariantSummary] = Field(default_factory=list) diff --git a/app/utils/pagination.py b/app/utils/pagination.py new file mode 100644 index 0000000..61cf855 --- /dev/null +++ b/app/utils/pagination.py @@ -0,0 +1,38 @@ +"""Pagination helper for raw SQL queries. + +Usage: + rows, total = await paginate(db, "SELECT * FROM table ORDER BY created_at DESC", (), page, per_page) + return success(rows, total=total, page=page, per_page=per_page) +""" + +from psycopg.rows import dict_row + +DEFAULT_PER_PAGE = 20 +MAX_PER_PAGE = 100 + + +async def paginate( + db, + sql: str, + values: tuple | list, + page: int, + per_page: int, +) -> tuple[list[dict], int]: + """Execute *sql* with LIMIT/OFFSET and return (rows, total_count). + + The total is obtained by wrapping the query in a COUNT subquery so any + WHERE / JOIN / GROUP BY clauses are automatically respected. + """ + per_page = min(per_page, MAX_PER_PAGE) + offset = (page - 1) * per_page + values = tuple(values) + + async with db.cursor() as cur: + await cur.execute(f"SELECT COUNT(*) FROM ({sql}) AS _pq", values) + total: int = (await cur.fetchone())[0] + + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute(f"{sql} LIMIT %s OFFSET %s", values + (per_page, offset)) + rows: list[dict] = await cur.fetchall() + + return rows, total diff --git a/app/utils/registrations.py b/app/utils/registrations.py new file mode 100644 index 0000000..b1746e0 --- /dev/null +++ b/app/utils/registrations.py @@ -0,0 +1,100 @@ +from datetime import datetime, timezone + +from fastapi import HTTPException +from psycopg.rows import dict_row + +from app.database.orm import delete, insert, select, update + + +async def get_event_id_by_code(db, event_code: str) -> str: + rows = await select(db, "events", filter={"code": event_code.upper()}) + if not rows: + raise HTTPException(status_code=404, detail=f"Event '{event_code}' not found") + return str(rows[0]["id"]) + + +async def register_participant(db, event_code: str, payload: dict, user_id: str | None = None): + event_id = await get_event_id_by_code(db, event_code) + + existing = await select(db, "registrations", filter={"event_id": event_id, "email": payload["email"]}) + if existing: + raise HTTPException(status_code=409, detail="This email is already registered for this event") + + data = {**payload, "event_id": event_id} + if user_id: + data["user_id"] = user_id + await insert(db, "registrations", data) + + rows = await select(db, "registrations", filter={"event_id": event_id, "email": payload["email"]}) + return rows[0] + + +async def get_registrations_by_event(db, event_code: str) -> list: + event_id = await get_event_id_by_code(db, event_code) + return await select(db, "registrations", filter={"event_id": event_id}) or [] + + +async def get_registration_by_id(db, registration_id: str) -> dict: + rows = await select(db, "registrations", filter={"id": registration_id}) + if not rows: + raise HTTPException(status_code=404, detail="Registration not found") + return rows[0] + + +async def update_registration(db, registration_id: str, payload: dict): + await get_registration_by_id(db, registration_id) + payload["updated_at"] = datetime.now(timezone.utc) + await update(db, "registrations", payload, filter={"id": registration_id}) + return await get_registration_by_id(db, registration_id) + + +async def update_registration_status(db, registration_id: str, status: str): + await get_registration_by_id(db, registration_id) + data: dict = {"status": status, "updated_at": datetime.now(timezone.utc)} + if status == "checked_in": + data["checked_in_at"] = datetime.now(timezone.utc) + await update(db, "registrations", data, filter={"id": registration_id}) + return await get_registration_by_id(db, registration_id) + + +async def delete_registration(db, registration_id: str): + await get_registration_by_id(db, registration_id) + await delete(db, "registrations", filter={"id": registration_id}) + + +async def get_registrations_dashboard(db, event_code: str) -> dict: + event_id = await get_event_id_by_code(db, event_code) + + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute( + "SELECT COUNT(*) AS total FROM registrations WHERE event_id = %s", + (event_id,), + ) + total = (await cur.fetchone())["total"] + + await cur.execute( + "SELECT status, COUNT(*) AS cnt FROM registrations WHERE event_id = %s GROUP BY status", + (event_id,), + ) + by_status = {r["status"]: r["cnt"] for r in await cur.fetchall()} + + await cur.execute( + "SELECT ticket_type, COUNT(*) AS cnt FROM registrations WHERE event_id = %s GROUP BY ticket_type", + (event_id,), + ) + by_ticket_type = {r["ticket_type"]: r["cnt"] for r in await cur.fetchall()} + + await cur.execute( + "SELECT COUNT(*) AS total FROM registrations " + "WHERE event_id = %s AND status = 'checked_in' " + "AND checked_in_at >= CURRENT_DATE", + (event_id,), + ) + checked_in_today = (await cur.fetchone())["total"] + + return { + "total": total, + "by_status": by_status, + "by_ticket_type": by_ticket_type, + "checked_in_today": checked_in_today, + } diff --git a/app/utils/responses.py b/app/utils/responses.py new file mode 100644 index 0000000..5e0326c --- /dev/null +++ b/app/utils/responses.py @@ -0,0 +1,78 @@ +"""Standardised API response helpers. + +Every endpoint returns one of these two shapes: + +Success: + { + "success": true, + "code": 200, + "data": , + "meta": { "timestamp": "...", "version": "2.1.0", "total"?: N, "page"?: N, "per_page"?: N } + } + +Error (also used by the global exception handlers in main.py): + { + "success": false, + "code": 4xx|5xx, + "data": null, + "error": { "message": "...", "details": }, + "meta": { "timestamp": "...", "version": "2.1.0" } + } +""" + +from datetime import datetime, timezone +from typing import Any + +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse + +APP_VERSION = "2.1.0" + + +def _meta(total: int | None = None, page: int | None = None, per_page: int | None = None) -> dict: + m: dict[str, Any] = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "version": APP_VERSION, + } + if total is not None: + m["total"] = total + if page is not None: + m["page"] = page + if per_page is not None: + m["per_page"] = per_page + return m + + +def success( + data: Any = None, + code: int = 200, + total: int | None = None, + page: int | None = None, + per_page: int | None = None, +) -> JSONResponse: + return JSONResponse( + status_code=code, + content={ + "success": True, + "code": code, + "data": jsonable_encoder(data), + "meta": _meta(total=total, page=page, per_page=per_page), + }, + ) + + +def error( + message: str, + code: int = 400, + details: Any = None, +) -> JSONResponse: + return JSONResponse( + status_code=code, + content={ + "success": False, + "code": code, + "data": None, + "error": {"message": message, "details": jsonable_encoder(details)}, + "meta": _meta(), + }, + ) diff --git a/app/utils/send_email.py b/app/utils/send_email.py index e69de29..04b78c6 100644 --- a/app/utils/send_email.py +++ b/app/utils/send_email.py @@ -0,0 +1,98 @@ +import asyncio +import smtplib +from email.headerregistry import Address +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from app.core.settings import logger, settings + + +def _from_header() -> str: + """Build a properly formatted From header: "Name" """ + return str(Address(display_name=settings.smtp_from_name, addr_spec=settings.smtp_from_email)) + + +async def send_email( + to: str, + subject: str, + body_html: str, + body_text: str | None = None, + reply_to: str | None = None, +) -> None: + """Send an email via SMTP (runs in a thread executor to avoid blocking the event loop).""" + def _send() -> None: + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = _from_header() + msg["To"] = to + if reply_to: + msg["Reply-To"] = reply_to + + if body_text: + msg.attach(MIMEText(body_text, "plain", "utf-8")) + msg.attach(MIMEText(body_html, "html", "utf-8")) + + with smtplib.SMTP(settings.smtp_server, settings.smtp_port) as server: + server.ehlo() + server.starttls() + server.login(settings.smtp_user, settings.smtp_password) + server.sendmail(settings.smtp_from_email, [to], msg.as_string()) + + try: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _send) + logger.info("Email sent to %s | subject: %s", to, subject) + except Exception as exc: + logger.error("Failed to send email to %s: %s", to, exc) + raise + + +def build_talk_decision_email( + speaker_name: str, + talk_title: str, + status: str, + event_title: str, +) -> tuple[str, str, str]: + """Return (subject, text_body, html_body) for a talk-decision notification.""" + subject = f"[{event_title}] Your talk submission — {status.capitalize()}" + + status_messages: dict[str, str] = { + "accepted": "We are delighted to inform you that your talk has been accepted.", + "rejected": "After careful review, we regret that we are unable to accept your talk this time.", + "waitlisted": "Your talk has been placed on our waitlist. We will contact you if a spot becomes available.", + } + html_message = status_messages.get( + status, f"The status of your talk has been updated to: {status}." + ) + text_message = html_message.replace("", "").replace("", "") + + text_body = f"""Hello {speaker_name}, + +{text_message} + +Talk: {talk_title} +Event: {event_title} + +Thank you for your submission. + +Best regards, +The {event_title} Team +""" + + html_body = f""" + + +

{event_title}

+

Hello {speaker_name},

+

{html_message}

+
    +
  • Talk: {talk_title}
  • +
  • Event: {event_title}
  • +
+

Thank you for your submission.

+
+

Best regards,
The {event_title} Team

+ +""" + + return subject, text_body, html_body diff --git a/app/utils/sessions.py b/app/utils/sessions.py index e69de29..0781c51 100644 --- a/app/utils/sessions.py +++ b/app/utils/sessions.py @@ -0,0 +1,95 @@ +from datetime import datetime, timezone + +from fastapi import HTTPException +from psycopg.rows import dict_row + +from app.database.orm import delete, insert, select, update + + +async def _get_event_id(db, event_code: str) -> str: + rows = await select(db, "events", filter={"code": event_code.upper()}) + if not rows: + raise HTTPException(status_code=404, detail=f"Event '{event_code}' not found") + return str(rows[0]["id"]) + + +async def _get_or_404(db, session_id: str) -> dict: + rows = await select(db, "sessions", filter={"id": session_id}) + if not rows: + raise HTTPException(status_code=404, detail="Session not found") + return rows[0] + + +async def add_session(db, event_code: str, payload: dict) -> dict: + event_id = await _get_event_id(db, event_code) + + existing = await select(db, "sessions", filter={"slug": payload["slug"]}) + if existing: + raise HTTPException(status_code=409, detail=f"Slug '{payload['slug']}' is already used") + + data = {k: str(v) if v is not None and not isinstance(v, (bool, int, datetime)) else v + for k, v in payload.items()} + data = {k: v for k, v in data.items() if v is not None} + data["event_id"] = event_id + await insert(db, "sessions", data) + + rows = await select(db, "sessions", filter={"slug": payload["slug"]}) + return rows[0] + + +async def get_sessions_by_event(db, event_code: str) -> list: + event_id = await _get_event_id(db, event_code) + return await select(db, "sessions", filter={"event_id": event_id}) or [] + + +async def get_all_sessions(db) -> list: + return await select(db, "sessions") or [] + + +async def get_session_by_id(db, session_id: str) -> dict: + return await _get_or_404(db, session_id) + + +async def update_session(db, session_id: str, payload: dict) -> dict: + await _get_or_404(db, session_id) + + if "slug" in payload: + existing = await select(db, "sessions", filter={"slug": payload["slug"]}) + if existing and str(existing[0]["id"]) != session_id: + raise HTTPException(status_code=409, detail=f"Slug '{payload['slug']}' is already used") + + data = {k: str(v) if v is not None and not isinstance(v, (bool, int, datetime)) else v + for k, v in payload.items()} + data = {k: v for k, v in data.items() if v is not None} + data["updated_at"] = datetime.now(timezone.utc) + await update(db, "sessions", data, filter={"id": session_id}) + return await _get_or_404(db, session_id) + + +async def delete_session(db, session_id: str) -> None: + await _get_or_404(db, session_id) + await delete(db, "sessions", filter={"id": session_id}) + + +async def get_sessions_schedule(db, event_code: str) -> list[dict]: + """Sessions d'un event triées par starts_at avec speakers et tracks.""" + event_id = await _get_event_id(db, event_code) + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute( + """ + SELECT + s.id, s.title, s.slug, s.session_type, + s.starts_at, s.ends_at, s.description, + s.venue_id, s.track_id, s.proposal_id, + t.name AS track_name, t.color AS track_color, + sp.full_name AS speaker_name, sp.headline AS speaker_headline, + sp.photo_url AS speaker_photo_url + FROM sessions s + LEFT JOIN tracks t ON t.id = s.track_id + LEFT JOIN speakers sp ON sp.id = s.speaker_id + WHERE s.event_id = %s + ORDER BY s.starts_at ASC + """, + (event_id,), + ) + return await cur.fetchall() diff --git a/app/utils/speaker.py b/app/utils/speaker.py index e69de29..59b2e3d 100644 --- a/app/utils/speaker.py +++ b/app/utils/speaker.py @@ -0,0 +1,72 @@ +from datetime import datetime, timezone + +from fastapi import HTTPException + +from app.database.orm import delete, insert, select, update + + +async def _get_event_id(db, event_code: str) -> str: + rows = await select(db, "events", filter={"code": event_code.upper()}) + if not rows: + raise HTTPException(status_code=404, detail=f"Event '{event_code}' not found") + return str(rows[0]["id"]) + + +async def _get_or_404(db, speaker_id: str) -> dict: + rows = await select(db, "speakers", filter={"id": speaker_id}) + if not rows: + raise HTTPException(status_code=404, detail="Speaker not found") + return rows[0] + + +async def add_speaker(db, event_code: str, payload: dict) -> dict: + event_id = await _get_event_id(db, event_code) + + existing = await select(db, "speakers", filter={"event_id": event_id, "email": payload["email"]}) + if existing: + raise HTTPException(status_code=409, detail="A speaker with this email already exists for this event") + + data = {k: str(v) if v is not None and not isinstance(v, (bool, int, dict)) else v + for k, v in payload.items()} + data = {k: v for k, v in data.items() if v is not None} + data["event_id"] = event_id + await insert(db, "speakers", data) + + rows = await select(db, "speakers", filter={"event_id": event_id, "email": payload["email"]}) + return rows[0] + + +async def get_speakers_by_event(db, event_code: str) -> list: + event_id = await _get_event_id(db, event_code) + return await select(db, "speakers", filter={"event_id": event_id}) or [] + + +async def get_all_speakers(db) -> list: + return await select(db, "speakers") or [] + + +async def get_speaker_by_id(db, speaker_id: str) -> dict: + return await _get_or_404(db, speaker_id) + + +async def update_speaker(db, speaker_id: str, payload: dict) -> dict: + await _get_or_404(db, speaker_id) + payload["updated_at"] = datetime.now(timezone.utc) + data = {k: str(v) if v is not None and not isinstance(v, (bool, int, dict)) else v + for k, v in payload.items()} + data = {k: v for k, v in data.items() if v is not None} + await update(db, "speakers", data, filter={"id": speaker_id}) + return await _get_or_404(db, speaker_id) + + +async def update_speaker_photo(db, speaker_id: str, photo_url: str) -> dict: + await _get_or_404(db, speaker_id) + await update(db, "speakers", + {"photo_url": photo_url, "updated_at": datetime.now(timezone.utc)}, + filter={"id": speaker_id}) + return await _get_or_404(db, speaker_id) + + +async def delete_speaker(db, speaker_id: str) -> None: + await _get_or_404(db, speaker_id) + await delete(db, "speakers", filter={"id": speaker_id}) diff --git a/app/utils/sponsor_packages.py b/app/utils/sponsor_packages.py new file mode 100644 index 0000000..543fdb2 --- /dev/null +++ b/app/utils/sponsor_packages.py @@ -0,0 +1,117 @@ +from datetime import datetime, timezone + +from fastapi import HTTPException +from psycopg.rows import dict_row + +from app.database.orm import delete, insert, select, update + + +async def _get_event_id(db, event_code: str) -> str: + rows = await select(db, "events", filter={"code": event_code.upper()}) + if not rows: + raise HTTPException(status_code=404, detail=f"Event '{event_code}' not found") + return str(rows[0]["id"]) + + +async def _get_or_404(db, package_id: str) -> dict: + rows = await select(db, "sponsor_packages", filter={"id": package_id}) + if not rows: + raise HTTPException(status_code=404, detail="Sponsor package not found") + return rows[0] + + +async def create_package(db, event_code: str, payload: dict) -> dict: + event_id = await _get_event_id(db, event_code) + + existing = await select(db, "sponsor_packages", + filter={"event_id": event_id, "tier": payload["tier"]}) + if existing: + raise HTTPException( + status_code=409, + detail=f"A package with tier '{payload['tier']}' already exists for this event", + ) + + data = {**payload, "event_id": event_id} + await insert(db, "sponsor_packages", data) + + rows = await select(db, "sponsor_packages", + filter={"event_id": event_id, "tier": payload["tier"]}) + return rows[0] + + +async def get_packages_by_event(db, event_code: str) -> list[dict]: + event_id = await _get_event_id(db, event_code) + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute( + """ + SELECT p.*, + COUNT(sp.id) FILTER (WHERE sp.is_confirmed = true) AS slots_used + FROM sponsor_packages p + LEFT JOIN sponsors_partners sp ON sp.package_id = p.id + WHERE p.event_id = %s + GROUP BY p.id + ORDER BY p.price DESC + """, + (event_id,), + ) + return await cur.fetchall() + + +async def get_package_by_id(db, package_id: str) -> dict: + async with db.cursor(row_factory=dict_row) as cur: + await cur.execute( + """ + SELECT p.*, + COUNT(sp.id) FILTER (WHERE sp.is_confirmed = true) AS slots_used + FROM sponsor_packages p + LEFT JOIN sponsors_partners sp ON sp.package_id = p.id + WHERE p.id = %s + GROUP BY p.id + """, + (package_id,), + ) + row = await cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Sponsor package not found") + return row + + +async def update_package(db, package_id: str, payload: dict) -> dict: + await _get_or_404(db, package_id) + payload["updated_at"] = datetime.now(timezone.utc) + await update(db, "sponsor_packages", payload, filter={"id": package_id}) + return await get_package_by_id(db, package_id) + + +async def delete_package(db, package_id: str) -> None: + pkg = await _get_or_404(db, package_id) + sponsors = await select(db, "sponsors_partners", filter={"package_id": package_id}) + if sponsors: + raise HTTPException( + status_code=409, + detail="Cannot delete a package that has sponsors assigned. Reassign or remove them first.", + ) + await delete(db, "sponsor_packages", filter={"id": package_id}) + + +async def assign_package_to_sponsor(db, sponsor_id: str, package_id: str | None) -> dict: + sponsors = await select(db, "sponsors_partners", filter={"id": sponsor_id}) + if not sponsors: + raise HTTPException(status_code=404, detail="Sponsor not found") + + data: dict = {"updated_at": datetime.now(timezone.utc)} + + if package_id: + pkg = await get_package_by_id(db, package_id) + if not pkg["is_active"]: + raise HTTPException(status_code=400, detail="This package is no longer active") + if pkg["max_slots"] and pkg["slots_used"] >= pkg["max_slots"]: + raise HTTPException(status_code=409, detail="No slots available for this package") + data["package_id"] = package_id + data["package_tier"] = pkg["tier"] + else: + data["package_id"] = None + + await update(db, "sponsors_partners", data, filter={"id": sponsor_id}) + rows = await select(db, "sponsors_partners", filter={"id": sponsor_id}) + return rows[0] diff --git a/docs/shop/admin.md b/docs/shop/admin.md new file mode 100644 index 0000000..f96d8dc --- /dev/null +++ b/docs/shop/admin.md @@ -0,0 +1,586 @@ +# Shop Admin — Guide de test +**Base URL :** `/api/v2/admin/shop` +**Auth requise sur tous les endpoints :** `Authorization: Bearer ` (role `admin` ou `staff`) + +--- + +## Ordre de test recommandé + +``` +1. Auth → obtenir un token admin +2. Catégories → créer les catégories (nécessaires pour les produits) +3. Produits → créer les produits (nécessite event_id + category_id) +4. Variantes → ajouter les variantes à chaque produit +5. Coupons → créer les codes promo +6. Commandes → consulter et gérer les commandes passées par les clients +7. Paiements → consulter l'historique des paiements +8. Clients → gérer les comptes clients +9. Dashboard → vérifier les statistiques globales +``` + +--- + +## 1. Auth — Obtenir un token admin + +> Les endpoints admin nécessitent un compte avec `role = admin` ou `staff`. +> Créer le compte via `POST /api/v2/auth/register` puis se connecter. + +### `POST /api/v2/auth/login` +```json +{ + "email": "admin@pytogo.org", + "password": "motdepasse" +} +``` + +**Réponse 200** +```json +{ + "access_token": "eyJ...", + "refresh_token": "eyJ...", + "token_type": "bearer" +} +``` + +> Utiliser `access_token` dans le header `Authorization: Bearer ` pour tous les appels suivants. + +--- + +## 2. Catégories + +### `POST /categories` +À faire en premier — les produits ont besoin d'un `category_id`. + +**Body** +```json +{ + "name": "T-Shirts", + "slug": "t-shirts", + "description": "Vêtements officiels PyCon TG", + "parent_id": null, + "is_active": true +} +``` +> `description` et `parent_id` sont optionnels. + +**Réponse 201** +```json +{ + "id": "uuid-category", + "name": "T-Shirts", + "slug": "t-shirts", + "description": "Vêtements officiels PyCon TG", + "parent_id": null, + "is_active": true, + "created_at": "2026-04-19T10:00:00Z", + "updated_at": "2026-04-19T10:00:00Z" +} +``` + +> Conserver l'`id` retourné — il sera utilisé comme `category_id` dans les produits. + +--- + +### `GET /categories` +Pas de body. Vérifier que la catégorie créée apparaît. + +**Réponse 200** — liste de catégories. + +--- + +### `GET /categories/{category_id}` +Pas de body. + +**Réponse 200** — objet catégorie. + +--- + +### `PUT /categories/{category_id}` +**Body** — tous les champs sont optionnels : +```json +{ + "name": "T-Shirts PyCon", + "slug": "t-shirts-pycon", + "description": "Nouvelle description", + "parent_id": null, + "is_active": true +} +``` + +**Réponse 200** — catégorie mise à jour. + +--- + +### `PATCH /categories/{category_id}/toggle` +Pas de body. Inverse `is_active` de la catégorie. + +> Une catégorie désactivée n'apparaît plus dans le catalogue client. +> Les produits liés restent dans leur état — c'est `is_active` du produit qui contrôle leur visibilité. + +**Réponse 200** — objet catégorie avec `is_active` mis à jour. + +--- + +### `DELETE /categories/{category_id}` +Pas de body. + +**Réponse 204** — pas de contenu. + +--- + +## 3. Produits + +### `POST /products` +Nécessite un `event_id` existant et un `category_id` créé à l'étape 2. + +**Body** +```json +{ + "event_id": "uuid-event", + "category_id": "uuid-category", + "name": "T-Shirt PyCon TG 2026", + "slug": "t-shirt-pycon-tg-2026", + "description": "T-shirt officiel de l'édition 2026", + "image_url": "https://cdn.example.com/tshirt.png", + "base_price": "15.00", + "is_active": true +} +``` +> `category_id`, `description`, `image_url` sont optionnels. + +**Réponse 201** +```json +{ + "id": "uuid-product", + "event_id": "uuid-event", + "category_id": "uuid-category", + "name": "T-Shirt PyCon TG 2026", + "slug": "t-shirt-pycon-tg-2026", + "description": "T-shirt officiel de l'édition 2026", + "image_url": "https://cdn.example.com/tshirt.png", + "base_price": "15.00", + "is_active": true, + "created_at": "2026-04-19T10:00:00Z", + "updated_at": "2026-04-19T10:00:00Z" +} +``` + +> Conserver l'`id` retourné — il sera utilisé pour ajouter des variantes. + +--- + +### `GET /products` +Pas de body. Vérifier que le produit créé apparaît. + +**Réponse 200** — liste de produits. + +--- + +### `GET /products/{product_id}` +Pas de body. + +**Réponse 200** — objet produit. + +--- + +### `PUT /products/{product_id}` +**Body** — tous les champs sont optionnels : +```json +{ + "name": "T-Shirt PyCon TG 2026 — Edition limitée", + "base_price": "20.00", + "is_active": true +} +``` + +**Réponse 200** — produit mis à jour. + +--- + +### `PATCH /products/{product_id}/toggle` +Pas de body. Inverse `is_active` du produit. + +> Si le produit est **désactivé**, il disparaît du catalogue client et **toutes ses variantes** deviennent invisibles même si elles sont actives individuellement. + +**Réponse 200** — produit avec `is_active` mis à jour. + +--- + +### `DELETE /products/{product_id}` +Pas de body. + +**Réponse 204** — pas de contenu. + +--- + +## 4. Variantes + +### `POST /products/{product_id}/variants` +Ajouter au moins une variante pour que le produit soit achetable. + +**Body** +```json +{ + "name": "Taille L — Bleu", + "sku": "TSHIRT-2026-L-BLUE", + "price_override": null, + "stock_quantity": 50, + "attributes": { "size": "L", "color": "blue" }, + "is_active": true +} +``` +> `price_override` optionnel — si null, le `base_price` du produit est utilisé. +> `attributes` : objet JSON libre pour décrire la variante. + +**Réponse 201** +```json +{ + "id": "uuid-variant", + "product_id": "uuid-product", + "name": "Taille L — Bleu", + "sku": "TSHIRT-2026-L-BLUE", + "price_override": null, + "stock_quantity": 50, + "attributes": { "size": "L", "color": "blue" }, + "is_active": true, + "created_at": "2026-04-19T10:00:00Z", + "updated_at": "2026-04-19T10:00:00Z" +} +``` + +> Conserver l'`id` retourné — il sera utilisé par les clients pour ajouter au panier. + +--- + +### `GET /products/{product_id}/variants` +Pas de body. + +**Réponse 200** — liste des variantes du produit. + +--- + +### `PUT /products/{product_id}/variants/{variant_id}` +**Body** — tous les champs sont optionnels : +```json +{ + "stock_quantity": 30, + "price_override": "18.00" +} +``` + +**Réponse 200** — variante mise à jour. + +--- + +### `PATCH /products/{product_id}/variants/{variant_id}/toggle` +Pas de body. Inverse `is_active` de la variante. + +> Une variante désactivée n'apparaît plus dans le catalogue client et ne peut pas être ajoutée au panier. +> Si elle est dans un panier existant, elle sera ignorée lors du checkout. + +**Réponse 200** +```json +{ + "id": "uuid-variant", + "product_id": "uuid-product", + "name": "Taille L — Bleu", + "sku": "TSHIRT-2026-L-BLUE", + "price_override": null, + "stock_quantity": 50, + "attributes": { "size": "L", "color": "blue" }, + "is_active": false, + "created_at": "2026-04-19T10:00:00Z", + "updated_at": "2026-04-19T10:00:00Z" +} +``` + +--- + +### `DELETE /products/{product_id}/variants/{variant_id}` +Pas de body. + +**Réponse 204** — pas de contenu. + +--- + +## 5. Coupons + +### `POST /coupons` +**Body** +```json +{ + "event_id": "uuid-event", + "code": "PYCON2026", + "type": "percentage", + "value": "15.00", + "max_uses": 100, + "expires_at": "2026-12-31T23:59:59Z", + "is_active": true +} +``` +> `event_id`, `max_uses`, `expires_at` sont optionnels. +> `type` : `percentage` (valeur en %) ou `fixed_amount` (valeur en monnaie). + +**Réponse 201** +```json +{ + "id": "uuid-coupon", + "event_id": "uuid-event", + "code": "PYCON2026", + "type": "percentage", + "value": "15.00", + "max_uses": 100, + "uses_count": 0, + "expires_at": "2026-12-31T23:59:59Z", + "is_active": true, + "created_at": "2026-04-19T10:00:00Z", + "updated_at": "2026-04-19T10:00:00Z" +} +``` + +--- + +### `GET /coupons` +Pas de body. + +**Réponse 200** — liste des coupons. + +--- + +### `PUT /coupons/{coupon_id}` +**Body** — tous les champs sont optionnels : +```json +{ + "max_uses": 200, + "is_active": false +} +``` + +**Réponse 200** — coupon mis à jour. + +--- + +### `DELETE /coupons/{coupon_id}` +Pas de body. + +**Réponse 204** — pas de contenu. + +--- + +## 6. Commandes + +> Les commandes sont créées par les clients via `POST /api/v2/shop/cart/checkout`. +> L'admin peut les consulter et mettre à jour leur statut. + +### `GET /orders` +Pas de body. Paramètres query disponibles : + +| Paramètre | Type | Description | +|---|---|---| +| `event_id` | UUID | Filtrer par événement | +| `status` | string | Filtrer par statut | +| `user_id` | UUID | Filtrer par client | +| `limit` | int | Nombre max de résultats (défaut `50`, max `500`) | +| `offset` | int | Pagination (défaut `0`) | + +Exemples : +``` +GET /api/v2/admin/shop/orders?status=pending +GET /api/v2/admin/shop/orders?event_id=uuid&status=paid&limit=20 +GET /api/v2/admin/shop/orders?user_id=uuid +``` + +> Statuts possibles : `pending` | `paid` | `shipped` | `delivered` | `cancelled` + +**Réponse 200** — triée par date décroissante : +```json +[ + { + "id": "uuid", + "event_id": "uuid", + "user_id": "uuid", + "coupon_id": null, + "status": "pending", + "total_amount": "35.00", + "discount_amount": "0.00", + "shipping_address": { + "full_name": "Jean Dupont", + "address": "12 rue de la Paix", + "city": "Lomé", + "country": "Togo" + }, + "created_at": "2026-04-19T10:00:00Z", + "updated_at": "2026-04-19T10:00:00Z" + } +] +``` + +--- + +### `GET /orders/{order_id}` +Pas de body. + +**Réponse 200** — commande avec ses lignes : +```json +{ + "id": "uuid", + "event_id": "uuid", + "user_id": "uuid", + "coupon_id": null, + "status": "paid", + "total_amount": "35.00", + "discount_amount": "5.00", + "shipping_address": { + "full_name": "Jean Dupont", + "address": "12 rue de la Paix", + "city": "Lomé", + "country": "Togo" + }, + "created_at": "2026-04-19T10:00:00Z", + "updated_at": "2026-04-19T10:00:00Z", + "items": [ + { + "id": "uuid", + "order_id": "uuid", + "product_variant_id": "uuid", + "quantity": 2, + "unit_price": "15.00", + "created_at": "2026-04-19T10:00:00Z" + } + ] +} +``` + +--- + +### `PATCH /orders/{order_id}/status` +**Body** +```json +{ + "status": "shipped" +} +``` +> Progression logique : `pending` → `paid` → `shipped` → `delivered` +> Annulation possible à tout moment : `cancelled` + +**Réponse 200** — commande mise à jour. + +--- + +## 7. Paiements + +> Les paiements sont créés automatiquement lors du checkout. +> L'admin peut uniquement les consulter. + +### `GET /payments` +Pas de body. + +**Réponse 200** +```json +[ + { + "id": "uuid", + "order_id": "uuid", + "amount": "35.00", + "status": "succeeded", + "method": "mobile_money", + "reference": "TXN-ABC123", + "created_at": "2026-04-19T10:00:00Z", + "updated_at": "2026-04-19T10:00:00Z" + } +] +``` +> Statuts possibles : `pending` | `succeeded` | `failed` | `refunded` + +--- + +### `GET /payments/{payment_id}` +Pas de body. + +**Réponse 200** — objet paiement. + +--- + +## 8. Clients + +### `GET /customers` +Pas de body. + +**Réponse 200** +```json +[ + { + "id": "uuid", + "username": "johndoe", + "email": "john@example.com", + "full_name": "John Doe", + "role": "member", + "is_active": true, + "created_at": "2026-04-19T10:00:00Z" + } +] +``` + +--- + +### `GET /customers/{customer_id}` +Pas de body. + +**Réponse 200** — objet client. + +--- + +### `GET /customers/{customer_id}/orders` +Pas de body. + +**Réponse 200** — liste des commandes du client. + +--- + +### `PATCH /customers/{customer_id}/toggle` +Pas de body. Inverse `is_active` (bloquer / débloquer). + +**Réponse 200** — client mis à jour. + +--- + +## 9. Dashboard + +> À consulter en dernier pour vérifier que toutes les données remontent correctement. + +### `GET /dashboard` +Pas de body. + +**Réponse 200** +```json +{ + "total_users": 120, + "total_orders": 45, + "total_revenue": "1350.00", + "recent_orders": [ + { + "id": "uuid", + "event_id": "uuid", + "user_id": "uuid", + "coupon_id": null, + "status": "paid", + "total_amount": "35.00", + "discount_amount": "0.00", + "shipping_address": {}, + "created_at": "2026-04-19T10:00:00Z", + "updated_at": "2026-04-19T10:00:00Z" + } + ] +} +``` + +--- + +## Codes d'erreur communs + +| Code | Signification | +|---|---| +| `401` | Token manquant ou expiré | +| `403` | Role insuffisant (`admin` ou `staff` requis) | +| `404` | Ressource introuvable | +| `409` | Conflit — slug, code ou SKU déjà utilisé | diff --git a/docs/shop/client.md b/docs/shop/client.md new file mode 100644 index 0000000..9c52df6 --- /dev/null +++ b/docs/shop/client.md @@ -0,0 +1,387 @@ +# Shop Client — Guide de test +**Base URL :** `/api/v2/shop` + +> Les routes publiques ne nécessitent pas de token. +> Les routes panier et commandes nécessitent : `Authorization: Bearer ` + +--- + +## Ordre de test recommandé + +``` +1. Auth → créer un compte et se connecter +2. Catalogue → parcourir les catégories et produits (sans connexion) +3. Panier → ajouter des articles, modifier, appliquer un coupon +4. Checkout → passer la commande +5. Mes commandes → vérifier que la commande apparaît +``` + +--- + +## 1. Auth — Créer un compte et se connecter + +### `POST /api/v2/auth/register` +```json +{ + "username": "johndoe", + "email": "john@example.com", + "password": "motdepasse", + "full_name": "John Doe" +} +``` + +**Réponse 201** +```json +{ + "id": "uuid-user", + "username": "johndoe", + "email": "john@example.com", + "full_name": "John Doe", + "role": "member", + "is_active": true, + "created_at": "2026-04-19T10:00:00Z" +} +``` + +--- + +### `POST /api/v2/auth/login` +```json +{ + "email": "john@example.com", + "password": "motdepasse" +} +``` + +**Réponse 200** +```json +{ + "access_token": "eyJ...", + "refresh_token": "eyJ...", + "token_type": "bearer" +} +``` + +> Utiliser `access_token` dans le header `Authorization: Bearer ` pour les routes protégées. + +--- + +## 2. Catalogue (sans connexion) + +### `GET /categories` +Pas de body. Liste toutes les catégories actives. + +**Réponse 200** +```json +[ + { + "id": "uuid-category", + "name": "T-Shirts", + "slug": "t-shirts", + "description": "Vêtements officiels PyCon TG", + "parent_id": null, + "is_active": true, + "created_at": "2026-04-19T10:00:00Z", + "updated_at": "2026-04-19T10:00:00Z" + } +] +``` + +--- + +### `GET /categories/{category_id}` +Pas de body. + +**Réponse 200** — objet catégorie. + +--- + +### `GET /products` +Pas de body. Paramètres query optionnels : + +| Paramètre | Type | Description | +|---|---|---| +| `event_id` | UUID | Filtrer par événement | +| `category_id` | UUID | Filtrer par catégorie | + +Exemples : +``` +GET /api/v2/shop/products +GET /api/v2/shop/products?event_id=uuid-event +GET /api/v2/shop/products?category_id=uuid-category +``` + +**Réponse 200** +```json +[ + { + "id": "uuid-product", + "event_id": "uuid", + "category_id": "uuid", + "name": "T-Shirt PyCon TG 2026", + "slug": "t-shirt-pycon-tg-2026", + "description": "T-shirt officiel de l'édition 2026", + "image_url": "https://cdn.example.com/tshirt.png", + "base_price": "15.00", + "is_active": true, + "created_at": "2026-04-19T10:00:00Z", + "updated_at": "2026-04-19T10:00:00Z" + } +] +``` + +--- + +### `GET /products/{product_id}` +Pas de body. Retourne le produit **avec toutes ses variantes actives**. + +**Réponse 200** +```json +{ + "id": "uuid-product", + "event_id": "uuid", + "category_id": "uuid", + "name": "T-Shirt PyCon TG 2026", + "slug": "t-shirt-pycon-tg-2026", + "description": "T-shirt officiel de l'édition 2026", + "image_url": "https://cdn.example.com/tshirt.png", + "base_price": "15.00", + "is_active": true, + "created_at": "2026-04-19T10:00:00Z", + "updated_at": "2026-04-19T10:00:00Z", + "variants": [ + { + "id": "uuid-variant", + "product_id": "uuid-product", + "name": "Taille L — Bleu", + "sku": "TSHIRT-2026-L-BLUE", + "price_override": null, + "stock_quantity": 50, + "attributes": { "size": "L", "color": "blue" }, + "is_active": true, + "created_at": "2026-04-19T10:00:00Z", + "updated_at": "2026-04-19T10:00:00Z" + } + ] +} +``` + +> Si `price_override` est null, le prix est `base_price` du produit. +> Conserver le `variant_id` pour l'ajouter au panier. + +--- + +## 3. Panier — Auth requise + +Le panier est stocké en session Redis (TTL 7 jours). Il est vidé automatiquement après un checkout réussi. + +### `POST /cart/items` +Ajouter un article au panier. + +Header : `Authorization: Bearer ` + +**Body** +```json +{ + "variant_id": "uuid-variant", + "quantity": 2 +} +``` +> Si la variante est déjà dans le panier, la quantité est **additionnée**. +> Retourne `400` si le stock est insuffisant. + +**Réponse 201** +```json +{ + "items": [ + { + "variant_id": "uuid-variant", + "quantity": 2, + "sku": "TSHIRT-2026-L-BLUE", + "name": "T-Shirt PyCon TG 2026 — Taille L — Bleu", + "unit_price": "15.00", + "subtotal": "30.00" + } + ], + "coupon_code": null, + "subtotal": "30.00", + "discount_amount": "0.00", + "total": "30.00" +} +``` + +--- + +### `GET /cart` +Voir le contenu du panier. + +Header : `Authorization: Bearer ` +Pas de body. + +**Réponse 200** — même structure que ci-dessus. + +--- + +### `PUT /cart/items/{variant_id}` +Modifier la quantité d'un article. + +Header : `Authorization: Bearer ` + +**Body** +```json +{ + "quantity": 3 +} +``` +> Remplace la quantité existante. + +**Réponse 200** — panier mis à jour. + +--- + +### `DELETE /cart/items/{variant_id}` +Retirer un article du panier. + +Header : `Authorization: Bearer ` +Pas de body. + +**Réponse 200** — panier mis à jour. + +--- + +### `DELETE /cart` +Vider complètement le panier. + +Header : `Authorization: Bearer ` +Pas de body. + +**Réponse 204** — pas de contenu. + +--- + +## 4. Checkout — Auth requise + +### `POST /cart/checkout` +Header : `Authorization: Bearer ` + +**Body** +```json +{ + "event_id": "uuid-event", + "shipping_address": { + "full_name": "John Doe", + "address": "12 rue de la Paix", + "city": "Lomé", + "country": "Togo" + }, + "coupon_code": "PYCON2026" +} +``` +> `coupon_code` et `shipping_address` sont optionnels. + +**Ce qui se passe automatiquement :** +1. Validation du stock pour chaque article +2. Validation du coupon (actif, non expiré, non épuisé) +3. Calcul du total avec réduction +4. Création de la commande et ses lignes en base de données +5. Décrémentation des stocks +6. Incrémentation du compteur du coupon +7. Vidage du panier Redis + +**Réponse 201** +```json +{ + "id": "uuid-order", + "event_id": "uuid", + "user_id": "uuid", + "coupon_id": "uuid-coupon", + "status": "pending", + "total_amount": "25.50", + "discount_amount": "4.50", + "shipping_address": { + "full_name": "John Doe", + "address": "12 rue de la Paix", + "city": "Lomé", + "country": "Togo" + }, + "created_at": "2026-04-19T10:00:00Z", + "updated_at": "2026-04-19T10:00:00Z" +} +``` + +> Conserver l'`id` de la commande pour la consulter ensuite. + +--- + +## 5. Mes commandes — Auth requise + +### `GET /orders/me` +Header : `Authorization: Bearer ` +Pas de body. + +**Réponse 200** +```json +[ + { + "id": "uuid-order", + "event_id": "uuid", + "user_id": "uuid", + "coupon_id": null, + "status": "pending", + "total_amount": "30.00", + "discount_amount": "0.00", + "shipping_address": {}, + "created_at": "2026-04-19T10:00:00Z", + "updated_at": "2026-04-19T10:00:00Z" + } +] +``` + +--- + +### `GET /orders/me/{order_id}` +Header : `Authorization: Bearer ` +Pas de body. + +**Réponse 200** — commande avec ses lignes : +```json +{ + "id": "uuid-order", + "event_id": "uuid", + "user_id": "uuid", + "coupon_id": null, + "status": "pending", + "total_amount": "30.00", + "discount_amount": "0.00", + "shipping_address": { + "full_name": "John Doe", + "address": "12 rue de la Paix", + "city": "Lomé", + "country": "Togo" + }, + "created_at": "2026-04-19T10:00:00Z", + "updated_at": "2026-04-19T10:00:00Z", + "items": [ + { + "id": "uuid", + "order_id": "uuid-order", + "product_variant_id": "uuid-variant", + "quantity": 2, + "unit_price": "15.00", + "created_at": "2026-04-19T10:00:00Z" + } + ] +} +``` + +> Un client ne peut voir **que ses propres commandes**. + +--- + +## Codes d'erreur communs + +| Code | Signification | +|---|---| +| `400` | Panier vide / stock insuffisant / coupon invalide ou expiré | +| `401` | Token manquant ou expiré | +| `404` | Ressource introuvable | diff --git a/insomnia_pytogo.json b/insomnia_pytogo.json new file mode 100644 index 0000000..95a807b --- /dev/null +++ b/insomnia_pytogo.json @@ -0,0 +1,3159 @@ +{ + "_type": "export", + "__export_format": 4, + "__export_date": "2026-04-26T00:00:00.000Z", + "__export_source": "insomnia.desktop.app:v2023.5.8", + "resources": [ + { + "_id": "wrk_pytogo", + "_type": "workspace", + "parentId": null, + "modified": 1714089600000, + "created": 1714089600000, + "name": "Python Togo API v2.1.0", + "description": "API compl\u00e8te Python Togo Community Platform", + "scope": "collection" + }, + { + "_id": "env_pytogo", + "_type": "environment", + "parentId": "wrk_pytogo", + "modified": 1714089600000, + "created": 1714089600000, + "name": "Dev \u2014 localhost", + "data": { + "base_url": "http://localhost:8000", + "access_token": "PASTE_YOUR_ACCESS_TOKEN_HERE", + "refresh_token": "PASTE_YOUR_REFRESH_TOKEN_HERE", + "api_key": "PASTE_YOUR_API_KEY_HERE", + "event_code": "PYTOGO2025", + "event_id": "00000000-0000-0000-0000-000000000001", + "proposal_id": "00000000-0000-0000-0000-000000000002", + "track_id": "00000000-0000-0000-0000-000000000003", + "contact_id": "00000000-0000-0000-0000-000000000004", + "partner_id": "00000000-0000-0000-0000-000000000005", + "speaker_id": "00000000-0000-0000-0000-000000000006", + "session_id": "00000000-0000-0000-0000-000000000007", + "registration_id": "00000000-0000-0000-0000-000000000008", + "package_id": "00000000-0000-0000-0000-000000000009", + "role_id": "00000000-0000-0000-0000-000000000010", + "permission_id": "00000000-0000-0000-0000-000000000011", + "user_id": "00000000-0000-0000-0000-000000000012", + "product_id": "00000000-0000-0000-0000-000000000013", + "variant_id": "00000000-0000-0000-0000-000000000014", + "category_id": "00000000-0000-0000-0000-000000000015", + "order_id": "00000000-0000-0000-0000-000000000016", + "coupon_id": "00000000-0000-0000-0000-000000000017", + "customer_id": "00000000-0000-0000-0000-000000000018", + "payment_id": "00000000-0000-0000-0000-000000000019", + "sponsor_id": "00000000-0000-0000-0000-000000000020" + }, + "color": null, + "isPrivate": false, + "metaSortKey": 1714089600000 + }, + { + "_id": "fld_health", + "_type": "request_group", + "parentId": "wrk_pytogo", + "name": "\u2764\ufe0f Health", + "metaSortKey": -110 + }, + { + "_id": "req_000", + "_type": "request", + "parentId": "fld_health", + "name": "Health Check (DB + Redis)", + "method": "GET", + "url": "{{ base_url }}/api/v2/health", + "headers": [], + "body": {}, + "metaSortKey": -1 + }, + { + "_id": "fld_auth", + "_type": "request_group", + "parentId": "wrk_pytogo", + "name": "\ud83d\udd10 Auth", + "metaSortKey": -100 + }, + { + "_id": "fld_public", + "_type": "request_group", + "parentId": "wrk_pytogo", + "name": "\ud83c\udf10 Public API (API Key)", + "metaSortKey": -90 + }, + { + "_id": "fld_partners", + "_type": "request_group", + "parentId": "fld_public", + "name": "Partners & Sponsors", + "metaSortKey": -10 + }, + { + "_id": "fld_contacts", + "_type": "request_group", + "parentId": "fld_public", + "name": "Contacts", + "metaSortKey": -9 + }, + { + "_id": "fld_events_pub", + "_type": "request_group", + "parentId": "fld_public", + "name": "Events", + "metaSortKey": -8 + }, + { + "_id": "fld_proposals_pub", + "_type": "request_group", + "parentId": "fld_public", + "name": "Proposals (CFP)", + "metaSortKey": -7 + }, + { + "_id": "fld_tracks_pub", + "_type": "request_group", + "parentId": "fld_public", + "name": "Tracks", + "metaSortKey": -6 + }, + { + "_id": "fld_registrations", + "_type": "request_group", + "parentId": "wrk_pytogo", + "name": "\ud83c\udf9f\ufe0f Registrations (JWT)", + "metaSortKey": -80 + }, + { + "_id": "fld_shop_public", + "_type": "request_group", + "parentId": "wrk_pytogo", + "name": "\ud83d\uded2 Shop \u2014 Public + Cart (JWT)", + "metaSortKey": -70 + }, + { + "_id": "fld_admin_shop", + "_type": "request_group", + "parentId": "wrk_pytogo", + "name": "\ud83c\udfea Admin Shop (JWT + Admin)", + "metaSortKey": -60 + }, + { + "_id": "fld_shop_dash", + "_type": "request_group", + "parentId": "fld_admin_shop", + "name": "Dashboard", + "metaSortKey": -10 + }, + { + "_id": "fld_shop_cat", + "_type": "request_group", + "parentId": "fld_admin_shop", + "name": "Categories", + "metaSortKey": -9 + }, + { + "_id": "fld_shop_prod", + "_type": "request_group", + "parentId": "fld_admin_shop", + "name": "Products & Variants", + "metaSortKey": -8 + }, + { + "_id": "fld_shop_ord", + "_type": "request_group", + "parentId": "fld_admin_shop", + "name": "Orders", + "metaSortKey": -7 + }, + { + "_id": "fld_shop_cust", + "_type": "request_group", + "parentId": "fld_admin_shop", + "name": "Customers", + "metaSortKey": -6 + }, + { + "_id": "fld_shop_pay", + "_type": "request_group", + "parentId": "fld_admin_shop", + "name": "Payments", + "metaSortKey": -5 + }, + { + "_id": "fld_shop_coup", + "_type": "request_group", + "parentId": "fld_admin_shop", + "name": "Coupons", + "metaSortKey": -4 + }, + { + "_id": "fld_admin", + "_type": "request_group", + "parentId": "wrk_pytogo", + "name": "\u2699\ufe0f Admin (JWT + Admin)", + "metaSortKey": -50 + }, + { + "_id": "fld_adm_overview", + "_type": "request_group", + "parentId": "fld_admin", + "name": "Overview", + "metaSortKey": -20 + }, + { + "_id": "fld_adm_security", + "_type": "request_group", + "parentId": "fld_admin", + "name": "Security", + "metaSortKey": -19 + }, + { + "_id": "fld_adm_users", + "_type": "request_group", + "parentId": "fld_admin", + "name": "Users", + "metaSortKey": -18 + }, + { + "_id": "fld_adm_outreach", + "_type": "request_group", + "parentId": "fld_admin", + "name": "Outreach", + "metaSortKey": -17 + }, + { + "_id": "fld_adm_proposals", + "_type": "request_group", + "parentId": "fld_admin", + "name": "Proposals", + "metaSortKey": -16 + }, + { + "_id": "fld_adm_events", + "_type": "request_group", + "parentId": "fld_admin", + "name": "Events", + "metaSortKey": -15 + }, + { + "_id": "fld_adm_regs", + "_type": "request_group", + "parentId": "fld_admin", + "name": "Registrations", + "metaSortKey": -14 + }, + { + "_id": "fld_adm_speakers", + "_type": "request_group", + "parentId": "fld_admin", + "name": "Speakers", + "metaSortKey": -13 + }, + { + "_id": "fld_adm_sessions", + "_type": "request_group", + "parentId": "fld_admin", + "name": "Sessions", + "metaSortKey": -12 + }, + { + "_id": "fld_adm_packages", + "_type": "request_group", + "parentId": "fld_admin", + "name": "Sponsor Packages", + "metaSortKey": -11 + }, + { + "_id": "fld_adm_rbac", + "_type": "request_group", + "parentId": "fld_admin", + "name": "RBAC \u2014 Roles & Permissions", + "metaSortKey": -10 + }, + { + "_id": "fld_adm_cfp", + "_type": "request_group", + "parentId": "fld_admin", + "name": "CFP Review", + "metaSortKey": -9 + }, + { + "_id": "req_001", + "_type": "request", + "parentId": "fld_auth", + "name": "Register", + "method": "POST", + "url": "{{ base_url }}/api/v2/auth/register", + "headers": [ + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"username\": \"johndoe\",\n \"email\": \"john@example.com\",\n \"password\": \"Secret@2025\",\n \"full_name\": \"John Doe\"\n}" + }, + "metaSortKey": -1 + }, + { + "_id": "req_002", + "_type": "request", + "parentId": "fld_auth", + "name": "Login", + "method": "POST", + "url": "{{ base_url }}/api/v2/auth/login", + "headers": [ + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"email\": \"superadmin@pytogo.org\",\n \"password\": \"ChangeMe!2025\"\n}" + }, + "metaSortKey": -2 + }, + { + "_id": "req_003", + "_type": "request", + "parentId": "fld_auth", + "name": "Refresh Token", + "method": "POST", + "url": "{{ base_url }}/api/v2/auth/refresh", + "headers": [ + { + "name": "Content-Type", + "value": "application/json" + } + ], + "parameters": [ + { + "name": "refresh_token", + "value": "{{ refresh_token }}", + "disabled": false + } + ], + "body": { + "mimeType": "application/json", + "text": "" + }, + "metaSortKey": -3 + }, + { + "_id": "req_004", + "_type": "request", + "parentId": "fld_auth", + "name": "Logout", + "method": "POST", + "url": "{{ base_url }}/api/v2/auth/logout", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": { + "mimeType": "application/json", + "text": "" + }, + "metaSortKey": -4 + }, + { + "_id": "req_005", + "_type": "request", + "parentId": "fld_auth", + "name": "Me (current user)", + "method": "GET", + "url": "{{ base_url }}/api/v2/auth/me", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -5 + }, + { + "_id": "req_006", + "_type": "request", + "parentId": "fld_partners", + "name": "Submit Partnership/Sponsorship Inquiry", + "method": "POST", + "url": "{{ base_url }}/api/v2/partners-sponsors/inquiry/{{ event_code }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"name\": \"Tech Corp\",\n \"website_url\": \"https://techcorp.com\",\n \"contact_name\": \"Alice Martin\",\n \"contact_email\": \"alice@techcorp.com\",\n \"contact_phone\": \"+228 90 00 00 00\",\n \"description\": \"Nous souhaitons sponsoriser l'\u00e9v\u00e9nement\",\n \"logo_url\": \"https://techcorp.com/logo.png\",\n \"partner_type\": \"sponsorship\",\n \"package_tier\": \"gold\"\n}" + }, + "metaSortKey": -1 + }, + { + "_id": "req_007", + "_type": "request", + "parentId": "fld_partners", + "name": "List All Partners/Sponsors", + "method": "GET", + "url": "{{ base_url }}/api/v2/partners-sponsors/all", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + } + ], + "body": {}, + "metaSortKey": -2, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_008", + "_type": "request", + "parentId": "fld_partners", + "name": "List Partners/Sponsors by Event", + "method": "GET", + "url": "{{ base_url }}/api/v2/partners-sponsors/all/{{ event_code }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + } + ], + "body": {}, + "metaSortKey": -3, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_009", + "_type": "request", + "parentId": "fld_partners", + "name": "Update Partner/Sponsor", + "method": "PUT", + "url": "{{ base_url }}/api/v2/partners-sponsors/{{ partner_id }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"name\": \"Tech Corp Updated\",\n \"is_confirmed\": true,\n \"package_tier\": \"platinum\",\n \"contact_email\": \"alice@techcorp.com\"\n}" + }, + "metaSortKey": -4 + }, + { + "_id": "req_010", + "_type": "request", + "parentId": "fld_partners", + "name": "Delete Partner/Sponsor", + "method": "DELETE", + "url": "{{ base_url }}/api/v2/partners-sponsors/{{ partner_id }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + } + ], + "body": {}, + "metaSortKey": -5 + }, + { + "_id": "req_011", + "_type": "request", + "parentId": "fld_contacts", + "name": "List All Contacts", + "method": "GET", + "url": "{{ base_url }}/api/v2/contacts/", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + } + ], + "body": {}, + "metaSortKey": -1, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_012", + "_type": "request", + "parentId": "fld_contacts", + "name": "Get Contact by ID", + "method": "GET", + "url": "{{ base_url }}/api/v2/contacts/{{ contact_id }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + } + ], + "body": {}, + "metaSortKey": -2 + }, + { + "_id": "req_013", + "_type": "request", + "parentId": "fld_contacts", + "name": "Send Contact Message", + "method": "POST", + "url": "{{ base_url }}/api/v2/contacts/send", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"name\": \"Koffi Amegah\",\n \"email\": \"koffi@example.com\",\n \"subject\": \"Demande d'information\",\n \"message\": \"Bonjour, je souhaite en savoir plus sur l'\u00e9v\u00e9nement.\"\n}" + }, + "metaSortKey": -3 + }, + { + "_id": "req_014", + "_type": "request", + "parentId": "fld_contacts", + "name": "Update Contact", + "method": "PUT", + "url": "{{ base_url }}/api/v2/contacts/{{ contact_id }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"is_resolved\": true\n}" + }, + "metaSortKey": -4 + }, + { + "_id": "req_015", + "_type": "request", + "parentId": "fld_contacts", + "name": "Delete Contact", + "method": "DELETE", + "url": "{{ base_url }}/api/v2/contacts/{{ contact_id }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + } + ], + "body": {}, + "metaSortKey": -5 + }, + { + "_id": "req_016", + "_type": "request", + "parentId": "fld_events_pub", + "name": "Create Event", + "method": "POST", + "url": "{{ base_url }}/api/v2/events/create", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"code\": \"PYTOGO2025\",\n \"title\": \"PyCon Togo 2025\",\n \"tagline\": \"La conf\u00e9rence Python au Togo\",\n \"description\": \"Rejoignez la communaut\u00e9 Python du Togo pour 2 jours de conf\u00e9rences, ateliers et networking.\",\n \"location\": \"Universit\u00e9 de Lom\u00e9\",\n \"country\": \"Togo\",\n \"city\": \"Lom\u00e9\",\n \"type\": \"conference\",\n \"format\": \"hybrid\",\n \"timezone\": \"Africa/Lome\",\n \"start_date\": \"2025-11-15\",\n \"end_date\": \"2025-11-16\",\n \"website_url\": \"https://pytogo.org\",\n \"cfp_open_at\": \"2025-07-01T00:00:00Z\",\n \"cfp_close_at\": \"2025-09-30T23:59:59Z\",\n \"ticket_sales_open_at\": \"2025-10-01T00:00:00Z\",\n \"ticket_sales_close_at\": \"2025-11-14T23:59:59Z\",\n \"is_active\": true\n}" + }, + "metaSortKey": -1 + }, + { + "_id": "req_017", + "_type": "request", + "parentId": "fld_events_pub", + "name": "List Events", + "method": "GET", + "url": "{{ base_url }}/api/v2/events/list", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + } + ], + "body": {}, + "metaSortKey": -2, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_018", + "_type": "request", + "parentId": "fld_events_pub", + "name": "Get Event by Code", + "method": "GET", + "url": "{{ base_url }}/api/v2/events/get/{{ event_code }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + } + ], + "body": {}, + "metaSortKey": -3 + }, + { + "_id": "req_019", + "_type": "request", + "parentId": "fld_events_pub", + "name": "Update Event", + "method": "PUT", + "url": "{{ base_url }}/api/v2/events/update/{{ event_code }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"title\": \"PyCon Togo 2025 \u2014 \u00c9dition Sp\u00e9ciale\",\n \"tagline\": \"Deux jours pour changer le monde en Python\",\n \"is_active\": true\n}" + }, + "metaSortKey": -4 + }, + { + "_id": "req_020", + "_type": "request", + "parentId": "fld_events_pub", + "name": "Delete Event", + "method": "DELETE", + "url": "{{ base_url }}/api/v2/events/delete/{{ event_code }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + } + ], + "body": {}, + "metaSortKey": -5 + }, + { + "_id": "req_021", + "_type": "request", + "parentId": "fld_proposals_pub", + "name": "Submit Proposal (CFP)", + "method": "POST", + "url": "{{ base_url }}/api/v2/proposals/create/{{ event_code }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"title\": \"Introduction \u00e0 FastAPI\",\n \"description\": \"Un tour complet de FastAPI pour construire des APIs modernes avec Python.\",\n \"abstract\": \"FastAPI est un framework moderne, rapide et bas\u00e9 sur Python 3.6+.\",\n \"level\": \"Beginner\",\n \"language\": \"French\",\n \"speaker_full_name\": \"Ama Kodjovi\",\n \"speaker_email\": \"ama@example.com\",\n \"speaker_phone\": \"+228 91 23 45 67\",\n \"speaker_organization\": \"Python Togo\",\n \"speaker_bio\": \"D\u00e9veloppeuse backend passionn\u00e9e par Python et l'open source.\",\n \"speaker_photo_url\": \"https://example.com/ama.jpg\",\n \"speaker_social_links\": {\n \"twitter\": \"https://twitter.com/ama\",\n \"linkedin\": \"https://linkedin.com/in/ama\"\n },\n \"session_type\": \"talk\",\n \"format\": \"onsite\",\n \"needs_equipment\": false\n}" + }, + "metaSortKey": -1 + }, + { + "_id": "req_022", + "_type": "request", + "parentId": "fld_proposals_pub", + "name": "List Proposals by Event", + "method": "GET", + "url": "{{ base_url }}/api/v2/proposals/list/{{ event_code }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + } + ], + "body": {}, + "metaSortKey": -2, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_023", + "_type": "request", + "parentId": "fld_proposals_pub", + "name": "List All Proposals", + "method": "GET", + "url": "{{ base_url }}/api/v2/proposals/list", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + } + ], + "body": {}, + "metaSortKey": -3, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_024", + "_type": "request", + "parentId": "fld_proposals_pub", + "name": "Get Proposal by ID", + "method": "GET", + "url": "{{ base_url }}/api/v2/proposals/{{ proposal_id }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + } + ], + "body": {}, + "metaSortKey": -4 + }, + { + "_id": "req_025", + "_type": "request", + "parentId": "fld_proposals_pub", + "name": "Update Proposal", + "method": "PUT", + "url": "{{ base_url }}/api/v2/proposals/update/{{ proposal_id }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"title\": \"Introduction \u00e0 FastAPI \u2014 Mise \u00e0 jour\",\n \"description\": \"Un tour complet de FastAPI.\",\n \"abstract\": \"FastAPI est un framework moderne.\",\n \"level\": \"Intermediate\",\n \"language\": \"French\",\n \"speaker_full_name\": \"Ama Kodjovi\",\n \"speaker_email\": \"ama@example.com\",\n \"session_type\": \"talk\",\n \"status\": \"submitted\",\n \"format\": \"onsite\",\n \"needs_equipment\": false\n}" + }, + "metaSortKey": -5 + }, + { + "_id": "req_026", + "_type": "request", + "parentId": "fld_proposals_pub", + "name": "Delete Proposal", + "method": "DELETE", + "url": "{{ base_url }}/api/v2/proposals/delete/{{ proposal_id }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + } + ], + "body": {}, + "metaSortKey": -6 + }, + { + "_id": "req_027", + "_type": "request", + "parentId": "fld_tracks_pub", + "name": "Create Track", + "method": "POST", + "url": "{{ base_url }}/api/v2/tracks/create/{{ event_code }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"name\": \"Web & APIs\",\n \"description\": \"Sessions sur le d\u00e9veloppement web et les APIs REST\",\n \"color\": \"#3B82F6\"\n}" + }, + "metaSortKey": -1 + }, + { + "_id": "req_028", + "_type": "request", + "parentId": "fld_tracks_pub", + "name": "List Tracks by Event", + "method": "GET", + "url": "{{ base_url }}/api/v2/tracks/list/{{ event_code }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + } + ], + "body": {}, + "metaSortKey": -2, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_029", + "_type": "request", + "parentId": "fld_tracks_pub", + "name": "List All Tracks", + "method": "GET", + "url": "{{ base_url }}/api/v2/tracks/list", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + } + ], + "body": {}, + "metaSortKey": -3, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_030", + "_type": "request", + "parentId": "fld_tracks_pub", + "name": "Get Track by ID", + "method": "GET", + "url": "{{ base_url }}/api/v2/tracks/{{ track_id }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + } + ], + "body": {}, + "metaSortKey": -4 + }, + { + "_id": "req_031", + "_type": "request", + "parentId": "fld_tracks_pub", + "name": "Update Track", + "method": "PUT", + "url": "{{ base_url }}/api/v2/tracks/update/{{ track_id }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"name\": \"Web & APIs \u2014 \u00c9dition 2\",\n \"description\": \"Mise \u00e0 jour de la description\",\n \"color\": \"#6366F1\"\n}" + }, + "metaSortKey": -5 + }, + { + "_id": "req_032", + "_type": "request", + "parentId": "fld_tracks_pub", + "name": "Delete Track", + "method": "DELETE", + "url": "{{ base_url }}/api/v2/tracks/delete/{{ track_id }}", + "headers": [ + { + "name": "X-API-Key", + "value": "{{ api_key }}" + } + ], + "body": {}, + "metaSortKey": -6 + }, + { + "_id": "req_033", + "_type": "request", + "parentId": "fld_registrations", + "name": "Register for Event", + "method": "POST", + "url": "{{ base_url }}/api/v2/registrations/{{ event_code }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"full_name\": \"Yao Mensah\",\n \"email\": \"yao@example.com\",\n \"phone\": \"+228 99 88 77 66\",\n \"organization\": \"EPAC\",\n \"ticket_type\": \"general\"\n}" + }, + "metaSortKey": -1 + }, + { + "_id": "req_034", + "_type": "request", + "parentId": "fld_shop_public", + "name": "List Categories (public)", + "method": "GET", + "url": "{{ base_url }}/api/v2/shop/categories", + "headers": [], + "body": {}, + "metaSortKey": -1 + }, + { + "_id": "req_035", + "_type": "request", + "parentId": "fld_shop_public", + "name": "Get Category (public)", + "method": "GET", + "url": "{{ base_url }}/api/v2/shop/categories/{{ category_id }}", + "headers": [], + "body": {}, + "metaSortKey": -2 + }, + { + "_id": "req_036", + "_type": "request", + "parentId": "fld_shop_public", + "name": "List Products (public)", + "method": "GET", + "url": "{{ base_url }}/api/v2/shop/products", + "headers": [], + "parameters": [ + { + "name": "event_id", + "value": "{{ event_id }}", + "disabled": true + }, + { + "name": "category_id", + "value": "{{ category_id }}", + "disabled": true + }, + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ], + "body": {}, + "metaSortKey": -3 + }, + { + "_id": "req_037", + "_type": "request", + "parentId": "fld_shop_public", + "name": "Get Product Detail (public)", + "method": "GET", + "url": "{{ base_url }}/api/v2/shop/products/{{ product_id }}", + "headers": [], + "body": {}, + "metaSortKey": -4 + }, + { + "_id": "req_038", + "_type": "request", + "parentId": "fld_shop_public", + "name": "Get Cart", + "method": "GET", + "url": "{{ base_url }}/api/v2/shop/cart", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -5 + }, + { + "_id": "req_039", + "_type": "request", + "parentId": "fld_shop_public", + "name": "Add Item to Cart", + "method": "POST", + "url": "{{ base_url }}/api/v2/shop/cart/items", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"variant_id\": \"{{ variant_id }}\",\n \"quantity\": 2\n}" + }, + "metaSortKey": -6 + }, + { + "_id": "req_040", + "_type": "request", + "parentId": "fld_shop_public", + "name": "Update Cart Item Quantity", + "method": "PUT", + "url": "{{ base_url }}/api/v2/shop/cart/items/{{ variant_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"quantity\": 3\n}" + }, + "metaSortKey": -7 + }, + { + "_id": "req_041", + "_type": "request", + "parentId": "fld_shop_public", + "name": "Remove Item from Cart", + "method": "DELETE", + "url": "{{ base_url }}/api/v2/shop/cart/items/{{ variant_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -8 + }, + { + "_id": "req_042", + "_type": "request", + "parentId": "fld_shop_public", + "name": "Clear Cart", + "method": "DELETE", + "url": "{{ base_url }}/api/v2/shop/cart", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -9 + }, + { + "_id": "req_043", + "_type": "request", + "parentId": "fld_shop_public", + "name": "Checkout", + "method": "POST", + "url": "{{ base_url }}/api/v2/shop/cart/checkout", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"event_id\": \"{{ event_id }}\",\n \"shipping_address\": {\n \"street\": \"123 Rue de la Paix\",\n \"city\": \"Lom\u00e9\",\n \"country\": \"Togo\"\n },\n \"coupon_code\": null\n}" + }, + "metaSortKey": -10 + }, + { + "_id": "req_044", + "_type": "request", + "parentId": "fld_shop_public", + "name": "My Orders", + "method": "GET", + "url": "{{ base_url }}/api/v2/shop/orders/me", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -11, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_045", + "_type": "request", + "parentId": "fld_shop_public", + "name": "My Order Detail", + "method": "GET", + "url": "{{ base_url }}/api/v2/shop/orders/me/{{ order_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -12 + }, + { + "_id": "req_046", + "_type": "request", + "parentId": "fld_shop_dash", + "name": "Shop Dashboard", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/shop/dashboard", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -1 + }, + { + "_id": "req_047", + "_type": "request", + "parentId": "fld_shop_dash", + "name": "Shop Analytics", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/shop/dashboard/analytics", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "parameters": [ + { + "name": "low_stock_threshold", + "value": "5", + "disabled": false + }, + { + "name": "pending_days_threshold", + "value": "3", + "disabled": false + }, + { + "name": "top_n", + "value": "10", + "disabled": false + } + ], + "body": {}, + "metaSortKey": -2 + }, + { + "_id": "req_048", + "_type": "request", + "parentId": "fld_shop_cat", + "name": "List Categories (admin)", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/shop/categories", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -1 + }, + { + "_id": "req_049", + "_type": "request", + "parentId": "fld_shop_cat", + "name": "Create Category", + "method": "POST", + "url": "{{ base_url }}/api/v2/admin/shop/categories", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"name\": \"T-Shirts\",\n \"slug\": \"t-shirts\",\n \"description\": \"T-shirts Python Togo officiel\",\n \"is_active\": true\n}" + }, + "metaSortKey": -2 + }, + { + "_id": "req_050", + "_type": "request", + "parentId": "fld_shop_cat", + "name": "Get Category (admin)", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/shop/categories/{{ category_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -3 + }, + { + "_id": "req_051", + "_type": "request", + "parentId": "fld_shop_cat", + "name": "Update Category", + "method": "PUT", + "url": "{{ base_url }}/api/v2/admin/shop/categories/{{ category_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"name\": \"T-Shirts \u00c9dition Limit\u00e9e\",\n \"slug\": \"t-shirts-limited\",\n \"description\": \"Collection limit\u00e9e\"\n}" + }, + "metaSortKey": -4 + }, + { + "_id": "req_052", + "_type": "request", + "parentId": "fld_shop_cat", + "name": "Toggle Category Active", + "method": "PATCH", + "url": "{{ base_url }}/api/v2/admin/shop/categories/{{ category_id }}/toggle", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -5 + }, + { + "_id": "req_053", + "_type": "request", + "parentId": "fld_shop_cat", + "name": "Delete Category", + "method": "DELETE", + "url": "{{ base_url }}/api/v2/admin/shop/categories/{{ category_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -6 + }, + { + "_id": "req_054", + "_type": "request", + "parentId": "fld_shop_prod", + "name": "List Products (admin)", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/shop/products", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -1, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_055", + "_type": "request", + "parentId": "fld_shop_prod", + "name": "Create Product", + "method": "POST", + "url": "{{ base_url }}/api/v2/admin/shop/products", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"event_id\": \"{{ event_id }}\",\n \"category_id\": \"{{ category_id }}\",\n \"name\": \"T-Shirt PyCon Togo 2025\",\n \"slug\": \"tshirt-pycon-togo-2025\",\n \"description\": \"T-shirt officiel PyCon Togo 2025\",\n \"image_url\": \"https://cdn.pytogo.org/tshirt.jpg\",\n \"base_price\": \"5000.00\",\n \"is_active\": true\n}" + }, + "metaSortKey": -2 + }, + { + "_id": "req_056", + "_type": "request", + "parentId": "fld_shop_prod", + "name": "Get Product (admin)", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/shop/products/{{ product_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -3 + }, + { + "_id": "req_057", + "_type": "request", + "parentId": "fld_shop_prod", + "name": "Update Product", + "method": "PUT", + "url": "{{ base_url }}/api/v2/admin/shop/products/{{ product_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"name\": \"T-Shirt PyCon Togo 2025 \u2014 \u00c9dition collector\",\n \"base_price\": \"6000.00\"\n}" + }, + "metaSortKey": -4 + }, + { + "_id": "req_058", + "_type": "request", + "parentId": "fld_shop_prod", + "name": "Toggle Product Active", + "method": "PATCH", + "url": "{{ base_url }}/api/v2/admin/shop/products/{{ product_id }}/toggle", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -5 + }, + { + "_id": "req_059", + "_type": "request", + "parentId": "fld_shop_prod", + "name": "Delete Product", + "method": "DELETE", + "url": "{{ base_url }}/api/v2/admin/shop/products/{{ product_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -6 + }, + { + "_id": "req_060", + "_type": "request", + "parentId": "fld_shop_prod", + "name": "List Variants", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/shop/products/{{ product_id }}/variants", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -7 + }, + { + "_id": "req_061", + "_type": "request", + "parentId": "fld_shop_prod", + "name": "Create Variant", + "method": "POST", + "url": "{{ base_url }}/api/v2/admin/shop/products/{{ product_id }}/variants", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"name\": \"Taille L\",\n \"sku\": \"TSHIRT-2025-L\",\n \"price_override\": null,\n \"stock_quantity\": 50,\n \"attributes\": { \"size\": \"L\", \"color\": \"black\" },\n \"is_active\": true\n}" + }, + "metaSortKey": -8 + }, + { + "_id": "req_062", + "_type": "request", + "parentId": "fld_shop_prod", + "name": "Update Variant", + "method": "PUT", + "url": "{{ base_url }}/api/v2/admin/shop/products/{{ product_id }}/variants/{{ variant_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"stock_quantity\": 30,\n \"price_override\": \"5500.00\"\n}" + }, + "metaSortKey": -9 + }, + { + "_id": "req_063", + "_type": "request", + "parentId": "fld_shop_prod", + "name": "Toggle Variant Active", + "method": "PATCH", + "url": "{{ base_url }}/api/v2/admin/shop/products/{{ product_id }}/variants/{{ variant_id }}/toggle", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -10 + }, + { + "_id": "req_064", + "_type": "request", + "parentId": "fld_shop_prod", + "name": "Delete Variant", + "method": "DELETE", + "url": "{{ base_url }}/api/v2/admin/shop/products/{{ product_id }}/variants/{{ variant_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -11 + }, + { + "_id": "req_065", + "_type": "request", + "parentId": "fld_shop_ord", + "name": "List Orders", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/shop/orders", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "parameters": [ + { + "name": "event_id", + "value": "{{ event_id }}", + "disabled": true + }, + { + "name": "status", + "value": "pending", + "disabled": true + }, + { + "name": "user_id", + "value": "{{ user_id }}", + "disabled": true + }, + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ], + "body": {}, + "metaSortKey": -1 + }, + { + "_id": "req_066", + "_type": "request", + "parentId": "fld_shop_ord", + "name": "Get Order Detail", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/shop/orders/{{ order_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -2 + }, + { + "_id": "req_067", + "_type": "request", + "parentId": "fld_shop_ord", + "name": "Update Order Status", + "method": "PATCH", + "url": "{{ base_url }}/api/v2/admin/shop/orders/{{ order_id }}/status", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"status\": \"paid\"\n}" + }, + "metaSortKey": -3 + }, + { + "_id": "req_068", + "_type": "request", + "parentId": "fld_shop_cust", + "name": "List Customers", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/shop/customers", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -1, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_069", + "_type": "request", + "parentId": "fld_shop_cust", + "name": "Get Customer", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/shop/customers/{{ customer_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -2 + }, + { + "_id": "req_070", + "_type": "request", + "parentId": "fld_shop_cust", + "name": "Get Customer Orders", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/shop/customers/{{ customer_id }}/orders", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -3 + }, + { + "_id": "req_071", + "_type": "request", + "parentId": "fld_shop_cust", + "name": "Toggle Customer Active", + "method": "PATCH", + "url": "{{ base_url }}/api/v2/admin/shop/customers/{{ customer_id }}/toggle", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -4 + }, + { + "_id": "req_072", + "_type": "request", + "parentId": "fld_shop_pay", + "name": "List Payments", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/shop/payments", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -1, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_073", + "_type": "request", + "parentId": "fld_shop_pay", + "name": "Get Payment", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/shop/payments/{{ payment_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -2 + }, + { + "_id": "req_074", + "_type": "request", + "parentId": "fld_shop_coup", + "name": "List Coupons", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/shop/coupons", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -1, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_075", + "_type": "request", + "parentId": "fld_shop_coup", + "name": "Create Coupon", + "method": "POST", + "url": "{{ base_url }}/api/v2/admin/shop/coupons", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"event_id\": \"{{ event_id }}\",\n \"code\": \"PYTHON25\",\n \"type\": \"percentage\",\n \"value\": \"20.00\",\n \"max_uses\": 100,\n \"expires_at\": \"2025-11-14T23:59:59Z\",\n \"is_active\": true\n}" + }, + "metaSortKey": -2 + }, + { + "_id": "req_076", + "_type": "request", + "parentId": "fld_shop_coup", + "name": "Update Coupon", + "method": "PUT", + "url": "{{ base_url }}/api/v2/admin/shop/coupons/{{ coupon_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"max_uses\": 200,\n \"is_active\": true\n}" + }, + "metaSortKey": -3 + }, + { + "_id": "req_077", + "_type": "request", + "parentId": "fld_shop_coup", + "name": "Delete Coupon", + "method": "DELETE", + "url": "{{ base_url }}/api/v2/admin/shop/coupons/{{ coupon_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -4 + }, + { + "_id": "req_078", + "_type": "request", + "parentId": "fld_adm_overview", + "name": "Global Overview", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/overview", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -1 + }, + { + "_id": "req_079", + "_type": "request", + "parentId": "fld_adm_security", + "name": "Security Overview", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/security/overview", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -1 + }, + { + "_id": "req_080", + "_type": "request", + "parentId": "fld_adm_security", + "name": "List API Keys", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/security/api-keys", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -2 + }, + { + "_id": "req_081", + "_type": "request", + "parentId": "fld_adm_security", + "name": "List Active Sessions", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/security/sessions", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -3 + }, + { + "_id": "req_082", + "_type": "request", + "parentId": "fld_adm_users", + "name": "Users Overview", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/users/overview", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -1 + }, + { + "_id": "req_083", + "_type": "request", + "parentId": "fld_adm_users", + "name": "List Inactive Users", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/users/inactive", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -2, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_084", + "_type": "request", + "parentId": "fld_adm_users", + "name": "List New Users (7 days)", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/users/new", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -3, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_085", + "_type": "request", + "parentId": "fld_adm_users", + "name": "List Users Without Orders", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/users/no-orders", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -4, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_086", + "_type": "request", + "parentId": "fld_adm_outreach", + "name": "Outreach Overview", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/outreach/overview", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -1 + }, + { + "_id": "req_087", + "_type": "request", + "parentId": "fld_adm_outreach", + "name": "Unresolved Contacts", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/outreach/contacts/unresolved", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -2, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_088", + "_type": "request", + "parentId": "fld_adm_outreach", + "name": "Unconfirmed Partners", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/outreach/partners/unconfirmed", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -3, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_089", + "_type": "request", + "parentId": "fld_adm_proposals", + "name": "Proposals Overview", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/proposals/overview", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -1 + }, + { + "_id": "req_090", + "_type": "request", + "parentId": "fld_adm_proposals", + "name": "Proposals by Status", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/proposals/by-status/submitted", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -2, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_091", + "_type": "request", + "parentId": "fld_adm_proposals", + "name": "Proposals Without Track", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/proposals/without-track", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -3, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_092", + "_type": "request", + "parentId": "fld_adm_events", + "name": "Events Overview", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/events/overview", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -1 + }, + { + "_id": "req_093", + "_type": "request", + "parentId": "fld_adm_regs", + "name": "Registrations Dashboard", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/registrations/{{ event_code }}/dashboard", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -1 + }, + { + "_id": "req_094", + "_type": "request", + "parentId": "fld_adm_regs", + "name": "List Registrations by Event", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/registrations/{{ event_code }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -2, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_095", + "_type": "request", + "parentId": "fld_adm_regs", + "name": "Create Registration (admin)", + "method": "POST", + "url": "{{ base_url }}/api/v2/admin/registrations/{{ event_code }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"full_name\": \"Sena Adzoa\",\n \"email\": \"sena@example.com\",\n \"phone\": \"+228 92 34 56 78\",\n \"organization\": \"Universit\u00e9 de Lom\u00e9\",\n \"ticket_type\": \"student\"\n}" + }, + "metaSortKey": -3 + }, + { + "_id": "req_096", + "_type": "request", + "parentId": "fld_adm_regs", + "name": "Get Registration Detail", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/registrations/detail/{{ registration_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -4 + }, + { + "_id": "req_097", + "_type": "request", + "parentId": "fld_adm_regs", + "name": "Update Registration", + "method": "PUT", + "url": "{{ base_url }}/api/v2/admin/registrations/{{ registration_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"notes\": \"Acc\u00e8s VIP accord\u00e9\",\n \"ticket_type\": \"vip\"\n}" + }, + "metaSortKey": -5 + }, + { + "_id": "req_098", + "_type": "request", + "parentId": "fld_adm_regs", + "name": "Change Registration Status", + "method": "PATCH", + "url": "{{ base_url }}/api/v2/admin/registrations/{{ registration_id }}/status", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"status\": \"confirmed\"\n}" + }, + "metaSortKey": -6 + }, + { + "_id": "req_099", + "_type": "request", + "parentId": "fld_adm_regs", + "name": "Delete Registration", + "method": "DELETE", + "url": "{{ base_url }}/api/v2/admin/registrations/{{ registration_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -7 + }, + { + "_id": "req_100", + "_type": "request", + "parentId": "fld_adm_speakers", + "name": "List All Speakers", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/speakers", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -1, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_101", + "_type": "request", + "parentId": "fld_adm_speakers", + "name": "List Speakers by Event", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/speakers/event/{{ event_code }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -2, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_102", + "_type": "request", + "parentId": "fld_adm_speakers", + "name": "Create Speaker", + "method": "POST", + "url": "{{ base_url }}/api/v2/admin/speakers/event/{{ event_code }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"first_name\": \"Ama\",\n \"last_name\": \"Kodjovi\",\n \"full_name\": \"Ama Kodjovi\",\n \"email\": \"ama@example.com\",\n \"headline\": \"Backend Developer & Python Enthusiast\",\n \"organization\": \"Python Togo\",\n \"country\": \"Togo\",\n \"bio\": \"D\u00e9veloppeuse backend avec 5 ans d'exp\u00e9rience en Python et FastAPI.\",\n \"social_links\": {\n \"twitter\": \"https://twitter.com/ama\",\n \"linkedin\": \"https://linkedin.com/in/ama\"\n },\n \"website_url\": \"https://ama.dev\"\n}" + }, + "metaSortKey": -3 + }, + { + "_id": "req_103", + "_type": "request", + "parentId": "fld_adm_speakers", + "name": "Get Speaker", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/speakers/{{ speaker_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -4 + }, + { + "_id": "req_104", + "_type": "request", + "parentId": "fld_adm_speakers", + "name": "Update Speaker", + "method": "PUT", + "url": "{{ base_url }}/api/v2/admin/speakers/{{ speaker_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"headline\": \"Senior Backend Developer & OSS Contributor\",\n \"organization\": \"Python Software Foundation\"\n}" + }, + "metaSortKey": -5 + }, + { + "_id": "req_105", + "_type": "request", + "parentId": "fld_adm_speakers", + "name": "Upload Speaker Photo", + "method": "POST", + "url": "{{ base_url }}/api/v2/admin/speakers/{{ speaker_id }}/photo", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": { + "mimeType": "multipart/form-data", + "params": [ + { + "name": "file", + "type": "file", + "fileName": "" + } + ] + }, + "metaSortKey": -6 + }, + { + "_id": "req_106", + "_type": "request", + "parentId": "fld_adm_speakers", + "name": "Delete Speaker", + "method": "DELETE", + "url": "{{ base_url }}/api/v2/admin/speakers/{{ speaker_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -7 + }, + { + "_id": "req_107", + "_type": "request", + "parentId": "fld_adm_sessions", + "name": "List All Sessions", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/sessions", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -1, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_108", + "_type": "request", + "parentId": "fld_adm_sessions", + "name": "List Sessions by Event", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/sessions/event/{{ event_code }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -2, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_109", + "_type": "request", + "parentId": "fld_adm_sessions", + "name": "Get Event Schedule", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/sessions/event/{{ event_code }}/schedule", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -3 + }, + { + "_id": "req_110", + "_type": "request", + "parentId": "fld_adm_sessions", + "name": "Create Session", + "method": "POST", + "url": "{{ base_url }}/api/v2/admin/sessions/event/{{ event_code }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"venue_id\": \"{{ event_id }}\",\n \"track_id\": \"{{ track_id }}\",\n \"speaker_id\": \"{{ speaker_id }}\",\n \"title\": \"Introduction \u00e0 FastAPI\",\n \"slug\": \"intro-fastapi-2025\",\n \"session_type\": \"talk\",\n \"starts_at\": \"2025-11-15T09:00:00Z\",\n \"ends_at\": \"2025-11-15T09:45:00Z\",\n \"description\": \"Un talk d'introduction \u00e0 FastAPI pour les d\u00e9veloppeurs Python.\"\n}" + }, + "metaSortKey": -4 + }, + { + "_id": "req_111", + "_type": "request", + "parentId": "fld_adm_sessions", + "name": "Get Session", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/sessions/{{ session_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -5 + }, + { + "_id": "req_112", + "_type": "request", + "parentId": "fld_adm_sessions", + "name": "Update Session", + "method": "PUT", + "url": "{{ base_url }}/api/v2/admin/sessions/{{ session_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"title\": \"Ma\u00eetriser FastAPI \u2014 Advanced\",\n \"starts_at\": \"2025-11-15T10:00:00Z\",\n \"ends_at\": \"2025-11-15T11:00:00Z\"\n}" + }, + "metaSortKey": -6 + }, + { + "_id": "req_113", + "_type": "request", + "parentId": "fld_adm_sessions", + "name": "Delete Session", + "method": "DELETE", + "url": "{{ base_url }}/api/v2/admin/sessions/{{ session_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -7 + }, + { + "_id": "req_114", + "_type": "request", + "parentId": "fld_adm_packages", + "name": "List Sponsor Packages by Event", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/sponsor-packages/event/{{ event_code }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -1 + }, + { + "_id": "req_115", + "_type": "request", + "parentId": "fld_adm_packages", + "name": "Create Sponsor Package", + "method": "POST", + "url": "{{ base_url }}/api/v2/admin/sponsor-packages/event/{{ event_code }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"name\": \"Gold Package\",\n \"tier\": \"gold\",\n \"description\": \"Package gold pour sponsors premium\",\n \"price\": \"500000.00\",\n \"benefits\": [\"Logo sur le site\", \"Stand d'exposition\", \"3 tickets\", \"Mention dans le programme\"],\n \"max_slots\": 5,\n \"is_active\": true\n}" + }, + "metaSortKey": -2 + }, + { + "_id": "req_116", + "_type": "request", + "parentId": "fld_adm_packages", + "name": "Get Sponsor Package", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/sponsor-packages/{{ package_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -3 + }, + { + "_id": "req_117", + "_type": "request", + "parentId": "fld_adm_packages", + "name": "Update Sponsor Package", + "method": "PUT", + "url": "{{ base_url }}/api/v2/admin/sponsor-packages/{{ package_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"price\": \"600000.00\",\n \"max_slots\": 3,\n \"benefits\": [\"Logo sur le site\", \"Stand premium\", \"5 tickets\", \"Keynote slot\"]\n}" + }, + "metaSortKey": -4 + }, + { + "_id": "req_118", + "_type": "request", + "parentId": "fld_adm_packages", + "name": "Delete Sponsor Package", + "method": "DELETE", + "url": "{{ base_url }}/api/v2/admin/sponsor-packages/{{ package_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -5 + }, + { + "_id": "req_119", + "_type": "request", + "parentId": "fld_adm_packages", + "name": "Assign Package to Sponsor", + "method": "PATCH", + "url": "{{ base_url }}/api/v2/admin/sponsor-packages/sponsors/{{ sponsor_id }}/assign", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "parameters": [ + { + "name": "package_id", + "value": "{{ package_id }}", + "disabled": false + } + ], + "body": {}, + "metaSortKey": -6 + }, + { + "_id": "req_120", + "_type": "request", + "parentId": "fld_adm_rbac", + "name": "List All Permissions", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/permissions", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -1 + }, + { + "_id": "req_121", + "_type": "request", + "parentId": "fld_adm_rbac", + "name": "List All Roles", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/roles", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -2 + }, + { + "_id": "req_122", + "_type": "request", + "parentId": "fld_adm_rbac", + "name": "Get Role Detail", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/roles/{{ role_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -3 + }, + { + "_id": "req_123", + "_type": "request", + "parentId": "fld_adm_rbac", + "name": "Create Role", + "method": "POST", + "url": "{{ base_url }}/api/v2/admin/roles", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"name\": \"moderator\",\n \"description\": \"Mod\u00e9rateur de la communaut\u00e9\"\n}" + }, + "metaSortKey": -4 + }, + { + "_id": "req_124", + "_type": "request", + "parentId": "fld_adm_rbac", + "name": "Update Role", + "method": "PUT", + "url": "{{ base_url }}/api/v2/admin/roles/{{ role_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"description\": \"Mod\u00e9rateur avec acc\u00e8s \u00e9tendu\"\n}" + }, + "metaSortKey": -5 + }, + { + "_id": "req_125", + "_type": "request", + "parentId": "fld_adm_rbac", + "name": "Delete Role", + "method": "DELETE", + "url": "{{ base_url }}/api/v2/admin/roles/{{ role_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -6 + }, + { + "_id": "req_126", + "_type": "request", + "parentId": "fld_adm_rbac", + "name": "Assign Permissions to Role", + "method": "POST", + "url": "{{ base_url }}/api/v2/admin/roles/{{ role_id }}/permissions", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"permission_ids\": [\n \"{{ permission_id }}\"\n ]\n}" + }, + "metaSortKey": -7 + }, + { + "_id": "req_127", + "_type": "request", + "parentId": "fld_adm_rbac", + "name": "Remove Permission from Role", + "method": "DELETE", + "url": "{{ base_url }}/api/v2/admin/roles/{{ role_id }}/permissions/{{ permission_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -8 + }, + { + "_id": "req_128", + "_type": "request", + "parentId": "fld_adm_rbac", + "name": "Get User Roles", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/users/{{ user_id }}/roles", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -9 + }, + { + "_id": "req_129", + "_type": "request", + "parentId": "fld_adm_rbac", + "name": "Assign Role to User", + "method": "POST", + "url": "{{ base_url }}/api/v2/admin/users/{{ user_id }}/roles", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"role_id\": \"{{ role_id }}\"\n}" + }, + "metaSortKey": -10 + }, + { + "_id": "req_130", + "_type": "request", + "parentId": "fld_adm_rbac", + "name": "Remove Role from User", + "method": "DELETE", + "url": "{{ base_url }}/api/v2/admin/users/{{ user_id }}/roles/{{ role_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -11 + }, + { + "_id": "req_131", + "_type": "request", + "parentId": "fld_adm_cfp", + "name": "List Talks with Avg Scores", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/cfp/talks", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -1, + "parameters": [ + { + "name": "page", + "value": "1", + "id": "", + "disabled": false + }, + { + "name": "per_page", + "value": "20", + "id": "", + "disabled": false + } + ] + }, + { + "_id": "req_132", + "_type": "request", + "parentId": "fld_adm_cfp", + "name": "Get Talk Detail (with reviews)", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/cfp/talks/{{ proposal_id }}", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -2 + }, + { + "_id": "req_133", + "_type": "request", + "parentId": "fld_adm_cfp", + "name": "Submit Review", + "method": "POST", + "url": "{{ base_url }}/api/v2/admin/cfp/talks/{{ proposal_id }}/reviews", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"score\": 4,\n \"comment\": \"Excellent sujet, tr\u00e8s bien pr\u00e9sent\u00e9. Le speaker a une bonne ma\u00eetrise du sujet.\"\n}" + }, + "metaSortKey": -3 + }, + { + "_id": "req_134", + "_type": "request", + "parentId": "fld_adm_cfp", + "name": "Get Reviews (masked if not voted)", + "method": "GET", + "url": "{{ base_url }}/api/v2/admin/cfp/talks/{{ proposal_id }}/reviews", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + } + ], + "body": {}, + "metaSortKey": -4 + }, + { + "_id": "req_135", + "_type": "request", + "parentId": "fld_adm_cfp", + "name": "Update Talk Status (+ email speaker)", + "method": "PATCH", + "url": "{{ base_url }}/api/v2/admin/cfp/talks/{{ proposal_id }}/status", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{ access_token }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mimeType": "application/json", + "text": "{\n \"status\": \"accepted\"\n}" + }, + "metaSortKey": -5 + } + ] +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 7a2305f..f073aad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,4 +10,5 @@ python-multipart==0.0.20 python-slugify==8.0.1 requests==2.32.4 pydantic==2.12 -nanoid==2.0.0 \ No newline at end of file +nanoid==2.0.0 +cloudinary==1.42.1 \ No newline at end of file