feat: Login-Name vs. Anzeigename (Server)

- Neue Spalte users.display_name (Original-Schreibweise); username bleibt der
  lowercase Login-Name. Setup/Create setzen display_name aus der Eingabe.
- Login bleibt case-insensitive (Anzeigename eingeben funktioniert -> wird
  lowercased -> trifft den Login-Namen).
- Profil: PUT /api/profile/ kann display_name UND username (Login-Name) aendern;
  bei Login-Namen-Wechsel kommt ein frischer Token zurueck (JWT sub haengt am
  Namen). Stabile interne ID (Integer-PK) traegt alle Verweise -> Umbenennen
  bricht Shares/Gruppen/creator_id nicht.
- display_name ueberall ausgeliefert/genutzt (me, profile, users, directory,
  shares, Gruppen-Mitglieder, creator/owner, ORGANIZER-Export).
- Migration + Backfill (display_name = username). Tests ergaenzt (17 gruen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-05-31 17:40:38 +02:00
parent 28a7cbe94e
commit f9923b022e
10 changed files with 103 additions and 15 deletions

View File

@@ -36,7 +36,7 @@ def resolve_creator(ev: models.LocalEvent, *, name_cache: Optional[dict] = None)
if name_cache is not None:
display = name_cache.get(ev.creator_id)
if display is None and ev.creator is not None:
display = ev.creator.username
display = ev.creator.display_name or ev.creator.username
if display is not None:
return {"id": ev.creator_id, "display_name": display}
if ev.creator_name_external:

View File

@@ -163,6 +163,18 @@ def _migrate():
logging.info("Migration: added group_visible_calendar_id to user_settings")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE users ADD COLUMN display_name VARCHAR(100)"))
conn.commit()
logging.info("Migration: added display_name to users")
except Exception:
pass
# Backfill display_name from username for existing rows (only where empty).
try:
conn.execute(text("UPDATE users SET display_name = username WHERE display_name IS NULL OR display_name = ''"))
conn.commit()
except Exception:
pass
_migrate()

View File

@@ -7,7 +7,10 @@ class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
# Login name: always lowercase, unique, used for authentication.
username = Column(String(50), unique=True, nullable=False)
# Human-facing name with original casing; editable. Falls back to username.
display_name = Column(String(100), nullable=True)
email = Column(String(100), unique=True, nullable=True)
password_hash = Column(String(255), nullable=False)
is_admin = Column(Boolean, default=False)
@@ -35,9 +38,9 @@ class User(Base):
)
@property
def display_name(self) -> str:
"""No dedicated display-name column exists — fall back to the username."""
return self.username
def display(self) -> str:
"""The name to show users: display_name if set, else the login name."""
return self.display_name or self.username
class CalDAVAccount(Base):

View File

