Compare commits
4 Commits
9f7bbbfcbb
...
1ebc41b9d7
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ebc41b9d7 | |||
| 8e27f2920b | |||
| 2f58282c31 | |||
| 581efa183a |
@ -9,8 +9,9 @@ class Settings(BaseSettings):
|
||||
COOKIE_SECURE: bool = False
|
||||
OPENWEATHERMAP_API_KEY: str = ""
|
||||
|
||||
# Session config
|
||||
SESSION_MAX_AGE_DAYS: int = 30
|
||||
# Session config — sliding window
|
||||
SESSION_MAX_AGE_DAYS: int = 7 # Sliding window: inactive sessions expire after 7 days
|
||||
SESSION_TOKEN_HARD_CEILING_DAYS: int = 30 # Absolute token lifetime for itsdangerous max_age
|
||||
|
||||
# MFA token config (short-lived token bridging password OK → TOTP verification)
|
||||
MFA_TOKEN_MAX_AGE_SECONDS: int = 300 # 5 minutes
|
||||
|
||||
@ -19,6 +19,59 @@ from app.models import system_config as _system_config_model # noqa: F401
|
||||
from app.models import audit_log as _audit_log_model # noqa: F401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure ASGI CSRF middleware — SEC-08 (global)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CSRFHeaderMiddleware:
|
||||
"""
|
||||
Require X-Requested-With: XMLHttpRequest on all state-mutating requests.
|
||||
|
||||
Browsers never send this header cross-origin without a CORS preflight,
|
||||
which our CORS policy blocks. This prevents CSRF attacks from simple
|
||||
form submissions and cross-origin fetches.
|
||||
|
||||
Uses pure ASGI (not BaseHTTPMiddleware) to avoid streaming/memory overhead.
|
||||
"""
|
||||
|
||||
_EXEMPT_PATHS = frozenset({
|
||||
"/health",
|
||||
"/",
|
||||
"/api/auth/login",
|
||||
"/api/auth/setup",
|
||||
"/api/auth/register",
|
||||
"/api/auth/totp-verify",
|
||||
"/api/auth/totp/enforce-setup",
|
||||
"/api/auth/totp/enforce-confirm",
|
||||
})
|
||||
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] == "http":
|
||||
method = scope.get("method", "")
|
||||
path = scope.get("path", "")
|
||||
|
||||
if method in self._MUTATING_METHODS and path not in self._EXEMPT_PATHS:
|
||||
headers = dict(scope.get("headers", []))
|
||||
if headers.get(b"x-requested-with") != b"XMLHttpRequest":
|
||||
body = b'{"detail":"Invalid request origin"}'
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": 403,
|
||||
"headers": [
|
||||
[b"content-type", b"application/json"],
|
||||
[b"content-length", str(len(body)).encode()],
|
||||
],
|
||||
})
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
return
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
scheduler = AsyncIOScheduler()
|
||||
@ -47,7 +100,12 @@ app = FastAPI(
|
||||
openapi_url="/openapi.json" if _is_dev else None,
|
||||
)
|
||||
|
||||
# CORS configuration
|
||||
# Middleware stack — added in reverse order (last added = outermost).
|
||||
# CSRF is added first (innermost), then CORS wraps it (outermost).
|
||||
# This ensures CORS headers appear on CSRF 403 responses.
|
||||
app.add_middleware(CSRFHeaderMiddleware)
|
||||
|
||||
# CORS configuration — outermost layer
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()],
|
||||
|
||||
@ -4,7 +4,7 @@ Admin router — full user management, system config, and audit log.
|
||||
Security measures implemented:
|
||||
SEC-02: Session revocation on role change
|
||||
SEC-05: Block admin self-actions (own role/password/MFA/active status)
|
||||
SEC-08: X-Requested-With header check (verify_xhr) on all state-mutating requests
|
||||
SEC-08: X-Requested-With validation (now handled globally by CSRFHeaderMiddleware)
|
||||
SEC-13: Session revocation + ntfy alert on MFA disable
|
||||
|
||||
All routes require the `require_admin` dependency (which chains through
|
||||
@ -15,7 +15,7 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import sqlalchemy as sa
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
@ -48,26 +48,12 @@ from app.services.audit import log_audit_event
|
||||
from app.services.auth import hash_password
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSRF guard — SEC-08
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def verify_xhr(request: Request) -> None:
|
||||
"""
|
||||
Lightweight CSRF mitigation: require X-Requested-With on state-mutating
|
||||
requests. Browsers never send this header cross-origin without CORS
|
||||
pre-flight, which our CORS policy blocks.
|
||||
"""
|
||||
if request.method not in ("GET", "HEAD", "OPTIONS"):
|
||||
if request.headers.get("X-Requested-With") != "XMLHttpRequest":
|
||||
raise HTTPException(status_code=403, detail="Invalid request origin")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router — all endpoints inherit require_admin + verify_xhr
|
||||
# Router — all endpoints inherit require_admin
|
||||
# (SEC-08 CSRF validation is now handled globally by CSRFHeaderMiddleware)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(require_admin), Depends(verify_xhr)],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
|
||||
|
||||
@ -145,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),
|
||||
):
|
||||
@ -221,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),
|
||||
@ -275,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),
|
||||
):
|
||||
@ -320,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),
|
||||
):
|
||||
@ -370,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),
|
||||
@ -405,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),
|
||||
@ -448,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),
|
||||
):
|
||||
@ -479,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),
|
||||
):
|
||||
|
||||
@ -36,6 +36,7 @@ from app.schemas.auth import (
|
||||
)
|
||||
from app.services.auth import (
|
||||
hash_password,
|
||||
verify_password,
|
||||
verify_password_with_upgrade,
|
||||
create_session_token,
|
||||
verify_session_token,
|
||||
@ -47,6 +48,12 @@ from app.config import settings as app_settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Pre-computed dummy hash for timing equalization (M-02).
|
||||
# When a login attempt targets a non-existent username, we still run
|
||||
# Argon2id verification against this dummy hash so the response time
|
||||
# is indistinguishable from a wrong-password attempt.
|
||||
_DUMMY_HASH = hash_password("timing-equalization-dummy")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cookie helper
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -68,11 +75,16 @@ def _set_session_cookie(response: Response, token: str) -> None:
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session_cookie: Optional[str] = Cookie(None, alias="session"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""
|
||||
Dependency that verifies the session cookie and returns the authenticated User.
|
||||
|
||||
L-03 sliding window: if the session has less than
|
||||
(SESSION_MAX_AGE_DAYS - 1) days remaining, silently extend expires_at
|
||||
and re-issue the cookie so active users never hit expiration.
|
||||
"""
|
||||
if not session_cookie:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
@ -106,6 +118,17 @@ async def get_current_user(
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found or inactive")
|
||||
|
||||
# L-03: Sliding window renewal — extend session if >1 day has elapsed since
|
||||
# last renewal (i.e. remaining time < SESSION_MAX_AGE_DAYS - 1 day).
|
||||
now = datetime.now()
|
||||
renewal_threshold = timedelta(days=app_settings.SESSION_MAX_AGE_DAYS - 1)
|
||||
if db_session.expires_at - now < renewal_threshold:
|
||||
db_session.expires_at = now + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
|
||||
await db.flush()
|
||||
# Re-issue cookie with fresh signed token to reset browser max_age timer
|
||||
fresh_token = create_session_token(user_id, session_id)
|
||||
_set_session_cookie(response, fresh_token)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@ -287,12 +310,17 @@ async def login(
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
# M-02: Run Argon2id against a dummy hash so the response time is
|
||||
# indistinguishable from a wrong-password attempt (prevents username enumeration).
|
||||
verify_password("x", _DUMMY_HASH)
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
await _check_account_lockout(user)
|
||||
|
||||
# M-02: Run password verification BEFORE lockout check so Argon2id always
|
||||
# executes — prevents distinguishing "locked" from "wrong password" via timing.
|
||||
valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash)
|
||||
|
||||
await _check_account_lockout(user)
|
||||
|
||||
if not valid:
|
||||
await _record_failed_login(db, user)
|
||||
await log_audit_event(
|
||||
|
||||
@ -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)
|
||||
):
|
||||
|
||||
@ -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),
|
||||
):
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
):
|
||||
|
||||
@ -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)
|
||||
):
|
||||
|
||||
@ -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)
|
||||
):
|
||||
|
||||
@ -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)
|
||||
):
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -88,10 +88,14 @@ def create_session_token(user_id: int, session_id: str) -> str:
|
||||
def verify_session_token(token: str, max_age: int | None = None) -> dict | None:
|
||||
"""
|
||||
Verify a session cookie and return its payload dict, or None if invalid/expired.
|
||||
max_age defaults to SESSION_MAX_AGE_DAYS from config.
|
||||
|
||||
max_age defaults to SESSION_TOKEN_HARD_CEILING_DAYS (absolute token lifetime).
|
||||
The sliding window (SESSION_MAX_AGE_DAYS) is enforced via DB expires_at checks,
|
||||
not by itsdangerous — this decoupling prevents the serializer from rejecting
|
||||
renewed tokens that were created more than SESSION_MAX_AGE_DAYS ago.
|
||||
"""
|
||||
if max_age is None:
|
||||
max_age = app_settings.SESSION_MAX_AGE_DAYS * 86400
|
||||
max_age = app_settings.SESSION_TOKEN_HARD_CEILING_DAYS * 86400
|
||||
try:
|
||||
return _serializer.loads(token, max_age=max_age)
|
||||
except (BadSignature, SignatureExpired):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user