Server: CRM-Domain mit REST-API, Autorisierung und CLI
Neues crm-Paket mit Datenmodell, Geschäftslogik und REST-Endpunkten
für die Entität Kunde (/xhr/kunde/{list,get,create,update,delete}).
authorisation_required-Decorator: app-spezifische Rollenprüfung via
has_permission(appid, ...). SysAdmin systemweit, AppAdmin implizit in
seiner Anwendung, User (Mitarbeiter) als Basis-Rolle, Zusatz-Rollen
wie KundenManager additiv.
Flask CLI: init-db (Schema + Musterdaten + App-Rollen) und reset-db
(Bestätigung, behält SysAdmin).
config.ini: Neuer appid-Eintrag (KBPlusCRM), getrennt vom technischen
PROJEKTNAME. checkdb: appid aus config, Migration _db_V01_02 für
bestehende DBs.
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
1 parent
65f81a0ee8
commit
13eaaec9c1
12 files changed
+627
-8
No files matched your search
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
CRM-Domainpaket.
|
||||
|
||||
Enthält die serverseitige Logik und REST-Endpunkte für die CRM-Entitäten
|
||||
(Kunde, Projekt, Kontakt, Kontaktadresse, Gesprächsnotiz). Die Datenmodelle
|
||||
liegen in der separaten SQLite-Datenbank kbp_crm.db (Bind-Key 'appdb').
|
||||
|
||||
:author: Mistral Vibe <vibe@mistral.ai>
|
||||
:date: 2026-09-02
|
||||
:version: 1.0
|
||||
"""
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
SQLAlchemy-Datenmodelle für die CRM-Domain.
|
||||
|
||||
Alle Modelle verwenden den Bind-Key 'appdb' und liegen damit in der separaten
|
||||
SQLite-Datenbank kbp_crm.db. Die Trennung zur Auth-Datenbank (Bind-Key 'auth')
|
||||
erfolgt ausschließlich über __bind_key__.
|
||||
|
||||
:author: Mistral Vibe <vibe@mistral.ai>
|
||||
:date: 2026-09-02
|
||||
:version: 1.0
|
||||
"""
|
||||
import datetime
|
||||
import decimal
|
||||
|
||||
from sqlalchemy import Column, DateTime, Integer, String
|
||||
|
||||
from db import dbapp
|
||||
|
||||
|
||||
# Bind-Key für alle CRM-Modelle (entspricht dem Eintrag in app.config["SQLALCHEMY_BINDS"])
|
||||
BINDKEY = 'appdb'
|
||||
|
||||
# App-Rollen (roleids, konsistent zu den Konstanten in auth.model). Die Rolle
|
||||
# 'AppAdmin' ist bereits in auth.model definiert und wird von den CRM-Routes
|
||||
# implizit über den Decorator berücksichtigt.
|
||||
#
|
||||
# Rollenmodell (additiv): Jeder User erhält zwingend die Basis-Rolle 'User'
|
||||
# (MITARBEITER, entspricht APP_USER in auth.model). Damit passiert er das
|
||||
# Login-Gate in auth/routes.checklogin. Zusätzliche Rollen wie 'KundenManager'
|
||||
# werden ergänzend zugewiesen und gewähren weitergehende Berechtigungen, die
|
||||
# über den Decorator authorisation_required geprüft werden.
|
||||
MITARBEITER = "User"
|
||||
KUNDEN_MANAGER = "KundenManager"
|
||||
|
||||
|
||||
def columns2dict(record, excludefields=[]):
|
||||
"""
|
||||
Wandelt die Spalten eines SQLAlchemy-Records in ein Dictionary um.
|
||||
|
||||
Datums- und Decimal-Werte werden dabei in JSON-taugliche Strings konvertiert.
|
||||
Über excludefields können Spalten ausgeschlossen werden (z.B. Passwort-Hashes).
|
||||
"""
|
||||
if not isinstance(excludefields, list):
|
||||
excludefields = []
|
||||
attribs = dict()
|
||||
if hasattr(record, '__table__'):
|
||||
if hasattr(record.__table__, 'columns'):
|
||||
for col in record.__table__.columns:
|
||||
if col.name in excludefields:
|
||||
continue
|
||||
value = getattr(record, col.name)
|
||||
if isinstance(value, datetime.date):
|
||||
value = value.isoformat()
|
||||
if isinstance(value, decimal.Decimal):
|
||||
value = "{0:.2f}".format(value)
|
||||
attribs[col.name] = value
|
||||
return attribs
|
||||
|
||||
|
||||
class CrmSysteminfo(dbapp.Model):
|
||||
"""
|
||||
Systeminformationstabelle für die CRM-Datenbank.
|
||||
|
||||
Hält die DB-Versionsnummer, mit der checkdb feststellt, welche Migrationen
|
||||
noch ausgeführt werden müssen. Der Klassenname unterscheidet sich bewusst
|
||||
von auth.model.Systeminfo, da beide Klassen dieselbe SQLAlchemy-Registry
|
||||
teilen und sich daher nicht namentlich doppeln dürfen.
|
||||
"""
|
||||
__tablename__ = 'systeminfo'
|
||||
__bind_key__ = BINDKEY
|
||||
id = Column(Integer, primary_key=True)
|
||||
version = Column(String(20), nullable=False)
|
||||
lastupdate = Column(DateTime, nullable=True)
|
||||
initialinstall = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class Kunde(dbapp.Model):
|
||||
"""
|
||||
Kundenstamm: Verwaltung aller Kunden (Firmen oder Privatpersonen) mit den
|
||||
relevanten Adressdaten.
|
||||
"""
|
||||
__tablename__ = 'kunden'
|
||||
__bind_key__ = BINDKEY
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
strasse = Column(String(200), nullable=True)
|
||||
plz = Column(String(20), nullable=True)
|
||||
ort = Column(String(100), nullable=True)
|
||||
land = Column(String(80), nullable=True)
|
||||
|
||||
def to_dict(self):
|
||||
"""Liefert die Kundendaten als Dictionary (für JSON-Serialisierung)."""
|
||||
return columns2dict(self)
|
||||
|
||||
|
||||
print("########## crm/model importiert")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
DB-Versionierung für die CRM-Datenbank (kbp_crm.db).
|
||||
|
||||
Analog zu auth/model/checkdb.py: Beim Start wird checkupdates() aufgerufen und
|
||||
führt alle _db_Vxx_xx-Funktionen aus, deren Versionsnummer größer ist als die
|
||||
in der Systeminfo-Tabelle vermerkte. Neue Migrationen werden als weitere
|
||||
_db_Vxx_xx-Funktionen ergänzt.
|
||||
|
||||
:author: Mistral Vibe <vibe@mistral.ai>
|
||||
:date: 2026-09-02
|
||||
:version: 1.0
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from db import dbapp
|
||||
from crm.model import CrmSysteminfo
|
||||
|
||||
BINDKEY = "appdb"
|
||||
session = dbapp.session
|
||||
|
||||
|
||||
def _updatesysteminfo(versionnr: str):
|
||||
"""Aktualisiert Versionsnummer und Zeitstempel in der Systeminfo-Tabelle."""
|
||||
s: CrmSysteminfo = CrmSysteminfo.query.get(1)
|
||||
s.version = versionnr
|
||||
s.lastupdate = datetime.now()
|
||||
|
||||
|
||||
def _db_V01_00():
|
||||
"""
|
||||
Ersteinrichtung: Alle CRM-Tabellen anlegen und Systeminfo-Datensatz
|
||||
schreiben.
|
||||
"""
|
||||
dbapp.create_all(bind_key=BINDKEY)
|
||||
systeminfo = CrmSysteminfo()
|
||||
systeminfo.id = 1
|
||||
systeminfo.version = "01.00"
|
||||
systeminfo.lastupdate = datetime.now()
|
||||
systeminfo.initialinstall = datetime.now()
|
||||
session.add(systeminfo)
|
||||
session.commit()
|
||||
|
||||
|
||||
def checkupdates():
|
||||
"""
|
||||
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
|
||||
_db_Vxx_xx-Funktionen aus, deren Versionsnummer größer ist. Ist die
|
||||
Datenbank noch leer (Tabelle fehlt), wird die Ersteinrichtung ausgeführt.
|
||||
"""
|
||||
curr_dbversion = '_db_V01_00'
|
||||
try:
|
||||
systeminfo: CrmSysteminfo = CrmSysteminfo.query.get(1)
|
||||
curr_dbversion = "_db_V" + systeminfo.version.replace(".", "_")
|
||||
except (OperationalError, AttributeError):
|
||||
_db_V01_00()
|
||||
|
||||
versionen = sorted(filter(lambda f: f.startswith("_db_V"), globals()))
|
||||
|
||||
for v in versionen:
|
||||
if v > curr_dbversion:
|
||||
globals()[v]()
|
||||
|
||||
print('########## crm Database updated')
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Geschäftslogik für die Entität Kunde.
|
||||
|
||||
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-02
|
||||
:version: 1.0
|
||||
"""
|
||||
from db import dbapp
|
||||
from crm.model import Kunde
|
||||
|
||||
from auth.model.errors import ApplicationError, HtmlStatusCode
|
||||
|
||||
session = dbapp.session
|
||||
|
||||
|
||||
def get_kunden():
|
||||
"""
|
||||
Liefert alle Kunden, sortiert nach Name.
|
||||
"""
|
||||
return Kunde.query.order_by(Kunde.name).all()
|
||||
|
||||
|
||||
def get_kunde(kunde_id: int) -> Kunde:
|
||||
"""
|
||||
Liefert einen einzelnen Kunden anhand seiner ID.
|
||||
Wirft ApplicationError (404), falls der Kunde nicht existiert.
|
||||
"""
|
||||
kunde = Kunde.query.get(kunde_id)
|
||||
if kunde is None:
|
||||
raise ApplicationError(
|
||||
"Kunde mit der ID {} wurde nicht gefunden.".format(kunde_id),
|
||||
htmlstatus=HtmlStatusCode.Not_Found,
|
||||
)
|
||||
return kunde
|
||||
|
||||
|
||||
def create_kunde(data: dict) -> Kunde:
|
||||
"""
|
||||
Legt einen neuen Kunden an. Die vom Client übergebene ID wird ignoriert;
|
||||
die ID wird serverseitig per Auto-Increment vergeben.
|
||||
|
||||
- data: dict mit den Feldern name, strasse, plz, ort, land
|
||||
"""
|
||||
if not data.get("name") or not str(data["name"]).strip():
|
||||
raise ApplicationError(
|
||||
"Der Kundenname darf nicht leer sein.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
kunde = Kunde()
|
||||
kunde.name = data["name"].strip()
|
||||
kunde.strasse = data.get("strasse") or None
|
||||
kunde.plz = data.get("plz") or None
|
||||
kunde.ort = data.get("ort") or None
|
||||
kunde.land = data.get("land") or None
|
||||
session.add(kunde)
|
||||
session.commit()
|
||||
return kunde
|
||||
|
||||
|
||||
def update_kunde(data: dict) -> Kunde:
|
||||
"""
|
||||
Aktualisiert einen bestehenden Kunden. Die ID im data-Dict bestimmt den
|
||||
zu ändernden Datensatz.
|
||||
"""
|
||||
kunde = get_kunde(data["id"])
|
||||
if not data.get("name") or not str(data["name"]).strip():
|
||||
raise ApplicationError(
|
||||
"Der Kundenname darf nicht leer sein.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
kunde.name = data["name"].strip()
|
||||
kunde.strasse = data.get("strasse") or None
|
||||
kunde.plz = data.get("plz") or None
|
||||
kunde.ort = data.get("ort") or None
|
||||
kunde.land = data.get("land") or None
|
||||
session.commit()
|
||||
return kunde
|
||||
|
||||
|
||||
def delete_kunde(kunde_id: int):
|
||||
"""
|
||||
Löscht einen Kunden anhand seiner ID.
|
||||
Wirft ApplicationError (404), falls der Kunde nicht existiert.
|
||||
"""
|
||||
kunde = get_kunde(kunde_id)
|
||||
session.delete(kunde)
|
||||
session.commit()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
REST-Endpunkte für die CRM-Domain.
|
||||
|
||||
Die Endpunkte orientieren sich 1:1 an den clientseitig definierten Pfaden
|
||||
(siehe Client/models/*.ts). Pro Entität gibt es einen zusammenhängenden Block
|
||||
mit den Aktionen list/get/create/update/delete.
|
||||
|
||||
Die Autorisierung erfolgt über den Decorator authorisation_required; AppAdmin
|
||||
und SysAdmin sind darin implizit immer erlaubt. Die Rollen Mitarbeiter und
|
||||
KundenManager werden aus crm.model importiert.
|
||||
|
||||
:author: Mistral Vibe <vibe@mistral.ai>
|
||||
:date: 2026-09-02
|
||||
:version: 1.0
|
||||
"""
|
||||
from flask import current_app as app
|
||||
from flask import jsonify, request
|
||||
|
||||
from auth.decorators import authorisation_required
|
||||
from crm.model import KUNDEN_MANAGER, MITARBEITER
|
||||
from crm.model import kunde as kunden
|
||||
|
||||
print("crm-routes")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Kunde #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@app.get("/xhr/kunde/list")
|
||||
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
|
||||
def kunde_list():
|
||||
"""Liefert alle Kunden als Liste."""
|
||||
result = [k.to_dict() for k in kunden.get_kunden()]
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@app.get("/xhr/kunde/get")
|
||||
@authorisation_required([MITARBEITER, KUNDEN_MANAGER])
|
||||
def kunde_get():
|
||||
"""Liefert einen einzelnen Kunden anhand seiner ID (Query-Param id)."""
|
||||
kunde = kunden.get_kunde(int(request.args.get("id")))
|
||||
return jsonify(kunde.to_dict()), 200
|
||||
|
||||
|
||||
@app.post("/xhr/kunde/create")
|
||||
@authorisation_required([KUNDEN_MANAGER])
|
||||
def kunde_create():
|
||||
"""Legt einen neuen Kunden an. ID wird serverseitig vergeben."""
|
||||
kunde = kunden.create_kunde(request.json)
|
||||
return jsonify(kunde.to_dict()), 200
|
||||
|
||||
|
||||
@app.put("/xhr/kunde/update")
|
||||
@authorisation_required([KUNDEN_MANAGER])
|
||||
def kunde_update():
|
||||
"""Aktualisiert einen bestehenden Kunden."""
|
||||
kunde = kunden.update_kunde(request.json)
|
||||
return jsonify(kunde.to_dict()), 200
|
||||
|
||||
|
||||
@app.delete("/xhr/kunde/delete")
|
||||
@authorisation_required([KUNDEN_MANAGER])
|
||||
def kunde_delete():
|
||||
"""Löscht einen Kunden anhand seiner ID (Query-Param id)."""
|
||||
kunden.delete_kunde(int(request.args.get("id")))
|
||||
return jsonify({}), 200
|
||||
|
||||
|
||||
print("########## crm.routes importiert")
|
||||
Reference in new issue
Block a user