Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbeed68147 | |||
| 715d932dba | |||
| 606e1822d1 | |||
| c0ce80d223 | |||
| 96debef4f4 | |||
| c6c57e4b53 | |||
| 3dc9ecfe5b | |||
| 8e20580552 | |||
| aa47325e80 | |||
| 539cf2b21c | |||
| 4ae0d7f00e | |||
| 0b09fe489e | |||
| a98f3751e9 | |||
| d722b5a774 | |||
| 0edbef3edd | |||
| d325fa57e8 | |||
| 8a6a842e1a | |||
| b4d61e27f4 | |||
| 318e57475f | |||
| 6a72bd5d82 | |||
| 74fa0d8a68 |
+1
-1
@@ -1 +1 @@
|
||||
v0.8.0
|
||||
v0.8.8
|
||||
|
||||
+1
-1
@@ -32,4 +32,4 @@ RUN if [ "$NUITKA_BUILD" = "1" ]; then \
|
||||
WORKDIR /app/Web
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
|
||||
+320
-75
@@ -74,9 +74,11 @@ import mimetypes
|
||||
import subprocess
|
||||
from Web.modules.inventarsystem.data_protection import (
|
||||
decrypt_document_fields,
|
||||
decrypt_text,
|
||||
encrypt_document_fields,
|
||||
encrypt_soft_deleted_media_pack,
|
||||
)
|
||||
from Web.modules.terminplaner.blueprint import appoint_bp as terminplaner_bp
|
||||
|
||||
# Set base directory and centralized settings
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -108,6 +110,7 @@ app.config['SESSION_COOKIE_SECURE'] = cfg.SSL_ENABLED if os.getenv('INVENTAR_SES
|
||||
app.config['PREFERRED_URL_SCHEME'] = 'https' if app.config['SESSION_COOKIE_SECURE'] else 'http'
|
||||
# app.config['QR_CODE_FOLDER'] = cfg.QR_CODE_FOLDER # QR Code storage deactivated
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
|
||||
app.register_blueprint(terminplaner_bp, url_prefix='/terminplaner')
|
||||
|
||||
"""--------------------------------------------------------------Path Init-------------------------------------------------------"""
|
||||
|
||||
@@ -154,6 +157,119 @@ def _decrypt_student_card_doc(card_doc):
|
||||
return card_doc
|
||||
return decrypt_document_fields(card_doc, STUDENT_CARD_ENCRYPTED_FIELDS)
|
||||
|
||||
|
||||
def _parse_and_increment_class(klass, max_class=13, graduate_label=''):
|
||||
"""Parse a class string like '7A' or '10' and increment the numeric part.
|
||||
If the numeric part would exceed max_class, return graduate_label (or empty).
|
||||
Leaves non-numeric / unparsable values unchanged.
|
||||
"""
|
||||
if not klass:
|
||||
return klass
|
||||
s = str(klass).strip()
|
||||
m = re.match(r"^\s*(\d{1,2})(\D.*)?$", s)
|
||||
if not m:
|
||||
return s
|
||||
try:
|
||||
num = int(m.group(1))
|
||||
except Exception:
|
||||
return s
|
||||
suffix = m.group(2) or ''
|
||||
new_num = num + 1
|
||||
if max_class is not None and new_num > int(max_class):
|
||||
return graduate_label or ''
|
||||
return f"{new_num}{suffix}"
|
||||
|
||||
|
||||
def rollover_student_card_classes(dry_run=False, *, max_class=None, graduate_label=''):
|
||||
"""Increment class years on all student cards.
|
||||
|
||||
Returns a summary dict with counts.
|
||||
"""
|
||||
client = None
|
||||
updated = 0
|
||||
examined = 0
|
||||
failures = 0
|
||||
try:
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
col = db['student_cards']
|
||||
now = datetime.datetime.now()
|
||||
|
||||
cursor = list(col.find({}, {'Klasse': 1}))
|
||||
for doc in cursor:
|
||||
examined += 1
|
||||
try:
|
||||
# Decrypt to work on plain class string
|
||||
plain = dict(doc)
|
||||
plain = _decrypt_student_card_doc(plain)
|
||||
orig = (plain.get('Klasse') or '').strip()
|
||||
new_class = _parse_and_increment_class(orig, max_class=max_class, graduate_label=graduate_label)
|
||||
if new_class != orig:
|
||||
if dry_run:
|
||||
updated += 1
|
||||
continue
|
||||
payload = {'Klasse': new_class}
|
||||
encrypted = encrypt_document_fields(payload, STUDENT_CARD_ENCRYPTED_FIELDS)
|
||||
update_doc = {'Klasse': encrypted.get('Klasse'), 'Aktualisiert': now}
|
||||
col.update_one({'_id': doc.get('_id')}, {'$set': update_doc})
|
||||
updated += 1
|
||||
except Exception:
|
||||
failures += 1
|
||||
app.logger.exception('Failed to process student card rollover for %s', doc.get('_id'))
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
summary = {'examined': examined, 'updated': updated, 'failures': failures, 'dry_run': bool(dry_run)}
|
||||
try:
|
||||
_append_audit_event_standalone('student_cards_rollover', summary)
|
||||
except Exception:
|
||||
app.logger.warning('Audit write failed for student_cards_rollover')
|
||||
return summary
|
||||
|
||||
|
||||
# Admin route to trigger rollover manually
|
||||
@app.route('/admin/trigger_school_year_rollover', methods=['POST'])
|
||||
def admin_trigger_school_year_rollover():
|
||||
if 'username' not in session:
|
||||
return jsonify({'ok': False, 'message': 'Nicht angemeldet.'}), 401
|
||||
if not us.check_admin(session['username']):
|
||||
return jsonify({'ok': False, 'message': 'Administratorrechte erforderlich.'}), 403
|
||||
|
||||
max_class = int(os.getenv('INVENTAR_SCHOOL_MAX_CLASS', '13'))
|
||||
graduate_label = os.getenv('INVENTAR_SCHOOL_GRADUATE_LABEL', '')
|
||||
dry = request.args.get('dry', '0') in ('1', 'true', 'yes')
|
||||
summary = rollover_student_card_classes(dry_run=dry, max_class=max_class, graduate_label=graduate_label)
|
||||
return jsonify({'ok': True, 'summary': summary}), 200
|
||||
|
||||
|
||||
# Schedule annual rollover job using APScheduler (configurable via env)
|
||||
try:
|
||||
if cfg.SCHEDULER_ENABLED:
|
||||
_rollover_month = getattr(cfg, 'SCHOOL_ROLLOVER_MONTH', 9)
|
||||
_rollover_day = getattr(cfg, 'SCHOOL_ROLLOVER_DAY', 1)
|
||||
_rollover_hour = getattr(cfg, 'SCHOOL_ROLLOVER_HOUR', 3)
|
||||
_rollover_minute = getattr(cfg, 'SCHOOL_ROLLOVER_MIN', 0)
|
||||
_rollover_max_class = getattr(cfg, 'SCHOOL_ROLLOVER_MAX_CLASS', 13)
|
||||
_rollover_grad_label = getattr(cfg, 'SCHOOL_ROLLOVER_GRADUATE_LABEL', '')
|
||||
|
||||
_scheduler = BackgroundScheduler()
|
||||
# Use a cron-style yearly job on the configured month/day
|
||||
_scheduler.add_job(
|
||||
func=lambda: rollover_student_card_classes(dry_run=False, max_class=_rollover_max_class, graduate_label=_rollover_grad_label),
|
||||
trigger='cron',
|
||||
month=_rollover_month,
|
||||
day=_rollover_day,
|
||||
hour=_rollover_hour,
|
||||
minute=_rollover_minute,
|
||||
id='school_year_rollover',
|
||||
replace_existing=True
|
||||
)
|
||||
_scheduler.start()
|
||||
app.logger.info('Scheduled annual school year rollover: %s-%s %s:%s', _rollover_month, _rollover_day, _rollover_hour, _rollover_minute)
|
||||
except Exception as e:
|
||||
app.logger.warning('Failed to schedule school year rollover: %s', e)
|
||||
|
||||
# Thumbnail sizes
|
||||
THUMBNAIL_SIZE = cfg.THUMBNAIL_SIZE
|
||||
PREVIEW_SIZE = cfg.PREVIEW_SIZE
|
||||
@@ -1145,6 +1261,7 @@ def inject_version():
|
||||
'CURRENT_MODULE': current_module,
|
||||
'school_periods': SCHOOL_PERIODS,
|
||||
'inventory_module_enabled': cfg.MODULES.is_enabled('inventory'),
|
||||
'terminplan_module_enabled': cfg.MODULES.is_enabled('terminplan'),
|
||||
'library_module_enabled': cfg.MODULES.is_enabled('library'),
|
||||
'student_cards_module_enabled': cfg.MODULES.is_enabled('student_cards'),
|
||||
'is_admin': is_admin,
|
||||
@@ -3053,6 +3170,7 @@ def api_library_items():
|
||||
|
||||
query = {
|
||||
'ItemType': {'$in': ['book', 'cd', 'dvd', 'media']},
|
||||
'IsGroupedSubItem': {'$ne': True},
|
||||
'Deleted': {'$ne': True}
|
||||
}
|
||||
|
||||
@@ -3078,7 +3196,39 @@ def api_library_items():
|
||||
raw_items = list(items_db.find(query, projection).sort([('Name', 1), ('_id', 1)]).skip(offset).limit(limit))
|
||||
|
||||
# Build maps for grouping by parent-child relationship (grouped sub-items)
|
||||
all_ids = [str(itm.get('_id')) for itm in raw_items if itm.get('_id')]
|
||||
parent_ids_list = [str(itm.get('_id')) for itm in raw_items if itm.get('_id')]
|
||||
children_by_parent = {}
|
||||
child_items = []
|
||||
if parent_ids_list:
|
||||
child_projection = {
|
||||
'Name': 1,
|
||||
'Autor': 1,
|
||||
'Author': 1,
|
||||
'ISBN': 1,
|
||||
'Code_4': 1,
|
||||
'Code4': 1,
|
||||
'ItemType': 1,
|
||||
'Verfuegbar': 1,
|
||||
'Condition': 1,
|
||||
'HasDamage': 1,
|
||||
'User': 1,
|
||||
'Ort': 1,
|
||||
'Beschreibung': 1,
|
||||
'Image': 1,
|
||||
'ParentItemId': 1,
|
||||
}
|
||||
child_items = list(items_db.find({
|
||||
'ParentItemId': {'$in': parent_ids_list},
|
||||
'IsGroupedSubItem': True,
|
||||
'Deleted': {'$ne': True}
|
||||
}, child_projection))
|
||||
for child in child_items:
|
||||
parent_id = str(child.get('ParentItemId') or '')
|
||||
if not parent_id:
|
||||
continue
|
||||
children_by_parent.setdefault(parent_id, []).append(child)
|
||||
|
||||
all_ids = parent_ids_list + [str(child.get('_id')) for child in child_items if child.get('_id')]
|
||||
active_records = []
|
||||
if all_ids:
|
||||
active_records = list(ausleihungen_db.find({'Item': {'$in': all_ids}, 'Status': 'active'}, {'Item': 1, 'User': 1}))
|
||||
@@ -3093,26 +3243,10 @@ def api_library_items():
|
||||
if item_id not in active_user_by_item:
|
||||
active_user_by_item[item_id] = rec.get('User', '')
|
||||
|
||||
# Organize children under their parent (ParentItemId) and prepare parent list
|
||||
items_by_id = {}
|
||||
children_by_parent = {}
|
||||
parent_ids = set()
|
||||
for itm in raw_items:
|
||||
iid = str(itm.get('_id'))
|
||||
items_by_id[iid] = itm
|
||||
parent = str(itm.get('ParentItemId') or '')
|
||||
if parent:
|
||||
children_by_parent.setdefault(parent, []).append(itm)
|
||||
else:
|
||||
parent_ids.add(iid)
|
||||
|
||||
# Build aggregated list: for each parent id, include parent + children as one entry
|
||||
# Build aggregated list: iterate ordered parent list and include children like inventory module
|
||||
aggregated = []
|
||||
processed = set()
|
||||
for pid in list(parent_ids):
|
||||
parent = items_by_id.get(pid)
|
||||
if not parent:
|
||||
continue
|
||||
for parent in raw_items:
|
||||
pid = str(parent.get('_id'))
|
||||
children = children_by_parent.get(pid, [])
|
||||
|
||||
# Compute grouped counts and availability
|
||||
@@ -3137,22 +3271,33 @@ def api_library_items():
|
||||
has_damage = bool(doc.get('HasDamage')) or condition_value == 'destroyed'
|
||||
has_active_borrow = any(str(unit.get('_id')) in active_item_ids for unit in grouped_units)
|
||||
|
||||
if has_damage and not has_active_borrow:
|
||||
# Prefer 'available' when any grouped unit is available
|
||||
if has_damage and len(available_units) == 0:
|
||||
doc['LibraryDisplayStatus'] = 'damaged'
|
||||
elif len(available_units) > 0:
|
||||
doc['LibraryDisplayStatus'] = 'available'
|
||||
elif has_active_borrow or doc.get('Verfuegbar') is False:
|
||||
doc['LibraryDisplayStatus'] = 'borrowed'
|
||||
else:
|
||||
doc['LibraryDisplayStatus'] = 'available'
|
||||
|
||||
# Determine borrower: prefer any active borrow on group units, fallback to parent.User
|
||||
borrower = active_user_by_item.get(str(parent.get('_id'))) or ''
|
||||
# Determine borrower: prefer any active borrow on grouped units, fallback to parent.User
|
||||
borrower = ''
|
||||
for unit in grouped_units:
|
||||
u_id = str(unit.get('_id'))
|
||||
if active_user_by_item.get(u_id):
|
||||
borrower = active_user_by_item.get(u_id)
|
||||
break
|
||||
if not borrower:
|
||||
for unit in grouped_units:
|
||||
u_id = str(unit.get('_id'))
|
||||
if active_user_by_item.get(u_id):
|
||||
borrower = active_user_by_item.get(u_id)
|
||||
break
|
||||
doc['BorrowedBy'] = borrower or doc.get('User', '')
|
||||
borrower = doc.get('User', '')
|
||||
# Decrypt if value is encrypted (decrypt_text returns original if not encrypted)
|
||||
try:
|
||||
borrower = decrypt_text(borrower) if borrower else ''
|
||||
except Exception:
|
||||
# Fallback: keep original string if decryption fails
|
||||
borrower = borrower or ''
|
||||
|
||||
doc['BorrowedBy'] = borrower
|
||||
|
||||
doc['GroupedDisplayCount'] = 1 + len(children)
|
||||
doc['Quantity'] = doc['GroupedDisplayCount']
|
||||
@@ -3161,40 +3306,6 @@ def api_library_items():
|
||||
doc['GroupedAllCodes'] = grouped_all_codes
|
||||
|
||||
aggregated.append(doc)
|
||||
processed.add(pid)
|
||||
for c in children:
|
||||
processed.add(str(c.get('_id')))
|
||||
|
||||
# Include any remaining items (orphans or standalones not parented)
|
||||
for itm in raw_items:
|
||||
iid = str(itm.get('_id'))
|
||||
if iid in processed:
|
||||
continue
|
||||
doc = dict(itm)
|
||||
doc['_id'] = iid
|
||||
if doc.get('Code4') in (None, '') and doc.get('Code_4') not in (None, ''):
|
||||
doc['Code4'] = doc.get('Code_4')
|
||||
|
||||
condition_value = str(doc.get('Condition', '')).strip().lower()
|
||||
has_damage = bool(doc.get('HasDamage')) or condition_value == 'destroyed'
|
||||
has_active_borrow = iid in active_item_ids
|
||||
|
||||
if has_damage and not has_active_borrow:
|
||||
doc['LibraryDisplayStatus'] = 'damaged'
|
||||
elif has_active_borrow or doc.get('Verfuegbar') is False:
|
||||
doc['LibraryDisplayStatus'] = 'borrowed'
|
||||
else:
|
||||
doc['LibraryDisplayStatus'] = 'available'
|
||||
|
||||
doc['BorrowedBy'] = active_user_by_item.get(iid) or doc.get('User', '')
|
||||
# Single item: grouped count = 1
|
||||
doc['GroupedDisplayCount'] = 1
|
||||
doc['Quantity'] = 1
|
||||
doc['AvailableGroupedCount'] = 1 if doc.get('Verfuegbar', True) else 0
|
||||
doc['GroupedAvailableUnits'] = [{'id': iid, 'code': doc.get('Code_4') or doc.get('Code4') or '', 'label': f"{doc.get('Code_4') or doc.get('Code4') or '-'} ({doc.get('Name','')})"}] if doc.get('Verfuegbar', True) else []
|
||||
doc['GroupedAllCodes'] = [doc.get('Code_4') or doc.get('Code4') or '']
|
||||
|
||||
aggregated.append(doc)
|
||||
|
||||
client.close()
|
||||
|
||||
@@ -3384,28 +3495,82 @@ def api_item_detail(item_id):
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
ausleihungen_col = db['ausleihungen']
|
||||
items_col = db['items']
|
||||
|
||||
active_borrow = ausleihungen_col.find_one(
|
||||
{'Item': str(item.get('_id')), 'Status': 'active'},
|
||||
{'User': 1}
|
||||
)
|
||||
client.close()
|
||||
# Determine parent/group id (if this is a child, use its ParentItemId)
|
||||
parent_id = str(item.get('ParentItemId') or str(item.get('_id')))
|
||||
|
||||
# Load all group units (parent + children)
|
||||
group_units_cursor = items_col.find({
|
||||
'$or': [
|
||||
{'_id': ObjectId(parent_id)},
|
||||
{'ParentItemId': parent_id}
|
||||
],
|
||||
'Deleted': {'$ne': True}
|
||||
})
|
||||
group_units = list(group_units_cursor)
|
||||
if not group_units:
|
||||
# Fallback: just use the single item
|
||||
group_units = [item]
|
||||
|
||||
group_unit_ids = [str(u.get('_id')) for u in group_units if u.get('_id')]
|
||||
|
||||
# Fetch all borrow records for the whole group (active + history)
|
||||
borrow_records = list(ausleihungen_col.find({'Item': {'$in': group_unit_ids}}).sort('Start', -1))
|
||||
|
||||
# Compute availability and damage state across group
|
||||
available_units = [u for u in group_units if u.get('Verfuegbar', True)]
|
||||
condition_value = str(item.get('Condition', '')).strip().lower()
|
||||
has_damage = bool(item.get('HasDamage')) or condition_value == 'destroyed'
|
||||
if has_damage and not active_borrow:
|
||||
|
||||
if has_damage and len(available_units) == 0:
|
||||
status_label = 'Defekt/Zerstört'
|
||||
elif item.get('Verfuegbar') is False or active_borrow:
|
||||
elif len(available_units) > 0:
|
||||
status_label = 'Verfügbar'
|
||||
elif any(r.get('Status') == 'active' for r in borrow_records):
|
||||
status_label = 'Ausgeliehen'
|
||||
else:
|
||||
status_label = 'Verfügbar'
|
||||
|
||||
# Prefer the most recent active borrower across the group
|
||||
borrower_value = ''
|
||||
if active_borrow:
|
||||
borrower_value = active_borrow.get('User', '')
|
||||
elif item.get('User'):
|
||||
borrower_value = item.get('User')
|
||||
|
||||
for rec in borrow_records:
|
||||
if rec.get('Status') == 'active' and rec.get('User'):
|
||||
try:
|
||||
borrower_value = decrypt_text(rec.get('User'))
|
||||
except Exception:
|
||||
borrower_value = rec.get('User')
|
||||
break
|
||||
if not borrower_value and item.get('User'):
|
||||
try:
|
||||
borrower_value = decrypt_text(item.get('User'))
|
||||
except Exception:
|
||||
borrower_value = item.get('User')
|
||||
|
||||
# Helper to format datetimes
|
||||
def fmt_dt(dt):
|
||||
try:
|
||||
return dt.strftime('%d.%m.%Y %H:%M') if dt else ''
|
||||
except Exception:
|
||||
return str(dt) if dt else ''
|
||||
|
||||
# Build HTML for borrow records (if any)
|
||||
borrows_html = ''
|
||||
if borrow_records:
|
||||
rows = []
|
||||
for rec in borrow_records:
|
||||
user_raw = rec.get('User') or ''
|
||||
try:
|
||||
user = decrypt_text(user_raw) if user_raw else ''
|
||||
except Exception:
|
||||
user = user_raw
|
||||
status = rec.get('Status', '')
|
||||
start = fmt_dt(rec.get('Start'))
|
||||
end = fmt_dt(rec.get('End'))
|
||||
notes = html.escape(str(rec.get('Notes') or ''))
|
||||
rows.append(f"<li><strong>{html.escape(user or '-')}</strong> — {html.escape(status)} — {html.escape(start)} → {html.escape(end)}{(' — ' + notes) if notes else ''}</li>")
|
||||
borrows_html = f"<h3>Ausleihhistorie</h3><ul>{''.join(rows)}</ul>"
|
||||
|
||||
# Basic detail HTML
|
||||
detail_html = f"""
|
||||
<h2>{html.escape(item.get('Name', 'Untitled'))}</h2>
|
||||
@@ -3414,7 +3579,10 @@ def api_item_detail(item_id):
|
||||
<p><strong>Beschreibung:</strong> {html.escape(item.get('Beschreibung', '-'))}</p>
|
||||
<p><strong>Status:</strong> {html.escape(status_label)}</p>
|
||||
{f'<p><strong>Ausgeliehen von:</strong> {html.escape(str(borrower_value))}</p>' if borrower_value and status_label == 'Ausgeliehen' else ''}
|
||||
{borrows_html}
|
||||
"""
|
||||
client.close()
|
||||
return detail_html, 200
|
||||
return detail_html, 200
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error fetching item detail: {e}")
|
||||
@@ -6035,6 +6203,79 @@ def delete_item(id):
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
|
||||
@app.route('/delete_library_item/<id>', methods=['POST'])
|
||||
def delete_library_item(id):
|
||||
"""
|
||||
Route to soft-delete library items (books/media) revisionssicher.
|
||||
Requires admin and library module enabled.
|
||||
"""
|
||||
if 'username' not in session:
|
||||
flash('Nicht angemeldet.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
if not us.check_admin(session['username']):
|
||||
flash('Administratorrechte erforderlich.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
if not cfg.MODULES.is_enabled('library'):
|
||||
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
# Verify item exists and is a library item
|
||||
try:
|
||||
item_doc = it.get_item(id)
|
||||
except Exception:
|
||||
item_doc = None
|
||||
|
||||
if not item_doc:
|
||||
flash('Element nicht gefunden.', 'error')
|
||||
return redirect(url_for('library_admin'))
|
||||
|
||||
if item_doc.get('ItemType') not in LIBRARY_ITEM_TYPES:
|
||||
flash('Element ist kein Bibliotheksmedium.', 'error')
|
||||
return redirect(url_for('library_admin'))
|
||||
|
||||
group_item_ids = it.get_group_item_ids(id)
|
||||
if not group_item_ids:
|
||||
flash('Element nicht gefunden.', 'error')
|
||||
return redirect(url_for('library_admin'))
|
||||
|
||||
client = None
|
||||
try:
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
result = _soft_delete_item_groups(db, [id], session.get('username', ''))
|
||||
success = bool(result.get('success'))
|
||||
soft_deleted = int(result.get('soft_deleted_items', 0))
|
||||
archived_files = int((result.get('archive') or {}).get('archived_files', 0))
|
||||
|
||||
try:
|
||||
_append_audit_event_standalone(
|
||||
event_type='library_item_deleted',
|
||||
payload={
|
||||
'root_item_id': id,
|
||||
'group_item_ids': result.get('group_item_ids', []),
|
||||
'soft_deleted_items': soft_deleted,
|
||||
'archived_files': archived_files,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
app.logger.warning('Audit write failed for library_item_deleted')
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error deleting library item {id}: {e}")
|
||||
success = False
|
||||
archived_files = 0
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
if success:
|
||||
flash(f'Bibliotheksmedium revisionssicher deaktiviert ({soft_deleted} Versionen, {archived_files} Mediendateien archiviert).', 'success')
|
||||
else:
|
||||
flash('Fehler beim Löschen des Bibliotheksmediums.', 'error')
|
||||
|
||||
return redirect(url_for('library_admin'))
|
||||
|
||||
|
||||
@app.route('/bulk_delete_items', methods=['POST'])
|
||||
def bulk_delete_items():
|
||||
"""Soft-delete multiple selected item groups in one request."""
|
||||
@@ -7325,6 +7566,10 @@ def terminplan():
|
||||
if 'username' not in session:
|
||||
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
if not cfg.MODULES.is_enabled('terminplan'):
|
||||
flash('Der Terminplaner ist deaktiviert.', 'info')
|
||||
return redirect(url_for('home'))
|
||||
|
||||
# Make sure the template exists
|
||||
template_path = os.path.join(BASE_DIR, 'templates', 'terminplan.html')
|
||||
|
||||
@@ -18,14 +18,6 @@ Collection Structure:
|
||||
- Optional fields: Images, Filter, Filter2, Filter3, Anschaffungsjahr, Anschaffungskosten, Code_4
|
||||
- Status fields: Verfuegbar, User (if currently borrowed)
|
||||
"""
|
||||
'''
|
||||
Copyright 2025-2026 AIIrondev
|
||||
|
||||
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
|
||||
See Legal/LICENSE for the full license text.
|
||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
'''
|
||||
from bson.objectid import ObjectId
|
||||
import datetime
|
||||
import Web.modules.database.settings as cfg
|
||||
|
||||
@@ -71,6 +71,15 @@ DEFAULTS = {
|
||||
'logo_path': '',
|
||||
'logo_thumb': '',
|
||||
'logo_thumb': '',
|
||||
# School-level rollover configuration for advancing class years
|
||||
'rollover': {
|
||||
'month': 9,
|
||||
'day': 1,
|
||||
'hour': 3,
|
||||
'minute': 0,
|
||||
'max_class': 13,
|
||||
'graduate_label': ''
|
||||
},
|
||||
},
|
||||
'schoolPeriods': {
|
||||
"1": {"start": "08:00", "end": "08:45", "label": "1. Stunde (08:00 - 08:45)"},
|
||||
@@ -88,6 +97,9 @@ DEFAULTS = {
|
||||
'inventory': {
|
||||
'enabled': True
|
||||
},
|
||||
'terminplan': {
|
||||
'enabled': True
|
||||
},
|
||||
'library': {
|
||||
'enabled': False
|
||||
},
|
||||
@@ -157,32 +169,18 @@ def _get_int_env(name, default):
|
||||
def get_version():
|
||||
# Prefer an explicit release marker if present (created by release process).
|
||||
project_root = os.path.abspath(os.path.join(BASE_DIR, '..', '..', '..'))
|
||||
release_file = os.path.join(project_root, '.release-version')
|
||||
release_file = os.path.join(project_root, '.docker-build.env')
|
||||
try:
|
||||
if os.path.isfile(release_file):
|
||||
with open(release_file, 'r', encoding='utf-8') as f:
|
||||
val = f.read().strip()
|
||||
if val:
|
||||
return val
|
||||
var = f.readline()
|
||||
for i in var:
|
||||
i = i.split(":")
|
||||
i.pop[0]
|
||||
return str(i)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to .docker-build.env (legacy behaviour)
|
||||
env_path = os.path.join(project_root, '.docker-build.env')
|
||||
try:
|
||||
with open(env_path, 'r', encoding='utf-8') as f:
|
||||
for l in f:
|
||||
if l.startswith('INVENTAR_APP_IMAGE='):
|
||||
return l.split(':', 1)[1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Final fallback: use config.json value or packaged default
|
||||
try:
|
||||
return _get(_conf, ['ver'], DEFAULTS.get('version', '0.0.0'))
|
||||
except Exception:
|
||||
return DEFAULTS.get('version', '0.0.0')
|
||||
|
||||
# Expose settings
|
||||
APP_VERSION = get_version()
|
||||
DEBUG = _get_bool_env('INVENTAR_DEBUG', _get(_conf, ['dbg'], DEFAULTS['debug']))
|
||||
@@ -221,10 +219,30 @@ SSL_ENABLED = _get(_conf, ['ssl', 'enabled'], DEFAULTS['ssl']['enabled'])
|
||||
SSL_CERT = _get(_conf, ['ssl', 'cert'], DEFAULTS['ssl']['cert'])
|
||||
SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
|
||||
|
||||
# Email settings
|
||||
EMAIL_ENABLED = _get(_conf, ['email', 'enabled'], False)
|
||||
EMAIL_SMTP_HOST = _get(_conf, ['email', 'smtp_host'], 'smtp.gmail.com')
|
||||
EMAIL_SMTP_PORT = int(_get(_conf, ['email', 'smtp_port'], 587))
|
||||
EMAIL_USE_TLS = bool(_get(_conf, ['email', 'use_tls'], True))
|
||||
EMAIL_USERNAME = _get(_conf, ['email', 'username'], '')
|
||||
EMAIL_PASSWORD = _get(_conf, ['email', 'password'], '')
|
||||
EMAIL_FROM_ADDRESS = _get(_conf, ['email', 'from_address'], EMAIL_USERNAME)
|
||||
EMAIL_DEFAULT_SENDER_NAME = _get(_conf, ['email', 'default_sender_name'], 'Inventarsystem')
|
||||
EMAIL_TIMEOUT_SECONDS = int(_get(_conf, ['email', 'timeout_seconds'], 30))
|
||||
|
||||
# School periods
|
||||
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
|
||||
SCHOOL_INFO_DEFAULT = _get(_conf, ['school'], DEFAULTS['school'])
|
||||
|
||||
# School rollover configuration (can be set in config.json under `school.rollover`)
|
||||
SCHOOL_ROLLOVER = _get(_conf, ['school', 'rollover'], DEFAULTS['school'].get('rollover', {}))
|
||||
SCHOOL_ROLLOVER_MONTH = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_MONTH', str(SCHOOL_ROLLOVER.get('month', 9))))
|
||||
SCHOOL_ROLLOVER_DAY = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_DAY', str(SCHOOL_ROLLOVER.get('day', 1))))
|
||||
SCHOOL_ROLLOVER_HOUR = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_HOUR', str(SCHOOL_ROLLOVER.get('hour', 3))))
|
||||
SCHOOL_ROLLOVER_MIN = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_MIN', str(SCHOOL_ROLLOVER.get('minute', 0))))
|
||||
SCHOOL_ROLLOVER_MAX_CLASS = int(os.getenv('INVENTAR_SCHOOL_MAX_CLASS', str(SCHOOL_ROLLOVER.get('max_class', 13))))
|
||||
SCHOOL_ROLLOVER_GRADUATE_LABEL = os.getenv('INVENTAR_SCHOOL_GRADUATE_LABEL', str(SCHOOL_ROLLOVER.get('graduate_label', '')))
|
||||
|
||||
# Optional feature modules
|
||||
TENANT_CONFIGS = _get(_conf, ['tenants'], {})
|
||||
|
||||
@@ -257,13 +275,20 @@ class _TenantAwareBool:
|
||||
from Web.modules.module_registry import registry as MODULES
|
||||
|
||||
INVENTORY_MODULE_ENABLED = _TenantAwareBool('inventory', _get(_conf, ['modules', 'inventory', 'enabled'], DEFAULTS['modules']['inventory']['enabled']))
|
||||
TERMINPLAN_MODULE_ENABLED = _TenantAwareBool('terminplan', _get(_conf, ['modules', 'terminplan', 'enabled'], DEFAULTS['modules']['terminplan']['enabled']))
|
||||
LIBRARY_MODULE_ENABLED = _TenantAwareBool('library', _get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
|
||||
STUDENT_CARDS_MODULE_ENABLED = _TenantAwareBool('student_cards', _get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
|
||||
|
||||
def _match_inventory(path):
|
||||
if not path: return False
|
||||
if path == '/' or path.startswith('/home'): return True
|
||||
return path.startswith(('/scanner', '/inventory', '/upload_admin', '/manage_filters', '/manage_locations', '/admin_borrowings', '/admin_damaged_items', '/admin/borrowings', '/admin/damaged_items', '/terminplan'))
|
||||
return path.startswith(('/scanner', '/inventory', '/upload_admin', '/manage_filters', '/manage_locations', '/admin_borrowings', '/admin_damaged_items', '/admin/borrowings', '/admin/damaged_items'))
|
||||
|
||||
|
||||
def _match_terminplan(path):
|
||||
if not path:
|
||||
return False
|
||||
return path.startswith(('/terminplan', '/terminplaner'))
|
||||
|
||||
def _match_library(path):
|
||||
if not path: return False
|
||||
@@ -275,6 +300,7 @@ def _match_student_cards(path):
|
||||
|
||||
# Register core modules into the pipeline
|
||||
MODULES.register('inventory', INVENTORY_MODULE_ENABLED, _match_inventory)
|
||||
MODULES.register('terminplan', TERMINPLAN_MODULE_ENABLED, _match_terminplan)
|
||||
MODULES.register('library', LIBRARY_MODULE_ENABLED, _match_library)
|
||||
MODULES.register('student_cards', STUDENT_CARDS_MODULE_ENABLED, _match_student_cards)
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Apointment Managment
|
||||
=========================
|
||||
|
||||
This module manages appointments in the database. It provides comprehensive
|
||||
functionality for creating, updating, retrieving appointments items.
|
||||
|
||||
Key Features:
|
||||
- Creating and updating appointments
|
||||
- Retrieving items by ID
|
||||
- Managing time slots
|
||||
- client retrival
|
||||
|
||||
Collection Structure:
|
||||
- appointments:
|
||||
- Required fields: user, start_date, end_date, daytime, slots, slot_time
|
||||
- Optional fields: Images, Filter, Filter2, Filter3, Anschaffungsjahr, Anschaffungskosten, Code_4
|
||||
- Status fields: slots_used_by
|
||||
"""
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
from bson.objectid import ObjectId
|
||||
import datetime
|
||||
|
||||
def _active_record_query(extra_query=None):
|
||||
"""Build a query that excludes logically deleted records."""
|
||||
base_query = {'Deleted': {'$ne': True}}
|
||||
if extra_query:
|
||||
base_query.update(extra_query)
|
||||
return base_query
|
||||
|
||||
|
||||
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str=""):
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
items = db['appointments']
|
||||
|
||||
item = {
|
||||
'date_start': date_start,
|
||||
'date_end': date_end,
|
||||
'time_span': time_span,
|
||||
'slots': slots,
|
||||
'slot_lenght': slot_lenght,
|
||||
'user': user,
|
||||
'mail': mail,
|
||||
'note': note,
|
||||
'slots_booked': [], # -> [(start_time, name), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
|
||||
'Created': datetime.datetime.now(),
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
}
|
||||
result = items.insert_one(item)
|
||||
return result.inserted_id
|
||||
except Exception as e:
|
||||
print(f"Exception accured: {e}")
|
||||
|
||||
|
||||
def get_item(id):
|
||||
"""
|
||||
Retrieve a specific appointment by its ID.
|
||||
|
||||
Args:
|
||||
id (str): ID of the appointsment to retrieve
|
||||
|
||||
Returns:
|
||||
dict: The appointment document or None if not found
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
items = db['appointments']
|
||||
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
|
||||
client.close()
|
||||
return item
|
||||
except Exception as e:
|
||||
print(f"Error retrieving item: {e}")
|
||||
return None
|
||||
|
||||
def update(id,slots_used: list):
|
||||
"""
|
||||
Update an existing appointment.
|
||||
|
||||
Args:
|
||||
id (str): ID of the item to update
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
items = db['appointments']
|
||||
|
||||
update_data = {
|
||||
'slots_booked': [slots_used],
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
}
|
||||
|
||||
result = items.update_one(
|
||||
{'_id': ObjectId(id)},
|
||||
{'$set': update_data}
|
||||
)
|
||||
|
||||
client.close()
|
||||
return result.modified_count > 0
|
||||
except Exception as e:
|
||||
print(f"Error updating item: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def remove_slot(id, date_start_time, name):
|
||||
"""
|
||||
Remove a booked slot from an appointment's `slots_booked`.
|
||||
|
||||
Args:
|
||||
id (str): Appointment ID
|
||||
date_start_time: The start time value used when booking
|
||||
name (str): Name associated with the booking
|
||||
|
||||
Returns:
|
||||
bool: True if a slot was removed, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
items = db['appointments']
|
||||
|
||||
# Attempt to pull the exact element (stored as an array/tuple)
|
||||
result = items.update_one(
|
||||
{'_id': ObjectId(id)},
|
||||
{'$pull': {'slots_booked': [date_start_time, name]}}
|
||||
)
|
||||
|
||||
client.close()
|
||||
return result.modified_count > 0
|
||||
except Exception as e:
|
||||
print(f"Error removing slot: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def remove(id):
|
||||
"""
|
||||
Soft-delete an appointment by setting its `Deleted` flag.
|
||||
|
||||
Args:
|
||||
id (str): Appointment ID
|
||||
|
||||
Returns:
|
||||
bool: True if the appointment was marked deleted, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
items = db['appointments']
|
||||
|
||||
result = items.delete_one({'_id': ObjectId(id)})
|
||||
|
||||
client.close()
|
||||
return result.modified_count > 0
|
||||
except Exception as e:
|
||||
print(f"Error removing appointment: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import smtplib
|
||||
|
||||
import Web.modules.database.settings as cfg
|
||||
|
||||
|
||||
def _build_smtp_client():
|
||||
smtp = smtplib.SMTP(cfg.EMAIL_SMTP_HOST, cfg.EMAIL_SMTP_PORT, timeout=cfg.EMAIL_TIMEOUT_SECONDS)
|
||||
smtp.ehlo()
|
||||
if cfg.EMAIL_USE_TLS:
|
||||
smtp.starttls()
|
||||
smtp.ehlo()
|
||||
if cfg.EMAIL_USERNAME:
|
||||
smtp.login(cfg.EMAIL_USERNAME, cfg.EMAIL_PASSWORD or '')
|
||||
return smtp
|
||||
|
||||
def send(email: list, subject: str, note: str, sender: str) -> bool:
|
||||
"""
|
||||
Sends the email with the link to the Clients
|
||||
|
||||
Input:
|
||||
- email: Email list of all the addresses to send the link to ["","",""]
|
||||
- subject: Subject of the email
|
||||
- note: Note that is send with the Emails
|
||||
|
||||
Output:
|
||||
- bool: true if the sending worked and false if it didnt
|
||||
"""
|
||||
msg = MIMEMultipart()
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = sender or cfg.EMAIL_FROM_ADDRESS or cfg.EMAIL_USERNAME
|
||||
msg['To'] = ', '.join(email) if isinstance(email, (list, tuple)) else str(email)
|
||||
msg.attach(MIMEText(note))
|
||||
smtp = None
|
||||
try:
|
||||
smtp = _build_smtp_client()
|
||||
smtp.sendmail(from_addr=msg['From'], to_addrs=email, msg=msg.as_string())
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
if smtp:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1 +1 @@
|
||||
print("hello")
|
||||
# Web.modules package initialization
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
Class for all funktions of the executive -> Lehrer
|
||||
"""
|
||||
import datetime
|
||||
import Web.modules.emailservice.email as mail_service
|
||||
import Web.modules.database.termine as termin
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.tenant import get_tenant_context
|
||||
|
||||
|
||||
def _normalize_time_span(time_span):
|
||||
if isinstance(time_span, list):
|
||||
return [str(entry).strip() for entry in time_span if str(entry).strip()]
|
||||
if isinstance(time_span, tuple):
|
||||
return [str(entry).strip() for entry in time_span if str(entry).strip()]
|
||||
if isinstance(time_span, str):
|
||||
normalized = []
|
||||
for line in time_span.replace(';', '\n').replace(',', '\n').splitlines():
|
||||
value = line.strip()
|
||||
if value:
|
||||
normalized.append(value)
|
||||
return normalized
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_mail_list(mail):
|
||||
if isinstance(mail, list):
|
||||
return [str(entry).strip() for entry in mail if str(entry).strip()]
|
||||
if isinstance(mail, tuple):
|
||||
return [str(entry).strip() for entry in mail if str(entry).strip()]
|
||||
if isinstance(mail, str):
|
||||
return [entry.strip() for entry in mail.replace(';', ',').split(',') if entry.strip()]
|
||||
return []
|
||||
|
||||
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="") -> str:
|
||||
"""
|
||||
Generates a link for the executive to send to his clients to book a time Slot
|
||||
|
||||
Input:
|
||||
- date_start: start of the time frae area
|
||||
- date_end: end of the time frame area
|
||||
- time_span: Time window for the days as a list [(first day Time Frame), (second day Time frame), (third etc.)]
|
||||
- slots: amount of slots that are available
|
||||
- slot_lenght: the lenght of a slot in minutes
|
||||
|
||||
Output:
|
||||
- link: The link for the user to send to the clients
|
||||
"""
|
||||
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_str = str(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
|
||||
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
|
||||
|
||||
|
||||
def book_slot(id, date_start_time, name):
|
||||
"""
|
||||
Updates slot for the booking per a id
|
||||
|
||||
Input:
|
||||
- id: the id is the id you get from the
|
||||
- date_start_time: the date of the booking that was selected with date and time
|
||||
- name: name that the client gave himself
|
||||
|
||||
Output:
|
||||
- bool: if worked or not
|
||||
"""
|
||||
try:
|
||||
# Retrieve the current appointment
|
||||
item = termin.get_item(id)
|
||||
if not item:
|
||||
return False
|
||||
|
||||
slots = item.get('slots_booked', []) or []
|
||||
if not isinstance(slots, list):
|
||||
slots = []
|
||||
|
||||
capacity = int(item.get('slots', 0) or 0)
|
||||
if capacity and len(slots) >= capacity:
|
||||
return False
|
||||
|
||||
for existing in slots:
|
||||
if isinstance(existing, (list, tuple)) and len(existing) >= 2:
|
||||
if existing[0] == date_start_time and existing[1] == name:
|
||||
return False
|
||||
|
||||
# Append the new booking as a tuple (start_time, name)
|
||||
slots.append((date_start_time, name))
|
||||
|
||||
# Update the appointment in the database
|
||||
success = termin.update(id, slots)
|
||||
return bool(success)
|
||||
except Exception as e:
|
||||
print(f"Error booking slot: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def remove_slot(id, date_start_time, name):
|
||||
"""
|
||||
Remove a booked slot for an appointment.
|
||||
|
||||
Returns True if the removal succeeded, False otherwise.
|
||||
"""
|
||||
try:
|
||||
# Prefer DB-level remove if available
|
||||
if hasattr(termin, 'remove_slot'):
|
||||
removed = termin.remove_slot(id, date_start_time, name)
|
||||
if removed:
|
||||
return True
|
||||
|
||||
# Fallback: fetch, filter, and replace the slot list
|
||||
item = termin.get_item(id)
|
||||
if not item:
|
||||
return False
|
||||
|
||||
slots = item.get('slots_booked', []) or []
|
||||
new_slots = []
|
||||
for s in slots:
|
||||
try:
|
||||
# s may be list or tuple like [start_time, name]
|
||||
if isinstance(s, (list, tuple)) and len(s) >= 2 and s[0] == date_start_time and s[1] == name:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
new_slots.append(s)
|
||||
|
||||
success = termin.update(id, new_slots)
|
||||
return bool(success)
|
||||
except Exception as e:
|
||||
print(f"Error removing slot: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def remove_appointment(id):
|
||||
"""
|
||||
Remove an entire appointment by id.
|
||||
"""
|
||||
try:
|
||||
return bool(termin.remove(id))
|
||||
except Exception as e:
|
||||
print(f"Error removing appointment: {e}")
|
||||
return False
|
||||
|
||||
def get_available(id):
|
||||
"""
|
||||
Gets the available time slots -> more over it returns the time frame and the allready booked slots also the lenght.
|
||||
And checks if there are slots left.
|
||||
|
||||
Input:
|
||||
- id: id of the appointment
|
||||
|
||||
Output:
|
||||
- dict: all the needet information -> [Start_date, End_date, (first day Time Frame,
|
||||
second day Time frame, third etc.), slot lenght, (bookedslots -> list)]
|
||||
"""
|
||||
try:
|
||||
termin_range = termin.get_item(id)
|
||||
if not termin_range:
|
||||
return {}
|
||||
|
||||
date_start = termin_range.get('date_start')
|
||||
date_end = termin_range.get('date_end')
|
||||
time_span = termin_range.get('time_span', [])
|
||||
slot_lenght = termin_range.get('slot_lenght')
|
||||
total_slots = termin_range.get('slots', 0)
|
||||
booked = termin_range.get('slots_booked', []) or []
|
||||
|
||||
# Normalize booked entries to dicts for easier consumption
|
||||
normalized = []
|
||||
for s in booked:
|
||||
if isinstance(s, (list, tuple)) and len(s) >= 2:
|
||||
normalized.append({'start': s[0], 'name': s[1]})
|
||||
elif isinstance(s, dict):
|
||||
normalized.append(s)
|
||||
else:
|
||||
normalized.append({'value': s})
|
||||
|
||||
slots_used = len(normalized)
|
||||
slots_left = max(0, total_slots - slots_used)
|
||||
|
||||
return {
|
||||
'date_start': date_start,
|
||||
'date_end': date_end,
|
||||
'time_span': time_span,
|
||||
'slot_lenght': slot_lenght,
|
||||
'slots_total': total_slots,
|
||||
'slots_booked': normalized,
|
||||
'slots_left': slots_left,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error getting available slots: {e}")
|
||||
return {}
|
||||
|
||||
def get_available_user(id):
|
||||
"""
|
||||
Gets the available time slots -> more over it returns the time frame and the allready booked slots also the lenght.
|
||||
And checks if there are slots left.
|
||||
|
||||
Input:
|
||||
- id: id of the appointment
|
||||
|
||||
Output:
|
||||
- dict: all the needet information -> [Start_date, End_date, (first day Time Frame,
|
||||
second day Time frame, third etc.), slot lenght, (bookedslots -> list)]
|
||||
"""
|
||||
return get_available(id)
|
||||
@@ -0,0 +1,96 @@
|
||||
from flask import Blueprint, render_template, request, session, url_for, redirect, flash
|
||||
import Web.modules.terminplaner.backend_server as appointment_service
|
||||
import Web.modules.database.settings as cfg
|
||||
|
||||
# Create a blueprint instance
|
||||
appoint_bp = Blueprint('terminplaner', __name__)
|
||||
|
||||
|
||||
def _require_module_enabled():
|
||||
if not cfg.MODULES.is_enabled('terminplan'):
|
||||
flash('Der Terminplaner ist deaktiviert.', 'info')
|
||||
return redirect(url_for('home'))
|
||||
return None
|
||||
|
||||
@appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
|
||||
def client(appointment_id):
|
||||
"""
|
||||
The Route for the terminplaner to work with the client
|
||||
"""
|
||||
guard = _require_module_enabled()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
available = appointment_service.get_available(appointment_id)
|
||||
if not available:
|
||||
flash('Der Termin wurde nicht gefunden.', 'error')
|
||||
return redirect(url_for('terminplan'))
|
||||
|
||||
if request.method == 'POST':
|
||||
start_daytime = request.form.get('start_day_time')
|
||||
username = request.form.get('client_name')
|
||||
if not start_daytime or not username:
|
||||
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
|
||||
return render_template(
|
||||
'termin_client.html',
|
||||
appointment_id=appointment_id,
|
||||
available=available,
|
||||
current_user=session.get('username', ''),
|
||||
)
|
||||
|
||||
if appointment_service.book_slot(appointment_id, start_daytime, username):
|
||||
flash('Der Termin wurde gespeichert.', 'success')
|
||||
return redirect(url_for('terminplaner.client', appointment_id=appointment_id))
|
||||
|
||||
flash('Der Termin konnte nicht gespeichert werden.', 'error')
|
||||
|
||||
return render_template(
|
||||
'termin_client.html',
|
||||
appointment_id=appointment_id,
|
||||
available=available,
|
||||
current_user=session.get('username', ''),
|
||||
)
|
||||
|
||||
@appoint_bp.route('/configure', methods=['GET', 'POST'])
|
||||
def configure():
|
||||
"""
|
||||
Route for authenticated persons to configure a new appointment for them
|
||||
"""
|
||||
guard = _require_module_enabled()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
if 'username' not in session:
|
||||
flash('Bitte mit einem Konto anmelden.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
if request.method == "POST":
|
||||
start = request.form.get('start_date')
|
||||
end = request.form.get('end_date')
|
||||
time = request.form.get('time_frame')
|
||||
slots_amount = request.form.get('slots_amounts')
|
||||
slot_lenght = request.form.get('slot_lenght')
|
||||
mail = request.form.get('mail', '')
|
||||
note = request.form.get('note', '')
|
||||
|
||||
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)
|
||||
|
||||
link = appointment_service.new(start, end, time, slots_amount, slot_lenght, session["username"], mail, note)
|
||||
flash('Der Terminplan wurde angelegt.', 'success')
|
||||
return render_template('termin_configure.html', school_periods=cfg.SCHOOL_PERIODS, generated_link=link)
|
||||
elif request.method == "GET":
|
||||
return render_template('termin_configure.html', school_periods=cfg.SCHOOL_PERIODS, generated_link=None)
|
||||
|
||||
@appoint_bp.route('/')
|
||||
def main():
|
||||
guard = _require_module_enabled()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
return render_template(
|
||||
'terminplaner.html',
|
||||
school_periods=cfg.SCHOOL_PERIODS,
|
||||
current_user=session.get('username', ''),
|
||||
)
|
||||
@@ -1,5 +1,4 @@
|
||||
flask
|
||||
bson
|
||||
werkzeug
|
||||
gunicorn
|
||||
pymongo
|
||||
|
||||
+72
-12
@@ -129,6 +129,14 @@
|
||||
--module-brand-icon: "📚";
|
||||
}
|
||||
|
||||
html[data-module="terminplan"] {
|
||||
--module-primary-color: #0f4c5c;
|
||||
--module-primary-light: #16697a;
|
||||
--module-accent-color: #2ec4b6;
|
||||
--module-accent-light: rgba(46, 196, 182, 0.1);
|
||||
--module-brand-icon: "📅";
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--module-primary-color: #1a252f;
|
||||
--module-primary-light: #2c3e50;
|
||||
@@ -145,6 +153,12 @@
|
||||
--module-accent-color: #8e44ad;
|
||||
}
|
||||
|
||||
html[data-theme="dark"][data-module="terminplan"] {
|
||||
--module-primary-color: #082f35;
|
||||
--module-primary-light: #0f4c5c;
|
||||
--module-accent-color: #21b6a8;
|
||||
}
|
||||
|
||||
/* Global styles */
|
||||
body {
|
||||
font-family: "Manrope", "Segoe UI", "Noto Sans", sans-serif;
|
||||
@@ -1078,29 +1092,73 @@
|
||||
<span class="module-label">Module:</span>
|
||||
<div class="module-tabs">
|
||||
{% if inventory_module_enabled %}
|
||||
<a href="{{ url_for('home') }}"
|
||||
class="module-tab {% if CURRENT_MODULE == 'inventory' %}active{% endif %}"
|
||||
id="inventoryModule"
|
||||
data-module="inventory">
|
||||
<a href="{{ url_for('home') }}" class="module-tab {% if CURRENT_MODULE == 'inventory' %}active{% endif %}" id="inventoryModule" data-module="inventory">
|
||||
📦 Inventarsystem
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if inventory_module_enabled and library_module_enabled %}
|
||||
{% if inventory_module_enabled and terminplan_module_enabled %}
|
||||
<div class="module-separator"></div>
|
||||
{% endif %}
|
||||
{% if terminplan_module_enabled %}
|
||||
<a href="{{ url_for('terminplaner.main') }}" class="module-tab {% if CURRENT_MODULE == 'terminplan' %}active{% endif %}" id="terminplanModule" data-module="terminplan">
|
||||
📅 Terminplaner
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if (inventory_module_enabled or terminplan_module_enabled) and library_module_enabled %}
|
||||
<div class="module-separator"></div>
|
||||
{% endif %}
|
||||
{% if library_module_enabled %}
|
||||
<a href="{{ url_for('library_view') }}"
|
||||
class="module-tab {% if CURRENT_MODULE == 'library' %}active{% endif %}"
|
||||
id="libraryModule"
|
||||
data-module="library">
|
||||
<a href="{{ url_for('library_view') }}" class="module-tab {% if CURRENT_MODULE == 'library' %}active{% endif %}" id="libraryModule" data-module="library">
|
||||
📚 Bibliothek
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if 'username' in session %}
|
||||
{% if CURRENT_MODULE != 'library' and inventory_module_enabled %}
|
||||
|
||||
{% if terminplan_module_enabled and CURRENT_MODULE == 'terminplan' %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark" id="terminplanNavbar">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('terminplaner.main') }}" data-icon="📅">Terminplaner</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#terminplanNavContent">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="terminplanNavContent">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="terminplanNavList">
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link {% if current_path == url_for('terminplaner.main') %}nav-active{% endif %}" href="{{ url_for('terminplaner.main') }}" data-tutorial-tip="Die Terminplaner-Übersicht bündelt die wichtigsten Aktionen.">Übersicht</a>
|
||||
</li>
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link {% if current_path == url_for('terminplan') %}nav-active{% endif %}" href="{{ url_for('terminplan') }}" data-tutorial-tip="Hier sehen Sie den Kalender mit den bestehenden Terminen.">Kalender</a>
|
||||
</li>
|
||||
{% if current_permissions.pages.get('terminplan', True) and current_permissions.actions.get('can_insert', True) %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link quick-link-pill {% if current_path == url_for('terminplaner.configure') %}nav-active{% endif %}" href="{{ url_for('terminplaner.configure') }}" data-tutorial-tip="Neuen Terminplan erstellen und an Ihr Team versenden.">Neue Planung</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
|
||||
<span class="theme-icon-light" style="display: none;">☀️</span>
|
||||
<span class="theme-icon-dark" style="display: none;">🌙</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item dropdown ms-lg-auto">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="termMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">Mehr Optionen</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="termMoreDropdown">
|
||||
{% if current_permissions.pages.get('home', True) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('home') }}">Inventarsystem</a></li>
|
||||
{% endif %}
|
||||
{% if current_permissions.pages.get('library_view', True) and library_module_enabled %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('library_view') }}">Bibliothek</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% elif CURRENT_MODULE != 'library' and inventory_module_enabled %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark" id="inventoryNavbar">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('home') }}" data-icon="📦">Inventarsystem</a>
|
||||
@@ -1220,6 +1278,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if library_module_enabled and CURRENT_MODULE == 'library' %}
|
||||
@@ -1332,6 +1391,7 @@
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark" id="loginNavbar">
|
||||
|
||||
@@ -720,6 +720,7 @@
|
||||
${actionLabel}
|
||||
</button>
|
||||
${canEditLibraryItems ? `<button class="button" style="background:#0ea5e9;color:#fff;" onclick="openEditLibraryItem('${item._id}')">Bearbeiten</button>` : ''}
|
||||
${canEditLibraryItems ? `<button class="button" style="background:#dc2626;color:#fff; margin-left:6px;" onclick="confirmDeleteLibraryItem('${item._id}')">Löschen</button>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
@@ -739,6 +740,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm and call library item delete endpoint
|
||||
function confirmDeleteLibraryItem(itemId) {
|
||||
if (!itemId) return;
|
||||
if (!confirm('Dieses Bibliotheksmedium revisionssicher löschen? Diese Aktion deaktiviert alle zugehörigen Exemplare und archiviert Medien.')) return;
|
||||
deleteLibraryItem(itemId);
|
||||
}
|
||||
|
||||
async function deleteLibraryItem(itemId) {
|
||||
if (!itemId) return;
|
||||
document.body.style.cursor = 'wait';
|
||||
try {
|
||||
const resp = await fetch(`/delete_library_item/${itemId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': '{{ csrf_token }}',
|
||||
'X-CSRF-Token': '{{ csrf_token }}'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
const ct = resp.headers.get('Content-Type') || '';
|
||||
if (ct.includes('application/json')) {
|
||||
const j = await resp.json();
|
||||
if (!resp.ok || j.ok === false) {
|
||||
alert(j.message || 'Fehler beim Löschen');
|
||||
} else {
|
||||
alert(j.message || 'Gelöscht');
|
||||
}
|
||||
loadLibraryItems();
|
||||
} else {
|
||||
// Server redirected to HTML (flash messages). Reload to show state.
|
||||
location.reload();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Delete failed', e);
|
||||
alert('Fehler beim Löschen des Elements.');
|
||||
} finally {
|
||||
document.body.style.cursor = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Filter toggle
|
||||
document.getElementById('filterToggleBtn').addEventListener('click', () => {
|
||||
filterPanelOpen = !filterPanelOpen;
|
||||
@@ -822,11 +864,27 @@
|
||||
setActiveStudentCard(cardId);
|
||||
|
||||
const durationInput = (window.prompt('Ausleihdauer in Tagen (optional):') || '').trim();
|
||||
const maxAvailable = Math.max(1, parseInt(selectedItem?.AvailableGroupedCount || selectedItem?.Quantity || 1, 10) || 1);
|
||||
const countPrompt = (window.prompt(`Anzahl ausleihen? (Standard: 1, verfügbar: ${maxAvailable})`, '1') || '').trim();
|
||||
let borrowCount = parseInt(countPrompt || '1', 10);
|
||||
if (!Number.isFinite(borrowCount) || borrowCount < 1) {
|
||||
borrowCount = 1;
|
||||
}
|
||||
if (borrowCount > maxAvailable) {
|
||||
alert(`Es sind nur ${maxAvailable} Exemplar(e) verfügbar.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/ausleihen/${itemId}`;
|
||||
|
||||
const csrfField = document.createElement('input');
|
||||
csrfField.type = 'hidden';
|
||||
csrfField.name = 'csrf_token';
|
||||
csrfField.value = '{{ csrf_token }}';
|
||||
form.appendChild(csrfField);
|
||||
|
||||
const cardField = document.createElement('input');
|
||||
cardField.type = 'hidden';
|
||||
cardField.name = 'borrower_card_id';
|
||||
@@ -847,6 +905,12 @@
|
||||
form.appendChild(durationField);
|
||||
}
|
||||
|
||||
const countField = document.createElement('input');
|
||||
countField.type = 'hidden';
|
||||
countField.name = 'exemplare_count';
|
||||
countField.value = String(borrowCount || 1);
|
||||
form.appendChild(countField);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Termin buchen - Inventarsystem{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-xl-10">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="card border-0 shadow-lg rounded-4 h-100">
|
||||
<div class="card-body p-4 p-md-5">
|
||||
<p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p>
|
||||
<h1 class="h3 fw-bold mb-3">Termin buchen</h1>
|
||||
<p class="text-muted mb-4">Wählen Sie einen freien Zeitpunkt und tragen Sie Ihren Namen ein. Der Termin wird anschließend im Plan gespeichert.</p>
|
||||
|
||||
<div class="p-3 rounded-3 bg-light mb-3">
|
||||
<div class="fw-semibold">Zeitraum</div>
|
||||
<div>{{ available.date_start }} bis {{ available.date_end }}</div>
|
||||
</div>
|
||||
<div class="p-3 rounded-3 bg-light mb-3">
|
||||
<div class="fw-semibold">Verfügbare Slots</div>
|
||||
<div>{{ available.slots_left }} von {{ available.slots_total }} frei</div>
|
||||
</div>
|
||||
<div class="p-3 rounded-3 bg-light mb-3">
|
||||
<div class="fw-semibold">Slot-Länge</div>
|
||||
<div>{{ available.slot_lenght }} Minuten</div>
|
||||
</div>
|
||||
|
||||
{% if available.slots_booked %}
|
||||
<div class="p-3 rounded-3 bg-light">
|
||||
<div class="fw-semibold mb-2">Bereits gebucht</div>
|
||||
<ul class="mb-0 small">
|
||||
{% for booking in available.slots_booked %}
|
||||
<li>{{ booking.start }}{% if booking.name %} - {{ booking.name }}{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="card border-0 shadow-lg rounded-4 h-100">
|
||||
<div class="card-body p-4 p-md-5 bg-white">
|
||||
<h2 class="h4 fw-bold mb-4">Buchung absenden</h2>
|
||||
<form method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id) }}" class="vstack gap-3">
|
||||
<div>
|
||||
<label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label>
|
||||
<input type="text" id="start_day_time" name="start_day_time" class="form-control form-control-lg" placeholder="2026-05-29 10:30" required>
|
||||
<div class="form-text">Tragen Sie Datum und Uhrzeit im Format YYYY-MM-DD HH:MM ein, sofern kein Kalenderfeld genutzt wird.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_name" class="form-label fw-semibold">Ihr Name</label>
|
||||
<input type="text" id="client_name" name="client_name" class="form-control form-control-lg" placeholder="Vor- und Nachname" required>
|
||||
</div>
|
||||
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Termin buchen</button>
|
||||
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main') }}">Zur Übersicht</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,72 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Terminplan konfigurieren - Inventarsystem{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-10 col-xl-8">
|
||||
<div class="card border-0 shadow-lg rounded-4 overflow-hidden">
|
||||
<div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);">
|
||||
<h1 class="h3 mb-1 fw-bold">Terminplan konfigurieren</h1>
|
||||
<p class="mb-0 opacity-75">Erstellen Sie einen neuen Plan und verschicken Sie anschließend den Buchungslink.</p>
|
||||
</div>
|
||||
<div class="card-body p-4 p-md-5 bg-white">
|
||||
<form method="post" action="{{ url_for('terminplaner.configure') }}" class="vstack gap-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="start_date" class="form-label fw-semibold">Startdatum</label>
|
||||
<input type="date" id="start_date" name="start_date" class="form-control form-control-lg" required>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="end_date" class="form-label fw-semibold">Enddatum</label>
|
||||
<input type="date" id="end_date" name="end_date" class="form-control form-control-lg" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="slots_amounts" class="form-label fw-semibold">Anzahl Slots</label>
|
||||
<input type="number" id="slots_amounts" name="slots_amounts" class="form-control" min="1" value="1" required>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="slot_lenght" class="form-label fw-semibold">Slot-Länge in Minuten</label>
|
||||
<input type="number" id="slot_lenght" name="slot_lenght" class="form-control" min="1" value="45" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="mail" class="form-label fw-semibold">Empfänger-Mails</label>
|
||||
<input type="text" id="mail" name="mail" class="form-control" placeholder="adresse1@schule.de, adresse2@schule.de">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="note" class="form-label fw-semibold">Notiz</label>
|
||||
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Terminplan erzeugen</button>
|
||||
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main') }}">Zur Übersicht</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if generated_link %}
|
||||
<div class="alert alert-success mt-4 shadow-sm rounded-4">
|
||||
<div class="fw-bold mb-1">Buchungslink erstellt</div>
|
||||
<div class="mb-2">Teilen Sie diesen Link mit den Teilnehmenden:</div>
|
||||
<a href="{{ generated_link }}" class="d-inline-block text-break">{{ generated_link }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,65 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Terminplaner - Inventarsystem{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-11 col-xl-10">
|
||||
<section class="p-4 p-md-5 rounded-4 shadow-lg" style="background: linear-gradient(135deg, rgba(15,76,92,0.96), rgba(22,105,122,0.92)); color: #fff;">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-start align-items-lg-end">
|
||||
<div>
|
||||
<p class="text-uppercase fw-semibold mb-2" style="letter-spacing: .12em; opacity: .8;">Terminplaner</p>
|
||||
<h1 class="display-6 fw-bold mb-3">Reservierungen und Termine übersichtlich verwalten</h1>
|
||||
<p class="lead mb-0" style="max-width: 60ch; opacity: .95;">Erstellen Sie neue Terminreihen, teilen Sie Buchungslinks und öffnen Sie den Kalender für bestehende Reservierungen.</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-light btn-lg fw-semibold" href="{{ url_for('terminplaner.configure') }}">Neue Planung</a>
|
||||
<a class="btn btn-outline-light btn-lg fw-semibold" href="{{ url_for('terminplan') }}">Kalender öffnen</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="row g-4 mt-1">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100 shadow-sm border-0 rounded-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="display-6 mb-3">🗓️</div>
|
||||
<h2 class="h4 fw-bold">Kalender</h2>
|
||||
<p class="mb-4 text-muted">Sehen Sie vorhandene Termine, ihre Auslastung und die aktuellen Reservierungen im Kalender.</p>
|
||||
<a class="btn btn-primary w-100" href="{{ url_for('terminplan') }}">Zum Kalender</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100 shadow-sm border-0 rounded-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="display-6 mb-3">✍️</div>
|
||||
<h2 class="h4 fw-bold">Neue Planung</h2>
|
||||
<p class="mb-4 text-muted">Erstellen Sie einen neuen Terminplan und verschicken Sie den Buchungslink an Ihre Zielgruppe.</p>
|
||||
<a class="btn btn-outline-primary w-100" href="{{ url_for('terminplaner.configure') }}">Konfiguration öffnen</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100 shadow-sm border-0 rounded-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="display-6 mb-3">🔗</div>
|
||||
<h2 class="h4 fw-bold">Buchungslink</h2>
|
||||
<p class="mb-4 text-muted">Die öffentliche Buchungsseite liegt unter dem jeweiligen Terminlink und kann direkt an Teilnehmende weitergegeben werden.</p>
|
||||
<div class="small text-muted">Die Buchungsseite wird aus dem Terminplan automatisch erzeugt.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user %}
|
||||
<div class="mt-4 p-4 rounded-4 bg-white shadow-sm">
|
||||
<h2 class="h5 fw-bold mb-2">Angemeldet als {{ current_user }}</h2>
|
||||
<p class="mb-0 text-muted">Sie können Termine anlegen, den Kalender prüfen und Buchungslinks verteilen.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -212,6 +212,7 @@ def _module_name_candidates(module_name):
|
||||
candidates = [normalized]
|
||||
alias_groups = {
|
||||
'inventory': {'inventory', 'inventar'},
|
||||
'terminplan': {'terminplan', 'terminplaner', 'termin', 'appointments'},
|
||||
'library': {'library', 'bib', 'bibliothek'},
|
||||
'student_cards': {'student_cards', 'studentcards', 'schuelerausweise', 'schueler_ausweise'},
|
||||
}
|
||||
|
||||
+11
@@ -19,6 +19,17 @@
|
||||
"cert": "Web/certs/cert.pem",
|
||||
"key": "Web/certs/key.pem"
|
||||
},
|
||||
"email": {
|
||||
"enabled": false,
|
||||
"smtp_host": "smtp.gmail.com",
|
||||
"smtp_port": 587,
|
||||
"use_tls": true,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"from_address": "",
|
||||
"default_sender_name": "Invario Inventurprogramm",
|
||||
"timeout_seconds": 30
|
||||
},
|
||||
"images": {
|
||||
"thumbnail_size": [
|
||||
150,
|
||||
|
||||
@@ -66,7 +66,7 @@ services:
|
||||
PYTHON_VERSION: "3.13"
|
||||
OPTIMIZATION_LEVEL: 2
|
||||
working_dir: /app/Web
|
||||
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
+154
-121
@@ -23,59 +23,70 @@ ensure_runtime_config_json() {
|
||||
echo "Warning: moved unexpected directory $config_path to $backup_path"
|
||||
fi
|
||||
|
||||
if [ ! -f "$config_path" ]; then
|
||||
FORCE_REMOVE=false
|
||||
if [ "${2:-}" = "--yes" ] || [ "${2:-}" = "-y" ]; then
|
||||
FORCE_REMOVE=true
|
||||
TENANT_ID="${3:-}"
|
||||
fi
|
||||
|
||||
if [ -z "$TENANT_ID" ]; then
|
||||
cat > "$config_path" <<'EOF'
|
||||
{
|
||||
"ver": "2.6.5",
|
||||
"dbg": false,
|
||||
"host": "0.0.0.0",
|
||||
"port": 8000,
|
||||
"mongodb": {
|
||||
"host": "mongodb",
|
||||
"port": 27017,
|
||||
"db": "Inventarsystem"
|
||||
},
|
||||
"modules": {
|
||||
"library": {
|
||||
"enabled": false
|
||||
},
|
||||
"student_cards": {
|
||||
"enabled": false,
|
||||
"default_borrow_days": 14,
|
||||
"max_borrow_days": 365
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
echo "Created default runtime config at $config_path"
|
||||
fi
|
||||
}
|
||||
|
||||
show_help() {
|
||||
echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " add <tenant_id> [port] Add a new tenant (initializes database)"
|
||||
echo " trial <tenant_id> [port] [days] Create a 7-day demo tenant that auto-deletes after expiry"
|
||||
echo " remove <tenant_id> Remove a tenant completely (deletes data!)"
|
||||
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
|
||||
echo " restart-all Restart all application containers (zero-downtime reload)"
|
||||
echo " list List active tenants"
|
||||
echo " module <tenant_id> <module>=<on|off>... Enable/disable modules for a tenant"
|
||||
echo " -h, --help Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " ./manage-tenant.sh add school_a 10001"
|
||||
echo " ./manage-tenant.sh trial school_demo 10002 7"
|
||||
echo " ./manage-tenant.sh remove test_tenant"
|
||||
echo " ./manage-tenant.sh module school_a inventory=off library=on"
|
||||
echo " ./manage-tenant.sh restart-all"
|
||||
echo " ./manage-tenant.sh -h"
|
||||
exit 1
|
||||
}
|
||||
if [ "$FORCE_REMOVE" != true ]; then
|
||||
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
|
||||
read confirm
|
||||
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
||||
echo "Removal canceled."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
tenant_aliases() {
|
||||
local tenant_id="$1"
|
||||
echo "Removing tenant '$TENANT_ID'..."
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
port_to_remove=""
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
port_to_remove="$({ docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" <<'PY'
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
sys.path.insert(0, '/app/Web')
|
||||
from tenant import delete_tenant, get_tenant_config
|
||||
|
||||
tenant_id = sys.argv[1]
|
||||
tenant_cfg = get_tenant_config(tenant_id)
|
||||
port = tenant_cfg.get('port')
|
||||
|
||||
if not delete_tenant(tenant_id):
|
||||
print(f'Error: failed to delete tenant {tenant_id}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if port is not None:
|
||||
print(port)
|
||||
PY
|
||||
} 2>/dev/null)"
|
||||
echo "Tenant '$TENANT_ID' database and config removed."
|
||||
else
|
||||
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
|
||||
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
|
||||
:
|
||||
else
|
||||
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$port_to_remove" ]; then
|
||||
remove_runtime_port "$port_to_remove"
|
||||
fi
|
||||
sync_tenant_port_map
|
||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
||||
restart_app_container
|
||||
fi
|
||||
if [ -n "$port_to_remove" ]; then
|
||||
echo "Removed tenant '$TENANT_ID' and cleaned runtime port $port_to_remove."
|
||||
else
|
||||
echo "Removed tenant '$TENANT_ID'. No port mapping was present."
|
||||
fi
|
||||
local normalized alias
|
||||
normalized="$(printf '%s' "$tenant_id" | tr '[:upper:]' '[:lower:]')"
|
||||
printf '%s\n' "$tenant_id"
|
||||
@@ -201,6 +212,7 @@ existing['modules'] = {
|
||||
'inventory': {'enabled': True},
|
||||
'library': {'enabled': True},
|
||||
'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365},
|
||||
'terminplan': {'enabled': True},
|
||||
}
|
||||
existing['trial'] = trial_config
|
||||
|
||||
@@ -312,58 +324,60 @@ print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
|
||||
update_runtime_ports() {
|
||||
local new_port="$1"
|
||||
local env_file="$PWD/.docker-build.env"
|
||||
local current_ports=""
|
||||
local port_list=()
|
||||
local unique_ports=()
|
||||
local first_port=""
|
||||
if [ -n "$new_port" ]; then
|
||||
if [ -f "$env_file" ]; then
|
||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||
if [ -z "$current_ports" ]; then
|
||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||
fi
|
||||
local current_ports port_list unique_ports first_port ports_csv
|
||||
|
||||
if [ -z "$new_port" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
current_ports=""
|
||||
if [ -f "$env_file" ]; then
|
||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||
if [ -z "$current_ports" ]; then
|
||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$current_ports" ]; then
|
||||
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
|
||||
port_list=()
|
||||
unique_ports=()
|
||||
if [ -n "$current_ports" ]; then
|
||||
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
|
||||
fi
|
||||
port_list+=("$new_port")
|
||||
|
||||
for port in "${port_list[@]}"; do
|
||||
if [ -n "$port" ] && ! printf '%s\n' "${unique_ports[@]}" | grep -qx "$port"; then
|
||||
unique_ports+=("$port")
|
||||
fi
|
||||
port_list+=("$new_port")
|
||||
done
|
||||
|
||||
for port in "${port_list[@]}"; do
|
||||
if [ -n "$port" ] && ! printf '%s\n' "${unique_ports[@]}" | grep -qx "$port"; then
|
||||
unique_ports+=("$port")
|
||||
fi
|
||||
done
|
||||
if [ ${#unique_ports[@]} -eq 0 ]; then
|
||||
unique_ports=("$new_port")
|
||||
fi
|
||||
|
||||
if [ ${#unique_ports[@]} -eq 0 ]; then
|
||||
unique_ports=("$new_port")
|
||||
fi
|
||||
first_port="${unique_ports[0]}"
|
||||
ports_csv="$(IFS=,; echo "${unique_ports[*]}")"
|
||||
|
||||
first_port="${unique_ports[0]}"
|
||||
local ports_csv
|
||||
ports_csv="$(IFS=,; echo "${unique_ports[*]}")"
|
||||
|
||||
if [ ! -f "$env_file" ]; then
|
||||
cat > "$env_file" <<EOF
|
||||
if [ ! -f "$env_file" ]; then
|
||||
cat > "$env_file" <<EOF
|
||||
NUITKA_BUILD=0
|
||||
INVENTAR_HTTP_PORT=$first_port
|
||||
INVENTAR_HTTP_PORTS=$ports_csv
|
||||
EOF
|
||||
else
|
||||
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
|
||||
else
|
||||
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
|
||||
else
|
||||
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
|
||||
fi
|
||||
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
|
||||
else
|
||||
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
|
||||
fi
|
||||
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
|
||||
fi
|
||||
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
|
||||
else
|
||||
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
|
||||
fi
|
||||
|
||||
echo "Updated runtime env ports: $ports_csv"
|
||||
fi
|
||||
|
||||
echo "Updated runtime env ports: $ports_csv"
|
||||
}
|
||||
|
||||
sync_tenant_port_map() {
|
||||
@@ -675,50 +689,69 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
|
||||
;;
|
||||
|
||||
remove)
|
||||
FORCE_REMOVE=false
|
||||
if [ "${2:-}" = "--yes" ] || [ "${2:-}" = "-y" ]; then
|
||||
FORCE_REMOVE=true
|
||||
TENANT_ID="${3:-}"
|
||||
fi
|
||||
|
||||
if [ -z "$TENANT_ID" ]; then
|
||||
echo "Error: Please provide a tenant_id to remove."
|
||||
exit 1
|
||||
fi
|
||||
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
|
||||
read confirm
|
||||
if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then
|
||||
echo "Removing tenant '$TENANT_ID'..."
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
docker exec $APP_CONTAINER python3 -c "
|
||||
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from tenant import TenantContext; from Web.modules.database import settings; from pymongo import MongoClient
|
||||
ctx = TenantContext()
|
||||
db_name = ctx._get_db_name(sys.argv[1])
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
client.drop_database(db_name)
|
||||
print(f'Database for tenant {sys.argv[1]} dropped.')
|
||||
" "$TENANT_ID"
|
||||
echo "Tenant '$TENANT_ID' database removed."
|
||||
else
|
||||
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
|
||||
fi
|
||||
|
||||
port_to_remove=""
|
||||
if [ "$FORCE_REMOVE" != true ]; then
|
||||
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
|
||||
read confirm
|
||||
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
||||
echo "Removal canceled."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Removing tenant '$TENANT_ID'..."
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
port_to_remove=""
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
port_to_remove="$(docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" <<'PY'
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
sys.path.insert(0, '/app/Web')
|
||||
from tenant import delete_tenant, get_tenant_config
|
||||
|
||||
tenant_id = sys.argv[1]
|
||||
tenant_cfg = get_tenant_config(tenant_id)
|
||||
port = tenant_cfg.get('port')
|
||||
|
||||
if not delete_tenant(tenant_id):
|
||||
print(f'Error: failed to delete tenant {tenant_id}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if port is not None:
|
||||
print(port)
|
||||
PY
|
||||
)"
|
||||
echo "Tenant '$TENANT_ID' database and config removed."
|
||||
else
|
||||
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
|
||||
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
|
||||
if [ -n "$port_to_remove" ]; then
|
||||
remove_runtime_port "$port_to_remove"
|
||||
sync_tenant_port_map
|
||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
||||
restart_app_container
|
||||
fi
|
||||
echo "Removed tenant '$TENANT_ID' from config.json and cleaned runtime port $port_to_remove."
|
||||
else
|
||||
sync_tenant_port_map
|
||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
||||
restart_app_container
|
||||
fi
|
||||
echo "Removed tenant '$TENANT_ID' from config.json. No port mapping was present."
|
||||
fi
|
||||
:
|
||||
else
|
||||
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$port_to_remove" ]; then
|
||||
remove_runtime_port "$port_to_remove"
|
||||
fi
|
||||
sync_tenant_port_map
|
||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
||||
restart_app_container
|
||||
fi
|
||||
if [ -n "$port_to_remove" ]; then
|
||||
echo "Removed tenant '$TENANT_ID' and cleaned runtime port $port_to_remove."
|
||||
else
|
||||
echo "Removal canceled."
|
||||
echo "Removed tenant '$TENANT_ID'. No port mapping was present."
|
||||
fi
|
||||
;;
|
||||
|
||||
|
||||
@@ -572,7 +572,7 @@ write_runtime_compose_override() {
|
||||
services:
|
||||
app:
|
||||
working_dir: /app/Web
|
||||
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "${INVENTAR_WORKERS:-2}", "--threads", "${INVENTAR_THREADS:-2}", "--timeout", "${INVENTAR_WORKER_TIMEOUT:-30}", "--graceful-timeout", "20", "--worker-connections", "${INVENTAR_WORKER_CONNECTIONS:-100}", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "${INVENTAR_WORKERS:-2}", "--threads", "${INVENTAR_THREADS:-2}", "--timeout", "${INVENTAR_WORKER_TIMEOUT:-30}", "--graceful-timeout", "20", "--worker-connections", "${INVENTAR_WORKER_CONNECTIONS:-100}", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
image: ${APP_IMAGE_VALUE}
|
||||
build: null
|
||||
EOF
|
||||
|
||||
Reference in New Issue
Block a user