Fiabilise la maintenance et les sauvegardes

This commit is contained in:
root 2026-08-14 22:56:36 +00:00
parent 1255066fa9
commit 3135f36a63
19 changed files with 338 additions and 122 deletions

View file

@ -5,7 +5,7 @@ Endpoints publics avec authentification par clé API.
from flask import Blueprint, request, jsonify, current_app from flask import Blueprint, request, jsonify, current_app
from flask_login import login_required, current_user from flask_login import login_required, current_user
from functools import wraps from functools import wraps
import hmac from werkzeug.security import check_password_hash
from app_new.extensions import db from app_new.extensions import db
api_bp = Blueprint('api_v1', __name__, url_prefix='/api/v1') api_bp = Blueprint('api_v1', __name__, url_prefix='/api/v1')
@ -19,12 +19,8 @@ def api_key_required(f):
api_key = request.headers.get('X-API-Key') api_key = request.headers.get('X-API-Key')
if api_key: if api_key:
from app_new.core.models.settings import AppSettings from app_new.core.models.settings import AppSettings
settings = AppSettings.query.first() api_key_hash = AppSettings.get('api_key_hash')
if ( if api_key_hash and check_password_hash(api_key_hash, api_key):
settings
and settings.api_key
and hmac.compare_digest(api_key, settings.api_key)
):
return f(*args, **kwargs) return f(*args, **kwargs)
return jsonify({'error': 'Clé API invalide'}), 401 return jsonify({'error': 'Clé API invalide'}), 401
# Sinon, fallback sur session login # Sinon, fallback sur session login
@ -47,7 +43,7 @@ def status():
'interventions_en_cours': Intervention.query.filter_by(status='en_cours').count(), 'interventions_en_cours': Intervention.query.filter_by(status='en_cours').count(),
'interventions_en_attente': Intervention.query.filter_by(status='en_attente').count(), 'interventions_en_attente': Intervention.query.filter_by(status='en_attente').count(),
'equipments_total': Equipment.query.count(), 'equipments_total': Equipment.query.count(),
'equipments_panne': Equipment.query.filter_by(status='panne').count(), 'equipments_panne': Equipment.query.filter(Equipment.status.in_(['en_panne', 'hors_service', 'hs'])).count(),
'rooms_total': Room.query.count(), 'rooms_total': Room.query.count(),
'buildings_total': Building.query.count(), 'buildings_total': Building.query.count(),
}) })
@ -59,8 +55,8 @@ def list_interventions():
"""Liste des interventions avec pagination.""" """Liste des interventions avec pagination."""
from app_new.core.models.maintenance import Intervention from app_new.core.models.maintenance import Intervention
page = int(request.args.get('page', 1)) page = max(request.args.get('page', 1, type=int) or 1, 1)
per_page = min(int(request.args.get('per_page', 20)), 100) per_page = min(max(request.args.get('per_page', 20, type=int) or 20, 1), 100)
status = request.args.get('status') status = request.args.get('status')
query = Intervention.query query = Intervention.query
@ -120,8 +116,8 @@ def list_equipments():
"""Liste des équipements avec pagination.""" """Liste des équipements avec pagination."""
from app_new.core.models.equipment import Equipment from app_new.core.models.equipment import Equipment
page = int(request.args.get('page', 1)) page = max(request.args.get('page', 1, type=int) or 1, 1)
per_page = min(int(request.args.get('per_page', 20)), 100) per_page = min(max(request.args.get('per_page', 20, type=int) or 20, 1), 100)
status = request.args.get('status') status = request.args.get('status')
query = Equipment.query query = Equipment.query

View file

@ -121,6 +121,12 @@ def required_permission(endpoint, method):
if endpoint in {"interventions.create", "interventions.create_for_group"}: if endpoint in {"interventions.create", "interventions.create_for_group"}:
return "intervention.create" return "intervention.create"
return "intervention.manage" if mutating else "intervention.view" return "intervention.manage" if mutating else "intervention.view"
if blueprint == "documents":
if "intervention" in endpoint:
return "intervention.manage" if mutating else "intervention.view"
if "equipment" in endpoint:
return "patrimoine.manage" if mutating else "patrimoine.view"
return "system.admin"
if blueprint in STOCK_BLUEPRINTS: if blueprint in STOCK_BLUEPRINTS:
return "stock.manage" if mutating else "stock.view" return "stock.manage" if mutating else "stock.view"
if blueprint in CONTRACT_BLUEPRINTS: if blueprint in CONTRACT_BLUEPRINTS:

View file

