Compare commits

...

17 Commits

Author SHA1 Message Date
Aiirondev_dev 77c4e1b4c5 fix(modules): restore accidentally deleted routes (library_view, etc.) and ensure STUDENT_MAX_BORROW_DAYS is set 2026-05-11 20:37:35 +02:00
Aiirondev_dev e17f098c47 Changes to be committed:
deleted:    .scheduler_lock
2026-05-11 20:25:48 +02:00
Aiirondev_dev f41acc78cd fix(modules): patch routing redirect logic and restore missing STUDENT_MAX_BORROW_DAYS variable 2026-05-11 20:25:19 +02:00
Aiirondev_dev a7a5341961 refactor(app): replace direct module enabled checks with centralized module management calls 2026-05-11 19:41:25 +02:00
Aiirondev_dev 9ace2e2e5e feat(modules): implement module registry for dynamic module management and path matching 2026-05-11 15:49:40 +02:00
Aiirondev_dev dcd1529377 feat(schedule): update modal title and button text from "Termin planen" to "Reservieren" 2026-05-11 15:25:28 +02:00
Aiirondev_dev ae80be31e7 Refactor PDF export functionality and remove unused files
- Removed quick-check button from admin audit template.
- Deleted obsolete test file `test_terminplan.py`.
- Removed `verify_audit_chain.py` as it is no longer needed.
- Introduced `pdf_export.py` to handle PDF generation for audit reports, ensuring compliance with DIN 5008 and other standards.
2026-05-11 12:41:25 +02:00
Aiirondev_dev 06682ef8f2 feat(pdf-export): improve table formatting for better readability and adjust column widths 2026-05-10 19:40:31 +02:00
Aiirondev_dev d69c478fed refactor(admin-dashboard): remove school banner from admin view for cleaner layout 2026-05-10 19:27:07 +02:00
Aiirondev_dev 5f551331aa feat(pdf-export): enhance audit PDF table formatting and readability 2026-05-10 19:22:27 +02:00
Aiirondev_dev 68dc2f71c6 feat(navigation): add link to school settings in admin dropdown menus 2026-05-10 19:03:57 +02:00
Aiirondev_dev 736dafd1e1 fix(admin-dashboard): update audit export link to point to the correct dashboard 2026-05-10 18:40:45 +02:00
Aiirondev_dev 123ad6f0a7 feat(school-logo): add logo thumbnail handling in school settings and update navbar display logic 2026-05-10 17:45:14 +02:00
Aiirondev_dev c953f22ab4 feat(logo-management): add thumbnail generation for school logos and update navbar to display logos 2026-05-10 17:35:22 +02:00
Aiirondev_dev b1fabfbfcf feat(school-logo): implement logo upload functionality and update school settings UI 2026-05-10 17:21:27 +02:00
Aiirondev_dev b905ec9024 feat(school-settings): implement school metadata management and update UI for PDF exports 2026-05-10 17:11:24 +02:00
Aiirondev_dev d6625d17c8 feat(tenant-management): add school configuration management to tenant script 2026-05-10 16:57:01 +02:00
13 changed files with 1132 additions and 815 deletions
View File
+451 -723
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
from typing import Dict, Any
class ModuleRegistry:
def __init__(self):
self._modules: Dict[str, Any] = {}
self._path_matchers = {}
def register(self, name: str, settings_bool, path_matcher=lambda path: False):
self._modules[name] = settings_bool
self._path_matchers[name] = path_matcher
def is_enabled(self, name: str) -> bool:
if name not in self._modules:
return False
return bool(self._modules[name])
def get_all_status(self) -> Dict[str, bool]:
return {name: bool(sys_bool) for name, sys_bool in self._modules.items()}
def get_module_for_path(self, path: str) -> str:
for name, matcher in self._path_matchers.items():
if self.is_enabled(name) and matcher(path):
return name
return None
registry = ModuleRegistry()
+267 -28
View File
@@ -7,6 +7,7 @@ PDF/A archiving standards for German schools and educational authorities.
import io
import json
import datetime
import os
import qrcode
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
@@ -17,8 +18,11 @@ from reportlab.pdfgen import canvas
from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER, TA_JUSTIFY
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
import settings as cfg
__version__ = cfg.APP_VERSION
class DIN5008AuditPDF:
"""
Professional PDF generator for audit reports compliant with:
@@ -106,6 +110,20 @@ class DIN5008AuditPDF:
fontName='Helvetica',
textColor=HexColor('#000000'),
)
logo_path = self.school_info.get('logo_path', '')
resolved_logo_path = None
if logo_path:
candidate_paths = [
logo_path,
os.path.join(cfg.UPLOAD_FOLDER, logo_path),
os.path.join('/opt/Inventarsystem/Web/uploads', logo_path),
os.path.join('/var/Inventarsystem/Web/uploads', logo_path),
]
for candidate_path in candidate_paths:
if candidate_path and os.path.exists(candidate_path):
resolved_logo_path = candidate_path
break
school_info_text = f"""
<b>{school_name}</b><br/>
@@ -113,8 +131,28 @@ class DIN5008AuditPDF:
{postal_code} {city}<br/>
<i>Schulnummer: {school_number}</i>
"""
story.append(Paragraph(school_info_text, header_style))
if resolved_logo_path:
logo_image = Image(resolved_logo_path)
try:
logo_image._restrictSize(3.4 * cm, 3.4 * cm)
except Exception:
pass
school_table = Table(
[[logo_image, Paragraph(school_info_text, header_style)]],
colWidths=[3.8 * cm, self.USABLE_WIDTH - 3.8 * cm],
)
school_table.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
('TOPPADDING', (0, 0), (-1, -1), 0),
('BOTTOMPADDING', (0, 0), (-1, -1), 0),
]))
story.append(school_table)
else:
story.append(Paragraph(school_info_text, header_style))
# Information block (right side simulation)
story.append(Spacer(1, 0.3 * cm))
@@ -137,7 +175,7 @@ class DIN5008AuditPDF:
Erstellungsdatum: {created_date}<br/>
Uhrzeit: {created_time}<br/>
Verantwortliche Person: {responsible_person}<br/>
System: Invario v2.6.5
System: Invario v{__version__}
"""
story.append(Paragraph(info_text, info_style))
@@ -247,66 +285,147 @@ class DIN5008AuditPDF:
spaceAfter=8,
)))
# Build table data with wrapped cells for better readability on A4 pages
cell_style = ParagraphStyle(
'EventCell',
parent=styles['Normal'],
fontName='Helvetica',
fontSize=8,
leading=9.5,
textColor=HexColor('#1f2937'),
alignment=TA_LEFT,
)
hash_style = ParagraphStyle(
'EventHashCell',
parent=cell_style,
fontName='Courier',
fontSize=7.2,
leading=9,
wordWrap='CJK',
)
def _safe(value):
return str(value or '').replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def _fmt_ts(value):
text = _safe(value)
if len(text) >= 19 and text[4] == '-' and text[7] == '-':
# Convert 2026-05-10 16:19:07... -> 10.05.2026 16:19
return f"{text[8:10]}.{text[5:7]}.{text[0:4]} {text[11:16]}"
return text[:16]
def _fmt_event(value):
text = _safe(value).replace('_', ' ').strip()
return text[:60]
def _chunk_text(value, chunk=4, sep=' '):
text = _safe(value)
if not text:
return ''
return sep.join(text[i:i + chunk] for i in range(0, len(text), chunk))
def _fmt_ip(value):
text = _safe(value)
# Keep IPv4 intact. For long IPv6 values insert only one line break in the middle.
if ':' in text and len(text) > 24:
parts = text.split(':')
if len(parts) > 4:
return ':'.join(parts[:4]) + ':<br/>' + ':'.join(parts[4:])
return text
# Build table data
if self.export_type == 'quick':
# Quick-Check: Minimal columns
table_data = [
['Index', 'Zeit', 'Ereignis', 'Benutzer', 'Hashwert (gekürzt)'],
['Idx', 'Zeit', 'Ereignis', 'Benutzer', 'Hash (gekürzt)'],
]
for row in audit_rows[:20]: # Limit to 20 rows for quick check
chain_idx = str(row.get('chain_index', ''))
timestamp = str(row.get('timestamp') or row.get('created_at', ''))[:16]
event_type = str(row.get('event_type', ''))
actor = str(row.get('actor', ''))
entry_hash = str(row.get('entry_hash', ''))[:12] + '...'
chain_idx = _safe(row.get('chain_index', ''))
timestamp = _fmt_ts(row.get('timestamp') or row.get('created_at', ''))
event_type = _fmt_event(row.get('event_type', ''))
actor = _safe(row.get('actor', ''))
entry_hash = _chunk_text(_safe(row.get('entry_hash', ''))[:20], chunk=4)
if entry_hash:
entry_hash += ' ...'
table_data.append([chain_idx, timestamp, event_type, actor, entry_hash])
table_data.append([
Paragraph(chain_idx, cell_style),
Paragraph(timestamp, cell_style),
Paragraph(event_type, cell_style),
Paragraph(actor, cell_style),
Paragraph(entry_hash, hash_style),
])
colWidths = [1.2*cm, 1.8*cm, 2.2*cm, 2*cm, 3.8*cm]
colWidths = [1.1*cm, 2.7*cm, 4.8*cm, 3.1*cm, 4.9*cm]
else:
# Official Report: Full columns
table_data = [
['Idx', 'Zeitstempel', 'Ereignistyp', 'Benutzer', 'Quelle', 'IP-Adresse', 'Hashwert'],
['Idx', 'Zeit', 'Ereignis', 'Benutzer', 'Quelle', 'IP', 'Hash'],
]
for row in audit_rows:
chain_idx = str(row.get('chain_index', ''))
timestamp = str(row.get('timestamp') or row.get('created_at', ''))[:19]
event_type = str(row.get('event_type', ''))
actor = str(row.get('actor', ''))
source = str(row.get('source', 'System'))[:15]
ip = str(row.get('ip', ''))
entry_hash = str(row.get('entry_hash', ''))[:16]
chain_idx = _safe(row.get('chain_index', ''))
timestamp = _fmt_ts(row.get('timestamp') or row.get('created_at', ''))
event_type = _fmt_event(row.get('event_type', ''))
actor = _safe(row.get('actor', ''))
source = _safe(row.get('source', 'System'))
ip = _fmt_ip(row.get('ip', ''))
entry_hash = _chunk_text(_safe(row.get('entry_hash', ''))[:40], chunk=8)
table_data.append([chain_idx, timestamp, event_type, actor, source, ip, entry_hash])
table_data.append([
Paragraph(chain_idx, cell_style),
Paragraph(timestamp, cell_style),
Paragraph(event_type, cell_style),
Paragraph(actor, cell_style),
Paragraph(source, cell_style),
Paragraph(ip, cell_style),
Paragraph(entry_hash, hash_style),
])
colWidths = [0.8*cm, 1.8*cm, 1.5*cm, 1.5*cm, 1.2*cm, 1.5*cm, 2.2*cm]
# Give IP and hash columns significantly more room for readability.
colWidths = [0.9*cm, 2.4*cm, 2.8*cm, 2.0*cm, 1.4*cm, 3.3*cm, 4.2*cm]
# Create table
events_table = Table(table_data, colWidths=colWidths)
events_table = Table(table_data, colWidths=colWidths, repeatRows=1)
events_table.setStyle(TableStyle([
# Header styling
('BACKGROUND', (0, 0), (-1, 0), HexColor('#2c3e50')),
('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#ffffff')),
('ALIGN', (0, 0), (-1, 0), 'CENTER'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 8),
('FONTSIZE', (0, 0), (-1, 0), 8.5),
('TOPPADDING', (0, 0), (-1, 0), 6),
('BOTTOMPADDING', (0, 0), (-1, 0), 6),
# Body styling
('FONTSIZE', (0, 1), (-1, -1), 7),
('FONTSIZE', (0, 1), (-1, -1), 8),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#bdc3c7')),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [HexColor('#ecf0f1'), HexColor('#ffffff')]),
('TOPPADDING', (0, 1), (-1, -1), 4),
('BOTTOMPADDING', (0, 1), (-1, -1), 4),
('LEFTPADDING', (0, 1), (-1, -1), 3),
('RIGHTPADDING', (0, 1), (-1, -1), 3),
('TOPPADDING', (0, 1), (-1, -1), 5),
('BOTTOMPADDING', (0, 1), (-1, -1), 5),
('LEFTPADDING', (0, 1), (-1, -1), 4),
('RIGHTPADDING', (0, 1), (-1, -1), 4),
('ALIGN', (0, 1), (0, -1), 'CENTER'),
('ALIGN', (1, 1), (1, -1), 'CENTER'),
('ALIGN', (-1, 1), (-1, -1), 'LEFT'),
]))
story.append(events_table)
story.append(Paragraph(
"Hinweis: Zeitangaben sind auf Minuten gerundet; Hashwerte werden aus Platzgründen gekürzt dargestellt.",
ParagraphStyle(
'TableHint',
parent=styles['Normal'],
fontSize=7.5,
leading=9,
textColor=HexColor('#6b7280'),
alignment=TA_LEFT,
spaceBefore=4,
)
))
story.append(Spacer(1, 0.2 * cm))
def _add_mismatches(self, story, mismatches):
@@ -549,3 +668,123 @@ def generate_audit_pdf(verify_result, event_counts, audit_rows, export_type='off
return pdf_gen.generate_quick_check(verify_result, event_counts, audit_rows)
else:
return pdf_gen.generate_official_report(verify_result, event_counts, audit_rows)
def _build_invoice_pdf(invoice_data):
"""Render a PDF invoice for a damaged borrowed item."""
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.colors import HexColor, black, white
from reportlab.lib.utils import simpleSplit
from reportlab.pdfgen import canvas
pdf_buffer = io.BytesIO()
c = canvas.Canvas(pdf_buffer, pagesize=A4)
page_width, page_height = A4
margin_x = 20 * mm
margin_top = 20 * mm
usable_width = page_width - (2 * margin_x)
current_y = page_height - margin_top
dark_color = HexColor('#0F172A')
accent_color = HexColor('#B91C1C')
light_color = HexColor('#F8FAFC')
border_color = HexColor('#CBD5E1')
text_color = HexColor('#1E293B')
muted_color = HexColor('#64748B')
def draw_wrapped_lines(text, x_pos, y_pos, width, font_name='Helvetica', font_size=11, leading=14, color=text_color):
if not text:
return y_pos
c.setFont(font_name, font_size)
c.setFillColor(color)
for line in simpleSplit(str(text), font_name, font_size, width):
c.drawString(x_pos, y_pos, line)
y_pos -= leading
return y_pos
def draw_label_value(label, value, x_pos, y_pos, label_width=45 * mm):
c.setFont('Helvetica-Bold', 10)
c.setFillColor(muted_color)
c.drawString(x_pos, y_pos, label)
c.setFont('Helvetica', 10)
c.setFillColor(text_color)
c.drawString(x_pos + label_width, y_pos, str(value or '-'))
return y_pos - 7 * mm
c.setFillColor(light_color)
c.rect(0, 0, page_width, page_height, fill=1, stroke=0)
c.setFillColor(dark_color)
c.rect(0, page_height - 28 * mm, page_width, 28 * mm, fill=1, stroke=0)
c.setFillColor(white)
c.setFont('Helvetica-Bold', 20)
c.drawString(margin_x, page_height - 16 * mm, 'RECHNUNG')
c.setFont('Helvetica', 10)
c.drawString(margin_x, page_height - 23 * mm, 'Inventarsystem - Schadensersatz für zerstörtes Ausleihobjekt')
current_y = page_height - 40 * mm
c.setStrokeColor(border_color)
c.setLineWidth(1)
c.line(margin_x, current_y, page_width - margin_x, current_y)
current_y -= 12 * mm
invoice_number = invoice_data.get('invoice_number', '-')
created_at = invoice_data.get('created_at_display', '-')
borrower = invoice_data.get('borrower', '-')
item_name = invoice_data.get('item_name', '-')
item_code = invoice_data.get('item_code', '-')
item_id = invoice_data.get('item_id', '-')
damage_reason = invoice_data.get('damage_reason', '-')
amount_text = invoice_data.get('amount_text', '-')
current_y = draw_label_value('Rechnungsnummer:', invoice_number, margin_x, current_y)
current_y = draw_label_value('Datum:', created_at, margin_x, current_y)
current_y = draw_label_value('Empfänger:', borrower, margin_x, current_y)
current_y = draw_label_value('Ausleihe / Element:', item_name, margin_x, current_y)
current_y = draw_label_value('Element-ID:', item_id, margin_x, current_y)
current_y = draw_label_value('Code:', item_code, margin_x, current_y)
current_y = current_y - 3 * mm
c.setFillColor(dark_color)
c.setFont('Helvetica-Bold', 12)
c.drawString(margin_x, current_y, 'Schadensbeschreibung')
current_y -= 6 * mm
current_y = draw_wrapped_lines(damage_reason, margin_x, current_y, usable_width, font_size=10, leading=13, color=text_color)
current_y -= 4 * mm
c.setFillColor(dark_color)
c.setFont('Helvetica-Bold', 12)
c.drawString(margin_x, current_y, 'Rechnungsbetrag')
current_y -= 8 * mm
c.setFillColor(accent_color)
c.setStrokeColor(accent_color)
c.rect(margin_x, current_y - 12 * mm, usable_width, 16 * mm, fill=1, stroke=0)
c.setFillColor(white)
c.setFont('Helvetica-Bold', 16)
c.drawString(margin_x + 5 * mm, current_y - 2 * mm, amount_text)
current_y -= 20 * mm
current_y = draw_wrapped_lines(
'Bitte begleichen Sie diesen Betrag zeitnah bei der Verwaltung. Der Betrag ergibt sich aus dem zerstörten Ausleihobjekt und der dokumentierten Schadensmeldung.',
margin_x,
current_y,
usable_width,
font_size=10,
leading=13,
color=muted_color,
)
footer_y = 18 * mm
c.setStrokeColor(border_color)
c.setLineWidth(0.8)
c.line(margin_x, footer_y + 8 * mm, page_width - margin_x, footer_y + 8 * mm)
c.setFillColor(muted_color)
c.setFont('Helvetica', 9)
c.drawString(margin_x, footer_y, 'Inventarsystem - Rechnungserstellung')
c.drawRightString(page_width - margin_x, footer_y, f'{amount_text}')
c.save()
pdf_buffer.seek(0)
return pdf_buffer
+96 -1
View File
@@ -61,6 +61,17 @@ DEFAULTS = {
'logs': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'logs'),
'deleted_archives': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'deleted_archives'),
},
'school': {
'name': 'Schulname',
'address': 'Schulstraße 1',
'postal_code': '00000',
'city': 'Ort',
'school_number': '000000',
'it_admin': 'IT-Beauftragte oder IT-Beauftragter',
'logo_path': '',
'logo_thumb': '',
'logo_thumb': '',
},
'schoolPeriods': {
"1": {"start": "08:00", "end": "08:45", "label": "1. Stunde (08:00 - 08:45)"},
"2": {"start": "08:45", "end": "09:30", "label": "2. Stunde (08:45 - 09:30)"},
@@ -163,6 +174,7 @@ SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
# School periods
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
SCHOOL_INFO_DEFAULT = _get(_conf, ['school'], DEFAULTS['school'])
# Optional feature modules
TENANT_CONFIGS = _get(_conf, ['tenants'], {})
@@ -193,11 +205,32 @@ class _TenantAwareBool:
return f"_TenantAwareBool(module_name={self.module_name!r}, value={self.resolve()!r})"
from module_registry import registry as MODULES
INVENTORY_MODULE_ENABLED = _TenantAwareBool('inventory', _get(_conf, ['modules', 'inventory', 'enabled'], DEFAULTS['modules']['inventory']['enabled']))
LIBRARY_MODULE_ENABLED = _TenantAwareBool('library', _get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
STUDENT_CARDS_MODULE_ENABLED = _TenantAwareBool('student_cards', _get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
def _match_inventory(path):
if not path: return False
if path == '/' or path.startswith('/home'): return True
return path.startswith(('/scanner', '/inventory', '/upload_admin', '/manage_filters', '/manage_locations', '/admin_borrowings', '/admin_damaged_items', '/admin/borrowings', '/admin/damaged_items', '/terminplan'))
def _match_library(path):
if not path: return False
return path.startswith(('/library', '/library_', '/student_cards'))
def _match_student_cards(path):
if not path: return False
return path.startswith(('/student_cards'))
# Register core modules into the pipeline
MODULES.register('inventory', INVENTORY_MODULE_ENABLED, _match_inventory)
MODULES.register('library', LIBRARY_MODULE_ENABLED, _match_library)
MODULES.register('student_cards', STUDENT_CARDS_MODULE_ENABLED, _match_student_cards)
STUDENT_DEFAULT_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'default_borrow_days'], DEFAULTS['modules']['student_cards']['default_borrow_days']))
STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'max_borrow_days'], DEFAULTS['modules']['student_cards']['max_borrow_days']))
STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ["modules", "student_cards", "max_borrow_days"], DEFAULTS["modules"]["student_cards"]["max_borrow_days"]))
# Upload/paths
ALLOWED_EXTENSIONS = set(_get(_conf, ['allowed_extensions'], DEFAULTS['upload']['allowed_extensions']))
@@ -348,3 +381,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)
-16
View File
@@ -20,28 +20,12 @@
<h4 style="margin:0 0 10px 0; color:#1a1a1a;">📄 PDF-Export (DIN 5008 konform)</h4>
<p style="margin:0 0 10px 0; font-size:0.9rem; color:#666;">Professionelle Berichte für Schulträger und Behörden</p>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<a class="btn btn-primary" href="{{ url_for('admin_audit_export_pdf_quick') }}" style="flex:1; text-align:center;">
🚀 Schnell-Check (kompakt)
</a>
<a class="btn btn-primary" href="{{ url_for('admin_audit_export_pdf_official') }}" style="flex:1; text-align:center;">
📋 Amtlicher Bericht (DIN 5008)
</a>
</div>
</div>
<!-- Other Formats Section -->
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
<h4 style="margin:0 0 10px 0; color:#1a1a1a;">📊 Weitere Formate</h4>
<p style="margin:0 0 10px 0; font-size:0.9rem; color:#666;">Technische Exporte für System-Integration</p>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<a class="btn btn-outline-secondary" href="{{ url_for('admin_audit_export', format='md') }}" style="flex:1; text-align:center;">
📝 Markdown
</a>
<a class="btn btn-outline-secondary" href="{{ url_for('admin_audit_export', format='json') }}" style="flex:1; text-align:center;">
⚙️ JSON
</a>
</div>
</div>
</div>
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0 20px;">
+268
View File
@@ -0,0 +1,268 @@
<!--
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') }}" enctype="multipart/form-data">
<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_upload">Schullogo hochladen</label>
<input type="file" id="logo_upload" name="logo_upload" accept=".png,.jpg,.jpeg,.gif,.webp,.svg,image/*">
<small>Upload ersetzt das bisherige Logo. Wenn kein neues Logo gewählt wird, bleibt das aktuelle erhalten.</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">
{% if school_info.logo_path %}
<div style="margin-bottom:16px; padding:12px; border:1px solid #e5e7eb; border-radius:10px; background:#fff;">
<div style="font-weight:700; margin-bottom:8px; color:#374151;">Aktuelles Logo</div>
<img src="{{ url_for('uploaded_file', filename=school_info.logo_path) }}" alt="Aktuelles Schullogo" style="max-width:100%; max-height:180px; object-fit:contain; display:block;">
<div style="margin-top:8px; font-size:0.85rem; color:#6b7280; word-break:break-word;">{{ school_info.logo_path }}</div>
</div>
{% endif %}
<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 %}
+17 -3
View File
@@ -1127,6 +1127,7 @@
{% if current_permissions.pages.get('manage_locations', True) %}
<li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li>
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('admin_school_settings') }}">Schulstammdaten</a></li>
{% if current_permissions.pages.get('admin_borrowings', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li>
{% endif %}
@@ -1252,6 +1253,7 @@
{% if student_cards_module_enabled %}
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('admin_school_settings') }}">Schulstammdaten</a></li>
<li><hr class="dropdown-divider"></li>
{% if current_permissions.actions.get('can_manage_users', True) %}
<li><h6 class="dropdown-header">System</h6></li>
@@ -1311,9 +1313,19 @@
{% else %}
<nav class="navbar navbar-expand-lg navbar-dark" id="loginNavbar">
<div class="container-fluid">
<a class="navbar-brand py-0" href="{{ url_for('home') }}">
<img src="{{ url_for('static', filename='img/invario-logo.png') }}" alt="Invario" class="invario-logo">
</a>
<a class="navbar-brand py-0" href="{{ url_for('home') }}">
{% set school_logo_thumb = school_info.get('logo_thumb') if school_info else '' %}
{% set school_logo_path = school_info.get('logo_path') if school_info else '' %}
{% if school_logo_thumb or school_logo_path %}
{% if school_logo_thumb %}
<img src="{{ url_for('uploaded_file', filename=school_logo_thumb) }}" alt="{{ school_info.name or 'Schullogo' }}" class="invario-logo">
{% else %}
<img src="{{ url_for('uploaded_file', filename=school_logo_path) }}" alt="{{ school_info.name or 'Schullogo' }}" class="invario-logo">
{% endif %}
{% else %}
<img src="{{ url_for('static', filename='img/invario-logo.png') }}" alt="Invario" class="invario-logo">
{% endif %}
</a>
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('impressum') }}">Impressum</a>
@@ -1519,6 +1531,7 @@
<option value="Defekte Items"></option>
<option value="Filter verwalten"></option>
<option value="Orte verwalten"></option>
<option value="Schulstammdaten"></option>
<option value="Audit Dashboard"></option>
<option value="Logs"></option>
<option value="Benutzer verwalten"></option>
@@ -1588,6 +1601,7 @@
{ label: 'Defekte Items', keywords: ['defekte items', 'defekt', 'schaden'], url: {{ url_for('admin_damaged_items')|tojson }} },
{ label: 'Filter verwalten', keywords: ['filter verwalten', 'filter'], url: {{ url_for('manage_filters')|tojson }} },
{ label: 'Orte verwalten', keywords: ['orte verwalten', 'orte', 'location'], url: {{ url_for('manage_locations')|tojson }} },
{ label: 'Schulstammdaten', keywords: ['schule', 'settings', 'stammdaten', 'school settings'], url: {{ url_for('admin_school_settings')|tojson }} },
{ label: 'Audit Dashboard', keywords: ['audit', 'audit dashboard'], url: {{ url_for('admin_audit_dashboard')|tojson }} },
{ label: 'Logs', keywords: ['logs', 'protokoll'], url: {{ url_for('logs')|tojson }} },
{ label: 'Benutzer verwalten', keywords: ['benutzer verwalten', 'user'], url: {{ url_for('user_del')|tojson }} },
+3 -3
View File
@@ -459,7 +459,7 @@
<div id="schedule-modal" class="item-modal">
<div class="modal-content">
<span class="close-modal" onclick="closeScheduleModal()">&times;</span>
<h2>Termin planen</h2>
<h2>Reservieren</h2>
<form id="schedule-form">
<input type="hidden" id="schedule-item-id" name="item_id">
@@ -1057,7 +1057,7 @@
:
`<button class="ausleihen disabled-button" disabled>${item.BlockedNow ? 'Reserviert' : 'Ausgeliehen'}</button>`
}
${canScheduleItem ? `<button class="schedule-button" type="button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
${canScheduleItem ? `<button class="schedule-button" type="button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
<button class="quick-details-button" type="button" onclick="openItemQuick('${item._id}')">Details</button>
</div>
`;
@@ -1734,7 +1734,7 @@
</form>`
: `<button class="ausleihen disabled-button" disabled>${item.BlockedNow ? 'Reserviert' : 'Ausgeliehen'}</button>`
}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
<button type="button" class="details-button" onclick="document.getElementById('item-modal').style.display='none';">Schließen</button>
</div>
`;
+3 -3
View File
@@ -2643,7 +2643,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div id="schedule-modal" class="item-modal">
<div class="modal-content">
<span class="close-modal" onclick="closeScheduleModal()">&times;</span>
<h2>Termin planen</h2>
<h2>Reservieren</h2>
<form id="schedule-form">
<input type="hidden" id="schedule-item-id" name="item_id">
<div class="form-group" id="schedule-specific-item-group" style="display:none;">
@@ -3654,7 +3654,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
</form>
<button class="edit-button" onclick="openEditModalForSelectedUnit('${item._id}', 'specific-item-card-${item._id}')">Bearbeiten</button>
<button class="duplicate-button" onclick="duplicateItem('${item._id}')">Duplizieren</button>
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
</div>
`;
itemsContainer.appendChild(card);
@@ -4531,7 +4531,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<button class="edit-button" onclick="openEditModalForSelectedUnit('${item._id}', 'specific-item-modal-${item._id}')">Bearbeiten</button>
<button class="duplicate-button" onclick="duplicateItem('${item._id}')">Duplizieren</button>
${damageReports.length > 0 ? `<button class="damage-button" onclick="markDamageAsRepaired('${item._id}')">Repariert</button>` : `<button class="damage-button" onclick="registerDamage('${item._id}')">Schaden melden</button>`}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
<form method="POST" action="/delete_item/${item._id}" style="display:inline;" onsubmit="return confirm('Sind Sie sicher?')">
<button class="delete-button" type="submit">Löschen</button>
</form>
-7
View File
@@ -1,7 +0,0 @@
from app import app
with app.test_client() as client:
with client.session_transaction() as sess:
sess['username'] = 'admin'
sess['admin'] = True
resp = client.get('/terminplan')
print("Status:", resp.status_code)
-30
View File
@@ -1,30 +0,0 @@
#!/usr/bin/env python3
"""CLI utility to verify the tamper-evident audit chain."""
import json
import sys
import settings as cfg
from settings import MongoClient
import audit_log as al
def main():
client = None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
al.ensure_audit_indexes(db)
result = al.verify_audit_chain(db)
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
return 0 if result.get("ok") else 2
except Exception as exc:
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
return 1
finally:
if client:
client.close()
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -1,7 +1,7 @@
{
"dbg": false,
"key": "InventarsystemSecureKey2026XYZ789abcdef012",
"ver": "0.6.44",
"ver": "0.7.20",
"host": "0.0.0.0",
"port": 8000,
"mongodb": {