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..94f1620 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 @@ -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..fe9c303 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -0,0 +1,109 @@ +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") + if expires_at and datetime.fromisoformat(expires_at.replace("Z", "+00:00")) < 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/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