@ -243,10 +243,9 @@ class Equipment(db.Model):
return self.room return self.room
def create_scheduled_tasks_from_lot(self, commit=True): def create_scheduled_tasks_from_lot(self, commit=True):
"""Crée les tâches planifiées à partir des tâches du lot héritées.""" """Crée les échéances via le moteur unique de maintenance."""
from .maintenance import LotTask from .maintenance import LotTask
from .planning import ScheduledTask from ..services.maintenance_engine import generate_due_tasks
from datetime import date, timedelta
# Utiliser le lot effectif (hérité du parent si non défini) # Utiliser le lot effectif (hérité du parent si non défini)
effective_lot_id = self.effective_lot_id effective_lot_id = self.effective_lot_id
@ -255,37 +254,9 @@ class Equipment(db.Model):
return [] return []
created_tasks = [] created_tasks = []
lot_tasks = LotTask.query.filter_by(lot_id=effective_lot_id, is_active=True).all()
# Récupérer les tâches du lot
lot_tasks = LotTask.query.filter_by(lot_id=effective_lot_id).all()
for lt in lot_tasks: for lt in lot_tasks:
# Vérifier si une tâche planifiée existe déjà created_tasks.extend(generate_due_tasks(lt, equipment_ids={self.id}, commit=commit))
existing = ScheduledTask.query.filter_by(
lot_task_id=lt.id,
equipment_id=self.id
).first()
if not existing:
# Calculer la première date planifiée
scheduled_date = date.today()
if lt.jours_entre_interventions:
scheduled_date = date.today() + timedelta(days=lt.jours_entre_interventions)
# Créer la tâche planifiée
scheduled_task = ScheduledTask(
lot_task_id=lt.id,
equipment_id=self.id,
room_id=self.room_id or (self.effective_room.id if self.effective_room else None),
scheduled_date=scheduled_date,
estimated_duration=lt.duree_minutes,
status='planned'
)
db.session.add(scheduled_task)
created_tasks.append(scheduled_task)
if created_tasks and commit:
db.session.commit()
return created_tasks return created_tasks
def __repr__(self): def __repr__(self):

View file

@ -3,6 +3,8 @@ Core Routes - Administration
GMAO Collège GMAO Collège
""" """
from flask import Blueprint, render_template, redirect, url_for, request, flash from flask import Blueprint, render_template, redirect, url_for, request, flash
import secrets
from werkzeug.security import generate_password_hash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from ...extensions import db from ...extensions import db
from ..models.user import User, Staff from ..models.user import User, Staff
@ -285,6 +287,30 @@ def settings_openrouter():
return jsonify({'success': True, 'message': 'Clé API OpenRouter sauvegardée'}) return jsonify({'success': True, 'message': 'Clé API OpenRouter sauvegardée'})
@admin_bp.route('/settings/api-key', methods=['POST'])
@login_required
@admin_required
def generate_api_key():
"""Génère une clé API affichée une seule fois et stockée hachée."""
from ..models.settings import AppSettings
raw_key = f"gmao_{secrets.token_urlsafe(32)}"
AppSettings.set(
'api_key_hash', generate_password_hash(raw_key),
description='Empreinte de la clé API REST GMAO',
)
return render_template('admin/api_key_created.html', api_key=raw_key)
@admin_bp.route('/settings/api-key/revoke', methods=['POST'])
@login_required
@admin_required
def revoke_api_key():
from ..models.settings import AppSettings
AppSettings.set('api_key_hash', '', description='Clé API REST révoquée')
flash('Clé API révoquée.', 'success')
return redirect(url_for('admin.settings'))
@admin_bp.route('/setup-wizard') @admin_bp.route('/setup-wizard')
@login_required @login_required
@admin_required @admin_required

View file

