feat: Vor-/Nachname, geteilte Listen zeigen Eigentuemer

Backend:
- User.first_name / User.last_name (nullable, Auto-Migrate fuegt sie an)
  full_name/display_name als Properties + in to_dict
- TaskList.owner-Relationship ergaenzt (fehlte, daher wurden geteilte
  Listen beim Empfaenger nicht korrekt aufgeloest)
- /auth/me GET + PUT (Profil bearbeiten: Vorname, Nachname, E-Mail)
- /users/search findet jetzt auch nach Vor-/Nachname und liefert
  full_name/display_name mit
- list_tasklists/list_calendars/list_addressbooks liefern
  owner_full_name und owner_display_name

Frontend:
- Sidebars bei Kontakten/Kalender/Aufgaben: "(geteilt von <Voller Name>)"
  mit Fallback auf Username
- User-Search-Popup zeigt vollen Namen neben Username
- SettingsView: Vorname/Nachname/E-Mail bearbeiten

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-14 15:34:22 +02:00
parent e4dd555bd1
commit 4d67819cac
9 changed files with 138 additions and 13 deletions
+2
View File
@@ -109,6 +109,8 @@ def list_calendars():
if share and share.color:
d['color'] = share.color
d['owner_name'] = c.owner.username
d['owner_full_name'] = c.owner.full_name
d['owner_display_name'] = c.owner.display_name
result.append(d)
return jsonify(result), 200
+2
View File
@@ -292,6 +292,8 @@ def list_addressbooks():
if share and share.color:
d['color'] = share.color
d['owner_name'] = b.owner.username
d['owner_full_name'] = b.owner.full_name
d['owner_display_name'] = b.owner.display_name
d['contact_count'] = b.contacts.count()
result.append(d)
+4 -1
View File
@@ -212,7 +212,10 @@ def list_tasklists():
continue
d = tl.to_dict()
d['permission'] = s.permission
d['owner_name'] = tl.owner.username if tl.owner else ''
owner = tl.owner
d['owner_name'] = owner.username if owner else ''
d['owner_full_name'] = owner.full_name if owner else ''
d['owner_display_name'] = owner.display_name if owner else ''
d['task_count'] = tl.tasks.count()
if s.color:
d['color'] = s.color
+33 -2
View File
@@ -281,6 +281,31 @@ def create_invite_link():
# --- User search (for sharing dialogs) ---
@api_bp.route('/auth/me', methods=['GET'])
@token_required
def get_me():
return jsonify(request.current_user.to_dict(include_email=True)), 200
@api_bp.route('/auth/me', methods=['PUT'])
@token_required
def update_me():
user = request.current_user
data = request.get_json() or {}
if 'first_name' in data:
user.first_name = (data.get('first_name') or '').strip() or None
if 'last_name' in data:
user.last_name = (data.get('last_name') or '').strip() or None
if 'email' in data:
email = (data.get('email') or '').strip() or None
if email and email != user.email:
if User.query.filter(User.email == email, User.id != user.id).first():
return jsonify({'error': 'E-Mail ist bereits vergeben'}), 409
user.email = email
db.session.commit()
return jsonify(user.to_dict(include_email=True)), 200
@api_bp.route('/users/search', methods=['GET'])
@token_required
def search_users():
@@ -289,13 +314,19 @@ def search_users():
if len(query) < 2:
return jsonify([]), 200
like = f'%{query}%'
users = User.query.filter(
User.username.ilike(f'%{query}%'),
(User.username.ilike(like)) | (User.first_name.ilike(like)) | (User.last_name.ilike(like)),
User.id != request.current_user.id,
User.is_active == True,
).limit(10).all()
return jsonify([{'id': u.id, 'username': u.username} for u in users]), 200
return jsonify([{
'id': u.id,
'username': u.username,
'full_name': u.full_name,
'display_name': u.display_name,
} for u in users]), 200
# --- Change password (non-admin, own account) ---
+18
View File
@@ -9,6 +9,8 @@ class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
email = db.Column(db.String(255), unique=True, nullable=True)
first_name = db.Column(db.String(100), nullable=True)
last_name = db.Column(db.String(100), nullable=True)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(20), default='user', nullable=False) # 'admin' or 'user'
master_key_salt = db.Column(db.LargeBinary, nullable=True) # For password manager
@@ -23,6 +25,7 @@ class User(db.Model):
foreign_keys='File.owner_id')
calendars = db.relationship('Calendar', backref='owner', lazy='dynamic')
address_books = db.relationship('AddressBook', backref='owner', lazy='dynamic')
task_lists = db.relationship('TaskList', backref='owner', lazy='dynamic')
email_accounts = db.relationship('EmailAccount', backref='user', lazy='dynamic',
order_by='EmailAccount.sort_order')
password_folders = db.relationship('PasswordFolder', backref='owner', lazy='dynamic')
@@ -33,10 +36,25 @@ class User(db.Model):
def check_password(self, password):
return bcrypt.check_password_hash(self.password_hash, password)
@property
def full_name(self) -> str:
"""Vor- + Nachname zusammengesetzt, sonst Leerstring."""
parts = [self.first_name or '', self.last_name or '']
return ' '.join(p.strip() for p in parts if p and p.strip())
@property
def display_name(self) -> str:
"""Voller Name falls vorhanden, sonst Username."""
return self.full_name or self.username
def to_dict(self, include_email=False):
data = {
'id': self.id,
'username': self.username,
'first_name': self.first_name or '',
'last_name': self.last_name or '',
'full_name': self.full_name,
'display_name': self.display_name,
'role': self.role,
'is_active': self.is_active,
'storage_quota_mb': self.storage_quota_mb,