This commit is contained in:
Andras Schmelczer 2026-05-28 21:24:47 +01:00
parent 3ad2766f82
commit f74ee43cb4
196 changed files with 18949 additions and 32173 deletions

View file

@ -0,0 +1,243 @@
"""APIRouter with all Life Towers endpoints."""
from __future__ import annotations
import json
import time
from typing import Annotated
import structlog
from fastapi import APIRouter, Depends, HTTPException, Request
from .auth import get_current_user
from .db import db_connection
from .limits import limiter
from .models import (
BlockOut,
DataIn,
DataOut,
HealthResponse,
HslColor,
PageOut,
RegisterRequest,
RegisterResponse,
TowerOut,
)
router = APIRouter(prefix="/api/v1")
logger = structlog.get_logger(__name__)
@router.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
return HealthResponse(status="ok")
@router.post("/register", response_model=RegisterResponse)
@limiter.limit("30/hour", key_func=lambda request: request.client.host if request.client else "unknown")
async def register(request: Request, body: RegisterRequest) -> RegisterResponse:
token = body.token
now = int(time.time())
with db_connection() as conn:
existing = conn.execute(
"SELECT id FROM users WHERE id = ?", (token,)
).fetchone()
if existing is None:
conn.execute(
"INSERT INTO users (id, created_at, last_seen_at) VALUES (?, ?, ?)",
(token, now, now),
)
else:
conn.execute(
"UPDATE users SET last_seen_at = ? WHERE id = ?",
(now, token),
)
conn.commit()
logger.info("user_registered", user_id=token, new=existing is None)
return RegisterResponse(user_id=token)
@router.get("/data", response_model=DataOut)
@limiter.limit("60/minute")
async def get_data(
request: Request,
user_id: Annotated[str, Depends(get_current_user)],
) -> DataOut:
with db_connection() as conn:
pages_rows = conn.execute(
"""
SELECT id, name, hide_create_tower_button, keep_tasks_open, default_date_from, default_date_to
FROM pages
WHERE user_id = ?
ORDER BY position
""",
(user_id,),
).fetchall()
pages_out: list[PageOut] = []
for page_row in pages_rows:
page_id = page_row["id"]
tower_rows = conn.execute(
"""
SELECT id, name, base_color_h, base_color_s, base_color_l
FROM towers
WHERE page_id = ?
ORDER BY position
""",
(page_id,),
).fetchall()
towers_out: list[TowerOut] = []
for tower_row in tower_rows:
tower_id = tower_row["id"]
block_rows = conn.execute(
"""
SELECT id, tag, description, is_done, created_at
FROM blocks
WHERE tower_id = ?
ORDER BY position
""",
(tower_id,),
).fetchall()
blocks_out = [
BlockOut(
id=b["id"],
tag=b["tag"],
description=b["description"],
is_done=bool(b["is_done"]),
created_at=b["created_at"],
)
for b in block_rows
]
towers_out.append(
TowerOut(
id=tower_row["id"],
name=tower_row["name"],
base_color=HslColor(
h=tower_row["base_color_h"],
s=tower_row["base_color_s"],
l=tower_row["base_color_l"],
),
blocks=blocks_out,
)
)
pages_out.append(
PageOut(
id=page_row["id"],
name=page_row["name"],
hide_create_tower_button=bool(
page_row["hide_create_tower_button"]
),
keep_tasks_open=bool(page_row["keep_tasks_open"]),
default_date_from=page_row["default_date_from"],
default_date_to=page_row["default_date_to"],
towers=towers_out,
)
)
return DataOut(pages=pages_out)
@router.put("/data", status_code=204)
@limiter.limit("30/minute")
async def put_data(
request: Request,
body: DataIn,
user_id: Annotated[str, Depends(get_current_user)],
) -> None:
# Tree-replace semantics mean the request size IS the user's total storage,
# so the 2 MiB request cap (enforced by payload_size_middleware) is the only
# quota we need.
now = int(time.time())
with db_connection() as conn:
conn.execute("BEGIN IMMEDIATE")
try:
# Delete existing data for this user (cascades to towers + blocks)
conn.execute("DELETE FROM pages WHERE user_id = ?", (user_id,))
for page_pos, page in enumerate(body.pages):
conn.execute(
"""
INSERT INTO pages
(id, user_id, position, name, hide_create_tower_button,
keep_tasks_open, default_date_from, default_date_to,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
page.id,
user_id,
page_pos,
page.name,
1 if page.hide_create_tower_button else 0,
1 if page.keep_tasks_open else 0,
page.default_date_from,
page.default_date_to,
now,
now,
),
)
for tower_pos, tower in enumerate(page.towers):
conn.execute(
"""
INSERT INTO towers
(id, page_id, user_id, position, name,
base_color_h, base_color_s, base_color_l,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
tower.id,
page.id,
user_id,
tower_pos,
tower.name,
tower.base_color.h,
tower.base_color.s,
tower.base_color.l,
now,
now,
),
)
for block_pos, block in enumerate(tower.blocks):
created_at = block.created_at if block.created_at is not None else now
conn.execute(
"""
INSERT INTO blocks
(id, tower_id, user_id, position, tag,
description, is_done, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
block.id,
tower.id,
user_id,
block_pos,
block.tag,
block.description,
1 if block.is_done else 0,
created_at,
now,
),
)
# Update last_seen_at
conn.execute(
"UPDATE users SET last_seen_at = ? WHERE id = ?",
(now, user_id),
)
conn.commit()
except Exception:
conn.rollback()
raise
logger.info("data_replaced", user_id=user_id, pages=len(body.pages))