initialer commit, Grundcode

This commit is contained in:
2026-03-26 11:20:48 +01:00
commit f029ed1544
25 changed files with 3530 additions and 0 deletions

0
backend/__init__.py Normal file
View File

66
backend/auth.py Normal file
View File

@@ -0,0 +1,66 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
import os
import models
from database import get_db
SECRET_KEY = os.environ.get(
"SECRET_KEY", "insecure-default-key-change-in-production-use-env-var"
)
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + (
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode["exp"] = expire
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def get_current_user(
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
) -> models.User:
exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if not username:
raise exc
except JWTError:
raise exc
user = db.query(models.User).filter(models.User.username == username).first()
if not user:
raise exc
return user
def get_current_admin(
current_user: models.User = Depends(get_current_user),
) -> models.User:
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
return current_user

267
backend/caldav_client.py Normal file
View File

@@ -0,0 +1,267 @@
import logging
import uuid
from datetime import date, datetime, timedelta, timezone
from typing import Dict, List, Optional
import caldav
from icalendar import Calendar, Event
logger = logging.getLogger(__name__)
PALETTE = ["#4285f4", "#ea4335", "#fbbc04", "#34a853", "#ff6d00", "#46bdc6", "#8e24aa"]
def _client(url: str, username: str, password: str) -> caldav.DAVClient:
return caldav.DAVClient(url=url, username=username, password=password)
def fetch_calendars(url: str, username: str, password: str) -> List[Dict]:
"""Return list of {url, name, color} dicts for all calendars in the account."""
try:
client = _client(url, username, password)
principal = client.principal()
calendars = principal.calendars()
except Exception as e:
raise ValueError(f"Cannot connect to CalDAV server: {e}") from e
result = []
for idx, cal in enumerate(calendars):
try:
try:
name = cal.get_properties([caldav.elements.dav.DisplayName()])[
"{DAV:}displayname"
]
except Exception:
name = str(cal.url).rstrip("/").split("/")[-1] or "Calendar"
color = None
try:
props = cal.get_properties(
[caldav.elements.cdav.CalendarColor()]
)
raw = props.get("{http://apple.com/ns/ical/}calendar-color", "")
if raw and len(raw) >= 7:
color = raw[:7]
except Exception:
pass
result.append(
{
"url": str(cal.url),
"name": name or "Calendar",
"color": color or PALETTE[idx % len(PALETTE)],
}
)
except Exception as exc:
logger.warning("Could not inspect calendar %s: %s", cal.url, exc)
result.append(
{
"url": str(cal.url),
"name": "Calendar",
"color": PALETTE[idx % len(PALETTE)],
}
)
return result
def fetch_events(
url: str,
username: str,
password: str,
calendar_url: str,
start: datetime,
end: datetime,
) -> List[Dict]:
"""Fetch events from a calendar within [start, end]."""
try:
client = _client(url, username, password)
cal = client.calendar(url=calendar_url)
resources = cal.date_search(start=start, end=end, expand=True)
except Exception as exc:
logger.error("Error fetching events from %s: %s", calendar_url, exc)
return []
events = []
for resource in resources:
try:
parsed = _parse_ics(resource.data, str(resource.url))
if parsed:
events.extend(parsed)
except Exception as exc:
logger.warning("Could not parse event %s: %s", resource.url, exc)
return events
def _parse_ics(raw: str, event_url: str) -> List[Dict]:
"""Parse raw ICS data and return a list of event dicts."""
cal = Calendar.from_ical(raw)
results = []
for component in cal.walk():
if component.name != "VEVENT":
continue
try:
uid = str(component.get("UID", uuid.uuid4()))
title = str(component.get("SUMMARY", "Untitled"))
location = str(component.get("LOCATION", "") or "")
description = str(component.get("DESCRIPTION", "") or "")
color = str(component.get("X-CALENDARR-COLOR", "") or "")
dtstart_prop = component.get("DTSTART")
dtend_prop = component.get("DTEND")
duration_prop = component.get("DURATION")
if dtstart_prop is None:
continue
dtstart = dtstart_prop.dt
all_day = isinstance(dtstart, date) and not isinstance(dtstart, datetime)
if all_day:
if dtend_prop:
dtend = dtend_prop.dt
elif duration_prop:
dtend = dtstart + duration_prop.dt
else:
dtend = dtstart + timedelta(days=1)
start_str = dtstart.isoformat()
end_str = dtend.isoformat() if isinstance(dtend, date) else dtstart.isoformat()
else:
if dtstart.tzinfo is None:
dtstart = dtstart.replace(tzinfo=timezone.utc)
if dtend_prop:
dtend = dtend_prop.dt
if isinstance(dtend, date) and not isinstance(dtend, datetime):
dtend = datetime.combine(dtend, datetime.min.time()).replace(
tzinfo=timezone.utc
)
elif dtend.tzinfo is None:
dtend = dtend.replace(tzinfo=timezone.utc)
elif duration_prop:
dtend = dtstart + duration_prop.dt
else:
dtend = dtstart + timedelta(hours=1)
start_str = dtstart.isoformat()
end_str = dtend.isoformat()
results.append(
{
"id": uid,
"url": event_url,
"title": title,
"start": start_str,
"end": end_str,
"allDay": all_day,
"location": location,
"description": description,
"color": color or None,
}
)
except Exception as exc:
logger.warning("Skipping malformed VEVENT: %s", exc)
return results
def create_event(
url: str,
username: str,
password: str,
calendar_url: str,
data: Dict,
) -> str:
client = _client(url, username, password)
cal_obj = client.calendar(url=calendar_url)
cal = Calendar()
cal.add("prodid", "-//Calendarr//EN")
cal.add("version", "2.0")
event = Event()
uid = str(uuid.uuid4())
event.add("uid", uid)
event.add("summary", data.get("title", "New Event"))
event.add("dtstamp", datetime.now(timezone.utc))
if data.get("allDay"):
start = date.fromisoformat(data["start"][:10])
end_raw = data.get("end", data["start"])[:10]
end = date.fromisoformat(end_raw)
if end <= start:
end = start + timedelta(days=1)
event.add("dtstart", start)
event.add("dtend", end)
else:
start = _parse_dt(data["start"])
end = _parse_dt(data["end"])
event.add("dtstart", start)
event.add("dtend", end)
if data.get("location"):
event.add("location", data["location"])
if data.get("description"):
event.add("description", data["description"])
if data.get("color"):
event.add("x-calendarr-color", data["color"])
cal.add_component(event)
cal_obj.save_event(cal.to_ical().decode("utf-8"))
return uid
def update_event(
url: str, username: str, password: str, event_url: str, data: Dict
):
client = _client(url, username, password)
resource = client.event(url=event_url)
raw = resource.data
cal = Calendar.from_ical(raw)
new_cal = Calendar()
for key, val in cal.items():
new_cal.add(key, val)
for component in cal.walk():
if component.name != "VEVENT":
if component.name != "VCALENDAR":
new_cal.add_component(component)
continue
if "title" in data or "summary" in data:
component["SUMMARY"] = data.get("title", data.get("summary", ""))
if "start" in data:
if data.get("allDay"):
component["DTSTART"] = date.fromisoformat(data["start"][:10])
else:
component["DTSTART"] = _parse_dt(data["start"])
if "end" in data:
if data.get("allDay"):
component["DTEND"] = date.fromisoformat(data["end"][:10])
else:
component["DTEND"] = _parse_dt(data["end"])
if "location" in data:
component["LOCATION"] = data["location"]
if "description" in data:
component["DESCRIPTION"] = data["description"]
if "color" in data:
component["X-CALENDARR-COLOR"] = data["color"]
new_cal.add_component(component)
resource.data = new_cal.to_ical().decode("utf-8")
resource.save()
def delete_event(url: str, username: str, password: str, event_url: str):
client = _client(url, username, password)
resource = client.event(url=event_url)
resource.delete()
def _parse_dt(s: str) -> datetime:
s = s.replace("Z", "+00:00")
dt = datetime.fromisoformat(s)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt

24
backend/database.py Normal file
View File

@@ -0,0 +1,24 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from pathlib import Path
import os
DATA_DIR = Path(os.environ.get("DATA_DIR", Path(__file__).parent.parent / "data"))
DATA_DIR.mkdir(parents=True, exist_ok=True)
DATABASE_URL = f"sqlite:///{DATA_DIR}/calendarr.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

43
backend/main.py Normal file
View File

@@ -0,0 +1,43 @@
import logging
import os
import sys
from pathlib import Path
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
sys.path.insert(0, str(Path(__file__).parent))
from database import Base, engine
from routers import auth_router, caldav_router, settings_router, users_router
logging.basicConfig(level=logging.INFO)
# Create DB tables on startup
Base.metadata.create_all(bind=engine)
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"])
app.include_router(users_router.router, prefix="/api/users", tags=["users"])
app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):
if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="API endpoint not found")
index = FRONTEND_DIR / "index.html"
return FileResponse(str(index))
if __name__ == "__main__":
port = int(os.environ.get("PORT", 8080))
host = os.environ.get("HOST", "0.0.0.0")
uvicorn.run(app, host=host, port=port)

