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
+757 -24

No files matched your search

+15
View File
@@ -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.
+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
+127 -12
View File
@@ -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
+8 -2
View File
@@ -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",
? [
m("button.w3-button.w3-theme",
{onclick: () => m.route.set("/kontaktadresseliste", {projekt_id: currentProjekt.id})},
m("i.material-icons", "contact_page"), " Kontaktadressen")
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'}},
+223
View File
@@ -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")
)
)
)
)
}
}
}
+2
View File
@@ -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,
+63
View File
@@ -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<iProjektUser[]> {
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<void> {
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<void> {
return m.request({
method: "DELETE",
url: PROJEKTUSER_REST_BASE + "/remove",
params: {projekt_id: projektId, user_id: userId}
}).then(() => undefined)
}