diff --git a/.env.example b/.env.example index 2d016a7..d93a09e 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,11 @@ DATABASE_URL=postgresql+psycopg2://evsy:evsy@db:5432/evsy # For local dev use http://localhost:3000 FRONTEND_URL=http://localhost:3000 +# Public URL of the backend (used for building OAuth callback URLs) +# For local dev use http://localhost:8000 +# IMPORTANT: In production, this must match the actual backend domain. +BACKEND_URL=http://localhost:8000 + # ======================== # Frontend Configuration diff --git a/backend/app/api/v1/routes/fields.py b/backend/app/api/v1/routes/fields.py index 51e4c19..c206e2c 100644 --- a/backend/app/api/v1/routes/fields.py +++ b/backend/app/api/v1/routes/fields.py @@ -19,11 +19,20 @@ description="Create a new field that can be associated with events.", responses={ 201: {"description": "Field created successfully"}, - 400: {"description": "Validation error"}, + 400: {"description": "Validation error or field already exists"}, }, ) def create_field_route(field: FieldCreate, db: Session = Depends(get_db)): - return field_crud.create_field(db=db, field=field) + try: + return field_crud.create_field(db=db, field=field) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "duplicate_resource", + "message": str(e), + }, + ) from e @router.get( @@ -79,22 +88,31 @@ def get_field_route( responses={ 200: {"description": "Field updated successfully"}, 404: {"description": "Field not found"}, - 400: {"description": "Validation error"}, + 400: {"description": "Validation error or field already exists"}, }, ) def update_field_route( field_id: int, field: FieldCreate, db: Session = Depends(get_db) ): - db_field = field_crud.update_field(db=db, field_id=field_id, field=field) - if db_field is None: + try: + db_field = field_crud.update_field(db=db, field_id=field_id, field=field) + if db_field is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "resource_not_found", + "message": f"Field with id {field_id} not found", + }, + ) + return db_field + except ValueError as e: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, + status_code=status.HTTP_400_BAD_REQUEST, detail={ - "code": "resource_not_found", - "message": f"Field with id {field_id} not found", + "code": "duplicate_resource", + "message": str(e), }, - ) - return db_field + ) from e @router.delete( diff --git a/backend/app/api/v1/routes/tags.py b/backend/app/api/v1/routes/tags.py index bec3349..e69dd85 100644 --- a/backend/app/api/v1/routes/tags.py +++ b/backend/app/api/v1/routes/tags.py @@ -19,11 +19,20 @@ description="Create a tag manually. Typically, tags are created automatically when creating or updating an event.", responses={ 201: {"description": "Tag created successfully"}, - 400: {"description": "Validation error"}, + 400: {"description": "Validation error or tag already exists"}, }, ) def create_tag_route(tag: TagCreate, db: Session = Depends(get_db)): - return tag_crud.create_tag(db=db, tag=tag) + try: + return tag_crud.create_tag(db=db, tag=tag) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "duplicate_resource", + "message": str(e), + }, + ) from e @router.get( diff --git a/backend/app/modules/auth/oauth.py b/backend/app/modules/auth/oauth.py index f4037dd..44c29c7 100644 --- a/backend/app/modules/auth/oauth.py +++ b/backend/app/modules/auth/oauth.py @@ -129,5 +129,5 @@ def exchange_code_for_email(provider: str, code: str, redirect_uri: str) -> str: def get_email_from_oauth(login: OAuthLogin) -> str: - redirect_uri = "http://localhost:8000/api/v1/auth/oauth/callback" + redirect_uri = f"{settings.backend_url}/api/v1/auth/oauth/callback" return exchange_code_for_email(login.provider, login.token, redirect_uri) diff --git a/backend/app/modules/auth/router.py b/backend/app/modules/auth/router.py index 5731988..298bb25 100644 --- a/backend/app/modules/auth/router.py +++ b/backend/app/modules/auth/router.py @@ -1,5 +1,6 @@ import base64 import json +import secrets from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi.responses import RedirectResponse @@ -143,6 +144,7 @@ def start_oauth_login( state_payload = { "provider": provider, "redirect": redirect, + "nonce": secrets.token_urlsafe(16), } state = base64.urlsafe_b64encode(json.dumps(state_payload).encode()).decode() diff --git a/backend/app/modules/auth/schemas.py b/backend/app/modules/auth/schemas.py index 5ec752a..3a78b0e 100644 --- a/backend/app/modules/auth/schemas.py +++ b/backend/app/modules/auth/schemas.py @@ -1,6 +1,6 @@ from typing import Annotated, Literal, Optional -from pydantic import BaseModel, ConfigDict, EmailStr, StringConstraints +from pydantic import BaseModel, ConfigDict, EmailStr, Field, StringConstraints PasswordStr = Annotated[str, StringConstraints(min_length=6)] ProviderName = Literal["github", "google"] @@ -21,7 +21,10 @@ class UserLogin(UserBase): class OAuthLogin(BaseModel): provider: ProviderName - token: str + token: str = Field( + ..., + description="The authorization code received from the OAuth provider callback.", + ) class UserOut(UserBase): diff --git a/backend/app/modules/events/crud.py b/backend/app/modules/events/crud.py index bcd3494..3509455 100644 --- a/backend/app/modules/events/crud.py +++ b/backend/app/modules/events/crud.py @@ -24,10 +24,6 @@ def create_event(db: Session, event: schemas.EventCreate): return db_event -def get_event(db: Session, event_id: int): - return db.query(models.Event).filter(models.Event.id == event_id).first() - - def get_events(db: Session): return ( db.query(models.Event) @@ -40,6 +36,10 @@ def get_events(db: Session): ) +def get_event(db: Session, event_id: int): + return db.query(models.Event).filter(models.Event.id == event_id).first() + + def update_event(db: Session, event_id: int, event: schemas.EventCreate): db_event = db.query(models.Event).filter(models.Event.id == event_id).first() if db_event is None: diff --git a/backend/app/modules/events/schemas.py b/backend/app/modules/events/schemas.py index 39dfb59..a14072b 100644 --- a/backend/app/modules/events/schemas.py +++ b/backend/app/modules/events/schemas.py @@ -42,11 +42,8 @@ class EventCreate(EventBase): pass -class EventOut(BaseModel): +class EventOut(EventBase): id: int - name: str - description: Optional[str] = None - links: Optional[List[EventLink]] = None tags: list[TagOut] fields: list[FieldOut] created_at: datetime diff --git a/backend/app/modules/fields/crud.py b/backend/app/modules/fields/crud.py index 7891d1f..e735c7f 100644 --- a/backend/app/modules/fields/crud.py +++ b/backend/app/modules/fields/crud.py @@ -1,4 +1,3 @@ -from fastapi import HTTPException from sqlalchemy import func from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -19,9 +18,7 @@ def create_field(db: Session, field: schemas.FieldCreate): return db_field except IntegrityError: db.rollback() - raise HTTPException( - status_code=400, detail=f"Field with name {field.name!r} already exists." - ) from None + raise ValueError(f"Field with name {field.name!r} already exists.") from None def get_fields(db: Session): @@ -38,9 +35,15 @@ def update_field(db: Session, field_id: int, field: schemas.FieldCreate): db_field.name = field.name db_field.description = field.description db_field.field_type = field.field_type - db.commit() - db.refresh(db_field) - return db_field + try: + db.commit() + db.refresh(db_field) + return db_field + except IntegrityError: + db.rollback() + raise ValueError( + f"Field with name {field.name!r} already exists." + ) from None return None diff --git a/backend/app/modules/tags/crud.py b/backend/app/modules/tags/crud.py index 1c56d77..7120534 100644 --- a/backend/app/modules/tags/crud.py +++ b/backend/app/modules/tags/crud.py @@ -1,3 +1,4 @@ +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.shared.models import EventTag @@ -8,9 +9,13 @@ def create_tag(db: Session, tag: schemas.TagCreate): db_tag = models.Tag(id=tag.id, description=tag.description) db.add(db_tag) - db.commit() - db.refresh(db_tag) - return db_tag + try: + db.commit() + db.refresh(db_tag) + return db_tag + except IntegrityError: + db.rollback() + raise ValueError(f"Tag with id {tag.id!r} already exists.") from None def get_tags(db: Session): diff --git a/backend/app/modules/tags/schemas.py b/backend/app/modules/tags/schemas.py index 2893a0e..b38c047 100644 --- a/backend/app/modules/tags/schemas.py +++ b/backend/app/modules/tags/schemas.py @@ -1,10 +1,14 @@ from datetime import datetime -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field class TagBase(BaseModel): - id: str + id: str = Field( + min_length=1, + max_length=50, + description="Tag ID must be between 1 and 50 characters", + ) description: str | None = None diff --git a/backend/app/settings.py b/backend/app/settings.py index 3711d7c..6c02062 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -51,6 +51,7 @@ def __init__(self, _env_file: Optional[str] = None, **kwargs): env: Literal["dev", "prod", "demo", "test"] = Field(default="dev", alias="ENV") database_url: str = "sqlite:///./test.db" frontend_url: Optional[str] = None + backend_url: str = "http://localhost:8000" secret_key: str = "your_secret_key_here"