diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cdf0b90 --- /dev/null +++ b/Makefile @@ -0,0 +1,129 @@ +# ============================================================================== +# Python Togo API - Makefile +# Usage : make | make help pour la liste complete +# ============================================================================== + +# --- Variables configurables (surchargeables : make dev PORT=9000) ----------- +PYTHON ?= python3 +VENV ?= venv +BIN := $(VENV)/bin +HOST ?= 0.0.0.0 +PORT ?= 8000 +WORKERS ?= 4 +APP := app/main.py +ENV_FILE := app/.env +ENV_EXAMPLE := app/.env.example +API_PORT ?= 8080 +COMPOSE ?= API_PORT=$(API_PORT) docker compose +SERVICE ?= api + +.DEFAULT_GOAL := help +.PHONY: help venv install env migrate dev run start stop check-env freeze \ + clean clean-pyc reset build up down restart logs ps shell db-shell \ + redis-cli docker-migrate rebuild prune + +# ============================================================================== +# Aide +# ============================================================================== +help: ## Affiche cette aide + @echo "Python Togo API - commandes disponibles :" + @echo "" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' + @echo "" + @echo "Variables : PORT=$(PORT) API_PORT=$(API_PORT) HOST=$(HOST) VENV=$(VENV) SERVICE=$(SERVICE)" + +# ============================================================================== +# Developpement local +# ============================================================================== +venv: ## Cree l'environnement virtuel Python + @test -d $(VENV) || $(PYTHON) -m venv $(VENV) + @$(BIN)/pip install --upgrade pip + +install: venv ## Installe les dependances dans le venv + @$(BIN)/pip install -r requirements.txt + +env: ## Cree app/.env a partir de app/.env.example (si absent) + @if [ -f $(ENV_FILE) ]; then \ + echo "$(ENV_FILE) existe deja, aucune action."; \ + else \ + cp $(ENV_EXAMPLE) $(ENV_FILE); \ + echo "$(ENV_FILE) cree. Pensez a remplir les variables."; \ + fi + +check-env: ## Verifie que app/.env est present + @if [ ! -f $(ENV_FILE) ]; then \ + echo "Erreur : $(ENV_FILE) manquant. Lancez 'make env'."; \ + exit 1; \ + fi + +migrate: check-env ## Execute les migrations de la base de donnees + @$(BIN)/python -m app.database.migrations + +dev: check-env ## Demarre l'API en mode developpement (rechargement auto) + @$(BIN)/fastapi dev $(APP) --host $(HOST) --port $(PORT) + +run: check-env ## Demarre l'API en mode production (multi-workers) + @$(BIN)/fastapi run $(APP) --host $(HOST) --port $(PORT) --workers $(WORKERS) + +start: install env migrate dev ## Installation complete puis demarrage en dev + +freeze: ## Fige les dependances installees dans requirements.lock.txt + @$(BIN)/pip freeze > requirements.lock.txt + @echo "requirements.lock.txt genere." + +# ============================================================================== +# Docker +# ============================================================================== +build: ## Construit les images Docker + @$(COMPOSE) build + +up: ## Demarre la stack Docker (api + postgres + redis) en arriere-plan + @$(COMPOSE) up -d + @echo "API disponible sur http://localhost:$(API_PORT)" + +down: ## Arrete la stack Docker + @$(COMPOSE) down + +stop: down ## Alias de 'down' + +restart: ## Redemarre la stack Docker + @$(COMPOSE) restart + +rebuild: ## Reconstruit sans cache puis redemarre la stack + @$(COMPOSE) build --no-cache + @$(COMPOSE) up -d --force-recreate + +logs: ## Suit les logs du service api (SERVICE=db pour un autre) + @$(COMPOSE) logs -f $(SERVICE) + +ps: ## Liste l'etat des conteneurs + @$(COMPOSE) ps + +shell: ## Ouvre un shell dans le conteneur api + @$(COMPOSE) exec $(SERVICE) bash + +db-shell: ## Ouvre psql dans le conteneur postgres + @$(COMPOSE) exec db psql -U $${DB_USER:-postgres} -d $${DB_NAME:-pythontogo_db} + +redis-cli: ## Ouvre redis-cli dans le conteneur redis + @$(COMPOSE) exec redis redis-cli + +docker-migrate: ## Execute les migrations dans le conteneur api + @$(COMPOSE) exec $(SERVICE) python -m app.database.migrations + +prune: ## Arrete la stack et supprime les volumes (DONNEES PERDUES) + @$(COMPOSE) down -v + +# ============================================================================== +# Nettoyage +# ============================================================================== +clean-pyc: ## Supprime les fichiers Python compiles + @find . -type d -name '__pycache__' -not -path './$(VENV)/*' -exec rm -rf {} + 2>/dev/null || true + @find . -type f -name '*.py[co]' -not -path './$(VENV)/*' -delete + +clean: clean-pyc ## Nettoie les caches (pycache, pytest, mypy) + @rm -rf .pytest_cache .mypy_cache .coverage htmlcov + +reset: clean ## Supprime aussi l'environnement virtuel + @rm -rf $(VENV) diff --git a/app/.env.example b/app/.env.example index 6ad5140..ddf0ee4 100644 --- a/app/.env.example +++ b/app/.env.example @@ -30,7 +30,7 @@ TICKETING_TEAM_EMAIL= CONTACT_TEAM_EMAIL= SPONSORSHIP_TEAM_EMAIL= -==========ADMIN notification service========= +# ========== ADMIN notification service ========== ADMIN_SMTP_SERVER= ADMIN_SMTP_PORT= ADMIN_SMTP_USER= diff --git a/app/core/auth.py b/app/core/auth.py new file mode 100644 index 0000000..ecf6a29 --- /dev/null +++ b/app/core/auth.py @@ -0,0 +1,71 @@ +from datetime import datetime, timezone, timedelta +from typing import Optional +from fastapi import Depends, HTTPException, status, Request +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from passlib.context import CryptContext +from app.core.settings import settings +from app.database.connection import get_db_connection +from app.database.orm import select + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v2/auth/login") + +ACCESS_TOKEN_EXPIRE_MINUTES = settings.access_token_expire_minutes +REFRESH_TOKEN_EXPIRE_DAYS = 7 +SECRET_KEY = settings.secret_key +ALGORITHM = settings.algorithm + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def create_access_token(subject: str, expires_delta: Optional[timedelta] = None) -> str: + expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)) + payload = {"sub": str(subject), "type": "access", "exp": expire} + return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + + +def create_refresh_token(user_id: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + payload = {"sub": str(user_id), "type": "refresh", "exp": expire} + return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + + +def decode_token(token: str) -> dict: + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +async def get_current_user(token: str = Depends(oauth2_scheme), db=Depends(get_db_connection)) -> dict: + payload = decode_token(token) + 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 payload") + users = await select(db, "users", filter={"id": user_id}) + if not users: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + user = users[0] + if not user.get("is_active", False): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user") + return user + + +async def get_current_superuser(current_user: dict = Depends(get_current_user)) -> dict: + if not current_user.get("is_superuser"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") + return current_user diff --git a/app/core/security.py b/app/core/security.py index c56c43a..27d19bf 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -1,10 +1,11 @@ -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, status, Request from fastapi.security import HTTPBearer, HTTPBasicCredentials from typing import Annotated 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.core.settings import settings security = HTTPBearer() @@ -18,29 +19,44 @@ def generate_api_key(): return api_key -async def verify_api_key(credentials: Annotated[HTTPBasicCredentials, Depends(security)], db=Depends(get_db_connection), redis=Depends(get_redis_client)): +async def verify_api_key(request: Request, db=Depends(get_db_connection), redis=Depends(get_redis_client)): + api_key_value = request.headers.get("X-API-Key") - api_key_value = credentials.credentials - if not api_key_value.startswith("PYTOGO_SK_") or len(api_key_value) != 50: + if not api_key_value: + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + api_key_value = auth_header[7:] + + if not api_key_value or not isinstance(api_key_value, str) or 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") - expected_api_key = await redis.get(f"PYTOGO_API_KEY:{credentials.credentials}") + expected_api_key = await redis.get(f"PYTOGO_API_KEY:{api_key_value}") 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": api_key_value}) if not expected_api_key: raise HTTPException( 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:{api_key_value}", dumps(api_key_data), ex=3600) 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: + if expected_api_key_data["key_value"] != api_key_value: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") return APIKeyVerificationResponse(is_valid=True, message="API key is valid") + + +async def require_admin_secret(request: Request): + if not settings.admin_api_key: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Admin secret not configured") + admin_secret = request.headers.get("X-Admin-Secret") + if admin_secret != settings.admin_api_key: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") + return True diff --git a/app/core/settings.py b/app/core/settings.py index 5ff0b46..04b2f1d 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -31,9 +31,9 @@ smtp_port=config("SMTP_PORT", default=587, cast=int), smtp_user=config("SMTP_USER", default="user"), smtp_password=config("SMTP_PASSWORD", default="password"), - # Admin SMTP settings for internal notification service + admin_api_key=config("ADMIN_API_KEY", default=""), admin_smtp_server=config("ADMIN_SMTP_SERVER", default=None), - admin_smtp_port=config("ADMIN_SMTP_PORT", default=None, cast=int), + admin_smtp_port=config("ADMIN_SMTP_PORT", default=None), admin_smtp_user=config("ADMIN_SMTP_USER", default=None), admin_smtp_password=config("ADMIN_SMTP_PASSWORD", default=None), paydunya_public_key=config("PAYDUNYA_PUBLIC_KEY", default=None), diff --git a/app/database/migrations.py b/app/database/migrations.py index 57d0882..717488f 100644 --- a/app/database/migrations.py +++ b/app/database/migrations.py @@ -521,8 +521,50 @@ REFERENCES events(id) ON DELETE CASCADE );""", - -] + """ + CREATE TABLE IF NOT EXISTS feedbacks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + sex VARCHAR(64), + age VARCHAR(32), + profession VARCHAR(255), + country VARCHAR(120), + python_level VARCHAR(120), + heard TEXT, + rating INTEGER CHECK (rating >= 1 AND rating <= 5), + overall TEXT, + favorite TEXT, + improvements TEXT, + comments TEXT, + is_resolved BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + );""", + """ + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL UNIQUE, + full_name VARCHAR(255) NOT NULL, + hashed_password TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_superuser BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + );""", + """ + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + token TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_refresh_tokens_user + FOREIGN KEY (user_id) + REFERENCES users(id) + ON DELETE CASCADE + );""", + + ] CREATE_INDEX_QUERIES = [ "CREATE INDEX IF NOT EXISTS idx_sponsors_partners_event_id ON sponsors_partners(event_id);", @@ -539,33 +581,52 @@ ALTER_TABLE_QUERIES = [ "ALTER TABLE sponsors_partners ADD COLUMN IF NOT EXISTS package_tier package_tier_enum;", + "ALTER TABLE feedbacks DROP COLUMN IF EXISTS event_code;", + "ALTER TABLE feedbacks DROP COLUMN IF EXISTS name;", + "ALTER TABLE feedbacks DROP COLUMN IF EXISTS email;", + "ALTER TABLE feedbacks DROP COLUMN IF EXISTS subject;", + "ALTER TABLE feedbacks DROP COLUMN IF EXISTS message;", + "ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS days JSONB DEFAULT '[]'::jsonb;", ] def create_tables(): """Return SQL queries in execution order for creating the schema.""" - conn = connect(settings.db_url) - with conn.cursor() as cur: - cur.execute(CREATE_EXTENSIONS_QUERY) - cur.execute(CREATE_TYPES_QUERY) - for query in CREATE_TABLE_QUERIES: - cur.execute(query) - for query in ALTER_TABLE_QUERIES: - cur.execute(query) - for query in CREATE_INDEX_QUERIES: - cur.execute(query) - conn.commit() - return ( - CREATE_EXTENSIONS_QUERY - + "\n" - + CREATE_TYPES_QUERY - + "\n" - + "\n".join(CREATE_TABLE_QUERIES) - + "\n" - + "\n".join(ALTER_TABLE_QUERIES) - + "\n" - + "\n".join(CREATE_INDEX_QUERIES) - ) + import time + max_retries = 10 + retry_delay = 2 + last_error = None + for attempt in range(1, max_retries + 1): + try: + conn = connect(settings.db_url) + with conn.cursor() as cur: + cur.execute(CREATE_EXTENSIONS_QUERY) + cur.execute(CREATE_TYPES_QUERY) + for query in CREATE_TABLE_QUERIES: + cur.execute(query) + for query in ALTER_TABLE_QUERIES: + cur.execute(query) + for query in CREATE_INDEX_QUERIES: + cur.execute(query) + conn.commit() + return ( + CREATE_EXTENSIONS_QUERY + + "\n" + + CREATE_TYPES_QUERY + + "\n" + + "\n".join(CREATE_TABLE_QUERIES) + + "\n" + + "\n".join(ALTER_TABLE_QUERIES) + + "\n" + + "\n".join(CREATE_INDEX_QUERIES) + ) + except Exception as exc: + last_error = exc + if attempt < max_retries: + print(f"Tentative {attempt}/{max_retries} : base non prête, attente {retry_delay}s...") + time.sleep(retry_delay) + else: + raise last_error def run_migrations(): diff --git a/app/database/orm.py b/app/database/orm.py index d6128f5..4117f75 100644 --- a/app/database/orm.py +++ b/app/database/orm.py @@ -21,9 +21,8 @@ async def select(db: Connection, table, columns=None, filter=None): result = await cur.fetchall() return result except Exception as e: - logger.error(f"Error executing select query on {table}: {str(e)}") - # TODO: sent email to admin about error during select query execution + raise async def select_with_join(db: Connection, table, join_table, join_condition, columns=None, filter=None): @@ -36,15 +35,13 @@ async def select_with_join(db: Connection, table, join_table, join_condition, co return result except Exception as e: - logger.error( f"Error executing select with join query on {table} and {join_table}: {str(e)}") - # TODO: sent email to admin about error during select with join query execution + raise async def select_with_multiple_joins(db: Connection, table, joins, columns=None, filter=None): try: - query, values = generate_multiple_joins_query( table, joins, columns, filter) async with db.cursor(row_factory=dict_row) as cur: @@ -52,10 +49,9 @@ async def select_with_multiple_joins(db: Connection, table, joins, columns=None, result = await cur.fetchall() return result except Exception as e: - logger.error( f"Error executing select with multiple joins query on {table}: {str(e)}") - # TODO: sent email to admin about error during select with multiple joins query execution + raise async def insert(db: Connection, table, data): @@ -69,9 +65,8 @@ async def insert(db: Connection, table, data): await cur.execute(query, values) await db.commit() except Exception as e: - logger.error(f"Error inserting record into {table}: {str(e)}") - # TODO: sent email to admin about error during insert query execution + raise async def update(db: Connection, table, data, filter): @@ -79,14 +74,11 @@ async def update(db: Connection, table, data, filter): data = remove_null_values(data) query, values = generate_update_query(table, data, filter) async with db.cursor() as cur: - await cur.execute(query, values) await db.commit() except Exception as e: - logger.error(f"Error updating record in {table}: {str(e)}") - # TODO: Log the error can be done here - # TODO: sent email to admin about error during update query execution + raise async def delete(db: Connection, table, filter): @@ -96,7 +88,5 @@ async def delete(db: Connection, table, filter): await cur.execute(query, values) await db.commit() except Exception as e: - logger.error(f"Error deleting record from {table}: {str(e)}") - # TODO: Log the error can be done here - # TODO: sent email to admin about error during delete query execution + raise diff --git a/app/main.py b/app/main.py index ba658d8..998b926 100644 --- a/app/main.py +++ b/app/main.py @@ -6,8 +6,10 @@ import redis.asyncio as redis from app.core.settings import settings from app.routers.api import api_routers +from app.routers.auth import api_router as auth_router from app.routers.notifications import api_router as notifications_router from app.webhooks.payments_callback import api_router as payments_callback_router +from app.routers.feedback_public import api_router as feedback_public_router from app.core.settings import logger, settings from pathlib import Path from datetime import datetime, timezone @@ -31,7 +33,7 @@ "https://api.pytogo.org", "https://api.pycontg.pytogo.org" # "http://127.0.0.1:8080/", - # "http://localhost:8080/" + "http://localhost:8080/" ] @@ -147,6 +149,8 @@ async def favicon(): return FileResponse(BASE_DIR / "static" / "favicon.ico") +app.include_router(auth_router, prefix="/api/v2") app.include_router(api_routers) app.include_router(payments_callback_router) app.include_router(notifications_router) +app.include_router(feedback_public_router) diff --git a/app/routers/api.py b/app/routers/api.py index a925471..a6fbfed 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -16,6 +16,7 @@ from app.routers.vauchers import api_router as vauchers_router from app.routers.teams import api_router as teams_router from app.routers.access_grant import api_router as access_grant_router +from app.routers.feedbacks import api_router as feedbacks_router from fastapi import APIRouter from app.core.security import verify_api_key @@ -40,3 +41,4 @@ api_routers.include_router(vauchers_router) api_routers.include_router(teams_router) api_routers.include_router(access_grant_router) +api_routers.include_router(feedbacks_router) diff --git a/app/routers/auth.py b/app/routers/auth.py index e69de29..145cc46 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -0,0 +1,112 @@ +from datetime import datetime, timezone, timedelta +from fastapi import APIRouter, Depends, HTTPException, status +from app.core.auth import ( + create_access_token, + create_refresh_token, + verify_password, + hash_password, + get_current_user, + get_current_superuser, + decode_token, + ACCESS_TOKEN_EXPIRE_MINUTES, +) +from app.database.connection import get_db_connection +from app.database.orm import select, insert, delete +from pydantic import BaseModel + + +class Token(BaseModel): + access_token: str + refresh_token: str + expires_in: int + + +class TokenRefresh(BaseModel): + refresh_token: str + + +class LoginRequest(BaseModel): + email: str + password: str + + +class MessageResponse(BaseModel): + message: str + + +api_router = APIRouter(prefix="/auth", tags=["auth"]) + + +@api_router.post("/login", response_model=Token, status_code=status.HTTP_200_OK) +async def login(payload: LoginRequest, db=Depends(get_db_connection)): + users = await select(db, "users", filter={"email": payload.email}) + if not users: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") + user = users[0] + if not verify_password(payload.password, user["hashed_password"]): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") + if not user.get("is_active", False): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user") + + access_token = create_access_token(subject=user["id"]) + refresh_token = create_refresh_token(user_id=user["id"]) + + now = datetime.now(timezone.utc) + refresh_expires = now + timedelta(days=7) + await insert(db, "refresh_tokens", { + "user_id": user["id"], + "token": refresh_token, + "expires_at": refresh_expires.isoformat(), + "revoked": False, + }) + + return Token(access_token=access_token, refresh_token=refresh_token, expires_in=ACCESS_TOKEN_EXPIRE_MINUTES * 60) + + +@api_router.post("/refresh", response_model=Token, status_code=status.HTTP_200_OK) +async def refresh_token(payload: TokenRefresh, db=Depends(get_db_connection)): + try: + token_data = decode_token(payload.refresh_token) + except HTTPException: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") + + if token_data.get("type") != "refresh": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type") + + user_id = token_data.get("sub") + stored = await select(db, "refresh_tokens", filter={"token": payload.refresh_token, "user_id": user_id, "revoked": False}) + if not stored: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token not found or revoked") + + token_row = stored[0] + expires_at = token_row.get("expires_at") + # La colonne est un TIMESTAMPTZ : psycopg renvoie deja un datetime. + if isinstance(expires_at, str): + expires_at = datetime.fromisoformat(expires_at.replace("Z", "+00:00")) + if expires_at and expires_at < datetime.now(timezone.utc): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expired") + + users = await select(db, "users", filter={"id": user_id}) + if not users or not users[0].get("is_active", False): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not found or inactive") + + user = users[0] + await delete(db, "refresh_tokens", filter={"token": payload.refresh_token}) + + new_access = create_access_token(subject=user["id"]) + new_refresh = create_refresh_token(user_id=user["id"]) + now = datetime.now(timezone.utc) + await insert(db, "refresh_tokens", { + "user_id": user["id"], + "token": new_refresh, + "expires_at": (now + timedelta(days=7)).isoformat(), + "revoked": False, + }) + + return Token(access_token=new_access, refresh_token=new_refresh, expires_in=ACCESS_TOKEN_EXPIRE_MINUTES * 60) + + +@api_router.post("/logout", response_model=MessageResponse, status_code=status.HTTP_200_OK) +async def logout(payload: TokenRefresh, db=Depends(get_db_connection)): + await delete(db, "refresh_tokens", filter={"token": payload.refresh_token}) + return MessageResponse(message="Logged out successfully") diff --git a/app/routers/feedback_public.py b/app/routers/feedback_public.py new file mode 100644 index 0000000..7f2fdc6 --- /dev/null +++ b/app/routers/feedback_public.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter, Request, status, HTTPException + +from app.utils.feedback import add_feedback + +from app.schemas.models import ( + FeedbackBase, + MessageResponse, +) +from app.core.settings import logger + + +api_router = APIRouter(prefix="/api/feedback", tags=["feedback"]) + + +@api_router.post("/", response_model=MessageResponse, status_code=status.HTTP_201_CREATED) +async def submit_public_feedback(request: Request, payload: FeedbackBase): + """Public endpoint to submit feedback without API key.""" + try: + result = await add_feedback(request.app.state.db_pool, payload.model_dump(mode="json")) + return result + except Exception as e: + logger.error(f"Error adding public feedback: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException( + status_code=500, detail="Error adding feedback") diff --git a/app/routers/feedbacks.py b/app/routers/feedbacks.py new file mode 100644 index 0000000..03bbcbe --- /dev/null +++ b/app/routers/feedbacks.py @@ -0,0 +1,93 @@ +from fastapi import APIRouter, BackgroundTasks, Depends, Request, status, HTTPException + +from app.utils.feedback import ( + add_feedback, get_feedback_by_id, get_all_feedbacks, update_feedback, delete_feedback) + +from app.schemas.models import ( + FeedbackSummary, + MessageResponse, + FeedbackUpdate, + FeedbackBase, + +) +from app.database.connection import get_db_connection +from app.core.settings import logger + + +api_router = APIRouter(prefix="/feedbacks", tags=["feedbacks"]) + + +@api_router.get("/", response_model=list[FeedbackSummary]) +async def _get_all_feedbacks(db=Depends(get_db_connection)): + try: + feedbacks = await get_all_feedbacks(db) + if not feedbacks: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail="No feedbacks found") + return feedbacks + except Exception as e: + logger.error(f"Error retrieving feedbacks: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException( + status_code=500, detail="Error retrieving feedbacks") + + +@api_router.get("/{feedback_id}", response_model=FeedbackSummary) +async def _get_feedback_by_id(feedback_id: str, db=Depends(get_db_connection)): + try: + feedback = await get_feedback_by_id(db, feedback_id) + if not feedback: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail=f"Feedback with id {feedback_id} not found") + return feedback + except Exception as e: + logger.error( + f"Error retrieving feedback with id {feedback_id}: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException( + status_code=500, detail="Error retrieving feedback") + + +@api_router.post("/send", response_model=MessageResponse, status_code=status.HTTP_201_CREATED) +async def add_feedback_message(request: Request, payload: FeedbackBase): + """Add a new feedback.""" + try: + result = await add_feedback(request.app.state.db_pool, payload.model_dump(mode="json")) + return result + except Exception as e: + logger.error(f"Error adding feedback: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException( + status_code=500, detail="Error adding feedback") + + +@api_router.put("/{feedback_id}", response_model=MessageResponse) +async def _update_feedback(feedback_id: str, payload: FeedbackUpdate, 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} + + result = await update_feedback(db, feedback_id, data_to_update) + return result + except Exception as e: + logger.error(f"Error updating feedback: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException( + status_code=500, detail="Error updating feedback") + + +@api_router.delete("/{feedback_id}", response_model=MessageResponse) +async def _delete_feedback(feedback_id: str, db=Depends(get_db_connection)): + try: + result = await delete_feedback(db, feedback_id) + return result + except Exception as e: + logger.error(f"Error deleting feedback: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException( + status_code=500, detail="Error deleting feedback") diff --git a/app/routers/registrations.py b/app/routers/registrations.py index 76c2251..3c90a33 100644 --- a/app/routers/registrations.py +++ b/app/routers/registrations.py @@ -11,15 +11,18 @@ AttendeeID, TicketSubmissionPayload ) -from uuid import uuid4 +from uuid import uuid4, UUID import httpx from app.utils.tickets import get_ticket_by_id from app.database.orm import select, select_with_join from app.routers.helper import submit_ticket from app.utils.registrations import ( create_registration, + get_all_registrations, ) from app.payments.paydunya_service import create_invoice +from app.core.auth import get_current_superuser +from app.database.orm import select as db_select api_router = APIRouter(tags=["registrations"]) @@ -145,3 +148,17 @@ async def _approve_student_registration(registration_id: AttendeeID, db=Depends( if isinstance(e, HTTPException): raise e raise HTTPException(status_code=500, detail="Internal server error") + + +@api_router.get("/registrations", dependencies=[Depends(get_current_superuser)]) +async def _list_registrations(request: Request, event_id: UUID | None = None, db=Depends(get_db_connection)): + try: + registrations = await get_all_registrations(db, event_id=event_id) + if not registrations: + return [] + return registrations + except Exception as e: + logger.error(f"Error listing registrations: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail="Error listing registrations") diff --git a/app/schemas/config.py b/app/schemas/config.py index 65e2c05..8fb08a9 100644 --- a/app/schemas/config.py +++ b/app/schemas/config.py @@ -25,6 +25,7 @@ class Config(BaseModel): smtp_port: int = 587 smtp_user: str = "user" smtp_password: str = "password" + admin_api_key: str = "" admin_smtp_server: str | None = None admin_smtp_port: int | None = None admin_smtp_user: str | None = None diff --git a/app/schemas/models.py b/app/schemas/models.py index 08208f0..40efd1f 100644 --- a/app/schemas/models.py +++ b/app/schemas/models.py @@ -122,6 +122,51 @@ class ContactMessageUpdate(BaseModel): default_factory=lambda: datetime.now(timezone.utc)) +class FeedbackBase(BaseModel): + sex: str | None = None + age: str | None = None + profession: str | None = None + country: str | None = None + python_level: str | None = None + heard: str | None = None + rating: int | None = Field(default=None, ge=1, le=5) + overall: str | None = None + favorite: str | None = None + improvements: str | None = None + comments: str | None = None + days: List[str] = Field(default_factory=list) + + +class FeedbackSummary(FeedbackBase): + id: UUID + is_resolved: bool = False + created_at: datetime + updated_at: datetime + + +class FeedbacksList(BaseModel): + feedbacks: list[FeedbackSummary] = Field( + default_factory=list) + + +class FeedbackUpdate(BaseModel): + sex: str | None = None + age: str | None = None + profession: str | None = None + country: str | None = None + python_level: str | None = None + heard: str | None = None + rating: int | None = Field(default=None, ge=1, le=5) + overall: str | None = None + favorite: str | None = None + improvements: str | None = None + comments: str | None = None + days: List[str] | None = None + is_resolved: bool | None = None + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc)) + + class APIKeyResponse(BaseModel): api_key: str @@ -531,6 +576,7 @@ class RegistrationCreate(RegistrationBase): class RegistrationSummary(RegistrationBase): id: UUID + event_id: UUID created_at: datetime updated_at: datetime diff --git a/app/utils/feedback.py b/app/utils/feedback.py new file mode 100644 index 0000000..dff7c7f --- /dev/null +++ b/app/utils/feedback.py @@ -0,0 +1,74 @@ +from fastapi import HTTPException, BackgroundTasks +from app.core.settings import logger + +from app.database.orm import select, insert, update, delete + + +async def add_feedback(db_pool, payload: dict): + try: + async with db_pool.connection() as db: + await insert(db, "feedbacks", payload) + return {"message": "Feedback received successfully"} + except Exception as e: + logger.error(f"Error adding feedback: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException( + status_code=500, detail="Error adding feedback") + + +async def delete_feedback(db, feedback_id: str): + try: + existing = await select(db, "feedbacks", filter={"id": feedback_id}) + if not existing: + raise HTTPException( + status_code=404, detail=f"Feedback with id {feedback_id} not found") + await delete(db, "feedbacks", filter={"id": feedback_id}) + return {"message": "Feedback deleted successfully"} + except Exception as e: + logger.error(f"Error deleting feedback: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail="Error deleting feedback") + + +async def get_feedback_by_id(db, feedback_id: str): + try: + feedback = await select(db, "feedbacks", filter={"id": feedback_id}) + if not feedback: + raise HTTPException( + status_code=404, detail=f"Feedback with id {feedback_id} not found") + return feedback[0] + except Exception as e: + logger.error(f"Error retrieving feedback: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail="Error retrieving feedback") + + +async def get_all_feedbacks(db): + try: + feedbacks = await select(db, "feedbacks") + return feedbacks + except Exception as e: + logger.error(f"Error retrieving all feedbacks: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException( + status_code=500, detail="Error retrieving all feedbacks") + + +async def update_feedback(db, feedback_id: str, payload: dict): + try: + existing = await select(db, "feedbacks", filter={"id": feedback_id}) + if not existing: + logger.error(f"Feedback with id {feedback_id} not found") + raise HTTPException( + status_code=404, detail=f"Feedback with id {feedback_id} not found") + await update(db, "feedbacks", payload, filter={"id": feedback_id}) + return {"message": "Feedback updated successfully"} + except Exception as e: + logger.error(f"Error updating feedback: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail="Error updating feedback") diff --git a/app/utils/registrations.py b/app/utils/registrations.py index ea04a26..553a566 100644 --- a/app/utils/registrations.py +++ b/app/utils/registrations.py @@ -11,6 +11,38 @@ from datetime import datetime, timezone +async def get_all_registrations(db, event_id: UUID | None = None): + try: + filter_data = {} + if event_id: + filter_data["event_id"] = str(event_id) + registrations = await select_with_join( + db, + table="registrations", + join_table="tickets", + join_condition="registrations.ticket_id = tickets.id", + filter=filter_data, + columns=[ + "registrations.id", + "registrations.full_name", + "registrations.email", + "registrations.ticket_type", + "registrations.ticket_quantity", + "registrations.attendance_status", + "registrations.payment_status", + "registrations.payment_reference", + "registrations.created_at", + "registrations.updated_at", + "registrations.event_id", + "tickets.name", + ], + ) + return registrations + except Exception as e: + logger.error(f"Error retrieving registrations: {str(e)}") + raise HTTPException(status_code=500, detail="Error retrieving registrations") + + def validate_registration_data(registration: RegistrationCreate, reg_existing, ticket): """ Validate the registration data. diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..1127f06 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,18 @@ +# Aligne la stack docker sur le volume postgres déjà initialisé +# (postgres/password/pythontogo_db) et libère le port 8080, occupé par Keycloak. +# Le .env racine vise l'instance locale supabase:5434, pas ces conteneurs. +services: + db: + ports: + - "5434:5432" + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=password + - POSTGRES_DB=pythontogo_db + + api: + ports: !override + - "8081:8080" + environment: + - DB_URL=postgresql://postgres:password@db:5432/pythontogo_db + - REDIS_URL=redis://redis:6379/0 diff --git a/docker-compose.yml b/docker-compose.yml index 8be6cb6..55fb9fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,7 @@ services: api: build: . ports: - - "8080:8080" + - "${API_PORT:-8080}:8080" env_file: - path: ./app/.env required: false diff --git a/entrypoint.sh b/entrypoint.sh index 019f4f2..e347cca 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -15,9 +15,14 @@ fi # 2. Attente de la base de données # Il est crucial d'attendre que le port 5432 soit ouvert avant de migrer echo "Attente de la base de données..." -# Si tu n'as pas 'nc' (netcat) installé, tu peux utiliser un simple sleep -# ou installer 'netcat-openbsd' dans ton Dockerfile -sleep 5 +for i in {1..30}; do + if python -c "import psycopg; psycopg.connect('postgresql://postgres:supabase@db:5432/postgres')" >/dev/null 2>&1; then + echo "Base de données prête." + break + fi + echo "Tentative $i/30 : base non prête, attente 1s..." + sleep 1 +done # 3. Exécution des migrations echo "Exécution des migrations..." diff --git a/requirements.txt b/requirements.txt index aaa9af8..65e2e7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ fastapi[standard]==0.115.12 uvicorn[standard]==0.20.0 pyjwt==2.10.1 +python-jose[cryptography]==3.3.0 python-decouple==3.8 passlib==1.7.4 psycopg[pool]==3.3.2