From 39a678235d7fa5501401d5dc6d7787a894937c8a Mon Sep 17 00:00:00 2001 From: root Date: Sun, 23 Aug 2026 11:41:31 +0000 Subject: [PATCH] fix(planning): isolate schedules and admin tasks by technician --- app_new/core/models/planning.py | 5 ++ app_new/core/services/day_planner.py | 20 +++-- app_new/core/services/planning_service.py | 21 ++++- app_new/planning/admin_rules.py | 7 +- .../templates/planning/admin_task_form.html | 10 ++- .../templates/planning/admin_tasks.html | 10 ++- .../templates/planning/time_tracking.html | 22 ++++- app_new/planning/time_tracking.py | 86 +++++++++++++------ app_new/templates/_navigation_phase16.html | 2 + app_new/templates/lots/index.html | 16 +++- app_new/templates/planning/my_day.html | 2 + ...g2b3c4d5e6f7_assign_admin_tasks_to_user.py | 35 ++++++++ .../test_phase21_requalification.py | 38 ++++++++ 13 files changed, 227 insertions(+), 47 deletions(-) create mode 100644 migrations/versions/g2b3c4d5e6f7_assign_admin_tasks_to_user.py create mode 100644 tests/integration/test_phase21_requalification.py diff --git a/app_new/core/models/planning.py b/app_new/core/models/planning.py index 2664551..c644a31 100644 --- a/app_new/core/models/planning.py +++ b/app_new/core/models/planning.py @@ -596,6 +596,9 @@ class AdminTask(db.Model): __tablename__ = "admin_tasks" id = db.Column(db.Integer, primary_key=True) + # Tâche personnelle facultative ; NULL conserve la compatibilité des + # anciennes tâches administratives globales. + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) name = db.Column(db.String(255), nullable=False) description = db.Column(db.Text) @@ -610,6 +613,8 @@ class AdminTask(db.Model): is_active = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + user = db.relationship("User", backref=db.backref("admin_tasks", lazy="dynamic")) def __repr__(self): return f"" diff --git a/app_new/core/services/day_planner.py b/app_new/core/services/day_planner.py index acf0ab3..eaed4ca 100644 --- a/app_new/core/services/day_planner.py +++ b/app_new/core/services/day_planner.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, field from datetime import date, datetime, time, timedelta from typing import Iterable, Optional +from ...extensions import db from .planning_service import PlanningService from .room_planning import room_is_available, room_occupancy @@ -206,7 +207,11 @@ class DayPlanner: )) previous = None for candidate in flexible: - duration = max(int(candidate.duration_minutes or 1), 1) + duration = int(candidate.duration_minutes or 0) + if duration <= 0: + candidate.status = "à replanifier" + candidate.explanation = "La durée de cette tâche n'est pas configurée." + continue chosen = None for window in windows: cursor = _minutes(window.start) @@ -240,7 +245,9 @@ class DayPlanner: def alternative_slots(cls, day: date, target: PlanningCandidate, candidates: Iterable[PlanningCandidate], user_id=None, limit=3): """Retourne quelques créneaux réellement validables pour une tâche.""" windows = cls.work_windows(day, user_id) - duration = max(int(target.duration_minutes or 1), 1) + duration = int(target.duration_minutes or 0) + if duration <= 0: + return [] occupied = [] room_occupied = _resolved_room_occupancy_batch(day, {item.room_id for item in candidates if item.room_id}) for item in candidates: @@ -312,7 +319,10 @@ class DayPlanner: assigned_to=intervention.technician_id or intervention.assigned_to_id, status=intervention.status, fixed_start=intervention.scheduled_start, fixed_end=intervention.scheduled_end, )) - for admin in AdminTask.query.filter_by(is_active=True).all(): + admin_query = AdminTask.query.filter_by(is_active=True) + if user_id is not None: + admin_query = admin_query.filter(db.or_(AdminTask.user_id.is_(None), AdminTask.user_id == user_id)) + for admin in admin_query.all(): if admin.frequency == "weekly" and admin.day_of_week != day.weekday(): continue if admin.frequency == "monthly" and admin.day_of_month != day.day: @@ -321,8 +331,8 @@ class DayPlanner: source_type="admin_task", source_id=admin.id, title=admin.name, description=admin.description or "", task_type="admin", priority=3, constraint="fixed" if admin.start_time else "flexible", target_date=day, - duration_minutes=int(admin.duration_minutes or 30), fixed_start=admin.start_time, - fixed_end=(datetime.combine(day, admin.start_time) + timedelta(minutes=int(admin.duration_minutes or 30))).time() if admin.start_time else None, + duration_minutes=int(admin.duration_minutes or 0), fixed_start=admin.start_time, + fixed_end=(datetime.combine(day, admin.start_time) + timedelta(minutes=int(admin.duration_minutes or 0))).time() if admin.start_time and admin.duration_minutes else None, status="à planifier", )) return cls.propose(day, candidates, user_id=user_id) diff --git a/app_new/core/services/planning_service.py b/app_new/core/services/planning_service.py index 19ec06e..246ad3d 100644 --- a/app_new/core/services/planning_service.py +++ b/app_new/core/services/planning_service.py @@ -186,12 +186,19 @@ class PlanningService: return None # Vérifier les congés personnels - leave = TechnicianAvailability.query.filter_by(date=d).first() + leave_query = TechnicianAvailability.query.filter_by(date=d) + if user_id is not None: + leave_query = leave_query.filter(TechnicianAvailability.user_id == user_id) + leave = leave_query.first() if leave and leave.availability_type in ('conge', 'maladie'): return None - # Horaires normaux - chercher le premier schedule avec des horaires valides - schedules = WorkSchedule.query.filter_by(day_of_week=day_of_week).all() + # Horaires normaux : lorsqu'un technicien est fourni, ne jamais + # mélanger ses horaires avec ceux d'un autre utilisateur. + schedule_query = WorkSchedule.query.filter_by(day_of_week=day_of_week) + if user_id is not None: + schedule_query = schedule_query.filter(WorkSchedule.user_id == user_id) + schedules = schedule_query.all() for schedule in schedules: source = schedule.template if getattr(schedule, 'template', None) and schedule.template.is_active else schedule if source.start_time and source.end_time: @@ -206,7 +213,13 @@ class PlanningService: if day_of_week >= 5: return None - # Horaires par défaut (8h-17h avec pause 12h-13h) + # Le moteur quotidien appelle cette méthode avec un utilisateur + # explicite et refuse alors toute journée implicite. Le fallback + # historique reste limité aux anciennes vues qui n'ont pas de cible. + if user_id is not None: + return None + + # Horaires historiques par défaut (uniquement sans technicien ciblé). return (time(8), time(17), time(12), time(13)) @staticmethod diff --git a/app_new/planning/admin_rules.py b/app_new/planning/admin_rules.py index 61cf946..36cb325 100644 --- a/app_new/planning/admin_rules.py +++ b/app_new/planning/admin_rules.py @@ -2,6 +2,7 @@ from flask import render_template, request, redirect, url_for, flash from flask_login import login_required, current_user from ..extensions import db +from ..core.models.user import User from ..core.models.planning import ( PreventiveTask, PreventiveTaskConsumable, ScheduledTask, Meter, MeterReading, Consumable, ConsumableUsage, @@ -36,6 +37,7 @@ def new_admin_task(): day_of_month=request.form.get('day_of_month', type=int), start_time=datetime.strptime(request.form.get('start_time'), '%H:%M').time() if request.form.get('start_time') else None, duration_minutes=request.form.get('duration_minutes', type=int, default=30), + user_id=request.form.get('user_id', type=int) or current_user.id, is_active=True ) db.session.add(task) @@ -45,7 +47,7 @@ def new_admin_task(): return render_template('planning/admin_task_form.html', title='Nouvelle tâche administrative', - task=None) + task=None, managed_users=(User.query.filter_by(is_active=True).order_by(User.full_name, User.username).all() if current_user.is_admin() else [current_user])) @planning_bp.route('/admin-tasks//edit', methods=['GET', 'POST']) @@ -62,6 +64,7 @@ def edit_admin_task(id): task.day_of_month = request.form.get('day_of_month', type=int) task.start_time = datetime.strptime(request.form.get('start_time'), '%H:%M').time() if request.form.get('start_time') else None task.duration_minutes = request.form.get('duration_minutes', type=int) + task.user_id = request.form.get('user_id', type=int) or current_user.id task.is_active = request.form.get('is_active') == 'on' db.session.commit() flash('Tâche administrative mise à jour', 'success') @@ -69,7 +72,7 @@ def edit_admin_task(id): return render_template('planning/admin_task_form.html', title='Modifier la tâche administrative', - task=task) + task=task, managed_users=(User.query.filter_by(is_active=True).order_by(User.full_name, User.username).all() if current_user.is_admin() else [current_user])) @planning_bp.route('/admin-tasks//delete', methods=['POST']) diff --git a/app_new/planning/templates/planning/admin_task_form.html b/app_new/planning/templates/planning/admin_task_form.html index 98729f4..a4ec75c 100644 --- a/app_new/planning/templates/planning/admin_task_form.html +++ b/app_new/planning/templates/planning/admin_task_form.html @@ -23,6 +23,14 @@ + +
+ + +
La tâche apparaîtra dans « Ma journée » de cette personne.
+
@@ -89,4 +97,4 @@ document.querySelector('select[name="frequency"]').addEventListener('change', fu document.getElementById('monthlyOptions').style.display = this.value === 'monthly' ? 'block' : 'none'; }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/planning/templates/planning/admin_tasks.html b/app_new/planning/templates/planning/admin_tasks.html index 08fecd6..f3bced8 100644 --- a/app_new/planning/templates/planning/admin_tasks.html +++ b/app_new/planning/templates/planning/admin_tasks.html @@ -16,6 +16,7 @@ Nom + Pour Fréquence Heure Durée @@ -26,6 +27,7 @@ {% for task in tasks if task.frequency in ['daily', 'weekly'] %} {{ task.name }} + {{ task.user.display_name if task.user and task.user.display_name else (task.user.username if task.user else 'Tous les techniciens') }} {% if task.frequency == 'daily' %} Quotidien @@ -43,7 +45,7 @@ {% else %} - Aucune tâche quotidienne/hebdomadaire + Aucune tâche quotidienne/hebdomadaire {% endfor %} @@ -56,6 +58,7 @@ Nom + Pour Fréquence Heure Durée @@ -66,6 +69,7 @@ {% for task in tasks if task.frequency in ['monthly', 'yearly'] %} {{ task.name }} + {{ task.user.display_name if task.user and task.user.display_name else (task.user.username if task.user else 'Tous les techniciens') }} {% if task.frequency == 'monthly' %} Mensuel @@ -83,11 +87,11 @@ {% else %} - Aucune tâche mensuelle/annuelle + Aucune tâche mensuelle/annuelle {% endfor %}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/planning/templates/planning/time_tracking.html b/app_new/planning/templates/planning/time_tracking.html index 0a8beb2..f44e918 100644 --- a/app_new/planning/templates/planning/time_tracking.html +++ b/app_new/planning/templates/planning/time_tracking.html @@ -3,10 +3,23 @@ {% block content %}
-

Horaires et suivi des heures

Période comptable : {{ period_start.strftime('%d/%m/%Y') }} au {{ period_end.strftime('%d/%m/%Y') }}.

+

Horaires de travail et suivi des heures

Période comptable : {{ period_start.strftime('%d/%m/%Y') }} au {{ period_end.strftime('%d/%m/%Y') }}.

+ {% if managed_users %} +
+
+ + + +
+ Les horaires et pauses affichés appartiennent à cette personne. +
+ {% else %}

Horaires affichés pour : {{ target_user.full_name or target_user.username }}

{% endif %} +
À réaliser

{{ '%d h %02d'|format(required_minutes // 60, required_minutes % 60) }}

1607 h moins pénibilité
Prévu par calendrier

{{ '%d h %02d'|format(planned_minutes // 60, planned_minutes % 60) }}

@@ -19,7 +32,7 @@
Objectif annuel {{ year }}
-
+
@@ -53,6 +66,11 @@