Compare commits

...

55 Commits

Author SHA1 Message Date
github-actions[bot] 9701805552 chore: bump version to v0.8.21 2026-05-31 16:54:47 +00:00
Aiirondev_dev 4227934252 feat: Add ICS export functionality for client slot bookings and enhance client booking interface 2026-05-31 18:54:21 +02:00
Aiirondev_dev d6883d1879 refactor: remove unused date and time parsing functions from backend_server.py and clean up termin_client.html 2026-05-31 18:50:31 +02:00
github-actions[bot] f47e133c45 chore: bump version to v0.8.20 2026-05-31 16:43:58 +00:00
Aiirondev_dev dec3c16d7f feat: Enhance appointment booking interface with date and time selection features 2026-05-31 18:43:04 +02:00
github-actions[bot] 897b2e43ad chore: bump version to v0.8.19 2026-05-31 16:33:17 +00:00
Aiirondev_dev 6dd44508ce fix: Ensure numeric fields are correctly cast to int in get_available function 2026-05-31 18:32:42 +02:00
github-actions[bot] 204ded6c7c chore: bump version to v0.8.18 2026-05-31 16:16:31 +00:00
Aiirondev_dev fbd2168aff feat: Add debug script for tenant-aware appointment lookup 2026-05-31 18:15:53 +02:00
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
github-actions[bot] 451b7bd3c5 chore: bump version to v0.8.0 2026-05-28 16:40:27 +00:00
Aiirondev_dev 84543c8734 feat: enhance file upload functionality and improve CSV import experience 2026-05-28 18:38:08 +02:00
github-actions[bot] 8a64a1adcd chore: bump version to v0.7.79-dev 2026-05-28 15:56:21 +00:00
Aiirondev_dev 25b7fe986c fix: improve ISBN validation and update related messages in upload forms 2026-05-28 17:54:20 +02:00
Aiirondev_dev 4b376bc426 fix: update password requirements title for clarity 2026-05-24 13:40:18 +02:00
Aiirondev_dev e41d739636 chore: update license information and remove outdated EULA content 2026-05-24 09:45:12 +02:00
30 changed files with 3090 additions and 1371 deletions
+1 -1
View File
@@ -1 +1 @@
v0.7.78
v0.8.21
+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", "-"]
+539 -67
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,
@@ -2028,11 +2149,6 @@ def _upload_excel_items(scope='inventory'):
flash('Nicht angemeldet.', 'error')
return redirect(url_for('login'))
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
if not _action_access_allowed(permissions, 'can_insert'):
flash('Einfüge-Rechte erforderlich.', 'error')
return redirect(url_for('home'))
is_library_scope = scope == 'library'
file_field = 'library_excel' if is_library_scope else 'inventory_excel'
fallback_route = 'library_admin' if is_library_scope else 'upload_admin'
@@ -2044,9 +2160,20 @@ def _upload_excel_items(scope='inventory'):
excel_file = request.files.get(file_field)
if not excel_file or not excel_file.filename:
flash('Bitte eine Excel-Datei auswählen.', 'error')
flash('Bitte eine Excel- oder CSV-Datei auswählen.', 'error')
return redirect(url_for(fallback_route))
# Permission handling: allow authenticated normal users to import CSV for inventory scope
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
has_insert = _action_access_allowed(permissions, 'can_insert')
filename_lower = (excel_file.filename or '').lower()
is_csv = filename_lower.endswith('.csv')
if not has_insert:
# Allow CSV imports for authenticated non-admin users only for inventory (non-library)
if not (is_csv and not is_library_scope and 'username' in session):
flash('Einfüge-Rechte erforderlich.', 'error')
return redirect(url_for('home'))
filename_lower = excel_file.filename.lower()
if not filename_lower.endswith(('.xlsx', '.csv')):
flash('Nur .xlsx oder .csv Dateien werden unterstützt.', 'error')
@@ -2349,6 +2476,19 @@ def _upload_excel_items(scope='inventory'):
flash(f'Excel-Import abgeschlossen: {created_total} Artikel importiert, {len(import_errors)} Zeilen fehlgeschlagen.', 'warning')
else:
flash(f'Excel-Import erfolgreich: {created_total} Artikel importiert.', 'success')
if is_library_scope:
try:
_append_audit_event_standalone(
event_type='library_bulk_import',
payload={
'channel': 'upload_library_excel',
'created_total': created_total,
'processed_rows': len(planned_rows),
}
)
except Exception:
app.logger.warning('Audit write failed for library_bulk_import')
return redirect(url_for('home_admin'))
@@ -3034,6 +3174,7 @@ def api_library_items():
query = {
'ItemType': {'$in': ['book', 'cd', 'dvd', 'media']},
'IsGroupedSubItem': {'$ne': True},
'Deleted': {'$ne': True}
}
@@ -3056,20 +3197,45 @@ def api_library_items():
total_count = items_db.count_documents(query)
library_items = list(
items_db.find(query, projection)
.sort([('Name', 1), ('_id', 1)])
.skip(offset)
.limit(limit)
)
raw_items = list(items_db.find(query, projection).sort([('Name', 1), ('_id', 1)]).skip(offset).limit(limit))
item_ids = [str(item.get('_id')) for item in library_items if item.get('_id')]
# Build maps for grouping by parent-child relationship (grouped sub-items)
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 item_ids:
active_records = list(ausleihungen_db.find(
{'Item': {'$in': item_ids}, 'Status': 'active'},
{'Item': 1, 'User': 1}
))
if all_ids:
active_records = list(ausleihungen_db.find({'Item': {'$in': all_ids}, 'Status': 'active'}, {'Item': 1, 'User': 1}))
active_item_ids = set()
active_user_by_item = {}
@@ -3080,32 +3246,76 @@ def api_library_items():
active_item_ids.add(item_id)
if item_id not in active_user_by_item:
active_user_by_item[item_id] = rec.get('User', '')
client.close()
# Convert ObjectId to string for JSON serialization
for item in library_items:
item_id = str(item['_id'])
item['_id'] = item_id
if item.get('Code4') in (None, '') and item.get('Code_4') not in (None, ''):
item['Code4'] = item.get('Code_4')
condition_value = str(item.get('Condition', '')).strip().lower()
has_damage = bool(item.get('HasDamage')) or condition_value == 'destroyed'
has_active_borrow = item_id in active_item_ids
# Build aggregated list: iterate ordered parent list and include children like inventory module
aggregated = []
for parent in raw_items:
pid = str(parent.get('_id'))
children = children_by_parent.get(pid, [])
if has_damage and not has_active_borrow:
item['LibraryDisplayStatus'] = 'damaged'
elif has_active_borrow or item.get('Verfuegbar') is False:
item['LibraryDisplayStatus'] = 'borrowed'
# Compute grouped counts and availability
grouped_units = [parent] + children
grouped_all_codes = []
available_units = []
for unit in grouped_units:
unit_id = str(unit.get('_id'))
code = unit.get('Code_4') or unit.get('Code4') or ''
if code:
grouped_all_codes.append(code)
if unit.get('Verfuegbar', True):
available_units.append({'id': unit_id, 'code': code, 'label': f"{code} ({unit.get('Name','')})"})
# Convert ObjectId to string for JSON serialization on parent doc copy
doc = dict(parent)
doc['_id'] = str(parent.get('_id'))
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 = any(str(unit.get('_id')) in active_item_ids for unit in grouped_units)
# 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:
item['LibraryDisplayStatus'] = 'available'
doc['LibraryDisplayStatus'] = 'available'
item['BorrowedBy'] = active_user_by_item.get(item_id) or item.get('User', '')
# 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:
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 ''
count = len(library_items)
doc['BorrowedBy'] = borrower
doc['GroupedDisplayCount'] = 1 + len(children)
doc['Quantity'] = doc['GroupedDisplayCount']
doc['AvailableGroupedCount'] = len(available_units)
doc['GroupedAvailableUnits'] = available_units
doc['GroupedAllCodes'] = grouped_all_codes
aggregated.append(doc)
client.close()
count = len(aggregated)
return jsonify({
'items': library_items,
'items': aggregated,
'offset': offset,
'limit': limit,
'count': count,
@@ -3289,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>
@@ -3319,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}")
@@ -3390,6 +3657,19 @@ def api_library_item_update(item_id):
update_doc['ISBN'] = ''
items_col.update_one({'_id': ObjectId(item_id)}, {'$set': update_doc})
try:
_append_audit_event_standalone(
event_type='library_item_updated',
payload={
'channel': 'library_table',
'item_id': item_id,
'updated_fields': list(update_doc.keys()),
'isbn': update_doc.get('ISBN', ''),
}
)
except Exception:
app.logger.warning('Audit write failed for library_item_updated')
client.close()
return jsonify({'ok': True, 'message': 'Bibliotheksmedium aktualisiert.'}), 200
@@ -4523,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():
"""
@@ -4801,16 +5157,15 @@ def upload_item():
item_isbn = ''
item_type = 'general'
if cfg.MODULES.is_enabled('library'):
item_isbn = normalize_and_validate_isbn(isbn_raw)
if isbn_raw and not item_isbn:
error_msg = 'Ungültige ISBN. Bitte ISBN-10 oder ISBN-13 verwenden.'
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint))
if item_isbn:
if cfg.MODULES.is_enabled('library') and isbn_raw:
normalized_isbn = normalize_and_validate_isbn(isbn_raw)
if normalized_isbn:
item_isbn = normalized_isbn
item_type = 'book'
else:
item_isbn = isbn_raw
if upload_mode == 'library':
item_type = 'book'
if upload_mode == 'library':
if not cfg.MODULES.is_enabled('library'):
@@ -4819,8 +5174,8 @@ def upload_item():
return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error')
return redirect(url_for(success_redirect_endpoint))
if not item_isbn:
error_msg = 'Für Bücher ist eine gültige ISBN erforderlich.'
if not isbn_raw:
error_msg = 'Für Bücher ist eine ISBN oder ein Barcode erforderlich.'
if is_mobile:
return jsonify({'success': False, 'message': error_msg}), 400
flash(error_msg, 'error')
@@ -5610,7 +5965,21 @@ def upload_item():
# Create QR code for the item (deactivated)
# create_qr_code(str(item_id))
success_msg = f'Element wurde erfolgreich hinzugefügt ({len(created_item_ids)} erstellt)'
if upload_mode == 'library':
try:
_append_audit_event_standalone(
event_type='library_item_created',
payload={
'channel': 'upload_admin',
'created_item_ids': created_item_ids,
'created_count': len(created_item_ids),
'isbn': item_isbn,
'codes': created_item_ids and [it.get_item(cid).get('Code_4') for cid in created_item_ids] or []
}
)
except Exception:
app.logger.warning('Audit write failed for library_item_created')
if is_mobile:
return jsonify({
'success': True,
@@ -5914,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."""
@@ -6161,6 +6603,20 @@ def report_damage(id):
except Exception as log_err:
app.logger.warning(f"Damage report log write failed for item {id}: {log_err}")
try:
_append_audit_event_standalone(
event_type='item_damage_reported',
payload={
'item_id': id,
'item_name': item_doc.get('Name', ''),
'reported_by': session.get('username'),
'description': description,
'damage_count': damage_count,
}
)
except Exception:
app.logger.warning('Audit write failed for item_damage_reported')
return jsonify({
'success': True,
'message': 'Schaden erfolgreich erfasst.',
@@ -6243,6 +6699,18 @@ def mark_damage_repaired(id):
except Exception as log_err:
app.logger.warning(f"Damage repair log write failed for item {id}: {log_err}")
try:
_append_audit_event_standalone(
event_type='item_damage_repaired',
payload={
'item_id': id,
'resolved_by': session.get('username'),
'resolved_count': len(open_reports),
}
)
except Exception:
app.logger.warning('Audit write failed for item_damage_repaired')
return jsonify({
'success': True,
'message': 'Schäden als repariert markiert.',
@@ -7178,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
+54 -5
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
},
@@ -155,10 +167,19 @@ def _get_int_env(name, default):
return int(default)
def get_version():
with open(os.path.join(BASE_DIR, '..', '..', '..', '.docker-build.env'), 'r') as f:
for l in f:
if l.startswith('INVENTAR_APP_IMAGE='):
return l.split(':', 1)[1].strip()
# 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, '.docker-build.env')
try:
if os.path.isfile(release_file):
with open(release_file, 'r', encoding='utf-8') as f:
var = f.readline()
for i in var:
i = i.split(":")
i.pop[0]
return str(i)
except Exception:
pass
# Expose settings
APP_VERSION = get_version()
@@ -198,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'], {})
@@ -234,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
@@ -252,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
+465
View File
@@ -0,0 +1,465 @@
"""
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 build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str = '') -> str | None:
"""Build a single-slot ICS export for a client booking candidate."""
item = termin.get_item(appointment_id)
if not item:
return None
try:
start_dt = datetime.datetime.strptime(str(slot_start).strip(), '%Y-%m-%d %H:%M')
except Exception:
return None
try:
slot_minutes = int(item.get('slot_lenght') or 0)
except Exception:
slot_minutes = 0
if slot_minutes <= 0:
slot_minutes = 45
end_dt = start_dt + datetime.timedelta(minutes=slot_minutes)
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}'
title_name = str(client_name or '').strip() or 'Termin'
summary = f"{title_name} - Terminbuchung"
description_lines = [
f"Buchungslink: {link}",
f"Geplanter Termin: {start_dt.strftime('%d.%m.%Y %H:%M')} - {end_dt.strftime('%H:%M')}",
]
uid = f"terminplaner-slot-{appointment_id}-{start_dt.strftime('%Y%m%d%H%M')}@invario.eu"
created_at = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
dt_start = start_dt.strftime('%Y%m%dT%H%M%S')
dt_end = end_dt.strftime('%Y%m%dT%H%M%S')
ics_lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Inventarsystem//Terminplaner Client Slot//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:{dt_start}',
f'DTEND:{dt_end}',
'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)
# Ensure numeric fields are cast to int when stored as strings
try:
total_slots = int(termin_range.get('slots', 0) or 0)
except Exception:
total_slots = 0
try:
slot_lenght = int(termin_range.get('slot_lenght') or 0)
except Exception:
slot_lenght = termin_range.get('slot_lenght')
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)
try:
slots_left = max(0, int(total_slots) - slots_used)
except Exception:
slots_left = max(0, slots_used - 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
+204
View File
@@ -0,0 +1,204 @@
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('/client_ics/<appointment_id>.ics', methods=['GET'])
def client_slot_calendar_export(appointment_id):
guard = _require_module_enabled()
if guard:
return guard
slot_start = str(request.args.get('start', '') or '').strip()
client_name = str(request.args.get('name', '') or '').strip()
ics_content = appointment_service.build_client_slot_ics(appointment_id, slot_start, client_name=client_name)
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=termin-{appointment_id}-{slot_start.replace(" ", "_").replace(":", "")}.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
+97 -48
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;
@@ -930,6 +944,29 @@
box-shadow: none;
}
.navbar-nav .nav-item.dropdown > .nav-link.dropdown-toggle,
.user-menu-wrap > .user-menu-btn {
display: none !important;
}
.navbar-nav .nav-item.dropdown > .dropdown-menu,
.user-menu-wrap > .dropdown-menu {
display: block !important;
position: static !important;
float: none !important;
transform: none !important;
inset: auto !important;
width: 100%;
}
.navbar-nav .nav-item.dropdown > .dropdown-menu {
margin-top: 0;
}
.user-menu-wrap > .dropdown-menu {
margin-top: 8px;
}
.navbar-nav .dropdown-item {
color: rgba(255, 255, 255, 0.9);
padding: 12px 16px;
@@ -974,6 +1011,7 @@
order: 3;
width: 100%;
margin: 0;
display: none !important;
}
.function-search-form {
@@ -1054,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>
@@ -1196,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">
@@ -2055,42 +2137,9 @@
return;
}
// Mobile menu is collapsed: avoid hiding links preemptively.
if (navCollapse && !navCollapse.classList.contains('show')) {
restoreAllNavItems();
return;
}
// Mobile burger menu should show the full list without overflow pruning.
restoreAllNavItems();
const hiddenSources = [];
let candidates = collectTopLevelNavSources();
const overflowBuffer = 12;
while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
const toHide = pickNextNavItemToHide(candidates);
if (!toHide) {
break;
}
toHide.style.display = 'none';
hiddenSources.unshift(toHide);
candidates = collectTopLevelNavSources();
}
rebuildOverflowControl(hiddenSources);
// One final pass in case the overflow menu label/count itself changed row width.
candidates = collectTopLevelNavSources();
while (isNavOverflowing(overflowBuffer) && candidates.length > 0) {
const toHide = pickNextNavItemToHide(candidates);
if (!toHide) {
break;
}
toHide.style.display = 'none';
hiddenSources.unshift(toHide);
rebuildOverflowControl(hiddenSources);
candidates = collectTopLevelNavSources();
}
return;
}
let resizeTimer = null;
+17
View File
@@ -72,6 +72,7 @@
</div>
<!-- Table Header (only in table mode) -->
<div class="library-table-wrapper">
<div class="table-header" id="tableHeader" style="display: none;">
<div class="table-row">
<div class="table-cell title-cell">Titel</div>
@@ -166,6 +167,7 @@
</div>
{% endif %}
</div>
</div>
</div>
<!-- Detail Modal -->
@@ -720,5 +722,20 @@ function onScanError(error) {
background: #fce8e6;
color: #b3261e;
}
/* Mobile: allow horizontal scrolling for table-mode views */
@media (max-width: 900px) {
.library-table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
width: 100%;
}
.library-table-wrapper .table-row {
min-width: 720px; /* allow table to have intrinsic width and be scrolled */
}
.table-header, .item-content.table-mode {
display: block; /* keep rows stacked but allow horizontal scroll */
}
}
</style>
{% endblock %}
+71 -5
View File
@@ -513,12 +513,13 @@
<table class="library-items-table">
<thead>
<tr>
<th style="width: 25%;">Titel</th>
<th style="width: 15%;">Autor/Künstler</th>
<th style="width: 24%;">Titel</th>
<th style="width: 14%;">Autor/Künstler</th>
<th style="width: 12%;">ISBN/Code</th>
<th style="width: 10%;">Typ</th>
<th style="width: 8%;">Typ</th>
<th style="width: 8%;">Anzahl</th>
<th style="width: 12%;">Status</th>
<th style="width: 26%;">Aktionen</th>
<th style="width: 22%;">Aktionen</th>
</tr>
</thead>
<tbody id="itemsTableBody">
@@ -603,7 +604,7 @@
}
} catch (error) {
console.error('Error loading library items:', error);
document.getElementById('itemsTableBody').innerHTML = '<tr><td colspan="6" style="text-align:center; color:#999;">Fehler beim Laden der Bibliothekselemente.</td></tr>';
document.getElementById('itemsTableBody').innerHTML = '<tr><td colspan="7" style="text-align:center; color:#999;">Fehler beim Laden der Bibliothekselemente.</td></tr>';
} finally {
pagingState.loading = false;
}
@@ -707,6 +708,7 @@
<td>${escapeHtml(item.Autor || item.Author || '-')}</td>
<td>${escapeHtml(item.ISBN || item.Code_4 || item.Code4 || '-')}</td>
<td>${getItemTypeLabel(item.ItemType || 'book')}</td>
<td style="font-weight:600; text-align:center;">${item.Quantity || item.GroupedDisplayCount || 1}</td>
<td>
<span class="table-status ${statusClass}">
${statusText}
@@ -718,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>
`;
@@ -737,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;
@@ -820,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';
@@ -845,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();
}
+2 -154
View File
@@ -1,11 +1,3 @@
<!--
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
-->
{% extends "base.html" %}
{% block title %}License - Inventarsystem{% endblock %}
@@ -17,10 +9,6 @@
<!-- Tab navigation -->
<ul class="nav nav-tabs mt-3" id="legalTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-eula" data-bs-toggle="tab" data-bs-target="#pane-eula"
type="button" role="tab">EULA</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-notice" data-bs-toggle="tab" data-bs-target="#pane-notice"
type="button" role="tab">NOTICE</button>
@@ -43,139 +31,12 @@
</li>
</ul>
<div class="tab-content" id="legalTabContent">
<!-- ── EULA ─────────────────────────────────────────────────────── -->
<div class="tab-pane fade show active" id="pane-eula" role="tabpanel">
<div class="license-content">
<!-- EULA -->
<h2>Endbenutzer-Lizenzvertrag (EULA) und Nutzungsbedingungen</h2>
<p><strong>Softwareprojekt:</strong> Inventarsystem<br>
<strong>Urheberrechtshalter (Lizenzgeber):</strong> AIIrondev<br>
<strong>Gültigkeit:</strong> Stand 2026</p>
<hr>
<h3>Präambel</h3>
<p>Dieser Endbenutzer-Lizenzvertrag (im Folgenden „Vertrag") stellt eine rechtsgültige Vereinbarung zwischen Ihnen
(im Folgenden „Lizenznehmer") und dem Urheber <strong>AIIrondev</strong> (im Folgenden „Lizenzgeber") dar.
Durch den Zugriff auf den Quellcode, die Installation, das Kopieren oder die sonstige Nutzung der Software
„Inventarsystem" (im Folgenden „Produkt") erklärt sich der Lizenznehmer mit den nachfolgenden Bedingungen
vollumfänglich einverstanden.</p>
<p>Sollte der Lizenznehmer den Bedingungen dieses Vertrags nicht zustimmen, ist jegliche Nutzung,
Vervielfältigung oder Distribution des Produkts mit sofortiger Wirkung untersagt.</p>
<hr>
<h3>§ 1 Gegenstand der Lizenz und Eigentumsrechte</h3>
<ol>
<li>Das Produkt wird dem Lizenznehmer unter Vorbehalt lizenziert, nicht verkauft. Sämtliche
Eigentumsrechte, Urheberrechte und sonstigen geistigen Eigentumsrechte am Produkt sowie an allen
Kopien davon verbleiben ausschließlich beim Lizenzgeber.</li>
<li>Diese Lizenz gewährt lediglich ein eingeschränktes Nutzungsrecht unter den in diesem Vertrag
explizit genannten Bedingungen.</li>
</ol>
<h3>§ 2 Zulässiger Nutzungskreis (Privatnutzung)</h3>
<ol>
<li>Die unentgeltliche Nutzung des Produkts ist ausschließlich <strong>natürlichen Personen für den
rein privaten, häuslichen Gebrauch</strong> gestattet.</li>
<li>Die Nutzung umfasst die Verwaltung privater Bestände ohne jegliche Gewinnerzielungsabsicht.</li>
<li>Jegliche Nutzung durch <strong>Institutionelle Nutzer</strong> (einschließlich, aber nicht
beschränkt auf: Unternehmen, Einzelunternehmer, Freiberufler, Vereine, Bildungseinrichtungen,
Behörden oder NGOs) ist ausdrücklich <strong>untersagt</strong> und bedarf einer gesonderten,
schriftlichen Lizenzvereinbarung mit dem Lizenzgeber.</li>
</ol>
<h3>§ 3 Funktionale Einschränkungen und Support</h3>
<ol>
<li>Dem Lizenznehmer wird das Produkt in der jeweils vorliegenden Fassung bereitgestellt („As-Is").</li>
<li>Für die kostenlose Privatnutzung besteht <strong>kein Anspruch</strong> auf:
<ul>
<li>Technischen Support oder Beratung.</li>
<li>Bereitstellung von Sicherheits-Updates oder Patches.</li>
<li>Gewährleistung der Kompatibilität mit spezifischen Hardware- oder Softwareumgebungen.</li>
</ul>
</li>
<li>Der Lizenzgeber behält sich das Recht vor, Funktionen in zukünftigen Versionen zu ändern,
einzuschränken oder kostenpflichtig zu gestalten.</li>
</ol>
<h3>§ 4 Wahrung der Urheberbezeichnung (Branding-Klausel)</h3>
<ol>
<li>Das Produkt verfügt über fest integrierte Urheberrechtshinweise, insbesondere die Kennzeichnung
<strong>„Powered by AIIrondev"</strong> in der Benutzeroberfläche (Footer/Menü).</li>
<li>Es ist dem Lizenznehmer untersagt, diese Hinweise zu entfernen, zu modifizieren, zu verdecken oder
deren Sichtbarkeit durch technische Maßnahmen (z. B. Manipulation von CSS, JavaScript oder
Metadaten) zu beeinträchtigen.</li>
<li>Das Entfernen dieser Hinweise führt zum sofortigen und automatischen Erlöschen der
Nutzungslizenz.</li>
</ol>
<h3>§ 5 Verbot der kommerziellen Verwertung &amp; SaaS</h3>
<ol>
<li>Die Bereitstellung des Produkts als Dienstleistung für Dritte (Software-as-a-Service, SaaS),
insbesondere gegen Entgelt oder zur Generierung von Werbeeinnahmen, ist strikt untersagt.</li>
<li>Das Hosting auf öffentlichen Servern mit dem Ziel, Dritten ohne eigene Installation Zugriff auf
die Funktionalität zu gewähren, ist nur mit ausdrücklicher schriftlicher Genehmigung des
Lizenzgebers zulässig.</li>
</ol>
<h3>§ 6 Modifikationen und Contributions</h3>
<ol>
<li>Der Lizenznehmer darf den Quellcode für den privaten Eigenbedarf modifizieren.</li>
<li>Im Falle einer Veröffentlichung von Modifikationen oder der Einreichung von
Verbesserungsvorschlägen (z. B. Pull Requests auf GitHub) räumt der Lizenznehmer dem Lizenzgeber
ein unwiderrufliches, weltweites, zeitlich unbeschränktes und kostenfreies Nutzungs- und
Verwertungsrecht an diesen Änderungen ein. Der Lizenzgeber ist berechtigt, diese Änderungen in
das Hauptprodukt zu übernehmen und unter dieser oder einer anderen Lizenz zu vertreiben.</li>
</ol>
<h3>§ 7 Haftungsbeschränkung</h3>
<ol>
<li>Die Haftung des Lizenzgebers für Schäden, die aus der Nutzung oder Unmöglichkeit der Nutzung des
Produkts entstehen (einschließlich Datenverlust, Betriebsunterbrechung oder entgangener Gewinn),
ist auf Vorsatz und grobe Fahrlässigkeit beschränkt.</li>
<li>Der Lizenzgeber übernimmt keine Haftung für die Richtigkeit der mit dem Produkt verwalteten
Daten.</li>
</ol>
<h3>§ 8 Rechtswahl und Gerichtsstand</h3>
<ol>
<li>Es gilt ausschließlich das Recht der <strong>Bundesrepublik Deutschland</strong> unter Ausschluss
des UN-Kaufrechts (CISG).</li>
<li>Soweit gesetzlich zulässig, wird als Gerichtsstand für alle Streitigkeiten aus diesem Vertrag der
Sitz des Lizenzgebers vereinbart.</li>
<li>Sollten einzelne Bestimmungen dieses Vertrags unwirksam sein, bleibt die Wirksamkeit der übrigen
Bestimmungen unberührt (Salvatorische Klausel).</li>
</ol>
<hr>
<div class="license-exception-notice">
<h3>⚠️ Anfragen für Ausnahmegenehmigungen (Kommerzielle Lizenzen)</h3>
<p>
Bitte kontaktieren Sie den Urheber direkt über das GitHub-Profil:
<a href="https://github.com/AIIrondev" target="_blank" rel="noopener noreferrer">AIIrondev auf GitHub</a>
</p>
</div>
</div><!-- /.license-content -->
</div><!-- /#pane-eula -->
<!-- ── NOTICE ────────────────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-notice" role="tabpanel">
<div class="license-content">
<h2>NOTICE Urheberrechtliche Hinweise</h2>
<p><strong>Inventarsystem</strong><br>Copyright © 2025-2026 AIIrondev</p>
<p>Dieses Projekt steht unter dem Inventarsystem EULA (Endbenutzer-Lizenzvertrag). Der vollständige Lizenztext
befindet sich in <code>Legal/LICENSE</code>. Die Software wird ausschließlich für den privaten,
nicht-kommerziellen Gebrauch bereitgestellt. Unerlaubte kommerzielle Nutzung, SaaS-Hosting oder das Entfernen
von Branding ist untersagt. Anfragen für kommerzielle Lizenzen:
<a href="https://github.com/AIIrondev" target="_blank" rel="noopener noreferrer">https://github.com/AIIrondev</a></p>
<p><strong>Invario Modul Suite</strong><br>Copyright © 2026 Invario UG</p>
<hr>
<h3>Drittanbieter-Hinweise</h3>
<p>Dieses Projekt nutzt oder referenziert die folgende Drittanbieter-Software. Deren jeweilige Lizenzen gelten
wie angegeben. Dieser NOTICE-Abschnitt dient ausschließlich der Attribution und ändert keine Lizenzbedingungen.</p>
@@ -200,13 +61,6 @@
<ul>
<li>html5-qrcode (MIT) © 2020-2025 Manoj Brahmbhatt (mebjas)</li>
</ul>
<h4>Systemkomponenten (nicht im Repository enthalten, ggf. im Deployment genutzt)</h4>
<ul>
<li>NGINX (2-clause BSD) © Nginx, Inc. and/or its licensors</li>
<li>FFmpeg (LGPL/GPL, je nach Konfiguration) © 2000-2025 FFmpeg developers</li>
</ul>
<hr>
</div>
</div><!-- /#pane-notice -->
@@ -258,9 +112,6 @@
<div class="license-content">
<h2>Sicherheitsrichtlinie (Security Policy)</h2>
<h3>Unterstützte Versionen</h3>
<p>Bitte nutzen Sie immer die neueste Version aus dem <code>main</code>-Branch, um Sicherheitsupdates zu erhalten.</p>
<h3>Datenschutzrechtliche Mechanismen</h3>
<ul>
<li><strong>Passwort-Hashing:</strong> Passwörter werden niemals im Klartext gespeichert.</li>
@@ -272,10 +123,7 @@
<h3>Meldung von Sicherheitslücken</h3>
<div class="license-exception-notice">
<h3>⚠️ Responsible Disclosure</h3>
<p>Falls Sie eine Sicherheitslücke entdecken, öffnen Sie bitte <strong>kein öffentliches Issue</strong>.
Kontaktieren Sie den Maintainer direkt über die
<a href="https://github.com/AIIrondev" target="_blank" rel="noopener noreferrer">GitHub Security Advisory-Funktion</a>
oder per E-Mail.</p>
<p>Falls Sie eine Sicherheitslücke entdecken, wenden sie sich umgehend an die Email: info@invario.eu .
</div>
</div>
</div><!-- /#pane-security -->
-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 %}
+1 -1
View File
@@ -55,7 +55,7 @@
<div class="form-group">
<label for="password">Passwort</label>
<div class="password-rules" id="password-rules" aria-live="polite">
<p class="password-rules-title">Passwort-Anforderungen (live):</p>
<p class="password-rules-title">Passwort-Anforderungen:</p>
<ul>
<li id="pw-rule-length" class="pw-rule">Mindestens 12 Zeichen</li>
<li id="pw-rule-lower" class="pw-rule">Mindestens ein Kleinbuchstabe</li>
+409
View File
@@ -0,0 +1,409 @@
{% extends "base.html" %}
{% block title %}Termin buchen - Inventarsystem{% endblock %}
{% block head %}
{{ super() }}
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.css" rel="stylesheet">
<style>
#client-slot-calendar {
min-height: 520px;
}
.fc .fc-timegrid-slot-label-cushion,
.fc .fc-timegrid-axis-cushion,
.fc .fc-col-header-cell-cushion {
font-weight: 600;
}
.slot-selected-chip {
display: inline-flex;
align-items: center;
gap: .4rem;
border-radius: 999px;
background: rgba(13, 110, 253, 0.1);
color: #0d6efd;
padding: .35rem .75rem;
font-size: .9rem;
font-weight: 700;
}
.day-slider-wrap {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: .9rem;
padding: .8rem 1rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-12 col-xxl-11">
<div class="row g-4">
<div class="col-12 col-lg-4">
<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 im Kalender einen freien Slot aus. Den gewählten Termin können Sie danach direkt wie in einem Kalender-Block verschieben.</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>
<div class="p-3 rounded-3 bg-light mb-3">
<div class="fw-semibold mb-2">Gewählter Termin</div>
<div id="selected-slot-badge" class="text-muted small">Noch kein Slot ausgewählt.</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-8">
<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-3">Termin im Kalender auswählen</h2>
<div class="day-slider-wrap mb-3">
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
<span class="small text-muted">Tag wählen</span>
<strong id="day-slider-label" class="small"></strong>
</div>
<input id="day-slider" type="range" class="form-range m-0" min="0" max="0" value="0">
</div>
<div id="client-slot-calendar" class="mb-4"></div>
<form id="client-booking-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="Bitte im Kalender auswählen" readonly required>
<div class="form-text">Klicken Sie auf einen freien Slot oder verschieben Sie den gewählten Block.</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>
<div class="modal fade" id="calendarDownloadModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Termin zum Kalender hinzufügen?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Schließen"></button>
</div>
<div class="modal-body">
<p class="mb-2">Sie haben einen Termin ausgewählt.</p>
<p class="mb-0 small text-muted">Mit einem Klick auf ".ics herunterladen" können Sie den Termin in Apple/Google/Outlook-Kalender importieren.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Später</button>
<a id="download-slot-ics" class="btn btn-primary" href="#">.ics herunterladen</a>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
<script>
(function () {
const available = {{ available|tojson }};
const appointmentId = {{ appointment_id|tojson }};
const slider = document.getElementById('day-slider');
const sliderLabel = document.getElementById('day-slider-label');
const selectedSlotInput = document.getElementById('start_day_time');
const selectedSlotBadge = document.getElementById('selected-slot-badge');
const clientNameInput = document.getElementById('client_name');
const form = document.getElementById('client-booking-form');
const calendarEl = document.getElementById('client-slot-calendar');
const modalEl = document.getElementById('calendarDownloadModal');
const downloadIcsLink = document.getElementById('download-slot-ics');
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
const slotLength = Number.parseInt(available.slot_lenght, 10) || 45;
const bookedStarts = new Set((available.slots_booked || []).map(function (entry) {
return String(entry.start || '').trim();
}).filter(Boolean));
function formatDateForInput(dateObj) {
const y = dateObj.getFullYear();
const m = String(dateObj.getMonth() + 1).padStart(2, '0');
const d = String(dateObj.getDate()).padStart(2, '0');
const hh = String(dateObj.getHours()).padStart(2, '0');
const mm = String(dateObj.getMinutes()).padStart(2, '0');
return y + '-' + m + '-' + d + ' ' + hh + ':' + mm;
}
function formatDateReadable(value) {
if (!value) return 'Noch kein Slot ausgewählt.';
return 'Ausgewählt: ' + value;
}
function dateRangeInclusive(startStr, endStr) {
const days = [];
const start = new Date(startStr + 'T00:00:00');
const end = new Date(endStr + 'T00:00:00');
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return days;
}
const cursor = new Date(start);
while (cursor <= end) {
const y = cursor.getFullYear();
const m = String(cursor.getMonth() + 1).padStart(2, '0');
const d = String(cursor.getDate()).padStart(2, '0');
days.push(y + '-' + m + '-' + d);
cursor.setDate(cursor.getDate() + 1);
}
return days;
}
function addMinutes(dateObj, minutes) {
return new Date(dateObj.getTime() + minutes * 60000);
}
function parseTimeSpanEntry(entry) {
const value = String(entry || '').trim();
let m = value.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})-(\d{2}:\d{2})$/);
if (m) {
return { date: m[1], from: m[2], to: m[3] };
}
m = value.match(/^(\d{2}:\d{2})-(\d{2}:\d{2})$/);
if (m) {
return { date: null, from: m[1], to: m[2] };
}
return null;
}
function buildCandidateSlots() {
const slots = [];
const allowedDates = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
const spans = Array.isArray(available.time_span) ? available.time_span : [];
spans.forEach(function (entry) {
const parsed = parseTimeSpanEntry(entry);
if (!parsed) {
return;
}
const targetDates = parsed.date ? [parsed.date] : allowedDates;
targetDates.forEach(function (date) {
const from = new Date(date + 'T' + parsed.from + ':00');
const to = new Date(date + 'T' + parsed.to + ':00');
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from >= to) {
return;
}
let cursor = new Date(from);
while (addMinutes(cursor, slotLength) <= to) {
const slotStart = formatDateForInput(cursor);
if (!bookedStarts.has(slotStart)) {
slots.push({
start: slotStart,
end: formatDateForInput(addMinutes(cursor, slotLength)),
});
}
cursor = addMinutes(cursor, slotLength);
}
});
});
return slots;
}
const candidateSlots = buildCandidateSlots();
const slotStartSet = new Set(candidateSlots.map(function (slot) { return slot.start; }));
let selectedSlot = '';
let selectedEvent = null;
let selectedShownOnce = false;
const calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'timeGridWeek',
locale: 'de',
firstDay: 1,
height: 'auto',
allDaySlot: false,
editable: true,
eventStartEditable: true,
eventDurationEditable: false,
selectable: false,
slotDuration: '00:15:00',
snapDuration: '00:15:00',
nowIndicator: true,
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'timeGridDay,timeGridWeek'
},
events: [],
eventDrop: function (info) {
if (info.event.id !== 'selected-slot') {
return;
}
const droppedStart = formatDateForInput(info.event.start);
if (!slotStartSet.has(droppedStart)) {
info.revert();
window.alert('Dieser Zeitpunkt ist nicht als freier Slot verfügbar.');
return;
}
applySelectedSlot(droppedStart);
},
eventClick: function (info) {
const slotType = info.event.extendedProps ? info.event.extendedProps.slotType : '';
if (slotType !== 'free') {
return;
}
applySelectedSlot(info.event.extendedProps.slotStart || '');
}
});
function updateSliderLabel() {
const dateList = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
const idx = Number.parseInt(slider.value, 10) || 0;
sliderLabel.textContent = dateList[idx] || '';
}
function applySelectedSlot(value) {
selectedSlot = String(value || '').trim();
selectedSlotInput.value = selectedSlot;
selectedSlotBadge.innerHTML = selectedSlot
? '<span class="slot-selected-chip">' + selectedSlot + '</span>'
: 'Noch kein Slot ausgewählt.';
if (selectedEvent) {
selectedEvent.remove();
selectedEvent = null;
}
if (!selectedSlot) {
return;
}
const start = new Date(selectedSlot.replace(' ', 'T') + ':00');
const end = addMinutes(start, slotLength);
selectedEvent = calendar.addEvent({
id: 'selected-slot',
title: 'Ihr ausgewählter Termin',
start: start,
end: end,
color: '#0d6efd',
editable: true,
});
if (modal && !selectedShownOnce) {
selectedShownOnce = true;
modal.show();
}
refreshIcsLink();
}
function refreshIcsLink() {
if (!downloadIcsLink) {
return;
}
const base = {{ url_for('terminplaner.client_slot_calendar_export', appointment_id=appointment_id, tenant=tenant_id)|tojson }};
const url = new URL(base, window.location.origin);
if (selectedSlot) {
url.searchParams.set('start', selectedSlot);
}
const name = String(clientNameInput.value || '').trim();
if (name) {
url.searchParams.set('name', name);
}
downloadIcsLink.setAttribute('href', url.toString());
}
candidateSlots.forEach(function (slot) {
const start = new Date(slot.start.replace(' ', 'T') + ':00');
const end = new Date(slot.end.replace(' ', 'T') + ':00');
calendar.addEvent({
title: 'Freier Slot',
start: start,
end: end,
color: '#20c997',
textColor: '#062b1f',
editable: false,
extendedProps: {
slotStart: slot.start,
slotType: 'free',
}
});
});
(available.slots_booked || []).forEach(function (booking) {
const startStr = String(booking.start || '').trim();
if (!startStr) {
return;
}
const start = new Date(startStr.replace(' ', 'T') + ':00');
const end = addMinutes(start, slotLength);
calendar.addEvent({
title: 'Gebucht' + (booking.name ? ' - ' + booking.name : ''),
start: start,
end: end,
color: '#dc3545',
editable: false,
display: 'block',
});
});
form.addEventListener('submit', function (ev) {
if (!selectedSlotInput.value) {
ev.preventDefault();
window.alert('Bitte zuerst im Kalender einen freien Slot auswählen.');
}
});
clientNameInput.addEventListener('input', refreshIcsLink);
const allDays = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
slider.max = String(Math.max(0, allDays.length - 1));
slider.value = '0';
updateSliderLabel();
if (allDays.length > 0) {
calendar.gotoDate(allDays[0]);
}
slider.addEventListener('input', function () {
const idx = Number.parseInt(slider.value, 10) || 0;
updateSliderLabel();
if (allDays[idx]) {
calendar.gotoDate(allDays[idx]);
}
});
calendar.render();
refreshIcsLink();
})();
</script>
{% 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 %}
+93 -39
View File
@@ -716,6 +716,8 @@
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich.</p>
<form method="POST" action="{{ url_for('upload_library_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="library_excel" accept=".xlsx,.csv" required>
<button type="button" class="btn btn-link" onclick="downloadSampleCsv('library')">Beispiel-CSV herunterladen</button>
<button type="button" class="btn btn-outline-secondary" title="Format-Hilfe" onclick="showCsvHelp('library')">?</button>
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Bibliothek importieren</button>
</form>
@@ -726,6 +728,8 @@
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl).</p>
<form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="inventory_excel" accept=".xlsx,.csv" required>
<button type="button" class="btn btn-link" onclick="downloadSampleCsv('inventory')">Beispiel-CSV herunterladen</button>
<button type="button" class="btn btn-outline-secondary" title="Format-Hilfe" onclick="showCsvHelp('inventory')">?</button>
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Inventar importieren</button>
</form>
@@ -883,7 +887,7 @@
<div class="form-group">
<label for="individual_codes">Einzelcodes (optional, je Zeile ein Code)</label>
<textarea id="individual_codes" name="individual_codes" rows="4" placeholder="z.B.\nABC-001\nABC-002"></textarea>
<small style="display:block; color:#666;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Scanner-Eingaben werden bei Mehrfachanzahl automatisch hier angefügt.</small>
<small style="display:block; color:#666;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Der Scanner setzt immer zuerst den Basis-Code im Feld oben; weitere Einzelcodes können hier bei Bedarf manuell ergänzt werden.</small>
</div>
<!-- Image upload (hidden for library mode) -->
<div class="form-group" {% if show_library_features %}style="display:none;"{% endif %}>
@@ -904,7 +908,7 @@
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Bild abrufen</button>
</div>
<div id="isbn-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
<small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Das Buchcover wird automatisch heruntergeladen.</small>
<small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.</small>
<div id="book-info-container" class="book-info-container"></div>
</div>
@@ -920,6 +924,53 @@
</div>
</div>
<!-- CSV Help Modal -->
<div id="csv-help-modal" class="popup-overlay" style="display:none;">
<div class="duplicate-code-popup" style="max-width:760px;">
<button class="popup-close-x" onclick="hideCsvHelp()">×</button>
<div class="popup-content">
<h3>CSV Import Format</h3>
<p id="csv-help-text" style="text-align:left; color:var(--ui-text);"></p>
<div style="margin-top:12px; text-align:center;">
<button class="popup-close-button" onclick="hideCsvHelp()">Schließen</button>
</div>
</div>
</div>
</div>
<script>
function downloadSampleCsv(scope) {
let headers = ['name','ort','beschreibung','filter1','filter2','filter3','anschaffungsjahr','anschaffungskosten','code_4','anzahl','isbn','item_type'];
let exampleInventory = ['Projektor','Aula','HD-Projektor für Präsentationen','Medien','Technik','','2019','450.00','PRJ-001','1','','general'];
let exampleLibrary = ['Harry Potter und der Stein der Weisen','Bibliothek','Kinderbuch von J.K. Rowling','Belletristik','','','1997','12.99','HP-001','1','9783551354013','book'];
let row = exampleInventory;
if (scope === 'library') row = exampleLibrary;
const csvContent = [headers.join(','), row.map(v=> '"'+String(v).replace(/"/g,'""')+'"').join(',')].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = scope === 'library' ? 'sample_library_import.csv' : 'sample_inventory_import.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function showCsvHelp(scope) {
const invText = `Erlaubte Spalten (Reihenfolge beliebig): name, ort, beschreibung, filter1, filter2, filter3, anschaffungsjahr, anschaffungskosten, code_4, anzahl, isbn, item_type.\n\n` +
`Hinweise:\n- Trenne Felder per Komma. Textfelder sollten in Anführungszeichen stehen, wenn sie Kommas enthalten.\n- Für Bibliotheks-Import ist eine gültige ISBN pro Zeile erforderlich (ISBN-10 oder ISBN-13).\n- 'code_4' kann leer bleiben; das System erzeugt in diesem Fall Codes.\n- 'anzahl' legt die Anzahl physischer Exemplare für diese Zeile fest.`;
const libText = invText;
document.getElementById('csv-help-text').textContent = scope === 'library' ? libText : invText;
document.getElementById('csv-help-modal').style.display = 'flex';
}
function hideCsvHelp() {
document.getElementById('csv-help-modal').style.display = 'none';
}
</script>
<script src="https://unpkg.com/html5-qrcode@2.0.9/dist/html5-qrcode.min.js"></script>
<script>
const libraryModuleEnabled = {{ 'true' if library_module_enabled else 'false' }};
@@ -965,33 +1016,30 @@
return '';
}
function appendIndividualCode(scannedCode) {
const textarea = document.getElementById('individual_codes');
const itemCountInput = document.getElementById('item_count');
if (!textarea || !itemCountInput) return false;
function updateIsbnLiveValidation() {
const isbnField = document.getElementById('isbn');
const statusEl = document.getElementById('isbn-scan-status');
if (!isbnField || !statusEl) return;
const itemCount = Math.max(1, parseInt(itemCountInput.value || '1', 10));
if (itemCount <= 1) return false;
const rawValue = isbnField.value.trim();
isbnField.classList.remove('code-valid', 'code-invalid');
const existing = textarea.value
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean);
if (existing.includes(scannedCode)) {
setCode4ScanStatus(`Code ${scannedCode} ist bereits eingetragen.`, true);
return true;
if (!rawValue) {
statusEl.textContent = 'Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.';
statusEl.style.color = '#666';
return;
}
if (existing.length >= itemCount) {
setCode4ScanStatus('Es sind bereits genug Einzelcodes für die gewählte Anzahl eingetragen.', true);
return true;
if (normalizeIsbnClient(rawValue)) {
isbnField.classList.add('code-valid');
statusEl.textContent = 'Gültiges ISBN-Format erkannt. Der Wert kann gespeichert werden.';
statusEl.style.color = '#666';
return;
}
existing.push(scannedCode);
textarea.value = existing.join('\n');
setCode4ScanStatus(`Code ${scannedCode} hinzugefügt (${existing.length}/${itemCount}).`);
return true;
isbnField.classList.add('code-invalid');
statusEl.textContent = 'Kein gültiges ISBN-10/13-Format erkannt. Der Wert wird trotzdem akzeptiert.';
statusEl.style.color = '#b00020';
}
function startCode4Scanner() {
@@ -1027,7 +1075,7 @@
rememberLastUsedCamera: true
});
code4ScannerRunning = true;
setCode4ScanStatus('Scanner läuft. Mehrere Codes nacheinander möglich.');
setCode4ScanStatus('Scanner läuft. Der Scan wird im Basis-Codefeld übernommen.');
code4ScannerInstance.render((decodedText) => {
const scannedCode = String(decodedText || '').trim();
@@ -1040,12 +1088,9 @@
code4LastScanned = scannedCode;
code4LastScannedAt = now;
const usedForIndividual = appendIndividualCode(scannedCode);
if (!usedForIndividual) {
codeField.value = scannedCode;
validateCodeField(codeField);
setCode4ScanStatus(`Code_4 gesetzt: ${scannedCode}`);
}
codeField.value = scannedCode;
validateCodeField(codeField);
setCode4ScanStatus(`Code_4 gesetzt: ${scannedCode}`);
}, () => {});
}
@@ -1094,21 +1139,23 @@
if (!scannedCode) return;
const normalizedIsbn = normalizeIsbnClient(scannedCode);
if (!normalizedIsbn) {
setIsbnScanStatus('Kein gültiger ISBN-Barcode erkannt. Bitte erneut scannen.', true);
return;
isbnField.value = normalizedIsbn || scannedCode;
updateIsbnLiveValidation();
if (normalizedIsbn) {
setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
} else {
setIsbnScanStatus('Kein gültiges ISBN-Format erkannt, der gescannte Wert bleibt aber im Feld.', true);
}
isbnField.value = normalizedIsbn;
setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
isbnScannerInstance.clear().catch(() => {});
scannerBox.style.display = 'none';
scanButton.textContent = 'ISBN scannen';
isbnScannerRunning = false;
// Automatically load book metadata after scan.
fetchBookInfo('upload');
if (normalizedIsbn) {
// Automatically load book metadata after a valid ISBN scan.
fetchBookInfo('upload');
}
}, () => {});
}
@@ -1231,7 +1278,7 @@
const isbn = normalizeIsbnClient(isbnField.value);
if (!isbn) {
alert('Bitte eine gültige ISBN-10 oder ISBN-13 eingeben.');
infoContainer.innerHTML = '<div class="error-message">Bitte geben Sie eine ISBN oder einen Barcode ein.</div>';
return;
}
@@ -1645,6 +1692,13 @@
if (scanIsbnBtn) {
scanIsbnBtn.addEventListener('click', startIsbnScanner);
}
const isbnField = document.getElementById('isbn');
if (isbnField) {
isbnField.addEventListener('input', updateIsbnLiveValidation);
isbnField.addEventListener('blur', updateIsbnLiveValidation);
updateIsbnLiveValidation();
}
// Setup add new location button
const addLocationBtn = document.getElementById('add-new-location-btn');
+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
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
Small debug script to check tenant-aware lookup for an appointment id.
Usage: ./tools/debug_get_appointment.py <appointment_id> [tenant]
"""
import sys
from bson.objectid import ObjectId
from Web.modules.database.settings import MongoClient, MONGODB_HOST, MONGODB_PORT, MONGODB_DB
import Web.modules.database.termine as termine
def main():
if len(sys.argv) < 2:
print("Usage: debug_get_appointment.py <appointment_id> [tenant]")
sys.exit(2)
aid = sys.argv[1]
tenant = sys.argv[2] if len(sys.argv) > 2 else None
if tenant:
print(f"Looking up appointment {aid} for tenant {tenant}")
else:
print(f"Looking up appointment {aid} for default tenant")
try:
# Use the module helper directly
item = termine.get_item(aid)
if item:
print('Found with termini.get_item:')
print(item)
else:
print('termin.get_item returned None')
# Try explicit MongoClient + tenant DB resolution
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
try:
from Web.tenant import TenantContext, get_tenant_db
if tenant:
ctx = TenantContext()
ctx.tenant_id = tenant
db = ctx.get_database(client)
else:
db = get_tenant_db(client) if 'get_tenant_db' in dir() else client[MONGODB_DB]
doc = db['appointments'].find_one({'_id': ObjectId(aid)})
print('Direct DB find_one returned:')
print(doc)
finally:
client.close()
except Exception as e:
print('Error during debug lookup:', e)
if __name__ == '__main__':
main()