Compare commits

...

40 Commits

Author SHA1 Message Date
github-actions[bot] 249a2bc2da chore: bump version to v0.8.17 2026-05-31 14:13:47 +00:00
Aiirondev_dev 7ab8f841d0 feat: Enhance tenant management and appointment deletion functionality 2026-05-31 16:13:11 +02:00
github-actions[bot] 43c544244c chore: bump version to v0.8.16 2026-05-31 13:47:10 +00:00
Aiirondev_dev 73e4407d7b feat: Implement upcoming appointments feature for users
- Added `remove_done` function to clean up finished appointments in the database.
- Introduced `get_upcoming_for_user` function to retrieve upcoming appointments for a specific user.
- Updated `backend_server.py` to include a new endpoint for fetching user-specific upcoming events.
- Modified `blueprint.py` to render upcoming events in the user interface.
- Enhanced `terminplan.html` to display a list of upcoming appointments with relevant details and action links.
- Updated configuration to enable the new appointment planning feature.
2026-05-31 15:46:22 +02:00
github-actions[bot] 94fc01c08a chore: bump version to v0.8.15 2026-05-30 12:55:19 +00:00
Aiirondev_dev 788ec1db62 feat: update terminology in calendar interface for clarity and consistency 2026-05-30 14:54:40 +02:00
github-actions[bot] 725584a104 chore: bump version to v0.8.14 2026-05-30 12:46:44 +00:00
Aiirondev_dev fbcc3b4522 feat: enhance calendar loading experience with skeleton UI 2026-05-30 14:46:22 +02:00
github-actions[bot] adc2bad3fb chore: bump version to v0.8.13 2026-05-30 12:31:16 +00:00
Aiirondev_dev f7be7d9bf0 feat: implement maintenance and not found error pages with appropriate messaging 2026-05-30 14:30:35 +02:00
github-actions[bot] 1bd2db41b4 chore: bump version to v0.8.12 2026-05-30 12:16:57 +00:00
Aiirondev_dev ac68beb80a feat: enhance appointment configuration with dynamic time frame generation and default time settings 2026-05-30 14:16:31 +02:00
github-actions[bot] ea6826b304 chore: bump version to v0.8.11 2026-05-30 12:08:25 +00:00
Aiirondev_dev d668e54042 feat: add calendar export functionality and option to create ICS file for appointments 2026-05-30 14:06:26 +02:00
Aiirondev_dev 3fd00aec30 feat: enhance calendar functionality with dayGrid and timeGrid plugins 2026-05-30 14:00:56 +02:00
github-actions[bot] ef2d1dfab0 chore: bump version to v0.8.10 2026-05-30 11:51:16 +00:00
Aiirondev_dev 1c5aa9beed feat: enhance appointment management with email service integration and calendar view options 2026-05-30 13:49:55 +02:00
github-actions[bot] a7d3863fde chore: bump version to v0.8.9 2026-05-30 11:29:25 +00:00
Aiirondev_dev ac0e60fadb fix: remove redundant endif statements in base.html 2026-05-30 13:28:59 +02:00
github-actions[bot] cbeed68147 chore: bump version to v0.8.8 2026-05-30 11:15:12 +00:00
Aiirondev_dev 715d932dba Merge remote-tracking branch 'refs/remotes/origin/main' 2026-05-30 13:09:30 +02:00
Aiirondev_dev 606e1822d1 fix: increase max-requests and max-requests-jitter for gunicorn in Dockerfile, docker-compose, and start.sh 2026-05-30 13:09:09 +02:00
github-actions[bot] c0ce80d223 chore: bump version to v0.8.7 2026-05-30 08:23:52 +00:00
Aiirondev_dev 96debef4f4 fix: update import path for tenant context and ensure client_id is a string 2026-05-30 00:19:35 +02:00
github-actions[bot] c6c57e4b53 chore: bump version to v0.8.6 2026-05-29 22:00:50 +00:00
Aiirondev_dev 3dc9ecfe5b fix: update import path for email service module 2026-05-29 23:59:20 +02:00
Aiirondev_dev 8e20580552 feat: enhance tenant removal process with confirmation and force option 2026-05-29 23:56:17 +02:00
github-actions[bot] aa47325e80 chore: bump version to v0.8.5 2026-05-29 21:23:03 +00:00
Aiirondev_dev 539cf2b21c feat: add appointment management module with booking and configuration features 2026-05-29 23:21:09 +02:00
github-actions[bot] 4ae0d7f00e chore: bump version to v0.8.4 2026-05-29 13:50:26 +00:00
Aiirondev_dev 0b09fe489e fix: update book_slot function parameters and documentation for clarity 2026-05-29 15:48:57 +02:00
Aiirondev_dev a98f3751e9 feat: add email service and appointment management module with configuration settings 2026-05-29 15:38:22 +02:00
Aiirondev_dev d722b5a774 feat: implement school year rollover functionality with configurable settings 2026-05-28 20:46:28 +02:00
github-actions[bot] 0edbef3edd chore: bump version to v0.8.3 2026-05-28 17:41:04 +00:00
Aiirondev_dev d325fa57e8 feat: add CSRF token handling for delete and borrow forms 2026-05-28 19:39:33 +02:00
Aiirondev_dev 8a6a842e1a feat: add borrowing count validation and prompt for library items 2026-05-28 19:35:15 +02:00
github-actions[bot] b4d61e27f4 chore: bump version to v0.8.2 2026-05-28 17:23:55 +00:00
Aiirondev_dev 318e57475f feat: enhance library item API to support grouping of sub-items and improve data aggregation 2026-05-28 19:23:10 +02:00
github-actions[bot] 6a72bd5d82 chore: bump version to v0.8.1 2026-05-28 16:53:11 +00:00
Aiirondev_dev 74fa0d8a68 feat: add soft-delete functionality for library items with confirmation dialog 2026-05-28 18:51:55 +02:00
25 changed files with 2316 additions and 1166 deletions
+1 -1
View File
@@ -1 +1 @@
v0.8.0
v0.8.17
+1 -1
View File
@@ -32,4 +32,4 @@ RUN if [ "$NUITKA_BUILD" = "1" ]; then \
WORKDIR /app/Web
EXPOSE 8000
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
+405 -80
View File
@@ -74,15 +74,17 @@ import mimetypes
import subprocess
from Web.modules.inventarsystem.data_protection import (
decrypt_document_fields,
decrypt_text,
encrypt_document_fields,
encrypt_soft_deleted_media_pack,
)
from Web.modules.terminplaner.blueprint import appoint_bp as terminplaner_bp
# Set base directory and centralized settings
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
from tenant import get_tenant_context, get_tenant_trial_status, purge_expired_trial_tenants
from tenant import get_tenant_context, get_tenant_db, get_tenant_trial_status, purge_expired_trial_tenants
app = Flask(__name__, static_folder='static') # Correctly set static folder
@@ -108,6 +110,7 @@ app.config['SESSION_COOKIE_SECURE'] = cfg.SSL_ENABLED if os.getenv('INVENTAR_SES
app.config['PREFERRED_URL_SCHEME'] = 'https' if app.config['SESSION_COOKIE_SECURE'] else 'http'
# app.config['QR_CODE_FOLDER'] = cfg.QR_CODE_FOLDER # QR Code storage deactivated
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
app.register_blueprint(terminplaner_bp, url_prefix='/terminplaner')
"""--------------------------------------------------------------Path Init-------------------------------------------------------"""
@@ -154,6 +157,119 @@ def _decrypt_student_card_doc(card_doc):
return card_doc
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_SIZE = cfg.THUMBNAIL_SIZE
PREVIEW_SIZE = cfg.PREVIEW_SIZE
@@ -510,14 +626,18 @@ def handle_unexpected_exception(e):
if request.is_json or request.path.startswith('/api/'):
return jsonify({'error': 'Internal server error', 'status': 500}), 500
if 'username' in session:
try:
return render_template(
'maintenance.html',
error_code=500,
error_message='Das System führt gerade Wartungsarbeiten durch.',
), 500
except Exception:
try:
return render_template('error.html', error_code=500, error_message='Interner Serverfehler.'), 500
return render_template('error.html', error_code=500, error_message='Das System führt gerade Wartungsarbeiten durch.'), 500
except Exception:
return 'Internal Server Error', 500
return redirect(url_for('login'))
def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'):
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}:
@@ -1145,6 +1265,7 @@ def inject_version():
'CURRENT_MODULE': current_module,
'school_periods': SCHOOL_PERIODS,
'inventory_module_enabled': cfg.MODULES.is_enabled('inventory'),
'terminplan_module_enabled': cfg.MODULES.is_enabled('terminplan'),
'library_module_enabled': cfg.MODULES.is_enabled('library'),
'student_cards_module_enabled': cfg.MODULES.is_enabled('student_cards'),
'is_admin': is_admin,
@@ -3053,6 +3174,7 @@ def api_library_items():
query = {
'ItemType': {'$in': ['book', 'cd', 'dvd', 'media']},
'IsGroupedSubItem': {'$ne': True},
'Deleted': {'$ne': True}
}
@@ -3078,7 +3200,39 @@ def api_library_items():
raw_items = list(items_db.find(query, projection).sort([('Name', 1), ('_id', 1)]).skip(offset).limit(limit))
# Build maps for grouping by parent-child relationship (grouped sub-items)
all_ids = [str(itm.get('_id')) for itm in raw_items if itm.get('_id')]
parent_ids_list = [str(itm.get('_id')) for itm in raw_items if itm.get('_id')]
children_by_parent = {}
child_items = []
if parent_ids_list:
child_projection = {
'Name': 1,
'Autor': 1,
'Author': 1,
'ISBN': 1,
'Code_4': 1,
'Code4': 1,
'ItemType': 1,
'Verfuegbar': 1,
'Condition': 1,
'HasDamage': 1,
'User': 1,
'Ort': 1,
'Beschreibung': 1,
'Image': 1,
'ParentItemId': 1,
}
child_items = list(items_db.find({
'ParentItemId': {'$in': parent_ids_list},
'IsGroupedSubItem': True,
'Deleted': {'$ne': True}
}, child_projection))
for child in child_items:
parent_id = str(child.get('ParentItemId') or '')
if not parent_id:
continue
children_by_parent.setdefault(parent_id, []).append(child)
all_ids = parent_ids_list + [str(child.get('_id')) for child in child_items if child.get('_id')]
active_records = []
if all_ids:
active_records = list(ausleihungen_db.find({'Item': {'$in': all_ids}, 'Status': 'active'}, {'Item': 1, 'User': 1}))
@@ -3093,26 +3247,10 @@ def api_library_items():
if item_id not in active_user_by_item:
active_user_by_item[item_id] = rec.get('User', '')
# Organize children under their parent (ParentItemId) and prepare parent list
items_by_id = {}
children_by_parent = {}
parent_ids = set()
for itm in raw_items:
iid = str(itm.get('_id'))
items_by_id[iid] = itm
parent = str(itm.get('ParentItemId') or '')
if parent:
children_by_parent.setdefault(parent, []).append(itm)
else:
parent_ids.add(iid)
# Build aggregated list: for each parent id, include parent + children as one entry
# Build aggregated list: iterate ordered parent list and include children like inventory module
aggregated = []
processed = set()
for pid in list(parent_ids):
parent = items_by_id.get(pid)
if not parent:
continue
for parent in raw_items:
pid = str(parent.get('_id'))
children = children_by_parent.get(pid, [])
# Compute grouped counts and availability
@@ -3137,22 +3275,33 @@ def api_library_items():
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)
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'
elif len(available_units) > 0:
doc['LibraryDisplayStatus'] = 'available'
elif has_active_borrow or doc.get('Verfuegbar') is False:
doc['LibraryDisplayStatus'] = 'borrowed'
else:
doc['LibraryDisplayStatus'] = 'available'
# Determine borrower: prefer any active borrow on group units, fallback to parent.User
borrower = active_user_by_item.get(str(parent.get('_id'))) or ''
# Determine borrower: prefer any active borrow on grouped units, fallback to parent.User
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:
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
doc['BorrowedBy'] = borrower or doc.get('User', '')
borrower = doc.get('User', '')
# Decrypt if value is encrypted (decrypt_text returns original if not encrypted)
try:
borrower = decrypt_text(borrower) if borrower else ''
except Exception:
# Fallback: keep original string if decryption fails
borrower = borrower or ''
doc['BorrowedBy'] = borrower
doc['GroupedDisplayCount'] = 1 + len(children)
doc['Quantity'] = doc['GroupedDisplayCount']
@@ -3161,40 +3310,6 @@ def api_library_items():
doc['GroupedAllCodes'] = grouped_all_codes
aggregated.append(doc)
processed.add(pid)
for c in children:
processed.add(str(c.get('_id')))
# Include any remaining items (orphans or standalones not parented)
for itm in raw_items:
iid = str(itm.get('_id'))
if iid in processed:
continue
doc = dict(itm)
doc['_id'] = iid
if doc.get('Code4') in (None, '') and doc.get('Code_4') not in (None, ''):
doc['Code4'] = doc.get('Code_4')
condition_value = str(doc.get('Condition', '')).strip().lower()
has_damage = bool(doc.get('HasDamage')) or condition_value == 'destroyed'
has_active_borrow = iid in active_item_ids
if has_damage and not has_active_borrow:
doc['LibraryDisplayStatus'] = 'damaged'
elif has_active_borrow or doc.get('Verfuegbar') is False:
doc['LibraryDisplayStatus'] = 'borrowed'
else:
doc['LibraryDisplayStatus'] = 'available'
doc['BorrowedBy'] = active_user_by_item.get(iid) or doc.get('User', '')
# Single item: grouped count = 1
doc['GroupedDisplayCount'] = 1
doc['Quantity'] = 1
doc['AvailableGroupedCount'] = 1 if doc.get('Verfuegbar', True) else 0
doc['GroupedAvailableUnits'] = [{'id': iid, 'code': doc.get('Code_4') or doc.get('Code4') or '', 'label': f"{doc.get('Code_4') or doc.get('Code4') or '-'} ({doc.get('Name','')})"}] if doc.get('Verfuegbar', True) else []
doc['GroupedAllCodes'] = [doc.get('Code_4') or doc.get('Code4') or '']
aggregated.append(doc)
client.close()
@@ -3384,28 +3499,82 @@ def api_item_detail(item_id):
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
ausleihungen_col = db['ausleihungen']
items_col = db['items']
active_borrow = ausleihungen_col.find_one(
{'Item': str(item.get('_id')), 'Status': 'active'},
{'User': 1}
)
client.close()
# Determine parent/group id (if this is a child, use its ParentItemId)
parent_id = str(item.get('ParentItemId') or str(item.get('_id')))
# 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()
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'
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'
else:
status_label = 'Verfügbar'
# Prefer the most recent active borrower across the group
borrower_value = ''
if active_borrow:
borrower_value = active_borrow.get('User', '')
elif item.get('User'):
borrower_value = item.get('User')
for rec in borrow_records:
if rec.get('Status') == 'active' and rec.get('User'):
try:
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
detail_html = f"""
<h2>{html.escape(item.get('Name', 'Untitled'))}</h2>
@@ -3414,7 +3583,10 @@ def api_item_detail(item_id):
<p><strong>Beschreibung:</strong> {html.escape(item.get('Beschreibung', '-'))}</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 ''}
{borrows_html}
"""
client.close()
return detail_html, 200
return detail_html, 200
except Exception as e:
app.logger.error(f"Error fetching item detail: {e}")
@@ -4631,6 +4803,82 @@ def get_bookings():
client.close()
@app.route('/get_user_appointments')
def get_user_appointments():
"""Return the current user's planned and active appointments for the calendar."""
if 'username' not in session:
return jsonify({'ok': False, 'error': 'unauthorized'}), 401
client = None
try:
username = session.get('username')
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_col = db['items']
# Use the established user-specific route logic for appointments.
bookings = au.get_ausleihung_by_user(
username,
status=['planned', 'active', 'completed'],
use_client_side_verification=True,
)
bookings = sorted(bookings, key=lambda b: b.get('Start') or datetime.datetime.min)
result = []
for booking in bookings:
start_dt = booking.get('Start')
if not start_dt:
continue
end_dt = booking.get('End')
if not end_dt and isinstance(start_dt, datetime.datetime):
end_dt = start_dt + datetime.timedelta(minutes=45)
elif not end_dt:
end_dt = start_dt
item_id = str(booking.get('Item') or '')
item_doc = None
if item_id:
try:
item_doc = items_col.find_one({'_id': ObjectId(item_id)})
except Exception:
item_doc = None
item_name = item_id or 'Termin'
if item_doc:
item_name = item_doc.get('Name') or item_doc.get('Code_4') or item_name
period = booking.get('Period')
title = item_name
if period:
title = f"{title} - {period}. Std"
status = booking.get('VerifiedStatus') or booking.get('Status') or 'unknown'
if status == 'active':
status = 'current'
result.append({
'id': str(booking.get('_id')),
'title': title,
'start': start_dt.isoformat() if isinstance(start_dt, datetime.datetime) else str(start_dt),
'end': end_dt.isoformat() if isinstance(end_dt, datetime.datetime) else str(end_dt),
'status': status,
'itemId': item_id,
'userName': str(booking.get('User') or ''),
'notes': str(booking.get('Notes') or ''),
'period': period,
'isCurrentUser': True,
'itemBorrower': '',
})
return jsonify({'ok': True, 'bookings': result})
except Exception as e:
return jsonify({'ok': False, 'error': str(e), 'bookings': []}), 500
finally:
if client:
client.close()
@app.route('/api/booking_conflicts')
def api_booking_conflicts():
"""
@@ -6035,6 +6283,79 @@ def delete_item(id):
return redirect(url_for('home_admin'))
@app.route('/delete_library_item/<id>', methods=['POST'])
def delete_library_item(id):
"""
Route to soft-delete library items (books/media) revisionssicher.
Requires admin and library module enabled.
"""
if 'username' not in session:
flash('Nicht angemeldet.', 'error')
return redirect(url_for('login'))
if not us.check_admin(session['username']):
flash('Administratorrechte erforderlich.', 'error')
return redirect(url_for('login'))
if not cfg.MODULES.is_enabled('library'):
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
return redirect(url_for('home_admin'))
# Verify item exists and is a library item
try:
item_doc = it.get_item(id)
except Exception:
item_doc = None
if not item_doc:
flash('Element nicht gefunden.', 'error')
return redirect(url_for('library_admin'))
if item_doc.get('ItemType') not in LIBRARY_ITEM_TYPES:
flash('Element ist kein Bibliotheksmedium.', 'error')
return redirect(url_for('library_admin'))
group_item_ids = it.get_group_item_ids(id)
if not group_item_ids:
flash('Element nicht gefunden.', 'error')
return redirect(url_for('library_admin'))
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
result = _soft_delete_item_groups(db, [id], session.get('username', ''))
success = bool(result.get('success'))
soft_deleted = int(result.get('soft_deleted_items', 0))
archived_files = int((result.get('archive') or {}).get('archived_files', 0))
try:
_append_audit_event_standalone(
event_type='library_item_deleted',
payload={
'root_item_id': id,
'group_item_ids': result.get('group_item_ids', []),
'soft_deleted_items': soft_deleted,
'archived_files': archived_files,
}
)
except Exception:
app.logger.warning('Audit write failed for library_item_deleted')
except Exception as e:
app.logger.error(f"Error deleting library item {id}: {e}")
success = False
archived_files = 0
finally:
if client:
client.close()
if success:
flash(f'Bibliotheksmedium revisionssicher deaktiviert ({soft_deleted} Versionen, {archived_files} Mediendateien archiviert).', 'success')
else:
flash('Fehler beim Löschen des Bibliotheksmediums.', 'error')
return redirect(url_for('library_admin'))
@app.route('/bulk_delete_items', methods=['POST'])
def bulk_delete_items():
"""Soft-delete multiple selected item groups in one request."""
@@ -7325,6 +7646,10 @@ def terminplan():
if 'username' not in session:
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
return redirect(url_for('login'))
if not cfg.MODULES.is_enabled('terminplan'):
flash('Der Terminplaner ist deaktiviert.', 'info')
return redirect(url_for('home'))
# Make sure the template exists
template_path = os.path.join(BASE_DIR, 'templates', 'terminplan.html')
-8
View File
@@ -18,14 +18,6 @@ Collection Structure:
- Optional fields: Images, Filter, Filter2, Filter3, Anschaffungsjahr, Anschaffungskosten, Code_4
- Status fields: Verfuegbar, User (if currently borrowed)
"""
'''
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
from bson.objectid import ObjectId
import datetime
import Web.modules.database.settings as cfg
+47 -21
View File
@@ -71,6 +71,15 @@ DEFAULTS = {
'logo_path': '',
'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': {
"1": {"start": "08:00", "end": "08:45", "label": "1. Stunde (08:00 - 08:45)"},
@@ -88,6 +97,9 @@ DEFAULTS = {
'inventory': {
'enabled': True
},
'terminplan': {
'enabled': True
},
'library': {
'enabled': False
},
@@ -157,32 +169,18 @@ def _get_int_env(name, default):
def get_version():
# Prefer an explicit release marker if present (created by release process).
project_root = os.path.abspath(os.path.join(BASE_DIR, '..', '..', '..'))
release_file = os.path.join(project_root, '.release-version')
release_file = os.path.join(project_root, '.docker-build.env')
try:
if os.path.isfile(release_file):
with open(release_file, 'r', encoding='utf-8') as f:
val = f.read().strip()
if val:
return val
var = f.readline()
for i in var:
i = i.split(":")
i.pop[0]
return str(i)
except Exception:
pass
# Fallback to .docker-build.env (legacy behaviour)
env_path = os.path.join(project_root, '.docker-build.env')
try:
with open(env_path, 'r', encoding='utf-8') as f:
for l in f:
if l.startswith('INVENTAR_APP_IMAGE='):
return l.split(':', 1)[1].strip()
except Exception:
pass
# Final fallback: use config.json value or packaged default
try:
return _get(_conf, ['ver'], DEFAULTS.get('version', '0.0.0'))
except Exception:
return DEFAULTS.get('version', '0.0.0')
# Expose settings
APP_VERSION = get_version()
DEBUG = _get_bool_env('INVENTAR_DEBUG', _get(_conf, ['dbg'], DEFAULTS['debug']))
@@ -221,10 +219,30 @@ SSL_ENABLED = _get(_conf, ['ssl', 'enabled'], DEFAULTS['ssl']['enabled'])
SSL_CERT = _get(_conf, ['ssl', 'cert'], DEFAULTS['ssl']['cert'])
SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
# Email settings
EMAIL_ENABLED = _get(_conf, ['email', 'enabled'], False)
EMAIL_SMTP_HOST = _get(_conf, ['email', 'smtp_host'], 'smtp.gmail.com')
EMAIL_SMTP_PORT = int(_get(_conf, ['email', 'smtp_port'], 587))
EMAIL_USE_TLS = bool(_get(_conf, ['email', 'use_tls'], True))
EMAIL_USERNAME = _get(_conf, ['email', 'username'], '')
EMAIL_PASSWORD = _get(_conf, ['email', 'password'], '')
EMAIL_FROM_ADDRESS = _get(_conf, ['email', 'from_address'], EMAIL_USERNAME)
EMAIL_DEFAULT_SENDER_NAME = _get(_conf, ['email', 'default_sender_name'], 'Inventarsystem')
EMAIL_TIMEOUT_SECONDS = int(_get(_conf, ['email', 'timeout_seconds'], 30))
# School periods
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
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
TENANT_CONFIGS = _get(_conf, ['tenants'], {})
@@ -257,13 +275,20 @@ class _TenantAwareBool:
from Web.modules.module_registry import registry as MODULES
INVENTORY_MODULE_ENABLED = _TenantAwareBool('inventory', _get(_conf, ['modules', 'inventory', 'enabled'], DEFAULTS['modules']['inventory']['enabled']))
TERMINPLAN_MODULE_ENABLED = _TenantAwareBool('terminplan', _get(_conf, ['modules', 'terminplan', 'enabled'], DEFAULTS['modules']['terminplan']['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'))
return path.startswith(('/scanner', '/inventory', '/upload_admin', '/manage_filters', '/manage_locations', '/admin_borrowings', '/admin_damaged_items', '/admin/borrowings', '/admin/damaged_items'))
def _match_terminplan(path):
if not path:
return False
return path.startswith(('/terminplan', '/terminplaner'))
def _match_library(path):
if not path: return False
@@ -275,6 +300,7 @@ def _match_student_cards(path):
# Register core modules into the pipeline
MODULES.register('inventory', INVENTORY_MODULE_ENABLED, _match_inventory)
MODULES.register('terminplan', TERMINPLAN_MODULE_ENABLED, _match_terminplan)
MODULES.register('library', LIBRARY_MODULE_ENABLED, _match_library)
MODULES.register('student_cards', STUDENT_CARDS_MODULE_ENABLED, _match_student_cards)
+234
View File
@@ -0,0 +1,234 @@
"""
Apointment Managment
=========================
This module manages appointments in the database. It provides comprehensive
functionality for creating, updating, retrieving appointments items.
Key Features:
- Creating and updating appointments
- Retrieving items by ID
- Managing time slots
- client retrival
Collection Structure:
- appointments:
- Required fields: user, start_date, end_date, daytime, slots, slot_time
- Optional fields: Images, Filter, Filter2, Filter3, Anschaffungsjahr, Anschaffungskosten, Code_4
- Status fields: slots_used_by
"""
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
from bson.objectid import ObjectId
import datetime
def _get_tenant_db(client):
try:
from tenant import get_tenant_db
return get_tenant_db(client)
except Exception:
return client[cfg.MONGODB_DB]
def _active_record_query(extra_query=None):
"""Build a query that excludes logically deleted records."""
base_query = {'Deleted': {'$ne': True}}
if extra_query:
base_query.update(extra_query)
return base_query
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False):
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
items = db['appointments']
item = {
'date_start': date_start,
'date_end': date_end,
'time_span': time_span,
'slots': slots,
'slot_lenght': slot_lenght,
'user': user,
'mail': mail,
'note': note,
'calendar_enabled': bool(calendar_enabled),
'slots_booked': [], # -> [(start_time, name), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
'Created': datetime.datetime.now(),
'LastUpdated': datetime.datetime.now()
}
result = items.insert_one(item)
return result.inserted_id
except Exception as e:
print(f"Exception accured: {e}")
def get_item(id):
"""
Retrieve a specific appointment by its ID.
Args:
id (str): ID of the appointsment to retrieve
Returns:
dict: The appointment document or None if not found
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
items = db['appointments']
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
client.close()
return item
except Exception as e:
print(f"Error retrieving item: {e}")
return None
def update(id,slots_used: list):
"""
Update an existing appointment.
Args:
id (str): ID of the item to update
Returns:
bool: True if successful, False otherwise
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
items = db['appointments']
update_data = {
'slots_booked': slots_used,
'LastUpdated': datetime.datetime.now()
}
result = items.update_one(
{'_id': ObjectId(id)},
{'$set': update_data}
)
client.close()
return result.modified_count > 0
except Exception as e:
print(f"Error updating item: {e}")
return False
def remove_slot(id, date_start_time, name):
"""
Remove a booked slot from an appointment's `slots_booked`.
Args:
id (str): Appointment ID
date_start_time: The start time value used when booking
name (str): Name associated with the booking
Returns:
bool: True if a slot was removed, False otherwise
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
items = db['appointments']
# Attempt to pull the exact element (stored as an array/tuple)
result = items.update_one(
{'_id': ObjectId(id)},
{'$pull': {'slots_booked': [date_start_time, name]}}
)
client.close()
return result.modified_count > 0
except Exception as e:
print(f"Error removing slot: {e}")
return False
def remove(id):
"""
Soft-delete an appointment by setting its `Deleted` flag.
Args:
id (str): Appointment ID
Returns:
bool: True if the appointment was marked deleted, False otherwise
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
items = db['appointments']
result = items.delete_one({'_id': ObjectId(id)})
client.close()
return result.deleted_count > 0
except Exception as e:
print(f"Error removing appointment: {e}")
return False
def remove_done():
"""removose already finisched appointments"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
items = db['appointments']
today = datetime.date.today().strftime('%Y-%m-%d')
removed_count = 0
cursor = items.find(
_active_record_query(
{
'date_end': {'$lt': today},
}
)
).sort('date_start', 1)
for item in cursor:
item['_id'] = str(item.get('_id'))
result = items.delete_one({'_id': ObjectId(item['_id'])})
removed_count += result.deleted_count
client.close()
return removed_count > 0
except Exception as e:
print(f"Error removing appointment: {e}")
return False
def get_upcoming_for_user(user: str, limit: int = 25):
"""Return upcoming appointment plans for a user ordered by start date."""
remove_done()
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
items = db['appointments']
today = datetime.date.today().strftime('%Y-%m-%d')
cursor = items.find(
_active_record_query(
{
'user': str(user or '').strip(),
'date_end': {'$gte': today},
}
)
).sort('date_start', 1)
results = []
for item in cursor:
item['_id'] = str(item.get('_id'))
results.append(item)
if len(results) >= max(1, int(limit)):
break
client.close()
return results
except Exception as e:
print(f"Error retrieving upcoming appointments: {e}")
return []
+47
View File
@@ -0,0 +1,47 @@
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import smtplib
import Web.modules.database.settings as cfg
def _build_smtp_client():
smtp = smtplib.SMTP(cfg.EMAIL_SMTP_HOST, cfg.EMAIL_SMTP_PORT, timeout=cfg.EMAIL_TIMEOUT_SECONDS)
smtp.ehlo()
if cfg.EMAIL_USE_TLS:
smtp.starttls()
smtp.ehlo()
if cfg.EMAIL_USERNAME:
smtp.login(cfg.EMAIL_USERNAME, cfg.EMAIL_PASSWORD or '')
return smtp
def send(email: list, subject: str, note: str, sender: str) -> bool:
"""
Sends the email with the link to the Clients
Input:
- email: Email list of all the addresses to send the link to ["","",""]
- subject: Subject of the email
- note: Note that is send with the Emails
Output:
- bool: true if the sending worked and false if it didnt
"""
msg = MIMEMultipart()
msg['Subject'] = subject
msg['From'] = sender or cfg.EMAIL_FROM_ADDRESS or cfg.EMAIL_USERNAME
msg['To'] = ', '.join(email) if isinstance(email, (list, tuple)) else str(email)
msg.attach(MIMEText(note))
smtp = None
try:
smtp = _build_smtp_client()
smtp.sendmail(from_addr=msg['From'], to_addrs=email, msg=msg.as_string())
return True
except Exception:
return False
finally:
try:
if smtp:
smtp.quit()
except Exception:
pass
+1 -1
View File
@@ -1 +1 @@
print("hello")
# Web.modules package initialization
+389
View File
@@ -0,0 +1,389 @@
"""
Class for all funktions of the executive -> Lehrer
"""
import datetime
from datetime import timedelta
from flask import url_for, has_request_context, request
import Web.modules.emailservice.email as mail_service
import Web.modules.database.termine as termin
import Web.modules.database.settings as cfg
from Web.tenant import get_tenant_context
def _resolve_public_base_url() -> str:
if has_request_context():
try:
return request.url_root.rstrip('/')
except Exception:
pass
tenant_context = get_tenant_context()
subdomain = ''
if tenant_context:
subdomain = getattr(tenant_context, 'subdomain', '') or getattr(tenant_context, 'tenant_id', '') or ''
return f"https://{subdomain}.invario.eu" if subdomain else "https://invario.eu"
def _current_tenant_id() -> str:
tenant_context = get_tenant_context()
if tenant_context and getattr(tenant_context, 'tenant_id', None):
return str(tenant_context.tenant_id)
if has_request_context():
try:
return str(request.args.get('tenant', '') or request.args.get('tenant_id', '') or '').strip()
except Exception:
return ''
return ''
def _normalize_time_span(time_span):
if isinstance(time_span, list):
return [str(entry).strip() for entry in time_span if str(entry).strip()]
if isinstance(time_span, tuple):
return [str(entry).strip() for entry in time_span if str(entry).strip()]
if isinstance(time_span, str):
normalized = []
for line in time_span.replace(';', '\n').replace(',', '\n').splitlines():
value = line.strip()
if value:
normalized.append(value)
return normalized
return []
def _normalize_mail_list(mail):
if isinstance(mail, list):
return [str(entry).strip() for entry in mail if str(entry).strip()]
if isinstance(mail, tuple):
return [str(entry).strip() for entry in mail if str(entry).strip()]
if isinstance(mail, str):
return [entry.strip() for entry in mail.replace(';', ',').split(',') if entry.strip()]
return []
def _escape_ics_text(value):
return str(value or '').replace('\\', '\\\\').replace(';', '\\;').replace(',', '\\,').replace('\r\n', '\n').replace('\n', '\\n').replace('\r', '')
def _format_ics_date(date_value):
if isinstance(date_value, datetime.datetime):
return date_value.strftime('%Y%m%dT%H%M%SZ')
if isinstance(date_value, datetime.date):
return date_value.strftime('%Y%m%d')
return ''
def build_calendar_ics(appointment_id: str) -> str | None:
item = termin.get_item(appointment_id)
if not item:
return None
date_start = item.get('date_start')
date_end = item.get('date_end')
time_span = item.get('time_span', []) or []
creator = item.get('user', 'Terminplaner')
note = item.get('note', '') or ''
tenant_id = _current_tenant_id()
try:
link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True)
except Exception:
host = _resolve_public_base_url()
link = host + "/terminplaner/client/" + str(appointment_id)
if tenant_id:
link += f"?tenant={tenant_id}"
try:
start_date = datetime.datetime.strptime(str(date_start), '%Y-%m-%d').date()
end_date = datetime.datetime.strptime(str(date_end), '%Y-%m-%d').date()
except Exception:
return None
uid = f"terminplaner-{appointment_id}@invario.eu"
created_at = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
summary = f"Terminplan für {creator}"
description_lines = [
f"Buchungslink: {link}",
f"Zeitraum: {date_start} bis {date_end}",
]
if time_span:
description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span))
if note:
description_lines.append('Notiz: ' + str(note))
ics_lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Inventarsystem//Terminplaner//DE',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
f'UID:{uid}',
f'DTSTAMP:{created_at}',
f'SUMMARY:{_escape_ics_text(summary)}',
f'DESCRIPTION:{_escape_ics_text(chr(10).join(description_lines))}',
f'URL:{_escape_ics_text(link)}',
f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}',
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + timedelta(days=1))}',
'END:VEVENT',
'END:VCALENDAR',
'',
]
return '\r\n'.join(ics_lines)
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False) -> dict:
"""
Generates a link for the executive to send to his clients to book a time Slot
Input:
- date_start: start of the time frae area
- date_end: end of the time frame area
- time_span: Time window for the days as a list [(first day Time Frame), (second day Time frame), (third etc.)]
- slots: amount of slots that are available
- slot_lenght: the lenght of a slot in minutes
Output:
- link: The link for the user to send to the clients
"""
normalized_time_span = _normalize_time_span(time_span)
normalized_mail = _normalize_mail_list(mail)
id = termin.add(date_start, date_end, normalized_time_span, slots, slot_lenght, user, normalized_mail, note, calendar_enabled=calendar_enabled)
id_str = str(id)
tenant_id = _current_tenant_id()
try:
link = url_for('terminplaner.client', appointment_id=id_str, tenant=tenant_id or None, _external=True)
except Exception:
host = _resolve_public_base_url()
link = host + "/terminplaner/client/" + id_str
if tenant_id:
link += f"?tenant={tenant_id}"
subject = f"Terminanfrage von {user}"
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
calendar_link = None
if calendar_enabled:
try:
calendar_link = url_for('terminplaner.calendar_export', appointment_id=id_str, tenant=tenant_id or None, _external=True)
except Exception:
host = _resolve_public_base_url()
calendar_link = host + "/terminplaner/calendar/" + id_str + ".ics"
if tenant_id:
calendar_link += f"?tenant={tenant_id}"
email_body = note_link
if calendar_link:
email_body += f"\n\nKalendereintrag: {calendar_link}"
if normalized_mail and cfg.EMAIL_ENABLED:
mail_service.send(normalized_mail, subject, email_body)
return {
'appointment_id': id_str,
'link': link,
'calendar_link': calendar_link,
}
def book_slot(id, date_start_time, name):
"""
Updates slot for the booking per a id
Input:
- id: the id is the id you get from the
- date_start_time: the date of the booking that was selected with date and time
- name: name that the client gave himself
Output:
- bool: if worked or not
"""
try:
# Retrieve the current appointment
item = termin.get_item(id)
if not item:
return False
slots = item.get('slots_booked', []) or []
if not isinstance(slots, list):
slots = []
capacity = int(item.get('slots', 0) or 0)
if capacity and len(slots) >= capacity:
return False
for existing in slots:
if isinstance(existing, (list, tuple)) and len(existing) >= 2:
if existing[0] == date_start_time and existing[1] == name:
return False
# Append the new booking as a tuple (start_time, name)
slots.append((date_start_time, name))
# Update the appointment in the database
success = termin.update(id, slots)
return bool(success)
except Exception as e:
print(f"Error booking slot: {e}")
return False
def remove_slot(id, date_start_time, name):
"""
Remove a booked slot for an appointment.
Returns True if the removal succeeded, False otherwise.
"""
try:
# Prefer DB-level remove if available
if hasattr(termin, 'remove_slot'):
removed = termin.remove_slot(id, date_start_time, name)
if removed:
return True
# Fallback: fetch, filter, and replace the slot list
item = termin.get_item(id)
if not item:
return False
slots = item.get('slots_booked', []) or []
new_slots = []
for s in slots:
try:
# s may be list or tuple like [start_time, name]
if isinstance(s, (list, tuple)) and len(s) >= 2 and s[0] == date_start_time and s[1] == name:
continue
except Exception:
pass
new_slots.append(s)
success = termin.update(id, new_slots)
return bool(success)
except Exception as e:
print(f"Error removing slot: {e}")
return False
def remove_appointment(id):
"""
Remove an entire appointment by id.
"""
try:
return bool(termin.remove(id))
except Exception as e:
print(f"Error removing appointment: {e}")
return False
def get_available(id):
"""
Gets the available time slots -> more over it returns the time frame and the allready booked slots also the lenght.
And checks if there are slots left.
Input:
- id: id of the appointment
Output:
- dict: all the needet information -> [Start_date, End_date, (first day Time Frame,
second day Time frame, third etc.), slot lenght, (bookedslots -> list)]
"""
try:
termin_range = termin.get_item(id)
if not termin_range:
return {}
date_start = termin_range.get('date_start')
date_end = termin_range.get('date_end')
time_span = termin_range.get('time_span', [])
slot_lenght = termin_range.get('slot_lenght')
total_slots = termin_range.get('slots', 0)
booked = termin_range.get('slots_booked', []) or []
# Normalize booked entries to dicts for easier consumption
normalized = []
for s in booked:
if isinstance(s, (list, tuple)) and len(s) >= 2:
normalized.append({'start': s[0], 'name': s[1]})
elif isinstance(s, dict):
normalized.append(s)
else:
normalized.append({'value': s})
slots_used = len(normalized)
slots_left = max(0, total_slots - slots_used)
return {
'date_start': date_start,
'date_end': date_end,
'time_span': time_span,
'slot_lenght': slot_lenght,
'slots_total': total_slots,
'slots_booked': normalized,
'slots_left': slots_left,
}
except Exception as e:
print(f"Error getting available slots: {e}")
return {}
def get_available_user(id):
"""
Gets the available time slots -> more over it returns the time frame and the allready booked slots also the lenght.
And checks if there are slots left.
Input:
- id: id of the appointment
Output:
- dict: all the needet information -> [Start_date, End_date, (first day Time Frame,
second day Time frame, third etc.), slot lenght, (bookedslots -> list)]
"""
return get_available(id)
def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
"""Return upcoming appointment plans for overview display."""
user_name = str(user or '').strip()
if not user_name:
return []
appointments = termin.get_upcoming_for_user(user_name, limit=limit)
host = _resolve_public_base_url()
tenant_id = _current_tenant_id()
result = []
for item in appointments:
appointment_id = str(item.get('_id') or '')
if not appointment_id:
continue
try:
link = url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id or None, _external=True)
except Exception:
link = host + '/terminplaner/client/' + appointment_id
if tenant_id:
link += f'?tenant={tenant_id}'
try:
calendar_link = url_for('terminplaner.calendar_export', appointment_id=appointment_id, tenant=tenant_id or None, _external=True)
except Exception:
calendar_link = host + '/terminplaner/calendar/' + appointment_id + '.ics'
if tenant_id:
calendar_link += f'?tenant={tenant_id}'
slots_total = int(item.get('slots', 0) or 0)
slots_booked = item.get('slots_booked', []) or []
if not isinstance(slots_booked, list):
slots_booked = []
result.append(
{
'appointment_id': appointment_id,
'date_start': str(item.get('date_start') or ''),
'date_end': str(item.get('date_end') or ''),
'time_span': item.get('time_span', []) or [],
'slots_total': slots_total,
'slots_booked': len(slots_booked),
'slots_left': max(0, slots_total - len(slots_booked)),
'note': str(item.get('note') or ''),
'link': link,
'calendar_link': calendar_link if item.get('calendar_enabled') else None,
}
)
return result
+187
View File
@@ -0,0 +1,187 @@
from flask import Blueprint, render_template, request, session, url_for, redirect, flash
from flask import Response
import Web.modules.terminplaner.backend_server as appointment_service
import Web.modules.database.settings as cfg
import Web.modules.database.termine as termin
import Web.modules.database.user as us
# Create a blueprint instance
appoint_bp = Blueprint('terminplaner', __name__)
def _require_module_enabled():
if not cfg.MODULES.is_enabled('terminplan'):
flash('Der Terminplaner ist deaktiviert.', 'info')
return redirect(url_for('home'))
return None
def _appointment_not_found_response():
return render_template(
'terminplaner_not_found.html',
error_code=404,
error_message='Der Termin wurde nicht gefunden.',
), 404
def _current_tenant_id():
try:
from Web.tenant import get_tenant_context
ctx = get_tenant_context()
if ctx and getattr(ctx, 'tenant_id', None):
return str(ctx.tenant_id)
except Exception:
pass
return str(session.get('tenant_id', '') or '').strip()
@appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
def client(appointment_id):
"""
The Route for the terminplaner to work with the client
"""
guard = _require_module_enabled()
if guard:
return guard
available = appointment_service.get_available(appointment_id)
if not available:
return _appointment_not_found_response()
if request.method == 'POST':
start_daytime = request.form.get('start_day_time')
username = request.form.get('client_name')
if not start_daytime or not username:
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
return render_template(
'termin_client.html',
appointment_id=appointment_id,
available=available,
current_user=session.get('username', ''),
)
if appointment_service.book_slot(appointment_id, start_daytime, username):
flash('Der Termin wurde gespeichert.', 'success')
return redirect(url_for('terminplaner.client', appointment_id=appointment_id))
flash('Der Termin konnte nicht gespeichert werden.', 'error')
return render_template(
'termin_client.html',
appointment_id=appointment_id,
available=available,
current_user=session.get('username', ''),
tenant_id=_current_tenant_id(),
)
@appoint_bp.route('/delete/<appointment_id>', methods=['POST'])
def delete_appointment(appointment_id):
guard = _require_module_enabled()
if guard:
return guard
if 'username' not in session:
flash('Bitte mit einem Konto anmelden.', 'error')
return redirect(url_for('login'))
appointment = termin.get_item(appointment_id)
if not appointment:
return _appointment_not_found_response()
current_user = str(session.get('username', '')).strip()
appointment_user = str(appointment.get('user', '')).strip()
if not us.check_admin(current_user) and appointment_user != current_user:
flash('Sie dürfen diesen Termin nicht löschen.', 'error')
return redirect(url_for('terminplaner.main', tenant=_current_tenant_id() or None))
if termin.remove(appointment_id):
flash('Der Terminplan wurde gelöscht.', 'success')
else:
flash('Der Terminplan konnte nicht gelöscht werden.', 'error')
return redirect(url_for('terminplaner.main', tenant=_current_tenant_id() or None))
@appoint_bp.route('/configure', methods=['GET', 'POST'])
def configure():
"""
Route for authenticated persons to configure a new appointment for them
"""
guard = _require_module_enabled()
if guard:
return guard
if 'username' not in session:
flash('Bitte mit einem Konto anmelden.', 'error')
return redirect(url_for('login'))
if request.method == "POST":
start = request.form.get('start_date')
end = request.form.get('end_date')
time = request.form.get('time_frame')
slots_amount = request.form.get('slots_amounts')
slot_lenght = request.form.get('slot_lenght')
mail = request.form.get('mail', '')
note = request.form.get('note', '')
add_to_calendar = request.form.get('add_to_calendar') == 'on'
if not start or not end or not time or not slots_amount or not slot_lenght:
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
return render_template(
'termin_configure.html',
school_periods=cfg.SCHOOL_PERIODS,
generated_link=None,
email_service_enabled=cfg.EMAIL_ENABLED,
)
result = appointment_service.new(start, end, time, slots_amount, slot_lenght, session["username"], mail, note, calendar_enabled=add_to_calendar)
flash('Der Terminplan wurde angelegt.', 'success')
return render_template(
'termin_configure.html',
school_periods=cfg.SCHOOL_PERIODS,
generated_link=result['link'],
calendar_link=result.get('calendar_link'),
add_to_calendar=add_to_calendar,
email_service_enabled=cfg.EMAIL_ENABLED,
)
elif request.method == "GET":
return render_template(
'termin_configure.html',
school_periods=cfg.SCHOOL_PERIODS,
generated_link=None,
calendar_link=None,
add_to_calendar=False,
email_service_enabled=cfg.EMAIL_ENABLED,
)
@appoint_bp.route('/calendar/<appointment_id>.ics', methods=['GET'])
def calendar_export(appointment_id):
guard = _require_module_enabled()
if guard:
return guard
ics_content = appointment_service.build_calendar_ics(appointment_id)
if not ics_content:
return _appointment_not_found_response()
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{appointment_id}.ics'
return response
@appoint_bp.route('/')
def main():
guard = _require_module_enabled()
if guard:
return guard
current_user = session.get('username', '')
upcoming_events = appointment_service.get_user_upcoming_events(current_user) if current_user else []
tenant_id = _current_tenant_id()
return render_template(
'terminplaner.html',
school_periods=cfg.SCHOOL_PERIODS,
current_user=current_user,
upcoming_events=upcoming_events,
tenant_id=tenant_id,
)
-1
View File
@@ -1,5 +1,4 @@
flask
bson
werkzeug
gunicorn
pymongo
+71 -13
View File
@@ -129,6 +129,14 @@
--module-brand-icon: "📚";
}
html[data-module="terminplan"] {
--module-primary-color: #0f4c5c;
--module-primary-light: #16697a;
--module-accent-color: #2ec4b6;
--module-accent-light: rgba(46, 196, 182, 0.1);
--module-brand-icon: "📅";
}
:root[data-theme="dark"] {
--module-primary-color: #1a252f;
--module-primary-light: #2c3e50;
@@ -145,6 +153,12 @@
--module-accent-color: #8e44ad;
}
html[data-theme="dark"][data-module="terminplan"] {
--module-primary-color: #082f35;
--module-primary-light: #0f4c5c;
--module-accent-color: #21b6a8;
}
/* Global styles */
body {
font-family: "Manrope", "Segoe UI", "Noto Sans", sans-serif;
@@ -1078,29 +1092,73 @@
<span class="module-label">Module:</span>
<div class="module-tabs">
{% if inventory_module_enabled %}
<a href="{{ url_for('home') }}"
class="module-tab {% if CURRENT_MODULE == 'inventory' %}active{% endif %}"
id="inventoryModule"
data-module="inventory">
<a href="{{ url_for('home') }}" class="module-tab {% if CURRENT_MODULE == 'inventory' %}active{% endif %}" id="inventoryModule" data-module="inventory">
📦 Inventarsystem
</a>
{% endif %}
{% if inventory_module_enabled and library_module_enabled %}
{% if inventory_module_enabled and terminplan_module_enabled %}
<div class="module-separator"></div>
{% endif %}
{% if terminplan_module_enabled %}
<a href="{{ url_for('terminplaner.main') }}" class="module-tab {% if CURRENT_MODULE == 'terminplan' %}active{% endif %}" id="terminplanModule" data-module="terminplan">
📅 Terminplaner
</a>
{% endif %}
{% if (inventory_module_enabled or terminplan_module_enabled) and library_module_enabled %}
<div class="module-separator"></div>
{% endif %}
{% if library_module_enabled %}
<a href="{{ url_for('library_view') }}"
class="module-tab {% if CURRENT_MODULE == 'library' %}active{% endif %}"
id="libraryModule"
data-module="library">
<a href="{{ url_for('library_view') }}" class="module-tab {% if CURRENT_MODULE == 'library' %}active{% endif %}" id="libraryModule" data-module="library">
📚 Bibliothek
</a>
{% endif %}
</div>
</div>
{% endif %}
{% if 'username' in session %}
{% if CURRENT_MODULE != 'library' and inventory_module_enabled %}
{% if terminplan_module_enabled and CURRENT_MODULE == 'terminplan' %}
<nav class="navbar navbar-expand-lg navbar-dark" id="terminplanNavbar">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('terminplaner.main') }}" data-icon="📅">Terminplaner</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#terminplanNavContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="terminplanNavContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="terminplanNavList">
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('terminplaner.main') %}nav-active{% endif %}" href="{{ url_for('terminplaner.main') }}" data-tutorial-tip="Die Terminplaner-Übersicht bündelt die wichtigsten Aktionen.">Übersicht</a>
</li>
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('terminplan') %}nav-active{% endif %}" href="{{ url_for('terminplan') }}" data-tutorial-tip="Hier sehen Sie den Kalender mit den bestehenden Terminen.">Kalender</a>
</li>
{% if current_permissions.pages.get('terminplan', True) and current_permissions.actions.get('can_insert', True) %}
<li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('terminplaner.configure') %}nav-active{% endif %}" href="{{ url_for('terminplaner.configure') }}" data-tutorial-tip="Neuen Terminplan erstellen und an Ihr Team versenden.">Neue Planung</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">
<span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span>
</button>
</li>
<li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="termMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">Mehr Optionen</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="termMoreDropdown">
{% if current_permissions.pages.get('home', True) %}
<li><a class="dropdown-item" href="{{ url_for('home') }}">Inventarsystem</a></li>
{% endif %}
{% if current_permissions.pages.get('library_view', True) and library_module_enabled %}
<li><a class="dropdown-item" href="{{ url_for('library_view') }}">Bibliothek</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% elif CURRENT_MODULE != 'library' and inventory_module_enabled %}
<nav class="navbar navbar-expand-lg navbar-dark" id="inventoryNavbar">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('home') }}" data-icon="📦">Inventarsystem</a>
@@ -1220,7 +1278,7 @@
</div>
</div>
</nav>
{% endif %}
{% endif %}
{% if library_module_enabled and CURRENT_MODULE == 'library' %}
<nav class="navbar navbar-expand-lg navbar-dark" id="libraryNavbar">
+64
View File
@@ -720,6 +720,7 @@
${actionLabel}
</button>
${canEditLibraryItems ? `<button class="button" style="background:#0ea5e9;color:#fff;" onclick="openEditLibraryItem('${item._id}')">Bearbeiten</button>` : ''}
${canEditLibraryItems ? `<button class="button" style="background:#dc2626;color:#fff; margin-left:6px;" onclick="confirmDeleteLibraryItem('${item._id}')">Löschen</button>` : ''}
</td>
</tr>
`;
@@ -739,6 +740,47 @@
}
}
// Confirm and call library item delete endpoint
function confirmDeleteLibraryItem(itemId) {
if (!itemId) return;
if (!confirm('Dieses Bibliotheksmedium revisionssicher löschen? Diese Aktion deaktiviert alle zugehörigen Exemplare und archiviert Medien.')) return;
deleteLibraryItem(itemId);
}
async function deleteLibraryItem(itemId) {
if (!itemId) return;
document.body.style.cursor = 'wait';
try {
const resp = await fetch(`/delete_library_item/${itemId}`, {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
'X-CSRF-Token': '{{ csrf_token }}'
},
credentials: 'same-origin'
});
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const j = await resp.json();
if (!resp.ok || j.ok === false) {
alert(j.message || 'Fehler beim Löschen');
} else {
alert(j.message || 'Gelöscht');
}
loadLibraryItems();
} else {
// Server redirected to HTML (flash messages). Reload to show state.
location.reload();
}
} catch (e) {
console.error('Delete failed', e);
alert('Fehler beim Löschen des Elements.');
} finally {
document.body.style.cursor = '';
}
}
// Filter toggle
document.getElementById('filterToggleBtn').addEventListener('click', () => {
filterPanelOpen = !filterPanelOpen;
@@ -822,11 +864,27 @@
setActiveStudentCard(cardId);
const durationInput = (window.prompt('Ausleihdauer in Tagen (optional):') || '').trim();
const maxAvailable = Math.max(1, parseInt(selectedItem?.AvailableGroupedCount || selectedItem?.Quantity || 1, 10) || 1);
const countPrompt = (window.prompt(`Anzahl ausleihen? (Standard: 1, verfügbar: ${maxAvailable})`, '1') || '').trim();
let borrowCount = parseInt(countPrompt || '1', 10);
if (!Number.isFinite(borrowCount) || borrowCount < 1) {
borrowCount = 1;
}
if (borrowCount > maxAvailable) {
alert(`Es sind nur ${maxAvailable} Exemplar(e) verfügbar.`);
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = `/ausleihen/${itemId}`;
const csrfField = document.createElement('input');
csrfField.type = 'hidden';
csrfField.name = 'csrf_token';
csrfField.value = '{{ csrf_token }}';
form.appendChild(csrfField);
const cardField = document.createElement('input');
cardField.type = 'hidden';
cardField.name = 'borrower_card_id';
@@ -847,6 +905,12 @@
form.appendChild(durationField);
}
const countField = document.createElement('input');
countField.type = 'hidden';
countField.name = 'exemplare_count';
countField.value = String(borrowCount || 1);
form.appendChild(countField);
document.body.appendChild(form);
form.submit();
}
-127
View File
@@ -269,23 +269,6 @@
line-height: 1;
}
/* Compact appointment badge on cards */
.appointment-badge {
margin-top: 10px;
padding: 10px 12px;
background-color: #e1f5fe;
border-left: 4px solid #03a9f4;
border-radius: 4px;
font-size: 0.9rem;
line-height: 1.3;
}
.appointment-badge .appointment-time {
display: inline-block;
margin-top: 4px;
color: #01579b;
font-weight: 600;
}
/* Search styles */
.search-container {
display: flex;
@@ -1006,23 +989,6 @@
</div>`;
}
// Add appointment badge if item has upcoming appointments
let appointmentBadge = '';
if (item.appointments && Array.isArray(item.appointments) && item.appointments.length > 0) {
const upcomingAppointment = getUpcomingAppointment(item.appointments);
if (upcomingAppointment) {
const formattedDate = formatAppointmentDate(upcomingAppointment.date);
const startPeriod = formatAppointmentPeriod(upcomingAppointment.start_period);
const endPeriod = formatAppointmentPeriod(upcomingAppointment.end_period);
appointmentBadge = `
<div class="appointment-badge">
<strong>Geplant für:</strong> ${formattedDate}<br>
<span class="appointment-time">${startPeriod}${endPeriod && endPeriod !== startPeriod ? ' - ' + endPeriod : ''}</span>
</div>`;
}
}
card.innerHTML = `
<div class="card-content" data-item-id="${item._id}">
<h3 class="item-col-name">${item.Name}</h3>
@@ -1037,7 +1003,6 @@
${imagesHtml}
</div>
${borrowerBadge}
${appointmentBadge}
</div>
<div class="actions">
${isAvailableForBorrow && !item.BlockedNow ?
@@ -1614,27 +1579,6 @@
`;
}
// Add appointment info panel if item has appointments
let appointmentInfoHtml = '';
if (item.appointments && Array.isArray(item.appointments) && item.appointments.length > 0) {
const upcomingAppointment = getUpcomingAppointment(item.appointments);
if (upcomingAppointment) {
const formattedDate = formatAppointmentDate(upcomingAppointment.date);
const startPeriod = formatAppointmentPeriod(upcomingAppointment.start_period);
const endPeriod = formatAppointmentPeriod(upcomingAppointment.end_period);
const timeDisplay = startPeriod + (endPeriod && endPeriod !== startPeriod ? ' - ' + endPeriod : '');
appointmentInfoHtml = `
<div class="appointment-info-panel">
<h4>Geplanter Termin</h4>
<p class="appointment-date">Datum: ${formattedDate}</p>
<p class="appointment-time">Zeit: ${timeDisplay}</p>
${upcomingAppointment.notes ? `<p class="appointment-notes">Notizen: ${upcomingAppointment.notes}</p>` : ''}
</div>
`;
}
}
// Build modal content HTML
modalContent.innerHTML = `
<h2>${item.Name}</h2>
@@ -1642,7 +1586,6 @@
<button type="button" class="bookmark-btn" id="modal-bookmark-btn" title="Merken">${(window.currentFavorites||new Set()).has(item._id) ? '★' : '☆'}</button>
</div>
${borrowerInfoHtml}
${appointmentInfoHtml}
<div class="modal-image-container">
${imagesHtml}
@@ -2804,66 +2747,6 @@
}
}
// Helper functions for appointment display
function formatAppointmentDate(dateString) {
if (!dateString) return '';
try {
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
} catch (e) {
return dateString;
}
}
function formatAppointmentPeriod(period) {
if (!period) return '';
const periodMap = {
'1': '1. Stunde',
'2': '2. Stunde',
'3': '3. Stunde',
'4': '4. Stunde',
'5': '5. Stunde',
'6': '6. Stunde',
'7': '7. Stunde',
'8': '8. Stunde',
'9': '9. Stunde',
'10': '10. Stunde',
'pause1': '1. Pause',
'pause2': '2. Pause',
'mittagspause': 'Mittagspause'
};
return periodMap[period] || period;
}
function getUpcomingAppointment(appointments) {
if (!appointments || !Array.isArray(appointments) || appointments.length === 0) {
return null;
}
const now = new Date();
const today = now.toDateString();
// Filter appointments that are today or in the future
const futureAppointments = appointments.filter(apt => {
try {
const aptDate = new Date(apt.date);
return aptDate.toDateString() === today || aptDate >= now.setHours(0, 0, 0, 0);
} catch (e) {
return false;
}
});
if (futureAppointments.length === 0) return null;
// Sort by date and return the earliest
futureAppointments.sort((a, b) => new Date(a.date) - new Date(b.date));
return futureAppointments[0];
}
function formatDateForInput(dateString) {
const options = { year: 'numeric', month: '2-digit', day: '2-digit' };
const date = new Date(dateString);
@@ -3947,16 +3830,6 @@
.borrower-info-panel h4 { color: #856404; margin: 0 0 10px 0; font-size: 1.1rem; }
.borrower-name, .borrow-time { margin: 5px 0; color: #856404; font-weight: 500; }
.appointment-info-panel {
background-color: #e1f5fe;
border: 1px solid #81d4fa;
border-radius: 5px;
padding: 15px;
margin-bottom: 20px;
}
.appointment-info-panel h4 { color: #0277bd; margin: 0 0 10px 0; font-size: 1.1rem; }
.appointment-date, .appointment-time, .appointment-user { margin: 5px 0; color: #0277bd; font-weight: 500; }
/* Modal Details Styling (text formatting) */
.modal-details { margin: 20px 0; }
.detail-group { display: flex; margin-bottom: 10px; align-items: flex-start; }
+28
View File
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block title %}Wartungsarbeiten - Inventarsystem{% endblock %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-8 col-xl-7">
<div class="card border-0 shadow-lg rounded-4 overflow-hidden">
<div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);">
<p class="text-uppercase small fw-semibold mb-1 opacity-75">Systemstatus</p>
<h1 class="h3 mb-0 fw-bold">Wartungsarbeiten</h1>
</div>
<div class="card-body p-4 p-md-5 bg-white text-center">
<div class="display-3 fw-bold text-warning mb-3">500</div>
<p class="lead mb-2">Das System führt gerade Wartungsarbeiten durch.</p>
<p class="text-muted mb-4">Bitte versuchen Sie es in wenigen Minuten erneut. Falls das Problem bestehen bleibt, informieren Sie bitte die Administration.</p>
<div class="d-flex flex-column flex-sm-row justify-content-center gap-2">
<a href="{{ url_for('login') }}" class="btn btn-primary btn-lg">Zum Login</a>
<a href="javascript:history.back()" class="btn btn-outline-secondary btn-lg">Zurück</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+69
View File
@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}Termin buchen - Inventarsystem{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-12 col-xl-10">
<div class="row g-4">
<div class="col-12 col-lg-5">
<div class="card border-0 shadow-lg rounded-4 h-100">
<div class="card-body p-4 p-md-5">
<p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p>
<h1 class="h3 fw-bold mb-3">Termin buchen</h1>
<p class="text-muted mb-4">Wählen Sie einen freien Zeitpunkt und tragen Sie Ihren Namen ein. Der Termin wird anschließend im Plan gespeichert.</p>
<div class="p-3 rounded-3 bg-light mb-3">
<div class="fw-semibold">Zeitraum</div>
<div>{{ available.date_start }} bis {{ available.date_end }}</div>
</div>
<div class="p-3 rounded-3 bg-light mb-3">
<div class="fw-semibold">Verfügbare Slots</div>
<div>{{ available.slots_left }} von {{ available.slots_total }} frei</div>
</div>
<div class="p-3 rounded-3 bg-light mb-3">
<div class="fw-semibold">Slot-Länge</div>
<div>{{ available.slot_lenght }} Minuten</div>
</div>
{% if available.slots_booked %}
<div class="p-3 rounded-3 bg-light">
<div class="fw-semibold mb-2">Bereits gebucht</div>
<ul class="mb-0 small">
{% for booking in available.slots_booked %}
<li>{{ booking.start }}{% if booking.name %} - {{ booking.name }}{% endif %}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-12 col-lg-7">
<div class="card border-0 shadow-lg rounded-4 h-100">
<div class="card-body p-4 p-md-5 bg-white">
<h2 class="h4 fw-bold mb-4">Buchung absenden</h2>
<form method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" class="vstack gap-3">
<div>
<label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label>
<input type="text" id="start_day_time" name="start_day_time" class="form-control form-control-lg" placeholder="2026-05-29 10:30" required>
<div class="form-text">Tragen Sie Datum und Uhrzeit im Format YYYY-MM-DD HH:MM ein, sofern kein Kalenderfeld genutzt wird.</div>
</div>
<div>
<label for="client_name" class="form-label fw-semibold">Ihr Name</label>
<input type="text" id="client_name" name="client_name" class="form-control form-control-lg" placeholder="Vor- und Nachname" required>
</div>
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
<button type="submit" class="btn btn-primary btn-lg">Termin buchen</button>
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main', tenant=tenant_id) }}">Zur Übersicht</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+258
View File
@@ -0,0 +1,258 @@
{% extends "base.html" %}
{% block title %}Terminplan konfigurieren - Inventarsystem{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-8">
<div class="card border-0 shadow-lg rounded-4 overflow-hidden">
<div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);">
<h1 class="h3 mb-1 fw-bold">Terminplan konfigurieren</h1>
<p class="mb-0 opacity-75">Erstellen Sie einen neuen Plan und verschicken Sie anschließend den Buchungslink.</p>
</div>
<div class="card-body p-4 p-md-5 bg-white">
<form method="post" action="{{ url_for('terminplaner.configure') }}" class="vstack gap-3">
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="start_date" class="form-label fw-semibold">Startdatum</label>
<input type="date" id="start_date" name="start_date" class="form-control form-control-lg" required>
</div>
<div class="col-12 col-md-6">
<label for="end_date" class="form-label fw-semibold">Enddatum</label>
<input type="date" id="end_date" name="end_date" class="form-control form-control-lg" required>
</div>
</div>
<div class="border rounded-4 p-3 p-md-4 bg-light-subtle">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-2">
<div>
<label class="form-label fw-semibold mb-0">Zeitfenster pro Tag</label>
<div class="form-text mb-0">Sobald Start- und Enddatum gesetzt sind, wird für jeden Tag automatisch ein eigener Eintrag erzeugt.</div>
</div>
<button type="button" class="btn btn-outline-primary btn-sm" id="build_time_frame">Tage aus Zeitraum erzeugen</button>
</div>
<div class="row g-3 mb-3">
<div class="col-12 col-md-6 col-xl-4">
<label for="default_day_start" class="form-label fw-semibold">Standard-Startzeit</label>
<input type="time" id="default_day_start" class="form-control" value="08:00">
</div>
<div class="col-12 col-md-6 col-xl-4">
<label for="default_day_end" class="form-label fw-semibold">Standard-Endzeit</label>
<input type="time" id="default_day_end" class="form-control" value="12:00">
</div>
</div>
<div id="time_frame_days" class="vstack gap-2"></div>
<div class="mt-3">
<label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster</label>
<textarea id="time_frame" name="time_frame" class="form-control font-monospace" rows="5" placeholder="Wird automatisch aus den Tagen erzeugt" required></textarea>
<div class="form-text">Das Formular überträgt die erzeugten Tageszeilen an das Backend. Sie können die Liste hier bei Bedarf noch anpassen.</div>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="slots_amounts" class="form-label fw-semibold">Anzahl Slots</label>
<input type="number" id="slots_amounts" name="slots_amounts" class="form-control" min="1" value="1" required>
</div>
<div class="col-12 col-md-6">
<label for="slot_lenght" class="form-label fw-semibold">Slot-Länge in Minuten</label>
<input type="number" id="slot_lenght" name="slot_lenght" class="form-control" min="1" value="45" required>
</div>
</div>
<div>
<label for="mail" class="form-label fw-semibold">Empfänger-Mails</label>
<input type="text" id="mail" name="mail" class="form-control" placeholder="adresse1@schule.de, adresse2@schule.de">
</div>
<div>
<label for="note" class="form-label fw-semibold">Notiz</label>
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" {% if add_to_calendar %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="add_to_calendar">Kalendereintrag für den Nutzer erzeugen</label>
<div class="form-text">Erzeugt zusätzlich eine .ics-Datei, die in gängige Kalender importiert werden kann.</div>
</div>
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
<button type="submit" class="btn btn-primary btn-lg">Terminplan erzeugen</button>
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main') }}">Zur Übersicht</a>
</div>
</form>
</div>
</div>
{% if generated_link %}
<div class="alert alert-success mt-4 shadow-sm rounded-4">
<div class="fw-bold mb-1">Buchungslink erstellt</div>
<div class="mb-2">
{% if email_service_enabled %}
Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail.
{% else %}
Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden.
{% endif %}
</div>
<a href="{{ generated_link }}" class="d-inline-block text-break">{{ generated_link }}</a>
{% if calendar_link %}
<div class="mt-3">
<div class="fw-semibold mb-1">Kalendereintrag</div>
<a href="{{ calendar_link }}" class="btn btn-outline-primary btn-sm">.ics herunterladen</a>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
<script>
(function () {
const startDateInput = document.getElementById('start_date');
const endDateInput = document.getElementById('end_date');
const buildButton = document.getElementById('build_time_frame');
const daysContainer = document.getElementById('time_frame_days');
const timeFrameTextarea = document.getElementById('time_frame');
const defaultStartInput = document.getElementById('default_day_start');
const defaultEndInput = document.getElementById('default_day_end');
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea || !defaultStartInput || !defaultEndInput) {
return;
}
const weekdayFormatter = new Intl.DateTimeFormat('de-DE', {
weekday: 'long',
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
function parseDate(value) {
if (!value) {
return null;
}
const parts = value.split('-').map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) {
return null;
}
return new Date(parts[0], parts[1] - 1, parts[2]);
}
function formatDateForRow(date) {
return weekdayFormatter.format(date);
}
function formatDateForValue(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function addDays(date, days) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
function syncTextarea() {
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
const lines = rows.map(function (row) {
const dayValue = row.getAttribute('data-day-value') || '';
const startTime = row.querySelector('[data-time-start]')?.value || '';
const endTime = row.querySelector('[data-time-end]')?.value || '';
return `${dayValue} ${startTime}-${endTime}`.trim();
}).filter(Boolean);
timeFrameTextarea.value = lines.join('\n');
}
function renderRows() {
const startDate = parseDate(startDateInput.value);
const endDate = parseDate(endDateInput.value);
if (!startDate || !endDate) {
daysContainer.innerHTML = '<div class="text-muted small">Bitte Start- und Enddatum auswählen, damit die Tageszeilen erzeugt werden.</div>';
timeFrameTextarea.value = '';
endDateInput.setCustomValidity('');
return;
}
if (endDate < startDate) {
daysContainer.innerHTML = '<div class="text-danger small">Das Enddatum muss nach dem Startdatum liegen.</div>';
timeFrameTextarea.value = '';
endDateInput.setCustomValidity('Das Enddatum muss nach dem Startdatum liegen.');
return;
}
endDateInput.setCustomValidity('');
const existingValues = new Map();
Array.from(daysContainer.querySelectorAll('[data-day-row]')).forEach(function (row) {
const dayValue = row.getAttribute('data-day-value');
const startTime = row.querySelector('[data-time-start]')?.value || '';
const endTime = row.querySelector('[data-time-end]')?.value || '';
if (dayValue) {
existingValues.set(dayValue, { startTime, endTime });
}
});
const defaultStart = defaultStartInput.value || '08:00';
const defaultEnd = defaultEndInput.value || '12:00';
const rows = [];
for (let current = startDate; current <= endDate; current = addDays(current, 1)) {
const dayValue = formatDateForValue(current);
const preserved = existingValues.get(dayValue) || {};
const rowStart = preserved.startTime || defaultStart;
const rowEnd = preserved.endTime || defaultEnd;
rows.push(`
<div class="border rounded-3 bg-white p-3" data-day-row data-day-value="${dayValue}">
<div class="row g-2 align-items-end">
<div class="col-12 col-lg-5">
<label class="form-label fw-semibold mb-1">${formatDateForRow(current)}</label>
<div class="text-muted small">${dayValue}</div>
</div>
<div class="col-6 col-lg-3">
<label class="form-label mb-1">Von</label>
<input type="time" class="form-control" value="${rowStart}" data-time-start>
</div>
<div class="col-6 col-lg-3">
<label class="form-label mb-1">Bis</label>
<input type="time" class="form-control" value="${rowEnd}" data-time-end>
</div>
</div>
</div>
`);
}
daysContainer.innerHTML = rows.join('');
daysContainer.querySelectorAll('input[type="time"]').forEach(function (input) {
input.addEventListener('input', syncTextarea);
input.addEventListener('change', syncTextarea);
});
syncTextarea();
}
buildButton.addEventListener('click', renderRows);
startDateInput.addEventListener('input', renderRows);
startDateInput.addEventListener('change', renderRows);
endDateInput.addEventListener('input', renderRows);
endDateInput.addEventListener('change', renderRows);
defaultStartInput.addEventListener('change', syncTextarea);
defaultEndInput.addEventListener('change', syncTextarea);
if (startDateInput.value && endDateInput.value) {
renderRows();
}
})();
</script>
{% endblock %}
File diff suppressed because it is too large Load Diff
+107
View File
@@ -0,0 +1,107 @@
{% extends "base.html" %}
{% block title %}Terminplaner - Inventarsystem{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-12 col-lg-11 col-xl-10">
<section class="p-4 p-md-5 rounded-4 shadow-lg" style="background: linear-gradient(135deg, rgba(15,76,92,0.96), rgba(22,105,122,0.92)); color: #fff;">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-start align-items-lg-end">
<div>
<p class="text-uppercase fw-semibold mb-2" style="letter-spacing: .12em; opacity: .8;">Terminplaner</p>
<h1 class="display-6 fw-bold mb-3">Reservierungen und Termine übersichtlich verwalten</h1>
<p class="lead mb-0" style="max-width: 60ch; opacity: .95;">Erstellen Sie neue Terminreihen, teilen Sie Buchungslinks und öffnen Sie den Kalender für bestehende Reservierungen.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-light btn-lg fw-semibold" href="{{ url_for('terminplaner.configure', tenant=tenant_id) }}">Neue Planung</a>
<a class="btn btn-outline-light btn-lg fw-semibold" href="{{ url_for('terminplan', tenant=tenant_id) }}">Kalender öffnen</a>
</div>
</div>
</section>
<div class="row g-4 mt-1">
<div class="col-12 col-md-4">
<div class="card h-100 shadow-sm border-0 rounded-4">
<div class="card-body p-4">
<div class="display-6 mb-3">🗓️</div>
<h2 class="h4 fw-bold">Kalender</h2>
<p class="mb-4 text-muted">Sehen Sie vorhandene Termine, ihre Auslastung und die aktuellen Reservierungen im Kalender.</p>
<a class="btn btn-primary w-100" href="{{ url_for('terminplan', tenant=tenant_id) }}">Zum Kalender</a>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card h-100 shadow-sm border-0 rounded-4">
<div class="card-body p-4">
<div class="display-6 mb-3">✍️</div>
<h2 class="h4 fw-bold">Neue Planung</h2>
<p class="mb-4 text-muted">Erstellen Sie einen neuen Terminplan und verschicken Sie den Buchungslink an Ihre Zielgruppe.</p>
<a class="btn btn-outline-primary w-100" href="{{ url_for('terminplaner.configure', tenant=tenant_id) }}">Konfiguration öffnen</a>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card h-100 shadow-sm border-0 rounded-4">
<div class="card-body p-4">
<div class="display-6 mb-3">🔗</div>
<h2 class="h4 fw-bold">Buchungslink</h2>
<p class="mb-4 text-muted">Die öffentliche Buchungsseite liegt unter dem jeweiligen Terminlink und kann direkt an Teilnehmende weitergegeben werden.</p>
<div class="small text-muted">Die Buchungsseite wird aus dem Terminplan automatisch erzeugt.</div>
</div>
</div>
</div>
</div>
{% if current_user %}
<div class="mt-4 p-4 rounded-4 bg-white shadow-sm">
<h2 class="h5 fw-bold mb-2">Angemeldet als {{ current_user }}</h2>
<p class="mb-0 text-muted">Sie können Termine anlegen, den Kalender prüfen und Buchungslinks verteilen.</p>
</div>
<div class="mt-4 p-4 rounded-4 bg-white shadow-sm">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-3">
<h2 class="h5 fw-bold mb-0">Kommende Termine</h2>
<span class="text-muted small">Alle offenen Terminpläne dieses Nutzers</span>
</div>
{% if upcoming_events %}
<div class="vstack gap-3">
{% for event in upcoming_events %}
<div class="border rounded-3 p-3">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
<div>
<div class="fw-semibold">{{ event.date_start }} bis {{ event.date_end }}</div>
<div class="text-muted small">ID: {{ event.appointment_id }}</div>
{% if event.time_span %}
<div class="small mt-1">Zeitfenster: {{ event.time_span|join(' | ') }}</div>
{% endif %}
{% if event.note %}
<div class="small mt-1 text-muted">Notiz: {{ event.note }}</div>
{% endif %}
</div>
<div class="text-lg-end">
<div class="small mb-2">Gebucht: <strong>{{ event.slots_booked }}</strong> / {{ event.slots_total }} | Frei: <strong>{{ event.slots_left }}</strong></div>
<div class="d-flex flex-wrap gap-2 justify-content-lg-end">
<a class="btn btn-sm btn-primary" href="{{ event.link }}" target="_blank" rel="noopener">Client-Link öffnen</a>
{% if event.calendar_link %}
<a class="btn btn-sm btn-outline-primary" href="{{ event.calendar_link }}">.ics</a>
{% endif %}
<form method="post" action="{{ url_for('terminplaner.delete_appointment', appointment_id=event.appointment_id, tenant=tenant_id) }}" class="d-inline" onsubmit="return confirm('Diesen Terminplan wirklich löschen?');">
<button type="submit" class="btn btn-sm btn-outline-danger">Entfernen</button>
</form>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-muted">Keine kommenden Termine gefunden.</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block title %}Termin nicht gefunden - Inventarsystem{% endblock %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-8 col-xl-7">
<div class="card border-0 shadow-lg rounded-4 overflow-hidden">
<div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);">
<p class="text-uppercase small fw-semibold mb-1 opacity-75">Terminplaner</p>
<h1 class="h3 mb-0 fw-bold">Termin nicht gefunden</h1>
</div>
<div class="card-body p-4 p-md-5 bg-white text-center">
<div class="display-3 fw-bold text-danger mb-3">404</div>
<p class="lead mb-2">Der angeforderte Termin existiert nicht mehr oder der Link ist ungültig.</p>
<p class="text-muted mb-4">Bitte prüfen Sie den Link erneut oder öffnen Sie den Terminplaner von der Startseite aus.</p>
<div class="d-flex flex-column flex-sm-row justify-content-center gap-2">
<a href="{{ url_for('home') }}" class="btn btn-primary btn-lg">Zur Startseite</a>
<a href="javascript:history.back()" class="btn btn-outline-secondary btn-lg">Zurück</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+19
View File
@@ -212,6 +212,7 @@ def _module_name_candidates(module_name):
candidates = [normalized]
alias_groups = {
'inventory': {'inventory', 'inventar'},
'terminplan': {'terminplan', 'terminplaner', 'termin', 'appointments'},
'library': {'library', 'bib', 'bibliothek'},
'student_cards': {'student_cards', 'studentcards', 'schuelerausweise', 'schueler_ausweise'},
}
@@ -472,6 +473,20 @@ class TenantContext:
if not has_request_context():
return None
# Query parameters are useful for public links that must open a specific tenant
# even when the host/subdomain cannot be mapped reliably.
tenant_from_query = (
request.args.get('tenant', '').strip()
or request.args.get('tenant_id', '').strip()
or request.args.get('tenantId', '').strip()
)
if tenant_from_query:
matched_tenant = _find_registered_tenant_id(tenant_from_query) or tenant_from_query
self.tenant_id = matched_tenant
self.config = get_tenant_config(matched_tenant)
session['tenant_id'] = matched_tenant
return self._get_db_name(matched_tenant)
# Priority 1: X-Tenant-ID header (for testing/internal APIs)
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
if tenant_from_header:
@@ -530,6 +545,10 @@ class TenantContext:
potential_subdomain = parts[0]
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
matched_tenant = _find_registered_tenant_id(potential_subdomain)
if not matched_tenant and potential_subdomain.startswith('school'):
matched_tenant = _find_registered_tenant_id('schule' + potential_subdomain[len('school'):])
elif not matched_tenant and potential_subdomain.startswith('schule'):
matched_tenant = _find_registered_tenant_id('school' + potential_subdomain[len('schule'):])
if matched_tenant:
self.subdomain = potential_subdomain
self.tenant_id = matched_tenant
+14
View File
@@ -19,6 +19,17 @@
"cert": "Web/certs/cert.pem",
"key": "Web/certs/key.pem"
},
"email": {
"enabled": false,
"smtp_host": "smtp.gmail.com",
"smtp_port": 587,
"use_tls": true,
"username": "",
"password": "",
"from_address": "",
"default_sender_name": "Invario Inventurprogramm",
"timeout_seconds": 30
},
"images": {
"thumbnail_size": [
150,
@@ -42,6 +53,9 @@
"inventory": {
"enabled": true
},
"terminplan": {
"enabled": true
},
"library": {
"enabled": true
},
+1 -1
View File
@@ -66,7 +66,7 @@ services:
PYTHON_VERSION: "3.13"
OPTIMIZATION_LEVEL: 2
working_dir: /app/Web
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
restart: unless-stopped
security_opt:
- no-new-privileges:true
+154 -121
View File
@@ -23,59 +23,70 @@ ensure_runtime_config_json() {
echo "Warning: moved unexpected directory $config_path to $backup_path"
fi
if [ ! -f "$config_path" ]; then
FORCE_REMOVE=false
if [ "${2:-}" = "--yes" ] || [ "${2:-}" = "-y" ]; then
FORCE_REMOVE=true
TENANT_ID="${3:-}"
fi
if [ -z "$TENANT_ID" ]; 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 ""
echo "Commands:"
echo " add <tenant_id> [port] Add a new tenant (initializes database)"
echo " trial <tenant_id> [port] [days] Create a 7-day demo tenant that auto-deletes after expiry"
echo " remove <tenant_id> Remove a tenant completely (deletes data!)"
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
echo " restart-all Restart all application containers (zero-downtime reload)"
echo " list List active tenants"
echo " module <tenant_id> <module>=<on|off>... Enable/disable modules for a tenant"
echo " -h, --help Show this help message"
echo ""
echo "Examples:"
echo " ./manage-tenant.sh add school_a 10001"
echo " ./manage-tenant.sh trial school_demo 10002 7"
echo " ./manage-tenant.sh remove test_tenant"
echo " ./manage-tenant.sh module school_a inventory=off library=on"
echo " ./manage-tenant.sh restart-all"
echo " ./manage-tenant.sh -h"
exit 1
}
if [ "$FORCE_REMOVE" != true ]; then
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
read confirm
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
echo "Removal canceled."
exit 0
fi
fi
tenant_aliases() {
local tenant_id="$1"
echo "Removing tenant '$TENANT_ID'..."
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
port_to_remove=""
if [ -n "$APP_CONTAINER" ]; then
port_to_remove="$({ docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" <<'PY'
import sys
sys.path.insert(0, '/app')
sys.path.insert(0, '/app/Web')
from tenant import delete_tenant, get_tenant_config
tenant_id = sys.argv[1]
tenant_cfg = get_tenant_config(tenant_id)
port = tenant_cfg.get('port')
if not delete_tenant(tenant_id):
print(f'Error: failed to delete tenant {tenant_id}', file=sys.stderr)
sys.exit(1)
if port is not None:
print(port)
PY
} 2>/dev/null)"
echo "Tenant '$TENANT_ID' database and config removed."
else
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
:
else
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
fi
fi
if [ -n "$port_to_remove" ]; then
remove_runtime_port "$port_to_remove"
fi
sync_tenant_port_map
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
if [ -n "$port_to_remove" ]; then
echo "Removed tenant '$TENANT_ID' and cleaned runtime port $port_to_remove."
else
echo "Removed tenant '$TENANT_ID'. No port mapping was present."
fi
local normalized alias
normalized="$(printf '%s' "$tenant_id" | tr '[:upper:]' '[:lower:]')"
printf '%s\n' "$tenant_id"
@@ -201,6 +212,7 @@ existing['modules'] = {
'inventory': {'enabled': True},
'library': {'enabled': True},
'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365},
'terminplan': {'enabled': True},
}
existing['trial'] = trial_config
@@ -312,58 +324,60 @@ print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
update_runtime_ports() {
local new_port="$1"
local env_file="$PWD/.docker-build.env"
local current_ports=""
local port_list=()
local unique_ports=()
local first_port=""
if [ -n "$new_port" ]; then
if [ -f "$env_file" ]; then
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
if [ -z "$current_ports" ]; then
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
fi
local current_ports port_list unique_ports first_port ports_csv
if [ -z "$new_port" ]; then
return 0
fi
current_ports=""
if [ -f "$env_file" ]; then
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
if [ -z "$current_ports" ]; then
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
fi
fi
if [ -n "$current_ports" ]; then
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
port_list=()
unique_ports=()
if [ -n "$current_ports" ]; then
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
fi
port_list+=("$new_port")
for port in "${port_list[@]}"; do
if [ -n "$port" ] && ! printf '%s\n' "${unique_ports[@]}" | grep -qx "$port"; then
unique_ports+=("$port")
fi
port_list+=("$new_port")
done
for port in "${port_list[@]}"; do
if [ -n "$port" ] && ! printf '%s\n' "${unique_ports[@]}" | grep -qx "$port"; then
unique_ports+=("$port")
fi
done
if [ ${#unique_ports[@]} -eq 0 ]; then
unique_ports=("$new_port")
fi
if [ ${#unique_ports[@]} -eq 0 ]; then
unique_ports=("$new_port")
fi
first_port="${unique_ports[0]}"
ports_csv="$(IFS=,; echo "${unique_ports[*]}")"
first_port="${unique_ports[0]}"
local ports_csv
ports_csv="$(IFS=,; echo "${unique_ports[*]}")"
if [ ! -f "$env_file" ]; then
cat > "$env_file" <<EOF
if [ ! -f "$env_file" ]; then
cat > "$env_file" <<EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=$first_port
INVENTAR_HTTP_PORTS=$ports_csv
EOF
else
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
else
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
else
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
fi
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
else
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
fi
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
fi
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
else
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
fi
echo "Updated runtime env ports: $ports_csv"
fi
echo "Updated runtime env ports: $ports_csv"
}
sync_tenant_port_map() {
@@ -675,50 +689,69 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
;;
remove)
FORCE_REMOVE=false
if [ "${2:-}" = "--yes" ] || [ "${2:-}" = "-y" ]; then
FORCE_REMOVE=true
TENANT_ID="${3:-}"
fi
if [ -z "$TENANT_ID" ]; then
echo "Error: Please provide a tenant_id to remove."
exit 1
fi
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
read confirm
if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then
echo "Removing tenant '$TENANT_ID'..."
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -n "$APP_CONTAINER" ]; then
docker exec $APP_CONTAINER python3 -c "
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from tenant import TenantContext; from Web.modules.database import settings; from pymongo import MongoClient
ctx = TenantContext()
db_name = ctx._get_db_name(sys.argv[1])
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
client.drop_database(db_name)
print(f'Database for tenant {sys.argv[1]} dropped.')
" "$TENANT_ID"
echo "Tenant '$TENANT_ID' database removed."
else
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
fi
port_to_remove=""
if [ "$FORCE_REMOVE" != true ]; then
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
read confirm
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
echo "Removal canceled."
exit 0
fi
fi
echo "Removing tenant '$TENANT_ID'..."
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
port_to_remove=""
if [ -n "$APP_CONTAINER" ]; then
port_to_remove="$(docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" <<'PY'
import sys
sys.path.insert(0, '/app')
sys.path.insert(0, '/app/Web')
from tenant import delete_tenant, get_tenant_config
tenant_id = sys.argv[1]
tenant_cfg = get_tenant_config(tenant_id)
port = tenant_cfg.get('port')
if not delete_tenant(tenant_id):
print(f'Error: failed to delete tenant {tenant_id}', file=sys.stderr)
sys.exit(1)
if port is not None:
print(port)
PY
)"
echo "Tenant '$TENANT_ID' database and config removed."
else
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
if [ -n "$port_to_remove" ]; then
remove_runtime_port "$port_to_remove"
sync_tenant_port_map
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
echo "Removed tenant '$TENANT_ID' from config.json and cleaned runtime port $port_to_remove."
else
sync_tenant_port_map
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
echo "Removed tenant '$TENANT_ID' from config.json. No port mapping was present."
fi
:
else
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
fi
fi
if [ -n "$port_to_remove" ]; then
remove_runtime_port "$port_to_remove"
fi
sync_tenant_port_map
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
if [ -n "$port_to_remove" ]; then
echo "Removed tenant '$TENANT_ID' and cleaned runtime port $port_to_remove."
else
echo "Removal canceled."
echo "Removed tenant '$TENANT_ID'. No port mapping was present."
fi
;;
+1 -1
View File
@@ -572,7 +572,7 @@ write_runtime_compose_override() {
services:
app:
working_dir: /app/Web
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "${INVENTAR_WORKERS:-2}", "--threads", "${INVENTAR_THREADS:-2}", "--timeout", "${INVENTAR_WORKER_TIMEOUT:-30}", "--graceful-timeout", "20", "--worker-connections", "${INVENTAR_WORKER_CONNECTIONS:-100}", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "${INVENTAR_WORKERS:-2}", "--threads", "${INVENTAR_THREADS:-2}", "--timeout", "${INVENTAR_WORKER_TIMEOUT:-30}", "--graceful-timeout", "20", "--worker-connections", "${INVENTAR_WORKER_CONNECTIONS:-100}", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
image: ${APP_IMAGE_VALUE}
build: null
EOF