@ -3,7 +3,7 @@ from datetime import date, timedelta
from app_new.extensions import db from app_new.extensions import db
from app_new.core.models.planning import Meter, ScheduledTask from app_new.core.models.planning import Meter, ScheduledTask
from app_new.core.models.maintenance import LotTask from app_new.core.models.maintenance import Intervention, LotTask
from app_new.core.services.planning_service import PlanningService from app_new.core.services.planning_service import PlanningService
@ -27,7 +27,41 @@ def _working_day(candidate):
raise ValueError("Aucune journée travaillée disponible dans les 12 prochains mois.") raise ValueError("Aucune journée travaillée disponible dans les 12 prochains mois.")
def generate_due_tasks(task: LotTask, event=None, today=None): def _last_completion(task, equipment):
dates = []
scheduled = ScheduledTask.query.filter(
ScheduledTask.lot_task_id == task.id,
ScheduledTask.equipment_id == equipment.id,
ScheduledTask.status.in_(['completed', 'done']),
).order_by(ScheduledTask.completed_at.desc(), ScheduledTask.scheduled_date.desc()).first()
if scheduled:
if scheduled.completed_at:
dates.append(scheduled.completed_at.date())
elif scheduled.scheduled_date:
dates.append(scheduled.scheduled_date)
intervention = Intervention.query.filter(
Intervention.lot_task_id == task.id,
Intervention.equipment_id == equipment.id,
Intervention.status.in_(['terminee', 'cloturee']),
Intervention.is_deleted.is_(False),
).order_by(Intervention.completed_at.desc(), Intervention.completed_date.desc()).first()
if intervention:
if intervention.completed_at:
dates.append(intervention.completed_at.date())
elif intervention.completed_date:
dates.append(intervention.completed_date)
elif intervention.scheduled_date:
dates.append(intervention.scheduled_date)
return max(dates) if dates else None
def _calendar_due(task, equipment, today):
last = _last_completion(task, equipment)
interval = task.jours_entre_interventions or 0
return last + timedelta(days=interval) if last and interval else today
def generate_due_tasks(task: LotTask, event=None, today=None, equipment_ids=None, commit=True):
"""Génère les échéances sans doublon et retourne les objets créés.""" """Génère les échéances sans doublon et retourne les objets créés."""
today = today or date.today() today = today or date.today()
if not task.is_active: if not task.is_active:
@ -41,18 +75,54 @@ def generate_due_tasks(task: LotTask, event=None, today=None):
return [] return []
created = [] created = []
for equipment in _targets(task.lot): for equipment in _targets(task.lot):
if equipment_ids is not None and equipment.id not in set(equipment_ids):
continue
if task.trigger_type == "meter": if task.trigger_type == "meter":
meter = Meter.query.filter_by(equipment_id=equipment.id, is_active=True).order_by(Meter.current_value.desc()).first() meter = Meter.query.filter_by(equipment_id=equipment.id, is_active=True).order_by(Meter.current_value.desc()).first()
if not meter or task.meter_threshold is None or meter.current_value < task.meter_threshold: base_value = meter.last_maintenance_value if meter else None
used_since_maintenance = (meter.current_value - (base_value or meter.initial_value or 0)) if meter else 0
if not meter or task.meter_threshold is None or used_since_maintenance < task.meter_threshold:
continue continue
existing = ScheduledTask.query.filter_by(lot_task_id=task.id, equipment_id=equipment.id, status="planned").first() existing = ScheduledTask.query.filter(
ScheduledTask.lot_task_id == task.id,
ScheduledTask.equipment_id == equipment.id,
ScheduledTask.status.in_(['planned', 'in_progress']),
).first()
if existing: if existing:
continue continue
due = _working_day(today + timedelta(days=max(task.advance_days or 0, 0))) due = _calendar_due(task, equipment, today) if task.trigger_type == 'calendar' else today
if task.trigger_type == 'calendar' and due > today + timedelta(days=max(task.advance_days or 0, 0)):
continue
if task.trigger_type == 'season':
completed_this_season = ScheduledTask.query.filter(
ScheduledTask.lot_task_id == task.id,
ScheduledTask.equipment_id == equipment.id,
ScheduledTask.status.in_(['completed', 'done']),
db.extract('year', ScheduledTask.scheduled_date) == today.year,
).first()
if completed_this_season:
continue
due = _working_day(max(due, today))
item = ScheduledTask(lot_task_id=task.id, equipment_id=equipment.id, item = ScheduledTask(lot_task_id=task.id, equipment_id=equipment.id,
room_id=equipment.effective_room.id if equipment.effective_room else None, room_id=equipment.effective_room.id if equipment.effective_room else None,
scheduled_date=due, estimated_duration=task.effective_duration(), status="planned") scheduled_date=due, estimated_duration=task.effective_duration(), status="planned")
db.session.add(item) db.session.add(item)
created.append(item) created.append(item)
db.session.commit() if commit:
db.session.commit()
elif created:
db.session.flush()
return created return created
def reconcile_planned_tasks():
"""Déplace les échéances ouvertes placées sur un jour non travaillé."""
changed = []
tasks = ScheduledTask.query.filter(ScheduledTask.status.in_(['planned', 'in_progress'])).all()
for item in tasks:
if item.scheduled_date and not PlanningService.get_working_hours(item.scheduled_date, user_id=item.assigned_to_id):
item.scheduled_date = _working_day(item.scheduled_date + timedelta(days=1))
changed.append(item)
if changed:
db.session.commit()
return changed

View file

