Compare commits

...

5 Commits

12 changed files with 329 additions and 39 deletions
+3 -1
View File
@@ -2,4 +2,6 @@ dist
logs
certs
build
.venv
.venv
__pycache__
.pyc
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+85 -7
View File
@@ -1123,6 +1123,8 @@ def update_appointment_statuses():
for appointment in appointments_to_check:
old_status = appointment.get('Status')
activation_user = str(appointment.get('User') or '').strip()
activation_item_name = str(appointment.get('Item') or 'Termin')
# Aktuellen Status bestimmen
new_status = au.get_current_status(appointment, log_changes=True, user='scheduler')
@@ -1138,6 +1140,7 @@ def update_appointment_statuses():
item_id_str = appointment.get('Item')
conflict_detected = False
conflict_note = ''
item_name = item_id_str or 'Termin'
if item_id_str:
try:
item_doc = items_col.find_one(
@@ -1145,6 +1148,8 @@ def update_appointment_statuses():
{'Verfuegbar': 1, 'User': 1, 'Name': 1, 'Exemplare': 1}
)
if item_doc:
item_name = item_doc.get('Name', item_id_str)
activation_item_name = item_name
total_exemplare = int(item_doc.get('Exemplare', 1))
# Count how many active (non-planned) borrows currently hold this item
active_borrows = ausleihungen.count_documents({
@@ -1194,6 +1199,31 @@ def update_appointment_statuses():
elif new_status == 'completed':
completed_count += 1
# Create activation notification even if another worker already updated the status.
if old_status == 'planned' and new_status == 'active' and activation_user:
try:
_create_notification(
db,
audience='user',
notif_type='appointment_activated',
title='Reservierung ist jetzt aktiv',
message=(
f"Deine geplante Ausleihe für {activation_item_name} startet jetzt."
),
target_user=activation_user,
reference={
'appointment_id': str(appointment.get('_id')),
'item_id': str(appointment.get('Item') or ''),
'event': 'activated',
},
unique_key=f"appointment:activated:{appointment.get('_id')}",
severity='info',
)
except Exception as notif_err:
app.logger.warning(
f"Failed to create activation notification for {appointment.get('_id')}: {notif_err}"
)
client.close()
if updated_count > 0:
@@ -1204,17 +1234,62 @@ def update_appointment_statuses():
except Exception as e:
app.logger.error(f"Automatic appointment status update failed: {e}")
# Schedule jobs
# Schedule jobs - only start scheduler if this is the main process or a single-worker deployment
# This prevents race conditions in multi-worker Gunicorn environments
scheduler = BackgroundScheduler()
if cfg.SCHEDULER_ENABLED:
scheduler.add_job(func=create_daily_backup, trigger="interval", hours=cfg.BACKUP_INTERVAL_HOURS)
scheduler.add_job(func=update_appointment_statuses, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
scheduler.add_job(func=create_return_reminders, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
scheduler.start()
_scheduler_initialized = False
def _initialize_scheduler():
"""Initialize the background scheduler in a safe way for multi-worker deployments."""
global _scheduler_initialized
if _scheduler_initialized or not cfg.SCHEDULER_ENABLED:
return
try:
# For multi-worker Gunicorn, check if we're in a reasonable scenario
# Using a lock file to ensure only one instance starts the scheduler
scheduler_lock_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), '.scheduler_lock')
try:
# Try to create the lock file - only succeeds if it doesn't exist
lock_fd = os.open(scheduler_lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
os.close(lock_fd)
should_start = True
except FileExistsError:
should_start = False
app.logger.warning("Scheduler lock exists - another process is already running the scheduler")
if should_start:
scheduler.add_job(func=create_daily_backup, trigger="interval", hours=cfg.BACKUP_INTERVAL_HOURS)
scheduler.add_job(func=update_appointment_statuses, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
scheduler.add_job(func=create_return_reminders, trigger="interval", minutes=cfg.SCHEDULER_INTERVAL_MIN)
scheduler.start()
_scheduler_initialized = True
app.logger.info(f"Scheduler started successfully (interval={cfg.SCHEDULER_INTERVAL_MIN} min)")
else:
app.logger.info("Scheduler skipped - another worker instance is running it")
except Exception as e:
app.logger.error(f"Failed to initialize scheduler: {e}")
_scheduler_initialized = False
# Initialize scheduler on app startup
_initialize_scheduler()
# Register shutdown handler to stop scheduler when app is terminated
import atexit
atexit.register(lambda: scheduler.shutdown() if cfg.SCHEDULER_ENABLED else None)
def _shutdown_scheduler():
if cfg.SCHEDULER_ENABLED and _scheduler_initialized:
try:
scheduler.shutdown()
lock_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), '.scheduler_lock')
try:
os.remove(lock_path)
except Exception:
pass
except Exception as e:
app.logger.error(f"Error during scheduler shutdown: {e}")
atexit.register(_shutdown_scheduler)
def allowed_file(filename, file_content=None, max_size_mb=cfg.MAX_UPLOAD_MB):
"""
@@ -4040,6 +4115,9 @@ def get_items():
'Filter3': 1,
'Ort': 1,
'User': 1,
'BlockedNow': 1,
'Reservierbar': 1,
'HasDamage': 1,
'ItemType': 1,
}
+101 -10
View File
@@ -799,10 +799,8 @@
}
function loadItems(offset = 0, append = false) {
// Für Pages nach der ersten: Explizit vollständige Daten laden (light_mode=false)
// Erste Page: light_mode wird automatisch enablet
const lightModeParam = offset > 0 ? '&light_mode=false' : '';
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ITEMS_PAGE_SIZE}${lightModeParam}`)
// Keep list payload lightweight; full details are fetched on-demand in openItemQuick.
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ITEMS_PAGE_SIZE}&light_mode=true`)
.then(response => response.json())
.then(data => {
const itemsContainer = document.querySelector('#items-container');
@@ -1073,12 +1071,9 @@
// Stop event from bubbling up
e.stopPropagation();
// Get full item data with correct image paths
const modalItemData = {...item};
modalItemData.Images = item.Images ? item.Images.map(img => "{{ url_for('uploaded_file', filename='') }}" + img) : [];
openItemModal(modalItemData);
// Always load full, up-to-date item details for the modal.
openItemQuick(item._id);
});
}
} catch (err) {
@@ -1414,6 +1409,76 @@
}
function openItemModal(item) {
const escapeHtml = (value) => String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const formatHistoryDate = (value) => {
if (!value) return '-';
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) {
return escapeHtml(value);
}
return dt.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const damageReports = Array.isArray(item.DamageReports) ? item.DamageReports : [];
const damageRepairs = Array.isArray(item.DamageRepairs) ? item.DamageRepairs : [];
const damageHistoryEntries = [];
damageReports.forEach((report) => {
damageHistoryEntries.push({
type: 'report',
rawDate: report?.reported_at || null,
dateLabel: formatHistoryDate(report?.reported_at),
actor: escapeHtml(report?.reported_by || '-'),
description: escapeHtml(report?.description || 'Schaden gemeldet'),
meta: '',
});
});
damageRepairs.forEach((repair) => {
const resolvedReports = Array.isArray(repair?.resolved_reports) ? repair.resolved_reports : [];
damageHistoryEntries.push({
type: 'repair',
rawDate: repair?.repaired_at || null,
dateLabel: formatHistoryDate(repair?.repaired_at),
actor: escapeHtml(repair?.repaired_by || '-'),
description: 'Als repariert markiert',
meta: `${resolvedReports.length} Meldung(en) abgeschlossen`,
});
});
damageHistoryEntries.sort((a, b) => {
const ta = a.rawDate ? new Date(a.rawDate).getTime() : 0;
const tb = b.rawDate ? new Date(b.rawDate).getTime() : 0;
return (Number.isNaN(tb) ? 0 : tb) - (Number.isNaN(ta) ? 0 : ta);
});
const damageHistoryHtml = damageHistoryEntries.length
? damageHistoryEntries.map((entry) => {
const badgeStyle = entry.type === 'repair'
? 'background:#dcfce7;color:#166534;'
: 'background:#fee2e2;color:#991b1b;';
const badgeText = entry.type === 'repair' ? 'Repariert' : 'Schaden';
const metaLine = entry.meta ? `<div style="font-size:0.84rem;color:#4b5563;">${escapeHtml(entry.meta)}</div>` : '';
return `
<div style="border:1px solid #dbe3ee;border-radius:8px;padding:10px;background:#fff;display:grid;gap:6px;">
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
<span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:0.75rem;font-weight:700;${badgeStyle}">${badgeText}</span>
<span style="font-size:0.84rem;color:#475569;">${entry.dateLabel}</span>
</div>
<div style="font-size:0.9rem;color:#0f172a;"><strong>Von:</strong> ${entry.actor}</div>
<div style="font-size:0.92rem;color:#1f2937;">${entry.description}</div>
${metaLine}
</div>
`;
}).join('')
: '<div style="font-size:0.92rem;color:#64748b;">Keine Beschädigungs-Historie vorhanden.</div>';
// Get modal elements
const modal = document.getElementById('item-modal');
const modalContent = document.getElementById('modal-content-wrapper');
@@ -1571,6 +1636,19 @@
<div class="detail-value">${item.Beschreibung || '-'}</div>
</div>
<div class="detail-group full-width" style="margin-top:12px;">
<div class="detail-label" style="font-weight:600; color:#374151;">Beschädigungs-Historie</div>
<div class="detail-value">
<button id="toggle-damage-history" class="calendar-toggle-btn" style="margin-bottom:12px; padding:10px 16px; border-radius:6px; background:#f3f4f6; border:1px solid #d1d5db; font-weight:500; cursor:pointer; display:inline-flex; align-items:center; gap:8px; transition:all 0.2s ease;">
<span>🛠️</span>
<span id="toggle-damage-history-text">Historie anzeigen</span>
</button>
<div id="damage-history-panel" style="display:none; margin-top:8px; border:1px solid #e7edf5; border-radius:10px; padding:12px; background:#f8fafc;">
<div style="display:grid; gap:10px;">${damageHistoryHtml}</div>
</div>
</div>
</div>
<div class="detail-group full-width" style="margin-top:14px; padding:12px; border:1px solid #e5e7eb; border-radius:10px; background:#fbfbfd; box-shadow: 0 1px 1px rgba(0,0,0,0.03);">
<div class="detail-label" style="font-weight:600; color:#374151;">Verfügbarkeit prüfen</div>
<div class="detail-value">
@@ -1687,6 +1765,9 @@
const detailsPanel = document.getElementById('calendar-day-details');
const detailsDate = document.getElementById('cal-details-date');
const detailsList = document.getElementById('cal-details-list');
const damageToggleBtn = document.getElementById('toggle-damage-history');
const damageHistoryPanel = document.getElementById('damage-history-panel');
const damageToggleText = document.getElementById('toggle-damage-history-text');
let bookings = [];
let currentDate = new Date();
@@ -1847,6 +1928,16 @@
renderCalendar();
});
damageToggleBtn?.addEventListener('click', () => {
const shouldOpen = damageHistoryPanel.style.display === 'none';
damageHistoryPanel.style.display = shouldOpen ? 'block' : 'none';
if (damageToggleText) {
damageToggleText.textContent = shouldOpen ? 'Historie verbergen' : 'Historie anzeigen';
}
damageToggleBtn.style.background = shouldOpen ? '#e0e7ff' : '#f3f4f6';
damageToggleBtn.style.borderColor = shouldOpen ? '#818cf8' : '#d1d5db';
});
// Availability checker (user)
const availDate = document.getElementById('avail-date');
const availStart = document.getElementById('avail-start');
+98 -21
View File
@@ -3415,10 +3415,8 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
function loadItems(offset = 0, append = false) {
// Für Pages nach der ersten: Explizit vollständige Daten laden (light_mode=false)
// Erste Page: light_mode wird automatisch enablet
const lightModeParam = offset > 0 ? '&light_mode=false' : '';
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}${lightModeParam}`)
// Keep list payload lightweight; full details are fetched on-demand in openItemQuick.
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}&light_mode=true`)
.then(response => response.json())
.then(data => {
const itemsContainer = document.querySelector('#items-container');
@@ -3592,6 +3590,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
}
const damageCount = Array.isArray(item.DamageReports) ? item.DamageReports.length : 0;
const hasDamage = Boolean(item.HasDamage) || damageCount > 0;
const groupedCount = Number(item.GroupedDisplayCount || 1);
const availableGroupedCount = Number(item.AvailableGroupedCount ?? (item.Verfuegbar ? 1 : 0));
const isGroupedItem = groupedCount > 1;
@@ -3608,7 +3607,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<p class="item-col-filter3"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
${damageCount > 0 ? `<div class="damage-badge">Schäden gemeldet: ${damageCount}</div>` : ''}
${hasDamage ? `<div class="damage-badge">${damageCount > 0 ? `Schäden gemeldet: ${damageCount}` : 'Schäden gemeldet'}</div>` : ''}
<div class="image-container">
${imagesHtml}
</div>
@@ -3680,11 +3679,8 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
e.stopPropagation();
const modalItemData = {...item};
modalItemData.Images = item.Images ? item.Images.map(img => "{{ url_for('uploaded_file', filename='') }}" + img) : [];
openItemModal(modalItemData);
openItemQuick(item._id);
});
}
} catch (err) {
@@ -4004,6 +4000,22 @@ document.addEventListener('DOMContentLoaded', ()=>{
applyFilters();
}
// Open item modal with fresh details from backend.
function openItemQuick(id) {
fetch(`/get_item/${id}`)
.then(response => response.json())
.then(item => {
if (item && !item.error) {
openItemModal(item);
} else {
console.error('Item details could not be loaded:', item);
}
})
.catch(err => {
console.error('Error loading item details:', err);
});
}
function openEditModalForSelectedUnit(defaultItemId, selectId) {
let targetItemId = defaultItemId;
@@ -4303,14 +4315,58 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
const damageReports = Array.isArray(item.DamageReports) ? item.DamageReports : [];
const damageInfoHtml = damageReports.length > 0
? `<ul class="damage-list">${damageReports.map(report => {
const desc = escapeHtml(report?.description || '-');
const by = escapeHtml(report?.reported_by || 'Unbekannt');
const at = escapeHtml(formatDamageTimestamp(report?.reported_at));
return `<li><strong>${at}</strong> durch ${by}<br>${desc}</li>`;
}).join('')}</ul>`
: 'Keine Schäden erfasst.';
const damageRepairs = Array.isArray(item.DamageRepairs) ? item.DamageRepairs : [];
const damageHistoryEntries = [];
damageReports.forEach(report => {
damageHistoryEntries.push({
type: 'report',
timestamp: report?.reported_at || null,
dateLabel: escapeHtml(formatDamageTimestamp(report?.reported_at)),
actor: escapeHtml(report?.reported_by || 'Unbekannt'),
description: escapeHtml(report?.description || 'Schaden gemeldet'),
meta: '',
});
});
damageRepairs.forEach(repair => {
const resolvedReports = Array.isArray(repair?.resolved_reports) ? repair.resolved_reports : [];
damageHistoryEntries.push({
type: 'repair',
timestamp: repair?.repaired_at || null,
dateLabel: escapeHtml(formatDamageTimestamp(repair?.repaired_at)),
actor: escapeHtml(repair?.repaired_by || 'Unbekannt'),
description: 'Als repariert markiert',
meta: `${resolvedReports.length} Meldung(en) abgeschlossen`,
});
});
damageHistoryEntries.sort((a, b) => {
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return (Number.isNaN(tb) ? 0 : tb) - (Number.isNaN(ta) ? 0 : ta);
});
const damageHistoryHtml = damageHistoryEntries.length > 0
? damageHistoryEntries.map(entry => {
const badgeStyle = entry.type === 'repair'
? 'background:#dcfce7;color:#166534;'
: 'background:#fee2e2;color:#991b1b;';
const badgeText = entry.type === 'repair' ? 'Repariert' : 'Schaden';
const metaLine = entry.meta ? `<div style="font-size:0.84rem;color:#4b5563;">${escapeHtml(entry.meta)}</div>` : '';
return `
<div style="border:1px solid #dbe3ee;border-radius:8px;padding:10px;background:#fff;display:grid;gap:6px;">
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
<span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:0.75rem;font-weight:700;${badgeStyle}">${badgeText}</span>
<span style="font-size:0.84rem;color:#475569;">${entry.dateLabel}</span>
</div>
<div style="font-size:0.9rem;color:#0f172a;"><strong>Von:</strong> ${entry.actor}</div>
<div style="font-size:0.92rem;color:#1f2937;">${entry.description}</div>
${metaLine}
</div>
`;
}).join('')
: '<div style="font-size:0.92rem;color:#64748b;">Keine Beschädigungs-Historie vorhanden.</div>';
modalContent.innerHTML = `
<h2>${item.Name}</h2>
@@ -4385,9 +4441,17 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="detail-value">${item.Beschreibung || '-'}</div>
</div>
<div class="detail-group full-width">
<div class="detail-label">Schäden:</div>
<div class="detail-value">${damageInfoHtml}</div>
<div class="detail-group full-width" style="margin-top:12px;">
<div class="detail-label" style="font-weight:600; color:#374151;">Beschädigungs-Historie</div>
<div class="detail-value">
<button id="toggle-damage-history" class="calendar-toggle-btn" style="margin-bottom:12px; padding:10px 16px; border-radius:6px; background:#f3f4f6; border:1px solid #d1d5db; font-weight:500; cursor:pointer; display:inline-flex; align-items:center; gap:8px; transition:all 0.2s ease;">
<span>🛠️</span>
<span id="toggle-damage-history-text">Historie anzeigen</span>
</button>
<div id="damage-history-panel" style="display:none; margin-top:8px; border:1px solid #e7edf5; border-radius:10px; padding:12px; background:#f8fafc;">
<div style="display:grid; gap:10px;">${damageHistoryHtml}</div>
</div>
</div>
</div>
<div class="detail-group full-width" style="margin-top:12px; padding:10px; border:1px solid #e3e3e3; border-radius:8px;">
@@ -4486,6 +4550,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
const detailsPanel = document.getElementById('calendar-day-details');
const detailsDate = document.getElementById('cal-details-date');
const detailsList = document.getElementById('cal-details-list');
const damageToggleBtn = document.getElementById('toggle-damage-history');
const damageHistoryPanel = document.getElementById('damage-history-panel');
const damageToggleText = document.getElementById('toggle-damage-history-text');
let bookings = [];
let currentDate = new Date();
@@ -4645,6 +4712,16 @@ document.addEventListener('DOMContentLoaded', ()=>{
renderCalendar();
});
damageToggleBtn?.addEventListener('click', () => {
const shouldOpen = damageHistoryPanel.style.display === 'none';
damageHistoryPanel.style.display = shouldOpen ? 'block' : 'none';
if (damageToggleText) {
damageToggleText.textContent = shouldOpen ? 'Historie verbergen' : 'Historie anzeigen';
}
damageToggleBtn.style.background = shouldOpen ? '#e0e7ff' : '#f3f4f6';
damageToggleBtn.style.borderColor = shouldOpen ? '#818cf8' : '#d1d5db';
});
// Availability checker
const availDate = document.getElementById('avail-date');
+42
View File
@@ -0,0 +1,42 @@
"""
Gunicorn configuration for Inventarsystem.
This configuration ensures that:
1. The BackgroundScheduler runs reliably in only one worker process
2. Appointment status updates and reminders work correctly
3. Multi-worker deployments don't cause race conditions
"""
import os
import sys
from pathlib import Path
# Get project root
PROJECT_ROOT = Path(__file__).parent
# Basic configuration
bind = "unix:/tmp/inventarsystem.sock"
workers = 1 # CRITICAL: Only 1 worker to prevent BackgroundScheduler race conditions
worker_class = "sync"
timeout = 60
graceful_timeout = 20
max_requests = 1000
max_requests_jitter = 100
# Logging
accesslog = str(PROJECT_ROOT / "logs" / "access.log")
errorlog = str(PROJECT_ROOT / "logs" / "error.log")
log_level = "info"
capture_output = True
# Worker initialization hook to ensure scheduler starts only once
def on_starting(server):
"""Called just before the master process is initialized."""
print("[GUNICORN] Starting Inventarsystem with scheduler support (1 worker only)")
def when_ready(server):
"""Called just after the server is started."""
print("[GUNICORN] Server is ready. Scheduler should be active in the single worker process.")
# Ensure the logs directory exists
os.makedirs(PROJECT_ROOT / "logs", exist_ok=True)