Compare commits

...

26 Commits

Author SHA1 Message Date
Aiirondev_dev 299d11c1d0 feat: implement DOM virtualization for item cards in main and admin templates 2026-05-18 17:06:46 +02:00
Aiirondev_dev 4d1f95cb1f feat: improve filter3 options handling and enhance item card visibility 2026-05-18 16:53:11 +02:00
Aiirondev_dev 6b197fd9d1 feat: add third filter management and update filter handling logic 2026-05-18 16:40:11 +02:00
Aiirondev_dev 58064ee249 Changes to be committed:
modified:   Web/user.py
2026-05-13 16:05:13 +02:00
Aiirondev_dev f7573b9a62 feat: remove back navigation link from upload admin template 2026-05-12 09:32:13 +02:00
Aiirondev_dev 85d758fadb feat: update application image version to v0.7.42 2026-05-12 09:08:38 +02:00
Aiirondev_dev 212dd2abcf feat: Add 404 error handling for missing templates and files 2026-05-12 09:07:37 +02:00
Aiirondev_dev 689819df08 feat: update application image version and enhance docker-compose configuration 2026-05-12 08:16:04 +02:00
Aiirondev_dev 457d32a087 feat: Module fallback redirects and duplicate item modal redirects 2026-05-12 08:00:02 +02:00
Aiirondev_dev cded7b02fe fix(modal): add functionality to open edit modal for existing items based on URL parameters 2026-05-12 07:37:26 +02:00
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
22 changed files with 1119 additions and 885 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
NUITKA_BUILD=0 NUITKA_BUILD=0
INVENTAR_HTTP_PORT=10000 INVENTAR_HTTP_PORT=10000
INVENTAR_HTTP_PORTS=10000 INVENTAR_HTTP_PORTS=10000
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.42
INVENTAR_APP_CPUS=1.0 INVENTAR_APP_CPUS=1.0
INVENTAR_APP_MEM_LIMIT=384m INVENTAR_APP_MEM_LIMIT=384m
INVENTAR_APP_MEM_SWAP_LIMIT=768m INVENTAR_APP_MEM_SWAP_LIMIT=768m
+1
View File
@@ -0,0 +1 @@
v0.7.42
View File
+434 -718
View File
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -570,7 +570,10 @@ def get_primary_filters():
items = db['items'] items = db['items']
filters = [f for f in items.distinct('Filter', _active_record_query(_non_library_query())) if f] filters = [f for f in items.distinct('Filter', _active_record_query(_non_library_query())) if f]
client.close() client.close()
return filters
# Add predefined values
predefined = get_predefined_filter_values(1)
return sorted(list(set(filters + predefined)))
except Exception as e: except Exception as e:
print(f"Error retrieving primary filters: {e}") print(f"Error retrieving primary filters: {e}")
return [] return []
@@ -589,7 +592,10 @@ def get_secondary_filters():
items = db['items'] items = db['items']
filters = [f for f in items.distinct('Filter2', _active_record_query(_non_library_query())) if f] filters = [f for f in items.distinct('Filter2', _active_record_query(_non_library_query())) if f]
client.close() client.close()
return filters
# Add predefined values
predefined = get_predefined_filter_values(2)
return sorted(list(set(filters + predefined)))
except Exception as e: except Exception as e:
print(f"Error retrieving secondary filters: {e}") print(f"Error retrieving secondary filters: {e}")
return [] return []
@@ -608,7 +614,10 @@ def get_tertiary_filters():
items = db['items'] items = db['items']
filters = [f for f in items.distinct('Filter3', _active_record_query(_non_library_query())) if f] filters = [f for f in items.distinct('Filter3', _active_record_query(_non_library_query())) if f]
client.close() client.close()
return filters
# Add predefined values
predefined = get_predefined_filter_values(3)
return sorted(list(set(filters + predefined)))
except Exception as e: except Exception as e:
print(f"Error retrieving tertiary filters: {e}") print(f"Error retrieving tertiary filters: {e}")
return [] return []
+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 }} },
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}
{% if error_code == 404 %}
Seite nicht gefunden - Inventarsystem
{% else %}
Fehler - Inventarsystem
{% endif %}
{% endblock %}
{% block content %}
<div class="error-container" style="text-align: center; padding: 60px 20px; min-height: 70vh; display: flex; flex-direction: column; justify-content: center; align-items: center;">
<div style="max-width: 600px;">
<!-- Error Code Display -->
<div style="font-size: 120px; font-weight: bold; color: #ef4444; margin-bottom: 20px;">
{{ error_code }}
</div>
<!-- Error Title -->
<h1 style="font-size: 32px; margin: 20px 0; color: #1f2937;">
{% if error_code == 404 %}
Seite nicht gefunden
{% else %}
Ein Fehler ist aufgetreten
{% endif %}
</h1>
<!-- Error Message -->
<p style="font-size: 18px; color: #6b7280; margin: 20px 0 40px;">
{{ error_message or 'Es tut uns leid, aber die angeforderte Seite konnte nicht gefunden werden.' }}
</p>
<!-- Action Buttons -->
<div style="display: flex; gap: 15px; justify-content: center; flex-wrap: wrap;">
<a href="{{ url_for('home') }}" class="button" style="background-color: #3b82f6; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: 500;">
Zur Startseite
</a>
<a href="javascript:history.back()" class="button" style="background-color: #6b7280; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: 500;">
Zurück
</a>
</div>
<!-- Additional Help -->
<div style="margin-top: 60px; padding: 20px; background-color: #f3f4f6; border-radius: 8px;">
<p style="color: #6b7280; font-size: 14px;">
Wenn Sie denken, dass dies ein Fehler ist, oder Sie Hilfe benötigen,
<a href="{{ url_for('impressum') }}" style="color: #3b82f6; text-decoration: none;">kontaktieren Sie den Administrator</a>.
</p>
</div>
</div>
</div>
<style>
.error-container {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.button:hover {
opacity: 0.9;
transform: translateY(-2px);
transition: all 0.2s ease;
}
</style>
{% endblock %}
+73 -9
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">
@@ -751,6 +751,7 @@
let mainItemsSentinel = null; let mainItemsSentinel = null;
let mainItemsLoadingIndicator = null; let mainItemsLoadingIndicator = null;
let mainItemsScrollPrefetchBound = false; let mainItemsScrollPrefetchBound = false;
let cardVirtualizationObserver = null;
function ensureMainItemsLoadingIndicator() { function ensureMainItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container'); const itemsContainer = document.querySelector('#items-container');
@@ -1057,7 +1058,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>
`; `;
@@ -1189,6 +1190,55 @@
mainItemsScrollPrefetchBound = true; mainItemsScrollPrefetchBound = true;
} }
// Setup DOM virtualization for items
if (!cardVirtualizationObserver) {
cardVirtualizationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const card = entry.target;
if (entry.isIntersecting) {
if (card.dataset.deloaded === 'true') {
card.innerHTML = card.dataset.savedHtml;
card.dataset.deloaded = 'false';
card.classList.remove('is-deloaded');
const favBtn = card.querySelector('.bookmark-btn');
if (favBtn) {
favBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
toggleFavorite(card.dataset.itemId, favBtn, card);
});
}
const cardContent = card.querySelector('.card-content');
if (cardContent) {
cardContent.addEventListener('click', function(e) {
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A' || e.target.tagName === 'INPUT' ||
e.target.closest('button') || e.target.closest('a') || e.target.closest('.image-container')) return;
e.stopPropagation();
openItemQuick(card.dataset.itemId);
});
}
}
} else {
if (card.dataset.deloaded !== 'true') {
const rect = card.getBoundingClientRect();
if (rect.height > 50) {
card.style.minHeight = rect.height + 'px';
card.dataset.itemId = card.querySelector('.card-content')?.dataset.itemId || '';
card.dataset.savedHtml = card.innerHTML;
card.innerHTML = '';
card.dataset.deloaded = 'true';
card.classList.add('is-deloaded');
}
}
}
});
}, {
rootMargin: isMobileLayout ? '1500px 0px 1500px 0px' : '0px 2500px 0px 2500px'
});
}
// Observe all valid cards
document.querySelectorAll('.item-card').forEach(card => cardVirtualizationObserver.observe(card));
if (!mainItemsHasMore) return; if (!mainItemsHasMore) return;
mainItemsObserver = new IntersectionObserver(entries => { mainItemsObserver = new IntersectionObserver(entries => {
@@ -1734,7 +1784,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>
`; `;
@@ -2419,29 +2469,29 @@
// Add these functions right after the loadFilterState() function // Add these functions right after the loadFilterState() function
function rebuildFilter3Options() { function rebuildFilter3Options() {
if (!allItems || allItems.length === 0) return; if (!allItems) return;
// Get current filter 1 and filter 2 selections // Get current filter 1 and filter 2 selections
const filter1Selections = activeFilters.filter1; const filter1Selections = activeFilters.filter1;
const filter2Selections = activeFilters.filter2; const filter2Selections = activeFilters.filter2;
// If both filters are empty, show all filter3 values // If both filters are empty, show all filter3 values from server
if (filter1Selections.length === 0 && filter2Selections.length === 0) { if (filter1Selections.length === 0 && filter2Selections.length === 0) {
// Get all unique filter3 values // Include dynamically added ones from loaded items too, just in case
const allFilter3Values = new Set(); const combinedFilter3Values = new Set(typeof allFilter3Values !== 'undefined' ? [...allFilter3Values] : []);
allItems.forEach(item => { allItems.forEach(item => {
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 :
(item.Filter3 ? [item.Filter3] : []); (item.Filter3 ? [item.Filter3] : []);
filter3Array.forEach(value => { filter3Array.forEach(value => {
if (value && typeof value === 'string' && value.trim() !== '') { if (value && typeof value === 'string' && value.trim() !== '') {
allFilter3Values.add(value); combinedFilter3Values.add(value);
} }
}); });
}); });
// Populate filter 3 dropdown with all values // Populate filter 3 dropdown with all values
populateFilterOptions('filter3-options', [...allFilter3Values].sort(), 3); populateFilterOptions('filter3-options', [...combinedFilter3Values].sort(), 3);
return; return;
} }
@@ -3114,6 +3164,8 @@
cursor: pointer; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
position: relative; position: relative;
content-visibility: auto;
contain-intrinsic-size: 300px 450px;
} }
.item-card:hover { .item-card:hover {
transform: translateY(-5px); transform: translateY(-5px);
@@ -4614,6 +4666,18 @@ function openItemQuick(id){
} }
} }
</style> </style>
{% if open_item %}
<script>
document.addEventListener("DOMContentLoaded", function() {
if (typeof openEditModalFromServer === 'function') {
setTimeout(function() {
openEditModalFromServer('{{ open_item }}');
}, 500);
}
});
</script>
{% endif %}
{% endblock %} {% endblock %}
<style> <style>
/* Admin/Main Content Container align */ /* Admin/Main Content Container align */
+75 -24
View File
@@ -403,6 +403,8 @@
cursor: pointer; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
position: relative; position: relative;
content-visibility: auto;
contain-intrinsic-size: 300px 450px;
} }
.item-card.bulk-selected { .item-card.bulk-selected {
@@ -2345,19 +2347,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 +2645,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;">
@@ -2889,30 +2878,29 @@ document.addEventListener('DOMContentLoaded', ()=>{
} }
function rebuildFilter3Options() { function rebuildFilter3Options() {
if (!allItems || allItems.length === 0) return; if (!allItems) return;
// Get current filter 1 and filter 2 selections // Get current filter 1 and filter 2 selections
const filter1Selections = activeFilters.filter1; const filter1Selections = activeFilters.filter1;
const filter2Selections = activeFilters.filter2; const filter2Selections = activeFilters.filter2;
// If both filters are empty, show all filter3 values // If both filters are empty, show all filter3 values from server
if (filter1Selections.length === 0 && filter2Selections.length === 0) { if (filter1Selections.length === 0 && filter2Selections.length === 0) {
// Get all unique filter3 values // Include dynamically added ones from loaded items too, just in case
const allFilter3Values = new Set(); const combinedFilter3Values = new Set(typeof allFilter3Values !== 'undefined' ? [...allFilter3Values] : []);
allItems.forEach(item => { allItems.forEach(item => {
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 :
(item.Filter3 ? [item.Filter3] : []); (item.Filter3 ? [item.Filter3] : []);
filter3Array.forEach(value => { filter3Array.forEach(value => {
if (value && typeof value === 'string' && value.trim() !== '') { if (value && typeof value === 'string' && value.trim() !== '') {
allFilter3Values.add(value); combinedFilter3Values.add(value);
} }
}); });
}); });
// Populate filter 3 dropdown with all values // Populate filter 3 dropdown with all values
populateFilterOptions('filter3-options', [...allFilter3Values].sort(), 3); populateFilterOptions('filter3-options', [...combinedFilter3Values].sort(), 3);
return;
} }
// Filter items based on filter1 and filter2 selections // Filter items based on filter1 and filter2 selections
@@ -3369,6 +3357,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
let mainAdminItemsLoadingIndicator = null; let mainAdminItemsLoadingIndicator = null;
let mainAdminItemsScrollPrefetchBound = false; let mainAdminItemsScrollPrefetchBound = false;
let totalItemsInSystem = 0; let totalItemsInSystem = 0;
let cardVirtualizationObserver = null;
function ensureMainAdminItemsLoadingIndicator() { function ensureMainAdminItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container'); const itemsContainer = document.querySelector('#items-container');
@@ -3667,7 +3656,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);
@@ -3796,6 +3785,56 @@ document.addEventListener('DOMContentLoaded', ()=>{
mainAdminItemsScrollPrefetchBound = true; mainAdminItemsScrollPrefetchBound = true;
} }
// Setup DOM virtualization for items
if (!cardVirtualizationObserver) {
cardVirtualizationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const card = entry.target;
if (entry.isIntersecting) {
if (card.dataset.deloaded === 'true') {
card.innerHTML = card.dataset.savedHtml;
card.dataset.deloaded = 'false';
card.classList.remove('is-deloaded');
const favBtn = card.querySelector('.bookmark-btn');
if (favBtn) {
favBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
toggleFavorite(card.dataset.itemId, favBtn, card);
});
}
const cardContent = card.querySelector('.card-content');
if (cardContent) {
cardContent.addEventListener('click', function(e) {
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A' || e.target.tagName === 'INPUT' ||
e.target.closest('button') || e.target.closest('a') || e.target.closest('.image-container') ||
e.target.closest('.bulk-delete-toggle') || e.target.closest('label')) return;
e.stopPropagation();
openItemQuick(card.dataset.itemId);
});
}
}
} else {
if (card.dataset.deloaded !== 'true') {
const rect = card.getBoundingClientRect();
if (rect.height > 50) {
card.style.minHeight = rect.height + 'px';
card.dataset.itemId = card.querySelector('.card-content')?.dataset.itemId || '';
card.dataset.savedHtml = card.innerHTML;
card.innerHTML = '';
card.dataset.deloaded = 'true';
card.classList.add('is-deloaded');
}
}
}
});
}, {
rootMargin: isMobileLayout ? '1500px 0px 1500px 0px' : '0px 2500px 0px 2500px'
});
}
// Observe all valid cards
document.querySelectorAll('.item-card').forEach(card => cardVirtualizationObserver.observe(card));
if (!mainAdminItemsHasMore) return; if (!mainAdminItemsHasMore) return;
mainAdminItemsObserver = new IntersectionObserver(entries => { mainAdminItemsObserver = new IntersectionObserver(entries => {
@@ -4544,7 +4583,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>
@@ -5729,4 +5768,16 @@ document.addEventListener('DOMContentLoaded', ()=>{
} }
} }
</style> </style>
{% if open_item %}
<script>
document.addEventListener("DOMContentLoaded", function() {
if (typeof openEditModalFromServer === 'function') {
setTimeout(function() {
openEditModalFromServer('{{ open_item }}');
}, 500);
}
});
</script>
{% endif %}
{% endblock %} {% endblock %}
+44 -8
View File
@@ -15,16 +15,16 @@
<h1 class="mb-4">Filterwerte verwalten</h1> <h1 class="mb-4">Filterwerte verwalten</h1>
<div class="row"> <div class="row">
<!-- Filter 1: Unterrichtsfach --> <!-- Filter 1 -->
<div class="col-md-6"> <div class="col-md-4">
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h2 class="card-title h5 mb-0">Unterrichtsfach (Filter 1)</h2> <h2 class="card-title h5 mb-0">Fach/Kategorie (Filter 1)</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4"> <form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4">
<div class="input-group"> <div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neues Unterrichtsfach..." required> <input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button> <button type="submit" class="btn btn-primary">Hinzufügen</button>
</div> </div>
</form> </form>
@@ -51,16 +51,16 @@
</div> </div>
</div> </div>
<!-- Filter 2: Jahrgangsstufe --> <!-- Filter 2 -->
<div class="col-md-6"> <div class="col-md-4">
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h2 class="card-title h5 mb-0">Jahrgangsstufe (Filter 2)</h2> <h2 class="card-title h5 mb-0">System/Bereich (Filter 2)</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4"> <form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4">
<div class="input-group"> <div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neue Jahrgangsstufe..." required> <input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button> <button type="submit" class="btn btn-primary">Hinzufügen</button>
</div> </div>
</form> </form>
@@ -86,6 +86,42 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Filter 3 -->
<div class="col-md-4">
<div class="card mb-4">
<div class="card-header">
<h2 class="card-title h5 mb-0">Typ/Art (Filter 3)</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=3) }}" class="mb-4">
<div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
<h5 class="mb-3">Vorhandene Werte</h5>
{% if filter3_values %}
<div class="list-group">
{% for value in filter3_values %}
<div class="list-group-item d-flex justify-content-between align-items-center">
{{ value }}
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=3, value=value) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen
</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">Keine Werte definiert.</div>
{% endif %}
</div>
</div>
</div>
</div> </div>
<div class="alert alert-warning"> <div class="alert alert-warning">
-1
View File
@@ -709,7 +709,6 @@
</style> </style>
<div class="upload-container"> <div class="upload-container">
<a href="{{ url_for(back_target|default('home_admin')) }}" class="nav-back-button">← Zurück zur Artikelübersicht</a>
{% if show_library_features %} {% if show_library_features %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;"> <div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
+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)
+1 -7
View File
@@ -498,8 +498,6 @@ def check_nm_pwd(username, password):
finally: finally:
client.close() client.close()
return None
def add_user( def add_user(
username, username,
@@ -536,16 +534,12 @@ def add_user(
for key, value in page_permissions.items(): for key, value in page_permissions.items():
permission_defaults['pages'][str(key)] = bool(value) permission_defaults['pages'][str(key)] = bool(value)
alias_first = name if str(name or '').strip() else username
alias_last = last_name if str(last_name or '').strip() else ''
name_alias = build_name_synonym(alias_first, alias_last)
user_doc = { user_doc = {
'Username': username, 'Username': username,
'Password': hashing(password), 'Password': hashing(password),
'Admin': False, 'Admin': False,
'active_ausleihung': None, 'active_ausleihung': None,
'name': name_alias, 'name': name.strip() if name else '',
'last_name': last_name.strip() if last_name else '', 'last_name': last_name.strip() if last_name else '',
'IsStudent': bool(is_student), 'IsStudent': bool(is_student),
'PermissionPreset': permission_defaults['preset'], 'PermissionPreset': permission_defaults['preset'],
-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())
Regular → Executable
View File
+47 -25
View File
@@ -1,41 +1,63 @@
version: "3.8"
services: services:
app: app:
build: . image: ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.39
container_name: inventory-app container_name: inventarsystem-app
restart: unless-stopped restart: unless-stopped
environment: ports:
- MONGO_URL=mongodb://mongodb:27017/inventar - "10000:8000"
- REDIS_URL=redis://redis:6379
- BASE_URL=https://inventar.maximiliangruendinger.de #alle öffentliche subdomains
depends_on: depends_on:
- mongodb - mongodb
- redis - redis
environment:
INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017"
INVENTAR_MONGODB_DB: Inventarsystem
INVENTAR_BACKUP_FOLDER: /data/backups
INVENTAR_LOGS_FOLDER: /data/logs
expose:
- "8000"
volumes:
- ./config.json:/app/config.json:ro
- app_uploads:/app/Web/uploads
- app_thumbnails:/app/Web/thumbnails
- app_previews:/app/Web/previews
- app_qrcodes:/app/Web/QRCodes
- app_backups:/data/backups
- app_logs:/data/logs
mongodb: mongodb:
image: mongo:latest image: mongo:7.0
container_name: mongodb container_name: inventarsystem-mongodb
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- mongo_data:/data/db - mongodb_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
redis: redis:
image: redis:alpine image: redis:7-alpine
container_name: redis container_name: inventarsystem-redis
restart: unless-stopped restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
cloudflared: ports:
image: cloudflare/cloudflared:latest - "6379:6379"
container_name: cloudflared
restart: unless-stopped
# Der Tunnel-Name 'homeserver' muss zu deiner credentials.json passen
command: tunnel run homeserver
volumes: volumes:
- ./config.yml:/etc/cloudflared/config.yml - redis_data:/data
- ./credentials.json:/etc/cloudflared/credentials.json healthcheck:
depends_on: test: ["CMD", "redis-cli", "ping"]
- app interval: 10s
timeout: 5s
retries: 5
volumes: volumes:
mongo_data: mongodb_data:
app_uploads:
app_thumbnails:
app_previews:
app_qrcodes:
app_backups:
app_logs:
redis_data: