diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 4dd0a41..04345c4 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -131,7 +131,7 @@ async def list_users( @router.get("/users/{user_id}", response_model=UserDetailResponse) async def get_user( - user_id: int, + user_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), _actor: User = Depends(get_current_user), ): @@ -207,8 +207,8 @@ async def create_user( @router.put("/users/{user_id}/role") async def update_user_role( - user_id: int, - data: UpdateUserRoleRequest, + user_id: int = Path(ge=1, le=2147483647), + data: UpdateUserRoleRequest = ..., request: Request, db: AsyncSession = Depends(get_db), actor: User = Depends(get_current_user), @@ -261,8 +261,8 @@ async def update_user_role( @router.post("/users/{user_id}/reset-password", response_model=ResetPasswordResponse) async def reset_user_password( - user_id: int, - request: Request, + user_id: int = Path(ge=1, le=2147483647), + request: Request = ..., db: AsyncSession = Depends(get_db), actor: User = Depends(get_current_user), ): @@ -306,8 +306,8 @@ async def reset_user_password( @router.post("/users/{user_id}/disable-mfa") async def disable_user_mfa( - user_id: int, - request: Request, + user_id: int = Path(ge=1, le=2147483647), + request: Request = ..., db: AsyncSession = Depends(get_db), actor: User = Depends(get_current_user), ): @@ -356,8 +356,8 @@ async def disable_user_mfa( @router.put("/users/{user_id}/enforce-mfa") async def toggle_mfa_enforce( - user_id: int, - data: ToggleMfaEnforceRequest, + user_id: int = Path(ge=1, le=2147483647), + data: ToggleMfaEnforceRequest = ..., request: Request, db: AsyncSession = Depends(get_db), actor: User = Depends(get_current_user), @@ -391,8 +391,8 @@ async def toggle_mfa_enforce( @router.put("/users/{user_id}/active") async def toggle_user_active( - user_id: int, - data: ToggleActiveRequest, + user_id: int = Path(ge=1, le=2147483647), + data: ToggleActiveRequest = ..., request: Request, db: AsyncSession = Depends(get_db), actor: User = Depends(get_current_user), @@ -434,8 +434,8 @@ async def toggle_user_active( @router.delete("/users/{user_id}/sessions") async def revoke_user_sessions( - user_id: int, - request: Request, + user_id: int = Path(ge=1, le=2147483647), + request: Request = ..., db: AsyncSession = Depends(get_db), actor: User = Depends(get_current_user), ): @@ -465,7 +465,7 @@ async def revoke_user_sessions( @router.get("/users/{user_id}/sessions") async def list_user_sessions( - user_id: int, + user_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), _actor: User = Depends(get_current_user), ): diff --git a/backend/app/routers/calendars.py b/backend/app/routers/calendars.py index 4f35984..2e324a7 100644 --- a/backend/app/routers/calendars.py +++ b/backend/app/routers/calendars.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Path from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, update from typing import List @@ -48,8 +48,8 @@ async def create_calendar( @router.put("/{calendar_id}", response_model=CalendarResponse) async def update_calendar( - calendar_id: int, - calendar_update: CalendarUpdate, + calendar_id: int = Path(ge=1, le=2147483647), + calendar_update: CalendarUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -77,7 +77,7 @@ async def update_calendar( @router.delete("/{calendar_id}", status_code=204) async def delete_calendar( - calendar_id: int, + calendar_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): diff --git a/backend/app/routers/event_templates.py b/backend/app/routers/event_templates.py index 7d7a90b..2d50354 100644 --- a/backend/app/routers/event_templates.py +++ b/backend/app/routers/event_templates.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Path, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -43,8 +43,8 @@ async def create_template( @router.put("/{template_id}", response_model=EventTemplateResponse) async def update_template( - template_id: int, - payload: EventTemplateUpdate, + template_id: int = Path(ge=1, le=2147483647), + payload: EventTemplateUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -68,7 +68,7 @@ async def update_template( @router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_template( - template_id: int, + template_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index e36c198..c0a3c2e 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -1,5 +1,5 @@ import json -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, delete from sqlalchemy.orm import selectinload @@ -272,7 +272,7 @@ async def create_event( @router.get("/{event_id}", response_model=CalendarEventResponse) async def get_event( - event_id: int, + event_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -296,8 +296,8 @@ async def get_event( @router.put("/{event_id}", response_model=CalendarEventResponse) async def update_event( - event_id: int, - event_update: CalendarEventUpdate, + event_id: int = Path(ge=1, le=2147483647), + event_update: CalendarEventUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -421,7 +421,7 @@ async def update_event( @router.delete("/{event_id}", status_code=204) async def delete_event( - event_id: int, + event_id: int = Path(ge=1, le=2147483647), scope: Optional[Literal["this", "this_and_future"]] = Query(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), diff --git a/backend/app/routers/locations.py b/backend/app/routers/locations.py index e209d8f..30ab340 100644 --- a/backend/app/routers/locations.py +++ b/backend/app/routers/locations.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, or_ from datetime import datetime, timezone @@ -120,7 +120,7 @@ async def create_location( @router.get("/{location_id}", response_model=LocationResponse) async def get_location( - location_id: int, + location_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -138,8 +138,8 @@ async def get_location( @router.put("/{location_id}", response_model=LocationResponse) async def update_location( - location_id: int, - location_update: LocationUpdate, + location_id: int = Path(ge=1, le=2147483647), + location_update: LocationUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -168,7 +168,7 @@ async def update_location( @router.delete("/{location_id}", status_code=204) async def delete_location( - location_id: int, + location_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py index 2bee2b1..2c1b517 100644 --- a/backend/app/routers/people.py +++ b/backend/app/routers/people.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, or_ from datetime import datetime, timezone @@ -91,7 +91,7 @@ async def create_person( @router.get("/{person_id}", response_model=PersonResponse) async def get_person( - person_id: int, + person_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -109,8 +109,8 @@ async def get_person( @router.put("/{person_id}", response_model=PersonResponse) async def update_person( - person_id: int, - person_update: PersonUpdate, + person_id: int = Path(ge=1, le=2147483647), + person_update: PersonUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -146,7 +146,7 @@ async def update_person( @router.delete("/{person_id}", status_code=204) async def delete_person( - person_id: int, + person_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index c49fecf..7314287 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from sqlalchemy.orm import selectinload @@ -128,7 +128,7 @@ async def create_project( @router.get("/{project_id}", response_model=ProjectResponse) async def get_project( - project_id: int, + project_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -149,8 +149,8 @@ async def get_project( @router.put("/{project_id}", response_model=ProjectResponse) async def update_project( - project_id: int, - project_update: ProjectUpdate, + project_id: int = Path(ge=1, le=2147483647), + project_update: ProjectUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -178,7 +178,7 @@ async def update_project( @router.delete("/{project_id}", status_code=204) async def delete_project( - project_id: int, + project_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -199,7 +199,7 @@ async def delete_project( @router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse]) async def get_project_tasks( - project_id: int, + project_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -230,8 +230,8 @@ async def get_project_tasks( @router.post("/{project_id}/tasks", response_model=ProjectTaskResponse, status_code=201) async def create_project_task( - project_id: int, - task: ProjectTaskCreate, + project_id: int = Path(ge=1, le=2147483647), + task: ProjectTaskCreate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -279,8 +279,8 @@ async def create_project_task( @router.put("/{project_id}/tasks/reorder", status_code=200) async def reorder_tasks( - project_id: int, - items: List[ReorderItem], + project_id: int = Path(ge=1, le=2147483647), + items: List[ReorderItem] = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -312,9 +312,9 @@ async def reorder_tasks( @router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse) async def update_project_task( - project_id: int, - task_id: int, - task_update: ProjectTaskUpdate, + project_id: int = Path(ge=1, le=2147483647), + task_id: int = Path(ge=1, le=2147483647), + task_update: ProjectTaskUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -356,8 +356,8 @@ async def update_project_task( @router.delete("/{project_id}/tasks/{task_id}", status_code=204) async def delete_project_task( - project_id: int, - task_id: int, + project_id: int = Path(ge=1, le=2147483647), + task_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -388,9 +388,9 @@ async def delete_project_task( @router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201) async def create_task_comment( - project_id: int, - task_id: int, - comment: TaskCommentCreate, + project_id: int = Path(ge=1, le=2147483647), + task_id: int = Path(ge=1, le=2147483647), + comment: TaskCommentCreate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -423,9 +423,9 @@ async def create_task_comment( @router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204) async def delete_task_comment( - project_id: int, - task_id: int, - comment_id: int, + project_id: int = Path(ge=1, le=2147483647), + task_id: int = Path(ge=1, le=2147483647), + comment_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): diff --git a/backend/app/routers/reminders.py b/backend/app/routers/reminders.py index 788ea79..ef4de54 100644 --- a/backend/app/routers/reminders.py +++ b/backend/app/routers/reminders.py @@ -1,6 +1,6 @@ from datetime import datetime, timedelta -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_, or_ from typing import Optional, List @@ -69,8 +69,8 @@ async def get_due_reminders( @router.patch("/{reminder_id}/snooze", response_model=ReminderResponse) async def snooze_reminder( - reminder_id: int, - body: ReminderSnooze, + reminder_id: int = Path(ge=1, le=2147483647), + body: ReminderSnooze = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -115,7 +115,7 @@ async def create_reminder( @router.get("/{reminder_id}", response_model=ReminderResponse) async def get_reminder( - reminder_id: int, + reminder_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -136,8 +136,8 @@ async def get_reminder( @router.put("/{reminder_id}", response_model=ReminderResponse) async def update_reminder( - reminder_id: int, - reminder_update: ReminderUpdate, + reminder_id: int = Path(ge=1, le=2147483647), + reminder_update: ReminderUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -175,7 +175,7 @@ async def update_reminder( @router.delete("/{reminder_id}", status_code=204) async def delete_reminder( - reminder_id: int, + reminder_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -199,7 +199,7 @@ async def delete_reminder( @router.patch("/{reminder_id}/dismiss", response_model=ReminderResponse) async def dismiss_reminder( - reminder_id: int, + reminder_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): diff --git a/backend/app/routers/todos.py b/backend/app/routers/todos.py index d5e1d57..f42fdb7 100644 --- a/backend/app/routers/todos.py +++ b/backend/app/routers/todos.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_, func from typing import Optional, List @@ -174,7 +174,7 @@ async def create_todo( @router.get("/{todo_id}", response_model=TodoResponse) async def get_todo( - todo_id: int, + todo_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -192,8 +192,8 @@ async def get_todo( @router.put("/{todo_id}", response_model=TodoResponse) async def update_todo( - todo_id: int, - todo_update: TodoUpdate, + todo_id: int = Path(ge=1, le=2147483647), + todo_update: TodoUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), current_settings: Settings = Depends(get_current_settings), @@ -249,7 +249,7 @@ async def update_todo( @router.delete("/{todo_id}", status_code=204) async def delete_todo( - todo_id: int, + todo_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -270,7 +270,7 @@ async def delete_todo( @router.patch("/{todo_id}/toggle", response_model=TodoResponse) async def toggle_todo( - todo_id: int, + todo_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), current_settings: Settings = Depends(get_current_settings), diff --git a/backend/app/routers/totp.py b/backend/app/routers/totp.py index e147892..e0424da 100644 --- a/backend/app/routers/totp.py +++ b/backend/app/routers/totp.py @@ -24,7 +24,7 @@ from datetime import datetime, timedelta from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, Response -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, delete from sqlalchemy.exc import IntegrityError @@ -76,32 +76,38 @@ _ph = PasswordHasher( # --------------------------------------------------------------------------- class TOTPConfirmRequest(BaseModel): - code: str + model_config = ConfigDict(extra="forbid") + code: str = Field(min_length=6, max_length=6) class TOTPVerifyRequest(BaseModel): - mfa_token: str - code: Optional[str] = None # 6-digit TOTP code - backup_code: Optional[str] = None # Alternative: XXXX-XXXX backup code + model_config = ConfigDict(extra="forbid") + mfa_token: str = Field(max_length=256) + code: Optional[str] = Field(None, min_length=6, max_length=6) # 6-digit TOTP code + backup_code: Optional[str] = Field(None, max_length=9) # XXXX-XXXX backup code class TOTPDisableRequest(BaseModel): - password: str - code: str # Current TOTP code required to disable + model_config = ConfigDict(extra="forbid") + password: str = Field(max_length=128) + code: str = Field(min_length=6, max_length=6) # Current TOTP code required to disable class BackupCodesRegenerateRequest(BaseModel): - password: str - code: str # Current TOTP code required to regenerate + model_config = ConfigDict(extra="forbid") + password: str = Field(max_length=128) + code: str = Field(min_length=6, max_length=6) # Current TOTP code required to regenerate class EnforceSetupRequest(BaseModel): - mfa_token: str + model_config = ConfigDict(extra="forbid") + mfa_token: str = Field(max_length=256) class EnforceConfirmRequest(BaseModel): - mfa_token: str - code: str # 6-digit TOTP code from authenticator app + model_config = ConfigDict(extra="forbid") + mfa_token: str = Field(max_length=256) + code: str = Field(min_length=6, max_length=6) # 6-digit TOTP code from authenticator app # --------------------------------------------------------------------------- diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index ccfcf74..ad86a15 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -84,6 +84,8 @@ class LoginRequest(BaseModel): class ChangePasswordRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + old_password: str new_password: str @@ -94,6 +96,8 @@ class ChangePasswordRequest(BaseModel): class VerifyPasswordRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + password: str @field_validator("password") diff --git a/backend/app/schemas/calendar.py b/backend/app/schemas/calendar.py index d2eafd4..e9e2753 100644 --- a/backend/app/schemas/calendar.py +++ b/backend/app/schemas/calendar.py @@ -1,16 +1,20 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from datetime import datetime from typing import Optional class CalendarCreate(BaseModel): - name: str - color: str = "#3b82f6" + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=100) + color: str = Field("#3b82f6", max_length=20) class CalendarUpdate(BaseModel): - name: Optional[str] = None - color: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + name: Optional[str] = Field(None, min_length=1, max_length=100) + color: Optional[str] = Field(None, max_length=20) is_visible: Optional[bool] = None diff --git a/backend/app/schemas/calendar_event.py b/backend/app/schemas/calendar_event.py index 54e40ff..88ccf66 100644 --- a/backend/app/schemas/calendar_event.py +++ b/backend/app/schemas/calendar_event.py @@ -39,12 +39,14 @@ def _coerce_recurrence_rule(v): class CalendarEventCreate(BaseModel): - title: str - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + title: str = Field(min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) start_datetime: datetime end_datetime: datetime all_day: bool = False - color: Optional[str] = None + color: Optional[str] = Field(None, max_length=20) location_id: Optional[int] = None recurrence_rule: Optional[RecurrenceRule] = None is_starred: bool = False @@ -57,12 +59,14 @@ class CalendarEventCreate(BaseModel): class CalendarEventUpdate(BaseModel): - title: Optional[str] = None - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + title: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) start_datetime: Optional[datetime] = None end_datetime: Optional[datetime] = None all_day: Optional[bool] = None - color: Optional[str] = None + color: Optional[str] = Field(None, max_length=20) location_id: Optional[int] = None recurrence_rule: Optional[RecurrenceRule] = None is_starred: Optional[bool] = None diff --git a/backend/app/schemas/event_template.py b/backend/app/schemas/event_template.py index 9868537..75fdf4e 100644 --- a/backend/app/schemas/event_template.py +++ b/backend/app/schemas/event_template.py @@ -1,25 +1,29 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from datetime import datetime from typing import Optional class EventTemplateCreate(BaseModel): - name: str - title: str - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + title: str = Field(min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) calendar_id: Optional[int] = None - recurrence_rule: Optional[str] = None + recurrence_rule: Optional[str] = Field(None, max_length=5000) all_day: bool = False location_id: Optional[int] = None is_starred: bool = False class EventTemplateUpdate(BaseModel): - name: Optional[str] = None - title: Optional[str] = None - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + name: Optional[str] = Field(None, min_length=1, max_length=255) + title: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) calendar_id: Optional[int] = None - recurrence_rule: Optional[str] = None + recurrence_rule: Optional[str] = Field(None, max_length=5000) all_day: Optional[bool] = None location_id: Optional[int] = None is_starred: Optional[bool] = None diff --git a/backend/app/schemas/location.py b/backend/app/schemas/location.py index e436d57..d8334ee 100644 --- a/backend/app/schemas/location.py +++ b/backend/app/schemas/location.py @@ -1,5 +1,5 @@ import re -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from datetime import datetime from typing import Optional, Literal @@ -14,13 +14,15 @@ class LocationSearchResult(BaseModel): class LocationCreate(BaseModel): - name: str - address: str - category: str = "other" - notes: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + address: str = Field(min_length=1, max_length=2000) + category: str = Field("other", max_length=100) + notes: Optional[str] = Field(None, max_length=5000) is_frequent: bool = False - contact_number: Optional[str] = None - email: Optional[str] = None + contact_number: Optional[str] = Field(None, max_length=50) + email: Optional[str] = Field(None, max_length=255) @field_validator('email') @classmethod @@ -31,13 +33,15 @@ class LocationCreate(BaseModel): class LocationUpdate(BaseModel): - name: Optional[str] = None - address: Optional[str] = None - category: Optional[str] = None - notes: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + name: Optional[str] = Field(None, min_length=1, max_length=255) + address: Optional[str] = Field(None, min_length=1, max_length=2000) + category: Optional[str] = Field(None, max_length=100) + notes: Optional[str] = Field(None, max_length=5000) is_frequent: Optional[bool] = None - contact_number: Optional[str] = None - email: Optional[str] = None + contact_number: Optional[str] = Field(None, max_length=50) + email: Optional[str] = Field(None, max_length=255) @field_validator('email') @classmethod diff --git a/backend/app/schemas/person.py b/backend/app/schemas/person.py index ebe37c7..6e2a5e7 100644 --- a/backend/app/schemas/person.py +++ b/backend/app/schemas/person.py @@ -1,5 +1,5 @@ import re -from pydantic import BaseModel, ConfigDict, model_validator, field_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator, field_validator from datetime import datetime, date from typing import Optional @@ -7,20 +7,22 @@ _EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') class PersonCreate(BaseModel): - name: Optional[str] = None # legacy fallback — auto-split into first/last if provided alone - first_name: Optional[str] = None - last_name: Optional[str] = None - nickname: Optional[str] = None - email: Optional[str] = None - phone: Optional[str] = None - mobile: Optional[str] = None - address: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + name: Optional[str] = Field(None, max_length=255) # legacy fallback — auto-split into first/last if provided alone + first_name: Optional[str] = Field(None, max_length=100) + last_name: Optional[str] = Field(None, max_length=100) + nickname: Optional[str] = Field(None, max_length=100) + email: Optional[str] = Field(None, max_length=255) + phone: Optional[str] = Field(None, max_length=50) + mobile: Optional[str] = Field(None, max_length=50) + address: Optional[str] = Field(None, max_length=2000) birthday: Optional[date] = None - category: Optional[str] = None + category: Optional[str] = Field(None, max_length=100) is_favourite: bool = False - company: Optional[str] = None - job_title: Optional[str] = None - notes: Optional[str] = None + company: Optional[str] = Field(None, max_length=255) + job_title: Optional[str] = Field(None, max_length=255) + notes: Optional[str] = Field(None, max_length=5000) @model_validator(mode='after') def require_some_name(self) -> 'PersonCreate': @@ -42,20 +44,22 @@ class PersonCreate(BaseModel): class PersonUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + # name is intentionally omitted — always computed from first/last/nickname - first_name: Optional[str] = None - last_name: Optional[str] = None - nickname: Optional[str] = None - email: Optional[str] = None - phone: Optional[str] = None - mobile: Optional[str] = None - address: Optional[str] = None + first_name: Optional[str] = Field(None, max_length=100) + last_name: Optional[str] = Field(None, max_length=100) + nickname: Optional[str] = Field(None, max_length=100) + email: Optional[str] = Field(None, max_length=255) + phone: Optional[str] = Field(None, max_length=50) + mobile: Optional[str] = Field(None, max_length=50) + address: Optional[str] = Field(None, max_length=2000) birthday: Optional[date] = None - category: Optional[str] = None + category: Optional[str] = Field(None, max_length=100) is_favourite: Optional[bool] = None - company: Optional[str] = None - job_title: Optional[str] = None - notes: Optional[str] = None + company: Optional[str] = Field(None, max_length=255) + job_title: Optional[str] = Field(None, max_length=255) + notes: Optional[str] = Field(None, max_length=5000) @field_validator('email') @classmethod diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 2b2f39e..925156e 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from datetime import datetime, date from typing import Optional, List, Literal from app.schemas.project_task import ProjectTaskResponse @@ -7,19 +7,23 @@ ProjectStatus = Literal["not_started", "in_progress", "completed", "blocked", "r class ProjectCreate(BaseModel): - name: str - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) status: ProjectStatus = "not_started" - color: Optional[str] = None + color: Optional[str] = Field(None, max_length=20) due_date: Optional[date] = None is_tracked: bool = False class ProjectUpdate(BaseModel): - name: Optional[str] = None - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + name: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) status: Optional[ProjectStatus] = None - color: Optional[str] = None + color: Optional[str] = Field(None, max_length=20) due_date: Optional[date] = None is_tracked: Optional[bool] = None diff --git a/backend/app/schemas/project_task.py b/backend/app/schemas/project_task.py index 760fdcb..531315e 100644 --- a/backend/app/schemas/project_task.py +++ b/backend/app/schemas/project_task.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from datetime import datetime, date from typing import Optional, List, Literal from app.schemas.task_comment import TaskCommentResponse @@ -8,8 +8,10 @@ TaskPriority = Literal["none", "low", "medium", "high"] class ProjectTaskCreate(BaseModel): - title: str - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + title: str = Field(min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) status: TaskStatus = "pending" priority: TaskPriority = "medium" due_date: Optional[date] = None @@ -19,8 +21,10 @@ class ProjectTaskCreate(BaseModel): class ProjectTaskUpdate(BaseModel): - title: Optional[str] = None - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + title: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) status: Optional[TaskStatus] = None priority: Optional[TaskPriority] = None due_date: Optional[date] = None diff --git a/backend/app/schemas/reminder.py b/backend/app/schemas/reminder.py index a4a8d39..f639c75 100644 --- a/backend/app/schemas/reminder.py +++ b/backend/app/schemas/reminder.py @@ -1,19 +1,23 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from datetime import datetime from typing import Literal, Optional class ReminderCreate(BaseModel): - title: str - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + title: str = Field(min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) remind_at: Optional[datetime] = None is_active: bool = True recurrence_rule: Optional[Literal['daily', 'weekly', 'monthly']] = None class ReminderUpdate(BaseModel): - title: Optional[str] = None - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + title: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) remind_at: Optional[datetime] = None is_active: Optional[bool] = None is_dismissed: Optional[bool] = None @@ -21,6 +25,8 @@ class ReminderUpdate(BaseModel): class ReminderSnooze(BaseModel): + model_config = ConfigDict(extra="forbid") + minutes: Literal[5, 10, 15] client_now: Optional[datetime] = None diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 8591e62..8f11b60 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -1,5 +1,5 @@ import re -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from datetime import datetime from typing import Literal, Optional @@ -9,17 +9,19 @@ _NTFY_TOPIC_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$') class SettingsUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + accent_color: Optional[AccentColor] = None upcoming_days: int | None = None - preferred_name: str | None = None - weather_city: str | None = None + preferred_name: str | None = Field(None, max_length=100) + weather_city: str | None = Field(None, max_length=100) weather_lat: float | None = None weather_lon: float | None = None first_day_of_week: int | None = None # ntfy configuration fields - ntfy_server_url: Optional[str] = None - ntfy_topic: Optional[str] = None + ntfy_server_url: Optional[str] = Field(None, max_length=500) + ntfy_topic: Optional[str] = Field(None, max_length=100) # Empty string means "clear the token"; None means "leave unchanged" ntfy_auth_token: Optional[str] = None ntfy_enabled: Optional[bool] = None diff --git a/backend/app/schemas/task_comment.py b/backend/app/schemas/task_comment.py index 5b3dfc0..6c28615 100644 --- a/backend/app/schemas/task_comment.py +++ b/backend/app/schemas/task_comment.py @@ -1,9 +1,11 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from datetime import datetime class TaskCommentCreate(BaseModel): - content: str + model_config = ConfigDict(extra="forbid") + + content: str = Field(min_length=1, max_length=10000) class TaskCommentResponse(BaseModel): diff --git a/backend/app/schemas/todo.py b/backend/app/schemas/todo.py index 80338eb..f7cf251 100644 --- a/backend/app/schemas/todo.py +++ b/backend/app/schemas/todo.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from datetime import datetime, date, time from typing import Optional, Literal @@ -7,24 +7,28 @@ RecurrenceRule = Literal["daily", "weekly", "monthly"] class TodoCreate(BaseModel): - title: str - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + title: str = Field(min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) priority: TodoPriority = "medium" due_date: Optional[date] = None due_time: Optional[time] = None - category: Optional[str] = None + category: Optional[str] = Field(None, max_length=100) recurrence_rule: Optional[RecurrenceRule] = None project_id: Optional[int] = None class TodoUpdate(BaseModel): - title: Optional[str] = None - description: Optional[str] = None + model_config = ConfigDict(extra="forbid") + + title: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = Field(None, max_length=5000) priority: Optional[TodoPriority] = None due_date: Optional[date] = None due_time: Optional[time] = None completed: Optional[bool] = None - category: Optional[str] = None + category: Optional[str] = Field(None, max_length=100) recurrence_rule: Optional[RecurrenceRule] = None project_id: Optional[int] = None