@@ -32,7 +32,12 @@ class LoginRequest(BaseModel):
def _user_dict(user: models.User) -> dict:
return {"id": user.id, "username": user.username, "is_admin": user.is_admin}
return {
"id": user.id,
"username": user.username,
"display_name": user.display_name or user.username,
"is_admin": user.is_admin,
}
@router.get("/setup-required")
@@ -46,6 +51,7 @@ def setup(req: SetupRequest, db: Session = Depends(get_db)):
raise HTTPException(400, "Setup already completed")
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=True,
@@ -113,6 +119,7 @@ def me(current_user: models.User = Depends(get_current_user)):
return {
"id": current_user.id,
"username": current_user.username,
"display_name": current_user.display_name or current_user.username,
"email": current_user.email,
"is_admin": current_user.is_admin,
"has_avatar": current_user.avatar_filename is not None,

View File

@@ -334,7 +334,7 @@ def get_events(
)
.all()
) if readable_ids else []
name_cache = {u.id: u.username for u in db.query(models.User).all()}
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
for local_cal in local_calendars:
local_events = (
db.query(models.LocalEvent)

View File

@@ -149,7 +149,7 @@ def _group_detail(db: Session, group: models.Group, current_user: models.User) -
u = db.query(models.User).filter(models.User.id == m.user_id).first()
member_dicts.append({
"id": m.user_id,
"display_name": u.username if u else None,
"display_name": (u.display_name or u.username) if u else None,
"role": m.role,
})
return {
@@ -264,7 +264,7 @@ def combined_events(
end_dt = end_dt.replace(tzinfo=timezone.utc)
members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group_id).all()
name_cache = {u.id: u.username for u in db.query(models.User).all()}
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
visibility_cache: dict[int, str] = {}
def visibility_for(user_id: int) -> str:

View File

@@ -134,7 +134,7 @@ def list_calendars(
owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
d = _cal_dict(
cal, owned=False,
shared_by=owner.username if owner else None,
shared_by=(owner.display_name or owner.username) if owner else None,
permission=share.permission,
)
if cal.id in group_cal_map:
@@ -330,7 +330,7 @@ def list_shares(
u = db.query(models.User).filter(models.User.id == s.user_id).first()
out.append({
"user_id": s.user_id,
"display_name": u.username if u else None,
"display_name": (u.display_name or u.username) if u else None,
"permission": s.permission,
"created_at": s.created_at,
})
@@ -501,7 +501,7 @@ def export_calendar(
.all()
)
# Resolve creator display names for ORGANIZER.
name_cache = {u.id: u.username for u in db.query(models.User).all()}
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
ics = ical_io.build_ics(cal, events, name_cache=name_cache)
safe_name = "".join(c for c in cal.name if c.isalnum() or c in (" ", "-", "_")).strip() or "calendar"
return Response(

View File

@@ -11,8 +11,10 @@ from PIL import Image
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func
import models
from auth import get_current_user, get_password_hash, verify_password
from auth import create_access_token, get_current_user, get_password_hash, verify_password
from database import DATA_DIR, get_db
router = APIRouter()
@@ -26,6 +28,8 @@ ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
# ── Schemas ───────────────────────────────────────────────
class ProfileUpdate(BaseModel):
email: Optional[str] = None
display_name: Optional[str] = None
username: Optional[str] = None # login name (stored lowercase)
class PasswordChange(BaseModel):
@@ -47,6 +51,7 @@ def get_profile(current_user: models.User = Depends(get_current_user)):
return {
"id": current_user.id,
"username": current_user.username,
"display_name": current_user.display_name or current_user.username,
"email": current_user.email,
"is_admin": current_user.is_admin,
"has_avatar": current_user.avatar_filename is not None,
@@ -60,10 +65,33 @@ def update_profile(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
result = {"ok": True}
if data.email is not None:
current_user.email = data.email or None
if data.display_name is not None:
dn = data.display_name.strip()
current_user.display_name = dn or current_user.username
if data.username is not None:
new_login = data.username.strip().lower()
if not new_login:
raise HTTPException(422, "Login name cannot be empty")
if new_login != current_user.username:
taken = (
db.query(models.User)
.filter(func.lower(models.User.username) == new_login,
models.User.id != current_user.id)
.first()
)
if taken:
raise HTTPException(400, "Username already taken")
current_user.username = new_login
db.commit()
# The JWT 'sub' is the login name — renaming it invalidates the old
# token, so hand back a fresh one for the client to store.
result["access_token"] = create_access_token({"sub": new_login})
return result
db.commit()
return {"ok": True}
return result
# ── Avatar ────────────────────────────────────────────────

View File

@@ -24,7 +24,13 @@ class ChangePasswordRequest(BaseModel):
def _user_dict(u: models.User) -> dict:
return {"id": u.id, "username": u.username, "email": u.email, "is_admin": u.is_admin}
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("/")
@@ -51,7 +57,7 @@ def user_directory(
.order_by(models.User.username)
.all()
)
return [{"id": u.id, "display_name": u.username} for u in users]
return [{"id": u.id, "display_name": u.display_name or u.username} for u in users]
@router.post("/")
@@ -64,6 +70,7 @@ def create_user(
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,

View File

@@ -225,6 +225,37 @@ def test_member_calendar_hidden_until_designated(client):
assert any(e["title"] == "Bobs Termin" for e in seen2)
def test_display_name_case_preserved_and_login_case_insensitive(client):
# Setup with mixed-case name: login name lowercased, display name kept.
r = client.post("/api/auth/setup", json={"username": "Guido", "password": "pw"})
assert r.status_code == 200, r.text
user = r.json()["user"]
assert user["username"] == "guido"
assert user["display_name"] == "Guido"
# Login is case-insensitive (typing the display name "GUIDO" works).
r2 = client.post("/api/auth/login", json={"username": "GUIDO", "password": "pw"})
assert r2.status_code == 200, r2.text
tok = r2.json()["access_token"]
# /me reflects the cased display name.
me = client.get("/api/auth/me", headers=auth(tok)).json()
assert me["display_name"] == "Guido" and me["username"] == "guido"
def test_rename_login_name_returns_new_token(client):
admin = register_admin(client, "alice")
# Change display name (no token change).
r = client.put("/api/profile/", headers=auth(admin), json={"display_name": "Alice W."})
assert r.status_code == 200 and "access_token" not in r.json()
# Change login name -> fresh token, references survive (id is stable).
r2 = client.put("/api/profile/", headers=auth(admin), json={"username": "alice2"})
assert r2.status_code == 200 and r2.json().get("access_token")
new_tok = r2.json()["access_token"]
me = client.get("/api/auth/me", headers=auth(new_tok)).json()
assert me["username"] == "alice2" and me["display_name"] == "Alice W."
def test_private_visibility_validation(client):
admin = register_admin(client)
r = client.put("/api/settings/", headers=auth(admin), json={"private_event_visibility": "bogus"})