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``.
"""
from datetime import date, datetime, time, timedelta
import hashlib
from ..models.college import RoomSchedule
from ...extensions import db
@ -110,6 +111,7 @@ def sync_pronote_schedules(room_id, entries, *, synced_at=None):
RoomSchedule.source == "manual",
RoomSchedule.resolution_status == "active",
RoomSchedule.day_of_week == row.day_of_week,
RoomSchedule.week_start == row.week_start,
RoomSchedule.protected_from_sync.is_(False),
).all()
protected_rows = RoomSchedule.query.filter(
@ -117,14 +119,22 @@ def sync_pronote_schedules(room_id, entries, *, synced_at=None):
RoomSchedule.source == "manual",
RoomSchedule.resolution_status == "active",
RoomSchedule.day_of_week == row.day_of_week,
RoomSchedule.week_start == row.week_start,
RoomSchedule.protected_from_sync.is_(True),
).all()
candidates = []
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
candidates.append(manual)
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:
if _overlaps(manual.start_time, manual.end_time, row.start_time, row.end_time):
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:
row.resolution_status = "disabled"
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 []
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."""
try:
# 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:
logger.warning(f"Erreur API pour {room_name} le {target_date}: {api_err}")
if strict:
raise
continue
# 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}")
import traceback
logger.error(traceback.format_exc())
if strict:
raise
return []
@ -429,4 +433,4 @@ def load_session_from_db() -> Optional[Dict[str, Any]]:
except Exception as 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))
@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('/')
@login_required
def index():

View file

@ -1,4 +1,4 @@
{% 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>
<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()
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 = {}
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
# 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 '',
'subject': schedule.subject 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,
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,
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'])
@login_required
def import_room_planning(room_id):
"""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)
flash(f'Import planning pour {room.name} non implémenté.', 'info')
return redirect(url_for('pronote.room_planning', room_id=room_id))
client = get_client()
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'])

View file

@ -15,6 +15,7 @@
<div>
{% 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;">
<input type="hidden" name="week" value="{{ week_start.isoformat() }}">
<button type="submit" class="btn btn-outline-success">
<i class="bi bi-download"></i> Importer 3 semaines depuis Pronote
</button>
@ -57,12 +58,12 @@
<!-- Comparaison planning -->
<div class="row">
<!-- Planning GMAO (importé par watchdog) -->
<!-- Planning interne résolu -->
<div class="col-md-6">
<div class="card">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="bi bi-database"></i> Planning importé (Watchdog)
<i class="bi bi-database"></i> Planning interne résolu
</h5>
</div>
<div class="card-body p-0">
@ -75,7 +76,7 @@
{% set day_date = week_start + timedelta(days=day_num) %}
<thead class="table-success">
<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>
</th>
</tr>
@ -88,11 +89,12 @@
<td>{{ s.subject or '-' }}</td>
<td>{{ s.teacher or '-' }}</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>
{% endfor %}
{% else %}
<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>
</td>
</tr>
@ -104,20 +106,20 @@
{% else %}
<div class="p-3 text-center text-muted">
<i class="bi bi-inbox"></i><br>
Aucun planning importé.<br>
<small>Le watchdog PRONOTE synchronise automatiquement les plannings.</small>
Aucun créneau interne pour cette semaine.<br>
<small>Vous pouvez saisir des créneaux manuellement sans Pronote.</small>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Planning PRONOTE (direct) -->
<!-- État de la source Pronote -->
<div class="col-md-6">
<div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-wifi"></i> Planning PRONOTE (direct)
<i class="bi bi-wifi"></i> Source Pronote
</h5>
{% if pronote_connected %}
<span class="badge bg-success">Connecté</span>
@ -164,9 +166,9 @@
{% else %}
<div class="p-3 text-center text-muted">
<i class="bi bi-exclamation-triangle"></i><br>
Connexion à PRONOTE impossible.
Pronote nest pas configuré ou momentanément indisponible.
<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>
{% endif %}
@ -192,4 +194,4 @@
{% endif %}
</div>
{% endblock %}
{% endblock %}