feat(documents): add phase 1 document center and room planning

This commit is contained in:
root 2026-08-22 22:44:21 +00:00
parent df9a402d27
commit ff507130da
23 changed files with 812 additions and 41 deletions

View file

@ -46,6 +46,7 @@ PERMISSIONS_BY_ROLE = {
# wildcard appartient exclusivement à super_admin (voir migration). # wildcard appartient exclusivement à super_admin (voir migration).
"super_admin": {"*"}, "super_admin": {"*"},
"admin": { "admin": {
"documents.view",
"dashboard.view", "intervention.view", "intervention.create", "intervention.edit", "dashboard.view", "intervention.view", "intervention.create", "intervention.edit",
"intervention.assign", "intervention.change_status", "intervention.close", "intervention.reject", "intervention.assign", "intervention.change_status", "intervention.close", "intervention.reject",
"intervention.delete", "intervention.postpone", "intervention.comment", "intervention.delete", "intervention.postpone", "intervention.comment",
@ -64,6 +65,7 @@ PERMISSIONS_BY_ROLE = {
"system.view", "system.configure", "system.admin", "system.view", "system.configure", "system.admin",
}, },
"responsable_gmao": { "responsable_gmao": {
"documents.view",
"dashboard.view", "intervention.view", "intervention.create", "intervention.manage", "dashboard.view", "intervention.view", "intervention.create", "intervention.manage",
"patrimoine.view", "patrimoine.manage", "planning.view", "planning.manage", "patrimoine.view", "patrimoine.manage", "planning.view", "planning.manage",
"stock.view", "stock.manage", "contract.view", "contract.manage", "stock.view", "stock.manage", "contract.view", "contract.manage",
@ -71,16 +73,18 @@ PERMISSIONS_BY_ROLE = {
"housing.private", "housing.private",
}, },
"technicien": { "technicien": {
"documents.view",
"dashboard.view", "intervention.view", "intervention.create", "intervention.manage", "dashboard.view", "intervention.view", "intervention.create", "intervention.manage",
"patrimoine.view", "patrimoine.manage", "planning.view", "planning.manage", "patrimoine.view", "patrimoine.manage", "planning.view", "planning.manage",
"stock.view", "stock.manage", "contract.view", "prevention.view", "export.use", "stock.view", "stock.manage", "contract.view", "prevention.view", "export.use",
}, },
"assistant_prevention": { "assistant_prevention": {
"documents.view",
"dashboard.view", "intervention.view", "intervention.create", "patrimoine.view", "dashboard.view", "intervention.view", "intervention.create", "patrimoine.view",
"planning.view", "prevention.view", "prevention.manage", "export.use", "planning.view", "prevention.view", "prevention.manage", "export.use",
}, },
"demandeur": {"dashboard.view", "intervention.view", "intervention.create"}, "demandeur": {"dashboard.view", "intervention.view", "intervention.create", "documents.view"},
"lecture": {"dashboard.view", "intervention.view", "patrimoine.view", "planning.view", "contract.view"}, "lecture": {"dashboard.view", "intervention.view", "patrimoine.view", "planning.view", "contract.view", "documents.view"},
} }
PUBLIC_ENDPOINTS = { PUBLIC_ENDPOINTS = {
@ -106,6 +110,7 @@ PREVENTION_BLUEPRINTS = {"trainings", "constraints", "prevention"}
GRANULAR_PERMISSION_CODES = { GRANULAR_PERMISSION_CODES = {
code for codes in PERMISSIONS_BY_ROLE.values() for code in codes if code != "*" code for codes in PERMISSIONS_BY_ROLE.values() for code in codes if code != "*"
} | { } | {
"documents.view",
"gmao_config.view", "gmao_config.configure", "gmao_config.view", "gmao_config.configure",
"watchdog_dnd.view", "watchdog_dnd.configure", "watchdog_dnd.view", "watchdog_dnd.configure",
"integration.ent.view", "integration.ent.configure", "integration.ent.view", "integration.ent.configure",
@ -127,6 +132,7 @@ PERMISSION_LABELS = {
"user": "Utilisateurs", "user": "Utilisateurs",
"role": "Rôles et permissions", "role": "Rôles et permissions",
"audit": "Journaux d'audit", "audit": "Journaux d'audit",
"documents": "Centre documentaire",
"system": "Système", "system": "Système",
} }
@ -162,6 +168,8 @@ def permission_metadata(code):
if code == "*": if code == "*":
return "Accès complet à l'application", ACTION_LABELS["all"][1] return "Accès complet à l'application", ACTION_LABELS["all"][1]
module, _, action = code.partition(".") 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()) 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}.")) 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} »") 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.create"
return "intervention.edit" if mutating else "intervention.view" return "intervention.edit" if mutating else "intervention.view"
if blueprint == "documents": if blueprint == "documents":
if endpoint in {"documents.index", "documents.fds", "documents.download"}:
return "documents.view"
if "intervention" in endpoint: if "intervention" in endpoint:
return "intervention.manage" if mutating else "intervention.view" return "intervention.manage" if mutating else "intervention.view"
if "equipment" in endpoint: if "equipment" in endpoint:

View file

@ -199,6 +199,16 @@ class RoomSchedule(db.Model):
teacher = db.Column(db.String(100), nullable=True) teacher = db.Column(db.String(100), nullable=True)
class_name = db.Column(db.String(50), nullable=True) class_name = db.Column(db.String(50), nullable=True)
course_name = db.Column(db.String(255), 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)) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
# Relations # Relations

