Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43c544244c | |||
| 73e4407d7b | |||
| 94fc01c08a | |||
| 788ec1db62 | |||
| 725584a104 | |||
| fbcc3b4522 | |||
| adc2bad3fb | |||
| f7be7d9bf0 | |||
| 1bd2db41b4 | |||
| ac68beb80a | |||
| ea6826b304 | |||
| d668e54042 | |||
| 3fd00aec30 | |||
| ef2d1dfab0 | |||
| 1c5aa9beed | |||
| a7d3863fde | |||
| ac0e60fadb | |||
| cbeed68147 | |||
| 715d932dba | |||
| 606e1822d1 |
+1
-1
@@ -1 +1 @@
|
|||||||
v0.8.7
|
v0.8.16
|
||||||
|
|||||||
+1
-1
@@ -32,4 +32,4 @@ RUN if [ "$NUITKA_BUILD" = "1" ]; then \
|
|||||||
WORKDIR /app/Web
|
WORKDIR /app/Web
|
||||||
EXPOSE 8000
|
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", "-"]
|
||||||
|
|||||||
+85
-5
@@ -84,7 +84,7 @@ from Web.modules.terminplaner.blueprint import appoint_bp as terminplaner_bp
|
|||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
import Web.modules.database.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
from Web.modules.database.settings import MongoClient
|
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
|
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/'):
|
if request.is_json or request.path.startswith('/api/'):
|
||||||
return jsonify({'error': 'Internal server error', 'status': 500}), 500
|
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:
|
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:
|
except Exception:
|
||||||
return 'Internal Server Error', 500
|
return 'Internal Server Error', 500
|
||||||
|
|
||||||
return redirect(url_for('login'))
|
|
||||||
|
|
||||||
|
|
||||||
def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'):
|
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'}:
|
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()
|
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')
|
@app.route('/api/booking_conflicts')
|
||||||
def api_booking_conflicts():
|
def api_booking_conflicts():
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ from Web.modules.database.settings import MongoClient
|
|||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
import datetime
|
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):
|
def _active_record_query(extra_query=None):
|
||||||
"""Build a query that excludes logically deleted records."""
|
"""Build a query that excludes logically deleted records."""
|
||||||
base_query = {'Deleted': {'$ne': True}}
|
base_query = {'Deleted': {'$ne': True}}
|
||||||
@@ -30,10 +38,10 @@ def _active_record_query(extra_query=None):
|
|||||||
return base_query
|
return base_query
|
||||||
|
|
||||||
|
|
||||||
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str=""):
|
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:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = _get_tenant_db(client)
|
||||||
items = db['appointments']
|
items = db['appointments']
|
||||||
|
|
||||||
item = {
|
item = {
|
||||||
@@ -45,6 +53,7 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
|
|||||||
'user': user,
|
'user': user,
|
||||||
'mail': mail,
|
'mail': mail,
|
||||||
'note': note,
|
'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
|
'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(),
|
'Created': datetime.datetime.now(),
|
||||||
'LastUpdated': datetime.datetime.now()
|
'LastUpdated': datetime.datetime.now()
|
||||||
@@ -67,7 +76,7 @@ def get_item(id):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = _get_tenant_db(client)
|
||||||
items = db['appointments']
|
items = db['appointments']
|
||||||
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
|
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
|
||||||
client.close()
|
client.close()
|
||||||
@@ -89,11 +98,11 @@ def update(id,slots_used: list):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = _get_tenant_db(client)
|
||||||
items = db['appointments']
|
items = db['appointments']
|
||||||
|
|
||||||
update_data = {
|
update_data = {
|
||||||
'slots_booked': [slots_used],
|
'slots_booked': slots_used,
|
||||||
'LastUpdated': datetime.datetime.now()
|
'LastUpdated': datetime.datetime.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +132,7 @@ def remove_slot(id, date_start_time, name):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = _get_tenant_db(client)
|
||||||
items = db['appointments']
|
items = db['appointments']
|
||||||
|
|
||||||
# Attempt to pull the exact element (stored as an array/tuple)
|
# Attempt to pull the exact element (stored as an array/tuple)
|
||||||
@@ -151,7 +160,7 @@ def remove(id):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = client[cfg.MONGODB_DB]
|
db = _get_tenant_db(client)
|
||||||
items = db['appointments']
|
items = db['appointments']
|
||||||
|
|
||||||
result = items.delete_one({'_id': ObjectId(id)})
|
result = items.delete_one({'_id': ObjectId(id)})
|
||||||
@@ -162,4 +171,62 @@ def remove(id):
|
|||||||
print(f"Error removing appointment: {e}")
|
print(f"Error removing appointment: {e}")
|
||||||
return False
|
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')
|
||||||
|
|
||||||
|
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'])})
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
return result.modified_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 []
|
||||||
|
|
||||||
|
|
||||||
@@ -2,12 +2,28 @@
|
|||||||
Class for all funktions of the executive -> Lehrer
|
Class for all funktions of the executive -> Lehrer
|
||||||
"""
|
"""
|
||||||
import datetime
|
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.emailservice.email as mail_service
|
||||||
import Web.modules.database.termine as termin
|
import Web.modules.database.termine as termin
|
||||||
import Web.modules.database.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
from Web.tenant import get_tenant_context
|
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 _normalize_time_span(time_span):
|
def _normalize_time_span(time_span):
|
||||||
if isinstance(time_span, list):
|
if isinstance(time_span, list):
|
||||||
return [str(entry).strip() for entry in time_span if str(entry).strip()]
|
return [str(entry).strip() for entry in time_span if str(entry).strip()]
|
||||||
@@ -32,7 +48,74 @@ def _normalize_mail_list(mail):
|
|||||||
return [entry.strip() for entry in mail.replace(';', ',').split(',') if entry.strip()]
|
return [entry.strip() for entry in mail.replace(';', ',').split(',') if entry.strip()]
|
||||||
return []
|
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 ''
|
||||||
|
try:
|
||||||
|
link = url_for('terminplaner.client', appointment_id=str(appointment_id), _external=True)
|
||||||
|
except Exception:
|
||||||
|
host = _resolve_public_base_url()
|
||||||
|
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
|
Generates a link for the executive to send to his clients to book a time Slot
|
||||||
|
|
||||||
@@ -48,21 +131,36 @@ def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
|
|||||||
"""
|
"""
|
||||||
normalized_time_span = _normalize_time_span(time_span)
|
normalized_time_span = _normalize_time_span(time_span)
|
||||||
normalized_mail = _normalize_mail_list(mail)
|
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)
|
id_str = str(id)
|
||||||
|
|
||||||
tenant_context = get_tenant_context()
|
try:
|
||||||
subdomain = ''
|
link = url_for('terminplaner.client', appointment_id=id_str, _external=True)
|
||||||
if tenant_context:
|
except Exception:
|
||||||
subdomain = getattr(tenant_context, 'subdomain', '') or getattr(tenant_context, 'tenant_id', '') or ''
|
host = _resolve_public_base_url()
|
||||||
|
link = host + "/terminplaner/client/" + id_str
|
||||||
host = f"https://{subdomain}.invario.eu" if subdomain else "invario.eu"
|
|
||||||
link = host + "/terminplaner/client" + "?" + "client_id=" + id_str
|
|
||||||
subject = f"Terminanfrage von {user}"
|
subject = f"Terminanfrage von {user}"
|
||||||
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
|
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
|
||||||
if normalized_mail:
|
calendar_link = None
|
||||||
mail_service.send(normalized_mail, subject, note_link)
|
if calendar_enabled:
|
||||||
return link
|
try:
|
||||||
|
calendar_link = url_for('terminplaner.calendar_export', appointment_id=id_str, _external=True)
|
||||||
|
except Exception:
|
||||||
|
host = _resolve_public_base_url()
|
||||||
|
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):
|
def book_slot(id, date_start_time, name):
|
||||||
@@ -216,3 +314,51 @@ def get_available_user(id):
|
|||||||
second day Time frame, third etc.), slot lenght, (bookedslots -> list)]
|
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()
|
||||||
|
|
||||||
|
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, _external=True)
|
||||||
|
except Exception:
|
||||||
|
link = host + '/terminplaner/client/' + appointment_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
calendar_link = url_for('terminplaner.calendar_export', appointment_id=appointment_id, _external=True)
|
||||||
|
except Exception:
|
||||||
|
calendar_link = host + '/terminplaner/calendar/' + appointment_id + '.ics'
|
||||||
|
|
||||||
|
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
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from flask import Blueprint, render_template, request, session, url_for, redirect, flash
|
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.terminplaner.backend_server as appointment_service
|
||||||
import Web.modules.database.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
|
|
||||||
@@ -12,6 +13,14 @@ def _require_module_enabled():
|
|||||||
return redirect(url_for('home'))
|
return redirect(url_for('home'))
|
||||||
return None
|
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
|
||||||
|
|
||||||
@appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
|
@appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
|
||||||
def client(appointment_id):
|
def client(appointment_id):
|
||||||
"""
|
"""
|
||||||
@@ -23,8 +32,7 @@ def client(appointment_id):
|
|||||||
|
|
||||||
available = appointment_service.get_available(appointment_id)
|
available = appointment_service.get_available(appointment_id)
|
||||||
if not available:
|
if not available:
|
||||||
flash('Der Termin wurde nicht gefunden.', 'error')
|
return _appointment_not_found_response()
|
||||||
return redirect(url_for('terminplan'))
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
start_daytime = request.form.get('start_day_time')
|
start_daytime = request.form.get('start_day_time')
|
||||||
@@ -72,16 +80,51 @@ def configure():
|
|||||||
slot_lenght = request.form.get('slot_lenght')
|
slot_lenght = request.form.get('slot_lenght')
|
||||||
mail = request.form.get('mail', '')
|
mail = request.form.get('mail', '')
|
||||||
note = request.form.get('note', '')
|
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:
|
if not start or not end or not time or not slots_amount or not slot_lenght:
|
||||||
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
|
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')
|
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":
|
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('/')
|
@appoint_bp.route('/')
|
||||||
def main():
|
def main():
|
||||||
@@ -89,8 +132,12 @@ def main():
|
|||||||
if guard:
|
if guard:
|
||||||
return guard
|
return guard
|
||||||
|
|
||||||
|
current_user = session.get('username', '')
|
||||||
|
upcoming_events = appointment_service.get_user_upcoming_events(current_user) if current_user else []
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'terminplaner.html',
|
'terminplaner.html',
|
||||||
school_periods=cfg.SCHOOL_PERIODS,
|
school_periods=cfg.SCHOOL_PERIODS,
|
||||||
current_user=session.get('username', ''),
|
current_user=current_user,
|
||||||
|
upcoming_events=upcoming_events,
|
||||||
)
|
)
|
||||||
@@ -1279,7 +1279,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if library_module_enabled and CURRENT_MODULE == 'library' %}
|
{% if library_module_enabled and CURRENT_MODULE == 'library' %}
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark" id="libraryNavbar">
|
<nav class="navbar navbar-expand-lg navbar-dark" id="libraryNavbar">
|
||||||
@@ -1391,7 +1390,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark" id="loginNavbar">
|
<nav class="navbar navbar-expand-lg navbar-dark" id="loginNavbar">
|
||||||
|
|||||||
@@ -269,23 +269,6 @@
|
|||||||
line-height: 1;
|
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 styles */
|
||||||
.search-container {
|
.search-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1006,23 +989,6 @@
|
|||||||
</div>`;
|
</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 = `
|
card.innerHTML = `
|
||||||
<div class="card-content" data-item-id="${item._id}">
|
<div class="card-content" data-item-id="${item._id}">
|
||||||
<h3 class="item-col-name">${item.Name}</h3>
|
<h3 class="item-col-name">${item.Name}</h3>
|
||||||
@@ -1037,7 +1003,6 @@
|
|||||||
${imagesHtml}
|
${imagesHtml}
|
||||||
</div>
|
</div>
|
||||||
${borrowerBadge}
|
${borrowerBadge}
|
||||||
${appointmentBadge}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
${isAvailableForBorrow && !item.BlockedNow ?
|
${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
|
// Build modal content HTML
|
||||||
modalContent.innerHTML = `
|
modalContent.innerHTML = `
|
||||||
<h2>${item.Name}</h2>
|
<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>
|
<button type="button" class="bookmark-btn" id="modal-bookmark-btn" title="Merken">${(window.currentFavorites||new Set()).has(item._id) ? '★' : '☆'}</button>
|
||||||
</div>
|
</div>
|
||||||
${borrowerInfoHtml}
|
${borrowerInfoHtml}
|
||||||
${appointmentInfoHtml}
|
|
||||||
|
|
||||||
<div class="modal-image-container">
|
<div class="modal-image-container">
|
||||||
${imagesHtml}
|
${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) {
|
function formatDateForInput(dateString) {
|
||||||
const options = { year: 'numeric', month: '2-digit', day: '2-digit' };
|
const options = { year: 'numeric', month: '2-digit', day: '2-digit' };
|
||||||
const date = new Date(dateString);
|
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-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; }
|
.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 Styling (text formatting) */
|
||||||
.modal-details { margin: 20px 0; }
|
.modal-details { margin: 20px 0; }
|
||||||
.detail-group { display: flex; margin-bottom: 10px; align-items: flex-start; }
|
.detail-group { display: flex; margin-bottom: 10px; align-items: flex-start; }
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -24,10 +24,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="border rounded-4 p-3 p-md-4 bg-light-subtle">
|
||||||
<label for="time_frame" class="form-label fw-semibold">Zeitfenster</label>
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-2">
|
||||||
<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>
|
||||||
<div class="form-text">Jede Zeile kann ein eigenes Zeitfenster enthalten.</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>
|
||||||
|
|
||||||
<div class="row g-3">
|
<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>
|
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
|
||||||
</div>
|
</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">
|
<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>
|
<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>
|
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main') }}">Zur Übersicht</a>
|
||||||
@@ -62,11 +91,168 @@
|
|||||||
{% if generated_link %}
|
{% if generated_link %}
|
||||||
<div class="alert alert-success mt-4 shadow-sm rounded-4">
|
<div class="alert alert-success mt-4 shadow-sm rounded-4">
|
||||||
<div class="fw-bold mb-1">Buchungslink erstellt</div>
|
<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>
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
+190
-790
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,45 @@
|
|||||||
<h2 class="h5 fw-bold mb-2">Angemeldet als {{ current_user }}</h2>
|
<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>
|
<p class="mb-0 text-muted">Sie können Termine anlegen, den Kalender prüfen und Buchungslinks verteilen.</p>
|
||||||
</div>
|
</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 %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted">Keine kommenden Termine gefunden.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -53,6 +53,9 @@
|
|||||||
"inventory": {
|
"inventory": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
"terminplan": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ services:
|
|||||||
PYTHON_VERSION: "3.13"
|
PYTHON_VERSION: "3.13"
|
||||||
OPTIMIZATION_LEVEL: 2
|
OPTIMIZATION_LEVEL: 2
|
||||||
working_dir: /app/Web
|
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
|
restart: unless-stopped
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
|||||||
@@ -572,7 +572,7 @@ write_runtime_compose_override() {
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
working_dir: /app/Web
|
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}
|
image: ${APP_IMAGE_VALUE}
|
||||||
build: null
|
build: null
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
Reference in New Issue
Block a user