Compare commits

...

9 Commits

14 changed files with 276 additions and 60 deletions
+3 -1
View File
@@ -2,4 +2,6 @@ dist
logs
certs
build
.venv
.venv
__pycache__
.pyc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+170 -34
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')
@@ -1147,6 +1149,7 @@ def update_appointment_statuses():
)
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({
@@ -1193,30 +1196,46 @@ def update_appointment_statuses():
updated_count += 1
if new_status == 'active':
activated_count += 1
try:
_create_notification(
db,
audience='user',
notif_type='appointment_activated',
title='Reservierung ist jetzt aktiv',
message=(
f"Deine geplante Ausleihe für {item_name} startet jetzt."
),
target_user=str(appointment.get('User') or '').strip() or None,
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}"
)
# Make item unshareable if no conflict is detected
if old_status == 'planned' and appointment.get('Item') and not extra_fields.get('ConflictDetected', False):
try:
it.update_item_status(str(appointment.get('Item')), False, activation_user)
except Exception as e:
app.logger.warning(f"Could not update item status to False for {appointment['_id']}: {e}")
elif new_status == 'completed':
completed_count += 1
# Make item available again
if appointment.get('Item'):
try:
it.update_item_status(str(appointment.get('Item')), True)
except Exception as e:
app.logger.warning(f"Could not update item status to True for {appointment['_id']}: {e}")
# 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()
@@ -1228,17 +1247,70 @@ 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, use a lock file to ensure only one instance starts the scheduler
# Clean up any stale lock file from previous runs (older than 5 minutes)
scheduler_lock_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), '.scheduler_lock')
try:
if os.path.exists(scheduler_lock_path):
lock_age = time.time() - os.path.getmtime(scheduler_lock_path)
if lock_age > 300: # 5 minutes - indicates a stale lock from a previous container run
os.remove(scheduler_lock_path)
app.logger.info(f"Removed stale scheduler lock file (age: {lock_age:.0f}s)")
except Exception:
pass # If we can't clean up, continue anyway
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):
"""
@@ -8963,8 +9035,7 @@ def my_borrowed_items():
planned_ausleihungen = list(ausleihungen_collection.find({
'User': username,
'Status': 'planned',
'Start': {'$gt': current_time}
'Status': 'planned'
}))
# DEBUG: Log the number of planned appointments found
@@ -9596,21 +9667,76 @@ def schedule_appointment():
print(f"Error checking for booking conflicts: {e}")
return jsonify({'success': False, 'message': f'Fehler beim Prüfen der Verfügbarkeit: {str(e)}'}), 500
# Create the appointment as a planned booking
# Check if the appointment should already be active
now = datetime.datetime.now()
initial_status = 'active' if start_datetime <= now else 'planned'
# Create the appointment
try:
appointment_id = au.add_planned_booking(
# Use add_ausleihung directly to set the correct initial status
appointment_id = au.add_ausleihung(
item_id=item_id,
user=session['username'],
start_date=start_datetime,
end_date=end_datetime,
notes=notes,
status=initial_status,
period=booking_period # Will be None for multi-day
)
# If it became active immediately, log it and send a notification
if initial_status == 'active' and appointment_id:
app.logger.info(f"Appointment {appointment_id} scheduled retroactively as active.")
# Make the item unavailable since it is now actively borrowed
try:
it.update_item_status(item_id, False, session['username'])
except Exception as update_err:
app.logger.warning(f"Failed to update item status when retroactively activating: {update_err}")
# We can also notify the user right away
item_name = item.get('Name', 'Unbekannt')
# Log audit event
_append_audit_event_standalone(
'ausleihung_started',
{
'borrow_id': str(appointment_id),
'item_id': item_id,
'item_name': item_name,
'user': session['username'],
'status_before': 'planned',
'status_after': 'active'
}
)
# Send notification
try:
client_temp = MongoClient(MONGODB_HOST, MONGODB_PORT)
db_temp = client_temp[MONGODB_DB]
_create_notification(
db_temp,
audience='user',
notif_type='appointment_activated',
title='Reservierung ist jetzt aktiv',
message=f"Deine geplante Ausleihe für {item_name} startet jetzt.",
target_user=session['username'],
reference={
'appointment_id': str(appointment_id),
'item_id': str(item_id),
'event': 'activated',
},
unique_key=f"appointment:activated:{appointment_id}",
severity='info'
)
client_temp.close()
except Exception as notif_err:
app.logger.error(f"Error sending immediate active notification: {notif_err}")
if not appointment_id:
return jsonify({'success': False, 'message': 'Termin konnte nicht erstellt werden'}), 500
except Exception as e:
print(f"Error creating planned booking: {e}")
print(f"Error creating booking: {e}")
return jsonify({'success': False, 'message': f'Fehler beim Erstellen des Termins: {str(e)}'}), 500
# If we got this far, we have a valid appointment_id
@@ -9697,6 +9823,16 @@ def cancel_ausleihung_route(id):
if au.cancel_ausleihung(id):
print(f"Successfully canceled ausleihung with ID: {id}")
flash('Ausleihung wurde erfolgreich storniert', 'success')
# If the booking was already active, make the item available again
item_id = str(ausleihung.get('Item')) if ausleihung.get('Item') is not None else None
if ausleihung_status == 'active' and item_id:
try:
it.update_item_status(item_id, True)
print(f"Restored availability of item {item_id} after active cancellation")
except Exception as status_err:
print(f"Warning: could not restore availability of item {item_id}: {status_err}")
_append_audit_event_standalone(
'ausleihung_cancelled',
{
+4 -2
View File
@@ -287,15 +287,17 @@ def update_item_status(id, verfuegbar, user=None):
'LastUpdated': datetime.datetime.now()
}
update_query = {'$set': update_data}
if user is not None:
update_data['User'] = user
elif verfuegbar:
# If item is being marked as available, clear the user field
update_data['$unset'] = {'User': ""}
update_query['$unset'] = {'User': ""}
result = items.update_one(
{'_id': ObjectId(id)},
{'$set': update_data}
update_query
)
client.close()
+16
View File
@@ -4000,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;
+4 -2
View File
@@ -16,8 +16,10 @@ services:
container_name: inventarsystem-nginx
restart: unless-stopped
depends_on:
- app
- redis
app:
condition: service_started
redis:
condition: service_started
ports:
- "${INVENTAR_HTTP_PORT:-80}:80"
- "${INVENTAR_HTTPS_PORT:-443}:443"
+2 -1
View File
@@ -4,7 +4,8 @@ services:
container_name: inventarsystem-nginx
restart: unless-stopped
depends_on:
- app
app:
condition: service_started
ports:
- "${INVENTAR_HTTP_PORT:-80}:80"
- "${INVENTAR_HTTPS_PORT:-443}:443"
+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)
+35 -20
View File
@@ -69,45 +69,60 @@ with open(compose_file, "r", encoding="utf-8") as f:
out = []
in_app = False
in_build = False
image_set = False
app_indent = None
app_service_indent = None
skip_build_block = False
def leading_spaces(text):
return len(text) - len(text.lstrip(" "))
for line in lines:
stripped = line.lstrip(" ")
indent = len(line) - len(stripped)
indent = leading_spaces(line)
if not in_app and re.match(r"^\s{2}app:\s*$", line):
if not in_app and re.match(r"^\s*app:\s*$", line):
in_app = True
image_set = False
app_indent = indent
app_service_indent = None
skip_build_block = False
out.append(line)
out.append(f" image: {target_image}\n")
image_set = True
continue
if in_app:
if indent == 2 and re.match(r"^[A-Za-z0-9_-]+:\s*$", stripped):
if app_service_indent is None and indent > app_indent:
app_service_indent = indent
if indent <= app_indent and re.match(r"^[A-Za-z0-9_-]+:\s*$", stripped):
in_app = False
in_build = False
app_indent = None
app_service_indent = None
skip_build_block = False
if in_app:
if in_build:
if indent > 4:
continue
in_build = False
if app_service_indent is None:
app_service_indent = indent
if re.match(r"^\s{4}build:\s*$", line):
in_build = True
if skip_build_block:
if indent > app_service_indent:
continue
skip_build_block = False
if re.match(rf"^\s{{{app_service_indent}}}build:\s*$", line):
skip_build_block = True
continue
if re.match(r"^\s{4}image:\s*", line):
if image_set:
continue
out.append(f" image: {target_image}\n")
image_set = True
if re.match(rf"^\s{{{app_service_indent}}}image:\s*", line):
continue
if re.match(rf"^\s{{{app_service_indent}}}[A-Za-z0-9_-]+:\s*$", line):
out.append(f"{' ' * app_service_indent}image: {target_image}\n")
app_service_indent = None
out.append(line)
if in_app and app_service_indent is not None:
out.append(f"{' ' * app_service_indent}image: {target_image}\n")
with open(compose_file, "w", encoding="utf-8") as f:
f.writelines(out)
PY