"""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()