feat(planning): connect Pronote synchronization to internal schedule

This commit is contained in:
root 2026-08-22 23:20:04 +00:00
parent e006f4f496
commit 0e30470604
6 changed files with 115 additions and 35 deletions

View file

@ -4,6 +4,7 @@ Pronote est traité ici comme une provenance optionnelle. Les autres services
ne doivent appeler que ``active_room_schedules`` ou ``room_is_available``. ne doivent appeler que ``active_room_schedules`` ou ``room_is_available``.
""" """
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
import hashlib
from ..models.college import RoomSchedule from ..models.college import RoomSchedule
from ...extensions import db from ...extensions import db
@ -110,6 +111,7 @@ def sync_pronote_schedules(room_id, entries, *, synced_at=None):
RoomSchedule.source == "manual", RoomSchedule.source == "manual",
RoomSchedule.resolution_status == "active", RoomSchedule.resolution_status == "active",
RoomSchedule.day_of_week == row.day_of_week, RoomSchedule.day_of_week == row.day_of_week,
RoomSchedule.week_start == row.week_start,
RoomSchedule.protected_from_sync.is_(False), RoomSchedule.protected_from_sync.is_(False),
).all() ).all()
protected_rows = RoomSchedule.query.filter( protected_rows = RoomSchedule.query.filter(
@ -117,14 +119,22 @@ def sync_pronote_schedules(room_id, entries, *, synced_at=None):
RoomSchedule.source == "manual", RoomSchedule.source == "manual",
RoomSchedule.resolution_status == "active", RoomSchedule.resolution_status == "active",
RoomSchedule.day_of_week == row.day_of_week, RoomSchedule.day_of_week == row.day_of_week,
RoomSchedule.week_start == row.week_start,
RoomSchedule.protected_from_sync.is_(True), RoomSchedule.protected_from_sync.is_(True),
).all() ).all()
candidates = []
for manual in manual_rows: 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): 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 continue
if _overlaps(manual.start_time, manual.end_time, row.start_time, row.end_time): if _overlaps(manual.start_time, manual.end_time, row.start_time, row.end_time):
manual.resolution_status = "superseded" candidates.append(manual)
break if len(candidates) == 1:
candidates[0].resolution_status = "superseded"
elif len(candidates) > 1:
note = "Correspondance Pronote ambiguë : validation nécessaire"
row.conflict_note = note
for manual in candidates:
manual.conflict_note = note
for manual in protected_rows: for manual in protected_rows:
if _overlaps(manual.start_time, manual.end_time, row.start_time, row.end_time): if _overlaps(manual.start_time, manual.end_time, row.start_time, row.end_time):
note = "Conflit avec un créneau manuel protégé" note = "Conflit avec un créneau manuel protégé"
@ -135,3 +145,41 @@ def sync_pronote_schedules(room_id, entries, *, synced_at=None):
if row.external_id not in seen: if row.external_id not in seen:
row.resolution_status = "disabled" row.resolution_status = "disabled"
db.session.commit() db.session.commit()
def normalize_pronote_lessons(lessons, week_start):
"""Transforme la sortie du client Pronote en entrées stables.
Le client historique ne fournit pas toujours d'identifiant de cours. Dans
ce cas, un identifiant déterministe est calculé à partir des attributs
visibles, ce qui permet de remettre à jour la même ligne sans doublon.
"""
entries = []
for lesson in lessons or []:
start_raw, end_raw = lesson.get("start_time"), lesson.get("end_time")
try:
start_time = time.fromisoformat(str(start_raw))
end_time = time.fromisoformat(str(end_raw))
except (TypeError, ValueError):
continue
day_of_week = int(lesson.get("day_of_week", 0))
subject = (lesson.get("subject") or "").strip() or None
teacher = (lesson.get("teacher") or "").strip() or None
class_name = (lesson.get("class_name") or "").strip() or None
stable = lesson.get("external_id") or "|".join(str(value or "") for value in (
week_start, day_of_week, start_time, end_time, subject, teacher, class_name
))
external_id = hashlib.sha256(str(stable).encode("utf-8")).hexdigest()[:64]
entries.append({
"external_id": external_id,
"week_start": week_start,
"day_of_week": day_of_week,
"start_time": start_time,
"end_time": end_time,
"subject": subject,
"teacher": teacher,
"class_name": class_name,
"course_name": subject or class_name or "Cours",
"event_type": "cours",
})
return entries

