fix scheduler orario start, immagine parcheggio, added more logs function

This commit is contained in:
2026-02-12 19:57:00 +01:00
parent a94ec11c80
commit 8f5c1e1f94
12 changed files with 192 additions and 24 deletions

View File

@@ -26,6 +26,9 @@ router = APIRouter(prefix="/api/offices", tags=["offices"])
class ValidOfficeCreate(BaseModel):
name: str
parking_quota: int = 0
booking_window_enabled: bool = True
booking_window_end_hour: int = 18
booking_window_end_minute: int = 0
class ClosingDayCreate(BaseModel):
@@ -121,6 +124,9 @@ def create_office(data: ValidOfficeCreate, db: Session = Depends(get_db), user=D
name=data.name,
parking_quota=data.parking_quota,
spot_prefix=get_next_available_prefix(db),
booking_window_enabled=data.booking_window_enabled,
booking_window_end_hour=data.booking_window_end_hour,
booking_window_end_minute=data.booking_window_end_minute,
created_at=datetime.utcnow()
)
db.add(office)
@@ -180,12 +186,12 @@ def update_office_settings(office_id: str, data: OfficeSettingsUpdate, db: Sessi
if data.booking_window_end_hour is not None:
if not (0 <= data.booking_window_end_hour <= 23):
raise HTTPException(status_code=400, detail="Hour must be 0-23")
raise HTTPException(status_code=400, detail="Hour must be 0-23")
office.booking_window_end_hour = data.booking_window_end_hour
if data.booking_window_end_minute is not None:
if not (0 <= data.booking_window_end_minute <= 59):
raise HTTPException(status_code=400, detail="Minute must be 0-59")
raise HTTPException(status_code=400, detail="Minute must be 0-59")
office.booking_window_end_minute = data.booking_window_end_minute
office.updated_at = datetime.utcnow()

View File

@@ -26,10 +26,10 @@ services:
networks:
- org-network
labels:
- "caddy=parking.lvh.me"
- "caddy=parcheggio.lvh.me"
- "caddy.reverse_proxy={{upstreams 8000}}"
- "caddy.forward_auth=authelia:9091"
- "caddy.forward_auth.uri=/api/verify?rd=https://parking.lvh.me/"
- "caddy.forward_auth.uri=/api/verify?rd=https://parcheggio.lvh.me/"
- "caddy.forward_auth.copy_headers=Remote-User Remote-Groups Remote-Name Remote-Email"
# cambiare l'url delle label per il reverse proxy

View File

@@ -241,15 +241,7 @@ class NotificationLog(Base):
)
notification_type = Column(Enum(NotificationType, values_callable=lambda obj: [e.value for e in obj]), nullable=False) # parking_change
subject = Column(Text, nullable=False)
body = Column(Text, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
sent_at = Column(DateTime) # null = not sent yet
__table_args__ = (
Index('idx_queue_pending', 'sent_at'),
)
class OfficeSpot(Base):

Binary file not shown.

Before

Width:  |  Height:  |  Size: 786 KiB

After

Width:  |  Height:  |  Size: 829 KiB

View File

@@ -17,8 +17,36 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadOffices();
setupEventListeners();
populateTimeSelects();
});
function populateTimeSelects() {
const hoursSelect = document.getElementById('officeCutoffHour');
const minutesSelect = document.getElementById('officeCutoffMinute');
// Clear existing
hoursSelect.innerHTML = '';
minutesSelect.innerHTML = ''; // Re-creating to allow 0-59 range
// Populate Hours 0-23
for (let i = 0; i < 24; i++) {
const val = i.toString().padStart(2, '0');
const opt = document.createElement('option');
opt.value = i;
opt.textContent = val;
hoursSelect.appendChild(opt);
}
// Populate Minutes 0-59
for (let i = 0; i < 60; i++) {
const val = i.toString().padStart(2, '0');
const opt = document.createElement('option');
opt.value = i;
opt.textContent = val;
minutesSelect.appendChild(opt);
}
}
async function loadOffices() {
const response = await api.get('/api/offices');
if (response && response.ok) {
@@ -70,6 +98,11 @@ async function editOffice(officeId) {
document.getElementById('officeName').value = office.name;
document.getElementById('officeQuota').value = office.parking_quota;
// Set booking window settings
document.getElementById('officeWindowEnabled').checked = office.booking_window_enabled !== false;
document.getElementById('officeCutoffHour').value = office.booking_window_end_hour != null ? office.booking_window_end_hour : 18;
document.getElementById('officeCutoffMinute').value = office.booking_window_end_minute != null ? office.booking_window_end_minute : 0;
openModal('Modifica Ufficio');
}
@@ -123,7 +156,10 @@ async function handleOfficeSubmit(e) {
const officeId = document.getElementById('officeId').value;
const data = {
name: document.getElementById('officeName').value,
parking_quota: parseInt(document.getElementById('officeQuota').value) || 0
parking_quota: parseInt(document.getElementById('officeQuota').value) || 0,
booking_window_enabled: document.getElementById('officeWindowEnabled').checked,
booking_window_end_hour: parseInt(document.getElementById('officeCutoffHour').value),
booking_window_end_minute: parseInt(document.getElementById('officeCutoffMinute').value)
};
console.log('Payload:', data);

View File

@@ -72,6 +72,15 @@ function populateHourSelect() {
option.textContent = h.toString().padStart(2, '0');
select.appendChild(option);
}
const minuteSelect = document.getElementById('bookingWindowMinute');
minuteSelect.innerHTML = '';
for (let m = 0; m < 60; m++) {
const option = document.createElement('option');
option.value = m;
option.textContent = m.toString().padStart(2, '0');
minuteSelect.appendChild(option);
}
}
async function loadOfficeSettings(id) {

View File

@@ -93,6 +93,27 @@
<small class="text-muted">Numero totale di posti auto assegnati a questo ufficio</small>
</div>
<div class="form-group">
<label>Orario di Cut-off (Giorno Precedente)</label>
<div style="display: flex; gap: 10px; align-items: center;">
<select id="officeCutoffHour" class="form-control" style="width: 80px;">
<!-- Hours 0-23 generated by JS -->
</select>
<span>:</span>
<select id="officeCutoffMinute" class="form-control" style="width: 80px;">
<option value="0">00</option>
<option value="15">15</option>
<option value="30">30</option>
<option value="45">45</option>
</select>
<label style="margin-left: 10px; display: flex; align-items: center; gap: 5px;">
<input type="checkbox" id="officeWindowEnabled">
Abilita Assegnazione Automatica
</label>
</div>
<small class="text-muted">Orario limite per la prenotazione del giorno successivo</small>
</div>
<div class="form-actions">

View File

@@ -88,10 +88,7 @@
</select>
<span>:</span>
<select id="bookingWindowMinute" style="width: 80px;">
<option value="0">00</option>
<option value="15">15</option>
<option value="30">30</option>
<option value="45">45</option>
<!-- Populated by JS -->
</select>
</div>
<small class="text-muted">Le presenze inserite prima di questo orario saranno messe in
@@ -159,7 +156,8 @@
<button id="testEmailBtn" class="btn btn-secondary">
Test (Solo a Me)
</button>
<button id="bulkEmailBtn" class="btn btn-warning" title="Invia mail reale a tutti gli assegnatari">
<button id="bulkEmailBtn" class="btn btn-warning"
title="Invia mail reale a tutti gli assegnatari">
Test (A Tutti)
</button>
</div>

View File

@@ -24,6 +24,7 @@ from app.routes.parking import router as parking_router
from app.routes.parking import router as parking_router
from database.connection import init_db, get_db_session
from services.notifications import run_scheduled_notifications
from services.parking import process_daily_allocations
import asyncio
# Rate limiter setup
@@ -37,6 +38,7 @@ async def scheduler_task():
try:
with get_db_session() as db:
run_scheduled_notifications(db)
process_daily_allocations(db)
except Exception as e:
config.logger.error(f"Scheduler error: {e}")

View File

@@ -228,7 +228,10 @@ def send_daily_parking_reminder(user: "User", date_obj: datetime, db: "Session")
from database.models import DailyParkingAssignment, NotificationLog
from services.parking import get_spot_display_name
config.logger.info(f"[SCHEDULER] Checking daily parking reminder for user {user.email}")
if not user.notify_daily_parking:
config.logger.debug(f"[SCHEDULER] User {user.email} has disabled daily parking notifications")
return False
date_str = date_obj.strftime("%Y-%m-%d")
@@ -308,10 +311,10 @@ def run_scheduled_notifications(db: "Session"):
users = db.query(User).all()
for user in users:
# Thursday at 12: Presence reminder
if current_weekday == 3 and current_hour == 12 and current_minute < 5:
next_week = get_next_week_dates(today_date)
send_presence_reminder(user, next_week, db)
# Thursday Reminder: DISABLED as per user request
# if current_weekday == 3 and current_hour == 12 and current_minute < 5:
# next_week = get_next_week_dates(today_date)
# send_presence_reminder(user, next_week, db)
# Daily parking reminder at user's preferred time
user_hour = user.notify_daily_parking_hour or 8
@@ -319,6 +322,7 @@ def run_scheduled_notifications(db: "Session"):
# Check if it's the right time for this user
if current_hour == user_hour and abs(current_minute - user_minute) < 5:
config.logger.info(f"[SCHEDULER] Triggering Daily Parking Reminder check for user {user.email} (Scheduled: {user_hour}:{user_minute})")
# Check if Office is OPEN today
is_office_open = True

View File

@@ -19,6 +19,7 @@ from database.models import (
)
from utils.helpers import generate_uuid
from app import config
from services.notifications import notify_parking_assigned
def get_spot_prefix(office: Office, db: Session) -> str:
@@ -252,6 +253,20 @@ def assign_parking_fairly(office_id: str, pool_date: date, db: Session) -> dict:
waitlist.append(candidate["user_id"])
db.commit()
# Send notifications to successful assignees
for user_id in assigned:
user = db.query(User).filter(User.id == user_id).first()
if user:
# Re-fetch the assignment to get the spot details
assignment = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user_id,
DailyParkingAssignment.date == pool_date
).first()
if assignment:
spot_name = get_spot_display_name(assignment.spot_id, office_id, db)
notify_parking_assigned(user, pool_date, spot_name)
return {"assigned": assigned, "waitlist": waitlist}
@@ -317,15 +332,20 @@ def handle_presence_change(user_id: str, change_date: date, old_status: Presence
# Check booking window
should_assign = True
if office.booking_window_enabled:
from zoneinfo import ZoneInfo
tz = ZoneInfo(config.TIMEZONE)
now = datetime.now(tz)
# Allocation time is Day-1 at cutoff hour
cutoff_dt = datetime.combine(change_date - timedelta(days=1), datetime.min.time())
cutoff_dt = cutoff_dt.replace(
hour=office.booking_window_end_hour,
minute=office.booking_window_end_minute
minute=office.booking_window_end_minute,
tzinfo=tz
)
# If now is before cutoff, do not assign yet (wait for batch job)
if datetime.utcnow() < cutoff_dt:
if now < cutoff_dt:
should_assign = False
config.logger.debug(f"Queuing parking request for user {user_id} on {change_date} (Window open until {cutoff_dt})")
@@ -362,3 +382,35 @@ def run_batch_allocation(office_id: str, pool_date: date, db: Session) -> dict:
# 2. Run fair allocation
return assign_parking_fairly(office_id, pool_date, db)
def process_daily_allocations(db: Session):
"""
Check if any office's booking window has just closed and run batch allocation.
Run by scheduler every minute.
HALT: Checks if the cutoff time for TOMORROW has been reached.
"""
from zoneinfo import ZoneInfo
# Use configured timezone
tz = ZoneInfo(config.TIMEZONE)
now = datetime.now(tz)
# Check all offices with window enabled
offices = db.query(Office).filter(Office.booking_window_enabled == True).all()
config.logger.debug(f"[SCHEDULER] Checking booking windows for {len(offices)} offices - Current Time: {now.strftime('%H:%M')}")
for office in offices:
# Cutoff is defined as "Previous Day" (today) at Booking End Hour
# If NOW matches the cutoff time, we run allocation for TOMORROW
if now.hour == office.booking_window_end_hour and now.minute == office.booking_window_end_minute:
target_date = now.date() + timedelta(days=1)
config.logger.info(f"[SCHEDULER] CUTOFF REACHED for {office.name} (Cutoff: {office.booking_window_end_hour}:{office.booking_window_end_minute}). Starting Assegnazione Giornaliera parcheggi for {target_date}")
try:
run_batch_allocation(office.id, target_date, db)
config.logger.info(f"[SCHEDULER] Assegnazione Giornaliera parcheggi completed for {office.name} on {target_date}")
except Exception as e:
config.logger.error(f"[SCHEDULER] Failed Assegnazione Giornaliera parcheggi for {office.name}: {e}")

48
upgrade_db_v1.py Normal file
View File

@@ -0,0 +1,48 @@
import sqlite3
import os
import sys
# Default path inside Docker container
DEFAULT_DB_PATH = "/app/data/parking.db"
def migrate():
# Allow overriding DB path via env var or argument
db_path = os.getenv("DATABASE_PATH", DEFAULT_DB_PATH)
if len(sys.argv) > 1:
db_path = sys.argv[1]
if not os.path.exists(db_path):
print(f"Error: Database file not found at {db_path}")
print("Usage: python upgrade_db_v1.py [path_to_db]")
sys.exit(1)
print(f"Migrating database at: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
columns_to_add = [
("booking_window_enabled", "BOOLEAN", "0"),
("booking_window_end_hour", "INTEGER", "18"),
("booking_window_end_minute", "INTEGER", "0")
]
for col_name, col_type, default_val in columns_to_add:
try:
# Check if column exists
cursor.execute(f"SELECT {col_name} FROM offices LIMIT 1")
except sqlite3.OperationalError:
# Column doesn't exist, add it
print(f"Adding column: {col_name} ({col_type})")
try:
cursor.execute(f"ALTER TABLE offices ADD COLUMN {col_name} {col_type} DEFAULT {default_val}")
except Exception as e:
print(f"Failed to add column {col_name}: {e}")
else:
print(f"Column {col_name} already exists. Skipping.")
conn.commit()
conn.close()
print("Migration complete.")
if __name__ == "__main__":
migrate()