@ -7,7 +7,8 @@ from typing import List, Dict, Optional, Tuple
from app_new.extensions import db from app_new.extensions import db
from app_new.core.models.planning import ( from app_new.core.models.planning import (
ScheduledTask, AdminTask, WorkSchedule, CollegeClosure, ScheduledTask, AdminTask, WorkSchedule, CollegeClosure,
ClosureSchedule, ClosureWorkDay, TechnicianAvailability, PlanningDay, PlanningItem ClosureSchedule, ClosureWorkDay, TechnicianAvailability, PersonalLeave,
Training, TrainingParticipant, PlanningDay, PlanningItem
) )
from app_new.core.models.maintenance import Intervention from app_new.core.models.maintenance import Intervention
from app_new.core.models.equipment import Equipment from app_new.core.models.equipment import Equipment
@ -107,7 +108,32 @@ class PlanningService:
PRIORITY_FORMATION = 1 PRIORITY_FORMATION = 1
@staticmethod @staticmethod
def get_working_hours(d: date) -> Optional[Tuple[time, time, Optional[time], Optional[time]]]: def maintenance_unavailability(d: date, user_id=None):
"""Retourne le motif qui interdit toute maintenance ce jour-là."""
leave_query = PersonalLeave.query.filter(
PersonalLeave.start_date <= d,
PersonalLeave.end_date >= d,
)
if user_id is not None:
leave_query = leave_query.filter(PersonalLeave.user_id == user_id)
leave = leave_query.first()
if leave:
return f"Absence : {leave.leave_type or 'indisponible'}"
training_query = Training.query.join(TrainingParticipant).filter(
Training.start_date <= d,
Training.end_date >= d,
TrainingParticipant.is_confirmed.is_(True),
)
if user_id is not None:
training_query = training_query.filter(TrainingParticipant.user_id == user_id)
training = training_query.first()
if training:
return f"Formation : {training.name}"
return None
@staticmethod
def get_working_hours(d: date, user_id=None) -> Optional[Tuple[time, time, Optional[time], Optional[time]]]:
""" """
Retourne les horaires de travail pour une date donnée. Retourne les horaires de travail pour une date donnée.
Retourne (start, end, lunch_start, lunch_end) ou None si non travaillé. Retourne (start, end, lunch_start, lunch_end) ou None si non travaillé.
@ -116,6 +142,9 @@ class PlanningService:
is_holiday, holiday_name = is_public_holiday(d) is_holiday, holiday_name = is_public_holiday(d)
if is_holiday: if is_holiday:
return None return None
if PlanningService.maintenance_unavailability(d, user_id):
return None
# Vérifier si c'est un jour de vacances # Vérifier si c'est un jour de vacances
closure = CollegeClosure.query.filter( closure = CollegeClosure.query.filter(

View file

@ -7,6 +7,7 @@ from flask_login import login_required, current_user
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from datetime import datetime, timezone from datetime import datetime, timezone
import os import os
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
@ -22,6 +23,8 @@ def allowed_file(filename):
@login_required @login_required
def upload_intervention(intervention_id): def upload_intervention(intervention_id):
"""Upload un document pour une intervention.""" """Upload un document pour une intervention."""
from ..core.models.maintenance import Intervention
Intervention.query.get_or_404(intervention_id)
if 'file' not in request.files: if 'file' not in request.files:
flash('Aucun fichier sélectionné.', 'error') flash('Aucun fichier sélectionné.', 'error')
return redirect(url_for('interventions.detail', id=intervention_id)) return redirect(url_for('interventions.detail', id=intervention_id))
@ -34,8 +37,7 @@ def upload_intervention(intervention_id):
if file and allowed_file(file.filename): if file and allowed_file(file.filename):
filename = secure_filename(file.filename) filename = secure_filename(file.filename)
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S') unique_filename = f"{uuid4().hex}_{filename}"
unique_filename = f"{timestamp}_{filename}"
upload_dir = os.path.join(current_app.config.get('UPLOAD_FOLDER', 'uploads'), 'interventions') upload_dir = os.path.join(current_app.config.get('UPLOAD_FOLDER', 'uploads'), 'interventions')
os.makedirs(upload_dir, exist_ok=True) os.makedirs(upload_dir, exist_ok=True)
@ -64,7 +66,8 @@ def upload_intervention(intervention_id):
@login_required @login_required
def upload_equipment(equipment_id): def upload_equipment(equipment_id):
"""Upload un document pour un équipement.""" """Upload un document pour un équipement."""
from ..core.models.equipment import EquipmentDocument from ..core.models.equipment import Equipment, EquipmentDocument
Equipment.query.get_or_404(equipment_id)
if 'file' not in request.files: if 'file' not in request.files:
flash('Aucun fichier sélectionné.', 'error') flash('Aucun fichier sélectionné.', 'error')
@ -78,8 +81,7 @@ def upload_equipment(equipment_id):
if file and allowed_file(file.filename): if file and allowed_file(file.filename):
filename = secure_filename(file.filename) filename = secure_filename(file.filename)
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S') unique_filename = f"{uuid4().hex}_{filename}"
unique_filename = f"{timestamp}_{filename}"
upload_dir = os.path.join(current_app.config.get('UPLOAD_FOLDER', 'uploads'), 'equipments') upload_dir = os.path.join(current_app.config.get('UPLOAD_FOLDER', 'uploads'), 'equipments')
os.makedirs(upload_dir, exist_ok=True) os.makedirs(upload_dir, exist_ok=True)

View file

@ -470,6 +470,15 @@ def change_status(id):
old_status = intervention.status old_status = intervention.status
new_status = request.form.get('status') new_status = request.form.get('status')
comment = request.form.get('comment', '') comment = request.form.get('comment', '')
if new_status == 'en_cours':
from ..core.services.planning_service import PlanningService
reason = PlanningService.maintenance_unavailability(
datetime.now(timezone.utc).date(), user_id=current_user.id
)
if reason:
flash(f'Impossible de démarrer une maintenance aujourdhui : {reason}.', 'danger')
return redirect(url_for('interventions.detail', id=intervention.id))
if new_status != old_status: if new_status != old_status:
intervention.status = new_status intervention.status = new_status
@ -484,7 +493,9 @@ def change_status(id):
db.session.add(status_change) db.session.add(status_change)
if new_status == 'terminee': if new_status == 'terminee':
intervention.completed_date = datetime.now(timezone.utc).date() now = datetime.now(timezone.utc)
intervention.completed_date = now.date()
intervention.completed_at = now
db.session.commit() db.session.commit()
flash('Statut modifié.', 'success') flash('Statut modifié.', 'success')
@ -572,4 +583,3 @@ def postpone(id):
flash(f"Intervention reportée au {new_date.strftime('%d/%m/%Y')}.", 'success') flash(f"Intervention reportée au {new_date.strftime('%d/%m/%Y')}.", 'success')
return redirect(url_for('interventions.detail', id=id)) return redirect(url_for('interventions.detail', id=id))

View file

@ -29,11 +29,13 @@ def planning():
@planning_bp.route('/<int:id>/delete', methods=['POST']) @planning_bp.route('/<int:id>/delete', methods=['POST'])
@login_required @login_required
def delete(id): def delete(id):
"""Supprimer définitivement une intervention.""" """Archiver une intervention sans détruire son historique."""
intervention = Intervention.query.get_or_404(id) intervention = Intervention.query.get_or_404(id)
db.session.delete(intervention) intervention.is_deleted = True
intervention.deleted_at = datetime.utcnow()
intervention.deleted_by_id = current_user.id
db.session.commit() db.session.commit()
flash('Intervention supprimée définitivement.', 'success') flash('Intervention placée dans la corbeille. Son historique est conservé.', 'success')
return redirect(url_for('interventions.index')) return redirect(url_for('interventions.index'))
@ -51,4 +53,4 @@ def restore(id):
intervention.deleted_by_id = None intervention.deleted_by_id = None
db.session.commit() db.session.commit()
flash('Intervention restaurée.', 'success') flash('Intervention restaurée.', 'success')
return redirect(url_for('interventions.detail', id=intervention.id)) return redirect(url_for('interventions.detail', id=intervention.id))

View file

@ -250,6 +250,15 @@ def scheduled():
return render_template('planning/scheduled.html', tasks=tasks, status=status) return render_template('planning/scheduled.html', tasks=tasks, status=status)
@planning_bp.route('/scheduled/reconcile', methods=['POST'])
@login_required
def reconcile_scheduled():
from app_new.core.services.maintenance_engine import reconcile_planned_tasks
changed = reconcile_planned_tasks()
flash(f'{len(changed)} échéance(s) déplacée(s) vers un jour travaillé.', 'success' if changed else 'info')
return redirect(url_for('planning.scheduled'))
@planning_bp.route('/scheduled/<int:id>') @planning_bp.route('/scheduled/<int:id>')
@login_required @login_required
def scheduled_detail(id): def scheduled_detail(id):

View file

@ -2,6 +2,7 @@
{% block title %}Tâches Planifiées — GMAO Collège{% endblock %} {% block title %}Tâches Planifiées — GMAO Collège{% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-end mb-3"><form method="post" action="{{ url_for('planning.reconcile_scheduled') }}"><button class="btn btn-outline-primary" type="submit"><i class="bi bi-calendar-check"></i> Réconcilier avec les disponibilités</button></form></div>
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-calendar-week"></i> Tâches Planifiées</h1> <h1><i class="bi bi-calendar-week"></i> Tâches Planifiées</h1>
<div> <div>
@ -67,4 +68,4 @@
<i class="bi bi-info-circle"></i> Aucune tâche planifiée pour le moment. <i class="bi bi-info-circle"></i> Aucune tâche planifiée pour le moment.
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -25,62 +25,15 @@ def get_working_hours(check_date, user_id=1):
Prend en compte les vacances (CollegeClosure) et les horaires normaux (WorkSchedule). Prend en compte les vacances (CollegeClosure) et les horaires normaux (WorkSchedule).
Retourne (start_time, end_time, lunch_start, lunch_end) ou None si non travaille. Retourne (start_time, end_time, lunch_start, lunch_end) ou None si non travaille.
""" """
# Verifier si c'est un jour de vacances from ..core.services.planning_service import PlanningService
closure = CollegeClosure.query.filter( return PlanningService.get_working_hours(check_date, user_id=user_id)
CollegeClosure.start_date <= check_date,
CollegeClosure.end_date >= check_date
).first()
if closure:
exceptional_day = ClosureWorkDay.query.filter_by(
closure_id=closure.id, work_date=check_date
).first()
if exceptional_day:
return (exceptional_day.start_time, exceptional_day.end_time,
exceptional_day.lunch_start, exceptional_day.lunch_end)
if closure.work_hours_type == 'none':
return None # Ferme
# Horaires reduits pendant les vacances
from ..core.models.planning import ClosureSchedule
cs = ClosureSchedule.query.filter_by(
closure_id=closure.id,
day_of_week=check_date.weekday(),
is_active=True
).first()
if cs:
return (cs.start_time, cs.end_time, cs.lunch_start, cs.lunch_end)
return None
# Horaires normaux
ws = WorkSchedule.query.filter_by(
day_of_week=check_date.weekday(),
is_active=True
).first()
if ws:
return (ws.start_time, ws.end_time, ws.lunch_start, ws.lunch_end)
return None
def is_user_available(check_date, user_id=1): def is_user_available(check_date, user_id=1):
"""Verifie si l'utilisateur est disponible (pas en conge, pas en formation).""" """Verifie si l'utilisateur est disponible (pas en conge, pas en formation)."""
# Conge personnel from ..core.services.planning_service import PlanningService
leave = PersonalLeave.query.filter( reason = PlanningService.maintenance_unavailability(check_date, user_id=user_id)
PersonalLeave.user_id == user_id, return (reason is None, reason)
PersonalLeave.start_date <= check_date,
PersonalLeave.end_date >= check_date
).first()
if leave:
return False, f"Conge: {leave.leave_type or 'N/A'}"
# Formation
training = Training.query.filter(
Training.start_date <= check_date,
Training.end_date >= check_date
).first()
if training:
return False, f"Formation: {training.name}"
return True, None
def get_due_tasks(weeks_ahead=4): def get_due_tasks(weeks_ahead=4):

View file

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}Nouvelle clé API — GMAO{% endblock %}
{% block content %}
<div class="container" style="max-width:760px;"><div class="card border-warning"><div class="card-header bg-warning"><strong>Copiez cette clé maintenant</strong></div><div class="card-body">
<p>Elle ne sera plus affichée après avoir quitté cette page.</p>
<div class="input-group mb-3"><input id="api-key" class="form-control font-monospace" value="{{ api_key }}" readonly><button class="btn btn-outline-secondary" type="button" onclick="navigator.clipboard.writeText(document.getElementById('api-key').value)"><i class="bi bi-clipboard"></i> Copier</button></div>
<p class="small text-muted">Envoyez-la dans len-tête HTTP <code>X-API-Key</code>. Ne la placez jamais dans Git.</p>
<a href="{{ url_for('admin.settings') }}" class="btn btn-primary">Jai sauvegardé la clé</a>
</div></div></div>
{% endblock %}

View file

@ -14,6 +14,13 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-lg-6 mb-4">
<div class="card h-100"><div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-lock"></i> API REST GMAO</h5></div><div class="card-body">
<p class="text-muted small">La clé donne un accès en lecture à lAPI. Elle nest jamais conservée en clair et ne sera affichée quune fois.</p>
<form method="post" action="{{ url_for('admin.generate_api_key') }}" class="d-inline"><button class="btn btn-primary" type="submit"><i class="bi bi-arrow-repeat"></i> Générer ou renouveler</button></form>
<form method="post" action="{{ url_for('admin.revoke_api_key') }}" class="d-inline"><button class="btn btn-outline-danger" type="submit"><i class="bi bi-x-circle"></i> Révoquer</button></form>
</div></div>
</div>
<!-- Section OpenRouter --> <!-- Section OpenRouter -->
<div class="col-lg-6 mb-4"> <div class="col-lg-6 mb-4">
<div class="card"> <div class="card">
@ -190,4 +197,4 @@ function testApiKey() {
}); });
} }
</script> </script>
{% endblock %} {% endblock %}

