Compare commits
4 Commits
9f7bbbfcbb
...
1ebc41b9d7
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ebc41b9d7 | |||
| 8e27f2920b | |||
| 2f58282c31 | |||
| 581efa183a |
@ -9,8 +9,9 @@ class Settings(BaseSettings):
|
|||||||
COOKIE_SECURE: bool = False
|
COOKIE_SECURE: bool = False
|
||||||
OPENWEATHERMAP_API_KEY: str = ""
|
OPENWEATHERMAP_API_KEY: str = ""
|
||||||
|
|
||||||
# Session config
|
# Session config — sliding window
|
||||||
SESSION_MAX_AGE_DAYS: int = 30
|
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 config (short-lived token bridging password OK → TOTP verification)
|
||||||
MFA_TOKEN_MAX_AGE_SECONDS: int = 300 # 5 minutes
|
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
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
@ -47,7 +100,12 @@ app = FastAPI(
|
|||||||
openapi_url="/openapi.json" if _is_dev else None,
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()],
|
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:
|
Security measures implemented:
|
||||||
SEC-02: Session revocation on role change
|
SEC-02: Session revocation on role change
|
||||||
SEC-05: Block admin self-actions (own role/password/MFA/active status)
|
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
|
SEC-13: Session revocation + ntfy alert on MFA disable
|
||||||
|
|
||||||
All routes require the `require_admin` dependency (which chains through
|
All routes require the `require_admin` dependency (which chains through
|
||||||
@ -15,7 +15,7 @@ from datetime import datetime
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import sqlalchemy as sa
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
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
|
from app.services.auth import hash_password
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CSRF guard — SEC-08
|
# Router — all endpoints inherit require_admin
|
||||||
# ---------------------------------------------------------------------------
|
# (SEC-08 CSRF validation is now handled globally by CSRFHeaderMiddleware)
|
||||||
|
|
||||||
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 = APIRouter(
|
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)
|
@router.get("/users/{user_id}", response_model=UserDetailResponse)
|
||||||
async def get_user(
|
async def get_user(
|
||||||
user_id: int,
|
user_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_actor: User = Depends(get_current_user),
|
_actor: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@ -221,8 +207,8 @@ async def create_user(
|
|||||||
|
|
||||||
@router.put("/users/{user_id}/role")
|
@router.put("/users/{user_id}/role")
|
||||||
async def update_user_role(
|
async def update_user_role(
|
||||||
user_id: int,
|
user_id: int = Path(ge=1, le=2147483647),
|
||||||
data: UpdateUserRoleRequest,
|
data: UpdateUserRoleRequest = ...,
|
||||||
request: Request,
|
request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
actor: User = Depends(get_current_user),
|
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)
|
@router.post("/users/{user_id}/reset-password", response_model=ResetPasswordResponse)
|
||||||
async def reset_user_password(
|
async def reset_user_password(
|
||||||
user_id: int,
|
user_id: int = Path(ge=1, le=2147483647),
|
||||||
request: Request,
|
request: Request = ...,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
actor: User = Depends(get_current_user),
|
actor: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@ -320,8 +306,8 @@ async def reset_user_password(
|
|||||||
|
|
||||||
@router.post("/users/{user_id}/disable-mfa")
|
@router.post("/users/{user_id}/disable-mfa")
|
||||||
async def disable_user_mfa(
|
async def disable_user_mfa(
|
||||||
user_id: int,
|
user_id: int = Path(ge=1, le=2147483647),
|
||||||
request: Request,
|
request: Request = ...,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
actor: User = Depends(get_current_user),
|
actor: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@ -370,8 +356,8 @@ async def disable_user_mfa(
|
|||||||
|
|
||||||
@router.put("/users/{user_id}/enforce-mfa")
|
@router.put("/users/{user_id}/enforce-mfa")
|
||||||
async def toggle_mfa_enforce(
|
async def toggle_mfa_enforce(
|
||||||
user_id: int,
|
user_id: int = Path(ge=1, le=2147483647),
|
||||||
data: ToggleMfaEnforceRequest,
|
data: ToggleMfaEnforceRequest = ...,
|
||||||
request: Request,
|
request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
actor: User = Depends(get_current_user),
|
actor: User = Depends(get_current_user),
|
||||||
@ -405,8 +391,8 @@ async def toggle_mfa_enforce(
|
|||||||
|
|
||||||
@router.put("/users/{user_id}/active")
|
@router.put("/users/{user_id}/active")
|
||||||
async def toggle_user_active(
|
async def toggle_user_active(
|
||||||
user_id: int,
|
user_id: int = Path(ge=1, le=2147483647),
|
||||||
data: ToggleActiveRequest,
|
data: ToggleActiveRequest = ...,
|
||||||
request: Request,
|
request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
actor: User = Depends(get_current_user),
|
actor: User = Depends(get_current_user),
|
||||||
@ -448,8 +434,8 @@ async def toggle_user_active(
|
|||||||
|
|
||||||
@router.delete("/users/{user_id}/sessions")
|
@router.delete("/users/{user_id}/sessions")
|
||||||
async def revoke_user_sessions(
|
async def revoke_user_sessions(
|
||||||
user_id: int,
|
user_id: int = Path(ge=1, le=2147483647),
|
||||||
request: Request,
|
request: Request = ...,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
actor: User = Depends(get_current_user),
|
actor: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@ -479,7 +465,7 @@ async def revoke_user_sessions(
|
|||||||
|
|
||||||
@router.get("/users/{user_id}/sessions")
|
@router.get("/users/{user_id}/sessions")
|
||||||
async def list_user_sessions(
|
async def list_user_sessions(
|
||||||
user_id: int,
|
user_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_actor: User = Depends(get_current_user),
|
_actor: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
|||||||
@ -36,6 +36,7 @@ from app.schemas.auth import (
|
|||||||
)
|
)
|
||||||
from app.services.auth import (
|
from app.services.auth import (
|
||||||
hash_password,
|
hash_password,
|
||||||
|
verify_password,
|
||||||
verify_password_with_upgrade,
|
verify_password_with_upgrade,
|
||||||
create_session_token,
|
create_session_token,
|
||||||
verify_session_token,
|
verify_session_token,
|
||||||
@ -47,6 +48,12 @@ from app.config import settings as app_settings
|
|||||||
|
|
||||||
router = APIRouter()
|
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
|
# Cookie helper
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -68,11 +75,16 @@ def _set_session_cookie(response: Response, token: str) -> None:
|
|||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
response: Response,
|
||||||
session_cookie: Optional[str] = Cookie(None, alias="session"),
|
session_cookie: Optional[str] = Cookie(None, alias="session"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> User:
|
) -> User:
|
||||||
"""
|
"""
|
||||||
Dependency that verifies the session cookie and returns the authenticated 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:
|
if not session_cookie:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
@ -106,6 +118,17 @@ async def get_current_user(
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=401, detail="User not found or inactive")
|
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
|
return user
|
||||||
|
|
||||||
|
|
||||||
@ -287,12 +310,17 @@ async def login(
|
|||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not user:
|
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")
|
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)
|
valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash)
|
||||||
|
|
||||||
|
await _check_account_lockout(user)
|
||||||
|
|
||||||
if not valid:
|
if not valid:
|
||||||
await _record_failed_login(db, user)
|
await _record_failed_login(db, user)
|
||||||
await log_audit_event(
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from typing import List
|
from typing import List
|
||||||
@ -48,8 +48,8 @@ async def create_calendar(
|
|||||||
|
|
||||||
@router.put("/{calendar_id}", response_model=CalendarResponse)
|
@router.put("/{calendar_id}", response_model=CalendarResponse)
|
||||||
async def update_calendar(
|
async def update_calendar(
|
||||||
calendar_id: int,
|
calendar_id: int = Path(ge=1, le=2147483647),
|
||||||
calendar_update: CalendarUpdate,
|
calendar_update: CalendarUpdate = ...,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
@ -77,7 +77,7 @@ async def update_calendar(
|
|||||||
|
|
||||||
@router.delete("/{calendar_id}", status_code=204)
|
@router.delete("/{calendar_id}", status_code=204)
|
||||||
async def delete_calendar(
|
async def delete_calendar(
|
||||||
calendar_id: int,
|
calendar_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@ -43,8 +43,8 @@ async def create_template(
|
|||||||
|
|
||||||
@router.put("/{template_id}", response_model=EventTemplateResponse)
|
@router.put("/{template_id}", response_model=EventTemplateResponse)
|
||||||
async def update_template(
|
async def update_template(
|
||||||
template_id: int,
|
template_id: int = Path(ge=1, le=2147483647),
|
||||||
payload: EventTemplateUpdate,
|
payload: EventTemplateUpdate = ...,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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)
|
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_template(
|
async def delete_template(
|
||||||
template_id: int,
|
template_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import json
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, delete
|
from sqlalchemy import select, delete
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@ -272,7 +272,7 @@ async def create_event(
|
|||||||
|
|
||||||
@router.get("/{event_id}", response_model=CalendarEventResponse)
|
@router.get("/{event_id}", response_model=CalendarEventResponse)
|
||||||
async def get_event(
|
async def get_event(
|
||||||
event_id: int,
|
event_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@ -296,8 +296,8 @@ async def get_event(
|
|||||||
|
|
||||||
@router.put("/{event_id}", response_model=CalendarEventResponse)
|
@router.put("/{event_id}", response_model=CalendarEventResponse)
|
||||||
async def update_event(
|
async def update_event(
|
||||||
event_id: int,
|
event_id: int = Path(ge=1, le=2147483647),
|
||||||
event_update: CalendarEventUpdate,
|
event_update: CalendarEventUpdate = ...,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@ -421,7 +421,7 @@ async def update_event(
|
|||||||
|
|
||||||
@router.delete("/{event_id}", status_code=204)
|
@router.delete("/{event_id}", status_code=204)
|
||||||
async def delete_event(
|
async def delete_event(
|
||||||
event_id: int,
|
event_id: int = Path(ge=1, le=2147483647),
|
||||||
scope: Optional[Literal["this", "this_and_future"]] = Query(None),
|
scope: Optional[Literal["this", "this_and_future"]] = Query(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, or_
|
from sqlalchemy import select, or_
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@ -120,7 +120,7 @@ async def create_location(
|
|||||||
|
|
||||||
@router.get("/{location_id}", response_model=LocationResponse)
|
@router.get("/{location_id}", response_model=LocationResponse)
|
||||||
async def get_location(
|
async def get_location(
|
||||||
location_id: int,
|
location_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
@ -138,8 +138,8 @@ async def get_location(
|
|||||||
|
|
||||||
@router.put("/{location_id}", response_model=LocationResponse)
|
@router.put("/{location_id}", response_model=LocationResponse)
|
||||||
async def update_location(
|
async def update_location(
|
||||||
location_id: int,
|
location_id: int = Path(ge=1, le=2147483647),
|
||||||
location_update: LocationUpdate,
|
location_update: LocationUpdate = ...,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
@ -168,7 +168,7 @@ async def update_location(
|
|||||||
|
|
||||||
@router.delete("/{location_id}", status_code=204)
|
@router.delete("/{location_id}", status_code=204)
|
||||||
async def delete_location(
|
async def delete_location(
|
||||||
location_id: int,
|
location_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, or_
|
from sqlalchemy import select, or_
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@ -91,7 +91,7 @@ async def create_person(
|
|||||||
|
|
||||||
@router.get("/{person_id}", response_model=PersonResponse)
|
@router.get("/{person_id}", response_model=PersonResponse)
|
||||||
async def get_person(
|
async def get_person(
|
||||||
person_id: int,
|
person_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
@ -109,8 +109,8 @@ async def get_person(
|
|||||||
|
|
||||||
@router.put("/{person_id}", response_model=PersonResponse)
|
@router.put("/{person_id}", response_model=PersonResponse)
|
||||||
async def update_person(
|
async def update_person(
|
||||||
person_id: int,
|
person_id: int = Path(ge=1, le=2147483647),
|
||||||
person_update: PersonUpdate,
|
person_update: PersonUpdate = ...,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
@ -146,7 +146,7 @@ async def update_person(
|
|||||||
|
|
||||||
@router.delete("/{person_id}", status_code=204)
|
@router.delete("/{person_id}", status_code=204)
|
||||||
async def delete_person(
|
async def delete_person(
|
||||||
person_id: int,
|
person_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@ -128,7 +128,7 @@ async def create_project(
|
|||||||
|
|
||||||
@router.get("/{project_id}", response_model=ProjectResponse)
|
@router.get("/{project_id}", response_model=ProjectResponse)
|
||||||
async def get_project(
|
async def get_project(
|
||||||
project_id: int,
|
project_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
@ -149,8 +149,8 @@ async def get_project(
|
|||||||
|
|
||||||
@router.put("/{project_id}", response_model=ProjectResponse)
|
@router.put("/{project_id}", response_model=ProjectResponse)
|
||||||
async def update_project(
|
async def update_project(
|
||||||
project_id: int,
|
project_id: int = Path(ge=1, le=2147483647),
|
||||||
project_update: ProjectUpdate,
|
project_update: ProjectUpdate = ...,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
@ -178,7 +178,7 @@ async def update_project(
|
|||||||
|
|
||||||
@router.delete("/{project_id}", status_code=204)
|
@router.delete("/{project_id}", status_code=204)
|
||||||
async def delete_project(
|
async def delete_project(
|
||||||
project_id: int,
|
project_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
@ -199,7 +199,7 @@ async def delete_project(
|
|||||||
|
|
||||||
@router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse])
|
@router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse])
|
||||||
async def get_project_tasks(
|
async def get_project_tasks(
|
||||||
project_id: int,
|
project_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
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)
|
@router.post("/{project_id}/tasks", response_model=ProjectTaskResponse, status_code=201)
|
||||||
async def create_project_task(
|
async def create_project_task(
|
||||||
project_id: int,
|
project_id: int = Path(ge=1, le=2147483647),
|
||||||
task: ProjectTaskCreate,
|
task: ProjectTaskCreate = ...,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
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)
|
@router.put("/{project_id}/tasks/reorder", status_code=200)
|
||||||
async def reorder_tasks(
|
async def reorder_tasks(
|
||||||
project_id: int,
|
project_id: int = Path(ge=1, le=2147483647),
|
||||||
items: List[ReorderItem],
|
items: List[ReorderItem] = ...,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
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)
|
@router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse)
|
||||||
async def update_project_task(
|
async def update_project_task(
|
||||||
project_id: int,
|
project_id: int = Path(ge=1, le=2147483647),
|
||||||
task_id: int,
|
task_id: int = Path(ge=1, le=2147483647),
|
||||||
task_update: ProjectTaskUpdate,
|
task_update: ProjectTaskUpdate = ...,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
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)
|
@router.delete("/{project_id}/tasks/{task_id}", status_code=204)
|
||||||
async def delete_project_task(
|
async def delete_project_task(
|
||||||
project_id: int,
|
project_id: int = Path(ge=1, le=2147483647),
|
||||||
task_id: int,
|
task_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
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)
|
@router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201)
|
||||||
async def create_task_comment(
|
async def create_task_comment(
|
||||||
project_id: int,
|
project_id: int = Path(ge=1, le=2147483647),
|
||||||
task_id: int,
|
task_id: int = Path(ge=1, le=2147483647),
|
||||||
comment: TaskCommentCreate,
|
comment: TaskCommentCreate = ...,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
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)
|
@router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204)
|
||||||
async def delete_task_comment(
|
async def delete_task_comment(
|
||||||
project_id: int,
|
project_id: int = Path(ge=1, le=2147483647),
|
||||||
task_id: int,
|
task_id: int = Path(ge=1, le=2147483647),
|
||||||
comment_id: int,
|
comment_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime, timedelta
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, and_, or_
|
from sqlalchemy import select, and_, or_
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
@ -69,8 +69,8 @@ async def get_due_reminders(
|
|||||||
|
|
||||||
@router.patch("/{reminder_id}/snooze", response_model=ReminderResponse)
|
@router.patch("/{reminder_id}/snooze", response_model=ReminderResponse)
|
||||||
async def snooze_reminder(
|
async def snooze_reminder(
|
||||||
reminder_id: int,
|
reminder_id: int = Path(ge=1, le=2147483647),
|
||||||
body: ReminderSnooze,
|
body: ReminderSnooze = ...,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
@ -115,7 +115,7 @@ async def create_reminder(
|
|||||||
|
|
||||||
@router.get("/{reminder_id}", response_model=ReminderResponse)
|
@router.get("/{reminder_id}", response_model=ReminderResponse)
|
||||||
async def get_reminder(
|
async def get_reminder(
|
||||||
reminder_id: int,
|
reminder_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
@ -136,8 +136,8 @@ async def get_reminder(
|
|||||||
|
|
||||||
@router.put("/{reminder_id}", response_model=ReminderResponse)
|
@router.put("/{reminder_id}", response_model=ReminderResponse)
|
||||||
async def update_reminder(
|
async def update_reminder(
|
||||||
reminder_id: int,
|
reminder_id: int = Path(ge=1, le=2147483647),
|
||||||
reminder_update: ReminderUpdate,
|
reminder_update: ReminderUpdate = ...,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
@ -175,7 +175,7 @@ async def update_reminder(
|
|||||||
|
|
||||||
@router.delete("/{reminder_id}", status_code=204)
|
@router.delete("/{reminder_id}", status_code=204)
|
||||||
async def delete_reminder(
|
async def delete_reminder(
|
||||||
reminder_id: int,
|
reminder_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
@ -199,7 +199,7 @@ async def delete_reminder(
|
|||||||
|
|
||||||
@router.patch("/{reminder_id}/dismiss", response_model=ReminderResponse)
|
@router.patch("/{reminder_id}/dismiss", response_model=ReminderResponse)
|
||||||
async def dismiss_reminder(
|
async def dismiss_reminder(
|
||||||
reminder_id: int,
|
reminder_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, and_, func
|
from sqlalchemy import select, and_, func
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
@ -174,7 +174,7 @@ async def create_todo(
|
|||||||
|
|
||||||
@router.get("/{todo_id}", response_model=TodoResponse)
|
@router.get("/{todo_id}", response_model=TodoResponse)
|
||||||
async def get_todo(
|
async def get_todo(
|
||||||
todo_id: int,
|
todo_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@ -192,8 +192,8 @@ async def get_todo(
|
|||||||
|
|
||||||
@router.put("/{todo_id}", response_model=TodoResponse)
|
@router.put("/{todo_id}", response_model=TodoResponse)
|
||||||
async def update_todo(
|
async def update_todo(
|
||||||
todo_id: int,
|
todo_id: int = Path(ge=1, le=2147483647),
|
||||||
todo_update: TodoUpdate,
|
todo_update: TodoUpdate = ...,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
current_settings: Settings = Depends(get_current_settings),
|
current_settings: Settings = Depends(get_current_settings),
|
||||||
@ -249,7 +249,7 @@ async def update_todo(
|
|||||||
|
|
||||||
@router.delete("/{todo_id}", status_code=204)
|
@router.delete("/{todo_id}", status_code=204)
|
||||||
async def delete_todo(
|
async def delete_todo(
|
||||||
todo_id: int,
|
todo_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@ -270,7 +270,7 @@ async def delete_todo(
|
|||||||
|
|
||||||
@router.patch("/{todo_id}/toggle", response_model=TodoResponse)
|
@router.patch("/{todo_id}/toggle", response_model=TodoResponse)
|
||||||
async def toggle_todo(
|
async def toggle_todo(
|
||||||
todo_id: int,
|
todo_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
current_settings: Settings = Depends(get_current_settings),
|
current_settings: Settings = Depends(get_current_settings),
|
||||||
|
|||||||
@ -24,7 +24,7 @@ from datetime import datetime, timedelta
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, delete
|
from sqlalchemy import select, delete
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@ -76,32 +76,38 @@ _ph = PasswordHasher(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TOTPConfirmRequest(BaseModel):
|
class TOTPConfirmRequest(BaseModel):
|
||||||
code: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
code: str = Field(min_length=6, max_length=6)
|
||||||
|
|
||||||
|
|
||||||
class TOTPVerifyRequest(BaseModel):
|
class TOTPVerifyRequest(BaseModel):
|
||||||
mfa_token: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
code: Optional[str] = None # 6-digit TOTP code
|
mfa_token: str = Field(max_length=256)
|
||||||
backup_code: Optional[str] = None # Alternative: XXXX-XXXX backup code
|
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):
|
class TOTPDisableRequest(BaseModel):
|
||||||
password: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
code: str # Current TOTP code required to disable
|
password: str = Field(max_length=128)
|
||||||
|
code: str = Field(min_length=6, max_length=6) # Current TOTP code required to disable
|
||||||
|
|
||||||
|
|
||||||
class BackupCodesRegenerateRequest(BaseModel):
|
class BackupCodesRegenerateRequest(BaseModel):
|
||||||
password: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
code: str # Current TOTP code required to regenerate
|
password: str = Field(max_length=128)
|
||||||
|
code: str = Field(min_length=6, max_length=6) # Current TOTP code required to regenerate
|
||||||
|
|
||||||
|
|
||||||
class EnforceSetupRequest(BaseModel):
|
class EnforceSetupRequest(BaseModel):
|
||||||
mfa_token: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
mfa_token: str = Field(max_length=256)
|
||||||
|
|
||||||
|
|
||||||
class EnforceConfirmRequest(BaseModel):
|
class EnforceConfirmRequest(BaseModel):
|
||||||
mfa_token: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
code: str # 6-digit TOTP code from authenticator app
|
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):
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
old_password: str
|
old_password: str
|
||||||
new_password: str
|
new_password: str
|
||||||
|
|
||||||
@ -94,6 +96,8 @@ class ChangePasswordRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class VerifyPasswordRequest(BaseModel):
|
class VerifyPasswordRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
@field_validator("password")
|
@field_validator("password")
|
||||||
|
|||||||
@ -1,16 +1,20 @@
|
|||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
class CalendarCreate(BaseModel):
|
class CalendarCreate(BaseModel):
|
||||||
name: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
color: str = "#3b82f6"
|
|
||||||
|
name: str = Field(min_length=1, max_length=100)
|
||||||
|
color: str = Field("#3b82f6", max_length=20)
|
||||||
|
|
||||||
|
|
||||||
class CalendarUpdate(BaseModel):
|
class CalendarUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
model_config = ConfigDict(extra="forbid")
|
||||||
color: Optional[str] = None
|
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
|
color: Optional[str] = Field(None, max_length=20)
|
||||||
is_visible: Optional[bool] = None
|
is_visible: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -39,12 +39,14 @@ def _coerce_recurrence_rule(v):
|
|||||||
|
|
||||||
|
|
||||||
class CalendarEventCreate(BaseModel):
|
class CalendarEventCreate(BaseModel):
|
||||||
title: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
description: Optional[str] = None
|
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = Field(None, max_length=5000)
|
||||||
start_datetime: datetime
|
start_datetime: datetime
|
||||||
end_datetime: datetime
|
end_datetime: datetime
|
||||||
all_day: bool = False
|
all_day: bool = False
|
||||||
color: Optional[str] = None
|
color: Optional[str] = Field(None, max_length=20)
|
||||||
location_id: Optional[int] = None
|
location_id: Optional[int] = None
|
||||||
recurrence_rule: Optional[RecurrenceRule] = None
|
recurrence_rule: Optional[RecurrenceRule] = None
|
||||||
is_starred: bool = False
|
is_starred: bool = False
|
||||||
@ -57,12 +59,14 @@ class CalendarEventCreate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CalendarEventUpdate(BaseModel):
|
class CalendarEventUpdate(BaseModel):
|
||||||
title: Optional[str] = None
|
model_config = ConfigDict(extra="forbid")
|
||||||
description: Optional[str] = None
|
|
||||||
|
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = Field(None, max_length=5000)
|
||||||
start_datetime: Optional[datetime] = None
|
start_datetime: Optional[datetime] = None
|
||||||
end_datetime: Optional[datetime] = None
|
end_datetime: Optional[datetime] = None
|
||||||
all_day: Optional[bool] = None
|
all_day: Optional[bool] = None
|
||||||
color: Optional[str] = None
|
color: Optional[str] = Field(None, max_length=20)
|
||||||
location_id: Optional[int] = None
|
location_id: Optional[int] = None
|
||||||
recurrence_rule: Optional[RecurrenceRule] = None
|
recurrence_rule: Optional[RecurrenceRule] = None
|
||||||
is_starred: Optional[bool] = 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 datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
class EventTemplateCreate(BaseModel):
|
class EventTemplateCreate(BaseModel):
|
||||||
name: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
title: str
|
|
||||||
description: Optional[str] = None
|
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
|
calendar_id: Optional[int] = None
|
||||||
recurrence_rule: Optional[str] = None
|
recurrence_rule: Optional[str] = Field(None, max_length=5000)
|
||||||
all_day: bool = False
|
all_day: bool = False
|
||||||
location_id: Optional[int] = None
|
location_id: Optional[int] = None
|
||||||
is_starred: bool = False
|
is_starred: bool = False
|
||||||
|
|
||||||
|
|
||||||
class EventTemplateUpdate(BaseModel):
|
class EventTemplateUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
model_config = ConfigDict(extra="forbid")
|
||||||
title: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
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
|
calendar_id: Optional[int] = None
|
||||||
recurrence_rule: Optional[str] = None
|
recurrence_rule: Optional[str] = Field(None, max_length=5000)
|
||||||
all_day: Optional[bool] = None
|
all_day: Optional[bool] = None
|
||||||
location_id: Optional[int] = None
|
location_id: Optional[int] = None
|
||||||
is_starred: Optional[bool] = None
|
is_starred: Optional[bool] = None
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import re
|
import re
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Literal
|
from typing import Optional, Literal
|
||||||
|
|
||||||
@ -14,13 +14,15 @@ class LocationSearchResult(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class LocationCreate(BaseModel):
|
class LocationCreate(BaseModel):
|
||||||
name: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
address: str
|
|
||||||
category: str = "other"
|
name: str = Field(min_length=1, max_length=255)
|
||||||
notes: Optional[str] = None
|
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
|
is_frequent: bool = False
|
||||||
contact_number: Optional[str] = None
|
contact_number: Optional[str] = Field(None, max_length=50)
|
||||||
email: Optional[str] = None
|
email: Optional[str] = Field(None, max_length=255)
|
||||||
|
|
||||||
@field_validator('email')
|
@field_validator('email')
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -31,13 +33,15 @@ class LocationCreate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class LocationUpdate(BaseModel):
|
class LocationUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
model_config = ConfigDict(extra="forbid")
|
||||||
address: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
notes: Optional[str] = None
|
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
|
is_frequent: Optional[bool] = None
|
||||||
contact_number: Optional[str] = None
|
contact_number: Optional[str] = Field(None, max_length=50)
|
||||||
email: Optional[str] = None
|
email: Optional[str] = Field(None, max_length=255)
|
||||||
|
|
||||||
@field_validator('email')
|
@field_validator('email')
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import re
|
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 datetime import datetime, date
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@ -7,20 +7,22 @@ _EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
|
|||||||
|
|
||||||
|
|
||||||
class PersonCreate(BaseModel):
|
class PersonCreate(BaseModel):
|
||||||
name: Optional[str] = None # legacy fallback — auto-split into first/last if provided alone
|
model_config = ConfigDict(extra="forbid")
|
||||||
first_name: Optional[str] = None
|
|
||||||
last_name: Optional[str] = None
|
name: Optional[str] = Field(None, max_length=255) # legacy fallback — auto-split into first/last if provided alone
|
||||||
nickname: Optional[str] = None
|
first_name: Optional[str] = Field(None, max_length=100)
|
||||||
email: Optional[str] = None
|
last_name: Optional[str] = Field(None, max_length=100)
|
||||||
phone: Optional[str] = None
|
nickname: Optional[str] = Field(None, max_length=100)
|
||||||
mobile: Optional[str] = None
|
email: Optional[str] = Field(None, max_length=255)
|
||||||
address: Optional[str] = None
|
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
|
birthday: Optional[date] = None
|
||||||
category: Optional[str] = None
|
category: Optional[str] = Field(None, max_length=100)
|
||||||
is_favourite: bool = False
|
is_favourite: bool = False
|
||||||
company: Optional[str] = None
|
company: Optional[str] = Field(None, max_length=255)
|
||||||
job_title: Optional[str] = None
|
job_title: Optional[str] = Field(None, max_length=255)
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = Field(None, max_length=5000)
|
||||||
|
|
||||||
@model_validator(mode='after')
|
@model_validator(mode='after')
|
||||||
def require_some_name(self) -> 'PersonCreate':
|
def require_some_name(self) -> 'PersonCreate':
|
||||||
@ -42,20 +44,22 @@ class PersonCreate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class PersonUpdate(BaseModel):
|
class PersonUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
# name is intentionally omitted — always computed from first/last/nickname
|
# name is intentionally omitted — always computed from first/last/nickname
|
||||||
first_name: Optional[str] = None
|
first_name: Optional[str] = Field(None, max_length=100)
|
||||||
last_name: Optional[str] = None
|
last_name: Optional[str] = Field(None, max_length=100)
|
||||||
nickname: Optional[str] = None
|
nickname: Optional[str] = Field(None, max_length=100)
|
||||||
email: Optional[str] = None
|
email: Optional[str] = Field(None, max_length=255)
|
||||||
phone: Optional[str] = None
|
phone: Optional[str] = Field(None, max_length=50)
|
||||||
mobile: Optional[str] = None
|
mobile: Optional[str] = Field(None, max_length=50)
|
||||||
address: Optional[str] = None
|
address: Optional[str] = Field(None, max_length=2000)
|
||||||
birthday: Optional[date] = None
|
birthday: Optional[date] = None
|
||||||
category: Optional[str] = None
|
category: Optional[str] = Field(None, max_length=100)
|
||||||
is_favourite: Optional[bool] = None
|
is_favourite: Optional[bool] = None
|
||||||
company: Optional[str] = None
|
company: Optional[str] = Field(None, max_length=255)
|
||||||
job_title: Optional[str] = None
|
job_title: Optional[str] = Field(None, max_length=255)
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = Field(None, max_length=5000)
|
||||||
|
|
||||||
@field_validator('email')
|
@field_validator('email')
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from typing import Optional, List, Literal
|
from typing import Optional, List, Literal
|
||||||
from app.schemas.project_task import ProjectTaskResponse
|
from app.schemas.project_task import ProjectTaskResponse
|
||||||
@ -7,19 +7,23 @@ ProjectStatus = Literal["not_started", "in_progress", "completed", "blocked", "r
|
|||||||
|
|
||||||
|
|
||||||
class ProjectCreate(BaseModel):
|
class ProjectCreate(BaseModel):
|
||||||
name: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
description: Optional[str] = None
|
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = Field(None, max_length=5000)
|
||||||
status: ProjectStatus = "not_started"
|
status: ProjectStatus = "not_started"
|
||||||
color: Optional[str] = None
|
color: Optional[str] = Field(None, max_length=20)
|
||||||
due_date: Optional[date] = None
|
due_date: Optional[date] = None
|
||||||
is_tracked: bool = False
|
is_tracked: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(BaseModel):
|
class ProjectUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
model_config = ConfigDict(extra="forbid")
|
||||||
description: Optional[str] = None
|
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = Field(None, max_length=5000)
|
||||||
status: Optional[ProjectStatus] = None
|
status: Optional[ProjectStatus] = None
|
||||||
color: Optional[str] = None
|
color: Optional[str] = Field(None, max_length=20)
|
||||||
due_date: Optional[date] = None
|
due_date: Optional[date] = None
|
||||||
is_tracked: Optional[bool] = 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 datetime import datetime, date
|
||||||
from typing import Optional, List, Literal
|
from typing import Optional, List, Literal
|
||||||
from app.schemas.task_comment import TaskCommentResponse
|
from app.schemas.task_comment import TaskCommentResponse
|
||||||
@ -8,8 +8,10 @@ TaskPriority = Literal["none", "low", "medium", "high"]
|
|||||||
|
|
||||||
|
|
||||||
class ProjectTaskCreate(BaseModel):
|
class ProjectTaskCreate(BaseModel):
|
||||||
title: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
description: Optional[str] = None
|
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = Field(None, max_length=5000)
|
||||||
status: TaskStatus = "pending"
|
status: TaskStatus = "pending"
|
||||||
priority: TaskPriority = "medium"
|
priority: TaskPriority = "medium"
|
||||||
due_date: Optional[date] = None
|
due_date: Optional[date] = None
|
||||||
@ -19,8 +21,10 @@ class ProjectTaskCreate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ProjectTaskUpdate(BaseModel):
|
class ProjectTaskUpdate(BaseModel):
|
||||||
title: Optional[str] = None
|
model_config = ConfigDict(extra="forbid")
|
||||||
description: Optional[str] = None
|
|
||||||
|
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = Field(None, max_length=5000)
|
||||||
status: Optional[TaskStatus] = None
|
status: Optional[TaskStatus] = None
|
||||||
priority: Optional[TaskPriority] = None
|
priority: Optional[TaskPriority] = None
|
||||||
due_date: Optional[date] = 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 datetime import datetime
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
|
||||||
class ReminderCreate(BaseModel):
|
class ReminderCreate(BaseModel):
|
||||||
title: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
description: Optional[str] = None
|
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = Field(None, max_length=5000)
|
||||||
remind_at: Optional[datetime] = None
|
remind_at: Optional[datetime] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
recurrence_rule: Optional[Literal['daily', 'weekly', 'monthly']] = None
|
recurrence_rule: Optional[Literal['daily', 'weekly', 'monthly']] = None
|
||||||
|
|
||||||
|
|
||||||
class ReminderUpdate(BaseModel):
|
class ReminderUpdate(BaseModel):
|
||||||
title: Optional[str] = None
|
model_config = ConfigDict(extra="forbid")
|
||||||
description: Optional[str] = None
|
|
||||||
|
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = Field(None, max_length=5000)
|
||||||
remind_at: Optional[datetime] = None
|
remind_at: Optional[datetime] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
is_dismissed: Optional[bool] = None
|
is_dismissed: Optional[bool] = None
|
||||||
@ -21,6 +25,8 @@ class ReminderUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ReminderSnooze(BaseModel):
|
class ReminderSnooze(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
minutes: Literal[5, 10, 15]
|
minutes: Literal[5, 10, 15]
|
||||||
client_now: Optional[datetime] = None
|
client_now: Optional[datetime] = None
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import re
|
import re
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
@ -9,17 +9,19 @@ _NTFY_TOPIC_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
|||||||
|
|
||||||
|
|
||||||
class SettingsUpdate(BaseModel):
|
class SettingsUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
accent_color: Optional[AccentColor] = None
|
accent_color: Optional[AccentColor] = None
|
||||||
upcoming_days: int | None = None
|
upcoming_days: int | None = None
|
||||||
preferred_name: str | None = None
|
preferred_name: str | None = Field(None, max_length=100)
|
||||||
weather_city: str | None = None
|
weather_city: str | None = Field(None, max_length=100)
|
||||||
weather_lat: float | None = None
|
weather_lat: float | None = None
|
||||||
weather_lon: float | None = None
|
weather_lon: float | None = None
|
||||||
first_day_of_week: int | None = None
|
first_day_of_week: int | None = None
|
||||||
|
|
||||||
# ntfy configuration fields
|
# ntfy configuration fields
|
||||||
ntfy_server_url: Optional[str] = None
|
ntfy_server_url: Optional[str] = Field(None, max_length=500)
|
||||||
ntfy_topic: Optional[str] = None
|
ntfy_topic: Optional[str] = Field(None, max_length=100)
|
||||||
# Empty string means "clear the token"; None means "leave unchanged"
|
# Empty string means "clear the token"; None means "leave unchanged"
|
||||||
ntfy_auth_token: Optional[str] = None
|
ntfy_auth_token: Optional[str] = None
|
||||||
ntfy_enabled: Optional[bool] = 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
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
class TaskCommentCreate(BaseModel):
|
class TaskCommentCreate(BaseModel):
|
||||||
content: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
content: str = Field(min_length=1, max_length=10000)
|
||||||
|
|
||||||
|
|
||||||
class TaskCommentResponse(BaseModel):
|
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 datetime import datetime, date, time
|
||||||
from typing import Optional, Literal
|
from typing import Optional, Literal
|
||||||
|
|
||||||
@ -7,24 +7,28 @@ RecurrenceRule = Literal["daily", "weekly", "monthly"]
|
|||||||
|
|
||||||
|
|
||||||
class TodoCreate(BaseModel):
|
class TodoCreate(BaseModel):
|
||||||
title: str
|
model_config = ConfigDict(extra="forbid")
|
||||||
description: Optional[str] = None
|
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = Field(None, max_length=5000)
|
||||||
priority: TodoPriority = "medium"
|
priority: TodoPriority = "medium"
|
||||||
due_date: Optional[date] = None
|
due_date: Optional[date] = None
|
||||||
due_time: Optional[time] = None
|
due_time: Optional[time] = None
|
||||||
category: Optional[str] = None
|
category: Optional[str] = Field(None, max_length=100)
|
||||||
recurrence_rule: Optional[RecurrenceRule] = None
|
recurrence_rule: Optional[RecurrenceRule] = None
|
||||||
project_id: Optional[int] = None
|
project_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class TodoUpdate(BaseModel):
|
class TodoUpdate(BaseModel):
|
||||||
title: Optional[str] = None
|
model_config = ConfigDict(extra="forbid")
|
||||||
description: Optional[str] = None
|
|
||||||
|
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = Field(None, max_length=5000)
|
||||||
priority: Optional[TodoPriority] = None
|
priority: Optional[TodoPriority] = None
|
||||||
due_date: Optional[date] = None
|
due_date: Optional[date] = None
|
||||||
due_time: Optional[time] = None
|
due_time: Optional[time] = None
|
||||||
completed: Optional[bool] = None
|
completed: Optional[bool] = None
|
||||||
category: Optional[str] = None
|
category: Optional[str] = Field(None, max_length=100)
|
||||||
recurrence_rule: Optional[RecurrenceRule] = None
|
recurrence_rule: Optional[RecurrenceRule] = None
|
||||||
project_id: Optional[int] = 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:
|
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.
|
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:
|
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:
|
try:
|
||||||
return _serializer.loads(token, max_age=max_age)
|
return _serializer.loads(token, max_age=max_age)
|
||||||
except (BadSignature, SignatureExpired):
|
except (BadSignature, SignatureExpired):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user