Skip to content
Merged
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
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ You can open an [issue](https://github.com/ivanskv2000/evsy/issues) — we’d l
You can setup both the backend and frontend in dev mode with hot-reloading:

```bash
cp .env.example .env
make dev
```

Expand Down
56 changes: 49 additions & 7 deletions backend/app/api/v1/routes/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ def create_event_route(event: EventCreate, db: Session = Depends(get_db)):
db_fields = get_fields_by_ids(db, event.fields)

if len(db_fields) != len(event.fields):
raise HTTPException(status_code=400, detail="One or more fields do not exist.")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "invalid_reference",
"message": "One or more referenced fields do not exist.",
},
)

get_or_create_tags(db, event.tags)
db_event = event_crud.create_event(db=db, event=event)
Expand All @@ -63,7 +69,13 @@ def create_event_route(event: EventCreate, db: Session = Depends(get_db)):
def get_event_route(event_id: int, db: Session = Depends(get_db)):
db_event = event_crud.get_event(db=db, event_id=event_id)
if db_event is None:
raise HTTPException(status_code=404, detail="Event not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "resource_not_found",
"message": f"Event with id {event_id} not found",
},
)
return db_event


Expand Down Expand Up @@ -100,12 +112,24 @@ def update_event_route(
db_fields = get_fields_by_ids(db, event.fields)

if len(db_fields) != len(event.fields):
raise HTTPException(status_code=400, detail="One or more fields do not exist.")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "invalid_reference",
"message": "One or more referenced fields do not exist.",
},
)

get_or_create_tags(db, event.tags)
db_event = event_crud.update_event(db=db, event_id=event_id, event=event)
if db_event is None:
raise HTTPException(status_code=404, detail="Event not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "resource_not_found",
"message": f"Event with id {event_id} not found",
},
)
return db_event


Expand All @@ -122,7 +146,13 @@ def update_event_route(
def delete_event_route(event_id: int, db: Session = Depends(get_db)):
db_event = event_crud.delete_event(db=db, event_id=event_id)
if db_event is None:
raise HTTPException(status_code=404, detail="Event not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "resource_not_found",
"message": f"Event with id {event_id} not found",
},
)
return db_event


Expand Down Expand Up @@ -151,7 +181,13 @@ def get_event_json_schema(
):
db_event = event_crud.get_event(db=db, event_id=event_id)
if not db_event:
raise HTTPException(status_code=404, detail="Event not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "resource_not_found",
"message": f"Event with id {event_id} not found",
},
)

event = EventOut.model_validate(db_event)
schema = generate_json_schema_for_event(
Expand Down Expand Up @@ -187,7 +223,13 @@ def get_event_yaml_schema(
):
db_event = event_crud.get_event(db=db, event_id=event_id)
if not db_event:
raise HTTPException(status_code=404, detail="Event not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "resource_not_found",
"message": f"Event with id {event_id} not found",
},
)

event = EventOut.model_validate(db_event)
schema = generate_json_schema_for_event(
Expand Down
24 changes: 21 additions & 3 deletions backend/app/api/v1/routes/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,13 @@ def get_field_route(
):
db_field = field_crud.get_field(db=db, field_id=field_id)
if db_field is None:
raise HTTPException(status_code=404, detail="Field not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "resource_not_found",
"message": f"Field with id {field_id} not found",
},
)

if with_event_count:
count = field_crud.get_field_event_count(db=db, field_id=field_id)
Expand All @@ -81,7 +87,13 @@ def update_field_route(
):
db_field = field_crud.update_field(db=db, field_id=field_id, field=field)
if db_field is None:
raise HTTPException(status_code=404, detail="Field not found")
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


Expand All @@ -98,5 +110,11 @@ def update_field_route(
def delete_field_route(field_id: int, db: Session = Depends(get_db)):
db_field = field_crud.delete_field(db=db, field_id=field_id)
if db_field is None:
raise HTTPException(status_code=404, detail="Field not found")
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
24 changes: 21 additions & 3 deletions backend/app/api/v1/routes/tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@ def list_tags_route(db: Session = Depends(get_db)):
def get_tag_route(tag_id: str, db: Session = Depends(get_db)):
db_tag = tag_crud.get_tag(db=db, tag_id=tag_id)
if db_tag is None:
raise HTTPException(status_code=404, detail="Tag not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "resource_not_found",
"message": f"Tag with id {tag_id!r} not found",
},
)
return db_tag


Expand All @@ -68,7 +74,13 @@ def get_tag_route(tag_id: str, db: Session = Depends(get_db)):
def update_tag_route(tag_id: str, tag: TagCreate, db: Session = Depends(get_db)):
db_tag = tag_crud.update_tag(db=db, tag_id=tag_id, tag=tag)
if db_tag is None:
raise HTTPException(status_code=404, detail="Tag not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "resource_not_found",
"message": f"Tag with id {tag_id!r} not found",
},
)
return db_tag


Expand All @@ -85,5 +97,11 @@ def update_tag_route(tag_id: str, tag: TagCreate, db: Session = Depends(get_db))
def delete_tag_route(tag_id: str, db: Session = Depends(get_db)):
db_tag = tag_crud.delete_tag(db=db, tag_id=tag_id)
if db_tag is None:
raise HTTPException(status_code=404, detail="Tag not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "resource_not_found",
"message": f"Tag with id {tag_id!r} not found",
},
)
return db_tag
14 changes: 11 additions & 3 deletions backend/app/core/guard.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
from fastapi import Depends, HTTPException
from fastapi import Depends, HTTPException, status