View file

@ -265,7 +265,7 @@ def get_all_salles(client) -> list:
return [] return []
def get_lessons_for_room(client, room_name: str, date_start, date_end) -> list: def get_lessons_for_room(client, room_name: str, date_start, date_end, *, strict=False) -> list:
"""Récupère les cours pour une salle donnée en utilisant l'API directe.""" """Récupère les cours pour une salle donnée en utilisant l'API directe."""
try: try:
# 1. Récupérer les salles # 1. Récupérer les salles
@ -313,6 +313,8 @@ def get_lessons_for_room(client, room_name: str, date_start, date_end) -> list:
}) })
except Exception as api_err: except Exception as api_err:
logger.warning(f"Erreur API pour {room_name} le {target_date}: {api_err}") logger.warning(f"Erreur API pour {room_name} le {target_date}: {api_err}")
if strict:
raise
continue continue
# 4. Parser les cours # 4. Parser les cours
@ -367,6 +369,8 @@ def get_lessons_for_room(client, room_name: str, date_start, date_end) -> list:
logger.error(f"Erreur récupération cours pour {room_name}: {e}") logger.error(f"Erreur récupération cours pour {room_name}: {e}")
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
if strict:
raise
return [] return []
@ -429,4 +433,4 @@ def load_session_from_db() -> Optional[Dict[str, Any]]:
except Exception as e: except Exception as e:
logger.error(f"Erreur chargement session: {e}") logger.error(f"Erreur chargement session: {e}")
return None return None

View file

@ -74,6 +74,17 @@ def room_schedule_protect(id):
return redirect(url_for('planning.room_schedules', room_id=schedule.room_id)) return redirect(url_for('planning.room_schedules', room_id=schedule.room_id))
@planning_bp.route('/rooms/<int:id>/disable', methods=['POST'])
@login_required
def room_schedule_disable(id):
"""Désactive explicitement un créneau sans supprimer son historique."""
schedule = RoomSchedule.query.get_or_404(id)
schedule.resolution_status = 'disabled'
db.session.commit()
flash('Créneau désactivé ; son historique est conservé.', 'success')
return redirect(url_for('planning.room_schedules', room_id=schedule.room_id))
@planning_bp.route('/') @planning_bp.route('/')
@login_required @login_required
def index(): def index():

View file

