Compare commits

...

16 Commits

Author SHA1 Message Date
Aiirondev_dev f314b5ae70 fix(tenant): apply DB aliases to tenant config resolution to prevent module configuration overrides by defaults 2026-05-11 21:08:37 +02:00
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
13 changed files with 738 additions and 818 deletions
View File
+376 -710
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()
+262 -26
View File
@@ -7,6 +7,7 @@ PDF/A archiving standards for German schools and educational authorities.
import io import io
import json import json
import datetime import datetime
import os
import qrcode import qrcode
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
@@ -110,6 +111,20 @@ class DIN5008AuditPDF:
textColor=HexColor('#000000'), 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""" school_info_text = f"""
<b>{school_name}</b><br/> <b>{school_name}</b><br/>
{address}<br/> {address}<br/>
@@ -117,7 +132,27 @@ class DIN5008AuditPDF:
<i>Schulnummer: {school_number}</i> <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) # Information block (right side simulation)
story.append(Spacer(1, 0.3 * cm)) story.append(Spacer(1, 0.3 * cm))
@@ -250,66 +285,147 @@ class DIN5008AuditPDF:
spaceAfter=8, 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 # Build table data
if self.export_type == 'quick': if self.export_type == 'quick':
# Quick-Check: Minimal columns # Quick-Check: Minimal columns
table_data = [ 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 for row in audit_rows[:20]: # Limit to 20 rows for quick check
chain_idx = str(row.get('chain_index', '')) chain_idx = _safe(row.get('chain_index', ''))
timestamp = str(row.get('timestamp') or row.get('created_at', ''))[:16] timestamp = _fmt_ts(row.get('timestamp') or row.get('created_at', ''))
event_type = str(row.get('event_type', '')) event_type = _fmt_event(row.get('event_type', ''))
actor = str(row.get('actor', '')) actor = _safe(row.get('actor', ''))
entry_hash = str(row.get('entry_hash', ''))[:12] + '...' 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: else:
# Official Report: Full columns # Official Report: Full columns
table_data = [ table_data = [
['Idx', 'Zeitstempel', 'Ereignistyp', 'Benutzer', 'Quelle', 'IP-Adresse', 'Hashwert'], ['Idx', 'Zeit', 'Ereignis', 'Benutzer', 'Quelle', 'IP', 'Hash'],
] ]
for row in audit_rows: for row in audit_rows:
chain_idx = str(row.get('chain_index', '')) chain_idx = _safe(row.get('chain_index', ''))
timestamp = str(row.get('timestamp') or row.get('created_at', ''))[:19] timestamp = _fmt_ts(row.get('timestamp') or row.get('created_at', ''))
event_type = str(row.get('event_type', '')) event_type = _fmt_event(row.get('event_type', ''))
actor = str(row.get('actor', '')) actor = _safe(row.get('actor', ''))
source = str(row.get('source', 'System'))[:15] source = _safe(row.get('source', 'System'))
ip = str(row.get('ip', '')) ip = _fmt_ip(row.get('ip', ''))
entry_hash = str(row.get('entry_hash', ''))[:16] 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 # Create table
events_table = Table(table_data, colWidths=colWidths) events_table = Table(table_data, colWidths=colWidths, repeatRows=1)
events_table.setStyle(TableStyle([ events_table.setStyle(TableStyle([
# Header styling # Header styling
('BACKGROUND', (0, 0), (-1, 0), HexColor('#2c3e50')), ('BACKGROUND', (0, 0), (-1, 0), HexColor('#2c3e50')),
('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#ffffff')), ('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#ffffff')),
('ALIGN', (0, 0), (-1, 0), 'CENTER'), ('ALIGN', (0, 0), (-1, 0), 'CENTER'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('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), ('BOTTOMPADDING', (0, 0), (-1, 0), 6),
# Body styling # Body styling
('FONTSIZE', (0, 1), (-1, -1), 7), ('FONTSIZE', (0, 1), (-1, -1), 8),
('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'), ('VALIGN', (0, 0), (-1, -1), 'TOP'),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#bdc3c7')), ('GRID', (0, 0), (-1, -1), 0.5, HexColor('#bdc3c7')),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [HexColor('#ecf0f1'), HexColor('#ffffff')]), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [HexColor('#ecf0f1'), HexColor('#ffffff')]),
('TOPPADDING', (0, 1), (-1, -1), 4), ('TOPPADDING', (0, 1), (-1, -1), 5),
('BOTTOMPADDING', (0, 1), (-1, -1), 4), ('BOTTOMPADDING', (0, 1), (-1, -1), 5),
('LEFTPADDING', (0, 1), (-1, -1), 3), ('LEFTPADDING', (0, 1), (-1, -1), 4),
('RIGHTPADDING', (0, 1), (-1, -1), 3), ('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(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)) story.append(Spacer(1, 0.2 * cm))
def _add_mismatches(self, story, mismatches): def _add_mismatches(self, story, mismatches):
@@ -552,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) return pdf_gen.generate_quick_check(verify_result, event_counts, audit_rows)
else: else:
return pdf_gen.generate_official_report(verify_result, event_counts, audit_rows) 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
+34 -1
View File
@@ -61,6 +61,17 @@ DEFAULTS = {
'logs': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'logs'), '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'), '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': { 'schoolPeriods': {
"1": {"start": "08:00", "end": "08:45", "label": "1. Stunde (08:00 - 08:45)"}, "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)"}, "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
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods']) SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
SCHOOL_INFO_DEFAULT = _get(_conf, ['school'], DEFAULTS['school'])
# Optional feature modules # Optional feature modules
TENANT_CONFIGS = _get(_conf, ['tenants'], {}) TENANT_CONFIGS = _get(_conf, ['tenants'], {})
@@ -193,11 +205,32 @@ class _TenantAwareBool:
return f"_TenantAwareBool(module_name={self.module_name!r}, value={self.resolve()!r})" 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'])) 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'])) 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'])) 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_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 # Upload/paths
ALLOWED_EXTENSIONS = set(_get(_conf, ['allowed_extensions'], DEFAULTS['upload']['allowed_extensions'])) ALLOWED_EXTENSIONS = set(_get(_conf, ['allowed_extensions'], DEFAULTS['upload']['allowed_extensions']))
-16
View File
@@ -20,28 +20,12 @@
<h4 style="margin:0 0 10px 0; color:#1a1a1a;">📄 PDF-Export (DIN 5008 konform)</h4> <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> <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;"> <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;"> <a class="btn btn-primary" href="{{ url_for('admin_audit_export_pdf_official') }}" style="flex:1; text-align:center;">
📋 Amtlicher Bericht (DIN 5008) 📋 Amtlicher Bericht (DIN 5008)
</a> </a>
</div> </div>
</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>
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0 20px;"> <div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0 20px;">
+11 -4
View File
@@ -32,7 +32,7 @@
<h2>Schuldaten</h2> <h2>Schuldaten</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('admin_school_settings') }}"> <form method="POST" action="{{ url_for('admin_school_settings') }}" enctype="multipart/form-data">
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="name">Schulname *</label> <label for="name">Schulname *</label>
@@ -66,9 +66,9 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="logo_path">Logo-Pfad oder Dateiname</label> <label for="logo_upload">Schullogo hochladen</label>
<input type="text" id="logo_path" name="logo_path" value="{{ school_info.logo_path or '' }}" placeholder="optional, z. B. static/img/schullogo.png"> <input type="file" id="logo_upload" name="logo_upload" accept=".png,.jpg,.jpeg,.gif,.webp,.svg,image/*">
<small>Der Logo-Pfad wird gespeichert und kann später für den PDF-Briefkopf verwendet werden.</small> <small>Upload ersetzt das bisherige Logo. Wenn kein neues Logo gewählt wird, bleibt das aktuelle erhalten.</small>
</div> </div>
<div class="actions-row"> <div class="actions-row">
@@ -84,6 +84,13 @@
<h2>Aktuelle Vorschau</h2> <h2>Aktuelle Vorschau</h2>
</div> </div>
<div class="card-body"> <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>Schulname:</strong> {{ school_info.name or 'Nicht gesetzt' }}</p>
<p><strong>Adresse:</strong> {{ school_info.address 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>Schulnummer:</strong> {{ school_info.school_number or 'Nicht gesetzt' }}</p>
+17 -3
View File
@@ -1127,6 +1127,7 @@
{% if current_permissions.pages.get('manage_locations', True) %} {% if current_permissions.pages.get('manage_locations', True) %}
<li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li> <li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li>
{% endif %} {% endif %}
<li><a class="dropdown-item" href="{{ url_for('admin_school_settings') }}">Schulstammdaten</a></li>
{% if current_permissions.pages.get('admin_borrowings', True) %} {% if current_permissions.pages.get('admin_borrowings', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li> <li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li>
{% endif %} {% endif %}
@@ -1252,6 +1253,7 @@
{% if student_cards_module_enabled %} {% if student_cards_module_enabled %}
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li> <li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
{% endif %} {% endif %}
<li><a class="dropdown-item" href="{{ url_for('admin_school_settings') }}">Schulstammdaten</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
{% if current_permissions.actions.get('can_manage_users', True) %} {% if current_permissions.actions.get('can_manage_users', True) %}
<li><h6 class="dropdown-header">System</h6></li> <li><h6 class="dropdown-header">System</h6></li>
@@ -1311,9 +1313,19 @@
{% else %} {% else %}
<nav class="navbar navbar-expand-lg navbar-dark" id="loginNavbar"> <nav class="navbar navbar-expand-lg navbar-dark" id="loginNavbar">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand py-0" href="{{ url_for('home') }}"> <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"> {% set school_logo_thumb = school_info.get('logo_thumb') if school_info else '' %}
</a> {% 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"> <ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('impressum') }}">Impressum</a> <a class="nav-link" href="{{ url_for('impressum') }}">Impressum</a>
@@ -1519,6 +1531,7 @@
<option value="Defekte Items"></option> <option value="Defekte Items"></option>
<option value="Filter verwalten"></option> <option value="Filter verwalten"></option>
<option value="Orte verwalten"></option> <option value="Orte verwalten"></option>
<option value="Schulstammdaten"></option>
<option value="Audit Dashboard"></option> <option value="Audit Dashboard"></option>
<option value="Logs"></option> <option value="Logs"></option>
<option value="Benutzer verwalten"></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: '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: '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: '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: 'Audit Dashboard', keywords: ['audit', 'audit dashboard'], url: {{ url_for('admin_audit_dashboard')|tojson }} },
{ label: 'Logs', keywords: ['logs', 'protokoll'], url: {{ url_for('logs')|tojson }} }, { label: 'Logs', keywords: ['logs', 'protokoll'], url: {{ url_for('logs')|tojson }} },
{ label: 'Benutzer verwalten', keywords: ['benutzer verwalten', 'user'], url: {{ url_for('user_del')|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 id="schedule-modal" class="item-modal">
<div class="modal-content"> <div class="modal-content">
<span class="close-modal" onclick="closeScheduleModal()">&times;</span> <span class="close-modal" onclick="closeScheduleModal()">&times;</span>
<h2>Termin planen</h2> <h2>Reservieren</h2>
<form id="schedule-form"> <form id="schedule-form">
<input type="hidden" id="schedule-item-id" name="item_id"> <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>` `<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> <button class="quick-details-button" type="button" onclick="openItemQuick('${item._id}')">Details</button>
</div> </div>
`; `;
@@ -1734,7 +1734,7 @@
</form>` </form>`
: `<button class="ausleihen disabled-button" disabled>${item.BlockedNow ? 'Reserviert' : 'Ausgeliehen'}</button>` : `<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> <button type="button" class="details-button" onclick="document.getElementById('item-modal').style.display='none';">Schließen</button>
</div> </div>
`; `;
+3 -16
View File
@@ -2345,19 +2345,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
</div> </div>
</div> </div>
</h1> </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="items-indicator" class="items-indicator">Objekte im System: 0</div>
<div id="bulk-delete-drawer" class="bulk-delete-drawer" aria-hidden="true"> <div id="bulk-delete-drawer" class="bulk-delete-drawer" aria-hidden="true">
<div class="bulk-delete-content"> <div class="bulk-delete-content">
@@ -2656,7 +2643,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div id="schedule-modal" class="item-modal"> <div id="schedule-modal" class="item-modal">
<div class="modal-content"> <div class="modal-content">
<span class="close-modal" onclick="closeScheduleModal()">&times;</span> <span class="close-modal" onclick="closeScheduleModal()">&times;</span>
<h2>Termin planen</h2> <h2>Reservieren</h2>
<form id="schedule-form"> <form id="schedule-form">
<input type="hidden" id="schedule-item-id" name="item_id"> <input type="hidden" id="schedule-item-id" name="item_id">
<div class="form-group" id="schedule-specific-item-group" style="display:none;"> <div class="form-group" id="schedule-specific-item-group" style="display:none;">
@@ -3667,7 +3654,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
</form> </form>
<button class="edit-button" onclick="openEditModalForSelectedUnit('${item._id}', 'specific-item-card-${item._id}')">Bearbeiten</button> <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> <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> </div>
`; `;
itemsContainer.appendChild(card); itemsContainer.appendChild(card);
@@ -4544,7 +4531,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<button class="edit-button" onclick="openEditModalForSelectedUnit('${item._id}', 'specific-item-modal-${item._id}')">Bearbeiten</button> <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> <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>`} ${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?')"> <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> <button class="delete-button" type="submit">Löschen</button>
</form> </form>
+4
View File
@@ -97,6 +97,10 @@ def get_tenant_config(tenant_id=None):
if tenant_id in TENANT_REGISTRY: if tenant_id in TENANT_REGISTRY:
return TENANT_REGISTRY[tenant_id] or {} return TENANT_REGISTRY[tenant_id] or {}
for alias in _tenant_db_aliases(tenant_id):
if alias in TENANT_REGISTRY:
return TENANT_REGISTRY[alias] or {}
return TENANT_REGISTRY.get('default', {}) or {} return TENANT_REGISTRY.get('default', {}) or {}
-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())