Projekt-Sichtbarkeit: Nutzer-Zuordnung, projektbezogene Filterung und Schreibschutz

- Neues Modell ProjektUser (projekt_users) für projektbezogene Sichtbarkeit
- Migration V01_09 legt Tabelle an (keine Initial-Befüllung)
- CRUD-Logik für Projekt-User-Zuordnungen mit Cross-DB-User-Auflösung
- Serverseitige Sichtbarkeits-Filterung: Projekt-Liste/Get filtert nach
  projekt_users-Zuordnung für Nicht-SysAdmin/Nicht-AppAdmin
- Auto-Assignment: Ersteller wird bei Projektanlage automatisch zugeordnet
- Sichtbarkeits-Filterung auf CallLog und Kontaktadresse erweitert
  (Liste und Einzelabruf)
- Schreibschutz: Alle Schreib-Endpunkte (update/delete) für Projekt,
  Kontaktadresse und CallLog prüfen Projekt-Zuordnung vor der Ausführung
- Hilfsfunktionen _get_visible_user_id() und _require_projekt_access()
  zur Kapselung der wiederkehrenden Prüfungen
- REST-Endpunkte /xhr/projektuser/{list,add,remove};
  Schreib-Endpunkte mit @authorisation_required([]) als Erweiterungspunkt
- Client: Neues Modell projekt_user.ts, Listen-Komponente mit Modal
  (Select-Widget), Button in Projekt-Maske, Route registriert
- Build aktualisiert (tsc + rollup)
- AGENTS.md: Vorgaben für projektbezogene Sichtbarkeit dokumentiert

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
gg committed 2026-09-05 15:45:32 +02:00
1 parent b2f8684afb
commit 4cb0084066
12 files changed
+758 -25

No files matched your search

