From ff507130da993597475d74f0704a332c7ac7331d Mon Sep 17 00:00:00 2001 From: root Date: Sat, 22 Aug 2026 22:44:21 +0000 Subject: [PATCH] feat(documents): add phase 1 document center and room planning --- app_new/core/authorization.py | 14 +- app_new/core/models/college.py | 10 + app_new/core/models/equipment.py | 1 + app_new/core/models/maintenance.py | 1 + app_new/core/services/planning_service.py | 32 +-- app_new/core/services/room_planning.py | 135 +++++++++++ app_new/documents/routes.py | 69 +++++- app_new/documents/service.py | 223 ++++++++++++++++++ app_new/equipments/documents.py | 1 + app_new/equipments/rooms.py | 9 +- app_new/equipments/templates/detail.html | 6 +- app_new/interventions/templates/detail.html | 4 + app_new/planning/schedules.py | 59 ++++- .../planning/room_schedule_form.html | 2 + .../templates/planning/room_schedules.html | 4 + app_new/pronote/routes.py | 10 +- app_new/templates/base.html | 7 + app_new/templates/documents/fds.html | 7 + app_new/templates/documents/index.html | 25 ++ app_new/templates/interventions/detail.html | 4 + .../g1a2b3c4d5e6_phase1_documents_planning.py | 95 ++++++++ tests/conftest.py | 16 +- .../test_phase1_documents_planning.py | 119 ++++++++++ 23 files changed, 812 insertions(+), 41 deletions(-) create mode 100644 app_new/core/services/room_planning.py create mode 100644 app_new/documents/service.py create mode 100644 app_new/planning/templates/planning/room_schedule_form.html create mode 100644 app_new/planning/templates/planning/room_schedules.html create mode 100644 app_new/templates/documents/fds.html create mode 100644 app_new/templates/documents/index.html create mode 100644 migrations/versions/g1a2b3c4d5e6_phase1_documents_planning.py create mode 100644 tests/integration/test_phase1_documents_planning.py diff --git a/app_new/core/authorization.py b/app_new/core/authorization.py index 6bcc299..d395df0 100644 --- a/app_new/core/authorization.py +++ b/app_new/core/authorization.py @@ -46,6 +46,7 @@ PERMISSIONS_BY_ROLE = { # wildcard appartient exclusivement à super_admin (voir migration). "super_admin": {"*"}, "admin": { + "documents.view", "dashboard.view", "intervention.view", "intervention.create", "intervention.edit", "intervention.assign", "intervention.change_status", "intervention.close", "intervention.reject", "intervention.delete", "intervention.postpone", "intervention.comment", @@ -64,6 +65,7 @@ PERMISSIONS_BY_ROLE = { "system.view", "system.configure", "system.admin", }, "responsable_gmao": { + "documents.view", "dashboard.view", "intervention.view", "intervention.create", "intervention.manage", "patrimoine.view", "patrimoine.manage", "planning.view", "planning.manage", "stock.view", "stock.manage", "contract.view", "contract.manage", @@ -71,16 +73,18 @@ PERMISSIONS_BY_ROLE = { "housing.private", }, "technicien": { + "documents.view", "dashboard.view", "intervention.view", "intervention.create", "intervention.manage", "patrimoine.view", "patrimoine.manage", "planning.view", "planning.manage", "stock.view", "stock.manage", "contract.view", "prevention.view", "export.use", }, "assistant_prevention": { + "documents.view", "dashboard.view", "intervention.view", "intervention.create", "patrimoine.view", "planning.view", "prevention.view", "prevention.manage", "export.use", }, - "demandeur": {"dashboard.view", "intervention.view", "intervention.create"}, - "lecture": {"dashboard.view", "intervention.view", "patrimoine.view", "planning.view", "contract.view"}, + "demandeur": {"dashboard.view", "intervention.view", "intervention.create", "documents.view"}, + "lecture": {"dashboard.view", "intervention.view", "patrimoine.view", "planning.view", "contract.view", "documents.view"}, } PUBLIC_ENDPOINTS = { @@ -106,6 +110,7 @@ PREVENTION_BLUEPRINTS = {"trainings", "constraints", "prevention"} GRANULAR_PERMISSION_CODES = { code for codes in PERMISSIONS_BY_ROLE.values() for code in codes if code != "*" } | { + "documents.view", "gmao_config.view", "gmao_config.configure", "watchdog_dnd.view", "watchdog_dnd.configure", "integration.ent.view", "integration.ent.configure", @@ -127,6 +132,7 @@ PERMISSION_LABELS = { "user": "Utilisateurs", "role": "Rôles et permissions", "audit": "Journaux d'audit", + "documents": "Centre documentaire", "system": "Système", } @@ -162,6 +168,8 @@ def permission_metadata(code): if code == "*": return "Accès complet à l'application", ACTION_LABELS["all"][1] module, _, action = code.partition(".") + if code == "documents.view": + return "Consulter le centre documentaire", "Permet de rechercher et consulter les documents autorisés des interventions, équipements et produits." domain = PERMISSION_LABELS.get(module, module.replace("_", " ").capitalize()) label, description = ACTION_LABELS.get(action, (action.replace("_", " ").capitalize(), f"Permet d'effectuer l'action « {action.replace('_', ' ')} » dans le domaine {domain}.")) return f"{label} — {domain}", description.replace("le domaine", f"le domaine « {domain} »") @@ -315,6 +323,8 @@ def required_permission(endpoint, method): return "intervention.create" return "intervention.edit" if mutating else "intervention.view" if blueprint == "documents": + if endpoint in {"documents.index", "documents.fds", "documents.download"}: + return "documents.view" if "intervention" in endpoint: return "intervention.manage" if mutating else "intervention.view" if "equipment" in endpoint: diff --git a/app_new/core/models/college.py b/app_new/core/models/college.py index 7114d11..a7b949b 100644 --- a/app_new/core/models/college.py +++ b/app_new/core/models/college.py @@ -199,6 +199,16 @@ class RoomSchedule(db.Model): teacher = db.Column(db.String(100), nullable=True) class_name = db.Column(db.String(50), nullable=True) course_name = db.Column(db.String(255), nullable=True) + source = db.Column(db.String(20), nullable=False, default="manual", index=True) + external_id = db.Column(db.String(191), nullable=True, index=True) + last_synced_at = db.Column(db.DateTime, nullable=True) + source_updated_at = db.Column(db.DateTime, nullable=True) + protected_from_sync = db.Column(db.Boolean, nullable=False, default=False, index=True) + resolution_status = db.Column(db.String(20), nullable=False, default="active", index=True) + valid_from = db.Column(db.Date, nullable=True) + valid_to = db.Column(db.Date, nullable=True) + event_type = db.Column(db.String(30), nullable=False, default="cours") + conflict_note = db.Column(db.Text, nullable=True) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) # Relations diff --git a/app_new/core/models/equipment.py b/app_new/core/models/equipment.py index 8d21570..34d8af2 100644 --- a/app_new/core/models/equipment.py +++ b/app_new/core/models/equipment.py @@ -298,6 +298,7 @@ class EquipmentDocument(db.Model): filename = db.Column(db.String(255), nullable=False) filepath = db.Column(db.String(500), nullable=False) description = db.Column(db.Text, nullable=True) + document_type = db.Column(db.String(30), nullable=False, default="autre", index=True) uploaded_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) uploaded_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) diff --git a/app_new/core/models/maintenance.py b/app_new/core/models/maintenance.py index 82c23c1..beb7cd5 100644 --- a/app_new/core/models/maintenance.py +++ b/app_new/core/models/maintenance.py @@ -358,6 +358,7 @@ class InterventionDocument(db.Model): filename = db.Column(db.String(255), nullable=False) filepath = db.Column(db.String(500), nullable=False) description = db.Column(db.Text, nullable=True) + document_type = db.Column(db.String(30), nullable=False, default="autre", index=True) uploaded_at = db.Column(db.DateTime, nullable=True) uploaded_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) diff --git a/app_new/core/services/planning_service.py b/app_new/core/services/planning_service.py index 0937d4a..19ec06e 100644 --- a/app_new/core/services/planning_service.py +++ b/app_new/core/services/planning_service.py @@ -351,31 +351,14 @@ class PlanningService: Vérifie si une salle est disponible pendant une période donnée. Utilise le planning PRONOTE importé. """ - from app_new.core.models.college import PronoteSchedule - - courses = PronoteSchedule.query.filter( - PronoteSchedule.room_id == room_id, - PronoteSchedule.start_time < end_time, - PronoteSchedule.end_time > start_time - ).all() - - return len(courses) == 0 + from .room_planning import room_is_available + return room_is_available(room_id, start_time, end_time) @staticmethod def get_room_occupancy(room_id: int, d: date) -> List[Tuple[time, time]]: """Retourne les périodes occupées d'une salle pour une date.""" - from app_new.core.models.college import PronoteSchedule - - start_dt = datetime.combine(d, time(0)) - end_dt = datetime.combine(d, time(23, 59)) - - courses = PronoteSchedule.query.filter( - PronoteSchedule.room_id == room_id, - PronoteSchedule.start_time >= start_dt, - PronoteSchedule.start_time < end_dt - ).order_by(PronoteSchedule.start_time).all() - - return [(c.start_time.time(), c.end_time.time()) for c in courses] + from .room_planning import room_occupancy + return [(start, end) for start, end, _ in room_occupancy(room_id, d)] @staticmethod def calculate_planning(start_date: date, end_date: date) -> List[PlanningDay]: @@ -455,8 +438,11 @@ class PlanningService: # Vérifier la disponibilité de la salle if task.room_id: - # TODO: Intégrer avec PRONOTE - pass + duration = task.estimated_duration or 30 + start = datetime.combine(current_date, time(8, 0)) + end = start + timedelta(minutes=duration) + if not PlanningService.is_room_available(task.room_id, start, end): + continue # Planifier la tâche task.scheduled_date = current_date diff --git a/app_new/core/services/room_planning.py b/app_new/core/services/room_planning.py new file mode 100644 index 0000000..956f443 --- /dev/null +++ b/app_new/core/services/room_planning.py @@ -0,0 +1,135 @@ +"""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() diff --git a/app_new/documents/routes.py b/app_new/documents/routes.py index a08cc10..a4e7087 100644 --- a/app_new/documents/routes.py +++ b/app_new/documents/routes.py @@ -10,6 +10,7 @@ import os from uuid import uuid4 from app_new.extensions import db from ..core.models.maintenance import InterventionDocument +from .service import list_documents, get_document, can_read_source documents_bp = Blueprint('documents', __name__, url_prefix='/documents') @@ -19,6 +20,70 @@ def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS +DOCUMENT_TYPES = { + 'notice': 'Notice / manuel', 'facture': 'Facture', 'devis': 'Devis', + 'rapport': 'Rapport / compte rendu', 'certificat': 'Certificat / contrôle', + 'photo': 'Photo', 'plan': 'Plan', 'autre': 'Autre', +} + + +@documents_bp.route('/') +@login_required +def index(): + """Centre documentaire global, agrégé sans recopier les fichiers.""" + from datetime import date + date_from = None + date_to = None + try: + date_from = date.fromisoformat(request.args.get('date_from')) if request.args.get('date_from') else None + date_to = date.fromisoformat(request.args.get('date_to')) if request.args.get('date_to') else None + except ValueError: + flash('La période de recherche est invalide.', 'warning') + all_results = list_documents( + query=request.args.get('q', ''), source=request.args.get('source') or None, + document_type=request.args.get('document_type') or None, + date_from=date_from, date_to=date_to, user=current_user, + ) + page = max(request.args.get('page', 1, type=int), 1) + per_page = 25 + total = len(all_results) + results = all_results[(page - 1) * per_page: page * per_page] + return render_template( + 'documents/index.html', results=results, page=page, + total=total, per_page=per_page, document_types=DOCUMENT_TYPES, + filters=request.args, + ) + + +@documents_bp.route('/fds') +@login_required +def fds(): + """Vue rapide des fiches de données de sécurité.""" + results = list_documents( + query=request.args.get('q', ''), source='product', + user=current_user, fds_only=True, + ) + return render_template('documents/fds.html', results=results, filters=request.args) + + +@documents_bp.route('/download//') +@login_required +def download(source, document_id): + """Téléchargement contrôlé, sans exposer le chemin de stockage.""" + from flask import send_file + document = get_document(source, document_id) + if not document or not can_read_source(source, current_user): + from flask import abort + abort(403) + filepath = os.path.realpath(document.filepath or '') + upload_root = os.path.realpath(current_app.config.get('UPLOAD_FOLDER', 'uploads')) + if not filepath or not os.path.isfile(filepath) or not (filepath == upload_root or filepath.startswith(upload_root + os.sep)): + current_app.logger.warning('Document physique introuvable ou hors racine: %s/%s', source, document_id) + flash('Le fichier documentaire est momentanément indisponible.', 'error') + return redirect(url_for('documents.index')) + return send_file(filepath, as_attachment=True, download_name=os.path.basename(document.filename or filepath)) + + @documents_bp.route('/upload/intervention/', methods=['POST']) @login_required def upload_intervention(intervention_id): @@ -50,6 +115,7 @@ def upload_intervention(intervention_id): filename=unique_filename, filepath=filepath, description=request.form.get('description', ''), + document_type=request.form.get('document_type') or 'autre', uploaded_by_id=current_user.id ) db.session.add(document) @@ -93,8 +159,7 @@ def upload_equipment(equipment_id): equipment_id=equipment_id, filename=unique_filename, filepath=filepath, - file_type=filename.rsplit('.', 1)[1].lower() if '.' in filename else 'unknown', - file_size=os.path.getsize(filepath), + document_type=request.form.get('document_type') or 'autre', uploaded_by_id=current_user.id ) db.session.add(document) diff --git a/app_new/documents/service.py b/app_new/documents/service.py new file mode 100644 index 0000000..debf1af --- /dev/null +++ b/app_new/documents/service.py @@ -0,0 +1,223 @@ +"""Agrégation en lecture des documents des modules métier. + +Ce service ne possède aucun fichier et ne crée aucune ligne documentaire : il +ne fait que présenter des adaptateurs homogènes aux vues du centre global. +""" +from dataclasses import dataclass +from datetime import date, datetime +from typing import Optional + +from flask import url_for + +from ..core.authorization import has_permission +from ..core.models.college import Building, Room, Zone +from ..core.models.cleaning import ProductDocument +from ..core.models.equipment import EquipmentDocument +from ..core.models.maintenance import InterventionDocument + + +DOCUMENT_TYPE_LABELS = { + "notice": "Notice / manuel", + "facture": "Facture", + "devis": "Devis", + "rapport": "Rapport / compte rendu", + "certificat": "Certificat / contrôle", + "photo": "Photo", + "plan": "Plan", + "fds": "FDS", + "fiche_technique": "Fiche technique", + "autre": "Autre", + "other": "Autre", +} + + +@dataclass +class DocumentResult: + source: str + source_id: int + parent_id: int + title: str + filename: str + document_type: str + document_type_label: str + description: Optional[str] + document_date: Optional[date] + context_label: str + context_url: Optional[str] + download_url: str + filepath: str + location_labels: tuple[str, ...] + location_summary: str + + +def _location_path(room): + if not room: + return None + parts = [] + if room.building: + parts.append(room.building.name) + if room.zone: + parts.append(room.zone.name) + parts.append(room.name) + return " > ".join(parts) + + +def _equipment_location(equipment): + if not equipment: + return () + room = equipment.effective_room or equipment.room + path = _location_path(room) + return (path,) if path else () + + +def _intervention_location(intervention): + return _equipment_location(intervention.equipment) or ( + (_location_path(intervention.room),) if _location_path(intervention.room) else () + ) + + +def _product_locations(product): + paths = set() + for lot in product.lots: + for balance in lot.balances: + if not balance.quantity or balance.quantity <= 0 or not balance.location: + continue + location = balance.location + parts = [] + building = Building.query.get(location.building_id) if location.building_id else None + zone = Zone.query.get(location.zone_id) if location.zone_id else None + room = Room.query.get(location.room_id) if location.room_id else None + if building: + parts.append(building.name) + if zone: + parts.append(zone.name) + if room: + parts.append(room.name) + parts.append(location.name) + paths.add(" > ".join(parts)) + return tuple(sorted(paths)) + + +def _summary(locations): + if not locations: + return "Localisation non renseignée" + if len(locations) == 1: + return locations[0] + return f"{len(locations)} emplacements" + + +def _result(source, document, parent, *, title, document_type, description, + document_date, context_label, context_url, locations): + return DocumentResult( + source=source, + source_id=document.id, + parent_id=parent.id, + title=title or document.filename, + filename=document.filename, + document_type=document_type or "autre", + document_type_label=DOCUMENT_TYPE_LABELS.get(document_type or "autre", "Autre"), + description=description, + document_date=document_date, + context_label=context_label, + context_url=context_url, + download_url=url_for("documents.download", source=source, document_id=document.id), + filepath=document.filepath, + location_labels=tuple(locations), + location_summary=_summary(locations), + ) + + +def list_documents(*, query="", source=None, document_type=None, + date_from=None, date_to=None, user=None, fds_only=False): + """Retourne les documents accessibles, filtrés sans stockage parallèle.""" + user = user + results = [] + if has_permission("documents.view", user) and has_permission("intervention.view", user): + for document in InterventionDocument.query.order_by(InterventionDocument.uploaded_at.desc()).all(): + parent = document.intervention + if not parent: + continue + item = _result( + "intervention", document, parent, title=document.filename, + document_type=getattr(document, "document_type", "autre"), + description=document.description, document_date=document.uploaded_at, + context_label=f"Intervention #{parent.id} — {parent.title}", + context_url=url_for("interventions.detail", id=parent.id), + locations=_intervention_location(parent), + ) + results.append(item) + if has_permission("documents.view", user) and has_permission("patrimoine.view", user): + for document in EquipmentDocument.query.order_by(EquipmentDocument.uploaded_at.desc()).all(): + parent = document.equipment + if not parent: + continue + results.append(_result( + "equipment", document, parent, title=document.filename, + document_type=getattr(document, "document_type", "autre"), + description=document.description, document_date=document.uploaded_at, + context_label=f"Équipement — {parent.name}", + context_url=url_for("equipments.detail", id=parent.id), + locations=_equipment_location(parent), + )) + if has_permission("documents.view", user) and has_permission("stock.view", user): + for document in ProductDocument.query.order_by(ProductDocument.uploaded_at.desc()).all(): + parent = document.commercial_product + if not parent: + continue + generic = parent.generic_product + label = parent.commercial_name + results.append(_result( + "product", document, parent, title=document.title or document.filename, + document_type=document.document_type, description=None, + document_date=document.published_at or document.uploaded_at, + context_label=f"Produit — {label}", + context_url=url_for("cleaning.references", highlight=parent.id), + locations=_product_locations(parent), + )) + needle = (query or "").strip().casefold() + if needle: + results = [item for item in results if needle in " ".join(( + item.title, item.filename, item.description or "", item.context_label, + item.location_summary, + )).casefold()] + if source: + results = [item for item in results if item.source == source] + if document_type: + results = [item for item in results if item.document_type == document_type] + if fds_only: + results = [item for item in results if item.document_type == "fds"] + def document_day(item): + if not item.document_date: + return None + return item.document_date.date() if isinstance(item.document_date, datetime) else item.document_date + if date_from: + results = [item for item in results if document_day(item) and document_day(item) >= date_from] + if date_to: + results = [item for item in results if document_day(item) and document_day(item) <= date_to] + # Les sources historiques mélangent ``date`` (produits) et ``datetime`` + # (interventions/équipements). Normaliser avant le tri évite une + # TypeError Python et garde un ordre stable entre les trois adaptateurs. + results.sort( + key=lambda item: datetime.combine(document_day(item), datetime.min.time()) + if document_day(item) else datetime.min, + reverse=True, + ) + return results + + +def get_document(source, document_id): + models = { + "intervention": InterventionDocument, + "equipment": EquipmentDocument, + "product": ProductDocument, + } + model = models.get(source) + return model.query.get_or_404(document_id) if model else None + + +def can_read_source(source, user=None): + return has_permission("documents.view", user) and has_permission({ + "intervention": "intervention.view", + "equipment": "patrimoine.view", + "product": "stock.view", + }.get(source, "system.admin"), user) diff --git a/app_new/equipments/documents.py b/app_new/equipments/documents.py index 4f5bc97..6c08f96 100644 --- a/app_new/equipments/documents.py +++ b/app_new/equipments/documents.py @@ -61,6 +61,7 @@ def upload_document(id): filename=original_filename, filepath=filepath, description=request.form.get('description'), + document_type=request.form.get('document_type') or 'autre', uploaded_by_id=current_user.id ) db.session.add(doc) diff --git a/app_new/equipments/rooms.py b/app_new/equipments/rooms.py index f107cf2..17c4000 100644 --- a/app_new/equipments/rooms.py +++ b/app_new/equipments/rooms.py @@ -52,11 +52,10 @@ def detail(id): room = Room.query.get_or_404(id) today = date.today() week_start = today - timedelta(days=today.weekday()) - courses = RoomSchedule.query.filter_by( - room_id=room.id, - week_start=week_start, - day_of_week=today.weekday(), - ).order_by(RoomSchedule.start_time).all() + # La vue salle consomme la résolution interne (manuel/Pronote/import), + # afin de ne pas réafficher un créneau supplanté par la synchronisation. + from app_new.core.services.room_planning import active_room_schedules + courses = active_room_schedules(room.id, today) current_time = datetime.now().time() current_course = next( (course for course in courses diff --git a/app_new/equipments/templates/detail.html b/app_new/equipments/templates/detail.html index e313cb2..a1bda0f 100644 --- a/app_new/equipments/templates/detail.html +++ b/app_new/equipments/templates/detail.html @@ -356,6 +356,10 @@
PDF, images, documents Office autorisés.
+
+ + +
@@ -396,4 +400,4 @@
{% endfor %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/interventions/templates/detail.html b/app_new/interventions/templates/detail.html index f338ad7..ac8d7e7 100644 --- a/app_new/interventions/templates/detail.html +++ b/app_new/interventions/templates/detail.html @@ -696,6 +696,10 @@
PDF, images, documents Office autorisés.
+
+ + +
diff --git a/app_new/planning/schedules.py b/app_new/planning/schedules.py index c7bfd07..d120471 100644 --- a/app_new/planning/schedules.py +++ b/app_new/planning/schedules.py @@ -11,11 +11,68 @@ from ..core.models.planning import ( from ..core.models.maintenance import Lot, LotTask, Intervention from ..core.models.equipment import EquipmentCategory from ..core.models.company import Company -from datetime import datetime, time as dt_time +from ..core.models.college import Room, RoomSchedule +from datetime import datetime, date, time as dt_time planning_bp = Blueprint('planning', __name__, url_prefix='/planning', template_folder='templates') +@planning_bp.route('/rooms') +@login_required +def room_schedules(): + """Emplois du temps internes des salles, toutes provenances confondues.""" + from ..core.services.room_planning import active_room_schedules + room_id = request.args.get('room_id', type=int) + rooms = Room.query.order_by(Room.name).all() + schedules = active_room_schedules(room_id) if room_id else [] + return render_template('planning/room_schedules.html', rooms=rooms, schedules=schedules, selected_room_id=room_id) + + +@planning_bp.route('/rooms/new', methods=['GET', 'POST']) +@login_required +def room_schedule_new(): + rooms = Room.query.order_by(Room.name).all() + if request.method == 'POST': + try: + room_id = int(request.form['room_id']) + week_start = date.fromisoformat(request.form['week_start']) + day_of_week = int(request.form['day_of_week']) + start_time = dt_time.fromisoformat(request.form['start_time']) + end_time = dt_time.fromisoformat(request.form['end_time']) + except (KeyError, TypeError, ValueError): + flash('Salle, date et horaires sont obligatoires.', 'danger') + return render_template('planning/room_schedule_form.html', rooms=rooms, schedule=None), 400 + if start_time >= end_time or not Room.query.get(room_id): + flash('Vérifiez la salle et l’ordre des horaires.', 'danger') + return render_template('planning/room_schedule_form.html', rooms=rooms, schedule=None), 400 + schedule = RoomSchedule( + room_id=room_id, week_start=week_start, day_of_week=day_of_week, + start_time=start_time, end_time=end_time, + subject=(request.form.get('subject') or '').strip() or None, + teacher=(request.form.get('teacher') or '').strip() or None, + class_name=(request.form.get('class_name') or '').strip() or None, + course_name=(request.form.get('course_name') or '').strip() or None, + event_type=request.form.get('event_type') or 'cours', + source='manual', + protected_from_sync=request.form.get('protected_from_sync') == 'on', + ) + db.session.add(schedule) + db.session.commit() + flash('Créneau de salle enregistré.', 'success') + return redirect(url_for('planning.room_schedules', room_id=room_id)) + return render_template('planning/room_schedule_form.html', rooms=rooms, schedule=None) + + +@planning_bp.route('/rooms//protect', methods=['POST']) +@login_required +def room_schedule_protect(id): + schedule = RoomSchedule.query.get_or_404(id) + schedule.protected_from_sync = request.form.get('protected_from_sync') == 'on' + db.session.commit() + flash('Protection du créneau mise à jour.', 'success') + return redirect(url_for('planning.room_schedules', room_id=schedule.room_id)) + + @planning_bp.route('/') @login_required def index(): diff --git a/app_new/planning/templates/planning/room_schedule_form.html b/app_new/planning/templates/planning/room_schedule_form.html new file mode 100644 index 0000000..b5c960e --- /dev/null +++ b/app_new/planning/templates/planning/room_schedule_form.html @@ -0,0 +1,2 @@ +{% extends "base.html" %}{% block title %}Ajouter un créneau de salle{% endblock %}{% block content %} +

Ajouter un créneau de salle

Saisissez un planning utilisable même lorsque Pronote n'est pas configuré.

À utiliser pour une réunion, un examen, des travaux ou une réservation décidée localement.
Annuler
{% endblock %} diff --git a/app_new/planning/templates/planning/room_schedules.html b/app_new/planning/templates/planning/room_schedules.html new file mode 100644 index 0000000..ea3eec7 --- /dev/null +++ b/app_new/planning/templates/planning/room_schedules.html @@ -0,0 +1,4 @@ +{% extends "base.html" %}{% block title %}Planning des salles{% endblock %}{% block content %} +

Planning interne des salles

Les créneaux restent utilisables sans Pronote. Leur provenance est indiquée pour vous aider à les comprendre.

Ajouter un créneau
+
+{% if selected_room_id %}
{% for item in schedules %}{% endfor %}{% if not schedules %}{% endif %}
JourHorairesLibelléOrigineProtection
{{ item.day_of_week }}{{ item.start_time }}–{{ item.end_time }}{{ item.course_name or item.subject or item.event_type }}{% if item.source == 'pronote' %}Mis à jour par Pronote{% elif item.source == 'import' %}Importé{% else %}Saisi manuellement{% endif %}{% if item.protected_from_sync %}Protégé des mises à jour automatiques{% else %}Peut être repris par Pronote{% endif %}
Aucun créneau actif pour cette salle.
{% endif %}
{% endblock %} diff --git a/app_new/pronote/routes.py b/app_new/pronote/routes.py index b9e262a..8bfffc3 100644 --- a/app_new/pronote/routes.py +++ b/app_new/pronote/routes.py @@ -167,10 +167,12 @@ def room_planning(room_id): }) # Récupérer les cours importés depuis la base de données - db_schedules = RoomSchedule.query.filter( - RoomSchedule.room_id == room_id, - RoomSchedule.week_start == monday - ).order_by(RoomSchedule.day_of_week, RoomSchedule.start_time).all() + # Pronote reste une source facultative ; l'affichage des créneaux internes + # passe par le service de résolution commun pour masquer les créneaux + # supplantés/désactivés et conserver les protections manuelles. + from app_new.core.services.room_planning import active_room_schedules + db_schedules = active_room_schedules(room_id) + db_schedules = [s for s in db_schedules if s.week_start == monday] db_by_day = {} for schedule in db_schedules: diff --git a/app_new/templates/base.html b/app_new/templates/base.html index 3b45792..ea19208 100644 --- a/app_new/templates/base.html +++ b/app_new/templates/base.html @@ -183,6 +183,7 @@ {% endif %}{% endif %} + {% if has_permission('documents.view') %}{% endif %} + {% if current_user.is_admin() %}
+
+ + +
diff --git a/migrations/versions/g1a2b3c4d5e6_phase1_documents_planning.py b/migrations/versions/g1a2b3c4d5e6_phase1_documents_planning.py new file mode 100644 index 0000000..fd50276 --- /dev/null +++ b/migrations/versions/g1a2b3c4d5e6_phase1_documents_planning.py @@ -0,0 +1,95 @@ +"""Fondations centre documentaire et planning interne des salles.""" +from alembic import op +import sqlalchemy as sa + + +revision = "g1a2b3c4d5e6" +down_revision = "9f4a1b2c3d5e" +branch_labels = None +depends_on = None + + +def _column_names(table): + bind = op.get_bind() + inspector = sa.inspect(bind) + return {column["name"] for column in inspector.get_columns(table)} + + +def upgrade(): + if "document_type" not in _column_names("intervention_documents"): + op.add_column( + "intervention_documents", + sa.Column("document_type", sa.String(30), nullable=False, server_default="autre"), + ) + op.create_index("ix_intervention_documents_document_type", "intervention_documents", ["document_type"]) + if "document_type" not in _column_names("equipment_documents"): + op.add_column( + "equipment_documents", + sa.Column("document_type", sa.String(30), nullable=False, server_default="autre"), + ) + op.create_index("ix_equipment_documents_document_type", "equipment_documents", ["document_type"]) + + room_columns = _column_names("room_schedules") + additions = ( + ("source", sa.String(20), "manual"), + ("external_id", sa.String(191), None), + ("last_synced_at", sa.DateTime(), None), + ("source_updated_at", sa.DateTime(), None), + ("protected_from_sync", sa.Boolean(), False), + ("resolution_status", sa.String(20), "active"), + ("valid_from", sa.Date(), None), + ("valid_to", sa.Date(), None), + ("event_type", sa.String(30), "cours"), + ("conflict_note", sa.Text(), None), + ) + for name, column_type, default in additions: + if name not in room_columns: + kwargs = {"nullable": False} if name in {"source", "protected_from_sync", "resolution_status", "event_type"} else {"nullable": True} + if default is not None: + kwargs["server_default"] = sa.text("1" if default is True else "0" if default is False else repr(default)) + op.add_column("room_schedules", sa.Column(name, column_type, **kwargs)) + for name in ("source", "external_id", "protected_from_sync", "resolution_status"): + index_name = f"ix_room_schedules_{name}" + if index_name not in {i["name"] for i in sa.inspect(op.get_bind()).get_indexes("room_schedules")}: + op.create_index(index_name, "room_schedules", [name]) + + bind = op.get_bind() + bind.execute(sa.text( + """INSERT INTO permissions (code, name, module, action, description, is_active) + SELECT 'documents.view', 'Consulter le centre documentaire', 'documents', 'view', + 'Permet de rechercher et consulter les documents autorisés des interventions, équipements et produits.', 1 + WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE code='documents.view')""" + )) + bind.execute(sa.text( + """INSERT INTO role_permissions (role_id, permission_id, effect) + SELECT r.id, p.id, 'allow' + FROM roles r CROSS JOIN permissions p + WHERE p.code='documents.view' + AND r.slug IN ('admin','responsable_gmao','technicien','assistant_prevention','demandeur','lecture') + AND NOT EXISTS ( + SELECT 1 FROM role_permissions rp + WHERE rp.role_id=r.id AND rp.permission_id=p.id + )""" + )) + + +def downgrade(): + bind = op.get_bind() + bind.execute(sa.text( + """DELETE rp FROM role_permissions rp + JOIN permissions p ON p.id=rp.permission_id + WHERE p.code='documents.view'""" + )) + bind.execute(sa.text("DELETE FROM permissions WHERE code='documents.view'")) + for name in ("source", "external_id", "last_synced_at", "source_updated_at", "protected_from_sync", "resolution_status", "valid_from", "valid_to", "event_type", "conflict_note"): + if name in _column_names("room_schedules"): + index_name = f"ix_room_schedules_{name}" + if index_name in {i["name"] for i in sa.inspect(bind).get_indexes("room_schedules")}: + op.drop_index(index_name, table_name="room_schedules") + op.drop_column("room_schedules", name) + if "document_type" in _column_names("equipment_documents"): + op.drop_index("ix_equipment_documents_document_type", table_name="equipment_documents") + op.drop_column("equipment_documents", "document_type") + if "document_type" in _column_names("intervention_documents"): + op.drop_index("ix_intervention_documents_document_type", table_name="intervention_documents") + op.drop_column("intervention_documents", "document_type") diff --git a/tests/conftest.py b/tests/conftest.py index a65adcc..e68d417 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,8 +30,15 @@ from app_new import create_app, db def app(): app = create_app('testing') with app.app_context(): - db.drop_all() - db.create_all() + # MariaDB refuse de supprimer certaines tables dans l'ordre SQLAlchemy + # lorsque des clés étrangères historiques existent déjà. Cette base + # est exclusivement la base de test : désactiver temporairement les + # contrôles garantit l'isolation sans toucher à la base de l'application. + with db.engine.begin() as connection: + connection.exec_driver_sql('SET FOREIGN_KEY_CHECKS=0') + db.metadata.drop_all(bind=connection) + db.metadata.create_all(bind=connection) + connection.exec_driver_sql('SET FOREIGN_KEY_CHECKS=1') # Le runtime ne lit plus User.role. Le bootstrap de test reproduit la # migration RBAC en créant explicitement les rôles et leurs permissions. from app_new.core.authorization import PERMISSIONS_BY_ROLE, ROLE_LABELS @@ -53,7 +60,10 @@ def app(): yield app with app.app_context(): db.session.remove() - db.drop_all() + with db.engine.begin() as connection: + connection.exec_driver_sql('SET FOREIGN_KEY_CHECKS=0') + db.metadata.drop_all(bind=connection) + connection.exec_driver_sql('SET FOREIGN_KEY_CHECKS=1') @pytest.fixture(scope='session') diff --git a/tests/integration/test_phase1_documents_planning.py b/tests/integration/test_phase1_documents_planning.py new file mode 100644 index 0000000..15a0d9d --- /dev/null +++ b/tests/integration/test_phase1_documents_planning.py @@ -0,0 +1,119 @@ +from datetime import date, datetime, time + +from app_new.extensions import db +from app_new.core.models.college import Building, Room, RoomSchedule, Zone +from app_new.core.models.cleaning import CommercialProduct, ProductGeneric, ProductDocument +from app_new.core.models.equipment import Equipment, EquipmentCategory, EquipmentDocument +from app_new.core.models.maintenance import Intervention, InterventionDocument +from app_new.core.models.rbac import Permission, RolePermission +from app_new.core.services.room_planning import active_room_schedules, room_is_available, sync_pronote_schedules + + +def test_document_center_aggregates_sources_and_download_handles_missing_file(authenticated_client, app): + with app.app_context(): + building = Building(name="Phase1 bâtiment") + db.session.add(building) + db.session.flush() + zone = Zone(name="Phase1 zone", building_id=building.id) + room = Room(name="Phase1 salle", building_id=building.id, zone_id=zone.id) + category = EquipmentCategory(name="Phase1 catégorie") + db.session.add_all([zone, room, category]) + db.session.flush() + equipment = Equipment(name="Phase1 équipement", room_id=room.id, category_id=category.id) + intervention = Intervention(title="Phase1 intervention", room_id=room.id, equipment=equipment) + generic = ProductGeneric(name="Phase1 produit") + db.session.add_all([equipment, intervention, generic]) + db.session.flush() + commercial = CommercialProduct(generic_product_id=generic.id, commercial_name="Phase1 référence") + db.session.add(commercial) + db.session.flush() + intervention_doc = InterventionDocument( + intervention_id=intervention.id, filename="intervention.pdf", + filepath="/tmp/phase1-missing.pdf", document_type="rapport", + ) + equipment_doc = EquipmentDocument( + equipment_id=equipment.id, filename="notice.pdf", + filepath="/tmp/phase1-missing-equipment.pdf", document_type="notice", + ) + product_doc = ProductDocument( + commercial_product_id=commercial.id, title="FDS Phase1", + filename="fds.pdf", filepath="/tmp/phase1-missing-fds.pdf", document_type="fds", + ) + db.session.add_all([intervention_doc, equipment_doc, product_doc]) + db.session.commit() + intervention_id, equipment_id, product_id = intervention_doc.id, equipment_doc.id, product_doc.id + response = authenticated_client.get("/documents/") + assert response.status_code == 200 + body = response.get_data(as_text=True) + assert "intervention.pdf" in body + assert "notice.pdf" in body + assert "fds.pdf" in body + assert authenticated_client.get("/documents/fds").status_code == 200 + assert authenticated_client.get(f"/documents/download/intervention/{intervention_id}").status_code == 302 + assert authenticated_client.get(f"/documents/download/equipment/{equipment_id}").status_code == 302 + assert authenticated_client.get(f"/documents/download/product/{product_id}").status_code == 302 + + +def test_room_schedule_replaces_only_unprotected_manual_slot(app): + with app.app_context(): + building = Building(name="Planning phase1 building") + db.session.add(building) + db.session.flush() + room = Room(name="Planning phase1 room", building_id=building.id) + db.session.add(room) + db.session.flush() + week = date(2026, 9, 7) + manual = RoomSchedule(room_id=room.id, week_start=week, day_of_week=0, + start_time=time(8), end_time=time(9), subject="Maths", + source="manual", protected_from_sync=False) + protected = RoomSchedule(room_id=room.id, week_start=week, day_of_week=0, + start_time=time(10), end_time=time(11), subject="Réunion", + source="manual", protected_from_sync=True) + pronote = RoomSchedule(room_id=room.id, week_start=week, day_of_week=0, + start_time=time(8, 30), end_time=time(9, 30), subject="Maths", + source="pronote", external_id="course-1") + conflicting = RoomSchedule(room_id=room.id, week_start=week, day_of_week=0, + start_time=time(10, 30), end_time=time(11, 30), subject="Cours", + source="pronote", external_id="course-2") + db.session.add_all([manual, protected, pronote, conflicting]) + db.session.commit() + rows = active_room_schedules(room.id, week) + ids = {row.id for row in rows} + assert manual.id not in ids + assert protected.id in ids + assert pronote.id in ids + assert conflicting.id in ids + assert room_is_available(room.id, datetime.combine(week, time(9, 30)), datetime.combine(week, time(10))) + assert not room_is_available(room.id, datetime.combine(week, time(10, 30)), datetime.combine(week, time(10, 45))) + + +def test_pronote_sync_supersedes_initial_manual_and_preserves_protected(app): + with app.app_context(): + building = Building(name="Planning sync building") + db.session.add(building) + db.session.flush() + room = Room(name="Planning sync room", building_id=building.id) + db.session.add(room) + db.session.flush() + week = date(2026, 9, 7) + initial = RoomSchedule(room_id=room.id, week_start=week, day_of_week=0, + start_time=time(8), end_time=time(9), subject="Maths", + source="manual", protected_from_sync=False) + protected = RoomSchedule(room_id=room.id, week_start=week, day_of_week=0, + start_time=time(10), end_time=time(11), subject="Réunion", + source="manual", protected_from_sync=True) + db.session.add_all([initial, protected]) + db.session.commit() + sync_pronote_schedules(room.id, [{ + "external_id": "sync-course", "week_start": week, "day_of_week": 0, + "start_time": time(8, 30), "end_time": time(9, 30), "subject": "Maths", + }, { + "external_id": "sync-conflict", "week_start": week, "day_of_week": 0, + "start_time": time(10, 30), "end_time": time(11, 30), "subject": "Cours", + }]) + db.session.refresh(initial) + db.session.refresh(protected) + rows = RoomSchedule.query.filter_by(room_id=room.id).all() + assert initial.resolution_status == "superseded" + assert protected.protected_from_sync is True + assert any(row.external_id == "sync-conflict" and row.conflict_note for row in rows)