gmao/app_new/core/services/room_planning.py

136 lines
5.9 KiB
Python
Raw Normal View History

"""Résolution des occupations internes d'une salle.
Pronote est traité ici comme une provenance optionnelle. Les autres services
ne doivent appeler que ``active_room_schedules`` ou ``room_is_available``.
"""
from datetime import date, datetime, time, timedelta
from ..models.college import RoomSchedule
from ...extensions import db
def _overlaps(left_start, left_end, right_start, right_end):
return left_start < right_end and left_end > right_start
def active_room_schedules(room_id, day=None):
query = RoomSchedule.query.filter_by(room_id=room_id, resolution_status="active")
rows = query.order_by(RoomSchedule.start_time).all()
if day is not None:
monday = day - timedelta(days=day.weekday())
rows = [row for row in rows if row.week_start == monday and
row.day_of_week == day.weekday() and
(not row.valid_from or row.valid_from <= day) and
(not row.valid_to or day <= row.valid_to)]
pronote = [row for row in rows if row.source == "pronote"]
resolved = []
for row in rows:
if row.source == "manual" and not row.protected_from_sync:
replacement = any(
_overlaps(row.start_time, row.end_time, other.start_time, other.end_time)
and (not row.class_name or not other.class_name or row.class_name == other.class_name)
and (not row.subject or not other.subject or row.subject == other.subject)
for other in pronote
)
if replacement:
continue
resolved.append(row)
return resolved
def room_occupancy(room_id, day):
return [(row.start_time, row.end_time, row) for row in active_room_schedules(room_id, day)]
def room_is_available(room_id, start, end):
"""Vérifie une salle à partir du planning interne résolu."""
if not isinstance(start, datetime) or not isinstance(end, datetime):
raise TypeError("La disponibilité d'une salle nécessite deux datetime")
if end <= start:
return False
day = start.date()
if end.date() != day:
return False
start_time, end_time = start.time(), end.time()
return not any(
_overlaps(start_time, end_time, occupied_start, occupied_end)
for occupied_start, occupied_end, _ in room_occupancy(room_id, day)
)
def available_slots(room_id, day, window_start=time(8), window_end=time(17), duration_minutes=30):
"""Retourne des intervalles libres déterministes dans une fenêtre."""
occupied = sorted(room_occupancy(room_id, day), key=lambda item: item[0])
cursor = window_start
slots = []
def minutes(value):
return value.hour * 60 + value.minute
def as_time(total):
return time(total // 60, total % 60)
for start, end, _ in occupied:
if minutes(start) > minutes(cursor) and minutes(start) - minutes(cursor) >= duration_minutes:
slots.append((cursor, start))
if minutes(end) > minutes(cursor):
cursor = end
if minutes(window_end) - minutes(cursor) >= duration_minutes:
slots.append((cursor, window_end))
return slots
def sync_pronote_schedules(room_id, entries, *, synced_at=None):
"""Applique un lot Pronote sans toucher aux créneaux manuels protégés.
``entries`` est une liste de dictionnaires normalisés par l'intégration
Pronote (external_id, week_start, day_of_week, start_time, end_time et
libellés optionnels). Cette fonction ne contacte jamais Pronote elle-même.
"""
synced_at = synced_at or datetime.utcnow()
seen = set()
for entry in entries:
external_id = str(entry.get("external_id") or "").strip()
if not external_id:
continue
seen.add(external_id)
row = RoomSchedule.query.filter_by(source="pronote", external_id=external_id).first()
if not row:
row = RoomSchedule(room_id=room_id, source="pronote", external_id=external_id)
db.session.add(row)
for field in ("week_start", "day_of_week", "start_time", "end_time", "subject", "teacher", "class_name", "course_name", "event_type"):
if field in entry and entry[field] is not None:
setattr(row, field, entry[field])
row.last_synced_at = synced_at
row.source_updated_at = entry.get("source_updated_at")
row.resolution_status = "active"
row.conflict_note = None
manual_rows = RoomSchedule.query.filter(
RoomSchedule.room_id == room_id,
RoomSchedule.source == "manual",
RoomSchedule.resolution_status == "active",
RoomSchedule.day_of_week == row.day_of_week,
RoomSchedule.protected_from_sync.is_(False),
).all()
protected_rows = RoomSchedule.query.filter(
RoomSchedule.room_id == room_id,
RoomSchedule.source == "manual",
RoomSchedule.resolution_status == "active",
RoomSchedule.day_of_week == row.day_of_week,
RoomSchedule.protected_from_sync.is_(True),
).all()
for manual in manual_rows:
if (manual.subject and row.subject and manual.subject != row.subject) or (manual.class_name and row.class_name and manual.class_name != row.class_name):
continue
if _overlaps(manual.start_time, manual.end_time, row.start_time, row.end_time):
manual.resolution_status = "superseded"
break
for manual in protected_rows:
if _overlaps(manual.start_time, manual.end_time, row.start_time, row.end_time):
note = "Conflit avec un créneau manuel protégé"
manual.conflict_note = note
row.conflict_note = note
existing = RoomSchedule.query.filter_by(room_id=room_id, source="pronote").all()
for row in existing:
if row.external_id not in seen:
row.resolution_status = "disabled"
db.session.commit()