Files
Calendarr/backend/routers/users_router.py
T
ScarriffleandClaude Opus 5 22a469d449 feat(anhaenge): Datenmodell und Ablage fuer Termin-Anhaenge
Neue Tabelle event_attachments samt Ablage-Modul. Dateien liegen unter
DATA_DIR/attachments, geshardet nach einem servergenerierten Namen -- weder
der hochgeladene Dateiname noch die Termin-UID beruehren je einen Pfad, da
die UID bei CalDAV-Terminen aus einem fremden Client stammt.

Der Dateityp wird an den Bytes bestimmt, nie am Content-Type des Clients.
SVG und HTML sind bewusst nicht erlaubt: beide fuehren Skript im Origin der
App aus, wo das Sitzungstoken liegt.

Aufgeraeumt wird an allen fuenf Loeschpfaden (Termin, Kalender, Gruppe,
Benutzer, CalDAV-DELETE), und zwar Zeilen vor dem Commit, Dateien erst
danach -- ein Rollback darf keine Bytes verlieren. Der Sweep als
Sicherheitsnetz ist implementiert, haengt aber noch an keinem Scheduler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:04:44 +02:00

157 lines
4.6 KiB
Python

from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session
import attachments_store
import models
from auth import get_current_admin, get_current_user, get_password_hash
from database import get_db
router = APIRouter()
class CreateUserRequest(BaseModel):
username: str
password: str
email: Optional[str] = None
is_admin: bool = False
class ChangePasswordRequest(BaseModel):
password: str
class SetAdminRequest(BaseModel):
is_admin: bool
def _user_dict(u: models.User) -> dict:
return {
"id": u.id,
"username": u.username,
"display_name": u.display_name or u.username,
"email": u.email,
"is_admin": u.is_admin,
}
@router.get("/")
def list_users(
db: Session = Depends(get_db),
_: models.User = Depends(get_current_admin),
):
return [_user_dict(u) for u in db.query(models.User).all()]
@router.get("/directory")
def user_directory(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Lightweight list of all users (id + display_name) for sharing/group pickers.
Available to any authenticated user (unlike GET / which is admin-only).
Excludes the requesting user.
"""
users = (
db.query(models.User)
.filter(
models.User.id != current_user.id,
models.User.directory_hidden == False, # noqa: E712 — hidden users opt out of pickers
)
.order_by(models.User.username)
.all()
)
return [{"id": u.id, "display_name": u.display_name or u.username} for u in users]
@router.post("/")
def create_user(
req: CreateUserRequest,
db: Session = Depends(get_db),
_: models.User = Depends(get_current_admin),
):
if db.query(models.User).filter(func.lower(models.User.username) == req.username.lower()).first():
raise HTTPException(400, "Username already taken")
user = models.User(
username=req.username.lower(),
display_name=req.username.strip(), # keep the original casing for display
email=req.email,
password_hash=get_password_hash(req.password),
is_admin=req.is_admin,
)
db.add(user)
db.flush()
db.add(models.UserSettings(user_id=user.id))
db.commit()
db.refresh(user)
return _user_dict(user)
@router.delete("/{user_id}")
def delete_user(
user_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_admin),
):
if user_id == current_user.id:
raise HTTPException(400, "Cannot delete yourself")
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user:
raise HTTPException(404, "User not found")
# user -> calendars -> events -> attachments: the longest cascade chain, and
# the one where orphaned files would pile up unnoticed.
cal_ids = [
r[0]
for r in db.query(models.LocalCalendar.id)
.filter(models.LocalCalendar.user_id == user_id)
.all()
]
stale = attachments_store.purge_for_calendars(db, cal_ids)
db.delete(user)
db.commit()
attachments_store.unlink_all(stale)
return {"ok": True}
@router.put("/{user_id}/admin")
def set_admin(
user_id: int,
req: SetAdminRequest,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_admin),
):
if user_id == current_user.id:
raise HTTPException(400, "Cannot change your own admin status")
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user:
raise HTTPException(404, "User not found")
# Never leave the instance without an admin.
if user.is_admin and not req.is_admin:
admin_count = db.query(models.User).filter(models.User.is_admin == True).count() # noqa: E712
if admin_count <= 1:
raise HTTPException(400, "At least one admin must remain")
user.is_admin = req.is_admin
db.commit()
return {"ok": True}
@router.put("/{user_id}/password")
def change_password(
user_id: int,
req: ChangePasswordRequest,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
if not current_user.is_admin and current_user.id != user_id:
raise HTTPException(403, "Not authorized")
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user:
raise HTTPException(404, "User not found")
user.password_hash = get_password_hash(req.password)
db.commit()
return {"ok": True}