snapshot
This commit is contained in:
parent
3ad2766f82
commit
f74ee43cb4
196 changed files with 18949 additions and 32173 deletions
243
backend/src/life_towers/api.py
Normal file
243
backend/src/life_towers/api.py
Normal 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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue