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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user