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
@@ -1,10 +1,9 @@
|
|||||||
from flask import Flask
|
from flask import Flask, jsonify
|
||||||
from flask_login import LoginManager
|
from flask_login import LoginManager
|
||||||
from settings import Configuration
|
from settings import Configuration
|
||||||
from db import dbauth
|
from db import dbauth
|
||||||
from db import dbapp
|
from db import dbapp
|
||||||
from auth.model.checkdb import checkupdates as checkupdatesauth
|
from auth.model.checkdb import checkupdates as checkupdatesauth
|
||||||
# from model.checkdb import checkupdates as checkupdatesappdb
|
|
||||||
from os import path
|
from os import path
|
||||||
|
|
||||||
login_manager = LoginManager()
|
login_manager = LoginManager()
|
||||||
@@ -15,7 +14,7 @@ def init_app():
|
|||||||
|
|
||||||
config: Configuration = Configuration("root/config.ini")
|
config: Configuration = Configuration("root/config.ini")
|
||||||
_projektname = config.app_projectname
|
_projektname = config.app_projectname
|
||||||
|
|
||||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:////usr/var/pythonappdb/{}.db".format(_projektname)
|
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:////usr/var/pythonappdb/{}.db".format(_projektname)
|
||||||
app.config["SQLALCHEMY_BINDS"] = {
|
app.config["SQLALCHEMY_BINDS"] = {
|
||||||
"auth": "sqlite:////usr/var/pythonappdb/auth.db",
|
"auth": "sqlite:////usr/var/pythonappdb/auth.db",
|
||||||
@@ -23,23 +22,43 @@ def init_app():
|
|||||||
}
|
}
|
||||||
app.config["DEBUG"] = path.exists("root/debug")
|
app.config["DEBUG"] = path.exists("root/debug")
|
||||||
app.config["PROJEKTNAME"] = config.app_projectname
|
app.config["PROJEKTNAME"] = config.app_projectname
|
||||||
|
app.config["APPID"] = config.app_appid
|
||||||
app.config["APPVERSION"] = config.clientapp_version
|
app.config["APPVERSION"] = config.clientapp_version
|
||||||
app.config["SECRET_KEY"] = config.flask_secret
|
app.config["SECRET_KEY"] = config.flask_secret
|
||||||
|
|
||||||
# Initialize Plugins
|
# Initialize Plugins
|
||||||
|
# dbapp ist dasselbe Objekt wie dbauth (siehe db.py); beide teilen sich
|
||||||
|
# die SQLAlchemy-Registry. Daher genügt ein einziger init_app-Aufruf, der
|
||||||
|
# sowohl den auth- als auch den appdb-Bind initialisiert.
|
||||||
login_manager.init_app(app)
|
login_manager.init_app(app)
|
||||||
dbauth.init_app(app)
|
dbauth.init_app(app)
|
||||||
#dbapp.init_app(app)
|
|
||||||
|
# Globale Fehlerbehandlung für ApplicationExceptionen. Die Route-internen
|
||||||
|
# try/except-Blöcke (auth) fangen weiterhin lokal ab; für alle nicht
|
||||||
|
# gefangenen Fälle (insbesondere crm) greift dieser Handler.
|
||||||
|
from auth.model.errors import ApplicationError, AutorisationError
|
||||||
|
|
||||||
|
@app.errorhandler(ApplicationError)
|
||||||
|
def handle_application_error(ex):
|
||||||
|
return jsonify(ex.error), ex.htmlstatus
|
||||||
|
|
||||||
|
@app.errorhandler(AutorisationError)
|
||||||
|
def handle_autorisation_error(ex):
|
||||||
|
return jsonify(ex.error), ex.htmlstatus
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
# Include our Routes
|
# Include our Routes
|
||||||
print("before auth.routes", flush=True)
|
print("before auth.routes", flush=True)
|
||||||
from auth import routes
|
from auth import routes
|
||||||
from routes import general
|
from routes import general
|
||||||
|
from crm import routes as crm_routes
|
||||||
|
from crm.model import checkdb as crm_checkdb
|
||||||
checkupdatesauth()
|
checkupdatesauth()
|
||||||
# checkupdatesappdb()
|
crm_checkdb.checkupdates()
|
||||||
|
|
||||||
# from maintenance import init_cli
|
# Flask CLI-Befehle (init-db, reset-db) registrieren
|
||||||
|
from cli import register_cli_commands
|
||||||
|
register_cli_commands(app)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
Decoratoren zur Authentifizierungs- und Autorisierungsprüfung.
|
||||||
|
|
||||||
|
Der Decorator authorisation_required hebt die Rollenprüfung an die Route-Ebene,
|
||||||
|
analog zu flask_login.login_required. Authentifizierung (login_required) ist
|
||||||
|
eingebettet, sodass pro Endpunkt nur ein Decorator nötig ist.
|
||||||
|
|
||||||
|
:author: Mistral Vibe <vibe@mistral.ai>
|
||||||
|
:date: 2026-09-02
|
||||||
|
:version: 1.0
|
||||||
|
"""
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
|
||||||
|
from auth.model import APP_ADMIN
|
||||||
|
from auth.model.errors import AutorisationError
|
||||||
|
|
||||||
|
|
||||||
|
def authorisation_required(allowed_roles=[]):
|
||||||
|
"""
|
||||||
|
Decorator, der sicherstellt, dass der angemeldete Benutzer für den
|
||||||
|
Endpunkt autorisiert ist. Authentifizierung (login_required) ist eingebettet,
|
||||||
|
sodass kein zusätzlicher @login_required-Aufruf nötig ist.
|
||||||
|
|
||||||
|
Aspekte der Autorisierung (Prüfreihenfolge):
|
||||||
|
|
||||||
|
1. SysAdmin (systemweite Super-Rolle):
|
||||||
|
Der SysAdmin darf alles, unabhängig von der Anwendung. Er braucht in
|
||||||
|
keiner Application eine Rolle zugewiesen zu haben. Die Prüfung erfolgt
|
||||||
|
über User.is_SysAdmin() (app-übergreifend). Dies ist die einzige Rolle,
|
||||||
|
die applikationsübergreifend gilt — sie ist eine Systemrolle, keine
|
||||||
|
App-Rolle.
|
||||||
|
|
||||||
|
2. AppAdmin (anwendungsbezogene Verwaltungsrolle):
|
||||||
|
Der AppAdmin darf innerhalb der Anwendung alles, der er zugeordnet ist.
|
||||||
|
Er braucht die Rolle AppAdmin nur in der aktuellen Anwendung. Der
|
||||||
|
Decorator ergänzt allowed_roles intern um APP_ADMIN, sodass der AppAdmin
|
||||||
|
jeden Endpunkt ohne explizite Nennung in allowed_roles passiert. Die
|
||||||
|
Prüfung erfolgt über User.has_permission(appid, [..., APP_ADMIN]) —
|
||||||
|
app-spezifisch, nicht systemweit.
|
||||||
|
|
||||||
|
3. Benutzer mit Basis-Rolle 'User' (Mitarbeiter):
|
||||||
|
Jeder Anwender hat zwingend die Basis-Rolle 'User' (Mitarbeiter), damit
|
||||||
|
er das Login-Gate in auth/routes.checklogin passiert. Diese Rolle allein
|
||||||
|
berechtigt nur zu Lese-Endpunkten, bei denen MITARBEITER in allowed_roles
|
||||||
|
steht. Für Schreib-Endpunkte reicht 'User' nicht.
|
||||||
|
|
||||||
|
4. Benutzer mit Zusatz-Rollen (z.B. KundenManager, später ProjektManager):
|
||||||
|
Zusätzlich zur Basis-Rolle 'User' können einem Benutzer weitere Rollen
|
||||||
|
zugewiesen werden. Diese gewähren weitergehende Berechtigungen für
|
||||||
|
bestimmte Entitäten. Die Prüfung erfolgt app-spezifisch über
|
||||||
|
User.has_permission(appid, allowed_roles). Der Benutzer muss die
|
||||||
|
Zusatz-Rolle in der aktuellen Anwendung haben, nicht in einer anderen.
|
||||||
|
|
||||||
|
Die appid der aktuellen Anwendung wird aus app.config["APPID"] gelesen
|
||||||
|
(gesetzt in approot.py aus config.ini, Eintrag [APP] appid). Sie ist der
|
||||||
|
lesbare Anwendungsname (z.B. 'KBPlusCRM') und dient der Zuordnung in der
|
||||||
|
Tabelle 'applications'. Sie ist nicht identisch mit PROJEKTNAME, der nur
|
||||||
|
für Datei- und Verzeichnisnamen verwendet wird.
|
||||||
|
|
||||||
|
- allowed_roles: List[str]
|
||||||
|
- roleids (nicht ids) der Rollen, die den Endpunkt zusätzlich zu
|
||||||
|
AppAdmin/SysAdmin erlauben. AppAdmin wird intern ergänzt, muss also
|
||||||
|
nicht explizit genannt werden. Der Benutzer muss die Rolle in der
|
||||||
|
aktuellen Anwendung haben (geprüft via User.has_permission).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(f):
|
||||||
|
@wraps(f)
|
||||||
|
@login_required
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
# appid der aktuellen Anwendung aus der Konfiguration
|
||||||
|
appid = current_app.config["APPID"]
|
||||||
|
|
||||||
|
# 1. SysAdmin darf systemweit alles (keine app-spezifische Prüfung)
|
||||||
|
if current_user.is_SysAdmin():
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
# 2.-4. AppAdmin und alle anderen Rollen: app-spezifische Prüfung.
|
||||||
|
# AppAdmin wird implizit zu allowed_roles hinzugefügt, damit der
|
||||||
|
# AppAdmin in seiner Anwendung jeden Endpunkt ohne explizite
|
||||||
|
# Nennung in allowed_roles nutzen kann.
|
||||||
|
effective_roles = list(allowed_roles) + [APP_ADMIN]
|
||||||
|
|
||||||
|
if not current_user.has_permission(appid, effective_roles):
|
||||||
|
# Rollen des Users in der aktuellen Anwendung für die Fehlermeldung
|
||||||
|
user_roles = current_user.get_userapproles(appid)
|
||||||
|
raise AutorisationError(
|
||||||
|
allowed_roles=allowed_roles, user_roles=user_roles
|
||||||
|
)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
@@ -3,6 +3,7 @@ from auth import model
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.exc import OperationalError
|
from sqlalchemy.exc import OperationalError
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from flask import current_app
|
||||||
from db import dbauth
|
from db import dbauth
|
||||||
|
|
||||||
session = dbauth.session
|
session = dbauth.session
|
||||||
@@ -48,8 +49,11 @@ def _db_V01_01():
|
|||||||
|
|
||||||
application = model.Application()
|
application = model.Application()
|
||||||
application.id = 1
|
application.id = 1
|
||||||
application.appid = "Project01"
|
# appid und name aus der Konfiguration übernehmen (config.ini [APP] appid).
|
||||||
application.name = "Project01 (Name ändern)"
|
# Die appid ist der lesbare Anwendungsname, der zur Autorisierung verwendet
|
||||||
|
# wird; nicht identisch mit PROJEKTNAME (technischer Bezeichner).
|
||||||
|
application.appid = current_app.config["APPID"]
|
||||||
|
application.name = current_app.config["APPID"]
|
||||||
session.add(application)
|
session.add(application)
|
||||||
|
|
||||||
userapp = model.Userapp()
|
userapp = model.Userapp()
|
||||||
@@ -61,6 +65,23 @@ def _db_V01_01():
|
|||||||
_updatesysteminfo('01.01')
|
_updatesysteminfo('01.01')
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
def _db_V01_02():
|
||||||
|
"""
|
||||||
|
Migriert bestehende Application-Datensätze, deren appid noch den alten
|
||||||
|
Platzhalter 'Project01' enthält, auf die appid aus der aktuellen Konfiguration
|
||||||
|
(config.ini [APP] appid). Wird nach reset-db nicht benötigt (da _db_V01_01
|
||||||
|
direkt den korrekten Wert setzt), greift aber bei Upgrades bestehender DBs.
|
||||||
|
"""
|
||||||
|
appid = current_app.config["APPID"]
|
||||||
|
application: model.Application = model.Application.query.filter(
|
||||||
|
model.Application.appid == "Project01"
|
||||||
|
).first()
|
||||||
|
if application is not None:
|
||||||
|
application.appid = appid
|
||||||
|
application.name = appid
|
||||||
|
_updatesysteminfo('01.02')
|
||||||
|
session.commit()
|
||||||
|
|
||||||
def _updatesysteminfo(versionnr):
|
def _updatesysteminfo(versionnr):
|
||||||
s: model.Systeminfo = model.Systeminfo.query.get(1)
|
s: model.Systeminfo = model.Systeminfo.query.get(1)
|
||||||
s.version = versionnr
|
s.version = versionnr
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
Flask CLI-Befehle zur Datenbank-Initialisierung und zum Reset.
|
||||||
|
|
||||||
|
Befehle (aufgerufen via Flask CLI):
|
||||||
|
flask --app wsgi:app init-db -- Schema + Musterdaten anlegen
|
||||||
|
flask --app wsgi:app reset-db -- alles bis auf SysAdmin löschen
|
||||||
|
|
||||||
|
init-db ist idempotent: bereits vorhandene Rollen/Kunden werden nicht
|
||||||
|
doppelt angelegt. reset-db fragt standardmäßig zur Bestätigung; mit --yes
|
||||||
|
wird die Abfrage übersprungen.
|
||||||
|
|
||||||
|
:author: Mistral Vibe <vibe@mistral.ai>
|
||||||
|
:date: 2026-09-02
|
||||||
|
:version: 1.0
|
||||||
|
"""
|
||||||
|
import click
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from db import dbapp, dbauth
|
||||||
|
|
||||||
|
|
||||||
|
def register_cli_commands(app: Flask):
|
||||||
|
"""Registriert die CLI-Befehle init-db und reset-db auf der Flask-App."""
|
||||||
|
|
||||||
|
@app.cli.command("init-db")
|
||||||
|
def init_db():
|
||||||
|
"""Initialisiert die Datenbanken und legt Musterdaten an."""
|
||||||
|
click.echo("Initialisiere auth.db ...")
|
||||||
|
from auth.model import checkdb as auth_checkdb
|
||||||
|
auth_checkdb.checkupdates()
|
||||||
|
_seed_app_roles()
|
||||||
|
|
||||||
|
click.echo("Initialisiere kbp_crm.db ...")
|
||||||
|
from crm.model import checkdb as crm_checkdb
|
||||||
|
crm_checkdb.checkupdates()
|
||||||
|
_seed_muster_kunden()
|
||||||
|
|
||||||
|
click.echo("Fertig.")
|
||||||
|
|
||||||
|
@app.cli.command("reset-db")
|
||||||
|
@click.option("--yes", is_flag=True, help="Bestätigungsabfrage überspringen")
|
||||||
|
def reset_db(yes):
|
||||||
|
"""Setzt die Datenbanken zurück, behält den SysAdmin."""
|
||||||
|
if not yes:
|
||||||
|
click.confirm(
|
||||||
|
"Wirklich alle Daten löschen? Der SysAdmin bleibt erhalten.",
|
||||||
|
abort=True,
|
||||||
|
)
|
||||||
|
click.echo("Setze kbp_crm.db zurück ...")
|
||||||
|
_reset_appdb()
|
||||||
|
click.echo("Setze auth.db zurück (SysAdmin bleibt) ...")
|
||||||
|
_reset_authdb()
|
||||||
|
_seed_app_roles()
|
||||||
|
click.echo("Fertig.")
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_app_roles():
|
||||||
|
"""
|
||||||
|
Legt die App-Rollen Mitarbeiter und KundenManager idempotent an und ordnet
|
||||||
|
sie der ersten Application zu. AppAdmin existiert bereits (wird in
|
||||||
|
auth/model/checkdb._db_V01_01 angelegt).
|
||||||
|
"""
|
||||||
|
from auth.model import Application, Role
|
||||||
|
|
||||||
|
session = dbauth.session
|
||||||
|
application = Application.query.first()
|
||||||
|
rollen = [
|
||||||
|
("User", "Mitarbeiter"),
|
||||||
|
("KundenManager", "Kunden-Manager"),
|
||||||
|
]
|
||||||
|
for roleid, rolename in rollen:
|
||||||
|
role = Role.query.filter(Role.roleid == roleid).first()
|
||||||
|
if role is None:
|
||||||
|
role = Role()
|
||||||
|
role.roleid = roleid
|
||||||
|
role.name = rolename
|
||||||
|
session.add(role)
|
||||||
|
if application is not None and role not in application.roles:
|
||||||
|
application.roles.append(role)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_muster_kunden():
|
||||||
|
"""
|
||||||
|
Legt die drei Muster-Kunden an, falls die kunden-Tabelle noch leer ist.
|
||||||
|
"""
|
||||||
|
from crm.model import Kunde
|
||||||
|
|
||||||
|
session = dbapp.session
|
||||||
|
if session.query(Kunde).count() > 0:
|
||||||
|
return
|
||||||
|
muster = [
|
||||||
|
Kunde(name="Musterfirma GmbH", strasse="Musterstraße 1",
|
||||||
|
plz="12345", ort="Musterstadt", land="Deutschland"),
|
||||||
|
Kunde(name="Beispiel AG", strasse="Beispielweg 2",
|
||||||
|
plz="54321", ort="Beispielstadt", land="Deutschland"),
|
||||||
|
Kunde(name="Test Unternehmen", strasse="Testallee 3",
|
||||||
|
plz="67890", ort="Testdorf", land="Österreich"),
|
||||||
|
]
|
||||||
|
for kunde in muster:
|
||||||
|
session.add(kunde)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_appdb():
|
||||||
|
"""
|
||||||
|
Setzt die CRM-Datenbank zurück: alle Tabellen löschen und neu anlegen
|
||||||
|
(Schema + Systeminfo, keine Musterdaten).
|
||||||
|
"""
|
||||||
|
from crm.model import checkdb as crm_checkdb
|
||||||
|
|
||||||
|
dbapp.drop_all(bind_key="appdb")
|
||||||
|
dbapp.session.remove()
|
||||||
|
crm_checkdb.checkupdates()
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_authdb():
|
||||||
|
"""
|
||||||
|
Setzt die Auth-Datenbank zurück: alle Tabellen löschen und neu anlegen.
|
||||||
|
Dadurch entsteht der frische Initialzustand mit SysAdmin-User, SysAdmin- und
|
||||||
|
AppAdmin-Rolle sowie der Application (entspricht auth/model/checkdb._db_V01_01).
|
||||||
|
"""
|
||||||
|
from auth.model import checkdb as auth_checkdb
|
||||||
|
|
||||||
|
dbauth.drop_all(bind_key="auth")
|
||||||
|
dbauth.session.remove()
|
||||||
|
auth_checkdb.checkupdates()
|
||||||
@@ -3,5 +3,6 @@ debug=True
|
|||||||
|
|
||||||
[APP]
|
[APP]
|
||||||
projectname=kbp_crm
|
projectname=kbp_crm
|
||||||
|
appid=KBPlusCRM
|
||||||
version=00.00.01
|
version=00.00.01
|
||||||
flasksecret=Hier_muss_ein_sicherer_schluessel_eingesetzt_werden
|
flasksecret=Hier_muss_ein_sicherer_schluessel_eingesetzt_werden
|
||||||
@@ -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")
|
||||||
@@ -18,6 +18,10 @@ class Configuration:
|
|||||||
def app_projectname(self) -> str:
|
def app_projectname(self) -> str:
|
||||||
return self.config["APP"].get("projectname")
|
return self.config["APP"].get("projectname")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app_appid(self) -> str:
|
||||||
|
return self.config["APP"].get("appid")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def flask_secret(self) -> str:
|
def flask_secret(self) -> str:
|
||||||
return self.config["APP"].get("flasksecret")
|
return self.config["APP"].get("flasksecret")
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
WSGI-Einstiegspunkt.
|
||||||
|
|
||||||
|
Initialisiert die Flask-App auf Modulebene, sodass sie sowohl für produktive
|
||||||
|
WSGI-Server (Waitress) als auch für die Flask CLI erreichbar ist:
|
||||||
|
|
||||||
|
flask --app wsgi:app init-db
|
||||||
|
flask --app wsgi:app reset-db
|
||||||
|
|
||||||
|
:author: Mistral Vibe <vibe@mistral.ai>
|
||||||
|
:date: 2026-09-02
|
||||||
|
:version: 1.0
|
||||||
|
"""
|
||||||
|
from approot import init_app
|
||||||
|
|
||||||
|
app = init_app()
|
||||||
Reference in new issue
Block a user