@ -1,4 +1,4 @@
{% extends "base.html" %}{% block title %}Planning des salles{% endblock %}{% block content %} {% extends "base.html" %}{% block title %}Planning des salles{% endblock %}{% block content %}
<div class="container-fluid main-content"><div class="d-flex justify-content-between align-items-center mb-3"><div><h1 class="h3">Planning interne des salles</h1><p class="text-muted">Les créneaux restent utilisables sans Pronote. Leur provenance est indiquée pour vous aider à les comprendre.</p></div><a class="btn btn-primary" href="{{ url_for('planning.room_schedule_new') }}"><i class="bi bi-plus-lg"></i> Ajouter un créneau</a></div> <div class="container-fluid main-content"><div class="d-flex justify-content-between align-items-center mb-3"><div><h1 class="h3">Planning interne des salles</h1><p class="text-muted">Les créneaux restent utilisables sans Pronote. Leur provenance est indiquée pour vous aider à les comprendre.</p></div><a class="btn btn-primary" href="{{ url_for('planning.room_schedule_new') }}"><i class="bi bi-plus-lg"></i> Ajouter un créneau</a></div>
<form class="row g-2 mb-3"><div class="col-md-6"><label class="form-label" for="room_id">Salle</label><select id="room_id" name="room_id" class="form-select"><option value="">Choisir une salle</option>{% for room in rooms %}<option value="{{ room.id }}" {% if selected_room_id == room.id %}selected{% endif %}>{{ room.full_name }}</option>{% endfor %}</select></div><div class="col-md-2 align-self-end"><button class="btn btn-outline-primary">Afficher</button></div></form> <form class="row g-2 mb-3"><div class="col-md-6"><label class="form-label" for="room_id">Salle</label><select id="room_id" name="room_id" class="form-select"><option value="">Choisir une salle</option>{% for room in rooms %}<option value="{{ room.id }}" {% if selected_room_id == room.id %}selected{% endif %}>{{ room.full_name }}</option>{% endfor %}</select></div><div class="col-md-2 align-self-end"><button class="btn btn-outline-primary">Afficher</button></div></form>
{% if selected_room_id %}<div class="table-responsive table-responsive-stack"><table class="table"><thead><tr><th>Jour</th><th>Horaires</th><th>Libellé</th><th>Origine</th><th>Protection</th></tr></thead><tbody>{% for item in schedules %}<tr><td data-label="Jour">{{ item.day_of_week }}</td><td data-label="Horaires">{{ item.start_time }}{{ item.end_time }}</td><td data-label="Libellé">{{ item.course_name or item.subject or item.event_type }}</td><td data-label="Origine">{% if item.source == 'pronote' %}Mis à jour par Pronote{% elif item.source == 'import' %}Importé{% else %}Saisi manuellement{% endif %}</td><td data-label="Protection">{% if item.protected_from_sync %}Protégé des mises à jour automatiques{% else %}Peut être repris par Pronote{% endif %}</td></tr>{% endfor %}{% if not schedules %}<tr><td colspan="5">Aucun créneau actif pour cette salle.</td></tr>{% endif %}</tbody></table></div>{% endif %}</div>{% endblock %} {% if selected_room_id %}<div class="table-responsive table-responsive-stack"><table class="table"><thead><tr><th>Jour</th><th>Horaires</th><th>Libellé</th><th>Origine</th><th>Protection</th><th>Action</th></tr></thead><tbody>{% for item in schedules %}<tr><td data-label="Jour">{{ item.day_of_week }}</td><td data-label="Horaires">{{ item.start_time }}{{ item.end_time }}</td><td data-label="Libellé">{{ item.course_name or item.subject or item.event_type }}{% if item.conflict_note %}<br><span class="badge bg-warning text-dark">Conflit à résoudre</span><br><small>{{ item.conflict_note }}</small>{% endif %}</td><td data-label="Origine">{% if item.source == 'pronote' %}Mis à jour par Pronote{% elif item.source == 'import' %}Importé{% else %}Saisi manuellement{% endif %}</td><td data-label="Protection">{% if item.protected_from_sync %}Conservé lors des synchronisations{% else %}Peut être repris par Pronote{% endif %}</td><td data-label="Action"><form method="post" action="{{ url_for('planning.room_schedule_protect', id=item.id) }}" class="d-inline"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="protected_from_sync" value="{% if not item.protected_from_sync %}on{% endif %}"><button class="btn btn-sm btn-outline-secondary">{% if item.protected_from_sync %}Déprotéger{% else %}Protéger{% endif %}</button></form>{% if item.source == 'pronote' %}<form method="post" action="{{ url_for('planning.room_schedule_disable', id=item.id) }}" class="d-inline"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-danger">Désactiver</button></form>{% endif %}</td></tr>{% endfor %}{% if not schedules %}<tr><td colspan="6">Aucun créneau actif pour cette salle.</td></tr>{% endif %}</tbody></table></div>{% endif %}</div>{% endblock %}

View file