+35 -1
View File
@@ -12,7 +12,7 @@ erfolgt ausschließlich über __bind_key__.
import datetime
import decimal
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Index, Integer, String
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship
from db import dbapp
@@ -160,6 +160,40 @@ class Projekt(dbapp.Model):
return result
class ProjektUser(dbapp.Model):
"""
Verknüpfungstabelle zwischen Projekt und User.
Steuert die Sichtbarkeit von Projekten: Ein Nutzer sieht nur Projekte,
denen er über diese Tabelle zugeordnet ist. Die Zuordnung erfolgt durch
den AppAdmin (oder später andere berechtigte Rollen).
Die Tabelle liegt in der CRM-Datenbank (appdb). Die Spalte 'user_id'
referenziert logisch die 'id' in der Auth-Datenbanktabelle 'users', ein
physischer Fremdschlüssel ist aufgrund der zwei getrennten SQLite-Datenbanken
nicht möglich. Die Auflösung von user_id auf username etc. erfolgt zur
Laufzeit durch eine separate Query gegen die Auth-Datenbank.
Das UniqueConstraint stellt sicher, dass jeder Nutzer nur einmal pro
Projekt zugeordnet werden kann.
"""
__tablename__ = 'projekt_users'
__bind_key__ = BINDKEY
__table_args__ = (
Index('ix_projekt_users_projekt_id', 'projekt_id'),
UniqueConstraint('projekt_id', 'user_id', name='uq_projekt_user'),
)
id = Column(Integer, primary_key=True)
projekt_id = Column(Integer, ForeignKey('projekte.id'), nullable=False)
user_id = Column(Integer, nullable=False)
projekt = relationship('Projekt')
def to_dict(self):
"""Liefert die Projekt-User-Zuordnung als Dictionary."""
return columns2dict(self)
class Kontaktadresse(dbapp.Model):
"""
Akquise-Ziel innerhalb eines Projekts. Jede Kontaktadresse ist genau
+29 -3
View File
@@ -10,31 +10,47 @@ dort über den Decorator authorisation_required.
:version: 1.0
"""
from db import dbapp
from crm.model import CallLog, Projekt
from crm.model import CallLog, Projekt, ProjektUser
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
def get_call_logs(projekt_id: int | None = None):
def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
"""
Liefert Gesprächsnotizen, sortiert nach Datum absteigend.
Ist projekt_id gesetzt, werden nur die Notizen dieses Projekts
zurückgegeben (serverseitige Filterung). Ohne projekt_id werden
alle Notizen geliefert.
Ist user_id gesetzt, werden nur Notizen von Projekten geliefert, denen
der Nutzer über projekt_users zugeordnet ist (Sichtbarkeits-Filterung).
SysAdmin und AppAdmin übergeben user_id=None und sehen alle Notizen.
"""
query = CallLog.query
if projekt_id is not None:
query = query.filter(CallLog.projekt_id == projekt_id)
if user_id is not None:
# Nur Notizen von Projekten, denen der Nutzer zugeordnet ist.
query = query.filter(
CallLog.projekt_id.in_(
dbapp.session.query(ProjektUser.projekt_id).filter(
ProjektUser.user_id == user_id
)
)
)
return query.order_by(CallLog.datum.desc()).all()
def get_call_log(call_log_id: int) -> CallLog:
def get_call_log(call_log_id: int, user_id: int | None = None) -> CallLog:
"""
Liefert eine einzelne Gesprächsnotiz anhand ihrer ID.
Wirft ApplicationError (404), falls die Notiz nicht existiert.
Ist user_id gesetzt, wird zusätzlich geprüft, ob der Nutzer dem
Projekt der Notiz zugeordnet ist; sonst wird 404 geliefert.
"""
call_log = CallLog.query.get(call_log_id)
if call_log is None:
@@ -42,6 +58,16 @@ def get_call_log(call_log_id: int) -> CallLog:
"Gesprächsnotiz mit der ID {} wurde nicht gefunden.".format(call_log_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
if user_id is not None:
zuordnung = ProjektUser.query.filter(
ProjektUser.projekt_id == call_log.projekt_id,
ProjektUser.user_id == user_id,
).first()
if zuordnung is None:
raise ApplicationError(
"Gesprächsnotiz mit der ID {} wurde nicht gefunden.".format(call_log_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
return call_log
+12
View File
@@ -200,6 +200,18 @@ def _db_V01_08():
session.commit()
def _db_V01_09():
"""
Legt die Tabelle 'projekt_users' an. Keine Initial-Befüllung —
bestehende Projekte bleiben ohne Zuordnungen, bis der AppAdmin
diese manuell vergibt. SysAdmin/AppAdmin sehen alle Projekte ohnehin.
create_all erzeugt nur fehlende Tabellen, bestehende bleiben unangetastet.
"""
dbapp.create_all(bind_key=BINDKEY)
_updatesysteminfo('01.09')
session.commit()
def checkupdates():
"""
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
+29 -3
View File
@@ -10,31 +10,47 @@ dort über den Decorator authorisation_required.
:version: 1.0
"""
from db import dbapp
from crm.model import Kontaktadresse, Projekt
from crm.model import Kontaktadresse, Projekt, ProjektUser
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
def get_kontaktadressen(projekt_id: int | None = None):
def get_kontaktadressen(projekt_id: int | None = None, user_id: int | None = None):
"""
Liefert Kontaktadressen, sortiert nach Name.
Ist projekt_id gesetzt, werden nur die Kontaktadressen dieses
Projekts zurückgegeben (serverseitige Filterung). Ohne projekt_id
werden alle Kontaktadressen geliefert.
Ist user_id gesetzt, werden nur Kontaktadressen von Projekten geliefert,
denen der Nutzer über projekt_users zugeordnet ist (Sichtbarkeits-
Filterung). SysAdmin und AppAdmin übergeben user_id=None und sehen alle.
"""
query = Kontaktadresse.query
if projekt_id is not None:
query = query.filter(Kontaktadresse.projekt_id == projekt_id)
if user_id is not None:
# Nur Kontaktadressen von Projekten, denen der Nutzer zugeordnet ist.
query = query.filter(
Kontaktadresse.projekt_id.in_(
dbapp.session.query(ProjektUser.projekt_id).filter(
ProjektUser.user_id == user_id
)
)
)
return query.order_by(Kontaktadresse.name).all()
def get_kontaktadresse(kontaktadresse_id: int) -> Kontaktadresse:
def get_kontaktadresse(kontaktadresse_id: int, user_id: int | None = None) -> Kontaktadresse:
"""
Liefert eine einzelne Kontaktadresse anhand ihrer ID.
Wirft ApplicationError (404), falls die Kontaktadresse nicht existiert.
Ist user_id gesetzt, wird zusätzlich geprüft, ob der Nutzer dem
Projekt der Kontaktadresse zugeordnet ist; sonst wird 404 geliefert.
"""
kontaktadresse = Kontaktadresse.query.get(kontaktadresse_id)
if kontaktadresse is None:
@@ -42,6 +58,16 @@ def get_kontaktadresse(kontaktadresse_id: int) -> Kontaktadresse:
"Kontaktadresse mit der ID {} wurde nicht gefunden.".format(kontaktadresse_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
if user_id is not None:
zuordnung = ProjektUser.query.filter(
ProjektUser.projekt_id == kontaktadresse.projekt_id,
ProjektUser.user_id == user_id,
).first()
if zuordnung is None:
raise ApplicationError(
"Kontaktadresse mit der ID {} wurde nicht gefunden.".format(kontaktadresse_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
return kontaktadresse
+47 -3
View File
@@ -10,31 +10,54 @@ dort über den Decorator authorisation_required.
:version: 1.0
"""
from db import dbapp
from crm.model import Projekt, Kunde
from crm.model import Projekt, Kunde, ProjektUser
from auth.model.errors import ApplicationError, HtmlStatusCode
from flask_login import current_user
current_user: object
session = dbapp.session
def get_projekte(kunden_id: int | None = None):
def get_projekte(kunden_id: int | None = None, user_id: int | None = None):
"""
Liefert Projekte, sortiert nach projektname.
Ist kunden_id gesetzt, werden nur die Projekte dieses Kunden
zurückgegeben (serverseitige Filterung). Ohne kunden_id werden
alle Projekte geliefert.
Ist user_id gesetzt, werden nur Projekte geliefert, denen der
Nutzer über die Tabelle 'projekt_users' zugeordnet ist. Dies
steuert die Sichtbarkeit: Nicht zugeordnete Nutzer sehen diese
Projekte nicht. SysAdmin und AppAdmin übergeben user_id=None
und sehen alle Projekte.
"""
query = Projekt.query
if kunden_id is not None:
query = query.filter(Projekt.kunden_id == kunden_id)
if user_id is not None:
# Nur Projekte, denen der Nutzer zugeordnet ist. Der Subquery
# auf projekt_users filtert die sichtbaren Projekt-IDs.
query = query.filter(
Projekt.id.in_(
dbapp.session.query(ProjektUser.projekt_id).filter(
ProjektUser.user_id == user_id
)
)
)
return query.order_by(Projekt.projektname).all()
def get_projekt(projekt_id: int) -> Projekt:
def get_projekt(projekt_id: int, user_id: int | None = None) -> Projekt:
"""
Liefert ein einzelnes Projekt anhand seiner ID.
Wirft ApplicationError (404), falls das Projekt nicht existiert.
Ist user_id gesetzt, wird zusätzlich geprüft, ob der Nutzer dem
Projekt zugeordnet ist. Ist er nicht zugeordnet, wird ein 404
geliefert, damit nicht zugeordnete Nutzer keine Rückschlüsse auf
die Existenz des Projekts ziehen können.
"""
projekt = Projekt.query.get(projekt_id)
if projekt is None:
@@ -42,6 +65,19 @@ def get_projekt(projekt_id: int) -> Projekt:
"Projekt mit der ID {} wurde nicht gefunden.".format(projekt_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
if user_id is not None:
# Sichtbarkeitsprüfung: Ist der Nutzer dem Projekt zugeordnet?
zuordnung = ProjektUser.query.filter(
ProjektUser.projekt_id == projekt_id,
ProjektUser.user_id == user_id,
).first()
if zuordnung is None:
# 404 statt 403, damit nicht zugeordnete Nutzer keine Info über
# die Existenz des Projekts erhalten.
raise ApplicationError(
"Projekt mit der ID {} wurde nicht gefunden.".format(projekt_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
return projekt
@@ -78,6 +114,14 @@ def create_projekt(data: dict) -> Projekt:
projekt.startdatum = (data.get("startdatum") or "").strip() or None
session.add(projekt)
session.commit()
# Auto-Assignment: Den Ersteller dem neuen Projekt zuordnen, damit
# er es sehen kann. Nur für authentifizierte Nutzer mit einer id.
if hasattr(current_user, "id") and current_user.id is not None:
zuordnung = ProjektUser()
zuordnung.projekt_id = projekt.id
zuordnung.user_id = current_user.id
session.add(zuordnung)
session.commit()
return projekt
+167
View File
@@ -0,0 +1,167 @@
"""
Geschäftslogik für die Projekt-User-Zuordnung (Sichtbarkeit von Projekten).
Diese Funktionen kapseln den DB-Zugriff auf die Tabelle 'projekt_users' und
die Auflösung der user_id gegen die Auth-Datenbank (username, email etc.).
Da CRM- und Auth-Datenbank getrennte SQLite-Dateien sind, erfolgt die
Nutzer-Auflösung durch eine separate Query, nicht durch einen SQL-Join.
Aufgerufen werden die Funktionen aus crm/routes.py; die Autorisierungsprüfung
erfolgt dort über den Decorator authorisation_required.
:author: Mistral Vibe <vibe@mistral.ai>
:date: 2026-09-05
:version: 1.0
"""
from db import dbapp
from crm.model import ProjektUser, Projekt
from auth.model import User
from auth.model.errors import ApplicationError, HtmlStatusCode
session = dbapp.session
def get_projekt_users(projekt_id: int):
"""
Liefert alle einem Projekt zugeordneten Nutzer als Liste von Dictionaries.
Jedes Dictionary enthält die Felder der ProjektUser-Zuordnung (id,
projekt_id, user_id) sowie die aus der Auth-Datenbank aufgelösten
Nutzerdaten (userid, username, email).
- projekt_id: int
- Die ID des Projekts, dessen Zuordnungen geliefert werden sollen.
"""
zuordnungen = ProjektUser.query.filter(
ProjektUser.projekt_id == projekt_id
).all()
if not zuordnungen:
return []
# Alle user_ids sammeln und in einer einzigen Query gegen die Auth-DB
# auflösen (Batch-Query statt N+1).
user_ids = [z.user_id for z in zuordnungen]
users = User.query.filter(User.id.in_(user_ids)).all()
user_map = {u.id: u for u in users}
result = []
for z in zuordnungen:
eintrag = z.to_dict()
user = user_map.get(z.user_id)
if user is not None:
eintrag['userid'] = user.userid
eintrag['username'] = user.username
eintrag['email'] = user.email or ""
else:
# Nutzer wurde aus der Auth-DB gelöscht, die Zuordnung existiert
# noch. Die Felder werden leer geliefert.
eintrag['userid'] = ""
eintrag['username'] = ""
eintrag['email'] = ""
result.append(eintrag)
return result
def add_projekt_user(projekt_id: int, user_id: int) -> ProjektUser:
"""
Ordnet einen Nutzer einem Projekt zu. Das UniqueConstraint verhindert
Duplikate. Wirft ApplicationError (404), falls das Projekt nicht
existiert, oder (400), falls die Zuordnung bereits besteht oder die
Nutzer-ID ungültig ist.
- projekt_id: int
- Die ID des Projekts.
- user_id: int
- Die ID (nicht userid) des Nutzers aus der Auth-Datenbank.
"""
projekt = Projekt.query.get(projekt_id)
if projekt is None:
raise ApplicationError(
"Das Projekt mit der ID {} wurde nicht gefunden.".format(projekt_id),
htmlstatus=HtmlStatusCode.Not_Found,
)
if user_id is None or int(user_id) <= 0:
raise ApplicationError(
"Es muss ein gültiger Nutzer ausgewählt werden.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
# Prüfen, ob der Nutzer in der Auth-Datenbank existiert.
user = User.query.get(int(user_id))
if user is None:
raise ApplicationError(
"Der ausgewählte Nutzer existiert nicht.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
# Duplikat-Prüfung (zusätzlich zum UniqueConstraint, um eine
# aussagekräftige Fehlermeldung zu liefern).
existing = ProjektUser.query.filter(
ProjektUser.projekt_id == projekt_id,
ProjektUser.user_id == int(user_id),
).first()
if existing is not None:
raise ApplicationError(
"Der Nutzer ist diesem Projekt bereits zugeordnet.",
htmlstatus=HtmlStatusCode.Bad_Request,
)
zuordnung = ProjektUser()
zuordnung.projekt_id = projekt_id
zuordnung.user_id = int(user_id)
session.add(zuordnung)
session.commit()
return zuordnung
def remove_projekt_user(projekt_id: int, user_id: int):
"""
Entfernt die Zuordnung eines Nutzers zu einem Projekt.
Wirft ApplicationError (404), falls die Zuordnung nicht existiert.
- projekt_id: int
- Die ID des Projekts.
- user_id: int
- Die ID (nicht userid) des Nutzers aus der Auth-Datenbank.
"""
zuordnung = ProjektUser.query.filter(
ProjektUser.projekt_id == projekt_id,
ProjektUser.user_id == int(user_id),
).first()
if zuordnung is None:
raise ApplicationError(
"Die Zuordnung existiert nicht.",
htmlstatus=HtmlStatusCode.Not_Found,
)
session.delete(zuordnung)
session.commit()
def get_projekt_ids_for_user(user_id: int):
"""
Liefert alle Projekt-IDs, denen der Nutzer zugeordnet ist.
Wird für die serverseitige Sichtbarkeits-Filterung in get_projekte
und get_projekt verwendet.
- user_id: int
- Die ID (nicht userid) des Nutzers aus der Auth-Datenbank.
"""
zuordnungen = ProjektUser.query.filter(
ProjektUser.user_id == user_id
).all()
return [z.projekt_id for z in zuordnungen]
def is_user_assigned(projekt_id: int, user_id: int) -> bool:
"""
Prüft, ob ein Nutzer einem Projekt zugeordnet ist.
- projekt_id: int
- Die ID des Projekts.
- user_id: int
- Die ID (nicht userid) des Nutzers aus der Auth-Datenbank.
"""
zuordnung = ProjektUser.query.filter(
ProjektUser.projekt_id == projekt_id,
ProjektUser.user_id == user_id,
).first()
return zuordnung is not None