diff --git a/AGENTS.md b/AGENTS.md index 404ac53..29a37de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,5 +55,20 @@ Nach jeder Änderung an `.ts`-Dateien unter `Client/` ist der Build-Prozess (tsc ## Vorgaben für die Server-seitige Entwicklung unter Python +### Projektbezogene Sichtbarkeit und Schreibschutz +Projekte sind nutzerbezogen: Ein Nutzer sieht nur Projekte, denen er über die Tabelle `projekt_users` zugeordnet ist. Diese Sichtbarkeits-Filterung muss für **jede** projektbezogene Entität (Projekt selbst und alle davon abhängigen Entitäten wie Kontaktadresse, CallLog) in den Lese- und Schreib-Endpunkten umgesetzt werden. + +Die Prüfung erfolgt rollenbasiert, nicht flagbasiert: SysAdmin und AppAdmin (geprüft via `current_user.is_SysAdmin()` bzw. `current_user.has_permission(app.config["APPID"], [APP_ADMIN])`) sehen alle Projekte. Alle anderen Nutzer werden über ihre `current_user.id` gefiltert. + +Dafür werden in `crm/routes.py` zwei Hilfsfunktionen bereitgestellt: +- `_get_visible_user_id()`: Liefert `None` für SysAdmin/AppAdmin (kein Filter), sonst `current_user.id`. Wird an die Modell-Funktionen der Lese-Endpunkte (list/get) übergeben. +- `_require_projekt_access(projekt_id)`: Prüft vor Schreiboperationen (create/update/delete), ob der Nutzer dem Projekt zugeordnet ist. Bei fehlender Zuordnung wird 404 (nicht 403) geworfen, um keine Rückschlüsse auf die Existenz des Projekts zu ermöglichen. + +Bei neuen projektbezogenen Entitäten muss: +1. Die Modell-Funktion `get_xxx(...)` einen optionalen `user_id`-Parameter erhalten. Ist dieser gesetzt, wird per Subquery auf `projekt_users` gefiltert. +2. Die Modell-Funktion `get_xxx_by_id(id, user_id)` bei gesetztem `user_id` die Projekt-Zuordnung prüfen und bei Fehlen 404 werfen. +3. Die Lese-Endpunkte in `crm/routes.py` `_get_visible_user_id()` aufrufen und das Ergebnis an die Modell-Funktion übergeben. +4. Die Schreib-Endpunkte `_require_projekt_access(projekt_id)` aufrufen, bevor die Änderung durchgeführt wird. Bei create/update steht `projekt_id` im JSON-Body (`request.json["projekt_id"]` bzw. `request.json["id"]`); bei delete muss die Entität ggf. erst mit Sichtbarkeits-Check geladen werden, um die `projekt_id` zu ermitteln. + ## Generierte Dateien nicht beachten Das Verzeichnis `Client/tsout/` enthält die JavaScript-Ausgabe der TypeScript-Kompilation und wird durch den Build-Prozess (tsc + rollup) erzeugt. Diese Dateien sind nicht manuell zu bearbeiten und brauchen bei der Codeanalyse nicht berücksichtigt zu werden. Ebenso sind die JavaScript-Dateien in `NGINX_Server/html/static/` (z.B. `kbp_crm_00.00.01.js`) die Ausgabe des Rollup-Builds und nicht zu untersuchen. diff --git a/App_Server/root/crm/model/__init__.py b/App_Server/root/crm/model/__init__.py index e4bb386..5f2216b 100644 --- a/App_Server/root/crm/model/__init__.py +++ b/App_Server/root/crm/model/__init__.py @@ -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 diff --git a/App_Server/root/crm/model/call_log.py b/App_Server/root/crm/model/call_log.py index f60280f..a21037c 100644 --- a/App_Server/root/crm/model/call_log.py +++ b/App_Server/root/crm/model/call_log.py @@ -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 diff --git a/App_Server/root/crm/model/checkdb.py b/App_Server/root/crm/model/checkdb.py index 05f24df..86d0648 100644 --- a/App_Server/root/crm/model/checkdb.py +++ b/App_Server/root/crm/model/checkdb.py @@ -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 diff --git a/App_Server/root/crm/model/kontaktadresse.py b/App_Server/root/crm/model/kontaktadresse.py index eb520c8..b2b1e8e 100644 --- a/App_Server/root/crm/model/kontaktadresse.py +++ b/App_Server/root/crm/model/kontaktadresse.py @@ -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 diff --git a/App_Server/root/crm/model/projekt.py b/App_Server/root/crm/model/projekt.py index 0f4ad87..3f8d25e 100644 --- a/App_Server/root/crm/model/projekt.py +++ b/App_Server/root/crm/model/projekt.py @@ -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 diff --git a/App_Server/root/crm/model/projekt_user.py b/App_Server/root/crm/model/projekt_user.py new file mode 100644 index 0000000..1608181 --- /dev/null +++ b/App_Server/root/crm/model/projekt_user.py @@ -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 +: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 diff --git a/App_Server/root/crm/routes.py b/App_Server/root/crm/routes.py index fc623e5..e950259 100644 --- a/App_Server/root/crm/routes.py +++ b/App_Server/root/crm/routes.py @@ -15,18 +15,65 @@ KundenManager werden aus crm.model importiert. """ from flask import current_app as app from flask import jsonify, request +from flask_login import current_user from auth.decorators import authorisation_required +from auth.model import APP_ADMIN +from auth.model.errors import ApplicationError, HtmlStatusCode from crm.model import KUNDEN_MANAGER, MITARBEITER from crm.model import kunde as kunden from crm.model import kontakt as kontakte from crm.model import projekt as projekte +from crm.model import projekt_user as projekt_users from crm.model import kontaktadresse as kontaktadressen from crm.model import call_log as call_logs print("crm-routes") +def _get_visible_user_id(): + """ + Liefert die user_id für die Sichtbarkeits-Filterung. + + SysAdmin und AppAdmin sehen alle Projekte und abhängige Entitäten — + für sie wird None zurückgegeben, sodass die Modell-Funktionen keinen + Filter anwenden. Alle anderen Nutzer (Mitarbeiter, KundenManager) + erhalten ihre current_user.id, sodass nur die ihnen zugeordneten + Projekte bzw. abhängige Einträge geliefert werden. + """ + if current_user.is_SysAdmin() or current_user.has_permission( + app.config["APPID"], [APP_ADMIN] + ): + return None + return current_user.id + + +def _require_projekt_access(projekt_id: int): + """ + Prüft, ob der aktuelle Nutzer für das angegebene Projekt schreibberechtigt ist. + + SysAdmin und AppAdmin haben immer Zugriff. Alle anderen Nutzer müssen + dem Projekt über projekt_users zugeordnet sein. Ist der Nutzer nicht + zugeordnet, wird ein ApplicationError (404) geworfen — 404 statt 403, + damit nicht zugeordnete Nutzer keine Rückschlüsse auf die Existenz + des Projekts ziehen können. + + Muss in allen Schreib-Endpunkten (create/update/delete) aufgerufen + werden, die ein konkretes Projekt betreffen, bevor die Änderung + durchgeführt wird. + + - projekt_id: int + - Die ID des Projekts, auf das zugegriffen werden soll. + """ + user_id = _get_visible_user_id() + if user_id is not None: + if not projekt_users.is_user_assigned(projekt_id, user_id): + raise ApplicationError( + "Projekt mit der ID {} wurde nicht gefunden.".format(projekt_id), + htmlstatus=HtmlStatusCode.Not_Found, + ) + + # --------------------------------------------------------------------------- # # Kunde # # --------------------------------------------------------------------------- # @@ -126,17 +173,23 @@ def kontakt_delete(): @app.get("/xhr/projekt/list") @authorisation_required([MITARBEITER, KUNDEN_MANAGER]) def projekt_list(): - """Liefert Projekte als Liste. Optional nach kunden_id gefiltert.""" + """Liefert Projekte als Liste. Optional nach kunden_id gefiltert. + Für Nicht-SysAdmin/Nicht-AppAdmin werden nur die Projekte geliefert, + denen der Nutzer über projekt_users zugeordnet ist.""" kunden_id = request.args.get("kunden_id", type=int) - result = [p.to_dict() for p in projekte.get_projekte(kunden_id)] + user_id = _get_visible_user_id() + result = [p.to_dict() for p in projekte.get_projekte(kunden_id, user_id)] return jsonify(result), 200 @app.get("/xhr/projekt/get") @authorisation_required([MITARBEITER, KUNDEN_MANAGER]) def projekt_get(): - """Liefert ein einzelnes Projekt anhand seiner ID (Query-Param id).""" - projekt = projekte.get_projekt(int(request.args.get("id"))) + """Liefert ein einzelnes Projekt anhand seiner ID (Query-Param id). + Für Nicht-SysAdmin/Nicht-AppAdmin wird geprüft, ob der Nutzer dem + Projekt zugeordnet ist; sonst wird 404 geliefert.""" + user_id = _get_visible_user_id() + projekt = projekte.get_projekt(int(request.args.get("id")), user_id) return jsonify(projekt.to_dict()), 200 @@ -152,6 +205,7 @@ def projekt_create(): @authorisation_required([KUNDEN_MANAGER]) def projekt_update(): """Aktualisiert ein bestehendes Projekt.""" + _require_projekt_access(request.json["id"]) projekt = projekte.update_projekt(request.json) return jsonify(projekt.to_dict()), 200 @@ -160,10 +214,49 @@ def projekt_update(): @authorisation_required([KUNDEN_MANAGER]) def projekt_delete(): """Löscht ein Projekt anhand seiner ID (Query-Param id).""" + _require_projekt_access(int(request.args.get("id"))) projekte.delete_projekt(int(request.args.get("id"))) return jsonify({}), 200 +# --------------------------------------------------------------------------- # +# ProjektUser (Nutzer-Zuordnung / Sichtbarkeit) # +# --------------------------------------------------------------------------- # + + +@app.get("/xhr/projektuser/list") +@authorisation_required([MITARBEITER, KUNDEN_MANAGER]) +def projektuser_list(): + """Liefert alle einem Projekt zugeordneten Nutzer (Query-Param projekt_id).""" + projekt_id = request.args.get("projekt_id", type=int) + result = projekt_users.get_projekt_users(projekt_id) + return jsonify(result), 200 + + +@app.post("/xhr/projektuser/add") +# Leeres allowed_roles-Array als Erweiterungspunkt: Aktuell passieren nur +# AppAdmin und SysAdmin (vom Decorator implizit ergänzt). Sobald später eine +# weitere Rolle diese Endpunkte nutzen soll, wird sie einfach hier eingetragen, +# z.B. @authorisation_required([KUNDEN_MANAGER]). +@authorisation_required([]) +def projektuser_add(): + """Ordnet einen Nutzer einem Projekt zu (body: {projekt_id, user_id}).""" + data = request.json + projekt_users.add_projekt_user(data["projekt_id"], data["user_id"]) + return jsonify({}), 200 + + +@app.delete("/xhr/projektuser/remove") +# Leeres allowed_roles-Array als Erweiterungspunkt (siehe projektuser_add). +@authorisation_required([]) +def projektuser_remove(): + """Entfernt die Zuordnung eines Nutzers zu einem Projekt (params: projekt_id, user_id).""" + projekt_id = request.args.get("projekt_id", type=int) + user_id = request.args.get("user_id", type=int) + projekt_users.remove_projekt_user(projekt_id, user_id) + return jsonify({}), 200 + + # --------------------------------------------------------------------------- # # Kontaktadresse # # --------------------------------------------------------------------------- # @@ -172,17 +265,22 @@ def projekt_delete(): @app.get("/xhr/kontaktadresse/list") @authorisation_required([MITARBEITER, KUNDEN_MANAGER]) def kontaktadresse_list(): - """Liefert Kontaktadressen als Liste. Optional nach projekt_id gefiltert.""" + """Liefert Kontaktadressen als Liste. Optional nach projekt_id gefiltert. + Für Nicht-SysAdmin/Nicht-AppAdmin nur Kontaktadressen sichtbarer Projekte.""" projekt_id = request.args.get("projekt_id", type=int) - result = [k.to_dict() for k in kontaktadressen.get_kontaktadressen(projekt_id)] + user_id = _get_visible_user_id() + result = [k.to_dict() for k in kontaktadressen.get_kontaktadressen(projekt_id, user_id)] return jsonify(result), 200 @app.get("/xhr/kontaktadresse/get") @authorisation_required([MITARBEITER, KUNDEN_MANAGER]) def kontaktadresse_get(): - """Liefert eine einzelne Kontaktadresse anhand ihrer ID (Query-Param id).""" - kontaktadresse = kontaktadressen.get_kontaktadresse(int(request.args.get("id"))) + """Liefert eine einzelne Kontaktadresse anhand ihrer ID (Query-Param id). + Für Nicht-SysAdmin/Nicht-AppAdmin wird geprüft, ob der Nutzer dem + Projekt der Kontaktadresse zugeordnet ist; sonst 404.""" + user_id = _get_visible_user_id() + kontaktadresse = kontaktadressen.get_kontaktadresse(int(request.args.get("id")), user_id) return jsonify(kontaktadresse.to_dict()), 200 @@ -190,6 +288,7 @@ def kontaktadresse_get(): @authorisation_required([KUNDEN_MANAGER]) def kontaktadresse_create(): """Legt eine neue Kontaktadresse an. ID wird serverseitig vergeben.""" + _require_projekt_access(int(request.json["projekt_id"])) kontaktadresse = kontaktadressen.create_kontaktadresse(request.json) return jsonify(kontaktadresse.to_dict()), 200 @@ -198,6 +297,7 @@ def kontaktadresse_create(): @authorisation_required([KUNDEN_MANAGER]) def kontaktadresse_update(): """Aktualisiert eine bestehende Kontaktadresse.""" + _require_projekt_access(int(request.json["projekt_id"])) kontaktadresse = kontaktadressen.update_kontaktadresse(request.json) return jsonify(kontaktadresse.to_dict()), 200 @@ -206,6 +306,10 @@ def kontaktadresse_update(): @authorisation_required([KUNDEN_MANAGER]) def kontaktadresse_delete(): """Löscht eine Kontaktadresse anhand ihrer ID (Query-Param id).""" + # projekt_id der Kontaktadresse ermitteln, um die Projekt-Zuordnung + # des Nutzers zu prüfen, bevor gelöscht wird. + user_id = _get_visible_user_id() + kontaktadresse = kontaktadressen.get_kontaktadresse(int(request.args.get("id")), user_id) kontaktadressen.delete_kontaktadresse(int(request.args.get("id"))) return jsonify({}), 200 @@ -218,17 +322,22 @@ def kontaktadresse_delete(): @app.get("/xhr/calllog/list") @authorisation_required([MITARBEITER, KUNDEN_MANAGER]) def calllog_list(): - """Liefert Gesprächsnotizen als Liste. Optional nach projekt_id gefiltert.""" + """Liefert Gesprächsnotizen als Liste. Optional nach projekt_id gefiltert. + Für Nicht-SysAdmin/Nicht-AppAdmin nur Notizen sichtbarer Projekte.""" projekt_id = request.args.get("projekt_id", type=int) - result = [l.to_dict() for l in call_logs.get_call_logs(projekt_id)] + user_id = _get_visible_user_id() + result = [l.to_dict() for l in call_logs.get_call_logs(projekt_id, user_id)] return jsonify(result), 200 @app.get("/xhr/calllog/get") @authorisation_required([MITARBEITER, KUNDEN_MANAGER]) def calllog_get(): - """Liefert eine einzelne Gesprächsnotiz anhand ihrer ID (Query-Param id).""" - call_log = call_logs.get_call_log(int(request.args.get("id"))) + """Liefert eine einzelne Gesprächsnotiz anhand ihrer ID (Query-Param id). + Für Nicht-SysAdmin/Nicht-AppAdmin wird geprüft, ob der Nutzer dem + Projekt der Notiz zugeordnet ist; sonst 404.""" + user_id = _get_visible_user_id() + call_log = call_logs.get_call_log(int(request.args.get("id")), user_id) return jsonify(call_log.to_dict()), 200 @@ -236,6 +345,7 @@ def calllog_get(): @authorisation_required([KUNDEN_MANAGER]) def calllog_create(): """Legt eine neue Gesprächsnotiz an. ID wird serverseitig vergeben.""" + _require_projekt_access(int(request.json["projekt_id"])) call_log = call_logs.create_call_log(request.json) return jsonify(call_log.to_dict()), 200 @@ -244,6 +354,7 @@ def calllog_create(): @authorisation_required([KUNDEN_MANAGER]) def calllog_update(): """Aktualisiert eine bestehende Gesprächsnotiz.""" + _require_projekt_access(int(request.json["projekt_id"])) call_log = call_logs.update_call_log(request.json) return jsonify(call_log.to_dict()), 200 @@ -252,6 +363,10 @@ def calllog_update(): @authorisation_required([KUNDEN_MANAGER]) def calllog_delete(): """Löscht eine Gesprächsnotiz anhand ihrer ID (Query-Param id).""" + # projekt_id der Notiz ermitteln, um die Projekt-Zuordnung + # des Nutzers zu prüfen, bevor gelöscht wird. + user_id = _get_visible_user_id() + call_log = call_logs.get_call_log(int(request.args.get("id")), user_id) call_logs.delete_call_log(int(request.args.get("id"))) return jsonify({}), 200 diff --git a/Client/Components/projekt.ts b/Client/Components/projekt.ts index fac0eea..0ff49a6 100644 --- a/Client/Components/projekt.ts +++ b/Client/Components/projekt.ts @@ -208,9 +208,15 @@ export const Projekt: m.ClosureComponent = (vnode) => { ? "Neues Projekt für " + currentProjekt.kunden_name : "Projekt-Stammblatt"), currentProjekt.id !== -1 - ? m("button.w3-button.w3-theme", - {onclick: () => m.route.set("/kontaktadresseliste", {projekt_id: currentProjekt.id})}, - m("i.material-icons", "contact_page"), " Kontaktadressen") + ? [ + m("button.w3-button.w3-theme", + {onclick: () => m.route.set("/kontaktadresseliste", {projekt_id: currentProjekt.id})}, + m("i.material-icons", "contact_page"), " Kontaktadressen"), + m("button.w3-button.w3-theme", + {style: {'margin-left': '4px'}, + onclick: () => m.route.set("/projektuserliste", {projekt_id: currentProjekt.id})}, + m("i.material-icons", "group"), " Nutzerberechtigungen") + ] : null ), m("div", {style: {flex: '1', 'min-height': '0'}}, diff --git a/Client/Components/projekt_user.ts b/Client/Components/projekt_user.ts new file mode 100644 index 0000000..cb525de --- /dev/null +++ b/Client/Components/projekt_user.ts @@ -0,0 +1,223 @@ +import m from "mithril" +import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton} from "./widgets/Treetable_V2" +import {Select, iSelect, iSelectOption} from "./widgets/select" +import {ModalView, ModalType, iModalView} from "./widgets/Modal" +import {gridMountpoints} from "./mainlayout" +import {iProjektUser, fetchProjektUsers, addProjektUserREST, removeProjektUserREST} from "../models/projekt_user" +import {iProjekt, fetchProjektById} from "../models/projekt" +import {iUser} from "./auth/models/users" +import {showServerError} from "./auth/authmessages" + +/** + * Liste der einem Projekt zugeordneten Nutzer (Sichtbarkeit). + * + * Der Liste wird die projekt_id über die Route-Params übergeben. Sie zeigt + * alle Nutzer, die das Projekt sehen dürfen. Der AppAdmin (und SysAdmin) + * kann Nutzer über ein Modal mit Select-Widget hinzufügen und über einen + * Entfernen-Button pro Zeile wieder wegnehmen. + */ +export const projektuserliste: m.ClosureComponent = () => { + let treenodes: iTreenode[] = [] + let selectedNode: iTreenode | null = null + let projektId: number = -1 + let projektName: string = "" + let projektUsers: iProjektUser[] = [] + // Alle Nutzer aus der Auth-DB, für das Hinzufügen-Modal. + let allUsers: iUser[] = [] + + const handleRowMarked = (node: iTreenode) => { + selectedNode = node + } + + /** + * Lädt die zugeordneten Nutzer vom Server und aktualisiert die Treenodes. + */ + const loadProjektUsers = () => { + fetchProjektUsers(projektId).then((result) => { + projektUsers = result + treenodes = result.map((pu: iProjektUser) => new Treenode(pu, true, [])) + }).catch((e) => showServerError(e)) + } + + /** + * Lädt alle Nutzer aus der Auth-DB (für das Hinzufügen-Modal). + */ + const loadAllUsers = () => { + m.request({ + method: "GET", + url: "/auth/getusers" + }).then((result) => { + allUsers = result as iUser[] + }).catch((e) => showServerError(e)) + } + + /** + * Entfernt einen Nutzer aus dem Projekt. Wird beim Klick auf den + * Entfernen-Button in der Zeile aufgerufen. + */ + const removeUser = (projektUser: iProjektUser, e: Event) => { + e.stopPropagation() + removeProjektUserREST(projektId, projektUser.user_id).then(() => { + loadProjektUsers() + }).catch((e) => showServerError(e)) + } + + const columnmapper: (node: iTreenode) => any[] = (node: iTreenode) => { + let pu: iProjektUser = node.nodevalue + return [ + m("div", m("strong", pu.userid)), + m("span", pu.username), + m("span", pu.email), + m("div", {style: {display: 'flex', gap: '0', 'white-space': 'nowrap'}}, + m("button.w3-button.w3-small.w3-red", + { + title: "Nutzer entfernen", + style: {padding: '0 4px'}, + onclick: (e: Event) => removeUser(pu, e) + }, + m("i.material-icons", "person_remove")) + ) + ] + } + + const filterFunction = (node: iTreenode, searchvalue: string): boolean => { + let pu: iProjektUser = node.nodevalue + let searchLower = searchvalue.toLowerCase() + return (pu.userid || "").toLowerCase().includes(searchLower) || + (pu.username || "").toLowerCase().includes(searchLower) || + (pu.email || "").toLowerCase().includes(searchLower) + } + + /** + * Öffnet das Modal zum Hinzufügen eines Nutzers. Die verfügbaren + * Nutzer werden clientseitig gegen die bereits zugeordneten gefiltert. + */ + const openAddModal = () => { + // Alle Nutzer laden, falls noch nicht geschehen. + if (allUsers.length === 0) { + loadAllUsers() + } + m.mount(gridMountpoints.modalhook, addUserModal) + } + + /** + * Modal-Komponente zur Auswahl eines Nutzers, der dem Projekt + * zugeordnet werden soll. Verwendet das Select-Widget für die + * Suche/Auswahl. Bereits zugeordnete Nutzer werden ausgeblendet. + */ + const addUserModal: m.ClosureComponent = () => { + let selectedUserId: number = -1 + + // Verfügbare Nutzer: alle, die noch nicht zugeordnet sind. + const getAvailableOptions = (): iSelectOption[] => { + const assignedIds = new Set(projektUsers.map((pu) => pu.user_id)) + return allUsers + .filter((u: iUser) => !assignedIds.has(u.id)) + .map((u: iUser) => ({ + value: u.id, + label: `${u.userid} (${u.username})` + } as iSelectOption)) + } + + const closeModal = () => { + m.mount(gridMountpoints.modalhook, null) + } + + const confirmAdd = () => { + if (selectedUserId > 0) { + addProjektUserREST(projektId, selectedUserId).then(() => { + closeModal() + loadProjektUsers() + }).catch((e) => { + showServerError(e) + closeModal() + }) + } + } + + return { + view: (vnode) => { + const options = getAvailableOptions() + return m(ModalView, + { + title: "Nutzer zuordnen", + type: ModalType.MESSAGE, + width: '500px' + } as iModalView, + m("div.w3-padding-16", + m("p", "Wählen Sie einen Nutzer aus, der dem Projekt \"" + projektName + "\" zugeordnet werden soll:"), + m("label", "Nutzer"), + m(Select, { + name: "user_id", + value: selectedUserId, + options: options, + required: true, + onchanged: (value: any) => { + selectedUserId = value + } + } as iSelect), + m("div.w3-bar.w3-padding-16", + m("button.w3-button.w3-theme.w3-right", + {style: {'margin-left': '4px'}, onclick: confirmAdd}, + "Zuordnen"), + m("button.w3-button.w3-theme.w3-right", + {style: {'margin-left': '4px'}, onclick: closeModal}, + "Abbrechen") + ) + ) + ) + } + } + } + + return { + oninit: (vnode) => { + const routeParams = m.route.param() + projektId = routeParams && routeParams.projekt_id ? parseInt(routeParams.projekt_id) : -1 + projektName = routeParams && routeParams.projekt_name ? routeParams.projekt_name : "" + // Projektname asynchron auflösen, falls nicht in Route-Params. + if (projektId !== -1 && projektName === "") { + fetchProjektById(projektId).then((projekt: iProjekt) => { + projektName = projekt.projektname + }).catch((e) => showServerError(e)) + } + // Alle Nutzer für das Hinzufügen-Modal vorab laden. + loadAllUsers() + // Zugeordnete Nutzer laden. + loadProjektUsers() + }, + view: (vnode) => { + return m("div.w3-container.w3-auto", + { + style: { + display: 'flex', + 'flex-direction': 'column', + height: 'inherit', + 'overflow-y': 'inherit' + } + }, + m("h1", projektName + ? "Nutzerberechtigungen für Projekt: " + projektName + : "Nutzerberechtigungen"), + m("div", {style: {flex: '1', 'overflow-y': 'inherit'}}, + m(Treetable, + { + treenodes: treenodes, + columnmapper: columnmapper, + filter: filterFunction, + onRowMarked: handleRowMarked, + isSelected: (node: iTreenode) => node === selectedNode + } as iTreetable, + m(TopbarButton, + { + onclick: openAddModal, + tooltip: "Nutzer hinzufügen" + } as iTopbarButton, + m("i.material-icons", "person_add") + ) + ) + ) + ) + } + } +} diff --git a/Client/index.ts b/Client/index.ts index d99df99..0dc110a 100644 --- a/Client/index.ts +++ b/Client/index.ts @@ -5,6 +5,7 @@ import { appmanagementpage } from "./Components/auth/appmanagementpage" import { changepasswordscreen, loginscreen } from "./Components/auth/login" import {kunde, kundenliste} from "./Components/kunde" import { Projekt, projektliste } from "./Components/projekt" +import { projektuserliste } from "./Components/projekt_user" import { Call_Log, call_log_liste } from "./Components/call_log" import { Kontakt, kontaktliste } from "./Components/kontakt" import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse" @@ -152,6 +153,7 @@ m.route(gridMountpoints.main, "/blankpage", { "/kunde": kunde, "/projektliste": projektliste, "/projekt": Projekt, + "/projektuserliste": projektuserliste, "/calllogliste": call_log_liste, "/call_log": Call_Log, "/kontaktliste": kontaktliste, diff --git a/Client/models/projekt_user.ts b/Client/models/projekt_user.ts new file mode 100644 index 0000000..6697189 --- /dev/null +++ b/Client/models/projekt_user.ts @@ -0,0 +1,63 @@ +import m from "mithril" + +/** + * Datenmodell für eine Projekt-User-Zuordnung (Sichtbarkeit). + * + * Die Felder id, projekt_id und user_id sind die ORM-Spalten der + * Verknüpfungstabelle 'projekt_users'. Die Felder userid, username und + * email werden serverseitig aus der Auth-Datenbank aufgelöst und im JSON + * ergänzt. + */ +export interface iProjektUser { + id: number + projekt_id: number + user_id: number + userid: string + username: string + email: string +} + +/* ------------------------------------------------------------------ */ +/* REST-API-Aufrufe (Client-Seite) */ +/* ------------------------------------------------------------------ */ +/* Die folgenden Funktionen kapseln die Kommunikation mit den */ +/* serverseitigen REST-Endpunkten (/xhr/projektuser/*). */ +/* ------------------------------------------------------------------ */ + +/** + * Basis-Pfad der ProjektUser-REST-Endpunkte. + */ +const PROJEKTUSER_REST_BASE = "/xhr/projektuser" + +/** + * Lädt die Liste der einem Projekt zugeordneten Nutzer vom Server. + */ +export function fetchProjektUsers(projektId: number): Promise { + return m.request({ + method: "GET", + url: PROJEKTUSER_REST_BASE + "/list", + params: {projekt_id: projektId} + }).then((result) => result as iProjektUser[]) +} + +/** + * Ordnet einen Nutzer einem Projekt zu. + */ +export function addProjektUserREST(projektId: number, userId: number): Promise { + return m.request({ + method: "POST", + url: PROJEKTUSER_REST_BASE + "/add", + body: {projekt_id: projektId, user_id: userId} + }).then(() => undefined) +} + +/** + * Entfernt die Zuordnung eines Nutzers zu einem Projekt. + */ +export function removeProjektUserREST(projektId: number, userId: number): Promise { + return m.request({ + method: "DELETE", + url: PROJEKTUSER_REST_BASE + "/remove", + params: {projekt_id: projektId, user_id: userId} + }).then(() => undefined) +}