65
backend/models.py Normal file
View File

@@ -0,0 +1,65 @@
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey
from sqlalchemy.orm import relationship
from database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, nullable=False)
email = Column(String(100), unique=True, nullable=True)
password_hash = Column(String(255), nullable=False)
is_admin = Column(Boolean, default=False)
caldav_accounts = relationship(
"CalDAVAccount", back_populates="user", cascade="all, delete-orphan"
)
settings = relationship(
"UserSettings", back_populates="user", uselist=False, cascade="all, delete-orphan"
)
class CalDAVAccount(Base):
__tablename__ = "caldav_accounts"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
name = Column(String(100), nullable=False)
url = Column(String(500), nullable=False)
username = Column(String(100), nullable=False)
password = Column(String(255), nullable=False)
color = Column(String(7), default="#4285f4")
enabled = Column(Boolean, default=True)
user = relationship("User", back_populates="caldav_accounts")
calendars = relationship(
"Calendar", back_populates="account", cascade="all, delete-orphan"
)
class Calendar(Base):
__tablename__ = "calendars"
id = Column(Integer, primary_key=True, index=True)
account_id = Column(Integer, ForeignKey("caldav_accounts.id"), nullable=False)
cal_id = Column(String(500), nullable=False)
name = Column(String(100), nullable=False)
color = Column(String(7), nullable=True)
enabled = Column(Boolean, default=True)
account = relationship("CalDAVAccount", back_populates="calendars")
class UserSettings(Base):
__tablename__ = "user_settings"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
default_view = Column(String(20), default="month")
primary_color = Column(String(7), default="#4285f4")
accent_color = Column(String(7), default="#ea4335")
today_color = Column(String(7), default="#4285f4")
dim_past_events = Column(Boolean, default=False)
user = relationship("User", back_populates="settings")

