Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
152 changes: 152 additions & 0 deletions SHOP_2026.md
Original file line number Diff line number Diff line change
@@ -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)
48 changes: 48 additions & 0 deletions app/core/cloudinary.py
Original file line number Diff line number Diff line change
@@ -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")
Loading