feat(school-settings): implement school metadata management and update UI for PDF exports

This commit is contained in:
2026-05-10 17:11:24 +02:00
parent d6625d17c8
commit b905ec9024
6 changed files with 412 additions and 80 deletions
-9
View File
@@ -49,15 +49,6 @@ Bearbeite `config.json`:
Falls nicht konfiguriert, werden Platzhalter verwendet.
Alternativ können Sie die Schulinformationen tenant-spezifisch mit dem Manage-Skript setzen:
```bash
# Beispiel: Schulinformationen für Tenant 'school_a' setzen
./manage-tenant.sh school school_a name="Grundschule Albert-Schweitzer-Straße" address="Albert-Schweitzer-Straße 1" postal_code=12345 city="Musterstadt" school_number=042123 it_admin="Max Mustermann"
```
Hinweis: `manage-tenant.sh school` schreibt die Daten unter `tenants.<tenant_id>.school` in `config.json` und löst bei aktiver App-Container-Instanz einen Neustart aus, damit die Änderungen wirksam werden.
### Schritt 2: System starten
```bash
+76 -14
View File
@@ -610,7 +610,9 @@ def _get_school_info_for_export():
Returns default info if not configured.
"""
try:
# Try to load from settings or config
if hasattr(cfg, 'get_school_info'):
return cfg.get_school_info()
school_info = {
'name': 'Schulname',
'address': 'Schuladresse',
@@ -618,19 +620,8 @@ def _get_school_info_for_export():
'city': 'Stadt',
'school_number': '000000',
'it_admin': 'IT-Beauftragter/in',
'logo_path': '',
}
# Try to load from config.json if available
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.json')
if os.path.exists(config_path):
try:
with open(config_path, 'r') as f:
config = json.load(f)
if 'school' in config:
school_info.update(config.get('school', {}))
except Exception:
pass
return school_info
except Exception:
# Return defaults if anything fails
@@ -641,6 +632,7 @@ def _get_school_info_for_export():
'city': 'Stadt',
'school_number': '000000',
'it_admin': 'IT-Beauftragter/in',
'logo_path': '',
}
@@ -2886,7 +2878,8 @@ def home_admin():
library_module_enabled=cfg.LIBRARY_MODULE_ENABLED,
student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED,
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
school_info=_get_school_info_for_export(),
)
@@ -9859,6 +9852,75 @@ def manage_locations():
student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED
)
@app.route('/admin/school-settings', methods=['GET', 'POST'])
def admin_school_settings():
"""Admin page for configuring school metadata used in exports."""
if 'username' not in session:
flash('Bitte melden Sie sich mit einem administrativen Konto an.', 'error')
return redirect(url_for('login'))
if not us.check_admin(session['username']):
flash('Für diese Seite sind Administratorrechte erforderlich.', 'error')
return redirect(url_for('login'))
permissions = _get_current_user_permissions()
if not _action_access_allowed(permissions, 'can_manage_settings'):
flash('Sie haben keine Berechtigung, die Schulstammdaten zu ändern.', 'error')
return redirect(url_for('home_admin'))
current_school = _get_school_info_for_export()
tenant_context = None
tenant_id = None
tenant_db = cfg.MONGODB_DB
try:
tenant_context = get_tenant_context()
except Exception:
tenant_context = None
if tenant_context:
tenant_id = tenant_context.tenant_id
tenant_db = tenant_context.db_name or tenant_db
if request.method == 'POST':
school_info = {
'name': sanitize_form_value(request.form.get('name')),
'address': sanitize_form_value(request.form.get('address')),
'postal_code': sanitize_form_value(request.form.get('postal_code')),
'city': sanitize_form_value(request.form.get('city')),
'school_number': sanitize_form_value(request.form.get('school_number')),
'it_admin': sanitize_form_value(request.form.get('it_admin')),
'logo_path': sanitize_form_value(request.form.get('logo_path')),
}
missing_fields = [
label for label, key in [
('Schulname', 'name'),
('Adresse', 'address'),
('PLZ', 'postal_code'),
('Ort', 'city'),
('Schulnummer', 'school_number'),
] if not school_info.get(key)
]
if missing_fields:
flash(f'Bitte füllen Sie die Pflichtfelder aus: {", ".join(missing_fields)}.', 'error')
else:
try:
updated_school = cfg.update_school_info(school_info)
current_school = updated_school
flash('Schulstammdaten wurden erfolgreich gespeichert.', 'success')
except Exception as exc:
app.logger.error(f'Could not update school settings: {exc}\n{traceback.format_exc()}')
flash('Die Schulstammdaten konnten nicht gespeichert werden.', 'error')
return render_template(
'admin_school_settings.html',
school_info=current_school,
tenant_id=tenant_id,
tenant_db=tenant_db,
library_module_enabled=cfg.LIBRARY_MODULE_ENABLED,
student_cards_module_enabled=cfg.STUDENT_CARDS_MODULE_ENABLED,
)
@app.route('/check_code_unique/<code>')
def check_code_unique(code):
"""
+62
View File
@@ -348,3 +348,65 @@ def MongoClient(*args, **kwargs):
cached_client = _MongoClientProxy(client)
_MONGO_CLIENT_CACHE[cache_key] = cached_client
return cached_client
def get_school_info():
"""Return the tenant-scoped school metadata used for PDFs and admin views."""
school_info = dict(SCHOOL_INFO_DEFAULT)
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
if 'settings' not in db.list_collection_names():
return school_info
settings_collection = db['settings']
settings_document = settings_collection.find_one({'setting_type': 'school_info'})
if not settings_document:
return school_info
configured_school = settings_document.get('school', {})
if isinstance(configured_school, dict):
for key, value in configured_school.items():
if value is not None:
school_info[key] = value
return school_info
except Exception:
return school_info
finally:
if client:
client.close()
def update_school_info(school_info):
"""Persist tenant-scoped school metadata into MongoDB and refresh the in-memory cache."""
if not isinstance(school_info, dict):
raise TypeError('school_info must be a dict')
updated_school = dict(SCHOOL_INFO_DEFAULT)
for key in updated_school.keys():
value = school_info.get(key, updated_school[key])
if value is None:
value = ''
updated_school[key] = str(value).strip()
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
settings_collection = db['settings']
settings_collection.update_one(
{'setting_type': 'school_info'},
{
'$set': {
'setting_type': 'school_info',
'school': updated_school,
}
},
upsert=True,
)
finally:
if client:
client.close()
return dict(updated_school)
+261
View File
@@ -0,0 +1,261 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %}
{% block title %}Schulstammdaten verwalten{% endblock %}
{% block content %}
<div class="container">
<div class="page-header">
<h1>Schulstammdaten verwalten</h1>
<p>Diese Angaben werden für amtliche Exporte, PDF-Berichte und die Zuordnung in Behörden verwendet.</p>
</div>
<div class="notice-card">
<strong>Hinweis:</strong> Die hier gespeicherten Daten werden tenantweise in der MongoDB gespeichert und sofort für die PDF-Exporte verwendet.
</div>
<div class="notice-card" style="background:#f8fafc; border-color:#dbe3ee; color:#334155;">
<strong>Aktueller Kontext:</strong>
Tenant {{ tenant_id or 'default' }} · Datenbank {{ tenant_db or 'Inventarsystem' }}
</div>
<div class="settings-grid">
<div class="card settings-card">
<div class="card-header">
<h2>Schuldaten</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('admin_school_settings') }}">
<div class="form-row">
<div class="form-group">
<label for="name">Schulname *</label>
<input type="text" id="name" name="name" value="{{ school_info.name or '' }}" required placeholder="Grundschule Beispielstadt">
</div>
<div class="form-group">
<label for="school_number">Schulnummer *</label>
<input type="text" id="school_number" name="school_number" value="{{ school_info.school_number or '' }}" required placeholder="042123">
</div>
</div>
<div class="form-group">
<label for="address">Adresse *</label>
<input type="text" id="address" name="address" value="{{ school_info.address or '' }}" required placeholder="Musterstraße 12">
</div>
<div class="form-row">
<div class="form-group">
<label for="postal_code">PLZ *</label>
<input type="text" id="postal_code" name="postal_code" value="{{ school_info.postal_code or '' }}" required placeholder="12345">
</div>
<div class="form-group">
<label for="city">Ort *</label>
<input type="text" id="city" name="city" value="{{ school_info.city or '' }}" required placeholder="Musterstadt">
</div>
</div>
<div class="form-group">
<label for="it_admin">Verantwortliche Person</label>
<input type="text" id="it_admin" name="it_admin" value="{{ school_info.it_admin or '' }}" placeholder="IT-Beauftragte/r, Sekretariat oder Schulleitung">
</div>
<div class="form-group">
<label for="logo_path">Logo-Pfad oder Dateiname</label>
<input type="text" id="logo_path" name="logo_path" value="{{ school_info.logo_path or '' }}" placeholder="optional, z. B. static/img/schullogo.png">
<small>Der Logo-Pfad wird gespeichert und kann später für den PDF-Briefkopf verwendet werden.</small>
</div>
<div class="actions-row">
<button type="submit" class="btn btn-primary">Schulstammdaten speichern</button>
<a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Admin-Übersicht</a>
</div>
</form>
</div>
</div>
<div class="card preview-card">
<div class="card-header">
<h2>Aktuelle Vorschau</h2>
</div>
<div class="card-body">
<p><strong>Schulname:</strong> {{ school_info.name or 'Nicht gesetzt' }}</p>
<p><strong>Adresse:</strong> {{ school_info.address or 'Nicht gesetzt' }}</p>
<p><strong>Schulnummer:</strong> {{ school_info.school_number or 'Nicht gesetzt' }}</p>
<p><strong>Ort:</strong> {{ school_info.postal_code or '' }} {{ school_info.city or '' }}</p>
<p><strong>Verantwortliche Person:</strong> {{ school_info.it_admin or 'Nicht gesetzt' }}</p>
<p><strong>Logo:</strong> {{ school_info.logo_path or 'Nicht gesetzt' }}</p>
<hr>
<p>Diese Angaben erscheinen künftig im amtlichen Audit-PDF und in anderen Behördenberichten.</p>
</div>
</div>
</div>
</div>
<style>
.container {
max-width: 1100px;
margin: 0 auto;
padding: 20px;
}
.page-header {
text-align: center;
margin-bottom: 18px;
}
.page-header h1 {
margin-bottom: 8px;
color: #1f2937;
}
.page-header p {
color: #4b5563;
margin: 0;
}
.notice-card {
background: #eff6ff;
border: 1px solid #bfdbfe;
color: #1d4ed8;
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 18px;
}
.settings-grid {
display: grid;
grid-template-columns: 1.4fr 0.9fr;
gap: 18px;
align-items: start;
}
.card {
background: var(--ui-surface);
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.06);
}
.card-header {
background: var(--ui-surface-soft);
padding: 16px 18px;
border-bottom: 1px solid #e5e7eb;
}
.card-header h2 {
margin: 0;
font-size: 1.1rem;
color: #111827;
}
.card-body {
padding: 18px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-weight: 700;
margin-bottom: 6px;
color: #374151;
}
.form-group input {
width: 100%;
padding: 11px 12px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
color: #111827;
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.form-group small {
display: block;
margin-top: 6px;
color: #6b7280;
line-height: 1.35;
}
.actions-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 16px;
border-radius: 8px;
font-weight: 700;
text-decoration: none;
border: 1px solid transparent;
cursor: pointer;
}
.btn-primary {
background: #2563eb;
color: #fff;
}
.btn-primary:hover {
background: #1d4ed8;
color: #fff;
}
.btn-secondary {
background: #f3f4f6;
color: #111827;
border-color: #d1d5db;
}
.btn-secondary:hover {
background: #e5e7eb;
color: #111827;
}
.preview-card p {
margin: 0 0 10px;
line-height: 1.45;
}
.preview-card hr {
border: 0;
border-top: 1px solid #e5e7eb;
margin: 16px 0;
}
@media (max-width: 900px) {
.settings-grid,
.form-row {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
+13
View File
@@ -2345,6 +2345,19 @@ document.addEventListener('DOMContentLoaded', ()=>{
</div>
</div>
</h1>
<div class="admin-school-banner" style="display:flex; flex-wrap:wrap; justify-content:space-between; gap:12px; align-items:center; margin:0 0 14px; padding:14px 16px; border:1px solid #dbe3ee; border-radius:12px; background: var(--ui-surface); box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);">
<div>
<div style="font-weight:700; font-size:1rem; color:var(--ui-title);">Schulstammdaten</div>
<div style="font-size:0.92rem; color:var(--ui-text-muted);">
{{ school_info.name if school_info else 'Schulname nicht konfiguriert' }}
{% if school_info and school_info.school_number %} | Schulnummer: {{ school_info.school_number }}{% endif %}
</div>
</div>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<a href="{{ url_for('admin_school_settings') }}" class="btn btn-primary">Schulstammdaten bearbeiten</a>
<a href="{{ url_for('admin_audit') }}" class="btn btn-outline-secondary">Audit-Export öffnen</a>
</div>
</div>
<div id="items-indicator" class="items-indicator">Objekte im System: 0</div>
<div id="bulk-delete-drawer" class="bulk-delete-drawer" aria-hidden="true">
<div class="bulk-delete-content">
-57
View File
@@ -21,7 +21,6 @@ show_help() {
echo " restart-all Restart all application containers (zero-downtime reload)"
echo " list List active tenants"
echo " module <tenant_id> <module>=<on|off>... Enable/disable modules for a tenant"
echo " school <tenant_id> [key]=[value]... Set school configuration for tenant (name,address,postal_code,city,school_number,it_admin)"
echo " -h, --help Show this help message"
echo ""
echo "Examples:"
@@ -566,62 +565,6 @@ PY
fi
;;
school)
# Usage: manage-tenant.sh school <tenant_id> key=value [...]
if [ -z "$TENANT_ID" ] || [ -z "${3:-}" ]; then
echo "Error: Usage: manage-tenant.sh school <tenant_id> key=value [...]"
exit 1
fi
# Pass all remaining args to python to update tenants[tenant_id]['school']
shift 2
if python3 - "$CONFIG_FILE" "$TENANT_ID" "$@" <<'PY'
import json, sys, os
path = sys.argv[1]
tenant_id = sys.argv[2]
kv_args = sys.argv[3:]
if not os.path.isfile(path):
print(f"Error: config file not found: {path}", file=sys.stderr)
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.setdefault('tenants', {})
tenant_cfg = tenants.setdefault(tenant_id, {})
school_cfg = tenant_cfg.setdefault('school', {})
for arg in kv_args:
if '=' not in arg:
print(f"Warning: Ignoring invalid argument '{arg}'. Expected key=value.", file=sys.stderr)
continue
key, val = arg.split('=', 1)
key = key.strip()
val = val.strip()
# try to coerce numeric values
if val.isdigit():
val_parsed = int(val)
else:
val_parsed = val
school_cfg[key] = val_parsed
print(f"Set school.{key} = {val_parsed} for tenant {tenant_id}")
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
PY
then
echo "School configuration updated successfully in config.json for tenant '$TENANT_ID'."
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
else
echo "Failed to update school configuration for tenant '$TENANT_ID'."
exit 1
fi
;;
*)
echo "Unknown command: $COMMAND"
show_help