add: Calander and reservation mode implemented.

This commit is contained in:
serversdwn
2026-02-06 20:40:31 +00:00
parent e515bff1a9
commit eb0a99796d
15 changed files with 2847 additions and 27 deletions

View File

@@ -0,0 +1,668 @@
"""
Fleet Calendar Service
Business logic for:
- Calculating unit availability on any given date
- Calibration status tracking (valid, expiring soon, expired)
- Job reservation management
- Conflict detection (calibration expires mid-job)
"""
from datetime import date, datetime, timedelta
from typing import Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from backend.models import (
RosterUnit, JobReservation, JobReservationUnit,
UserPreferences, Project
)
def get_calibration_status(
unit: RosterUnit,
check_date: date,
warning_days: int = 30
) -> str:
"""
Determine calibration status for a unit on a specific date.
Returns:
"valid" - Calibration is good on this date
"expiring_soon" - Within warning_days of expiry
"expired" - Calibration has expired
"needs_calibration" - No calibration date set
"""
if not unit.last_calibrated:
return "needs_calibration"
# Calculate expiry date (1 year from last calibration)
expiry_date = unit.last_calibrated + timedelta(days=365)
if check_date >= expiry_date:
return "expired"
elif check_date >= expiry_date - timedelta(days=warning_days):
return "expiring_soon"
else:
return "valid"
def get_unit_reservations_on_date(
db: Session,
unit_id: str,
check_date: date
) -> List[JobReservation]:
"""Get all reservations that include this unit on the given date."""
# Get reservation IDs that have this unit assigned
assigned_reservation_ids = db.query(JobReservationUnit.reservation_id).filter(
JobReservationUnit.unit_id == unit_id
).subquery()
# Get reservations that:
# 1. Have this unit assigned AND date is within range
reservations = db.query(JobReservation).filter(
JobReservation.id.in_(assigned_reservation_ids),
JobReservation.start_date <= check_date,
JobReservation.end_date >= check_date
).all()
return reservations
def is_unit_available_on_date(
db: Session,
unit: RosterUnit,
check_date: date,
warning_days: int = 30
) -> Tuple[bool, str, Optional[str]]:
"""
Check if a unit is available on a specific date.
Returns:
(is_available, status, reservation_name)
- is_available: True if unit can be assigned to new work
- status: "available", "reserved", "expired", "retired", "needs_calibration"
- reservation_name: Name of blocking reservation (if any)
"""
# Check if retired
if unit.retired:
return False, "retired", None
# Check calibration status
cal_status = get_calibration_status(unit, check_date, warning_days)
if cal_status == "expired":
return False, "expired", None
if cal_status == "needs_calibration":
return False, "needs_calibration", None
# Check if already reserved
reservations = get_unit_reservations_on_date(db, unit.id, check_date)
if reservations:
return False, "reserved", reservations[0].name
# Unit is available (even if expiring soon - that's just a warning)
return True, "available", None
def get_day_summary(
db: Session,
check_date: date,
device_type: str = "seismograph"
) -> Dict:
"""
Get a complete summary of fleet status for a specific day.
Returns dict with:
- available_units: List of available unit IDs with calibration info
- reserved_units: List of reserved unit IDs with reservation info
- expired_units: List of units with expired calibration
- expiring_soon_units: List of units expiring within warning period
- reservations: List of active reservations on this date
- counts: Summary counts
"""
# Get user preferences for warning days
prefs = db.query(UserPreferences).filter_by(id=1).first()
warning_days = prefs.calibration_warning_days if prefs else 30
# Get all non-retired units of the specified device type
units = db.query(RosterUnit).filter(
RosterUnit.device_type == device_type,
RosterUnit.retired == False
).all()
available_units = []
reserved_units = []
expired_units = []
expiring_soon_units = []
needs_calibration_units = []
cal_expiring_today = [] # Units whose calibration expires ON this day
for unit in units:
is_avail, status, reservation_name = is_unit_available_on_date(
db, unit, check_date, warning_days
)
cal_status = get_calibration_status(unit, check_date, warning_days)
expiry_date = None
if unit.last_calibrated:
expiry_date = (unit.last_calibrated + timedelta(days=365)).isoformat()
unit_info = {
"id": unit.id,
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
"expiry_date": expiry_date,
"calibration_status": cal_status,
"deployed": unit.deployed,
"note": unit.note or ""
}
# Check if calibration expires ON this specific day
if unit.last_calibrated:
unit_expiry_date = unit.last_calibrated + timedelta(days=365)
if unit_expiry_date == check_date:
cal_expiring_today.append(unit_info)
if status == "available":
available_units.append(unit_info)
if cal_status == "expiring_soon":
expiring_soon_units.append(unit_info)
elif status == "reserved":
unit_info["reservation_name"] = reservation_name
reserved_units.append(unit_info)
if cal_status == "expiring_soon":
expiring_soon_units.append(unit_info)
elif status == "expired":
expired_units.append(unit_info)
elif status == "needs_calibration":
needs_calibration_units.append(unit_info)
# Get active reservations on this date
reservations = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= check_date,
JobReservation.end_date >= check_date
).all()
reservation_list = []
for res in reservations:
# Count assigned units for this reservation
assigned_count = db.query(JobReservationUnit).filter(
JobReservationUnit.reservation_id == res.id
).count()
reservation_list.append({
"id": res.id,
"name": res.name,
"start_date": res.start_date.isoformat(),
"end_date": res.end_date.isoformat(),
"assignment_type": res.assignment_type,
"quantity_needed": res.quantity_needed,
"assigned_count": assigned_count,
"color": res.color,
"project_id": res.project_id
})
return {
"date": check_date.isoformat(),
"device_type": device_type,
"available_units": available_units,
"reserved_units": reserved_units,
"expired_units": expired_units,
"expiring_soon_units": expiring_soon_units,
"needs_calibration_units": needs_calibration_units,
"cal_expiring_today": cal_expiring_today,
"reservations": reservation_list,
"counts": {
"available": len(available_units),
"reserved": len(reserved_units),
"expired": len(expired_units),
"expiring_soon": len(expiring_soon_units),
"needs_calibration": len(needs_calibration_units),
"cal_expiring_today": len(cal_expiring_today),
"total": len(units)
}
}
def get_calendar_year_data(
db: Session,
year: int,
device_type: str = "seismograph"
) -> Dict:
"""
Get calendar data for an entire year.
For performance, this returns summary counts per day rather than
full unit lists. Use get_day_summary() for detailed day data.
"""
# Get user preferences
prefs = db.query(UserPreferences).filter_by(id=1).first()
warning_days = prefs.calibration_warning_days if prefs else 30
# Get all units
units = db.query(RosterUnit).filter(
RosterUnit.device_type == device_type,
RosterUnit.retired == False
).all()
# Get all reservations that overlap with this year
# Include TBD reservations (end_date is null) that started before year end
year_start = date(year, 1, 1)
year_end = date(year, 12, 31)
reservations = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= year_end,
or_(
JobReservation.end_date >= year_start,
JobReservation.end_date == None # TBD reservations
)
).all()
# Get all unit assignments for these reservations
reservation_ids = [r.id for r in reservations]
assignments = db.query(JobReservationUnit).filter(
JobReservationUnit.reservation_id.in_(reservation_ids)
).all() if reservation_ids else []
# Build a lookup: unit_id -> list of (start_date, end_date, reservation_name)
# For TBD reservations, use estimated_end_date if available, or a far future date
unit_reservations = {}
for res in reservations:
res_assignments = [a for a in assignments if a.reservation_id == res.id]
for assignment in res_assignments:
unit_id = assignment.unit_id
# Use unit-specific dates if set, otherwise use reservation dates
start_d = assignment.unit_start_date or res.start_date
if assignment.unit_end_tbd or (assignment.unit_end_date is None and res.end_date_tbd):
# TBD: use estimated date or far future for availability calculation
end_d = res.estimated_end_date or date(year + 5, 12, 31)
else:
end_d = assignment.unit_end_date or res.end_date or date(year + 5, 12, 31)
if unit_id not in unit_reservations:
unit_reservations[unit_id] = []
unit_reservations[unit_id].append((start_d, end_d, res.name))
# Generate data for each month
months_data = {}
for month in range(1, 13):
# Get first and last day of month
first_day = date(year, month, 1)
if month == 12:
last_day = date(year, 12, 31)
else:
last_day = date(year, month + 1, 1) - timedelta(days=1)
days_data = {}
current_day = first_day
while current_day <= last_day:
available = 0
reserved = 0
expired = 0
expiring_soon = 0
needs_cal = 0
cal_expiring_on_day = 0 # Units whose calibration expires ON this day
cal_expired_on_day = 0 # Units whose calibration expired ON this day
for unit in units:
# Check calibration
cal_status = get_calibration_status(unit, current_day, warning_days)
# Check if calibration expires/expired ON this specific day
if unit.last_calibrated:
unit_expiry = unit.last_calibrated + timedelta(days=365)
if unit_expiry == current_day:
cal_expiring_on_day += 1
# Check if expired yesterday (first day of being expired)
elif unit_expiry == current_day - timedelta(days=1):
cal_expired_on_day += 1
if cal_status == "expired":
expired += 1
continue
if cal_status == "needs_calibration":
needs_cal += 1
continue
# Check if reserved
is_reserved = False
if unit.id in unit_reservations:
for start_d, end_d, _ in unit_reservations[unit.id]:
if start_d <= current_day <= end_d:
is_reserved = True
break
if is_reserved:
reserved += 1
else:
available += 1
if cal_status == "expiring_soon":
expiring_soon += 1
days_data[current_day.day] = {
"available": available,
"reserved": reserved,
"expired": expired,
"expiring_soon": expiring_soon,
"needs_calibration": needs_cal,
"cal_expiring_on_day": cal_expiring_on_day,
"cal_expired_on_day": cal_expired_on_day
}
current_day += timedelta(days=1)
months_data[month] = {
"name": first_day.strftime("%B"),
"short_name": first_day.strftime("%b"),
"days": days_data,
"first_weekday": first_day.weekday(), # 0=Monday, 6=Sunday
"num_days": last_day.day
}
# Also include reservation summary for the year
reservation_list = []
for res in reservations:
assigned_count = len([a for a in assignments if a.reservation_id == res.id])
reservation_list.append({
"id": res.id,
"name": res.name,
"start_date": res.start_date.isoformat(),
"end_date": res.end_date.isoformat(),
"quantity_needed": res.quantity_needed,
"assigned_count": assigned_count,
"color": res.color
})
return {
"year": year,
"device_type": device_type,
"months": months_data,
"reservations": reservation_list,
"total_units": len(units)
}
def get_rolling_calendar_data(
db: Session,
start_year: int,
start_month: int,
device_type: str = "seismograph"
) -> Dict:
"""
Get calendar data for 12 months starting from a specific month/year.
This supports the rolling calendar view where users can scroll through
months one at a time, viewing any 12-month window.
"""
# Get user preferences
prefs = db.query(UserPreferences).filter_by(id=1).first()
warning_days = prefs.calibration_warning_days if prefs else 30
# Get all units
units = db.query(RosterUnit).filter(
RosterUnit.device_type == device_type,
RosterUnit.retired == False
).all()
# Calculate the date range for 12 months
first_date = date(start_year, start_month, 1)
# Calculate end date (12 months later)
end_year = start_year + 1 if start_month == 1 else start_year
end_month = 12 if start_month == 1 else start_month - 1
if start_month == 1:
end_year = start_year
end_month = 12
else:
# 12 months from start_month means we end at start_month - 1 next year
end_year = start_year + 1
end_month = start_month - 1
# Actually, simpler: go 11 months forward from start
end_year = start_year + ((start_month + 10) // 12)
end_month = ((start_month + 10) % 12) + 1
if end_month == 12:
last_date = date(end_year, 12, 31)
else:
last_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
# Get all reservations that overlap with this 12-month range
reservations = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= last_date,
or_(
JobReservation.end_date >= first_date,
JobReservation.end_date == None # TBD reservations
)
).all()
# Get all unit assignments for these reservations
reservation_ids = [r.id for r in reservations]
assignments = db.query(JobReservationUnit).filter(
JobReservationUnit.reservation_id.in_(reservation_ids)
).all() if reservation_ids else []
# Build a lookup: unit_id -> list of (start_date, end_date, reservation_name)
unit_reservations = {}
for res in reservations:
res_assignments = [a for a in assignments if a.reservation_id == res.id]
for assignment in res_assignments:
unit_id = assignment.unit_id
start_d = assignment.unit_start_date or res.start_date
if assignment.unit_end_tbd or (assignment.unit_end_date is None and res.end_date_tbd):
end_d = res.estimated_end_date or date(start_year + 5, 12, 31)
else:
end_d = assignment.unit_end_date or res.end_date or date(start_year + 5, 12, 31)
if unit_id not in unit_reservations:
unit_reservations[unit_id] = []
unit_reservations[unit_id].append((start_d, end_d, res.name))
# Generate data for each of the 12 months
months_data = []
current_year = start_year
current_month = start_month
for i in range(12):
# Calculate this month's year and month
m_year = start_year + ((start_month - 1 + i) // 12)
m_month = ((start_month - 1 + i) % 12) + 1
first_day = date(m_year, m_month, 1)
if m_month == 12:
last_day = date(m_year, 12, 31)
else:
last_day = date(m_year, m_month + 1, 1) - timedelta(days=1)
days_data = {}
current_day = first_day
while current_day <= last_day:
available = 0
reserved = 0
expired = 0
expiring_soon = 0
needs_cal = 0
cal_expiring_on_day = 0
cal_expired_on_day = 0
for unit in units:
cal_status = get_calibration_status(unit, current_day, warning_days)
if unit.last_calibrated:
unit_expiry = unit.last_calibrated + timedelta(days=365)
if unit_expiry == current_day:
cal_expiring_on_day += 1
elif unit_expiry == current_day - timedelta(days=1):
cal_expired_on_day += 1
if cal_status == "expired":
expired += 1
continue
if cal_status == "needs_calibration":
needs_cal += 1
continue
is_reserved = False
if unit.id in unit_reservations:
for start_d, end_d, _ in unit_reservations[unit.id]:
if start_d <= current_day <= end_d:
is_reserved = True
break
if is_reserved:
reserved += 1
else:
available += 1
if cal_status == "expiring_soon":
expiring_soon += 1
days_data[current_day.day] = {
"available": available,
"reserved": reserved,
"expired": expired,
"expiring_soon": expiring_soon,
"needs_calibration": needs_cal,
"cal_expiring_on_day": cal_expiring_on_day,
"cal_expired_on_day": cal_expired_on_day
}
current_day += timedelta(days=1)
months_data.append({
"year": m_year,
"month": m_month,
"name": first_day.strftime("%B"),
"short_name": first_day.strftime("%b"),
"year_short": first_day.strftime("%y"),
"days": days_data,
"first_weekday": first_day.weekday(),
"num_days": last_day.day
})
return {
"start_year": start_year,
"start_month": start_month,
"device_type": device_type,
"months": months_data,
"total_units": len(units)
}
def check_calibration_conflicts(
db: Session,
reservation_id: str
) -> List[Dict]:
"""
Check if any units assigned to a reservation will have their
calibration expire during the reservation period.
Returns list of conflicts with unit info and expiry date.
"""
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
if not reservation:
return []
# Get assigned units
assigned = db.query(JobReservationUnit).filter_by(
reservation_id=reservation_id
).all()
conflicts = []
for assignment in assigned:
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
if not unit or not unit.last_calibrated:
continue
expiry_date = unit.last_calibrated + timedelta(days=365)
# Check if expiry falls within reservation period
if reservation.start_date < expiry_date <= reservation.end_date:
conflicts.append({
"unit_id": unit.id,
"last_calibrated": unit.last_calibrated.isoformat(),
"expiry_date": expiry_date.isoformat(),
"reservation_name": reservation.name,
"days_into_job": (expiry_date - reservation.start_date).days
})
return conflicts
def get_available_units_for_period(
db: Session,
start_date: date,
end_date: date,
device_type: str = "seismograph",
exclude_reservation_id: Optional[str] = None
) -> List[Dict]:
"""
Get units that are available for the entire specified period.
A unit is available if:
- Not retired
- Calibration is valid through the end date
- Not assigned to any other reservation that overlaps the period
"""
prefs = db.query(UserPreferences).filter_by(id=1).first()
warning_days = prefs.calibration_warning_days if prefs else 30
units = db.query(RosterUnit).filter(
RosterUnit.device_type == device_type,
RosterUnit.retired == False
).all()
# Get reservations that overlap with this period
overlapping_reservations = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= end_date,
JobReservation.end_date >= start_date
)
if exclude_reservation_id:
overlapping_reservations = overlapping_reservations.filter(
JobReservation.id != exclude_reservation_id
)
overlapping_reservations = overlapping_reservations.all()
# Get all units assigned to overlapping reservations
reserved_unit_ids = set()
for res in overlapping_reservations:
assigned = db.query(JobReservationUnit).filter_by(
reservation_id=res.id
).all()
for a in assigned:
reserved_unit_ids.add(a.unit_id)
available_units = []
for unit in units:
# Check if already reserved
if unit.id in reserved_unit_ids:
continue
# Check calibration through end of period
if not unit.last_calibrated:
continue # Needs calibration
expiry_date = unit.last_calibrated + timedelta(days=365)
if expiry_date <= end_date:
continue # Calibration expires during period
cal_status = get_calibration_status(unit, end_date, warning_days)
available_units.append({
"id": unit.id,
"last_calibrated": unit.last_calibrated.isoformat(),
"expiry_date": expiry_date.isoformat(),
"calibration_status": cal_status,
"deployed": unit.deployed,
"note": unit.note or ""
})
return available_units

View File

@@ -70,6 +70,10 @@ async def sync_slm_status_to_emitters() -> Dict[str, Any]:
# Convert to naive UTC for consistency with existing code
if last_seen.tzinfo:
last_seen = last_seen.astimezone(timezone.utc).replace(tzinfo=None)
elif is_reachable:
# Device is reachable but no last_success yet (first poll or just started)
# Use current time so it shows as OK, not Missing
last_seen = datetime.utcnow()
else:
last_seen = None