- 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>
168 lines
6.1 KiB
Python
168 lines
6.1 KiB
Python
"""
|
|
Geschäftslogik für die Entität Projekt.
|
|
|
|
Diese Funktionen kapseln den DB-Zugriff und die fachlichen Validierungen.
|
|
Aufgerufen werden sie aus crm/routes.py; die Autorisierungsprüfung erfolgt
|
|
dort über den Decorator authorisation_required.
|
|
|
|
:author: Mistral Vibe <vibe@mistral.ai>
|
|
:date: 2026-09-04
|
|
:version: 1.0
|
|
"""
|
|
from db import dbapp
|
|
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, 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, 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:
|
|
raise ApplicationError(
|
|
"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
|
|
|
|
|
|
def create_projekt(data: dict) -> Projekt:
|
|
"""
|
|
Legt ein neues Projekt an. Die vom Client übergebene ID wird ignoriert;
|
|
die ID wird serverseitig per Auto-Increment vergeben.
|
|
|
|
- data: dict mit den Feldern kunden_id, projektname, zweck,
|
|
verantwortlicher, startdatum
|
|
"""
|
|
kunden_id = data.get("kunden_id")
|
|
if kunden_id is None:
|
|
raise ApplicationError(
|
|
"Es muss ein Kunde ausgewählt werden.",
|
|
htmlstatus=HtmlStatusCode.Bad_Request,
|
|
)
|
|
kunde = Kunde.query.get(int(kunden_id))
|
|
if kunde is None:
|
|
raise ApplicationError(
|
|
"Der ausgewählte Kunde existiert nicht.",
|
|
htmlstatus=HtmlStatusCode.Bad_Request,
|
|
)
|
|
if not data.get("projektname") or not str(data["projektname"]).strip():
|
|
raise ApplicationError(
|
|
"Der Projektname darf nicht leer sein.",
|
|
htmlstatus=HtmlStatusCode.Bad_Request,
|
|
)
|
|
projekt = Projekt()
|
|
projekt.kunden_id = int(kunden_id)
|
|
projekt.projektname = data["projektname"].strip()
|
|
projekt.zweck = (data.get("zweck") or "").strip() or None
|
|
projekt.verantwortlicher = (data.get("verantwortlicher") or "").strip() or None
|
|
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
|
|
|
|
|
|
def update_projekt(data: dict) -> Projekt:
|
|
"""
|
|
Aktualisiert ein bestehendes Projekt. Die ID im data-Dict bestimmt den
|
|
zu ändernden Datensatz.
|
|
"""
|
|
projekt = get_projekt(data["id"])
|
|
kunden_id = data.get("kunden_id")
|
|
if kunden_id is None:
|
|
raise ApplicationError(
|
|
"Es muss ein Kunde ausgewählt werden.",
|
|
htmlstatus=HtmlStatusCode.Bad_Request,
|
|
)
|
|
kunde = Kunde.query.get(int(kunden_id))
|
|
if kunde is None:
|
|
raise ApplicationError(
|
|
"Der ausgewählte Kunde existiert nicht.",
|
|
htmlstatus=HtmlStatusCode.Bad_Request,
|
|
)
|
|
if not data.get("projektname") or not str(data["projektname"]).strip():
|
|
raise ApplicationError(
|
|
"Der Projektname darf nicht leer sein.",
|
|
htmlstatus=HtmlStatusCode.Bad_Request,
|
|
)
|
|
projekt.kunden_id = int(kunden_id)
|
|
projekt.projektname = data["projektname"].strip()
|
|
projekt.zweck = (data.get("zweck") or "").strip() or None
|
|
projekt.verantwortlicher = (data.get("verantwortlicher") or "").strip() or None
|
|
projekt.startdatum = (data.get("startdatum") or "").strip() or None
|
|
session.commit()
|
|
return projekt
|
|
|
|
|
|
def delete_projekt(projekt_id: int):
|
|
"""
|
|
Löscht ein Projekt anhand seiner ID.
|
|
Wirft ApplicationError (404), falls das Projekt nicht existiert.
|
|
"""
|
|
projekt = get_projekt(projekt_id)
|
|
session.delete(projekt)
|
|
session.commit()
|