feat(documents): add phase 1 document center and room planning
This commit is contained in:
parent
df9a402d27
commit
ff507130da
23 changed files with 812 additions and 41 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
135
app_new/core/services/room_planning.py
Normal file
135
app_new/core/services/room_planning.py
Normal 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()
|
||||
|
|
@ -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/<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'])
|
||||
@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)
|
||||
|
|
|
|||
223
app_new/documents/service.py
Normal file
223
app_new/documents/service.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -356,6 +356,10 @@
|
|||
<input type="file" name="file" class="form-control" required>
|
||||
<div class="form-text">PDF, images, documents Office autorisés.</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">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" class="form-control" rows="2" placeholder="Description optionnelle..."></textarea>
|
||||
|
|
@ -396,4 +400,4 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -696,6 +696,10 @@
|
|||
<input type="file" name="file" class="form-control" required>
|
||||
<div class="form-text">PDF, images, documents Office autorisés.</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">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" class="form-control" rows="2" placeholder="Description optionnelle..."></textarea>
|
||||
|
|
|
|||
|
|
@ -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/<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('/')
|
||||
@login_required
|
||||
def index():
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
4
app_new/planning/templates/planning/room_schedules.html
Normal file
4
app_new/planning/templates/planning/room_schedules.html
Normal 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 %}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -183,6 +183,7 @@
|
|||
<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('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.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>
|
||||
|
|
@ -242,6 +243,12 @@
|
|||
</ul>
|
||||
</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() %}
|
||||
<li class="nav-group-label d-lg-none mt-2">Administration</li>
|
||||
<li class="nav-item dropdown">
|
||||
|
|
|
|||
7
app_new/templates/documents/fds.html
Normal file
7
app_new/templates/documents/fds.html
Normal 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 %}
|
||||
25
app_new/templates/documents/index.html
Normal file
25
app_new/templates/documents/index.html
Normal 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 %}
|
||||
|
|
@ -549,6 +549,10 @@
|
|||
<input type="file" name="file" class="form-control" required>
|
||||
<div class="form-text">PDF, images, documents Office autorisés.</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">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" class="form-control" rows="2" placeholder="Description optionnelle..."></textarea>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
119
tests/integration/test_phase1_documents_planning.py
Normal file
119
tests/integration/test_phase1_documents_planning.py
Normal 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)
|
||||
Loading…
Reference in a new issue