M-01+M-03: Add input validation and extra=forbid to all request schemas

- Add max_length constraints to all string fields in request schemas,
  matching DB column limits (title:255, description:5000, etc.)
- Add min_length=1 to required name/title fields
- Add ConfigDict(extra="forbid") to all request schemas to reject
  unknown fields (prevents silent field injection)
- Add Path(ge=1, le=2147483647) to all integer path parameters across
  all routers to prevent integer overflow → 500 errors
- Add max_length to TOTP inline schemas (code:6, mfa_token:256, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-27 15:43:55 +08:00
parent 581efa183a
commit 2f58282c31
22 changed files with 224 additions and 172 deletions

View File

@ -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),
):

View File

@ -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)
):

View File

@ -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),
):

View File

@ -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),

View File

@ -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)
):

View File

@ -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)
):

View File

@ -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)
):

View File

@ -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)
):

View File

@ -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),

View File

@ -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
# ---------------------------------------------------------------------------

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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