gmao/app_new/planning/schedules.py
root e006f4f496
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
fix(planning): validate room schedule provenance scope
2026-08-22 22:59:51 +00:00

545 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Planning Schedules Routes - GMAO Collège"""
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from ..extensions import db
from ..core.models.planning import (
PreventiveTask, PreventiveTaskConsumable, ScheduledTask,
Meter, MeterReading, Consumable, ConsumableUsage,
TechnicianAvailability, AdminTask, ZoneAccessRule,
WorkSchedule, CollegeClosure, PlanningDay, PlanningItem
)
from ..core.models.maintenance import Lot, LotTask, Intervention
from ..core.models.equipment import EquipmentCategory
from ..core.models.company import Company
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)
or day_of_week < 0 or day_of_week > 6):
flash('Vérifiez la salle et lordre des horaires.', 'danger')
return render_template('planning/room_schedule_form.html', rooms=rooms, schedule=None), 400
schedule = RoomSchedule(
room_id=room_id, week_start=week_start, day_of_week=day_of_week,
start_time=start_time, end_time=end_time,
subject=(request.form.get('subject') or '').strip() or None,
teacher=(request.form.get('teacher') or '').strip() or None,
class_name=(request.form.get('class_name') or '').strip() or None,
course_name=(request.form.get('course_name') or '').strip() or None,
event_type=request.form.get('event_type') or 'cours',
source='manual',
protected_from_sync=request.form.get('protected_from_sync') == 'on',
)
db.session.add(schedule)
db.session.commit()
flash('Créneau de salle enregistré.', 'success')
return redirect(url_for('planning.room_schedules', room_id=room_id))
return render_template('planning/room_schedule_form.html', rooms=rooms, schedule=None)
@planning_bp.route('/rooms/<int:id>/protect', methods=['POST'])
@login_required
def room_schedule_protect(id):
schedule = RoomSchedule.query.get_or_404(id)
schedule.protected_from_sync = request.form.get('protected_from_sync') == 'on'
db.session.commit()
flash('Protection du créneau mise à jour.', 'success')
return redirect(url_for('planning.room_schedules', room_id=schedule.room_id))
@planning_bp.route('/')
@login_required
def index():
"""Page principale de planification - calendrier sur 1 an."""
from datetime import date, timedelta
from calendar import monthrange
from ..core.services.planning_service import PlanningService
from ..core.models.planning import PlanningDay, PlanningItem, ClosureWorkDay, WorkSchedule, TimeEntry, PersonalLeave
from ..core.models.settings import AppSettings
from ..core.services.planning_service import get_french_public_holidays
# Récupérer l'année demandée ou année courante
mode = request.args.get('mode')
saved_mode = AppSettings.get(f'planning_calendar_mode_{current_user.id}', 'calendar')
if mode not in ('calendar', 'school'):
mode = saved_mode if saved_mode in ('calendar', 'school') else 'calendar'
else:
AppSettings.set(f'planning_calendar_mode_{current_user.id}', mode)
year = request.args.get('year', date.today().year, type=int)
if mode == 'school' and 'year' not in request.args:
year = date.today().year if date.today().month >= 9 else date.today().year - 1
display_start = date(year, 9, 1) if mode == 'school' else date(year, 1, 1)
display_end = date(year + 1, 8, 31) if mode == 'school' else date(year, 12, 31)
# Préparer les données du calendrier. Le calendrier principal est la
# source unique : il rassemble les interventions et les échéances
# préventives qui n'ont pas encore été converties en intervention.
months_data = []
months_names = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
# Récupérer les vacances
closures = CollegeClosure.query.filter(
CollegeClosure.start_date <= display_end,
CollegeClosure.end_date >= display_start
).all()
permanence_dates = {item.work_date for item in ClosureWorkDay.query.filter(ClosureWorkDay.work_date >= display_start, ClosureWorkDay.work_date <= display_end).all()}
permanence_map = {item.work_date: item for item in ClosureWorkDay.query.filter(ClosureWorkDay.work_date >= display_start, ClosureWorkDay.work_date <= display_end).all()}
assignments = {item.day_of_week: item for item in WorkSchedule.query.filter_by(user_id=current_user.id, is_active=True).all()}
time_entries = {item.work_date: item for item in TimeEntry.query.filter_by(user_id=current_user.id).filter(TimeEntry.work_date >= display_start, TimeEntry.work_date <= display_end).all()}
personal_leaves = PersonalLeave.query.filter(PersonalLeave.user_id == current_user.id, PersonalLeave.end_date >= display_start, PersonalLeave.start_date <= display_end).all()
leave_dates = {leave.start_date + timedelta(days=offset) for leave in personal_leaves for offset in range((leave.end_date - leave.start_date).days + 1)}
holiday_dates = {item[0] for item in get_french_public_holidays(year)}
if mode == 'school':
holiday_dates |= {item[0] for item in get_french_public_holidays(year + 1)}
# Fonction pour vérifier si une date est en vacances
def is_vacation(d):
for c in closures:
if c.start_date <= d <= c.end_date:
return c.name
return None
# Récupérer les congés personnels
leaves = TechnicianAvailability.query.filter(
TechnicianAvailability.date >= display_start,
TechnicianAvailability.date <= display_end
).all()
leave_dates = {l.date: l for l in leaves}
year_start = display_start
year_end = display_end
terminal_intervention_statuses = ('terminee', 'cloturee', 'annulee', 'ignoree')
interventions = Intervention.query.filter(
Intervention.scheduled_date >= year_start,
Intervention.scheduled_date <= year_end,
Intervention.is_deleted.is_(False),
~Intervention.status.in_(terminal_intervention_statuses),
).order_by(Intervention.scheduled_date, Intervention.scheduled_start).all()
scheduled_tasks = ScheduledTask.query.filter(
ScheduledTask.scheduled_date >= year_start,
ScheduledTask.scheduled_date <= year_end,
ScheduledTask.status.in_(('planned', 'in_progress', 'suspended', 'postponed')),
# Une échéance convertie est déjà représentée par son intervention.
ScheduledTask.intervention_id.is_(None),
).order_by(ScheduledTask.scheduled_date, ScheduledTask.scheduled_start).all()
events_by_date = {}
for intervention in interventions:
events_by_date.setdefault(intervention.scheduled_date, []).append({
'title': intervention.title or 'Intervention',
'type': intervention.type or 'curatif',
'status': intervention.status,
'start_time': intervention.scheduled_start,
'end_time': intervention.scheduled_end,
'duration': intervention.estimated_duration,
'room_name': intervention.room.name if intervention.room else None,
'url': url_for('interventions.detail', id=intervention.id),
'source': 'Intervention',
'color': 'danger' if intervention.type == 'curatif' else 'primary',
})
for task in scheduled_tasks:
title = 'Tâche préventive'
if task.lot_task and task.lot_task.tache:
title = task.lot_task.tache
elif task.preventive_task and task.preventive_task.name:
title = task.preventive_task.name
elif task.equipment:
title = f'Intervention sur {task.equipment.name}'
room = task.room or (task.equipment.effective_room if task.equipment else None)
events_by_date.setdefault(task.scheduled_date, []).append({
'title': title,
'type': 'preventive',
'status': task.status,
'start_time': task.scheduled_start,
'end_time': task.scheduled_end,
'duration': task.estimated_duration,
'room_name': room.name if room else None,
'url': url_for('planning.scheduled_detail', id=task.id),
'source': 'Échéance préventive',
'color': 'info',
})
month_order = list(range(9, 13)) + list(range(1, 9)) if mode == 'school' else list(range(1, 13))
for month in month_order:
actual_year = year if mode != 'school' or month >= 9 else year + 1
# Nombre de jours dans le mois
days_in_month = monthrange(actual_year, month)[1]
# Jour de la semaine du premier jour (0=lundi, 6=dimanche)
first_weekday = date(actual_year, month, 1).weekday()
weeks = []
month_planned_minutes = 0
month_overtime_minutes = 0
month_delay_minutes = 0
week = [None] * first_weekday # Cases vides avant le 1er jour
for day in range(1, days_in_month + 1):
d = date(actual_year, month, day)
vacation = is_vacation(d)
leave = d in leave_dates
weekday = d.weekday()
planned_minutes = 0
permanence = permanence_map.get(d)
if permanence:
planned_minutes = max((permanence.end_time.hour * 60 + permanence.end_time.minute) - (permanence.start_time.hour * 60 + permanence.start_time.minute), 0)
if permanence.lunch_start and permanence.lunch_end:
planned_minutes -= (permanence.lunch_end.hour * 60 + permanence.lunch_end.minute) - (permanence.lunch_start.hour * 60 + permanence.lunch_start.minute)
elif not holiday_dates.__contains__(d) and not leave and not vacation and weekday < 5:
assignment = assignments.get(weekday)
if assignment:
source = assignment.template if assignment.template and assignment.template.is_active else assignment
planned_minutes = max((source.end_time.hour * 60 + source.end_time.minute) - (source.start_time.hour * 60 + source.start_time.minute), 0)
if source.lunch_start and source.lunch_end:
planned_minutes -= (source.lunch_end.hour * 60 + source.lunch_end.minute) - (source.lunch_start.hour * 60 + source.lunch_start.minute)
entry = time_entries.get(d)
actual_minutes = entry.actual_minutes if entry else 0
month_planned_minutes += planned_minutes
if entry and entry.entry_type == 'supplementaire':
month_overtime_minutes += actual_minutes or 0
elif entry and entry.entry_type == 'retard':
month_delay_minutes += actual_minutes or 0
fmt = lambda minutes: f"{minutes // 60}h{minutes % 60:02d}"
deviation_hours = None
deviation_class = None
if entry and entry.entry_type == 'retard' and actual_minutes:
deviation_hours, deviation_class = f"-{fmt(actual_minutes)}", 'text-danger'
elif entry and entry.entry_type == 'supplementaire' and actual_minutes:
deviation_hours, deviation_class = f"+{fmt(actual_minutes)}", 'text-success'
week.append({
'day': day,
'date': d,
'is_weekend': weekday >= 5,
'is_vacation': vacation is not None,
'vacation_name': vacation,
'is_holiday': d in holiday_dates,
'is_permanence': d in permanence_dates,
'is_leave': leave,
'leave_type': 'congé' if leave else None,
'worked_hours': fmt(actual_minutes),
'planned_hours': fmt(planned_minutes),
'deviation_hours': deviation_hours,
'deviation_class': deviation_class,
'events': events_by_date.get(d, []),
})
# Si on atteint dimanche (fin de semaine), nouvelle semaine
if weekday == 6 or day == days_in_month:
# Compléter la semaine si nécessaire
while len(week) < 7:
week.append(None)
weeks.append(week)
week = []
months_data.append({
'name': months_names[month - 1],
'year': actual_year,
'weeks': weeks,
'planned_hours': fmt(month_planned_minutes),
'overtime_hours': fmt(month_overtime_minutes),
'delay_hours': fmt(month_delay_minutes),
})
# Statistiques
stats = {
'preventive_tasks': len(scheduled_tasks),
'curative_interventions': sum(1 for i in interventions if i.type == 'curatif'),
'admin_tasks': AdminTask.query.filter_by(is_active=True).count(),
'pending_items': PlanningItem.query.filter_by(status='planned').count(),
}
return render_template('planning/calendar.html',
year=year,
mode=mode,
months=months_data,
stats=stats
)
@planning_bp.route('/day/<date_str>')
@login_required
def day(date_str):
"""Afficher le détail d'une journée."""
from datetime import datetime
from ..core.services.planning_service import PlanningService
try:
d = datetime.strptime(date_str, '%Y-%m-%d').date()
except ValueError:
flash('Date invalide', 'error')
return redirect(url_for('planning.index'))
# Récupérer le planning du jour
schedule = PlanningService.get_day_schedule(d)
# Récupérer les tâches planifiées pour ce jour
scheduled_tasks = ScheduledTask.query.filter(
ScheduledTask.scheduled_date == d,
ScheduledTask.status.in_(('planned', 'in_progress', 'suspended', 'postponed')),
ScheduledTask.intervention_id.is_(None),
).all()
return render_template('planning/day.html',
date=d,
schedule=schedule,
tasks=scheduled_tasks
)
@planning_bp.route('/tasks')
@login_required
def tasks():
"""Liste des tâches préventives."""
from app_new.core.models.maintenance import LotTask
tasks = LotTask.query.join(Lot).order_by(Lot.name, LotTask.num_tache).all()
lots = Lot.query.order_by(Lot.name).all()
categories = EquipmentCategory.query.order_by(EquipmentCategory.name).all()
return render_template('planning/tasks.html',
tasks=tasks,
lots=lots,
categories=categories)
@planning_bp.route('/tasks/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit_task(id):
"""Modifier une tâche préventive."""
from app_new.core.models.maintenance import LotTask
task = LotTask.query.get_or_404(id)
if request.method == 'POST':
duree = request.form.get('duree_minutes')
task.duree_minutes = int(duree) if duree and duree.isdigit() else None
task.gestionnaire = request.form.get('gestionnaire') or None
task.periodicite = request.form.get('periodicite') or None
task.jours_entre_interventions = int(request.form.get('jours_entre_interventions')) if request.form.get('jours_entre_interventions') and request.form.get('jours_entre_interventions').isdigit() else None
task.type_maintenance = request.form.get('type_maintenance') or None
task.trigger_type = request.form.get('trigger_type', 'calendar')
task.trigger_event = request.form.get('trigger_event') or None
task.meter_threshold = request.form.get('meter_threshold', type=float)
task.season_start_month = request.form.get('season_start_month', type=int)
task.season_end_month = request.form.get('season_end_month', type=int)
task.advance_days = request.form.get('advance_days', type=int, default=0)
task.is_active = request.form.get('is_active') == 'on'
task.contrat = request.form.get('contrat') == 'on'
db.session.commit()
flash('Tâche mise à jour', 'success')
return redirect(url_for('planning.tasks'))
return render_template('planning/edit_task.html', task=task)
@planning_bp.route('/tasks/<int:id>/generate', methods=['POST'])
@login_required
def generate_task(id):
from app_new.core.models.maintenance import LotTask
from app_new.core.services.maintenance_engine import generate_due_tasks
task = LotTask.query.get_or_404(id)
created = generate_due_tasks(task, event=(request.form.get('event') or '').strip() or None)
flash(f'{len(created)} échéance(s) créée(s), sans doublon.', 'success' if created else 'info')
return redirect(url_for('planning.tasks'))
@planning_bp.route('/tasks/new', methods=['GET', 'POST'])
@login_required
def new_task():
"""Créer une nouvelle tâche préventive."""
if request.method == 'POST':
task = LotTask(
lot_id=request.form.get('lot_id', type=int),
num_tache=request.form.get('task_number'),
tache=request.form.get('name'),
periodicite=request.form.get('periodicite'),
jours_entre_interventions=request.form.get('period_days', type=int),
duree_minutes=request.form.get('duration_minutes', type=int, default=30),
contrat=request.form.get('has_contract') == 'on',
gestionnaire=request.form.get('manager'),
trigger_type=request.form.get('trigger_type', 'calendar'),
trigger_event=request.form.get('trigger_event') or None,
meter_threshold=request.form.get('meter_threshold', type=float),
season_start_month=request.form.get('season_start_month', type=int),
season_end_month=request.form.get('season_end_month', type=int),
is_active=True
)
db.session.add(task)
db.session.commit()
flash('Tâche préventive créée', 'success')
return redirect(url_for('planning.tasks'))
lots = Lot.query.order_by(Lot.name).all()
categories = EquipmentCategory.query.order_by(EquipmentCategory.name).all()
companies = Company.query.order_by(Company.name).all()
return render_template('planning/task_form.html',
title='Nouvelle tâche préventive',
task=None,
lots=lots,
categories=categories,
companies=companies)
@planning_bp.route('/scheduled')
@login_required
def scheduled():
"""Liste des tâches planifiées."""
status = request.args.get('status', 'all')
date_filter = request.args.get('date')
query = ScheduledTask.query.filter(ScheduledTask.equipment_id.isnot(None))
if status != 'all':
query = query.filter_by(status=status)
if date_filter:
query = query.filter_by(scheduled_date=datetime.strptime(date_filter, '%Y-%m-%d').date())
tasks = query.order_by(ScheduledTask.scheduled_date, ScheduledTask.scheduled_start).all()
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>')
@login_required
def scheduled_detail(id):
"""Détail d'une tâche planifiée."""
task = ScheduledTask.query.get_or_404(id)
return render_template('planning/scheduled_detail.html', task=task)
@planning_bp.route('/meters')
@login_required
def meters():
"""Liste des compteurs."""
meters = Meter.query.order_by(Meter.equipment_id).all()
return render_template('planning/meters.html', meters=meters)
@planning_bp.route('/meters/new', methods=['GET', 'POST'])
@login_required
def new_meter():
"""Créer un nouveau compteur."""
if request.method == 'POST':
meter = Meter(
equipment_id=request.form.get('equipment_id', type=int),
name=request.form.get('name'),
meter_type=request.form.get('meter_type', 'hours'),
unit=request.form.get('unit', 'h'),
current_value=request.form.get('current_value', type=float, default=0),
initial_value=request.form.get('initial_value', type=float, default=0),
warning_threshold=request.form.get('warning_threshold', type=float),
critical_threshold=request.form.get('critical_threshold', type=float),
maintenance_interval=request.form.get('maintenance_interval', type=float),
is_active=True
)
db.session.add(meter)
db.session.commit()
flash('Compteur créé', 'success')
return redirect(url_for('planning.meters'))
from ..core.models.equipment import Equipment
equipments = Equipment.query.filter_by(is_group=False).order_by(Equipment.name).all()
return render_template('planning/meter_form.html',
title='Nouveau compteur',
meter=None,
equipments=equipments)
@planning_bp.route('/consumables')
@login_required
def consumables():
"""Liste des consommables."""
consumables = Consumable.query.order_by(Consumable.category, Consumable.name).all()
return render_template('planning/consumables.html', consumables=consumables)
@planning_bp.route('/consumables/new', methods=['GET', 'POST'])
@login_required
def new_consumable():
"""Créer un nouveau consommable."""
if request.method == 'POST':
consumable = Consumable(
name=request.form.get('name'),
reference=request.form.get('reference'),
category=request.form.get('category'),
quantity=request.form.get('quantity', type=int, default=0),
min_quantity=request.form.get('min_quantity', type=int, default=0),
unit=request.form.get('unit', 'pièce'),
unit_price=request.form.get('unit_price', type=float),
supplier_id=request.form.get('supplier_id', type=int) or None,
storage_location=request.form.get('storage_location'),
is_active=True
)
db.session.add(consumable)
db.session.commit()
flash('Consommable créé', 'success')
return redirect(url_for('planning.consumables'))
companies = Company.query.order_by(Company.name).all()
return render_template('planning/consumable_form.html',
title='Nouveau consommable',
consumable=None,
companies=companies)
@planning_bp.route('/consumables/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit_consumable(id):
consumable = Consumable.query.get_or_404(id)
if request.method == 'POST':
consumable.name = (request.form.get('name') or '').strip()
consumable.reference = request.form.get('reference') or None
consumable.category = request.form.get('category') or None
consumable.quantity = max(0, request.form.get('quantity', type=int, default=0))
consumable.min_quantity = max(0, request.form.get('min_quantity', type=int, default=0))
consumable.unit = request.form.get('unit') or 'pièce'
consumable.unit_price = request.form.get('unit_price', type=float)
consumable.supplier_id = request.form.get('supplier_id', type=int) or None
consumable.storage_location = request.form.get('storage_location') or request.form.get('location') or None
db.session.commit()
flash('Consommable mis à jour', 'success')
return redirect(url_for('planning.consumables'))
companies = Company.query.order_by(Company.name).all()
return render_template('planning/consumable_form.html', title='Modifier le consommable',
consumable=consumable, companies=companies)