Fiabilise la maintenance et les sauvegardes
This commit is contained in:
parent
1255066fa9
commit
3135f36a63
19 changed files with 338 additions and 122 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
if commit:
|
||||||
db.session.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
|
||||||
|
|
|
||||||
|
|
@ -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é.
|
||||||
|
|
@ -117,6 +143,9 @@ class PlanningService:
|
||||||
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(
|
||||||
CollegeClosure.start_date <= d,
|
CollegeClosure.start_date <= d,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -471,6 +471,15 @@ def change_status(id):
|
||||||
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 aujourd’hui : {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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
10
app_new/templates/admin/api_key_created.html
Normal file
10
app_new/templates/admin/api_key_created.html
Normal 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 l’en-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">J’ai sauvegardé la clé</a>
|
||||||
|
</div></div></div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -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 à l’API. Elle n’est jamais conservée en clair et ne sera affichée qu’une 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">
|
||||||
|
|
|
||||||
|
|
@ -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'))
|
||||||
|
|
|
||||||
|
|
@ -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,15 +76,37 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
37
tests/integration/test_lot2_reliability.py
Normal file
37
tests/integration/test_lot2_reliability.py
Normal 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'
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue