feat(school-settings): implement school metadata management and update UI for PDF exports
This commit is contained in:
@@ -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
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user