View file

@ -298,6 +298,7 @@ class EquipmentDocument(db.Model):
filename = db.Column(db.String(255), nullable=False) filename = db.Column(db.String(255), nullable=False)
filepath = db.Column(db.String(500), nullable=False) filepath = db.Column(db.String(500), nullable=False)
description = db.Column(db.Text, nullable=True) 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_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
uploaded_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) uploaded_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)

View file

@ -358,6 +358,7 @@ class InterventionDocument(db.Model):
filename = db.Column(db.String(255), nullable=False) filename = db.Column(db.String(255), nullable=False)
filepath = db.Column(db.String(500), nullable=False) filepath = db.Column(db.String(500), nullable=False)
description = db.Column(db.Text, nullable=True) 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_at = db.Column(db.DateTime, nullable=True)
uploaded_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) uploaded_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)

View file

@ -351,31 +351,14 @@ class PlanningService:
Vérifie si une salle est disponible pendant une période donnée. Vérifie si une salle est disponible pendant une période donnée.
Utilise le planning PRONOTE importé. Utilise le planning PRONOTE importé.
""" """
from app_new.core.models.college import PronoteSchedule from .room_planning import room_is_available
return room_is_available(room_id, start_time, end_time)
courses = PronoteSchedule.query.filter(
PronoteSchedule.room_id == room_id,
PronoteSchedule.start_time < end_time,
PronoteSchedule.end_time > start_time
).all()
return len(courses) == 0
@staticmethod @staticmethod
def get_room_occupancy(room_id: int, d: date) -> List[Tuple[time, time]]: 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.""" """Retourne les périodes occupées d'une salle pour une date."""
from app_new.core.models.college import PronoteSchedule from .room_planning import room_occupancy
return [(start, end) for start, end, _ in room_occupancy(room_id, d)]
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]
@staticmethod @staticmethod
def calculate_planning(start_date: date, end_date: date) -> List[PlanningDay]: def calculate_planning(start_date: date, end_date: date) -> List[PlanningDay]:
@ -455,8 +438,11 @@ class PlanningService:
# Vérifier la disponibilité de la salle # Vérifier la disponibilité de la salle
if task.room_id: if task.room_id:
# TODO: Intégrer avec PRONOTE duration = task.estimated_duration or 30
pass 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 # Planifier la tâche
task.scheduled_date = current_date task.scheduled_date = current_date

View file

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

View file

@ -10,6 +10,7 @@ import os
from uuid import uuid4 from uuid import uuid4
from app_new.extensions import db from app_new.extensions import db
from ..core.models.maintenance import InterventionDocument from ..core.models.maintenance import InterventionDocument
from .service import list_documents, get_document, can_read_source
documents_bp = Blueprint('documents', __name__, url_prefix='/documents') 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 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/<source>/<int:document_id>')
@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/<int:intervention_id>', methods=['POST']) @documents_bp.route('/upload/intervention/<int:intervention_id>', methods=['POST'])
@login_required @login_required
def upload_intervention(intervention_id): def upload_intervention(intervention_id):
@ -50,6 +115,7 @@ def upload_intervention(intervention_id):
filename=unique_filename, filename=unique_filename,
filepath=filepath, filepath=filepath,
description=request.form.get('description', ''), description=request.form.get('description', ''),
document_type=request.form.get('document_type') or 'autre',
uploaded_by_id=current_user.id uploaded_by_id=current_user.id
) )
db.session.add(document) db.session.add(document)
@ -93,8 +159,7 @@ def upload_equipment(equipment_id):
equipment_id=equipment_id, equipment_id=equipment_id,
filename=unique_filename, filename=unique_filename,
filepath=filepath, filepath=filepath,
file_type=filename.rsplit('.', 1)[1].lower() if '.' in filename else 'unknown', document_type=request.form.get('document_type') or 'autre',
file_size=os.path.getsize(filepath),
uploaded_by_id=current_user.id uploaded_by_id=current_user.id
) )
db.session.add(document) db.session.add(document)