from app.settings import get_settings


def ensure_not_demo(settings=Depends(get_settings)):
if settings.is_demo:
raise HTTPException(
status_code=403, detail="This action is not allowed in demo mode."
status_code=status.HTTP_403_FORBIDDEN,
detail={
"code": "action_forbidden_in_demo",
"message": "This action is not allowed in demo mode.",
},
)


def ensure_dev(settings=Depends(get_settings)):
if not settings.is_dev:
raise HTTPException(
status_code=403, detail="This action is allowed only in development."
status_code=status.HTTP_403_FORBIDDEN,
detail={
"code": "action_requires_dev_mode",
"message": "This action is allowed only in development.",
},
)
62 changes: 62 additions & 0 deletions backend/app/core/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from fastapi import Request, status
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from starlette.exceptions import HTTPException as StarletteHTTPException


class ValidationErrorDetail(BaseModel):
loc: tuple[str | int, ...] = Field(
..., description="Location of the error in the request"
)
msg: str = Field(..., description="A human-readable message for the error")
type: str = Field(..., description="The type of the error")


class ErrorResponse(BaseModel):
code: str = Field(..., description="A unique, machine-readable error code")
message: str = Field(..., description="A human-readable message for the error")
details: list[ValidationErrorDetail] | None = Field(
None, description="Optional details for validation errors"
)


async def http_exception_handler(request: Request, exc: StarletteHTTPException):
"""
Handler for FastAPI's built-in HTTPException.

This handler formats the error into a standard ErrorResponse model.
It supports passing a dictionary with 'code' and 'message' in exc.detail.
"""
if isinstance(exc.detail, dict):
# If detail is a dict, assume it matches our convention
code = exc.detail.get("code", "http_exception")
message = exc.detail.get("message", "An unexpected error occurred.")
else:
# If detail is a string, wrap it in the standard format
code = "http_exception"
message = str(exc.detail)

return JSONResponse(
status_code=exc.status_code,
content=jsonable_encoder(ErrorResponse(code=code, message=message)),
)


async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Handler for Pydantic's RequestValidationError."""
details = [
ValidationErrorDetail(loc=err["loc"], msg=err["msg"], type=err["type"])
for err in exc.errors()
]
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=jsonable_encoder(
ErrorResponse(
code="validation_error",
message="Input validation failed",
details=details,
)
),
)
6 changes: 6 additions & 0 deletions backend/app/factory.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import sessionmaker
from starlette.exceptions import HTTPException as StarletteHTTPException

from app.api.v1.routes import admin, auth, events, fields, generic, tags
from app.core.handlers import http_exception_handler, validation_exception_handler
from app.modules.auth.schemas import UserCreate
from app.modules.auth.service import create_user_if_not_exists
from app.settings import Settings
Expand Down Expand Up @@ -77,6 +80,9 @@ async def lifespan(app: FastAPI):
app.include_router(admin.router, prefix="/v1", tags=["admin"])
app.include_router(auth.router, prefix="/v1", tags=["auth"])

app.add_exception_handler(StarletteHTTPException, http_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler)

@app.get("/")
def read_root():
return {"message": "Welcome to the Evsy API!"}
Expand Down
6 changes: 3 additions & 3 deletions backend/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def test_signup_duplicate_email(client, auth_data):
# Repeat signup to trigger duplicate error
response = client.post("/v1/auth/signup", json=auth_data)
assert response.status_code == 400
assert "already exists" in response.json()["detail"].lower()
assert "already exists" in response.json()["message"].lower()


def test_login_with_valid_credentials(client, auth_data):
Expand All @@ -38,7 +38,7 @@ def test_login_with_invalid_password(client, auth_data):
json={"email": auth_data["email"], "password": "wrongpassword"},
)
assert response.status_code == 401
assert "invalid credentials" in response.json()["detail"].lower()
assert "invalid credentials" in response.json()["message"].lower()


def test_login_with_unknown_email(client):
Expand All @@ -47,7 +47,7 @@ def test_login_with_unknown_email(client):
json={"email": "unknown@example.com", "password": "irrelevant"},
)
assert response.status_code == 401
assert "invalid credentials" in response.json()["detail"].lower()
assert "invalid credentials" in response.json()["message"].lower()


def test_me_requires_auth(client):
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def test_create_event_with_invalid_field(auth_client):
},
)
assert response.status_code == 400
assert "fields" in response.json()["detail"].lower()
assert "fields" in response.json()["message"].lower()


def test_create_event_with_new_tag(auth_client):
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_events_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def test_get_nonexistent_event(auth_client):
"""Test getting event that doesn't exist"""
response = auth_client.get("/v1/events/99999")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
assert "not found" in response.json()["message"].lower()


def test_update_nonexistent_event(auth_client):
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_fields_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ def test_get_nonexistent_field(auth_client):
"""Test getting field that doesn't exist"""
response = auth_client.get("/v1/fields/99999")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
assert "not found" in response.json()["message"].lower()


def test_update_nonexistent_field(auth_client):
Expand Down
Loading