Compare commits

...

33 Commits

Author SHA1 Message Date
Aiirondev_dev 541ca7172b feat: update get_version function to read application image from .docker-build.env 2026-05-19 21:06:44 +02:00
Aiirondev_dev 001a9fae17 feat: add get_version function to retrieve application version from .release-version file 2026-05-19 20:55:53 +02:00
Aiirondev_dev b572704381 feat: add runtime config initialization in manage-tenant.sh and update health check method in docker-compose 2026-05-19 20:53:50 +02:00
Aiirondev_dev 8b114b5b8e feat: enhance item card layout in table view for better spacing and alignment 2026-05-19 10:05:29 +02:00
Aiirondev_dev 96771d192c feat: add export functionality for library items and include 'schulbuch' type 2026-05-19 09:53:04 +02:00
Aiirondev_dev 073652c63d feat: optimize rootMargin for better performance based on layout 2026-05-18 23:38:13 +02:00
Aiirondev_dev 23d9d0871a feat: implement customizable filter names and update management interfaces 2026-05-18 23:20:52 +02:00
Aiirondev_dev 576d31a384 feat: add edit functionality for predefined filter values in filter management 2026-05-18 22:01:37 +02:00
Aiirondev_dev c43bb30301 feat: enhance bookmark and favorite button styles for dark mode 2026-05-18 18:30:14 +02:00
Aiirondev_dev 7eeea1d319 feat: update dark theme colors and add border radius for improved aesthetics 2026-05-18 17:49:42 +02:00
Aiirondev_dev 56486bcbb2 feat: update address details in impressum section 2026-05-18 17:39:19 +02:00
Aiirondev_dev 2c3fb779ce feat: update styles for dark mode and enhance filter handling in admin template 2026-05-18 17:35:02 +02:00
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
30 changed files with 1585 additions and 976 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=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_MEM_LIMIT=384m
INVENTAR_APP_MEM_SWAP_LIMIT=768m
+1
View File
@@ -0,0 +1 @@
v0.7.42
View File
+445 -720
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
import io
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
def generate_library_excel(items):
wb = Workbook()
ws = wb.active
ws.title = "Export"
headers = [
"Code", "Titel", "Autor", "Typ", "ISBN/Code",
"Filter 1", "Filter 2", "Filter 3",
"Status", "Ausgeliehen von", "Rückgabe", "Kosten"
]
ws.append(headers)
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="1F2937", end_color="1F2937", fill_type="solid")
for cell in ws[1]:
cell.font = header_font
cell.fill = header_fill
for item in items:
status = "Verfuegbar" if str(item.get("Verfuegbar", "True")).lower() == "true" else "Ausgeliehen"
row = [
item.get("Code_4", ""),
item.get("Name", ""),
item.get("Author", ""),
item.get("ItemType", ""),
item.get("ISBN", ""),
item.get("Filter", ""),
item.get("Filter2", ""),
item.get("Filter3", ""),
status,
item.get("User", ""),
item.get("ReturnDate", ""),
item.get("Anschaffungskosten", "")
]
ws.append(row)
for col in ws.columns:
max_length = 0
column = col[0].column_letter
for cell in col:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
ws.column_dimensions[column].width = min(max_length + 2, 50)
excel_buffer = io.BytesIO()
wb.save(excel_buffer)
excel_buffer.seek(0)
return excel_buffer
+86 -3
View File
@@ -570,7 +570,10 @@ def get_primary_filters():
items = db['items']
filters = [f for f in items.distinct('Filter', _active_record_query(_non_library_query())) if f]
client.close()
return filters
# Add predefined values
predefined = get_predefined_filter_values(1)
return sorted(list(set(filters + predefined)))
except Exception as e:
print(f"Error retrieving primary filters: {e}")
return []
@@ -589,7 +592,10 @@ def get_secondary_filters():
items = db['items']
filters = [f for f in items.distinct('Filter2', _active_record_query(_non_library_query())) if f]
client.close()
return filters
# Add predefined values
predefined = get_predefined_filter_values(2)
return sorted(list(set(filters + predefined)))
except Exception as e:
print(f"Error retrieving secondary filters: {e}")
return []
@@ -608,7 +614,10 @@ def get_tertiary_filters():
items = db['items']
filters = [f for f in items.distinct('Filter3', _active_record_query(_non_library_query())) if f]
client.close()
return filters
# Add predefined values
predefined = get_predefined_filter_values(3)
return sorted(list(set(filters + predefined)))
except Exception as e:
print(f"Error retrieving tertiary filters: {e}")
return []
@@ -785,6 +794,80 @@ def remove_predefined_filter_value(filter_num, value):
return result.modified_count > 0
def edit_predefined_filter_value(filter_num, old_value, new_value):
"""
Edit a predefined value from a filter and update all matching items.
Args:
filter_num (int): Filter number (1 for Unterrichtsfach, 2 for Jahrgangsstufe)
old_value (str): Value to replace
new_value (str): New value
Returns:
bool: True if value was updated, False otherwise
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
filter_presets = db['filter_presets']
# Check if the new value already exists
existing = filter_presets.find_one({
'filter_num': filter_num,
'values': new_value
})
if existing and old_value != new_value:
client.close()
return False
# Update the value in the filter
result = filter_presets.update_one(
{'filter_num': filter_num, 'values': old_value},
{'$set': {'values.$': new_value}}
)
if result.modified_count > 0:
items = db['items']
filter_field = 'Filter' if filter_num == 1 else f'Filter{filter_num}'
# Also update all items that use this filter
items.update_many(
{filter_field: old_value},
{'$set': {filter_field: new_value}}
)
client.close()
return result.modified_count > 0
def get_filter_names():
"""Get customized filter category names."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
names_doc = db.settings.find_one({'setting_type': 'filter_names'})
client.close()
if names_doc and 'names' in names_doc:
return names_doc['names']
return {
'1': 'Fach/Kategorie',
'2': 'System/Bereich',
'3': 'Typ/Art'
}
def set_filter_name(filter_num, name):
"""Set custom name for a filter category."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
names = get_filter_names()
names[str(filter_num)] = name
db.settings.update_one(
{'setting_type': 'filter_names'},
{'$set': {'names': names}},
upsert=True
)
client.close()
return True
# === LOCATION MANAGEMENT ===
def get_predefined_locations():
+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()
+226 -25
View File
@@ -285,66 +285,147 @@ class DIN5008AuditPDF:
spaceAfter=8,
)))
# Build table data with wrapped cells for better readability on A4 pages
cell_style = ParagraphStyle(
'EventCell',
parent=styles['Normal'],
fontName='Helvetica',
fontSize=8,
leading=9.5,
textColor=HexColor('#1f2937'),
alignment=TA_LEFT,
)
hash_style = ParagraphStyle(
'EventHashCell',
parent=cell_style,
fontName='Courier',
fontSize=7.2,
leading=9,
wordWrap='CJK',
)
def _safe(value):
return str(value or '').replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def _fmt_ts(value):
text = _safe(value)
if len(text) >= 19 and text[4] == '-' and text[7] == '-':
# Convert 2026-05-10 16:19:07... -> 10.05.2026 16:19
return f"{text[8:10]}.{text[5:7]}.{text[0:4]} {text[11:16]}"
return text[:16]
def _fmt_event(value):
text = _safe(value).replace('_', ' ').strip()
return text[:60]
def _chunk_text(value, chunk=4, sep=' '):
text = _safe(value)
if not text:
return ''
return sep.join(text[i:i + chunk] for i in range(0, len(text), chunk))
def _fmt_ip(value):
text = _safe(value)
# Keep IPv4 intact. For long IPv6 values insert only one line break in the middle.
if ':' in text and len(text) > 24:
parts = text.split(':')
if len(parts) > 4:
return ':'.join(parts[:4]) + ':<br/>' + ':'.join(parts[4:])
return text
# Build table data
if self.export_type == 'quick':
# Quick-Check: Minimal columns
table_data = [
['Index', 'Zeit', 'Ereignis', 'Benutzer', 'Hashwert (gekürzt)'],
['Idx', 'Zeit', 'Ereignis', 'Benutzer', 'Hash (gekürzt)'],
]
for row in audit_rows[:20]: # Limit to 20 rows for quick check
chain_idx = str(row.get('chain_index', ''))
timestamp = str(row.get('timestamp') or row.get('created_at', ''))[:16]
event_type = str(row.get('event_type', ''))
actor = str(row.get('actor', ''))
entry_hash = str(row.get('entry_hash', ''))[:12] + '...'
chain_idx = _safe(row.get('chain_index', ''))
timestamp = _fmt_ts(row.get('timestamp') or row.get('created_at', ''))
event_type = _fmt_event(row.get('event_type', ''))
actor = _safe(row.get('actor', ''))
entry_hash = _chunk_text(_safe(row.get('entry_hash', ''))[:20], chunk=4)
if entry_hash:
entry_hash += ' ...'
table_data.append([chain_idx, timestamp, event_type, actor, entry_hash])
table_data.append([
Paragraph(chain_idx, cell_style),
Paragraph(timestamp, cell_style),
Paragraph(event_type, cell_style),
Paragraph(actor, cell_style),
Paragraph(entry_hash, hash_style),
])
colWidths = [1.2*cm, 1.8*cm, 2.2*cm, 2*cm, 3.8*cm]
colWidths = [1.1*cm, 2.7*cm, 4.8*cm, 3.1*cm, 4.9*cm]
else:
# Official Report: Full columns
table_data = [
['Idx', 'Zeitstempel', 'Ereignistyp', 'Benutzer', 'Quelle', 'IP-Adresse', 'Hashwert'],
['Idx', 'Zeit', 'Ereignis', 'Benutzer', 'Quelle', 'IP', 'Hash'],
]
for row in audit_rows:
chain_idx = str(row.get('chain_index', ''))
timestamp = str(row.get('timestamp') or row.get('created_at', ''))[:19]
event_type = str(row.get('event_type', ''))
actor = str(row.get('actor', ''))
source = str(row.get('source', 'System'))[:15]
ip = str(row.get('ip', ''))
entry_hash = str(row.get('entry_hash', ''))[:16]
chain_idx = _safe(row.get('chain_index', ''))
timestamp = _fmt_ts(row.get('timestamp') or row.get('created_at', ''))
event_type = _fmt_event(row.get('event_type', ''))
actor = _safe(row.get('actor', ''))
source = _safe(row.get('source', 'System'))
ip = _fmt_ip(row.get('ip', ''))
entry_hash = _chunk_text(_safe(row.get('entry_hash', ''))[:40], chunk=8)
table_data.append([chain_idx, timestamp, event_type, actor, source, ip, entry_hash])
table_data.append([
Paragraph(chain_idx, cell_style),
Paragraph(timestamp, cell_style),
Paragraph(event_type, cell_style),
Paragraph(actor, cell_style),
Paragraph(source, cell_style),
Paragraph(ip, cell_style),
Paragraph(entry_hash, hash_style),
])
colWidths = [0.8*cm, 1.8*cm, 1.5*cm, 1.5*cm, 1.2*cm, 1.5*cm, 2.2*cm]
# Give IP and hash columns significantly more room for readability.
colWidths = [0.9*cm, 2.4*cm, 2.8*cm, 2.0*cm, 1.4*cm, 3.3*cm, 4.2*cm]
# Create table
events_table = Table(table_data, colWidths=colWidths)
events_table = Table(table_data, colWidths=colWidths, repeatRows=1)
events_table.setStyle(TableStyle([
# Header styling
('BACKGROUND', (0, 0), (-1, 0), HexColor('#2c3e50')),
('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#ffffff')),
('ALIGN', (0, 0), (-1, 0), 'CENTER'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 8),
('FONTSIZE', (0, 0), (-1, 0), 8.5),
('TOPPADDING', (0, 0), (-1, 0), 6),
('BOTTOMPADDING', (0, 0), (-1, 0), 6),
# Body styling
('FONTSIZE', (0, 1), (-1, -1), 7),
('FONTSIZE', (0, 1), (-1, -1), 8),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#bdc3c7')),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [HexColor('#ecf0f1'), HexColor('#ffffff')]),
('TOPPADDING', (0, 1), (-1, -1), 4),
('BOTTOMPADDING', (0, 1), (-1, -1), 4),
('LEFTPADDING', (0, 1), (-1, -1), 3),
('RIGHTPADDING', (0, 1), (-1, -1), 3),
('TOPPADDING', (0, 1), (-1, -1), 5),
('BOTTOMPADDING', (0, 1), (-1, -1), 5),
('LEFTPADDING', (0, 1), (-1, -1), 4),
('RIGHTPADDING', (0, 1), (-1, -1), 4),
('ALIGN', (0, 1), (0, -1), 'CENTER'),
('ALIGN', (1, 1), (1, -1), 'CENTER'),
('ALIGN', (-1, 1), (-1, -1), 'LEFT'),
]))
story.append(events_table)
story.append(Paragraph(
"Hinweis: Zeitangaben sind auf Minuten gerundet; Hashwerte werden aus Platzgründen gekürzt dargestellt.",
ParagraphStyle(
'TableHint',
parent=styles['Normal'],
fontSize=7.5,
leading=9,
textColor=HexColor('#6b7280'),
alignment=TA_LEFT,
spaceBefore=4,
)
))
story.append(Spacer(1, 0.2 * cm))
def _add_mismatches(self, story, mismatches):
@@ -587,3 +668,123 @@ def generate_audit_pdf(verify_result, event_counts, audit_rows, export_type='off
return pdf_gen.generate_quick_check(verify_result, event_counts, audit_rows)
else:
return pdf_gen.generate_official_report(verify_result, event_counts, audit_rows)
def _build_invoice_pdf(invoice_data):
"""Render a PDF invoice for a damaged borrowed item."""
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.colors import HexColor, black, white
from reportlab.lib.utils import simpleSplit
from reportlab.pdfgen import canvas
pdf_buffer = io.BytesIO()
c = canvas.Canvas(pdf_buffer, pagesize=A4)
page_width, page_height = A4
margin_x = 20 * mm
margin_top = 20 * mm
usable_width = page_width - (2 * margin_x)
current_y = page_height - margin_top
dark_color = HexColor('#0F172A')
accent_color = HexColor('#B91C1C')
light_color = HexColor('#F8FAFC')
border_color = HexColor('#CBD5E1')
text_color = HexColor('#1E293B')
muted_color = HexColor('#64748B')
def draw_wrapped_lines(text, x_pos, y_pos, width, font_name='Helvetica', font_size=11, leading=14, color=text_color):
if not text:
return y_pos
c.setFont(font_name, font_size)
c.setFillColor(color)
for line in simpleSplit(str(text), font_name, font_size, width):
c.drawString(x_pos, y_pos, line)
y_pos -= leading
return y_pos
def draw_label_value(label, value, x_pos, y_pos, label_width=45 * mm):
c.setFont('Helvetica-Bold', 10)
c.setFillColor(muted_color)
c.drawString(x_pos, y_pos, label)
c.setFont('Helvetica', 10)
c.setFillColor(text_color)
c.drawString(x_pos + label_width, y_pos, str(value or '-'))
return y_pos - 7 * mm
c.setFillColor(light_color)
c.rect(0, 0, page_width, page_height, fill=1, stroke=0)
c.setFillColor(dark_color)
c.rect(0, page_height - 28 * mm, page_width, 28 * mm, fill=1, stroke=0)
c.setFillColor(white)
c.setFont('Helvetica-Bold', 20)
c.drawString(margin_x, page_height - 16 * mm, 'RECHNUNG')
c.setFont('Helvetica', 10)
c.drawString(margin_x, page_height - 23 * mm, 'Inventarsystem - Schadensersatz für zerstörtes Ausleihobjekt')
current_y = page_height - 40 * mm
c.setStrokeColor(border_color)
c.setLineWidth(1)
c.line(margin_x, current_y, page_width - margin_x, current_y)
current_y -= 12 * mm
invoice_number = invoice_data.get('invoice_number', '-')
created_at = invoice_data.get('created_at_display', '-')
borrower = invoice_data.get('borrower', '-')
item_name = invoice_data.get('item_name', '-')
item_code = invoice_data.get('item_code', '-')
item_id = invoice_data.get('item_id', '-')
damage_reason = invoice_data.get('damage_reason', '-')
amount_text = invoice_data.get('amount_text', '-')
current_y = draw_label_value('Rechnungsnummer:', invoice_number, margin_x, current_y)
current_y = draw_label_value('Datum:', created_at, margin_x, current_y)
current_y = draw_label_value('Empfänger:', borrower, margin_x, current_y)
current_y = draw_label_value('Ausleihe / Element:', item_name, margin_x, current_y)
current_y = draw_label_value('Element-ID:', item_id, margin_x, current_y)
current_y = draw_label_value('Code:', item_code, margin_x, current_y)
current_y = current_y - 3 * mm
c.setFillColor(dark_color)
c.setFont('Helvetica-Bold', 12)
c.drawString(margin_x, current_y, 'Schadensbeschreibung')
current_y -= 6 * mm
current_y = draw_wrapped_lines(damage_reason, margin_x, current_y, usable_width, font_size=10, leading=13, color=text_color)
current_y -= 4 * mm
c.setFillColor(dark_color)
c.setFont('Helvetica-Bold', 12)
c.drawString(margin_x, current_y, 'Rechnungsbetrag')
current_y -= 8 * mm
c.setFillColor(accent_color)
c.setStrokeColor(accent_color)
c.rect(margin_x, current_y - 12 * mm, usable_width, 16 * mm, fill=1, stroke=0)
c.setFillColor(white)
c.setFont('Helvetica-Bold', 16)
c.drawString(margin_x + 5 * mm, current_y - 2 * mm, amount_text)
current_y -= 20 * mm
current_y = draw_wrapped_lines(
'Bitte begleichen Sie diesen Betrag zeitnah bei der Verwaltung. Der Betrag ergibt sich aus dem zerstörten Ausleihobjekt und der dokumentierten Schadensmeldung.',
margin_x,
current_y,
usable_width,
font_size=10,
leading=13,
color=muted_color,
)
footer_y = 18 * mm
c.setStrokeColor(border_color)
c.setLineWidth(0.8)
c.line(margin_x, footer_y + 8 * mm, page_width - margin_x, footer_y + 8 * mm)
c.setFillColor(muted_color)
c.setFont('Helvetica', 9)
c.drawString(margin_x, footer_y, 'Inventarsystem - Rechnungserstellung')
c.drawRightString(page_width - margin_x, footer_y, f'{amount_text}')
c.save()
pdf_buffer.seek(0)
return pdf_buffer
+29 -2
View File
@@ -134,8 +134,14 @@ def _get_int_env(name, default):
except ValueError:
return int(default)
def get_version():
with open(os.path.join(BASE_DIR, '..', '.docker-build.env'), 'r') as f:
for l in f:
if l.startswith('INVENTAR_APP_IMAGE='):
return l.split(':', 1)[1].strip()
# Expose settings
APP_VERSION = _get(_conf, ['ver'], DEFAULTS['version'])
APP_VERSION = get_version()
DEBUG = _get_bool_env('INVENTAR_DEBUG', _get(_conf, ['dbg'], DEFAULTS['debug']))
SECRET_KEY = str(os.getenv('INVENTAR_SECRET_KEY', _get(_conf, ['key'], DEFAULTS['secret_key'])))
HOST = _get(_conf, ['host'], DEFAULTS['host'])
@@ -205,11 +211,32 @@ class _TenantAwareBool:
return f"_TenantAwareBool(module_name={self.module_name!r}, value={self.resolve()!r})"
from module_registry import registry as MODULES
INVENTORY_MODULE_ENABLED = _TenantAwareBool('inventory', _get(_conf, ['modules', 'inventory', 'enabled'], DEFAULTS['modules']['inventory']['enabled']))
LIBRARY_MODULE_ENABLED = _TenantAwareBool('library', _get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
STUDENT_CARDS_MODULE_ENABLED = _TenantAwareBool('student_cards', _get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
def _match_inventory(path):
if not path: return False
if path == '/' or path.startswith('/home'): return True
return path.startswith(('/scanner', '/inventory', '/upload_admin', '/manage_filters', '/manage_locations', '/admin_borrowings', '/admin_damaged_items', '/admin/borrowings', '/admin/damaged_items', '/terminplan'))
def _match_library(path):
if not path: return False
return path.startswith(('/library', '/library_', '/student_cards'))
def _match_student_cards(path):
if not path: return False
return path.startswith(('/student_cards'))
# Register core modules into the pipeline
MODULES.register('inventory', INVENTORY_MODULE_ENABLED, _match_inventory)
MODULES.register('library', LIBRARY_MODULE_ENABLED, _match_library)
MODULES.register('student_cards', STUDENT_CARDS_MODULE_ENABLED, _match_student_cards)
STUDENT_DEFAULT_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'default_borrow_days'], DEFAULTS['modules']['student_cards']['default_borrow_days']))
STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'max_borrow_days'], DEFAULTS['modules']['student_cards']['max_borrow_days']))
STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ["modules", "student_cards", "max_borrow_days"], DEFAULTS["modules"]["student_cards"]["max_borrow_days"]))
# Upload/paths
ALLOWED_EXTENSIONS = set(_get(_conf, ['allowed_extensions'], DEFAULTS['upload']['allowed_extensions']))
+8 -8
View File
@@ -10,13 +10,13 @@
.planned-appointments-panel {
margin: 15px 0;
padding: 15px;
background-color: #e1f5fe;
background-color: var(--ui-surface-soft, #e1f5fe);
border-left: 4px solid #03a9f4;
border-radius: 4px;
}
.planned-appointments-panel h4 {
color: #0277bd;
color: var(--ui-title, #0277bd);
margin-top: 0;
margin-bottom: 10px;
font-size: 1.1em;
@@ -30,9 +30,9 @@
.appointment-item {
padding: 10px;
background-color: #f9fdff;
background-color: var(--ui-surface, #f9fdff);
border-radius: 4px;
border: 1px solid #b3e5fc;
border: 1px solid var(--ui-border, #b3e5fc);
}
.appointment-header {
@@ -44,7 +44,7 @@
.appointment-date {
font-weight: bold;
color: #0288d1;
color: var(--ui-title, #0288d1);
}
.appointment-period {
@@ -86,14 +86,14 @@
.appointment-user {
margin-left: auto;
font-style: italic;
color: #546e7a;
color: var(--ui-text-muted, #546e7a);
}
.appointment-notes {
font-size: 0.9em;
color: #455a64;
color: var(--ui-text, #455a64);
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid #e1f5fe;
border-top: 1px solid var(--ui-border, #e1f5fe);
font-style: italic;
}
+116 -10
View File
@@ -22,16 +22,21 @@
}
:root[data-theme="dark"] {
--ui-bg: #0f172a;
--ui-bg-accent: #1e293b;
/* Tiefes Blau-Theme (Dark Blue) */
--ui-bg: #0b1120;
--ui-bg-accent: #111827;
--ui-surface: #1e293b;
--ui-surface-soft: #25314a;
--ui-border: #334155;
--ui-text: #f1f5f9;
--ui-surface-soft: #334155;
--ui-border: #475569;
--ui-text: #f8fafc;
--ui-text-muted: #94a3b8;
--ui-title: #ffffff;
--ui-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4);
--ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5);
--ui-shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.4), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
--ui-shadow-md: 0 10px 30px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
/* Abgerundetes Format im gesamten Dark Mode */
--ui-radius: 20px;
--ui-btn-radius: 99px; /* Pillenform für Buttons */
}
/* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */
@@ -57,9 +62,17 @@ body {
:root[data-theme="dark"] input,
:root[data-theme="dark"] select,
:root[data-theme="dark"] textarea {
background-color: var(--ui-surface-soft) !important;
background-color: var(--ui-bg) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
border: 1px solid var(--ui-border) !important;
border-radius: 12px !important;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.3) !important;
}
:root[data-theme="dark"] input:focus,
:root[data-theme="dark"] select:focus,
:root[data-theme="dark"] textarea:focus {
border-color: #52a8ff !important;
box-shadow: 0 0 0 2px rgba(82, 168, 255, 0.2), inset 0 1px 2px rgba(0,0,0,0.3) !important;
}
body {
@@ -295,7 +308,7 @@ select:focus {
/* Modal dialog styling */
.modal-dialog-white {
background: white;
background: var(--ui-surface);
padding: 20px;
border-radius: 5px;
text-align: center;
@@ -389,6 +402,10 @@ body.table-view .item-card {
background: var(--ui-surface);
box-shadow: none;
scroll-snap-align: none;
min-height: 60px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
body.table-view .item-card .card-content {
@@ -896,3 +913,92 @@ html, body {
padding-bottom: env(safe-area-inset-bottom);
}
}
/* ========================================================= */
/* UNIVERSAL DARK MODE OVERRIDES FOR HARDCODED HTML STYLES */
/* ========================================================= */
:root[data-theme="dark"] .bg-white,
:root[data-theme="dark"] .bg-light,
:root[data-theme="dark"] [style*="background-color: #fff"],
:root[data-theme="dark"] [style*="background-color: #ffffff"],
:root[data-theme="dark"] [style*="background-color: #f8f9fa"],
:root[data-theme="dark"] [style*="background-color: #f5f5f5"],
:root[data-theme="dark"] [style*="background: #fff"],
:root[data-theme="dark"] [style*="background: #ffffff"],
:root[data-theme="dark"] [style*="background: #f8f9fa"],
:root[data-theme="dark"] [style*="background: #f5f5f5"],
:root[data-theme="dark"] [style*="background: white"],
:root[data-theme="dark"] [style*="background-color: white"],
:root[data-theme="dark"] .modal-content,
:root[data-theme="dark"] .card,
:root[data-theme="dark"] .dropdown-menu {
background-color: var(--ui-surface) !important;
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .text-dark,
:root[data-theme="dark"] .text-black,
:root[data-theme="dark"] [style*="color: #000"],
:root[data-theme="dark"] [style*="color: rgb(0, 0, 0)"],
:root[data-theme="dark"] [style*="color: black"],
:root[data-theme="dark"] [style*="color: #333"] {
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .text-muted {
color: var(--ui-text-muted) !important;
}
/* Fix table backgrounds that might have hardcoded light colors */
:root[data-theme="dark"] .table-light,
:root[data-theme="dark"] table.table,
:root[data-theme="dark"] table [style*="background: #fff"] {
background-color: var(--ui-bg) !important;
color: var(--ui-text) !important;
}
/* Fix list items */
:root[data-theme="dark"] .list-group-item {
background-color: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
/* Additional inline style coverage */
:root[data-theme="dark"] [style*="background: #eef4ff"],
:root[data-theme="dark"] [style*="background-color: #eef4ff"],
:root[data-theme="dark"] [style*="background: #e9ecef"],
:root[data-theme="dark"] [style*="background-color: #e9ecef"],
:root[data-theme="dark"] [style*="background: #e2e8f0"],
:root[data-theme="dark"] [style*="background-color: #e2e8f0"],
:root[data-theme="dark"] [style*="background-color: #fefefe"],
:root[data-theme="dark"] [style*="background: #fffdfd"] {
background-color: var(--ui-surface) !important;
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .badge-light,
:root[data-theme="dark"] .bg-light {
background-color: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
border: 1px solid var(--ui-border);
}
/* Fix Bookmark/Favorite Button in Dark Mode */
:root[data-theme="dark"] .bookmark-btn {
background: var(--ui-surface-soft) !important;
border-color: #f59e0b !important;
color: #f59e0b !important;
}
:root[data-theme="dark"] .bookmark-btn:hover,
:root[data-theme="dark"] .bookmark-btn.active {
background: #f59e0b !important;
color: var(--ui-bg) !important;
}
:root[data-theme="dark"] .favorite-item {
outline: 2px solid #f59e0b !important;
}
-16
View File
@@ -20,28 +20,12 @@
<h4 style="margin:0 0 10px 0; color:#1a1a1a;">📄 PDF-Export (DIN 5008 konform)</h4>
<p style="margin:0 0 10px 0; font-size:0.9rem; color:#666;">Professionelle Berichte für Schulträger und Behörden</p>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<a class="btn btn-primary" href="{{ url_for('admin_audit_export_pdf_quick') }}" style="flex:1; text-align:center;">
🚀 Schnell-Check (kompakt)
</a>
<a class="btn btn-primary" href="{{ url_for('admin_audit_export_pdf_official') }}" style="flex:1; text-align:center;">
📋 Amtlicher Bericht (DIN 5008)
</a>
</div>
</div>
<!-- Other Formats Section -->
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
<h4 style="margin:0 0 10px 0; color:#1a1a1a;">📊 Weitere Formate</h4>
<p style="margin:0 0 10px 0; font-size:0.9rem; color:#666;">Technische Exporte für System-Integration</p>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<a class="btn btn-outline-secondary" href="{{ url_for('admin_audit_export', format='md') }}" style="flex:1; text-align:center;">
📝 Markdown
</a>
<a class="btn btn-outline-secondary" href="{{ url_for('admin_audit_export', format='json') }}" style="flex:1; text-align:center;">
⚙️ JSON
</a>
</div>
</div>
</div>
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0 20px;">
+19
View File
@@ -71,6 +71,25 @@
<small>Upload ersetzt das bisherige Logo. Wenn kein neues Logo gewählt wird, bleibt das aktuelle erhalten.</small>
</div>
<hr style="margin: 24px 0; border: 0; border-top: 1px solid #e5e7eb;">
<h3 style="font-size: 1.1rem; margin-bottom: 16px; color: #111827;">Globales Filter-Wording</h3>
<p style="font-size: 0.9rem; color: #6b7280; margin-bottom: 16px;">Hier kannst du die globalen Bezeichnungen für die drei Haupt-Kategorien (Filter) der Anwendung ändern.</p>
<div class="form-row">
<div class="form-group">
<label for="filter_name_1">Name Filter 1</label>
<input type="text" id="filter_name_1" name="filter_name_1" value="{{ filter_names.get('1', 'Fach/Kategorie') }}" placeholder="z. B. Fach/Kategorie">
</div>
<div class="form-group">
<label for="filter_name_2">Name Filter 2</label>
<input type="text" id="filter_name_2" name="filter_name_2" value="{{ filter_names.get('2', 'System/Bereich') }}" placeholder="z. B. System/Bereich">
</div>
<div class="form-group">
<label for="filter_name_3">Name Filter 3</label>
<input type="text" id="filter_name_3" name="filter_name_3" value="{{ filter_names.get('3', 'Typ/Art') }}" placeholder="z. B. Typ/Art">
</div>
</div>
<div class="actions-row">
<button type="submit" class="btn btn-primary">Schulstammdaten speichern</button>
<a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Admin-Übersicht</a>
+10 -11
View File
@@ -329,7 +329,7 @@
}
/* iPad/Tablet responsive adjustments */
@media (max-width: 1024px) {
@media (max-width: 768px) {
.navbar .container-fluid {
gap: 6px;
padding-left: max(env(safe-area-inset-left, 0), 10px);
@@ -1096,16 +1096,6 @@
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}" data-tutorial-tip="Ihre aktuellen Ausleihen und Rückgaben finden Sie hier.">Meine Ausleihen</a>
</li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Öffnen Sie das Tutorial, um das System Schritt für Schritt kennenzulernen.">Tutorial</a>
</li>
{% endif %}
{% endif %}
{% if 'username' in session and current_permissions.pages.get('upload_admin', True) and current_permissions.actions.get('can_insert', True) %}
<li class="nav-item">
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}" data-tutorial-tip="Hier können Sie neue Artikel ins Inventar einpflegen."> Hochladen</a>
</li>
{% endif %}
<li class="nav-item" data-nav-fixed="true">
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
@@ -1119,6 +1109,15 @@
Mehr Optionen
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
{% if 'username' in session %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
{% endif %}
{% if current_permissions.pages.get('upload_admin', True) and current_permissions.actions.get('can_insert', True) %}
<li><a class="dropdown-item" href="{{ url_for('upload_admin') }}"> Hochladen</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
<li><h6 class="dropdown-header">Verwaltung</h6></li>
{% if current_permissions.pages.get('manage_filters', True) %}
+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 %}
+4 -4
View File
@@ -12,7 +12,7 @@
{% block content %}
<div class="container my-4">
<div class="impressum-content bg-white p-4 shadow rounded">
<div class="impressum-content p-4 shadow rounded" style="background: var(--ui-surface); color: var(--ui-text);">
<h1 class="mb-4 text-center">Impressum</h1>
<p class="text-center mb-5">(Angaben gemäß § 5 DDG)</p>
@@ -20,7 +20,7 @@
<h3 class="mb-3">Kontaktinformationen</h3>
<address class="mb-0">
<p><strong>Name:</strong> Invario UG</p>
<p><strong>Adresse:</strong> Musterstraße 123<br>12345 Musterstadt<br>Deutschland</p>
<p><strong>Adresse:</strong> Am Sportplatz 10<br>83052 Bruckmühl<br>Deutschland</p>
</address>
<p><strong>E-Mail:</strong> <a href="mailto:info@invario.eu">info@invario.eu</a></p>
</div>
@@ -29,8 +29,8 @@
<h3 class="mb-3">Verantwortlich für den Inhalt</h3>
<p>(nach § 18 Abs. 2 MStV)</p>
<p>Invario UG<br>
Musterstraße 123<br>
12345 Musterstadt<br>
Am Sportplatz 10<br>
83052 Bruckmühl<br>
Deutschland</p>
</div>
+11
View File
@@ -477,6 +477,7 @@
<select id="filterType">
<option value="">-- Alle Typen --</option>
<option value="book">Buch</option>
<option value="schulbuch">Schulbuch</option>
<option value="cd">CD</option>
<option value="dvd">DVD</option>
<option value="other">Sonstige</option>
@@ -495,6 +496,15 @@
<div class="filter-buttons">
<button id="applyFilterBtn" class="button" style="background: #4f46e5; color: white;">Filter anwenden</button>
<button id="clearFilterBtn" class="button" style="background: #f0f2f5; color: var(--ui-text);">Zurücksetzen</button>
<div style="flex-grow: 1;"></div>
<button class="button" style="background: #10b981; color: white; display: flex; align-items: center; gap: 0.5rem;" onclick="window.location.href='/library/export/schulbuecher'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="8" y1="13" x2="16" y2="13"></line><line x1="8" y1="17" x2="16" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
Schulbücher Excel
</button>
<button class="button" style="background: #10b981; color: white; display: flex; align-items: center; gap: 0.5rem;" onclick="window.location.href='/library/export/all_borrowed'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="8" y1="13" x2="16" y2="13"></line><line x1="8" y1="17" x2="16" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
Alle ausgeliehen Excel
</button>
</div>
</div>
@@ -1174,6 +1184,7 @@
<label for="editLibraryType">Medientyp</label>
<select id="editLibraryType">
<option value="book">Buch</option>
<option value="schulbuch">Schulbuch</option>
<option value="cd">CD</option>
<option value="dvd">DVD</option>
<option value="media">Sonstige Medien</option>
+116 -46
View File
@@ -459,7 +459,7 @@
<div id="schedule-modal" class="item-modal">
<div class="modal-content">
<span class="close-modal" onclick="closeScheduleModal()">&times;</span>
<h2>Termin planen</h2>
<h2>Reservieren</h2>
<form id="schedule-form">
<input type="hidden" id="schedule-item-id" name="item_id">
@@ -751,6 +751,7 @@
let mainItemsSentinel = null;
let mainItemsLoadingIndicator = null;
let mainItemsScrollPrefetchBound = false;
let cardVirtualizationObserver = null;
function ensureMainItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container');
@@ -759,11 +760,14 @@
if (!mainItemsLoadingIndicator) {
mainItemsLoadingIndicator = document.createElement('div');
mainItemsLoadingIndicator.id = 'main-items-loading-indicator';
mainItemsLoadingIndicator.className = 'items-loading-indicator';
mainItemsLoadingIndicator.className = 'items-loading-indicator empty-frames-placeholder';
mainItemsLoadingIndicator.innerHTML = `
<div class="loading-track"><div class="loading-fill"></div></div>
<div class="loading-label">Weitere Objekte werden vorbereitet...</div>
<div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px;"></div>
<div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px; display: none;" class="d-none d-md-block"></div>
<div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px; display: none;" class="d-none d-lg-block"></div>
`;
mainItemsLoadingIndicator.style.display = 'flex';
mainItemsLoadingIndicator.style.width = '100%';
}
itemsContainer.appendChild(mainItemsLoadingIndicator);
@@ -1057,7 +1061,7 @@
:
`<button class="ausleihen disabled-button" disabled>${item.BlockedNow ? 'Reserviert' : 'Ausgeliehen'}</button>`
}
${canScheduleItem ? `<button class="schedule-button" type="button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
${canScheduleItem ? `<button class="schedule-button" type="button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
<button class="quick-details-button" type="button" onclick="openItemQuick('${item._id}')">Details</button>
</div>
`;
@@ -1189,6 +1193,59 @@
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');
card.classList.remove('empty-frame');
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');
card.classList.add('empty-frame');
}
}
}
});
}, {
// Keep approx ~120 items rendered in DOM relative to scroll position.
// Assuming ~100-400px per item/row, this margin unloads elements beyond that range.
rootMargin: isMobileLayout ? '18000px 0px 18000px 0px' : '9000px 0px 9000px 0px'
});
}
// Observe all valid cards
document.querySelectorAll('.item-card').forEach(card => cardVirtualizationObserver.observe(card));
if (!mainItemsHasMore) return;
mainItemsObserver = new IntersectionObserver(entries => {
@@ -1734,7 +1791,7 @@
</form>`
: `<button class="ausleihen disabled-button" disabled>${item.BlockedNow ? 'Reserviert' : 'Ausgeliehen'}</button>`
}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
<button type="button" class="details-button" onclick="document.getElementById('item-modal').style.display='none';">Schließen</button>
</div>
`;
@@ -2419,29 +2476,29 @@
// Add these functions right after the loadFilterState() function
function rebuildFilter3Options() {
if (!allItems || allItems.length === 0) return;
if (!allItems) return;
// Get current filter 1 and filter 2 selections
const filter1Selections = activeFilters.filter1;
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) {
// Get all unique filter3 values
const allFilter3Values = new Set();
// Include dynamically added ones from loaded items too, just in case
const combinedFilter3Values = new Set(typeof allFilter3Values !== 'undefined' ? [...allFilter3Values] : []);
allItems.forEach(item => {
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 :
(item.Filter3 ? [item.Filter3] : []);
filter3Array.forEach(value => {
if (value && typeof value === 'string' && value.trim() !== '') {
allFilter3Values.add(value);
combinedFilter3Values.add(value);
}
});
});
// Populate filter 3 dropdown with all values
populateFilterOptions('filter3-options', [...allFilter3Values].sort(), 3);
populateFilterOptions('filter3-options', [...combinedFilter3Values].sort(), 3);
return;
}
@@ -3106,44 +3163,45 @@
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius);
box-shadow: var(--ui-shadow-sm);
padding: 20px;
width: 100%;
max-width: 340px;
flex: 1 1 300px;
scroll-snap-align: center;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
position: relative;
}
.item-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
padding: 15px;
width: 100%;
max-width: 340px;
flex: 1 1 300px;
scroll-snap-align: center;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
position: relative;
content-visibility: auto;
contain-intrinsic-size: 300px 380px;
}
.item-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
.item-card h3 {
margin-top: 0;
}
.item-card h3 {
margin-top: 0;
font-size: 1.15rem;
margin-bottom: 8px;
}
.item-card .description {
max-height: 100px;
overflow-y: auto;
}
.item-card .description {
max-height: 60px;
overflow-y: auto;
font-size: 0.9rem;
margin-bottom: 8px;
}
.item-card .image-container {
position: relative;
max-width: 300px;
margin: 10px 0;
}
.item-card .image-container {
position: relative;
max-width: 300px;
margin: 8px 0;
}
.item-card .item-image {
width: 100%;
height: auto;
max-height: 300px;
display: none;
object-fit: cover;
}
.item-card .item-image:first-child {
.item-card .item-image {
width: 100%;
height: auto;
max-height: 180px;
display: block;
}
@@ -4614,6 +4672,18 @@ function openItemQuick(id){
}
}
</style>
{% if open_item %}
<script>
document.addEventListener("DOMContentLoaded", function() {
if (typeof openEditModalFromServer === 'function') {
setTimeout(function() {
openEditModalFromServer('{{ open_item }}');
}, 500);
}
});
</script>
{% endif %}
{% endblock %}
<style>
/* Admin/Main Content Container align */
+120 -37
View File
@@ -395,7 +395,7 @@
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius);
box-shadow: var(--ui-shadow-sm);
padding: 20px;
padding: 15px;
width: 100%;
max-width: 340px;
flex: 1 1 300px;
@@ -403,6 +403,8 @@
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
position: relative;
content-visibility: auto;
contain-intrinsic-size: 300px 380px;
}
.item-card.bulk-selected {
@@ -418,17 +420,21 @@
.item-card h3 {
margin-top: 0;
font-size: 1.15rem;
margin-bottom: 8px;
}
.item-card .description {
max-height: 100px;
max-height: 60px;
overflow-y: auto;
font-size: 0.9rem;
margin-bottom: 8px;
}
.item-card .image-container {
position: relative;
max-width: 300px;
margin: 10px 0;
margin: 8px 0;
}
.bulk-delete-row {
@@ -477,7 +483,7 @@
.item-card .item-image {
width: 100%;
height: auto;
max-height: 300px;
max-height: 180px;
display: none;
object-fit: cover;
}
@@ -2345,15 +2351,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
</div>
</div>
</h1>
<div class="admin-school-banner" style="display:flex; flex-wrap:wrap; justify-content:space-between; gap:12px; align-items:center; margin:0 0 14px; padding:14px 16px; border:1px solid #dbe3ee; border-radius:12px; background: var(--ui-surface); box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);">
<div>
<div style="font-weight:700; font-size:1rem; color:var(--ui-title);">Schulstammdaten</div>
<div style="font-size:0.92rem; color:var(--ui-text-muted);">
{{ school_info.name if school_info else 'Schulname nicht konfiguriert' }}
{% if school_info and school_info.school_number %} | Schulnummer: {{ school_info.school_number }}{% endif %}
</div>
</div>
</div>
<div id="items-indicator" class="items-indicator">Objekte im System: 0</div>
<div id="bulk-delete-drawer" class="bulk-delete-drawer" aria-hidden="true">
<div class="bulk-delete-content">
@@ -2652,7 +2649,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div id="schedule-modal" class="item-modal">
<div class="modal-content">
<span class="close-modal" onclick="closeScheduleModal()">&times;</span>
<h2>Termin planen</h2>
<h2>Reservieren</h2>
<form id="schedule-form">
<input type="hidden" id="schedule-item-id" name="item_id">
<div class="form-group" id="schedule-specific-item-group" style="display:none;">
@@ -2747,6 +2744,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
let descSearchIds = null; // Set of matching IDs or null when disabled
let currentUsername = '';
let allItems = []; // Cache for all items
let allFilter1Values = new Set();
let allFilter2Values = new Set();
let allFilter3Values = new Set();
let bulkDeleteSelection = new Set();
let bulkDeleteDrawerOpen = false;
const studentCardsModuleEnabled = {{ 'true' if student_cards_module_enabled else 'false' }};
@@ -2885,30 +2885,29 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
function rebuildFilter3Options() {
if (!allItems || allItems.length === 0) return;
if (!allItems) return;
// Get current filter 1 and filter 2 selections
const filter1Selections = activeFilters.filter1;
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) {
// Get all unique filter3 values
const allFilter3Values = new Set();
// Include dynamically added ones from loaded items too, just in case
const combinedFilter3Values = new Set(typeof allFilter3Values !== 'undefined' ? [...allFilter3Values] : []);
allItems.forEach(item => {
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 :
(item.Filter3 ? [item.Filter3] : []);
(item.Filter3 ? [item.Filter3] : []);
filter3Array.forEach(value => {
if (value && typeof value === 'string' && value.trim() !== '') {
allFilter3Values.add(value);
combinedFilter3Values.add(value);
}
});
});
// Populate filter 3 dropdown with all values
populateFilterOptions('filter3-options', [...allFilter3Values].sort(), 3);
return;
populateFilterOptions('filter3-options', [...combinedFilter3Values].sort(), 3);
}
// Filter items based on filter1 and filter2 selections
@@ -3267,6 +3266,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
// Load predefined filter values for dropdowns
loadPredefinedFilterValues(1);
loadPredefinedFilterValues(2);
loadPredefinedFilterValues(3);
// Set up edit form submission
setupEditFormSubmission();
@@ -3291,11 +3291,24 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
});
// Fetch user status to get current username
fetch('/user_status')
.then(response => response.json())
.then(data => {
currentUsername = data.username;
// Fetch user status and global filters
Promise.all([
fetch('/user_status').then(response => response.json()),
fetch('/get_filter').then(response => response.json())
])
.then(([userData, filterData]) => {
currentUsername = userData.username;
if (filterData.filter1) {
allFilter1Values = new Set(filterData.filter1.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
if (filterData.filter2) {
allFilter2Values = new Set(filterData.filter2.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
if (filterData.filter3) {
allFilter3Values = new Set(filterData.filter3.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
// Now load items with username information
loadItems();
// Setup card images display after a short delay to ensure items are loaded
@@ -3304,7 +3317,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
}, 300);
})
.catch(error => {
console.error('Error fetching user status:', error);
console.error('Error fetching user status or filters:', error);
loadItems(); // Load items anyway in case of error
});
@@ -3365,6 +3378,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
let mainAdminItemsLoadingIndicator = null;
let mainAdminItemsScrollPrefetchBound = false;
let totalItemsInSystem = 0;
let cardVirtualizationObserver = null;
function ensureMainAdminItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container');
@@ -3373,11 +3387,14 @@ document.addEventListener('DOMContentLoaded', ()=>{
if (!mainAdminItemsLoadingIndicator) {
mainAdminItemsLoadingIndicator = document.createElement('div');
mainAdminItemsLoadingIndicator.id = 'main-admin-items-loading-indicator';
mainAdminItemsLoadingIndicator.className = 'items-loading-indicator';
mainAdminItemsLoadingIndicator.className = 'items-loading-indicator empty-frames-placeholder';
mainAdminItemsLoadingIndicator.innerHTML = `
<div class="loading-track"><div class="loading-fill"></div></div>
<div class="loading-label">Weitere Objekte werden vorbereitet...</div>
<div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px;"></div>
<div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px; display: none;" class="d-none d-md-block"></div>
<div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px; display: none;" class="d-none d-lg-block"></div>
`;
mainAdminItemsLoadingIndicator.style.display = 'flex';
mainAdminItemsLoadingIndicator.style.width = '100%';
}
itemsContainer.appendChild(mainAdminItemsLoadingIndicator);
@@ -3663,7 +3680,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
</form>
<button class="edit-button" onclick="openEditModalForSelectedUnit('${item._id}', 'specific-item-card-${item._id}')">Bearbeiten</button>
<button class="duplicate-button" onclick="duplicateItem('${item._id}')">Duplizieren</button>
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
</div>
`;
itemsContainer.appendChild(card);
@@ -3704,10 +3721,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
});
// Populate filter options
populateFilterOptions('filter1-options', [...filter1Values].sort(), 1);
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
// Populate filter options using server-provided full filter lists when available.
populateFilterOptions('filter1-options', allFilter1Values.size ? [...allFilter1Values].sort() : [...filter1Values].sort(), 1);
populateFilterOptions('filter2-options', allFilter2Values.size ? [...allFilter2Values].sort() : [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', allFilter3Values.size ? [...allFilter3Values].sort() : [...filter3Values].sort(), 3);
if (!append) {
itemsContainer.scrollLeft = 0;
@@ -3792,6 +3809,60 @@ document.addEventListener('DOMContentLoaded', ()=>{
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');
card.classList.remove('empty-frame');
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');
card.classList.add('empty-frame');
}
}
}
});
}, {
// Keep approx ~120 items rendered in DOM relative to scroll position.
// Assuming ~100-400px per item/row, this margin unloads elements beyond that range.
rootMargin: isMobileLayout ? '18000px 0px 18000px 0px' : '9000px 0px 9000px 0px'
});
}
// Observe all valid cards
document.querySelectorAll('.item-card').forEach(card => cardVirtualizationObserver.observe(card));
if (!mainAdminItemsHasMore) return;
mainAdminItemsObserver = new IntersectionObserver(entries => {
@@ -4540,7 +4611,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<button class="edit-button" onclick="openEditModalForSelectedUnit('${item._id}', 'specific-item-modal-${item._id}')">Bearbeiten</button>
<button class="duplicate-button" onclick="duplicateItem('${item._id}')">Duplizieren</button>
${damageReports.length > 0 ? `<button class="damage-button" onclick="markDamageAsRepaired('${item._id}')">Repariert</button>` : `<button class="damage-button" onclick="registerDamage('${item._id}')">Schaden melden</button>`}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
<form method="POST" action="/delete_item/${item._id}" style="display:inline;" onsubmit="return confirm('Sind Sie sicher?')">
<button class="delete-button" type="submit">Löschen</button>
</form>
@@ -5725,4 +5796,16 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
}
</style>
{% if open_item %}
<script>
document.addEventListener("DOMContentLoaded", function() {
if (typeof openEditModalFromServer === 'function') {
setTimeout(function() {
openEditModalFromServer('{{ open_item }}');
}, 500);
}
});
</script>
{% endif %}
{% endblock %}
+105 -22
View File
@@ -15,16 +15,16 @@
<h1 class="mb-4">Filterwerte verwalten</h1>
<div class="row">
<!-- Filter 1: Unterrichtsfach -->
<div class="col-md-6">
<!-- Filter 1 -->
<div class="col-md-4">
<div class="card mb-4">
<div class="card-header">
<h2 class="card-title h5 mb-0">Unterrichtsfach (Filter 1)</h2>
<h2 class="card-title h5 mb-0">{{ filter_names.get('1', 'Fach/Kategorie') }} (Filter 1)</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4">
<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>
</div>
</form>
@@ -33,13 +33,25 @@
{% if filter1_values %}
<div class="list-group">
{% for value in filter1_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=1, 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>
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center mb-2">
<span>{{ value }}</span>
<div>
<button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-1-{{ loop.index }}')">Bearbeiten</button>
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=1, 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>
</div>
<form id="edit-1-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=1, old_value=value) }}" style="display: none;" class="mt-2">
<div class="input-group input-group-sm">
<input type="text" name="new_value" class="form-control" value="{{ value }}" required>
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button>
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-1-{{ loop.index }}')">Abbrechen</button>
</div>
</form>
</div>
{% endfor %}
@@ -51,16 +63,16 @@
</div>
</div>
<!-- Filter 2: Jahrgangsstufe -->
<div class="col-md-6">
<!-- Filter 2 -->
<div class="col-md-4">
<div class="card mb-4">
<div class="card-header">
<h2 class="card-title h5 mb-0">Jahrgangsstufe (Filter 2)</h2>
<h2 class="card-title h5 mb-0">{{ filter_names.get('2', 'System/Bereich') }} (Filter 2)</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4">
<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>
</div>
</form>
@@ -69,13 +81,73 @@
{% if filter2_values %}
<div class="list-group">
{% for value in filter2_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=2, 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>
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center mb-2">
<span>{{ value }}</span>
<div>
<button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-2-{{ loop.index }}')">Bearbeiten</button>
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=2, 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>
</div>
<form id="edit-2-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=2, old_value=value) }}" style="display: none;" class="mt-2">
<div class="input-group input-group-sm">
<input type="text" name="new_value" class="form-control" value="{{ value }}" required>
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button>
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-2-{{ loop.index }}')">Abbrechen</button>
</div>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">Keine Werte definiert.</div>
{% endif %}
</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">{{ filter_names.get('3', '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">
<div class="d-flex justify-content-between align-items-center mb-2">
<span>{{ value }}</span>
<div>
<button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-3-{{ loop.index }}')">Bearbeiten</button>
<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>
</div>
<form id="edit-3-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=3, old_value=value) }}" style="display: none;" class="mt-2">
<div class="input-group input-group-sm">
<input type="text" name="new_value" class="form-control" value="{{ value }}" required>
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button>
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-3-{{ loop.index }}')">Abbrechen</button>
</div>
</form>
</div>
{% endfor %}
@@ -97,4 +169,15 @@
<a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Admin-Übersicht</a>
</div>
</div>
<script>
function toggleEdit(id) {
const el = document.getElementById(id);
if (el.style.display === 'none') {
el.style.display = 'block';
} else {
el.style.display = 'none';
}
}
</script>
{% endblock %}
-1
View File
@@ -709,7 +709,6 @@
</style>
<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 %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
+4
View File
@@ -96,6 +96,10 @@ def get_tenant_config(tenant_id=None):
if tenant_id in TENANT_REGISTRY:
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 {}
-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)
+32
View File
@@ -0,0 +1,32 @@
from pymongo import MongoClient
import Web.settings as cfg
def get_filter_names():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
names = db.settings.find_one({'setting_type': 'filter_names'})
client.close()
if names:
return names.get('names', {
'1': 'Fach/Kategorie',
'2': 'System/Bereich',
'3': 'Typ/Art'
})
return {
'1': 'Fach/Kategorie',
'2': 'System/Bereich',
'3': 'Typ/Art'
}
def set_filter_name(filter_num, name):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
names = get_filter_names()
names[str(filter_num)] = name
db.settings.update_one(
{'setting_type': 'filter_names'},
{'$set': {'names': names}},
upsert=True
)
client.close()
return True
+1 -7
View File
@@ -498,8 +498,6 @@ def check_nm_pwd(username, password):
finally:
client.close()
return None
def add_user(
username,
@@ -536,16 +534,12 @@ def add_user(
for key, value in page_permissions.items():
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 = {
'Username': username,
'Password': hashing(password),
'Admin': False,
'active_ausleihung': None,
'name': name_alias,
'name': name.strip() if name else '',
'last_name': last_name.strip() if last_name else '',
'IsStudent': bool(is_student),
'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
+7 -1
View File
@@ -144,7 +144,13 @@ services:
# Health check for load balancer
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5).read(); print('OK')"
]
interval: 30s
timeout: 10s
retries: 3
+47 -25
View File
@@ -1,41 +1,63 @@
version: "3.8"
services:
app:
build: .
container_name: inventory-app
image: ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.39
container_name: inventarsystem-app
restart: unless-stopped
environment:
- MONGO_URL=mongodb://mongodb:27017/inventar
- REDIS_URL=redis://redis:6379
- BASE_URL=https://inventar.maximiliangruendinger.de #alle öffentliche subdomains
ports:
- "10000:8000"
depends_on:
- mongodb
- 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:
image: mongo:latest
container_name: mongodb
image: mongo:7.0
container_name: inventarsystem-mongodb
restart: unless-stopped
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:
image: redis:alpine
container_name: redis
image: redis:7-alpine
container_name: inventarsystem-redis
restart: unless-stopped
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
# Der Tunnel-Name 'homeserver' muss zu deiner credentials.json passen
command: tunnel run homeserver
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
ports:
- "6379:6379"
volumes:
- ./config.yml:/etc/cloudflared/config.yml
- ./credentials.json:/etc/cloudflared/credentials.json
depends_on:
- app
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
mongo_data:
mongodb_data:
app_uploads:
app_thumbnails:
app_previews:
app_qrcodes:
app_backups:
app_logs:
redis_data:
+40
View File
@@ -11,6 +11,44 @@ fi
CONFIG_FILE="$PWD/config.json"
ensure_runtime_config_json() {
local config_path backup_path
config_path="$CONFIG_FILE"
if [ -d "$config_path" ]; then
backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak"
mv "$config_path" "$backup_path"
echo "Warning: moved unexpected directory $config_path to $backup_path"
fi
if [ ! -f "$config_path" ]; then
cat > "$config_path" <<'EOF'
{
"ver": "2.6.5",
"dbg": false,
"host": "0.0.0.0",
"port": 8000,
"mongodb": {
"host": "mongodb",
"port": 27017,
"db": "Inventarsystem"
},
"modules": {
"library": {
"enabled": false
},
"student_cards": {
"enabled": false,
"default_borrow_days": 14,
"max_borrow_days": 365
}
}
}
EOF
echo "Created default runtime config at $config_path"
fi
}
show_help() {
echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]"
echo ""
@@ -176,6 +214,8 @@ EOF
restart_app_container() {
local env_file="$PWD/.docker-build.env"
local compose_args=()
ensure_runtime_config_json
# If HOST_WORKDIR is set (called from container), use absolute paths so docker daemon resolves them correctly
if [ -n "$HOST_WORKDIR" ]; then