@ -149,22 +149,10 @@ def room_planning(room_id):
client = get_client() client = get_client()
pronote_connected = client is not None pronote_connected = client is not None
# Récupérer les cours depuis PRONOTE si connecté # La page ne consulte pas Pronote pour calculer l'occupation : elle
# affiche uniquement la vue interne résolue. Pronote est une source
# facultative, déclenchée par l'import explicite ci-dessous.
pronote_by_day = {} pronote_by_day = {}
if client and pronote_connected:
from app_new.lib_ext.pronote_client import get_lessons_for_room
lessons = get_lessons_for_room(client, room.name, monday, week_end)
# Grouper par jour (les données sont déjà groupées par 'day')
for lesson in lessons:
lesson_day = lesson.get('day_of_week', lesson.get('day', 0))
if lesson_day not in pronote_by_day:
pronote_by_day[lesson_day] = []
pronote_by_day[lesson_day].append({
'heure': lesson.get('start_time', lesson.get('heure', '')),
'matiere': lesson.get('subject', lesson.get('matiere', '')),
'prof': lesson.get('teacher', lesson.get('prof', '')),
'classe': lesson.get('class_name', lesson.get('classe', ''))
})
# Récupérer les cours importés depuis la base de données # Récupérer les cours importés depuis la base de données
# Pronote reste une source facultative ; l'affichage des créneaux internes # Pronote reste une source facultative ; l'affichage des créneaux internes
@ -183,22 +171,49 @@ def room_planning(room_id):
'end_time': schedule.end_time.strftime('%H:%M') if schedule.end_time else '', 'end_time': schedule.end_time.strftime('%H:%M') if schedule.end_time else '',
'subject': schedule.subject or '', 'subject': schedule.subject or '',
'teacher': schedule.teacher or '', 'teacher': schedule.teacher or '',
'class_name': schedule.class_name or '' 'class_name': schedule.class_name or '',
'source': schedule.source,
'protected': schedule.protected_from_sync,
'conflict_note': schedule.conflict_note,
'last_synced_at': schedule.last_synced_at,
}) })
last_sync_at = max((item['last_synced_at'] for values in db_by_day.values() for item in values
if item['last_synced_at']), default=None)
return render_template('pronote/room_planning.html', room=room, week_start=monday, week_end=week_end, return render_template('pronote/room_planning.html', room=room, week_start=monday, week_end=week_end,
prev_week=monday - timedelta(days=7), next_week=monday + timedelta(days=7), prev_week=monday - timedelta(days=7), next_week=monday + timedelta(days=7),
today=date.today(), pronote_connected=pronote_connected, pronote_by_day=pronote_by_day, today=date.today(), pronote_connected=pronote_connected, pronote_by_day=pronote_by_day,
db_by_day=db_by_day, timedelta=timedelta) db_by_day=db_by_day, last_sync_at=last_sync_at, timedelta=timedelta)
@pronote_bp.route('/planning/<int:room_id>/import', methods=['POST']) @pronote_bp.route('/planning/<int:room_id>/import', methods=['POST'])
@login_required @login_required
def import_room_planning(room_id): def import_room_planning(room_id):
"""Importer l'emploi du temps d'une salle depuis Pronote.""" """Importer l'emploi du temps d'une salle depuis Pronote."""
from app_new.lib_ext.pronote_client import get_client, get_lessons_for_room
from app_new.core.services.room_planning import normalize_pronote_lessons, sync_pronote_schedules
room = Room.query.get_or_404(room_id) room = Room.query.get_or_404(room_id)
flash(f'Import planning pour {room.name} non implémenté.', 'info') client = get_client()
return redirect(url_for('pronote.room_planning', room_id=room_id)) if not client:
flash('Pronote nest pas connecté : le planning interne reste disponible.', 'warning')
return redirect(url_for('pronote.room_planning', room_id=room_id))
week_value = request.form.get('week') or request.args.get('week') or date.today().isoformat()
try:
monday = date.fromisoformat(week_value)
monday -= timedelta(days=monday.weekday())
except ValueError:
monday = date.today() - timedelta(days=date.today().weekday())
try:
lessons = get_lessons_for_room(client, room.name, monday, monday + timedelta(days=6), strict=True)
entries = normalize_pronote_lessons(lessons, monday)
sync_pronote_schedules(room.id, entries)
flash(f'{len(entries)} créneaux Pronote synchronisés. Les protections locales ont été conservées.', 'success')
except Exception:
db.session.rollback()
logger.exception('Synchronisation Pronote impossible pour la salle %s', room.id)
flash('Pronote est momentanément indisponible. Les derniers créneaux connus sont conservés.', 'warning')
return redirect(url_for('pronote.room_planning', room_id=room_id, week=monday.isoformat()))
@pronote_bp.route('/disconnect', methods=['POST']) @pronote_bp.route('/disconnect', methods=['POST'])

View file

