Einige kleine verbesserungen #1

Open
Scarriffle wants to merge 115 commits from beta into master
10 changed files with 103 additions and 15 deletions
Showing only changes of commit f9923b022e - Show all commits

View File

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

View File

@@ -163,6 +163,18 @@ def _migrate():
logging.info("Migration: added group_visible_calendar_id to user_settings") logging.info("Migration: added group_visible_calendar_id to user_settings")
except Exception: except Exception:
pass 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() _migrate()

View File

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

View File

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

View File

@@ -334,7 +334,7 @@ def get_events(
) )
.all() .all()
) if readable_ids else [] ) 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: for local_cal in local_calendars:
local_events = ( local_events = (
db.query(models.LocalEvent) 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() u = db.query(models.User).filter(models.User.id == m.user_id).first()
member_dicts.append({ member_dicts.append({
"id": m.user_id, "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, "role": m.role,
}) })
return { return {
@@ -264,7 +264,7 @@ def combined_events(
end_dt = end_dt.replace(tzinfo=timezone.utc) end_dt = end_dt.replace(tzinfo=timezone.utc)
members = db.query(models.GroupMember).filter(models.GroupMember.group_id == group_id).all() 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] = {} visibility_cache: dict[int, str] = {}
def visibility_for(user_id: 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() owner = db.query(models.User).filter(models.User.id == cal.user_id).first()
d = _cal_dict( d = _cal_dict(
cal, owned=False, 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, permission=share.permission,
) )
if cal.id in group_cal_map: 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() u = db.query(models.User).filter(models.User.id == s.user_id).first()
out.append({ out.append({
"user_id": s.user_id, "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, "permission": s.permission,
"created_at": s.created_at, "created_at": s.created_at,
}) })
@@ -501,7 +501,7 @@ def export_calendar(
.all() .all()
) )
# Resolve creator display names for ORGANIZER. # 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) 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" safe_name = "".join(c for c in cal.name if c.isalnum() or c in (" ", "-", "_")).strip() or "calendar"
return Response( return Response(

View File

@@ -11,8 +11,10 @@ from PIL import Image
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func
import models 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 from database import DATA_DIR, get_db
router = APIRouter() router = APIRouter()
@@ -26,6 +28,8 @@ ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
# ── Schemas ─────────────────────────────────────────────── # ── Schemas ───────────────────────────────────────────────
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
email: Optional[str] = None email: Optional[str] = None
display_name: Optional[str] = None
username: Optional[str] = None # login name (stored lowercase)
class PasswordChange(BaseModel): class PasswordChange(BaseModel):
@@ -47,6 +51,7 @@ def get_profile(current_user: models.User = Depends(get_current_user)):
return { return {
"id": current_user.id, "id": current_user.id,
"username": current_user.username, "username": current_user.username,
"display_name": current_user.display_name or current_user.username,
"email": current_user.email, "email": current_user.email,
"is_admin": current_user.is_admin, "is_admin": current_user.is_admin,
"has_avatar": current_user.avatar_filename is not None, "has_avatar": current_user.avatar_filename is not None,
@@ -60,10 +65,33 @@ def update_profile(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
result = {"ok": True}
if data.email is not None: if data.email is not None:
current_user.email = data.email or 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() db.commit()
return {"ok": True} # 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 result
# ── Avatar ──────────────────────────────────────────────── # ── Avatar ────────────────────────────────────────────────

View File

@@ -24,7 +24,13 @@ class ChangePasswordRequest(BaseModel):
def _user_dict(u: models.User) -> dict: 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("/") @router.get("/")
@@ -51,7 +57,7 @@ def user_directory(
.order_by(models.User.username) .order_by(models.User.username)
.all() .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("/") @router.post("/")
@@ -64,6 +70,7 @@ def create_user(
raise HTTPException(400, "Username already taken") raise HTTPException(400, "Username already taken")
user = models.User( user = models.User(
username=req.username.lower(), username=req.username.lower(),
display_name=req.username.strip(), # keep the original casing for display
email=req.email, email=req.email,
password_hash=get_password_hash(req.password), password_hash=get_password_hash(req.password),
is_admin=req.is_admin, 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) 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): def test_private_visibility_validation(client):
admin = register_admin(client) admin = register_admin(client)
r = client.put("/api/settings/", headers=auth(admin), json={"private_event_visibility": "bogus"}) r = client.put("/api/settings/", headers=auth(admin), json={"private_event_visibility": "bogus"})