Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 110327b73e | |||
| d8cd1906b3 | |||
| b8a7d6c797 | |||
| ac3e48da3d | |||
| 5d9069e690 |
+3
-1
@@ -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.
+142
-12
@@ -1196,8 +1196,21 @@ def update_appointment_statuses():
|
||||
updated_count += 1
|
||||
if new_status == 'active':
|
||||
activated_count += 1
|
||||
# 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:
|
||||
@@ -1234,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):
|
||||
"""
|
||||
@@ -8969,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
|
||||
@@ -9602,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
|
||||
@@ -9703,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
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user