@ -15,6 +15,7 @@
<div> <div>
{% if room.room_type and room.room_type.is_teaching %} {% if room.room_type and room.room_type.is_teaching %}
<form method="POST" action="{{ url_for('pronote.import_room_planning', room_id=room.id) }}" style="display:inline;"> <form method="POST" action="{{ url_for('pronote.import_room_planning', room_id=room.id) }}" style="display:inline;">
<input type="hidden" name="week" value="{{ week_start.isoformat() }}">
<button type="submit" class="btn btn-outline-success"> <button type="submit" class="btn btn-outline-success">
<i class="bi bi-download"></i> Importer 3 semaines depuis Pronote <i class="bi bi-download"></i> Importer 3 semaines depuis Pronote
</button> </button>
@ -57,12 +58,12 @@
<!-- Comparaison planning --> <!-- Comparaison planning -->
<div class="row"> <div class="row">
<!-- Planning GMAO (importé par watchdog) --> <!-- Planning interne résolu -->
<div class="col-md-6"> <div class="col-md-6">
<div class="card"> <div class="card">
<div class="card-header bg-light"> <div class="card-header bg-light">
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi bi-database"></i> Planning importé (Watchdog) <i class="bi bi-database"></i> Planning interne résolu
</h5> </h5>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
@ -75,7 +76,7 @@
{% set day_date = week_start + timedelta(days=day_num) %} {% set day_date = week_start + timedelta(days=day_num) %}
<thead class="table-success"> <thead class="table-success">
<tr> <tr>
<th colspan="4" style="background-color: #d1e7dd;"> <th colspan="5" style="background-color: #d1e7dd;">
<strong>{{ days[day_num] }} {{ day_date.strftime('%d/%m') }}</strong> <strong>{{ days[day_num] }} {{ day_date.strftime('%d/%m') }}</strong>
</th> </th>
</tr> </tr>
@ -88,11 +89,12 @@
<td>{{ s.subject or '-' }}</td> <td>{{ s.subject or '-' }}</td>
<td>{{ s.teacher or '-' }}</td> <td>{{ s.teacher or '-' }}</td>
<td><span class="badge bg-secondary">{{ s.class_name or '-' }}</span></td> <td><span class="badge bg-secondary">{{ s.class_name or '-' }}</span></td>
<td><small>{% if s.source == 'pronote' %}Mis à jour par Pronote{% elif s.source == 'import' %}Importé{% else %}Saisi manuellement{% endif %}</small>{% if s.protected %}<br><small class="text-success">Conservé lors des synchronisations</small>{% endif %}{% if s.conflict_note %}<br><span class="badge bg-warning text-dark">Conflit à résoudre</span><br><small>{{ s.conflict_note }}</small>{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% else %} {% else %}
<tr class="table-light"> <tr class="table-light">
<td colspan="4" class="text-center text-muted"> <td colspan="5" class="text-center text-muted">
<em>Pas de cours</em> <em>Pas de cours</em>
</td> </td>
</tr> </tr>
@ -104,20 +106,20 @@
{% else %} {% else %}
<div class="p-3 text-center text-muted"> <div class="p-3 text-center text-muted">
<i class="bi bi-inbox"></i><br> <i class="bi bi-inbox"></i><br>
Aucun planning importé.<br> Aucun créneau interne pour cette semaine.<br>
<small>Le watchdog PRONOTE synchronise automatiquement les plannings.</small> <small>Vous pouvez saisir des créneaux manuellement sans Pronote.</small>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
<!-- Planning PRONOTE (direct) --> <!-- État de la source Pronote -->
<div class="col-md-6"> <div class="col-md-6">
<div class="card"> <div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center"> <div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi bi-wifi"></i> Planning PRONOTE (direct) <i class="bi bi-wifi"></i> Source Pronote
</h5> </h5>
{% if pronote_connected %} {% if pronote_connected %}
<span class="badge bg-success">Connecté</span> <span class="badge bg-success">Connecté</span>
@ -164,9 +166,9 @@
{% else %} {% else %}
<div class="p-3 text-center text-muted"> <div class="p-3 text-center text-muted">
<i class="bi bi-exclamation-triangle"></i><br> <i class="bi bi-exclamation-triangle"></i><br>
Connexion à PRONOTE impossible. Pronote nest pas configuré ou momentanément indisponible.
<div class="mt-2"> <div class="mt-2">
<small>Vérifiez la configuration du client PRONOTE.</small> <small>Le planning interne reste utilisable et conserve les dernières données connues.</small>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -192,4 +194,4 @@
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}