Compare commits

...

17 Commits

Author SHA1 Message Date
github-actions[bot] 249a2bc2da chore: bump version to v0.8.17 2026-05-31 14:13:47 +00:00
Aiirondev_dev 7ab8f841d0 feat: Enhance tenant management and appointment deletion functionality 2026-05-31 16:13:11 +02:00
github-actions[bot] 43c544244c chore: bump version to v0.8.16 2026-05-31 13:47:10 +00:00
Aiirondev_dev 73e4407d7b feat: Implement upcoming appointments feature for users
- Added `remove_done` function to clean up finished appointments in the database.
- Introduced `get_upcoming_for_user` function to retrieve upcoming appointments for a specific user.
- Updated `backend_server.py` to include a new endpoint for fetching user-specific upcoming events.
- Modified `blueprint.py` to render upcoming events in the user interface.
- Enhanced `terminplan.html` to display a list of upcoming appointments with relevant details and action links.
- Updated configuration to enable the new appointment planning feature.
2026-05-31 15:46:22 +02:00
github-actions[bot] 94fc01c08a chore: bump version to v0.8.15 2026-05-30 12:55:19 +00:00
Aiirondev_dev 788ec1db62 feat: update terminology in calendar interface for clarity and consistency 2026-05-30 14:54:40 +02:00
github-actions[bot] 725584a104 chore: bump version to v0.8.14 2026-05-30 12:46:44 +00:00
Aiirondev_dev fbcc3b4522 feat: enhance calendar loading experience with skeleton UI 2026-05-30 14:46:22 +02:00
github-actions[bot] adc2bad3fb chore: bump version to v0.8.13 2026-05-30 12:31:16 +00:00
Aiirondev_dev f7be7d9bf0 feat: implement maintenance and not found error pages with appropriate messaging 2026-05-30 14:30:35 +02:00
github-actions[bot] 1bd2db41b4 chore: bump version to v0.8.12 2026-05-30 12:16:57 +00:00
Aiirondev_dev ac68beb80a feat: enhance appointment configuration with dynamic time frame generation and default time settings 2026-05-30 14:16:31 +02:00
github-actions[bot] ea6826b304 chore: bump version to v0.8.11 2026-05-30 12:08:25 +00:00
Aiirondev_dev d668e54042 feat: add calendar export functionality and option to create ICS file for appointments 2026-05-30 14:06:26 +02:00
Aiirondev_dev 3fd00aec30 feat: enhance calendar functionality with dayGrid and timeGrid plugins 2026-05-30 14:00:56 +02:00
github-actions[bot] ef2d1dfab0 chore: bump version to v0.8.10 2026-05-30 11:51:16 +00:00
Aiirondev_dev 1c5aa9beed feat: enhance appointment management with email service integration and calendar view options 2026-05-30 13:49:55 +02:00
14 changed files with 951 additions and 962 deletions
+1 -1
View File
@@ -1 +1 @@
v0.8.9
v0.8.17
+85 -5
View File
@@ -84,7 +84,7 @@ from Web.modules.terminplaner.blueprint import appoint_bp as terminplaner_bp
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
@@ -626,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'}:
@@ -4799,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():
"""
+77 -8
View File
@@ -22,6 +22,14 @@ 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}}
@@ -30,10 +38,10 @@ 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]
db = _get_tenant_db(client)
items = db['appointments']
item = {
@@ -45,6 +53,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()
@@ -67,7 +76,7 @@ def get_item(id):
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
items = db['appointments']
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
client.close()
@@ -89,11 +98,11 @@ def update(id,slots_used: list):
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
items = db['appointments']
update_data = {
'slots_booked': [slots_used],
'slots_booked': slots_used,
'LastUpdated': datetime.datetime.now()
}
@@ -123,7 +132,7 @@ def remove_slot(id, date_start_time, name):
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
items = db['appointments']
# Attempt to pull the exact element (stored as an array/tuple)
@@ -151,15 +160,75 @@ def remove(id):
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
items = db['appointments']
result = items.delete_one({'_id': ObjectId(id)})
client.close()
return result.modified_count > 0
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 []
+184 -13
View File
@@ -2,12 +2,40 @@
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()]
@@ -32,7 +60,77 @@ def _normalize_mail_list(mail):
return [entry.strip() for entry in mail.replace(';', ',').split(',') if entry.strip()]
return []
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="") -> str:
def _escape_ics_text(value):
return str(value or '').replace('\\', '\\\\').replace(';', '\\;').replace(',', '\\,').replace('\r\n', '\n').replace('\n', '\\n').replace('\r', '')
def _format_ics_date(date_value):
if isinstance(date_value, datetime.datetime):
return date_value.strftime('%Y%m%dT%H%M%SZ')
if isinstance(date_value, datetime.date):
return date_value.strftime('%Y%m%d')
return ''
def build_calendar_ics(appointment_id: str) -> str | None:
item = termin.get_item(appointment_id)
if not item:
return None
date_start = item.get('date_start')
date_end = item.get('date_end')
time_span = item.get('time_span', []) or []
creator = item.get('user', 'Terminplaner')
note = item.get('note', '') or ''
tenant_id = _current_tenant_id()
try:
link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True)
except Exception:
host = _resolve_public_base_url()
link = host + "/terminplaner/client/" + str(appointment_id)
if tenant_id:
link += f"?tenant={tenant_id}"
try:
start_date = datetime.datetime.strptime(str(date_start), '%Y-%m-%d').date()
end_date = datetime.datetime.strptime(str(date_end), '%Y-%m-%d').date()
except Exception:
return None
uid = f"terminplaner-{appointment_id}@invario.eu"
created_at = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
summary = f"Terminplan für {creator}"
description_lines = [
f"Buchungslink: {link}",
f"Zeitraum: {date_start} bis {date_end}",
]
if time_span:
description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span))
if note:
description_lines.append('Notiz: ' + str(note))
ics_lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Inventarsystem//Terminplaner//DE',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
f'UID:{uid}',
f'DTSTAMP:{created_at}',
f'SUMMARY:{_escape_ics_text(summary)}',
f'DESCRIPTION:{_escape_ics_text(chr(10).join(description_lines))}',
f'URL:{_escape_ics_text(link)}',
f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}',
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + timedelta(days=1))}',
'END:VEVENT',
'END:VCALENDAR',
'',
]
return '\r\n'.join(ics_lines)
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False) -> dict:
"""
Generates a link for the executive to send to his clients to book a time Slot
@@ -48,21 +146,41 @@ def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
"""
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)
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()
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_str
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}"
if normalized_mail:
mail_service.send(normalized_mail, subject, note_link)
return 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):
@@ -215,4 +333,57 @@ def get_available_user(id):
- 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)
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
+98 -7
View File
@@ -1,6 +1,9 @@
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__)
@@ -12,6 +15,25 @@ def _require_module_enabled():
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):
"""
@@ -23,8 +45,7 @@ def client(appointment_id):
available = appointment_service.get_available(appointment_id)
if not available:
flash('Der Termin wurde nicht gefunden.', 'error')
return redirect(url_for('terminplan'))
return _appointment_not_found_response()
if request.method == 'POST':
start_daytime = request.form.get('start_day_time')
@@ -49,8 +70,37 @@ def client(appointment_id):
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():
"""
@@ -72,16 +122,51 @@ def configure():
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)
return render_template(
'termin_configure.html',
school_periods=cfg.SCHOOL_PERIODS,
generated_link=None,
email_service_enabled=cfg.EMAIL_ENABLED,
)
link = appointment_service.new(start, end, time, slots_amount, slot_lenght, session["username"], mail, note)
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=link)
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)
return render_template(
'termin_configure.html',
school_periods=cfg.SCHOOL_PERIODS,
generated_link=None,
calendar_link=None,
add_to_calendar=False,
email_service_enabled=cfg.EMAIL_ENABLED,
)
@appoint_bp.route('/calendar/<appointment_id>.ics', methods=['GET'])
def calendar_export(appointment_id):
guard = _require_module_enabled()
if guard:
return guard
ics_content = appointment_service.build_calendar_ics(appointment_id)
if not ics_content:
return _appointment_not_found_response()
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{appointment_id}.ics'
return response
@appoint_bp.route('/')
def main():
@@ -89,8 +174,14 @@ def main():
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=session.get('username', ''),
current_user=current_user,
upcoming_events=upcoming_events,
tenant_id=tenant_id,
)
-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 %}
+2 -2
View File
@@ -44,7 +44,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">
<form method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" class="vstack gap-3">
<div>
<label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label>
<input type="text" id="start_day_time" name="start_day_time" class="form-control form-control-lg" placeholder="2026-05-29 10:30" required>
@@ -56,7 +56,7 @@
</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>
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main', tenant=tenant_id) }}">Zur Übersicht</a>
</div>
</form>
</div>
+191 -5
View File
@@ -24,10 +24,33 @@
</div>
</div>
<div>
<label for="time_frame" class="form-label fw-semibold">Zeitfenster</label>
<textarea id="time_frame" name="time_frame" class="form-control" rows="5" placeholder="Beispiele:\nMontag 08:00-12:00\nDienstag 08:00-12:00" required></textarea>
<div class="form-text">Jede Zeile kann ein eigenes Zeitfenster enthalten.</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">
@@ -51,6 +74,12 @@
<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>
@@ -62,11 +91,168 @@
{% 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">Teilen Sie diesen Link mit den Teilnehmenden:</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
+46 -4
View File
@@ -14,8 +14,8 @@
<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>
<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>
@@ -27,7 +27,7 @@
<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>
<a class="btn btn-primary w-100" href="{{ url_for('terminplan', tenant=tenant_id) }}">Zum Kalender</a>
</div>
</div>
</div>
@@ -37,7 +37,7 @@
<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>
<a class="btn btn-outline-primary w-100" href="{{ url_for('terminplaner.configure', tenant=tenant_id) }}">Konfiguration öffnen</a>
</div>
</div>
</div>
@@ -58,6 +58,48 @@
<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>
+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 %}
+18
View File
@@ -473,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:
@@ -531,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
+3
View File
@@ -53,6 +53,9 @@
"inventory": {
"enabled": true
},
"terminplan": {
"enabled": true
},
"library": {
"enabled": true
},