View file

@ -36,6 +36,13 @@ def create():
location=request.form.get('location'), location=request.form.get('location'),
) )
db.session.add(training) db.session.add(training)
db.session.flush()
db.session.add(TrainingParticipant(
training_id=training.id,
user_id=current_user.id,
is_confirmed=True,
notes='Créateur de la formation',
))
db.session.commit() db.session.commit()
flash('Formation créée avec succès.', 'success') flash('Formation créée avec succès.', 'success')
return redirect(url_for('trainings.index')) return redirect(url_for('trainings.index'))

View file

@ -7,6 +7,7 @@ import os
import sys import sys
import subprocess import subprocess
import gzip import gzip
import tarfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -15,6 +16,8 @@ DUMP_DIR.mkdir(parents=True, exist_ok=True)
TIMESTAMP = datetime.now().strftime("%Y%m%d_%H%M%S") TIMESTAMP = datetime.now().strftime("%Y%m%d_%H%M%S")
DUMP_FILE = DUMP_DIR / f"gmao_db_{TIMESTAMP}.sql.gz" DUMP_FILE = DUMP_DIR / f"gmao_db_{TIMESTAMP}.sql.gz"
BUNDLE_FILE = DUMP_DIR / f"gmao_backup_{TIMESTAMP}.tar.gz"
UPLOAD_DIR = Path(os.environ.get("UPLOAD_FOLDER", "/app/app_new/uploads"))
DB_HOST = os.environ.get("DB_HOST", "mariadb") DB_HOST = os.environ.get("DB_HOST", "mariadb")
DB_PORT = os.environ.get("DB_PORT", "3306") DB_PORT = os.environ.get("DB_PORT", "3306")
@ -34,6 +37,8 @@ try:
database=DB_NAME, charset="utf8mb4" database=DB_NAME, charset="utf8mb4"
) )
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ")
cursor.execute("START TRANSACTION WITH CONSISTENT SNAPSHOT")
# Obtenir la liste des tables # Obtenir la liste des tables
cursor.execute("SHOW TABLES") cursor.execute("SHOW TABLES")
@ -71,16 +76,38 @@ try:
f.write("SET FOREIGN_KEY_CHECKS=1;\n") f.write("SET FOREIGN_KEY_CHECKS=1;\n")
conn.rollback()
conn.close() conn.close()
# Une archive de reprise contient la base cohérente et les documents.
with tarfile.open(BUNDLE_FILE, "w:gz") as archive:
archive.add(DUMP_FILE, arcname=f"database/{DUMP_FILE.name}")
if UPLOAD_DIR.exists():
archive.add(UPLOAD_DIR, arcname="uploads", recursive=True)
version_file = Path("/app/VERSION")
if version_file.exists():
archive.add(version_file, arcname="VERSION")
# Validation minimale immédiate : l'archive doit être lisible et contenir
# un dump SQL non vide. Une restauration complète reste à tester hors ligne.
with tarfile.open(BUNDLE_FILE, "r:gz") as archive:
sql_members = [m for m in archive.getmembers() if m.name.startswith("database/")]
if not sql_members or sql_members[0].size <= 0:
raise RuntimeError("Archive de sauvegarde invalide : dump SQL absent")
# Rotation : garder les 7 derniers # Rotation : garder les 7 derniers
dumps = sorted(DUMP_DIR.glob("gmao_db_*.sql.gz"), key=lambda p: p.stat().st_mtime, reverse=True) dumps = sorted(DUMP_DIR.glob("gmao_db_*.sql.gz"), key=lambda p: p.stat().st_mtime, reverse=True)
for old in dumps[7:]: for old in dumps[7:]:
old.unlink() old.unlink()
bundles = sorted(DUMP_DIR.glob("gmao_backup_*.tar.gz"), key=lambda p: p.stat().st_mtime, reverse=True)
for old in bundles[7:]:
old.unlink()
size = DUMP_FILE.stat().st_size / 1024 size = DUMP_FILE.stat().st_size / 1024
print(f"{datetime.now()}: Dump cree: {DUMP_FILE} ({size:.0f} KB, {len(tables)} tables)") bundle_size = BUNDLE_FILE.stat().st_size / 1024
print(f"{datetime.now()}: Sauvegarde validee: {BUNDLE_FILE} ({bundle_size:.0f} KB, {len(tables)} tables)")
except Exception as e: except Exception as e:
print(f"{datetime.now()}: ERREUR dump: {e}", file=sys.stderr) print(f"{datetime.now()}: ERREUR dump: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)

View file

@ -8,7 +8,8 @@ export PYTHONPATH=/home/gmao/.local/lib/python3.13/site-packages:$PYTHONPATH
# Si on est root, corriger les permissions des volumes montes # Si on est root, corriger les permissions des volumes montes
if [ "$(id -u)" = "0" ]; then if [ "$(id -u)" = "0" ]; then
echo "Mise a jour des permissions des volumes..." echo "Mise a jour des permissions des volumes..."
chown -R 1000:1000 /app/app_new /app/data /app/migrations /app/gmao_watchdog.py /app/ent_watchdog.py /app/pronote_watchdog.py /app/run_app_new.py /app/watchdog_dnd.py /var/log/supervisor /run/supervisor 2>/dev/null || true mkdir -p /backups
chown -R 1000:1000 /app/app_new /app/data /app/migrations /app/gmao_watchdog.py /app/ent_watchdog.py /app/pronote_watchdog.py /app/run_app_new.py /app/watchdog_dnd.py /backups /var/log/supervisor /run/supervisor 2>/dev/null || true
chmod -R u+rX /app 2>/dev/null || true chmod -R u+rX /app 2>/dev/null || true
fi fi

View file

@ -0,0 +1,37 @@
import re
from app_new.extensions import db
def test_api_key_is_hashed_and_authenticates(authenticated_client, app):
response = authenticated_client.post('/admin/settings/api-key')
assert response.status_code == 200
match = re.search(rb'value="(gmao_[^"]+)"', response.data)
assert match
raw_key = match.group(1).decode()
from app_new.core.models.settings import AppSettings
with app.app_context():
stored = AppSettings.get('api_key_hash')
assert stored
assert raw_key not in stored
anonymous = app.test_client()
assert anonymous.get('/api/v1/status', headers={'X-API-Key': raw_key}).status_code == 200
assert anonymous.get('/api/v1/status', headers={'X-API-Key': 'incorrecte'}).status_code == 401
def test_api_pagination_is_bounded(authenticated_client):
response = authenticated_client.get('/api/v1/equipments?page=-4&per_page=10000')
assert response.status_code == 200
assert response.get_json()['page'] == 1
assert response.get_json()['per_page'] == 100
def test_document_permissions_follow_parent_domain(app):
from app_new.core.authorization import required_permission
with app.test_request_context('/'):
assert required_permission('documents.download_intervention_document', 'GET') == 'intervention.view'
assert required_permission('documents.delete_intervention_document', 'POST') == 'intervention.manage'
assert required_permission('documents.download_equipment_document', 'GET') == 'patrimoine.view'
assert required_permission('documents.upload_equipment', 'POST') == 'patrimoine.manage'

View file

@ -5,9 +5,12 @@ from app_new.extensions import db
from app_new.core.models.college import Building, Room from app_new.core.models.college import Building, Room
from app_new.core.models.equipment import Equipment, EquipmentCategory from app_new.core.models.equipment import Equipment, EquipmentCategory
from app_new.core.models.maintenance import Lot, LotTask from app_new.core.models.maintenance import Lot, LotTask
from app_new.core.models.planning import CollegeClosure, ClosureWorkDay, ScheduledTask from app_new.core.models.planning import (
CollegeClosure, ClosureWorkDay, Meter, ScheduledTask, Training, TrainingParticipant,
)
from app_new.core.services.maintenance_engine import generate_due_tasks from app_new.core.services.maintenance_engine import generate_due_tasks
from app_new.scheduler.engine import get_working_hours from app_new.scheduler.engine import get_working_hours
from app_new.core.models.user import User
def test_exceptional_vacation_day_has_its_specific_hours(app): def test_exceptional_vacation_day_has_its_specific_hours(app):
@ -36,3 +39,52 @@ def test_lot_task_engine_uses_individual_units_and_prevents_duplicates(app):
assert generate_due_tasks(task, event="gel") == [] assert generate_due_tasks(task, event="gel") == []
scheduled = ScheduledTask.query.filter_by(lot_task_id=task.id).one() scheduled = ScheduledTask.query.filter_by(lot_task_id=task.id).one()
assert scheduled.equipment_id == unit.id assert scheduled.equipment_id == unit.id
def test_calendar_task_waits_for_interval_after_completion(app):
with app.app_context():
suffix = uuid4().hex[:7]
building = Building(name=f"B cal {suffix}"); db.session.add(building); db.session.flush()
room = Room(name="Local cal", building_id=building.id)
category = EquipmentCategory(name=f"Cat cal {suffix}")
lot = Lot(name=f"Lot cal {suffix}", category=category)
equipment = Equipment(name=f"Eq cal {suffix}", lot=lot, category=category, room=room)
task = LotTask(lot=lot, tache="Contrôle périodique", trigger_type="calendar",
jours_entre_interventions=30, advance_days=7, is_active=True)
db.session.add_all([room, category, lot, equipment, task]); db.session.flush()
completed = ScheduledTask(lot_task_id=task.id, equipment_id=equipment.id,
scheduled_date=date.today(), status='completed')
db.session.add(completed); db.session.commit()
assert generate_due_tasks(task, today=date.today()) == []
created = generate_due_tasks(task, today=date.today() + timedelta(days=23))
assert len(created) == 1
def test_meter_trigger_uses_delta_since_last_maintenance(app):
with app.app_context():
suffix = uuid4().hex[:7]
building = Building(name=f"B meter {suffix}"); db.session.add(building); db.session.flush()
room = Room(name="Local meter", building_id=building.id)
category = EquipmentCategory(name=f"Cat meter {suffix}")
lot = Lot(name=f"Lot meter {suffix}", category=category)
equipment = Equipment(name=f"Eq meter {suffix}", lot=lot, category=category, room=room)
task = LotTask(lot=lot, tache="Compteur", trigger_type="meter", meter_threshold=100, is_active=True)
db.session.add_all([room, category, lot, equipment, task]); db.session.flush()
meter = Meter(equipment_id=equipment.id, name='Copies', current_value=1050,
initial_value=0, last_maintenance_value=1000, is_active=True)
db.session.add(meter); db.session.commit()
assert generate_due_tasks(task) == []
meter.current_value = 1100; db.session.commit()
assert len(generate_due_tasks(task)) == 1
def test_confirmed_training_blocks_maintenance_day(app):
with app.app_context():
suffix = uuid4().hex[:7]
user = User(username=f'tech_{suffix}', email=f'{suffix}@test.local', full_name='Tech', role='technicien')
user.set_password('test-password-strong')
training = Training(name='Formation bloquante', start_date=date.today(), end_date=date.today())
db.session.add_all([user, training]); db.session.flush()
db.session.add(TrainingParticipant(training_id=training.id, user_id=user.id, is_confirmed=True))
db.session.commit()
assert get_working_hours(date.today(), user_id=user.id) is None