Files
linkvault/app/models.py
Erik Thiele 604c4ea9c2 Add invite-code registration and admin user management
Open registration let anyone with the URL create an account. Two
changes address that:

- REGISTRATION_CODE (.env, optional): when set, registration requires
  entering it correctly. Empty/unset keeps registration open, so
  existing installs are unaffected until configured.
- is_admin flag on User: the first account ever created on an install
  becomes admin automatically (existing installs get their oldest
  account promoted via the startup migration, so nobody is locked
  out of user management after upgrading).

Admins get a new "Benutzerverwaltung" panel in Einstellungen listing
every account (email, link/prompt counts, join date) with a delete
button per account — deleting cascades to that user's links and
prompts via the existing relationship cascade. Deleting your own
account through this page is blocked (redirects with an error) to
avoid accidental admin lockout. Non-admins get a 403 on the
/settings/users routes.

Also fixes several pre-existing German pluralization bugs found while
writing the new counts ("2 Linke" -> "2 Links", "Kontoen"/"Konton" ->
"Konten") — irregular plurals need a full word swap, not a suffix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 11:16:01 +02:00

76 lines
2.7 KiB
Python

import json
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
is_admin: Mapped[bool] = mapped_column(default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
links: Mapped[list["Link"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
prompts: Mapped[list["Prompt"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class Link(Base):
__tablename__ = "links"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
url: Mapped[str] = mapped_column(Text)
title: Mapped[str] = mapped_column(Text, default="")
summary: Mapped[str] = mapped_column(Text, default="")
category: Mapped[str] = mapped_column(String(120), default="", index=True)
manufacturer: Mapped[str] = mapped_column(String(120), default="", index=True)
tags: Mapped[str] = mapped_column(Text, default="[]") # JSON-Liste
embedding: Mapped[str] = mapped_column(Text, default="") # JSON-Liste (Vektor)
status: Mapped[str] = mapped_column(String(20), default="ok")
needs_review: Mapped[bool] = mapped_column(default=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True)
user: Mapped["User"] = relationship(back_populates="links")
@property
def tag_list(self) -> list[str]:
try:
return json.loads(self.tags)
except (ValueError, TypeError):
return []
class Prompt(Base):
__tablename__ = "prompts"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
title: Mapped[str] = mapped_column(Text, default="")
content: Mapped[str] = mapped_column(Text, default="")
category: Mapped[str] = mapped_column(String(120), default="", index=True)
tags: Mapped[str] = mapped_column(Text, default="[]") # JSON-Liste
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True)
user: Mapped["User"] = relationship(back_populates="prompts")
@property
def tag_list(self) -> list[str]:
try:
return json.loads(self.tags)
except (ValueError, TypeError):
return []