Compare commits

...

19 Commits

Author SHA1 Message Date
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
19 changed files with 1208 additions and 320 deletions
+1 -1
View File
@@ -1 +1 @@
v0.8.4
v0.8.12
+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", "-"]
+7
View File
@@ -78,6 +78,7 @@ from Web.modules.inventarsystem.data_protection import (
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__))
@@ -109,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-------------------------------------------------------"""
@@ -1259,6 +1261,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,
@@ -7563,6 +7566,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')
+12 -1
View File
@@ -97,6 +97,9 @@ DEFAULTS = {
'inventory': {
'enabled': True
},
'terminplan': {
'enabled': True
},
'library': {
'enabled': False
},
@@ -272,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
@@ -290,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)
+57 -1
View File
@@ -30,7 +30,7 @@ def _active_record_query(extra_query=None):
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=""):
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 = client[cfg.MONGODB_DB]
@@ -45,6 +45,7 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, 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()
@@ -108,3 +109,58 @@ def update(id,slots_used: list):
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 = client[cfg.MONGODB_DB]
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 = client[cfg.MONGODB_DB]
items = db['appointments']
result = items.delete_one({'_id': ObjectId(id)})
client.close()
return result.modified_count > 0
except Exception as e:
print(f"Error removing appointment: {e}")
return False
+315
View File
@@ -0,0 +1,315 @@
"""
Class for all funktions of the executive -> Lehrer
"""
import datetime
from datetime import timedelta
from flask import url_for
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 _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 ''
try:
link = url_for('terminplaner.client', appointment_id=str(appointment_id), _external=True)
except Exception:
tenant_context = get_tenant_context()
subdomain = ''
if tenant_context:
subdomain = getattr(tenant_context, 'subdomain', '') or getattr(tenant_context, 'tenant_id', '') or ''
host = f"https://{subdomain}.invario.eu" if subdomain else "https://invario.eu"
link = host + "/terminplaner/client/" + str(appointment_id)
try:
start_date = datetime.datetime.strptime(str(date_start), '%Y-%m-%d').date()
end_date = datetime.datetime.strptime(str(date_end), '%Y-%m-%d').date()
except Exception:
return None
uid = f"terminplaner-{appointment_id}@invario.eu"
created_at = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
summary = f"Terminplan für {creator}"
description_lines = [
f"Buchungslink: {link}",
f"Zeitraum: {date_start} bis {date_end}",
]
if time_span:
description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span))
if note:
description_lines.append('Notiz: ' + str(note))
ics_lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Inventarsystem//Terminplaner//DE',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
f'UID:{uid}',
f'DTSTAMP:{created_at}',
f'SUMMARY:{_escape_ics_text(summary)}',
f'DESCRIPTION:{_escape_ics_text(chr(10).join(description_lines))}',
f'URL:{_escape_ics_text(link)}',
f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}',
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + timedelta(days=1))}',
'END:VEVENT',
'END:VCALENDAR',
'',
]
return '\r\n'.join(ics_lines)
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False) -> dict:
"""
Generates a link for the executive to send to his clients to book a time Slot
Input:
- date_start: start of the time frae area
- date_end: end of the time frame area
- time_span: Time window for the days as a list [(first day Time Frame), (second day Time frame), (third etc.)]
- slots: amount of slots that are available
- slot_lenght: the lenght of a slot in minutes
Output:
- link: The link for the user to send to the clients
"""
normalized_time_span = _normalize_time_span(time_span)
normalized_mail = _normalize_mail_list(mail)
id = termin.add(date_start, date_end, normalized_time_span, slots, slot_lenght, user, normalized_mail, note, calendar_enabled=calendar_enabled)
id_str = str(id)
tenant_context = get_tenant_context()
subdomain = ''
if tenant_context:
subdomain = getattr(tenant_context, 'subdomain', '') or getattr(tenant_context, 'tenant_id', '') or ''
try:
link = url_for('terminplaner.client', appointment_id=id_str, _external=True)
except Exception:
host = f"https://{subdomain}.invario.eu" if subdomain else "https://invario.eu"
link = host + "/terminplaner/client/" + id_str
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, _external=True)
except Exception:
tenant_context = get_tenant_context()
subdomain = ''
if tenant_context:
subdomain = getattr(tenant_context, 'subdomain', '') or getattr(tenant_context, 'tenant_id', '') or ''
host = f"https://{subdomain}.invario.eu" if subdomain else "https://invario.eu"
calendar_link = host + "/terminplaner/calendar/" + id_str + ".ics"
email_body = note_link
if calendar_link:
email_body += f"\n\nKalendereintrag: {calendar_link}"
if normalized_mail and cfg.EMAIL_ENABLED:
mail_service.send(normalized_mail, subject, email_body)
return {
'appointment_id': id_str,
'link': link,
'calendar_link': calendar_link,
}
def book_slot(id, date_start_time, name):
"""
Updates slot for the booking per a id
Input:
- id: the id is the id you get from the
- date_start_time: the date of the booking that was selected with date and time
- name: name that the client gave himself
Output:
- bool: if worked or not
"""
try:
# Retrieve the current appointment
item = termin.get_item(id)
if not item:
return False
slots = item.get('slots_booked', []) or []
if not isinstance(slots, list):
slots = []
capacity = int(item.get('slots', 0) or 0)
if capacity and len(slots) >= capacity:
return False
for existing in slots:
if isinstance(existing, (list, tuple)) and len(existing) >= 2:
if existing[0] == date_start_time and existing[1] == name:
return False
# Append the new booking as a tuple (start_time, name)
slots.append((date_start_time, name))
# Update the appointment in the database
success = termin.update(id, slots)
return bool(success)
except Exception as e:
print(f"Error booking slot: {e}")
return False
def remove_slot(id, date_start_time, name):
"""
Remove a booked slot for an appointment.
Returns True if the removal succeeded, False otherwise.
"""
try:
# Prefer DB-level remove if available
if hasattr(termin, 'remove_slot'):
removed = termin.remove_slot(id, date_start_time, name)
if removed:
return True
# Fallback: fetch, filter, and replace the slot list
item = termin.get_item(id)
if not item:
return False
slots = item.get('slots_booked', []) or []
new_slots = []
for s in slots:
try:
# s may be list or tuple like [start_time, name]
if isinstance(s, (list, tuple)) and len(s) >= 2 and s[0] == date_start_time and s[1] == name:
continue
except Exception:
pass
new_slots.append(s)
success = termin.update(id, new_slots)
return bool(success)
except Exception as e:
print(f"Error removing slot: {e}")
return False
def remove_appointment(id):
"""
Remove an entire appointment by id.
"""
try:
return bool(termin.remove(id))
except Exception as e:
print(f"Error removing appointment: {e}")
return False
def get_available(id):
"""
Gets the available time slots -> more over it returns the time frame and the allready booked slots also the lenght.
And checks if there are slots left.
Input:
- id: id of the appointment
Output:
- dict: all the needet information -> [Start_date, End_date, (first day Time Frame,
second day Time frame, third etc.), slot lenght, (bookedslots -> list)]
"""
try:
termin_range = termin.get_item(id)
if not termin_range:
return {}
date_start = termin_range.get('date_start')
date_end = termin_range.get('date_end')
time_span = termin_range.get('time_span', [])
slot_lenght = termin_range.get('slot_lenght')
total_slots = termin_range.get('slots', 0)
booked = termin_range.get('slots_booked', []) or []
# Normalize booked entries to dicts for easier consumption
normalized = []
for s in booked:
if isinstance(s, (list, tuple)) and len(s) >= 2:
normalized.append({'start': s[0], 'name': s[1]})
elif isinstance(s, dict):
normalized.append(s)
else:
normalized.append({'value': s})
slots_used = len(normalized)
slots_left = max(0, total_slots - slots_used)
return {
'date_start': date_start,
'date_end': date_end,
'time_span': time_span,
'slot_lenght': slot_lenght,
'slots_total': total_slots,
'slots_booked': normalized,
'slots_left': slots_left,
}
except Exception as e:
print(f"Error getting available slots: {e}")
return {}
def get_available_user(id):
"""
Gets the available time slots -> more over it returns the time frame and the allready booked slots also the lenght.
And checks if there are slots left.
Input:
- id: id of the appointment
Output:
- dict: all the needet information -> [Start_date, End_date, (first day Time Frame,
second day Time frame, third etc.), slot lenght, (bookedslots -> list)]
"""
return get_available(id)
+133
View File
@@ -0,0 +1,133 @@
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
# 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
@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:
flash('Der Termin wurde nicht gefunden.', 'error')
return redirect(url_for('terminplan'))
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', ''),
)
@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:
flash('Der Termin wurde nicht gefunden.', 'error')
return redirect(url_for('terminplaner.configure'))
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{appointment_id}.ics'
return response
@appoint_bp.route('/')
def main():
guard = _require_module_enabled()
if guard:
return guard
return render_template(
'terminplaner.html',
school_periods=cfg.SCHOOL_PERIODS,
current_user=session.get('username', ''),
)
-51
View File
@@ -1,51 +0,0 @@
"""
Class for all funktions of the executive -> Lehrer
"""
import datetime
import emailservice.email as mail_service
import Web.modules.database.termine as termin
import Web.modules.database.settings as cfg
from tenant import get_tenant_context
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="") -> str:
"""
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
"""
id = termin.add(date_start, date_end, time_span, slots, slot_lenght, user, mail, note)
tenant_context = get_tenant_context()
subdomain = ''
if tenant_context:
subdomain = getattr(tenant_context, 'subdomain', '') or getattr(tenant_context, 'tenant_id', '') or ''
host = f"https://{subdomain}.invario.eu" if subdomain else "invario.eu"
link = host + "/terminplaner/client" + "?" + "client_id=" + id
subject = f"Terminanfrage von {user}"
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
mail_service.send(mail, subject, note_link)
return 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
"""
termin.update()
-1
View File
@@ -1,5 +1,4 @@
flask
bson
werkzeug
gunicorn
pymongo
+71 -13
View File
@@ -129,6 +129,14 @@
--module-brand-icon: "📚";
}
html[data-module="terminplan"] {
--module-primary-color: #0f4c5c;
--module-primary-light: #16697a;
--module-accent-color: #2ec4b6;
--module-accent-light: rgba(46, 196, 182, 0.1);
--module-brand-icon: "📅";
}
:root[data-theme="dark"] {
--module-primary-color: #1a252f;
--module-primary-light: #2c3e50;
@@ -145,6 +153,12 @@
--module-accent-color: #8e44ad;
}
html[data-theme="dark"][data-module="terminplan"] {
--module-primary-color: #082f35;
--module-primary-light: #0f4c5c;
--module-accent-color: #21b6a8;
}
/* Global styles */
body {
font-family: "Manrope", "Segoe UI", "Noto Sans", sans-serif;
@@ -1078,29 +1092,73 @@
<span class="module-label">Module:</span>
<div class="module-tabs">
{% if inventory_module_enabled %}
<a href="{{ url_for('home') }}"
class="module-tab {% if CURRENT_MODULE == 'inventory' %}active{% endif %}"
id="inventoryModule"
data-module="inventory">
<a href="{{ url_for('home') }}" class="module-tab {% if CURRENT_MODULE == 'inventory' %}active{% endif %}" id="inventoryModule" data-module="inventory">
📦 Inventarsystem
</a>
{% endif %}
{% if inventory_module_enabled and library_module_enabled %}
{% if inventory_module_enabled and terminplan_module_enabled %}
<div class="module-separator"></div>
{% endif %}
{% if terminplan_module_enabled %}
<a href="{{ url_for('terminplaner.main') }}" class="module-tab {% if CURRENT_MODULE == 'terminplan' %}active{% endif %}" id="terminplanModule" data-module="terminplan">
📅 Terminplaner
</a>
{% endif %}
{% if (inventory_module_enabled or terminplan_module_enabled) and library_module_enabled %}
<div class="module-separator"></div>
{% endif %}
{% if library_module_enabled %}
<a href="{{ url_for('library_view') }}"
class="module-tab {% if CURRENT_MODULE == 'library' %}active{% endif %}"
id="libraryModule"
data-module="library">
<a href="{{ url_for('library_view') }}" class="module-tab {% if CURRENT_MODULE == 'library' %}active{% endif %}" id="libraryModule" data-module="library">
📚 Bibliothek
</a>
{% endif %}
</div>
</div>
{% endif %}
{% if 'username' in session %}
{% if CURRENT_MODULE != 'library' and inventory_module_enabled %}
{% if terminplan_module_enabled and CURRENT_MODULE == 'terminplan' %}
<nav class="navbar navbar-expand-lg navbar-dark" id="terminplanNavbar">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('terminplaner.main') }}" data-icon="📅">Terminplaner</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#terminplanNavContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="terminplanNavContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="terminplanNavList">
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('terminplaner.main') %}nav-active{% endif %}" href="{{ url_for('terminplaner.main') }}" data-tutorial-tip="Die Terminplaner-Übersicht bündelt die wichtigsten Aktionen.">Übersicht</a>
</li>
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('terminplan') %}nav-active{% endif %}" href="{{ url_for('terminplan') }}" data-tutorial-tip="Hier sehen Sie den Kalender mit den bestehenden Terminen.">Kalender</a>
</li>
{% if current_permissions.pages.get('terminplan', True) and current_permissions.actions.get('can_insert', True) %}
<li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('terminplaner.configure') %}nav-active{% endif %}" href="{{ url_for('terminplaner.configure') }}" data-tutorial-tip="Neuen Terminplan erstellen und an Ihr Team versenden.">Neue Planung</a>
</li>
{% endif %}
<li class="nav-item" data-nav-fixed="true">
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
<span class="theme-icon-light" style="display: none;">☀️</span>
<span class="theme-icon-dark" style="display: none;">🌙</span>
</button>
</li>
<li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="termMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">Mehr Optionen</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="termMoreDropdown">
{% if current_permissions.pages.get('home', True) %}
<li><a class="dropdown-item" href="{{ url_for('home') }}">Inventarsystem</a></li>
{% endif %}
{% if current_permissions.pages.get('library_view', True) and library_module_enabled %}
<li><a class="dropdown-item" href="{{ url_for('library_view') }}">Bibliothek</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% elif CURRENT_MODULE != 'library' and inventory_module_enabled %}
<nav class="navbar navbar-expand-lg navbar-dark" id="inventoryNavbar">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('home') }}" data-icon="📦">Inventarsystem</a>
@@ -1220,7 +1278,7 @@
</div>
</div>
</nav>
{% endif %}
{% endif %}
{% if library_module_enabled and CURRENT_MODULE == 'library' %}
<nav class="navbar navbar-expand-lg navbar-dark" id="libraryNavbar">
-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; }
+69
View File
@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}Termin buchen - Inventarsystem{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-12 col-xl-10">
<div class="row g-4">
<div class="col-12 col-lg-5">
<div class="card border-0 shadow-lg rounded-4 h-100">
<div class="card-body p-4 p-md-5">
<p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p>
<h1 class="h3 fw-bold mb-3">Termin buchen</h1>
<p class="text-muted mb-4">Wählen Sie einen freien Zeitpunkt und tragen Sie Ihren Namen ein. Der Termin wird anschließend im Plan gespeichert.</p>
<div class="p-3 rounded-3 bg-light mb-3">
<div class="fw-semibold">Zeitraum</div>
<div>{{ available.date_start }} bis {{ available.date_end }}</div>
</div>
<div class="p-3 rounded-3 bg-light mb-3">
<div class="fw-semibold">Verfügbare Slots</div>
<div>{{ available.slots_left }} von {{ available.slots_total }} frei</div>
</div>
<div class="p-3 rounded-3 bg-light mb-3">
<div class="fw-semibold">Slot-Länge</div>
<div>{{ available.slot_lenght }} Minuten</div>
</div>
{% if available.slots_booked %}
<div class="p-3 rounded-3 bg-light">
<div class="fw-semibold mb-2">Bereits gebucht</div>
<ul class="mb-0 small">
{% for booking in available.slots_booked %}
<li>{{ booking.start }}{% if booking.name %} - {{ booking.name }}{% endif %}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-12 col-lg-7">
<div class="card border-0 shadow-lg rounded-4 h-100">
<div class="card-body p-4 p-md-5 bg-white">
<h2 class="h4 fw-bold mb-4">Buchung absenden</h2>
<form method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id) }}" class="vstack gap-3">
<div>
<label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label>
<input type="text" id="start_day_time" name="start_day_time" class="form-control form-control-lg" placeholder="2026-05-29 10:30" required>
<div class="form-text">Tragen Sie Datum und Uhrzeit im Format YYYY-MM-DD HH:MM ein, sofern kein Kalenderfeld genutzt wird.</div>
</div>
<div>
<label for="client_name" class="form-label fw-semibold">Ihr Name</label>
<input type="text" id="client_name" name="client_name" class="form-control form-control-lg" placeholder="Vor- und Nachname" required>
</div>
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
<button type="submit" class="btn btn-primary btn-lg">Termin buchen</button>
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main') }}">Zur Übersicht</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+258
View File
@@ -0,0 +1,258 @@
{% extends "base.html" %}
{% block title %}Terminplan konfigurieren - Inventarsystem{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-8">
<div class="card border-0 shadow-lg rounded-4 overflow-hidden">
<div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);">
<h1 class="h3 mb-1 fw-bold">Terminplan konfigurieren</h1>
<p class="mb-0 opacity-75">Erstellen Sie einen neuen Plan und verschicken Sie anschließend den Buchungslink.</p>
</div>
<div class="card-body p-4 p-md-5 bg-white">
<form method="post" action="{{ url_for('terminplaner.configure') }}" class="vstack gap-3">
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="start_date" class="form-label fw-semibold">Startdatum</label>
<input type="date" id="start_date" name="start_date" class="form-control form-control-lg" required>
</div>
<div class="col-12 col-md-6">
<label for="end_date" class="form-label fw-semibold">Enddatum</label>
<input type="date" id="end_date" name="end_date" class="form-control form-control-lg" required>
</div>
</div>
<div class="border rounded-4 p-3 p-md-4 bg-light-subtle">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-2">
<div>
<label class="form-label fw-semibold mb-0">Zeitfenster pro Tag</label>
<div class="form-text mb-0">Sobald Start- und Enddatum gesetzt sind, wird für jeden Tag automatisch ein eigener Eintrag erzeugt.</div>
</div>
<button type="button" class="btn btn-outline-primary btn-sm" id="build_time_frame">Tage aus Zeitraum erzeugen</button>
</div>
<div class="row g-3 mb-3">
<div class="col-12 col-md-6 col-xl-4">
<label for="default_day_start" class="form-label fw-semibold">Standard-Startzeit</label>
<input type="time" id="default_day_start" class="form-control" value="08:00">
</div>
<div class="col-12 col-md-6 col-xl-4">
<label for="default_day_end" class="form-label fw-semibold">Standard-Endzeit</label>
<input type="time" id="default_day_end" class="form-control" value="12:00">
</div>
</div>
<div id="time_frame_days" class="vstack gap-2"></div>
<div class="mt-3">
<label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster</label>
<textarea id="time_frame" name="time_frame" class="form-control font-monospace" rows="5" placeholder="Wird automatisch aus den Tagen erzeugt" required></textarea>
<div class="form-text">Das Formular überträgt die erzeugten Tageszeilen an das Backend. Sie können die Liste hier bei Bedarf noch anpassen.</div>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="slots_amounts" class="form-label fw-semibold">Anzahl Slots</label>
<input type="number" id="slots_amounts" name="slots_amounts" class="form-control" min="1" value="1" required>
</div>
<div class="col-12 col-md-6">
<label for="slot_lenght" class="form-label fw-semibold">Slot-Länge in Minuten</label>
<input type="number" id="slot_lenght" name="slot_lenght" class="form-control" min="1" value="45" required>
</div>
</div>
<div>
<label for="mail" class="form-label fw-semibold">Empfänger-Mails</label>
<input type="text" id="mail" name="mail" class="form-control" placeholder="adresse1@schule.de, adresse2@schule.de">
</div>
<div>
<label for="note" class="form-label fw-semibold">Notiz</label>
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" {% if add_to_calendar %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="add_to_calendar">Kalendereintrag für den Nutzer erzeugen</label>
<div class="form-text">Erzeugt zusätzlich eine .ics-Datei, die in gängige Kalender importiert werden kann.</div>
</div>
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
<button type="submit" class="btn btn-primary btn-lg">Terminplan erzeugen</button>
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main') }}">Zur Übersicht</a>
</div>
</form>
</div>
</div>
{% if generated_link %}
<div class="alert alert-success mt-4 shadow-sm rounded-4">
<div class="fw-bold mb-1">Buchungslink erstellt</div>
<div class="mb-2">
{% if email_service_enabled %}
Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail.
{% else %}
Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden.
{% endif %}
</div>
<a href="{{ generated_link }}" class="d-inline-block text-break">{{ generated_link }}</a>
{% if calendar_link %}
<div class="mt-3">
<div class="fw-semibold mb-1">Kalendereintrag</div>
<a href="{{ calendar_link }}" class="btn btn-outline-primary btn-sm">.ics herunterladen</a>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
<script>
(function () {
const startDateInput = document.getElementById('start_date');
const endDateInput = document.getElementById('end_date');
const buildButton = document.getElementById('build_time_frame');
const daysContainer = document.getElementById('time_frame_days');
const timeFrameTextarea = document.getElementById('time_frame');
const defaultStartInput = document.getElementById('default_day_start');
const defaultEndInput = document.getElementById('default_day_end');
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea || !defaultStartInput || !defaultEndInput) {
return;
}
const weekdayFormatter = new Intl.DateTimeFormat('de-DE', {
weekday: 'long',
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
function parseDate(value) {
if (!value) {
return null;
}
const parts = value.split('-').map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) {
return null;
}
return new Date(parts[0], parts[1] - 1, parts[2]);
}
function formatDateForRow(date) {
return weekdayFormatter.format(date);
}
function formatDateForValue(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function addDays(date, days) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
function syncTextarea() {
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
const lines = rows.map(function (row) {
const dayValue = row.getAttribute('data-day-value') || '';
const startTime = row.querySelector('[data-time-start]')?.value || '';
const endTime = row.querySelector('[data-time-end]')?.value || '';
return `${dayValue} ${startTime}-${endTime}`.trim();
}).filter(Boolean);
timeFrameTextarea.value = lines.join('\n');
}
function renderRows() {
const startDate = parseDate(startDateInput.value);
const endDate = parseDate(endDateInput.value);
if (!startDate || !endDate) {
daysContainer.innerHTML = '<div class="text-muted small">Bitte Start- und Enddatum auswählen, damit die Tageszeilen erzeugt werden.</div>';
timeFrameTextarea.value = '';
endDateInput.setCustomValidity('');
return;
}
if (endDate < startDate) {
daysContainer.innerHTML = '<div class="text-danger small">Das Enddatum muss nach dem Startdatum liegen.</div>';
timeFrameTextarea.value = '';
endDateInput.setCustomValidity('Das Enddatum muss nach dem Startdatum liegen.');
return;
}
endDateInput.setCustomValidity('');
const existingValues = new Map();
Array.from(daysContainer.querySelectorAll('[data-day-row]')).forEach(function (row) {
const dayValue = row.getAttribute('data-day-value');
const startTime = row.querySelector('[data-time-start]')?.value || '';
const endTime = row.querySelector('[data-time-end]')?.value || '';
if (dayValue) {
existingValues.set(dayValue, { startTime, endTime });
}
});
const defaultStart = defaultStartInput.value || '08:00';
const defaultEnd = defaultEndInput.value || '12:00';
const rows = [];
for (let current = startDate; current <= endDate; current = addDays(current, 1)) {
const dayValue = formatDateForValue(current);
const preserved = existingValues.get(dayValue) || {};
const rowStart = preserved.startTime || defaultStart;
const rowEnd = preserved.endTime || defaultEnd;
rows.push(`
<div class="border rounded-3 bg-white p-3" data-day-row data-day-value="${dayValue}">
<div class="row g-2 align-items-end">
<div class="col-12 col-lg-5">
<label class="form-label fw-semibold mb-1">${formatDateForRow(current)}</label>
<div class="text-muted small">${dayValue}</div>
</div>
<div class="col-6 col-lg-3">
<label class="form-label mb-1">Von</label>
<input type="time" class="form-control" value="${rowStart}" data-time-start>
</div>
<div class="col-6 col-lg-3">
<label class="form-label mb-1">Bis</label>
<input type="time" class="form-control" value="${rowEnd}" data-time-end>
</div>
</div>
</div>
`);
}
daysContainer.innerHTML = rows.join('');
daysContainer.querySelectorAll('input[type="time"]').forEach(function (input) {
input.addEventListener('input', syncTextarea);
input.addEventListener('change', syncTextarea);
});
syncTextarea();
}
buildButton.addEventListener('click', renderRows);
startDateInput.addEventListener('input', renderRows);
startDateInput.addEventListener('change', renderRows);
endDateInput.addEventListener('input', renderRows);
endDateInput.addEventListener('change', renderRows);
defaultStartInput.addEventListener('change', syncTextarea);
defaultEndInput.addEventListener('change', syncTextarea);
if (startDateInput.value && endDateInput.value) {
renderRows();
}
})();
</script>
{% endblock %}
+62 -1
View File
@@ -19,6 +19,11 @@
<span id="current-day-display"></span>
<button id="next-day">Nächster Tag</button>
<button id="today">Heute</button>
<div class="calendar-view-switch" role="group" aria-label="Kalenderansicht wechseln">
<button type="button" class="calendar-view-btn active" data-calendar-view="timeGridDay">Tag</button>
<button type="button" class="calendar-view-btn" data-calendar-view="timeGridWeek">Woche</button>
<button type="button" class="calendar-view-btn" data-calendar-view="dayGridMonth">Monat</button>
</div>
<button id="new-booking" class="primary-button">Neue Reservierung</button>
</div>
<div class="calendar-legend">
@@ -167,6 +172,8 @@
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/daygrid/main.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/timegrid/main.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/locales/de.js"></script>
<script>
@@ -179,6 +186,7 @@ document.addEventListener('DOMContentLoaded', function() {
let currentEventId = null;
let calendar;
let showCompletedBookings = false;
let activeCalendarView = 'timeGridDay';
// School period times from server configuration
let schoolPeriods;
@@ -246,6 +254,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Initialize calendar
calendar = new FullCalendar.Calendar(calendarEl, {
plugins: [FullCalendar.dayGridPlugin, FullCalendar.timeGridPlugin],
initialView: 'timeGridDay',
locale: 'de',
headerToolbar: false, // We're using our custom header
@@ -422,14 +431,29 @@ document.addEventListener('DOMContentLoaded', function() {
eventClick: function(info) {
showEventDetails(info.event);
},
datesSet: function(dateInfo) {
datesSet: function() {
updateCurrentDayDisplay();
}
});
function setCalendarView(viewName) {
activeCalendarView = viewName;
calendar.changeView(viewName);
document.querySelectorAll('[data-calendar-view]').forEach(button => {
button.classList.toggle('active', button.dataset.calendarView === viewName);
});
}
document.querySelectorAll('[data-calendar-view]').forEach(button => {
button.addEventListener('click', function() {
setCalendarView(this.dataset.calendarView);
});
});
// Render calendar
try {
calendar.render();
setCalendarView(activeCalendarView);
setTimeout(() => {
calendar.updateSize();
@@ -1256,6 +1280,32 @@ document.addEventListener('DOMContentLoaded', function() {
white-space: nowrap;
}
.calendar-view-switch {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px;
border: 1px solid #d7dce3;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
}
.calendar-view-btn {
border: 0;
border-radius: 999px;
background: transparent;
color: var(--ui-text);
font-weight: 600;
padding: 7px 14px;
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.calendar-view-btn.active {
background: var(--module-accent-color);
color: #fff;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.12);
}
.calendar-actions .primary-button {
background-color: #3788d8;
color: white;
@@ -1281,6 +1331,17 @@ document.addEventListener('DOMContentLoaded', function() {
width: 100%;
margin: 5px 0;
}
.calendar-view-switch {
width: 100%;
justify-content: space-between;
flex-wrap: wrap;
}
.calendar-view-btn {
flex: 1 1 30%;
min-width: 88px;
}
.calendar-actions .primary-button {
margin-left: 0;
+65
View File
@@ -0,0 +1,65 @@
{% 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') }}">Neue Planung</a>
<a class="btn btn-outline-light btn-lg fw-semibold" href="{{ url_for('terminplan') }}">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') }}">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') }}">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>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+1
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'},
}
+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