View File

View File

@@ -0,0 +1,73 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy.orm import Session
import models
from auth import create_access_token, get_current_user, get_password_hash, verify_password
from database import get_db
router = APIRouter()
class SetupRequest(BaseModel):
username: str
password: str
email: Optional[str] = None
def _user_dict(user: models.User) -> dict:
return {"id": user.id, "username": user.username, "is_admin": user.is_admin}
@router.get("/setup-required")
def setup_required(db: Session = Depends(get_db)):
return {"required": db.query(models.User).count() == 0}
@router.post("/setup")
def setup(req: SetupRequest, db: Session = Depends(get_db)):
if db.query(models.User).count() > 0:
raise HTTPException(400, "Setup already completed")
user = models.User(
username=req.username,
email=req.email,
password_hash=get_password_hash(req.password),
is_admin=True,
)
db.add(user)
db.flush()
db.add(models.UserSettings(user_id=user.id))
db.commit()
db.refresh(user)
token = create_access_token({"sub": user.username})
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
@router.post("/token")
def login(
form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)
):
user = (
db.query(models.User).filter(models.User.username == form_data.username).first()
)
if not user or not verify_password(form_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
token = create_access_token({"sub": user.username})
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
@router.get("/me")
def me(current_user: models.User = Depends(get_current_user)):
return {
"id": current_user.id,
"username": current_user.username,
"email": current_user.email,
"is_admin": current_user.is_admin,
}

View File

@@ -0,0 +1,375 @@
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
import caldav_client
import models
from auth import get_current_user
from database import get_db
logger = logging.getLogger(__name__)
router = APIRouter()
PALETTE = ["#4285f4", "#ea4335", "#fbbc04", "#34a853", "#ff6d00", "#46bdc6", "#8e24aa"]
class AccountCreate(BaseModel):
name: str
url: str
username: str
password: str
color: str = "#4285f4"
class CalendarUpdate(BaseModel):
enabled: Optional[bool] = None
color: Optional[str] = None
class EventCreate(BaseModel):
calendar_id: int
title: str
start: str
end: str
allDay: bool = False
location: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
class EventUpdate(BaseModel):
title: Optional[str] = None
start: Optional[str] = None
end: Optional[str] = None
allDay: Optional[bool] = None
location: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
def _account_dict(a: models.CalDAVAccount) -> dict:
return {
"id": a.id,
"name": a.name,
"url": a.url,
"username": a.username,
"color": a.color,
"enabled": a.enabled,
"calendars": [
{
"id": c.id,
"name": c.name,
"color": c.color or a.color,
"enabled": c.enabled,
"cal_id": c.cal_id,
}
for c in a.calendars
],
}
def _find_account_for_event_url(
event_url: str, accounts: list[models.CalDAVAccount]
) -> Optional[models.CalDAVAccount]:
for acc in accounts:
if event_url.startswith(acc.url):
return acc
# fallback: check calendar urls
for acc in accounts:
for cal in acc.calendars:
if event_url.startswith(cal.cal_id):
return acc
return None
@router.get("/accounts")
def list_accounts(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
accounts = (
db.query(models.CalDAVAccount)
.filter(models.CalDAVAccount.user_id == current_user.id)
.all()
)
return [_account_dict(a) for a in accounts]
@router.post("/accounts")
def add_account(
data: AccountCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
try:
remote_cals = caldav_client.fetch_calendars(data.url, data.username, data.password)
except ValueError as e:
raise HTTPException(400, str(e))
account = models.CalDAVAccount(
user_id=current_user.id,
name=data.name,
url=data.url,
username=data.username,
password=data.password,
color=data.color,
)
db.add(account)
db.flush()
for idx, cal in enumerate(remote_cals):
db.add(
models.Calendar(
account_id=account.id,
cal_id=cal["url"],
name=cal["name"],
color=cal.get("color") or PALETTE[idx % len(PALETTE)],
enabled=True,
)
)
db.commit()
db.refresh(account)
return _account_dict(account)
@router.delete("/accounts/{account_id}")
def delete_account(
account_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
account = (
db.query(models.CalDAVAccount)
.filter(
models.CalDAVAccount.id == account_id,
models.CalDAVAccount.user_id == current_user.id,
)
.first()
)
if not account:
raise HTTPException(404, "Account not found")
db.delete(account)
db.commit()
return {"ok": True}
@router.post("/accounts/{account_id}/sync")
def sync_account(
account_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
account = (
db.query(models.CalDAVAccount)
.filter(
models.CalDAVAccount.id == account_id,
models.CalDAVAccount.user_id == current_user.id,
)
.first()
)
if not account:
raise HTTPException(404, "Account not found")
try:
remote_cals = caldav_client.fetch_calendars(
account.url, account.username, account.password
)
except ValueError as e:
raise HTTPException(400, str(e))
existing = {c.cal_id: c for c in account.calendars}
for idx, cal in enumerate(remote_cals):
if cal["url"] not in existing:
db.add(
models.Calendar(
account_id=account.id,
cal_id=cal["url"],
name=cal["name"],
color=cal.get("color") or PALETTE[idx % len(PALETTE)],
enabled=True,
)
)
else:
existing[cal["url"]].name = cal["name"]
db.commit()
db.refresh(account)
return _account_dict(account)
@router.put("/calendars/{calendar_id}")
def update_calendar(
calendar_id: int,
data: CalendarUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
calendar = (
db.query(models.Calendar)
.join(models.CalDAVAccount)
.filter(
models.Calendar.id == calendar_id,
models.CalDAVAccount.user_id == current_user.id,
)
.first()
)
if not calendar:
raise HTTPException(404, "Calendar not found")
if data.enabled is not None:
calendar.enabled = data.enabled
if data.color is not None:
calendar.color = data.color
db.commit()
return {"ok": True}
@router.get("/events")
def get_events(
start: str = Query(...),
end: str = Query(...),
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
from datetime import datetime, timezone
try:
start_dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
end_dt = datetime.fromisoformat(end.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(400, "Invalid date format — use ISO 8601")
# Make timezone-aware
if start_dt.tzinfo is None:
start_dt = start_dt.replace(tzinfo=timezone.utc)
if end_dt.tzinfo is None:
end_dt = end_dt.replace(tzinfo=timezone.utc)
all_events = []
accounts = (
db.query(models.CalDAVAccount)
.filter(
models.CalDAVAccount.user_id == current_user.id,
models.CalDAVAccount.enabled == True,
)
.all()
)
for account in accounts:
for calendar in account.calendars:
if not calendar.enabled:
continue
try:
events = caldav_client.fetch_events(
account.url,
account.username,
account.password,
calendar.cal_id,
start_dt,
end_dt,
)
cal_color = calendar.color or account.color
for ev in events:
ev["calendar_id"] = calendar.id
ev["calendar_name"] = calendar.name
ev["calendarColor"] = cal_color
all_events.append(ev)
except Exception as exc:
logger.error(
"Error fetching calendar %s: %s", calendar.id, exc
)
return all_events
@router.post("/events")
def create_event(
data: EventCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
calendar = (
db.query(models.Calendar)
.join(models.CalDAVAccount)
.filter(
models.Calendar.id == data.calendar_id,
models.CalDAVAccount.user_id == current_user.id,
)
.first()
)
if not calendar:
raise HTTPException(404, "Calendar not found")
account = calendar.account
try:
uid = caldav_client.create_event(
account.url,
account.username,
account.password,
calendar.cal_id,
{
"title": data.title,
"start": data.start,
"end": data.end,
"allDay": data.allDay,
"location": data.location,
"description": data.description,
"color": data.color,
},
)
return {"uid": uid, "calendar_id": data.calendar_id}
except Exception as exc:
raise HTTPException(500, f"Could not create event: {exc}")
@router.put("/events/{event_id}")
def update_event(
event_id: str,
event_url: str = Query(...),
data: EventUpdate = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
accounts = (
db.query(models.CalDAVAccount)
.filter(models.CalDAVAccount.user_id == current_user.id)
.all()
)
account = _find_account_for_event_url(event_url, accounts)
if not account:
raise HTTPException(404, "Event not found or not authorized")
try:
caldav_client.update_event(
account.url,
account.username,
account.password,
event_url,
data.model_dump(exclude_none=True) if data else {},
)
return {"ok": True}
except Exception as exc:
raise HTTPException(500, f"Could not update event: {exc}")
@router.delete("/events/{event_id}")
def delete_event(
event_id: str,
event_url: str = Query(...),
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
accounts = (
db.query(models.CalDAVAccount)
.filter(models.CalDAVAccount.user_id == current_user.id)
.all()
)
account = _find_account_for_event_url(event_url, accounts)
if not account:
raise HTTPException(404, "Event not found or not authorized")
try:
caldav_client.delete_event(
account.url, account.username, account.password, event_url
)
return {"ok": True}
except Exception as exc:
raise HTTPException(500, f"Could not delete event: {exc}")

View File

@@ -0,0 +1,69 @@
from typing import Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
import models
from auth import get_current_user
from database import get_db
router = APIRouter()
class SettingsUpdate(BaseModel):
default_view: Optional[str] = None
primary_color: Optional[str] = None
accent_color: Optional[str] = None
today_color: Optional[str] = None
dim_past_events: Optional[bool] = None
def _settings_dict(s: models.UserSettings) -> dict:
return {
"default_view": s.default_view,
"primary_color": s.primary_color,
"accent_color": s.accent_color,
"today_color": s.today_color,
"dim_past_events": s.dim_past_events,
}
@router.get("/")
def get_settings(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
settings = (
db.query(models.UserSettings)
.filter(models.UserSettings.user_id == current_user.id)
.first()
)
if not settings:
settings = models.UserSettings(user_id=current_user.id)
db.add(settings)
db.commit()
db.refresh(settings)
return _settings_dict(settings)
@router.put("/")
def update_settings(
data: SettingsUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
settings = (
db.query(models.UserSettings)
.filter(models.UserSettings.user_id == current_user.id)
.first()
)
if not settings:
settings = models.UserSettings(user_id=current_user.id)
db.add(settings)
for field, value in data.model_dump(exclude_none=True).items():
setattr(settings, field, value)
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,89 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
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
def _user_dict(u: models.User) -> dict:
return {"id": u.id, "username": 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.post("/")
def create_user(
req: CreateUserRequest,
db: Session = Depends(get_db),
_: models.User = Depends(get_current_admin),
):
if db.query(models.User).filter(models.User.username == req.username).first():
raise HTTPException(400, "Username already taken")
user = models.User(
username=req.username,
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")
db.delete(user)
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}