Skip to content

Commit 91c4dab

Browse files
authored
refactor(frontend): replace useAsyncTask with TanStack Query
1 parent 3fe0a6c commit 91c4dab

38 files changed

+806
-493
lines changed

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ You can open an [issue](https://github.com/ivanskv2000/evsy/issues) — we’d l
2323
You can setup both the backend and frontend in dev mode with hot-reloading:
2424

2525
```bash
26+
cp .env.example .env
2627
make dev
2728
```
2829

backend/app/api/v1/routes/events.py

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,13 @@ def create_event_route(event: EventCreate, db: Session = Depends(get_db)):
4242
db_fields = get_fields_by_ids(db, event.fields)
4343

4444
if len(db_fields) != len(event.fields):
45-
raise HTTPException(status_code=400, detail="One or more fields do not exist.")
45+
raise HTTPException(
46+
status_code=status.HTTP_400_BAD_REQUEST,
47+
detail={
48+
"code": "invalid_reference",
49+
"message": "One or more referenced fields do not exist.",
50+
},
51+
)
4652

4753
get_or_create_tags(db, event.tags)
4854
db_event = event_crud.create_event(db=db, event=event)
@@ -63,7 +69,13 @@ def create_event_route(event: EventCreate, db: Session = Depends(get_db)):
6369
def get_event_route(event_id: int, db: Session = Depends(get_db)):
6470
db_event = event_crud.get_event(db=db, event_id=event_id)
6571
if db_event is None:
66-
raise HTTPException(status_code=404, detail="Event not found")
72+
raise HTTPException(
73+
status_code=status.HTTP_404_NOT_FOUND,
74+
detail={
75+
"code": "resource_not_found",
76+
"message": f"Event with id {event_id} not found",
77+
},
78+
)
6779
return db_event
6880

6981

@@ -100,12 +112,24 @@ def update_event_route(
100112
db_fields = get_fields_by_ids(db, event.fields)
101113

102114
if len(db_fields) != len(event.fields):
103-
raise HTTPException(status_code=400, detail="One or more fields do not exist.")
115+
raise HTTPException(
116+
status_code=status.HTTP_400_BAD_REQUEST,
117+
detail={
118+
"code": "invalid_reference",
119+
"message": "One or more referenced fields do not exist.",
120+
},
121+
)
104122

105123
get_or_create_tags(db, event.tags)
106124
db_event = event_crud.update_event(db=db, event_id=event_id, event=event)
107125
if db_event is None:
108-
raise HTTPException(status_code=404, detail="Event not found")
126+
raise HTTPException(
127+
status_code=status.HTTP_404_NOT_FOUND,
128+
detail={
129+
"code": "resource_not_found",
130+
"message": f"Event with id {event_id} not found",
131+
},
132+
)
109133
return db_event
110134

111135

@@ -122,7 +146,13 @@ def update_event_route(
122146
def delete_event_route(event_id: int, db: Session = Depends(get_db)):
123147
db_event = event_crud.delete_event(db=db, event_id=event_id)
124148
if db_event is None:
125-
raise HTTPException(status_code=404, detail="Event not found")
149+
raise HTTPException(
150+
status_code=status.HTTP_404_NOT_FOUND,
151+
detail={
152+
"code": "resource_not_found",
153+
"message": f"Event with id {event_id} not found",
154+
},
155+
)
126156
return db_event
127157

128158

@@ -151,7 +181,13 @@ def get_event_json_schema(
151181
):
152182
db_event = event_crud.get_event(db=db, event_id=event_id)
153183
if not db_event:
154-
raise HTTPException(status_code=404, detail="Event not found")
184+
raise HTTPException(
185+
status_code=status.HTTP_404_NOT_FOUND,
186+
detail={
187+
"code": "resource_not_found",
188+
"message": f"Event with id {event_id} not found",
189+
},
190+
)
155191

156192
event = EventOut.model_validate(db_event)
157193
schema = generate_json_schema_for_event(
@@ -187,7 +223,13 @@ def get_event_yaml_schema(
187223
):
188224
db_event = event_crud.get_event(db=db, event_id=event_id)
189225
if not db_event:
190-
raise HTTPException(status_code=404, detail="Event not found")
226+
raise HTTPException(
227+
status_code=status.HTTP_404_NOT_FOUND,
228+
detail={
229+
"code": "resource_not_found",
230+
"message": f"Event with id {event_id} not found",
231+
},
232+
)
191233

192234
event = EventOut.model_validate(db_event)
193235
schema = generate_json_schema_for_event(

backend/app/api/v1/routes/fields.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,13 @@ def get_field_route(
5656
):
5757
db_field = field_crud.get_field(db=db, field_id=field_id)
5858
if db_field is None:
59-
raise HTTPException(status_code=404, detail="Field not found")
59+
raise HTTPException(
60+
status_code=status.HTTP_404_NOT_FOUND,
61+
detail={
62+
"code": "resource_not_found",
63+
"message": f"Field with id {field_id} not found",
64+
},
65+
)
6066

6167
if with_event_count:
6268
count = field_crud.get_field_event_count(db=db, field_id=field_id)
@@ -81,7 +87,13 @@ def update_field_route(
8187
):
8288
db_field = field_crud.update_field(db=db, field_id=field_id, field=field)
8389
if db_field is None:
84-
raise HTTPException(status_code=404, detail="Field not found")
90+
raise HTTPException(
91+
status_code=status.HTTP_404_NOT_FOUND,
92+
detail={
93+
"code": "resource_not_found",
94+
"message": f"Field with id {field_id} not found",
95+
},
96+
)
8597
return db_field
8698

8799

@@ -98,5 +110,11 @@ def update_field_route(
98110
def delete_field_route(field_id: int, db: Session = Depends(get_db)):
99111
db_field = field_crud.delete_field(db=db, field_id=field_id)
100112
if db_field is None:
101-
raise HTTPException(status_code=404, detail="Field not found")
113+
raise HTTPException(
114+
status_code=status.HTTP_404_NOT_FOUND,
115+
detail={
116+
"code": "resource_not_found",
117+
"message": f"Field with id {field_id} not found",
118+
},
119+
)
102120
return db_field

backend/app/api/v1/routes/tags.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,13 @@ def list_tags_route(db: Session = Depends(get_db)):
5050
def get_tag_route(tag_id: str, db: Session = Depends(get_db)):
5151
db_tag = tag_crud.get_tag(db=db, tag_id=tag_id)
5252
if db_tag is None:
53-
raise HTTPException(status_code=404, detail="Tag not found")
53+
raise HTTPException(
54+
status_code=status.HTTP_404_NOT_FOUND,
55+
detail={
56+
"code": "resource_not_found",
57+
"message": f"Tag with id {tag_id!r} not found",
58+
},
59+
)
5460
return db_tag
5561

5662

@@ -68,7 +74,13 @@ def get_tag_route(tag_id: str, db: Session = Depends(get_db)):
6874
def update_tag_route(tag_id: str, tag: TagCreate, db: Session = Depends(get_db)):
6975
db_tag = tag_crud.update_tag(db=db, tag_id=tag_id, tag=tag)
7076
if db_tag is None:
71-
raise HTTPException(status_code=404, detail="Tag not found")
77+
raise HTTPException(
78+
status_code=status.HTTP_404_NOT_FOUND,
79+
detail={
80+
"code": "resource_not_found",
81+
"message": f"Tag with id {tag_id!r} not found",
82+
},
83+
)
7284
return db_tag
7385

7486

@@ -85,5 +97,11 @@ def update_tag_route(tag_id: str, tag: TagCreate, db: Session = Depends(get_db))
8597
def delete_tag_route(tag_id: str, db: Session = Depends(get_db)):
8698
db_tag = tag_crud.delete_tag(db=db, tag_id=tag_id)
8799
if db_tag is None:
88-
raise HTTPException(status_code=404, detail="Tag not found")
100+
raise HTTPException(
101+
status_code=status.HTTP_404_NOT_FOUND,
102+
detail={
103+
"code": "resource_not_found",
104+
"message": f"Tag with id {tag_id!r} not found",
105+
},
106+
)
89107
return db_tag

backend/app/core/guard.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
1-
from fastapi import Depends, HTTPException
1+
from fastapi import Depends, HTTPException, status
22

33
from app.settings import get_settings
44

55

66
def ensure_not_demo(settings=Depends(get_settings)):
77
if settings.is_demo:
88
raise HTTPException(
9-
status_code=403, detail="This action is not allowed in demo mode."
9+
status_code=status.HTTP_403_FORBIDDEN,
10+
detail={
11+
"code": "action_forbidden_in_demo",
12+
"message": "This action is not allowed in demo mode.",
13+
},
1014
)
1115

1216

1317
def ensure_dev(settings=Depends(get_settings)):
1418
if not settings.is_dev:
1519
raise HTTPException(
16-
status_code=403, detail="This action is allowed only in development."
20+
status_code=status.HTTP_403_FORBIDDEN,
21+
detail={
22+
"code": "action_requires_dev_mode",
23+
"message": "This action is allowed only in development.",
24+
},
1725
)

backend/app/core/handlers.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from fastapi import Request, status
2+
from fastapi.encoders import jsonable_encoder
3+
from fastapi.exceptions import RequestValidationError
4+
from fastapi.responses import JSONResponse
5+
from pydantic import BaseModel, Field
6+
from starlette.exceptions import HTTPException as StarletteHTTPException
7+
8+
9+
class ValidationErrorDetail(BaseModel):
10+
loc: tuple[str | int, ...] = Field(
11+
..., description="Location of the error in the request"
12+
)
13+
msg: str = Field(..., description="A human-readable message for the error")
14+
type: str = Field(..., description="The type of the error")
15+
16+
17+
class ErrorResponse(BaseModel):
18+
code: str = Field(..., description="A unique, machine-readable error code")
19+
message: str = Field(..., description="A human-readable message for the error")
20+
details: list[ValidationErrorDetail] | None = Field(
21+
None, description="Optional details for validation errors"
22+
)
23+
24+
25+
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
26+
"""
27+
Handler for FastAPI's built-in HTTPException.
28+
29+
This handler formats the error into a standard ErrorResponse model.
30+
It supports passing a dictionary with 'code' and 'message' in exc.detail.
31+
"""
32+
if isinstance(exc.detail, dict):
33+
# If detail is a dict, assume it matches our convention
34+
code = exc.detail.get("code", "http_exception")
35+
message = exc.detail.get("message", "An unexpected error occurred.")
36+
else:
37+
# If detail is a string, wrap it in the standard format
38+
code = "http_exception"
39+
message = str(exc.detail)
40+
41+
return JSONResponse(
42+
status_code=exc.status_code,
43+
content=jsonable_encoder(ErrorResponse(code=code, message=message)),
44+
)
45+
46+
47+
async def validation_exception_handler(request: Request, exc: RequestValidationError):
48+
"""Handler for Pydantic's RequestValidationError."""
49+
details = [
50+
ValidationErrorDetail(loc=err["loc"], msg=err["msg"], type=err["type"])
51+
for err in exc.errors()
52+
]
53+
return JSONResponse(
54+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
55+
content=jsonable_encoder(
56+
ErrorResponse(
57+
code="validation_error",
58+
message="Input validation failed",
59+
details=details,
60+
)
61+
),
62+
)

backend/app/factory.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
from contextlib import asynccontextmanager
22

33
from fastapi import FastAPI
4+
from fastapi.exceptions import RequestValidationError
45
from fastapi.middleware.cors import CORSMiddleware
56
from sqlalchemy.orm import sessionmaker
7+
from starlette.exceptions import HTTPException as StarletteHTTPException
68

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

83+
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
84+
app.add_exception_handler(RequestValidationError, validation_exception_handler)
85+
8086
@app.get("/")
8187
def read_root():
8288
return {"message": "Welcome to the Evsy API!"}

backend/tests/test_auth.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def test_signup_duplicate_email(client, auth_data):
2121
# Repeat signup to trigger duplicate error
2222
response = client.post("/v1/auth/signup", json=auth_data)
2323
assert response.status_code == 400
24-
assert "already exists" in response.json()["detail"].lower()
24+
assert "already exists" in response.json()["message"].lower()
2525

2626

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

4343

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

5252

5353
def test_me_requires_auth(client):

backend/tests/test_events.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def test_create_event_with_invalid_field(auth_client):
3737
},
3838
)
3939
assert response.status_code == 400
40-
assert "fields" in response.json()["detail"].lower()
40+
assert "fields" in response.json()["message"].lower()
4141

4242

4343
def test_create_event_with_new_tag(auth_client):

backend/tests/test_events_extended.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def test_get_nonexistent_event(auth_client):
119119
"""Test getting event that doesn't exist"""
120120
response = auth_client.get("/v1/events/99999")
121121
assert response.status_code == 404
122-
assert "not found" in response.json()["detail"].lower()
122+
assert "not found" in response.json()["message"].lower()
123123

124124

125125
def test_update_nonexistent_event(auth_client):

0 commit comments

Comments
 (0)