View file

@ -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)

View file

@ -61,6 +61,7 @@ def upload_document(id):
filename=original_filename, filename=original_filename,
filepath=filepath, filepath=filepath,
description=request.form.get('description'), description=request.form.get('description'),
document_type=request.form.get('document_type') or 'autre',
uploaded_by_id=current_user.id uploaded_by_id=current_user.id
) )
db.session.add(doc) db.session.add(doc)

View file

@ -52,11 +52,10 @@ def detail(id):
room = Room.query.get_or_404(id) room = Room.query.get_or_404(id)
today = date.today() today = date.today()
week_start = today - timedelta(days=today.weekday()) week_start = today - timedelta(days=today.weekday())
courses = RoomSchedule.query.filter_by( # La vue salle consomme la résolution interne (manuel/Pronote/import),
room_id=room.id, # afin de ne pas réafficher un créneau supplanté par la synchronisation.
week_start=week_start, from app_new.core.services.room_planning import active_room_schedules
day_of_week=today.weekday(), courses = active_room_schedules(room.id, today)
).order_by(RoomSchedule.start_time).all()
current_time = datetime.now().time() current_time = datetime.now().time()
current_course = next( current_course = next(
(course for course in courses (course for course in courses

View file

@ -356,6 +356,10 @@
<input type="file" name="file" class="form-control" required> <input type="file" name="file" class="form-control" required>
<div class="form-text">PDF, images, documents Office autorisés.</div> <div class="form-text">PDF, images, documents Office autorisés.</div>
</div> </div>
<div class="mb-3">
<label class="form-label" for="document_type">Type de document</label>
<select id="document_type" name="document_type" class="form-select"><option value="autre">Autre</option><option value="notice">Notice / manuel</option><option value="facture">Facture</option><option value="devis">Devis</option><option value="rapport">Rapport / compte rendu</option><option value="certificat">Certificat / contrôle</option><option value="photo">Photo</option><option value="plan">Plan</option></select>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Description</label> <label class="form-label">Description</label>
<textarea name="description" class="form-control" rows="2" placeholder="Description optionnelle..."></textarea> <textarea name="description" class="form-control" rows="2" placeholder="Description optionnelle..."></textarea>

View file

@ -696,6 +696,10 @@
<input type="file" name="file" class="form-control" required> <input type="file" name="file" class="form-control" required>
<div class="form-text">PDF, images, documents Office autorisés.</div> <div class="form-text">PDF, images, documents Office autorisés.</div>
</div> </div>
<div class="mb-3">
<label class="form-label" for="document_type">Type de document</label>
<select id="document_type" name="document_type" class="form-select"><option value="autre">Autre</option><option value="notice">Notice / manuel</option><option value="facture">Facture</option><option value="devis">Devis</option><option value="rapport">Rapport / compte rendu</option><option value="certificat">Certificat / contrôle</option><option value="photo">Photo</option><option value="plan">Plan</option></select>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Description</label> <label class="form-label">Description</label>
<textarea name="description" class="form-control" rows="2" placeholder="Description optionnelle..."></textarea> <textarea name="description" class="form-control" rows="2" placeholder="Description optionnelle..."></textarea>

View file

@ -11,11 +11,68 @@ from ..core.models.planning import (
from ..core.models.maintenance import Lot, LotTask, Intervention from ..core.models.maintenance import Lot, LotTask, Intervention
from ..core.models.equipment import EquipmentCategory from ..core.models.equipment import EquipmentCategory
from ..core.models.company import Company 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 = 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 lordre 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/<int:id>/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('/') @planning_bp.route('/')
@login_required @login_required
def index(): def index():

View file

@ -0,0 +1,2 @@
{% extends "base.html" %}{% block title %}Ajouter un créneau de salle{% endblock %}{% block content %}
<div class="container main-content"><h1 class="h3">Ajouter un créneau de salle</h1><p class="text-muted">Saisissez un planning utilisable même lorsque Pronote n'est pas configuré.</p><form method="post" class="card card-body"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="row g-3"><div class="col-md-6"><label class="form-label" for="room_id">Salle *</label><select class="form-select" id="room_id" name="room_id" required><option value="">Choisir</option>{% for room in rooms %}<option value="{{ room.id }}">{{ room.full_name }}</option>{% endfor %}</select></div><div class="col-md-3"><label class="form-label" for="week_start">Semaine du *</label><input class="form-control" id="week_start" name="week_start" type="date" required></div><div class="col-md-3"><label class="form-label" for="day_of_week">Jour *</label><select class="form-select" id="day_of_week" name="day_of_week" required>{% for value,label in [(0,'Lundi'),(1,'Mardi'),(2,'Mercredi'),(3,'Jeudi'),(4,'Vendredi'),(5,'Samedi'),(6,'Dimanche')] %}<option value="{{ value }}">{{ label }}</option>{% endfor %}</select></div><div class="col-md-3"><label class="form-label" for="start_time">Début *</label><input class="form-control" id="start_time" name="start_time" type="time" required></div><div class="col-md-3"><label class="form-label" for="end_time">Fin *</label><input class="form-control" id="end_time" name="end_time" type="time" required></div><div class="col-md-6"><label class="form-label" for="event_type">Type</label><select class="form-select" id="event_type" name="event_type"><option value="cours">Cours</option><option value="réunion">Réunion</option><option value="examen">Examen</option><option value="réservation">Réservation</option><option value="travaux">Travaux</option><option value="fermeture">Fermeture</option><option value="autre">Autre</option></select></div><div class="col-md-6"><label class="form-label" for="course_name">Libellé</label><input class="form-control" id="course_name" name="course_name"></div><div class="col-md-6"><label class="form-label" for="subject">Matière</label><input class="form-control" id="subject" name="subject"></div><div class="col-md-6"><label class="form-label" for="class_name">Classe/groupe</label><input class="form-control" id="class_name" name="class_name"></div><div class="col-12"><div class="form-check"><input class="form-check-input" id="protected_from_sync" name="protected_from_sync" type="checkbox"><label class="form-check-label" for="protected_from_sync">Conserver ce créneau lors des mises à jour automatiques</label><div class="form-text">À utiliser pour une réunion, un examen, des travaux ou une réservation décidée localement.</div></div></div></div><div class="mt-3"><button class="btn btn-primary">Enregistrer</button> <a class="btn btn-outline-secondary" href="{{ url_for('planning.room_schedules') }}">Annuler</a></div></form></div>{% endblock %}

View file

@ -0,0 +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 %}

View file

@ -167,10 +167,12 @@ def room_planning(room_id):
}) })
# 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
db_schedules = RoomSchedule.query.filter( # Pronote reste une source facultative ; l'affichage des créneaux internes
RoomSchedule.room_id == room_id, # passe par le service de résolution commun pour masquer les créneaux
RoomSchedule.week_start == monday # supplantés/désactivés et conserver les protections manuelles.
).order_by(RoomSchedule.day_of_week, RoomSchedule.start_time).all() 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 = {} db_by_day = {}
for schedule in db_schedules: for schedule in db_schedules:

