feat: implement school year rollover functionality with configurable settings

This commit is contained in:
2026-05-28 20:46:28 +02:00
parent 0edbef3edd
commit d722b5a774
2 changed files with 221 additions and 21 deletions
+202 -20
View File
@@ -74,6 +74,7 @@ import mimetypes
import subprocess import subprocess
from Web.modules.inventarsystem.data_protection import ( from Web.modules.inventarsystem.data_protection import (
decrypt_document_fields, decrypt_document_fields,
decrypt_text,
encrypt_document_fields, encrypt_document_fields,
encrypt_soft_deleted_media_pack, encrypt_soft_deleted_media_pack,
) )
@@ -154,6 +155,119 @@ def _decrypt_student_card_doc(card_doc):
return card_doc return card_doc
return decrypt_document_fields(card_doc, STUDENT_CARD_ENCRYPTED_FIELDS) return decrypt_document_fields(card_doc, STUDENT_CARD_ENCRYPTED_FIELDS)
def _parse_and_increment_class(klass, max_class=13, graduate_label=''):
"""Parse a class string like '7A' or '10' and increment the numeric part.
If the numeric part would exceed max_class, return graduate_label (or empty).
Leaves non-numeric / unparsable values unchanged.
"""
if not klass:
return klass
s = str(klass).strip()
m = re.match(r"^\s*(\d{1,2})(\D.*)?$", s)
if not m:
return s
try:
num = int(m.group(1))
except Exception:
return s
suffix = m.group(2) or ''
new_num = num + 1
if max_class is not None and new_num > int(max_class):
return graduate_label or ''
return f"{new_num}{suffix}"
def rollover_student_card_classes(dry_run=False, *, max_class=None, graduate_label=''):
"""Increment class years on all student cards.
Returns a summary dict with counts.
"""
client = None
updated = 0
examined = 0
failures = 0
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
col = db['student_cards']
now = datetime.datetime.now()
cursor = list(col.find({}, {'Klasse': 1}))
for doc in cursor:
examined += 1
try:
# Decrypt to work on plain class string
plain = dict(doc)
plain = _decrypt_student_card_doc(plain)
orig = (plain.get('Klasse') or '').strip()
new_class = _parse_and_increment_class(orig, max_class=max_class, graduate_label=graduate_label)
if new_class != orig:
if dry_run:
updated += 1
continue
payload = {'Klasse': new_class}
encrypted = encrypt_document_fields(payload, STUDENT_CARD_ENCRYPTED_FIELDS)
update_doc = {'Klasse': encrypted.get('Klasse'), 'Aktualisiert': now}
col.update_one({'_id': doc.get('_id')}, {'$set': update_doc})
updated += 1
except Exception:
failures += 1
app.logger.exception('Failed to process student card rollover for %s', doc.get('_id'))
finally:
if client:
client.close()
summary = {'examined': examined, 'updated': updated, 'failures': failures, 'dry_run': bool(dry_run)}
try:
_append_audit_event_standalone('student_cards_rollover', summary)
except Exception:
app.logger.warning('Audit write failed for student_cards_rollover')
return summary
# Admin route to trigger rollover manually
@app.route('/admin/trigger_school_year_rollover', methods=['POST'])
def admin_trigger_school_year_rollover():
if 'username' not in session:
return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401
if not us.check_admin(session['username']):
return jsonify({'ok': False, 'message': 'Administratorrechte erforderlich.'}), 403
max_class = int(os.getenv('INVENTAR_SCHOOL_MAX_CLASS', '13'))
graduate_label = os.getenv('INVENTAR_SCHOOL_GRADUATE_LABEL', '')
dry = request.args.get('dry', '0') in ('1', 'true', 'yes')
summary = rollover_student_card_classes(dry_run=dry, max_class=max_class, graduate_label=graduate_label)
return jsonify({'ok': True, 'summary': summary}), 200
# Schedule annual rollover job using APScheduler (configurable via env)
try:
if cfg.SCHEDULER_ENABLED:
_rollover_month = getattr(cfg, 'SCHOOL_ROLLOVER_MONTH', 9)
_rollover_day = getattr(cfg, 'SCHOOL_ROLLOVER_DAY', 1)
_rollover_hour = getattr(cfg, 'SCHOOL_ROLLOVER_HOUR', 3)
_rollover_minute = getattr(cfg, 'SCHOOL_ROLLOVER_MIN', 0)
_rollover_max_class = getattr(cfg, 'SCHOOL_ROLLOVER_MAX_CLASS', 13)
_rollover_grad_label = getattr(cfg, 'SCHOOL_ROLLOVER_GRADUATE_LABEL', '')
_scheduler = BackgroundScheduler()
# Use a cron-style yearly job on the configured month/day
_scheduler.add_job(
func=lambda: rollover_student_card_classes(dry_run=False, max_class=_rollover_max_class, graduate_label=_rollover_grad_label),
trigger='cron',
month=_rollover_month,
day=_rollover_day,
hour=_rollover_hour,
minute=_rollover_minute,
id='school_year_rollover',
replace_existing=True
)
_scheduler.start()
app.logger.info('Scheduled annual school year rollover: %s-%s %s:%s', _rollover_month, _rollover_day, _rollover_hour, _rollover_minute)
except Exception as e:
app.logger.warning('Failed to schedule school year rollover: %s', e)
# Thumbnail sizes # Thumbnail sizes
THUMBNAIL_SIZE = cfg.THUMBNAIL_SIZE THUMBNAIL_SIZE = cfg.THUMBNAIL_SIZE
PREVIEW_SIZE = cfg.PREVIEW_SIZE PREVIEW_SIZE = cfg.PREVIEW_SIZE
@@ -3154,22 +3268,33 @@ def api_library_items():
has_damage = bool(doc.get('HasDamage')) or condition_value == 'destroyed' has_damage = bool(doc.get('HasDamage')) or condition_value == 'destroyed'
has_active_borrow = any(str(unit.get('_id')) in active_item_ids for unit in grouped_units) has_active_borrow = any(str(unit.get('_id')) in active_item_ids for unit in grouped_units)
if has_damage and not has_active_borrow: # Prefer 'available' when any grouped unit is available
if has_damage and len(available_units) == 0:
doc['LibraryDisplayStatus'] = 'damaged' doc['LibraryDisplayStatus'] = 'damaged'
elif len(available_units) > 0:
doc['LibraryDisplayStatus'] = 'available'
elif has_active_borrow or doc.get('Verfuegbar') is False: elif has_active_borrow or doc.get('Verfuegbar') is False:
doc['LibraryDisplayStatus'] = 'borrowed' doc['LibraryDisplayStatus'] = 'borrowed'
else: else:
doc['LibraryDisplayStatus'] = 'available' doc['LibraryDisplayStatus'] = 'available'
# Determine borrower: prefer any active borrow on group units, fallback to parent.User # Determine borrower: prefer any active borrow on grouped units, fallback to parent.User
borrower = active_user_by_item.get(str(parent.get('_id'))) or '' borrower = ''
for unit in grouped_units:
u_id = str(unit.get('_id'))
if active_user_by_item.get(u_id):
borrower = active_user_by_item.get(u_id)
break
if not borrower: if not borrower:
for unit in grouped_units: borrower = doc.get('User', '')
u_id = str(unit.get('_id')) # Decrypt if value is encrypted (decrypt_text returns original if not encrypted)
if active_user_by_item.get(u_id): try:
borrower = active_user_by_item.get(u_id) borrower = decrypt_text(borrower) if borrower else ''
break except Exception:
doc['BorrowedBy'] = borrower or doc.get('User', '') # Fallback: keep original string if decryption fails
borrower = borrower or ''
doc['BorrowedBy'] = borrower
doc['GroupedDisplayCount'] = 1 + len(children) doc['GroupedDisplayCount'] = 1 + len(children)
doc['Quantity'] = doc['GroupedDisplayCount'] doc['Quantity'] = doc['GroupedDisplayCount']
@@ -3367,27 +3492,81 @@ def api_item_detail(item_id):
client = MongoClient(MONGODB_HOST, MONGODB_PORT) client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB] db = client[MONGODB_DB]
ausleihungen_col = db['ausleihungen'] ausleihungen_col = db['ausleihungen']
items_col = db['items']
active_borrow = ausleihungen_col.find_one( # Determine parent/group id (if this is a child, use its ParentItemId)
{'Item': str(item.get('_id')), 'Status': 'active'}, parent_id = str(item.get('ParentItemId') or str(item.get('_id')))
{'User': 1}
)
client.close()
# Load all group units (parent + children)
group_units_cursor = items_col.find({
'$or': [
{'_id': ObjectId(parent_id)},
{'ParentItemId': parent_id}
],
'Deleted': {'$ne': True}
})
group_units = list(group_units_cursor)
if not group_units:
# Fallback: just use the single item
group_units = [item]
group_unit_ids = [str(u.get('_id')) for u in group_units if u.get('_id')]
# Fetch all borrow records for the whole group (active + history)
borrow_records = list(ausleihungen_col.find({'Item': {'$in': group_unit_ids}}).sort('Start', -1))
# Compute availability and damage state across group
available_units = [u for u in group_units if u.get('Verfuegbar', True)]
condition_value = str(item.get('Condition', '')).strip().lower() condition_value = str(item.get('Condition', '')).strip().lower()
has_damage = bool(item.get('HasDamage')) or condition_value == 'destroyed' has_damage = bool(item.get('HasDamage')) or condition_value == 'destroyed'
if has_damage and not active_borrow:
if has_damage and len(available_units) == 0:
status_label = 'Defekt/Zerstört' status_label = 'Defekt/Zerstört'
elif item.get('Verfuegbar') is False or active_borrow: elif len(available_units) > 0:
status_label = 'Verfügbar'
elif any(r.get('Status') == 'active' for r in borrow_records):
status_label = 'Ausgeliehen' status_label = 'Ausgeliehen'
else: else:
status_label = 'Verfügbar' status_label = 'Verfügbar'
# Prefer the most recent active borrower across the group
borrower_value = '' borrower_value = ''
if active_borrow: for rec in borrow_records:
borrower_value = active_borrow.get('User', '') if rec.get('Status') == 'active' and rec.get('User'):
elif item.get('User'): try:
borrower_value = item.get('User') borrower_value = decrypt_text(rec.get('User'))
except Exception:
borrower_value = rec.get('User')
break
if not borrower_value and item.get('User'):
try:
borrower_value = decrypt_text(item.get('User'))
except Exception:
borrower_value = item.get('User')
# Helper to format datetimes
def fmt_dt(dt):
try:
return dt.strftime('%d.%m.%Y %H:%M') if dt else ''
except Exception:
return str(dt) if dt else ''
# Build HTML for borrow records (if any)
borrows_html = ''
if borrow_records:
rows = []
for rec in borrow_records:
user_raw = rec.get('User') or ''
try:
user = decrypt_text(user_raw) if user_raw else ''
except Exception:
user = user_raw
status = rec.get('Status', '')
start = fmt_dt(rec.get('Start'))
end = fmt_dt(rec.get('End'))
notes = html.escape(str(rec.get('Notes') or ''))
rows.append(f"<li><strong>{html.escape(user or '-')}</strong> — {html.escape(status)}{html.escape(start)}{html.escape(end)}{('' + notes) if notes else ''}</li>")
borrows_html = f"<h3>Ausleihhistorie</h3><ul>{''.join(rows)}</ul>"
# Basic detail HTML # Basic detail HTML
detail_html = f""" detail_html = f"""
@@ -3397,7 +3576,10 @@ def api_item_detail(item_id):
<p><strong>Beschreibung:</strong> {html.escape(item.get('Beschreibung', '-'))}</p> <p><strong>Beschreibung:</strong> {html.escape(item.get('Beschreibung', '-'))}</p>
<p><strong>Status:</strong> {html.escape(status_label)}</p> <p><strong>Status:</strong> {html.escape(status_label)}</p>
{f'<p><strong>Ausgeliehen von:</strong> {html.escape(str(borrower_value))}</p>' if borrower_value and status_label == 'Ausgeliehen' else ''} {f'<p><strong>Ausgeliehen von:</strong> {html.escape(str(borrower_value))}</p>' if borrower_value and status_label == 'Ausgeliehen' else ''}
{borrows_html}
""" """
client.close()
return detail_html, 200
return detail_html, 200 return detail_html, 200
except Exception as e: except Exception as e:
app.logger.error(f"Error fetching item detail: {e}") app.logger.error(f"Error fetching item detail: {e}")
+18
View File
@@ -71,6 +71,15 @@ DEFAULTS = {
'logo_path': '', 'logo_path': '',
'logo_thumb': '', 'logo_thumb': '',
'logo_thumb': '', 'logo_thumb': '',
# School-level rollover configuration for advancing class years
'rollover': {
'month': 9,
'day': 1,
'hour': 3,
'minute': 0,
'max_class': 13,
'graduate_label': ''
},
}, },
'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)"},
@@ -225,6 +234,15 @@ SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods']) SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
SCHOOL_INFO_DEFAULT = _get(_conf, ['school'], DEFAULTS['school']) SCHOOL_INFO_DEFAULT = _get(_conf, ['school'], DEFAULTS['school'])
# School rollover configuration (can be set in config.json under `school.rollover`)
SCHOOL_ROLLOVER = _get(_conf, ['school', 'rollover'], DEFAULTS['school'].get('rollover', {}))
SCHOOL_ROLLOVER_MONTH = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_MONTH', str(SCHOOL_ROLLOVER.get('month', 9))))
SCHOOL_ROLLOVER_DAY = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_DAY', str(SCHOOL_ROLLOVER.get('day', 1))))
SCHOOL_ROLLOVER_HOUR = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_HOUR', str(SCHOOL_ROLLOVER.get('hour', 3))))
SCHOOL_ROLLOVER_MIN = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_MIN', str(SCHOOL_ROLLOVER.get('minute', 0))))
SCHOOL_ROLLOVER_MAX_CLASS = int(os.getenv('INVENTAR_SCHOOL_MAX_CLASS', str(SCHOOL_ROLLOVER.get('max_class', 13))))
SCHOOL_ROLLOVER_GRADUATE_LABEL = os.getenv('INVENTAR_SCHOOL_GRADUATE_LABEL', str(SCHOOL_ROLLOVER.get('graduate_label', '')))
# Optional feature modules # Optional feature modules
TENANT_CONFIGS = _get(_conf, ['tenants'], {}) TENANT_CONFIGS = _get(_conf, ['tenants'], {})