View file

@ -183,6 +183,7 @@
<ul class="dropdown-menu dropdown-menu-dark" style="background:#34495e;"> <ul class="dropdown-menu dropdown-menu-dark" style="background:#34495e;">
{% if has_permission('intervention.view') %}<li><a class="dropdown-item" href="{{ url_for('interventions.index') }}"><i class="bi bi-list-ul"></i> Toutes les interventions</a></li>{% endif %} {% if has_permission('intervention.view') %}<li><a class="dropdown-item" href="{{ url_for('interventions.index') }}"><i class="bi bi-list-ul"></i> Toutes les interventions</a></li>{% endif %}
{% if has_permission('planning.view') %}<li><a class="dropdown-item" href="{{ url_for('planning.index') }}"><i class="bi bi-calendar-check"></i> Planning unifié</a></li> {% if has_permission('planning.view') %}<li><a class="dropdown-item" href="{{ url_for('planning.index') }}"><i class="bi bi-calendar-check"></i> Planning unifié</a></li>
<li><a class="dropdown-item" href="{{ url_for('planning.room_schedules') }}"><i class="bi bi-door-open"></i> Planning des salles</a></li>
<li><a class="dropdown-item" href="{{ url_for('planning.tasks') }}"><i class="bi bi-list-check"></i> Tâches préventives</a></li> <li><a class="dropdown-item" href="{{ url_for('planning.tasks') }}"><i class="bi bi-list-check"></i> Tâches préventives</a></li>
<li><a class="dropdown-item" href="{{ url_for('planning.scheduled') }}"><i class="bi bi-calendar-week"></i> Tâches planifiées</a></li>{% endif %} <li><a class="dropdown-item" href="{{ url_for('planning.scheduled') }}"><i class="bi bi-calendar-week"></i> Tâches planifiées</a></li>{% endif %}
<li><hr class="dropdown-divider" style="border-color:rgba(255,255,255,0.15)"></li> <li><hr class="dropdown-divider" style="border-color:rgba(255,255,255,0.15)"></li>
@ -242,6 +243,12 @@
</ul> </ul>
</li>{% endif %}{% endif %} </li>{% endif %}{% endif %}
{% if has_permission('documents.view') %}<li class="nav-item">
<a class="nav-link {% if request.path.startswith('/documents') %}active{% endif %}" href="{{ url_for('documents.index') }}">
<i class="bi bi-folder2-open"></i> <span>Centre documentaire</span>
</a>
</li>{% endif %}
{% if current_user.is_admin() %} {% if current_user.is_admin() %}
<li class="nav-group-label d-lg-none mt-2">Administration</li> <li class="nav-group-label d-lg-none mt-2">Administration</li>
<li class="nav-item dropdown"> <li class="nav-item dropdown">

View file

@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block title %}FDS — Centre documentaire{% 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">Fiches de données de sécurité</h1><p class="text-muted">Les FDS sont consultées depuis les documents des références commerciales accessibles.</p></div><a class="btn btn-outline-secondary" href="{{ url_for('documents.index') }}">Centre documentaire</a></div>
<form class="row g-2 mb-3"><div class="col-md-8"><label for="q" class="visually-hidden">Rechercher un produit</label><input id="q" name="q" class="form-control" value="{{ filters.get('q','') }}" placeholder="Produit, fabricant ou référence"></div><div class="col-md-2"><button class="btn btn-primary" type="submit">Rechercher</button></div></form>
{% if results %}<div class="table-responsive table-responsive-stack"><table class="table table-hover"><thead><tr><th>Produit</th><th>Fichier</th><th>Version</th><th>Localisation</th><th>Action</th></tr></thead><tbody>{% for item in results %}<tr><td data-label="Produit">{% if item.context_url %}<a href="{{ item.context_url }}">{{ item.context_label }}</a>{% else %}{{ item.context_label }}{% endif %}</td><td data-label="Fichier">{{ item.filename }}</td><td data-label="Version">{{ item.document_type_label }}</td><td data-label="Localisation">{{ item.location_summary }}</td><td data-label="Action"><a class="btn btn-sm btn-outline-primary" href="{{ item.download_url }}">Télécharger</a></td></tr>{% endfor %}</tbody></table></div>{% else %}<div class="alert alert-info">Aucune FDS accessible n'a été trouvée.</div>{% endif %}</div>
{% endblock %}

View file

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Centre documentaire{% 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 mb-1">Centre documentaire</h1><p class="text-muted mb-0">Recherchez les documents accessibles depuis les interventions, équipements et produits.</p></div>
<a class="btn btn-outline-primary" href="{{ url_for('documents.fds') }}"><i class="bi bi-shield-check"></i> Voir les FDS</a>
</div>
<form class="card card-body mb-3 filter-bar" method="get">
<div class="row g-2 align-items-end">
<div class="col-md-5"><label class="form-label" for="q">Recherche</label><input id="q" name="q" class="form-control" value="{{ filters.get('q','') }}" placeholder="Nom, titre, intervention, équipement ou produit"></div>
<div class="col-md-2"><label class="form-label" for="source">Source</label><select id="source" name="source" class="form-select"><option value="">Toutes</option>{% for value,label in [('intervention','Interventions'),('equipment','Équipements'),('product','Produits')] %}<option value="{{ value }}" {% if filters.get('source') == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="col-md-2"><label class="form-label" for="document_type">Type</label><select id="document_type" name="document_type" class="form-select"><option value="">Tous</option>{% for value,label in document_types.items() %}<option value="{{ value }}" {% if filters.get('document_type') == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="col-md-2"><label class="form-label" for="date_from">Depuis</label><input id="date_from" type="date" name="date_from" class="form-control" value="{{ filters.get('date_from','') }}"></div>
<div class="col-md-1"><button class="btn btn-primary w-100" type="submit" aria-label="Rechercher"><i class="bi bi-search"></i></button></div>
</div>
</form>
{% if results %}
<div class="table-responsive table-responsive-stack"><table class="table table-hover align-middle"><thead><tr><th>Document</th><th>Type</th><th>Lié à</th><th>Localisation</th><th>Date</th><th>Actions</th></tr></thead><tbody>
{% for item in results %}<tr><td data-label="Document"><strong>{{ item.title }}</strong><br><small class="text-muted">{{ item.filename }}</small></td><td data-label="Type">{{ item.document_type_label }}</td><td data-label="Lié à">{% if item.context_url %}<a href="{{ item.context_url }}">{{ item.context_label }}</a>{% else %}{{ item.context_label }}{% endif %}</td><td data-label="Localisation">{{ item.location_summary }}{% if item.location_labels|length > 1 %}<details><summary class="small">Détail</summary><ul class="mb-0">{% for location in item.location_labels %}<li>{{ location }}</li>{% endfor %}</ul></details>{% endif %}</td><td data-label="Date">{{ item.document_date|datetime_fmt if item.document_date else '—' }}</td><td data-label="Actions"><a class="btn btn-sm btn-outline-primary" href="{{ item.download_url }}"><i class="bi bi-download"></i> Télécharger</a></td></tr>{% endfor %}
</tbody></table></div>
{% else %}<div class="alert alert-info"><i class="bi bi-info-circle"></i> Aucun document accessible n'a été trouvé. Les documents sont ajoutés depuis les fiches des équipements, interventions et produits.</div>{% endif %}
{% if total > per_page %}<nav aria-label="Pagination documentaire"><ul class="pagination"><li class="page-item {% if page <= 1 %}disabled{% endif %}"><a class="page-link" href="{{ url_for('documents.index', page=page-1, **filters.to_dict()) }}">Précédent</a></li><li class="page-item active"><span class="page-link">{{ page }}</span></li><li class="page-item {% if page * per_page >= total %}disabled{% endif %}"><a class="page-link" href="{{ url_for('documents.index', page=page+1, **filters.to_dict()) }}">Suivant</a></li></ul></nav>{% endif %}
</div>
{% endblock %}

View file

@ -549,6 +549,10 @@
<input type="file" name="file" class="form-control" required> <input type="file" name="file" class="form-control" required>
<div class="form-text">PDF, images, documents Office autorisés.</div> <div class="form-text">PDF, images, documents Office autorisés.</div>
</div> </div>
<div class="mb-3">
<label class="form-label" for="document_type">Type de document</label>
<select id="document_type" name="document_type" class="form-select"><option value="autre">Autre</option><option value="notice">Notice / manuel</option><option value="facture">Facture</option><option value="devis">Devis</option><option value="rapport">Rapport / compte rendu</option><option value="certificat">Certificat / contrôle</option><option value="photo">Photo</option><option value="plan">Plan</option></select>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Description</label> <label class="form-label">Description</label>
<textarea name="description" class="form-control" rows="2" placeholder="Description optionnelle..."></textarea> <textarea name="description" class="form-control" rows="2" placeholder="Description optionnelle..."></textarea>

View file

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

View file

@ -30,8 +30,15 @@ from app_new import create_app, db
def app(): def app():
app = create_app('testing') app = create_app('testing')
with app.app_context(): with app.app_context():
db.drop_all() # MariaDB refuse de supprimer certaines tables dans l'ordre SQLAlchemy
db.create_all() # 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 # 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. # migration RBAC en créant explicitement les rôles et leurs permissions.
from app_new.core.authorization import PERMISSIONS_BY_ROLE, ROLE_LABELS from app_new.core.authorization import PERMISSIONS_BY_ROLE, ROLE_LABELS
@ -53,7 +60,10 @@ def app():
yield app yield app
with app.app_context(): with app.app_context():
db.session.remove() 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') @pytest.fixture(scope='session')

View file

@ -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)