fix(planning): isolate schedules and admin tasks by technician

This commit is contained in:
root 2026-08-23 11:41:31 +00:00
parent 0c4e107e06
commit 39a678235d
13 changed files with 227 additions and 47 deletions

View file

@ -596,6 +596,9 @@ class AdminTask(db.Model):
__tablename__ = "admin_tasks" __tablename__ = "admin_tasks"
id = db.Column(db.Integer, primary_key=True) 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) name = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text) description = db.Column(db.Text)
@ -611,6 +614,8 @@ class AdminTask(db.Model):
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) 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)) 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): def __repr__(self):
return f"<AdminTask {self.name}>" return f"<AdminTask {self.name}>"

View file

@ -9,6 +9,7 @@ from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
from typing import Iterable, Optional from typing import Iterable, Optional
from ...extensions import db
from .planning_service import PlanningService from .planning_service import PlanningService
from .room_planning import room_is_available, room_occupancy from .room_planning import room_is_available, room_occupancy
@ -206,7 +207,11 @@ class DayPlanner:
)) ))
previous = None previous = None
for candidate in flexible: 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 chosen = None
for window in windows: for window in windows:
cursor = _minutes(window.start) 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): 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.""" """Retourne quelques créneaux réellement validables pour une tâche."""
windows = cls.work_windows(day, user_id) 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 = [] occupied = []
room_occupied = _resolved_room_occupancy_batch(day, {item.room_id for item in candidates if item.room_id}) room_occupied = _resolved_room_occupancy_batch(day, {item.room_id for item in candidates if item.room_id})
for item in candidates: for item in candidates:
@ -312,7 +319,10 @@ class DayPlanner:
assigned_to=intervention.technician_id or intervention.assigned_to_id, assigned_to=intervention.technician_id or intervention.assigned_to_id,
status=intervention.status, fixed_start=intervention.scheduled_start, fixed_end=intervention.scheduled_end, 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(): if admin.frequency == "weekly" and admin.day_of_week != day.weekday():
continue continue
if admin.frequency == "monthly" and admin.day_of_month != day.day: 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, source_type="admin_task", source_id=admin.id, title=admin.name,
description=admin.description or "", task_type="admin", priority=3, description=admin.description or "", task_type="admin", priority=3,
constraint="fixed" if admin.start_time else "flexible", target_date=day, constraint="fixed" if admin.start_time else "flexible", target_date=day,
duration_minutes=int(admin.duration_minutes or 30), fixed_start=admin.start_time, 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 30))).time() if admin.start_time else None, 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", status="à planifier",
)) ))
return cls.propose(day, candidates, user_id=user_id) return cls.propose(day, candidates, user_id=user_id)

View file

@ -186,12 +186,19 @@ class PlanningService:
return None return None
# Vérifier les congés personnels # 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'): if leave and leave.availability_type in ('conge', 'maladie'):
return None return None
# Horaires normaux - chercher le premier schedule avec des horaires valides # Horaires normaux : lorsqu'un technicien est fourni, ne jamais
schedules = WorkSchedule.query.filter_by(day_of_week=day_of_week).all() # 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: for schedule in schedules:
source = schedule.template if getattr(schedule, 'template', None) and schedule.template.is_active else schedule source = schedule.template if getattr(schedule, 'template', None) and schedule.template.is_active else schedule
if source.start_time and source.end_time: if source.start_time and source.end_time:
@ -206,7 +213,13 @@ class PlanningService:
if day_of_week >= 5: if day_of_week >= 5:
return None 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)) return (time(8), time(17), time(12), time(13))
@staticmethod @staticmethod

View file

@ -2,6 +2,7 @@
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
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 ..core.models.user import User
from ..core.models.planning import ( from ..core.models.planning import (
PreventiveTask, PreventiveTaskConsumable, ScheduledTask, PreventiveTask, PreventiveTaskConsumable, ScheduledTask,
Meter, MeterReading, Consumable, ConsumableUsage, Meter, MeterReading, Consumable, ConsumableUsage,
@ -36,6 +37,7 @@ def new_admin_task():
day_of_month=request.form.get('day_of_month', type=int), 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, 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), 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 is_active=True
) )
db.session.add(task) db.session.add(task)
@ -45,7 +47,7 @@ def new_admin_task():
return render_template('planning/admin_task_form.html', return render_template('planning/admin_task_form.html',
title='Nouvelle tâche administrative', 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/<int:id>/edit', methods=['GET', 'POST']) @planning_bp.route('/admin-tasks/<int:id>/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.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.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.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' task.is_active = request.form.get('is_active') == 'on'
db.session.commit() db.session.commit()
flash('Tâche administrative mise à jour', 'success') flash('Tâche administrative mise à jour', 'success')
@ -69,7 +72,7 @@ def edit_admin_task(id):
return render_template('planning/admin_task_form.html', return render_template('planning/admin_task_form.html',
title='Modifier la tâche administrative', 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/<int:id>/delete', methods=['POST']) @planning_bp.route('/admin-tasks/<int:id>/delete', methods=['POST'])

View file

@ -24,6 +24,14 @@
</div> </div>
</div> </div>
<div class="mb-3">
<label class="form-label" for="admin-task-user">Tâche administrative de</label>
<select id="admin-task-user" name="user_id" class="form-select">
{% for user in managed_users %}<option value="{{ user.id }}" {% if task and task.user_id == user.id %}selected{% elif not task and user.id == current_user.id %}selected{% endif %}>{{ user.full_name or user.username }}</option>{% endfor %}
</select>
<div class="form-text">La tâche apparaîtra dans « Ma journée » de cette personne.</div>
</div>
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label class="form-label">Heure de début</label> <label class="form-label">Heure de début</label>

View file

@ -16,6 +16,7 @@
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>Nom</th> <th>Nom</th>
<th>Pour</th>
<th>Fréquence</th> <th>Fréquence</th>
<th>Heure</th> <th>Heure</th>
<th>Durée</th> <th>Durée</th>
@ -26,6 +27,7 @@
{% for task in tasks if task.frequency in ['daily', 'weekly'] %} {% for task in tasks if task.frequency in ['daily', 'weekly'] %}
<tr> <tr>
<td><strong>{{ task.name }}</strong></td> <td><strong>{{ task.name }}</strong></td>
<td>{{ task.user.display_name if task.user and task.user.display_name else (task.user.username if task.user else 'Tous les techniciens') }}</td>
<td> <td>
{% if task.frequency == 'daily' %} {% if task.frequency == 'daily' %}
<span class="badge bg-primary">Quotidien</span> <span class="badge bg-primary">Quotidien</span>
@ -43,7 +45,7 @@
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="5" class="text-muted text-center">Aucune tâche quotidienne/hebdomadaire</td> <td colspan="6" class="text-muted text-center">Aucune tâche quotidienne/hebdomadaire</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -56,6 +58,7 @@
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>Nom</th> <th>Nom</th>
<th>Pour</th>
<th>Fréquence</th> <th>Fréquence</th>
<th>Heure</th> <th>Heure</th>
<th>Durée</th> <th>Durée</th>
@ -66,6 +69,7 @@
{% for task in tasks if task.frequency in ['monthly', 'yearly'] %} {% for task in tasks if task.frequency in ['monthly', 'yearly'] %}
<tr> <tr>
<td><strong>{{ task.name }}</strong></td> <td><strong>{{ task.name }}</strong></td>
<td>{{ task.user.display_name if task.user and task.user.display_name else (task.user.username if task.user else 'Tous les techniciens') }}</td>
<td> <td>
{% if task.frequency == 'monthly' %} {% if task.frequency == 'monthly' %}
<span class="badge bg-warning">Mensuel</span> <span class="badge bg-warning">Mensuel</span>
@ -83,7 +87,7 @@
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="5" class="text-muted text-center">Aucune tâche mensuelle/annuelle</td> <td colspan="6" class="text-muted text-center">Aucune tâche mensuelle/annuelle</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View file

@ -3,10 +3,23 @@
{% block content %} {% block content %}
<div class="container-fluid"> <div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div><h1 class="h3"><i class="bi bi-clock-history me-2"></i>Horaires et suivi des heures</h1><p class="text-muted mb-0">Période comptable : {{ period_start.strftime('%d/%m/%Y') }} au {{ period_end.strftime('%d/%m/%Y') }}.</p></div> <div><h1 class="h3"><i class="bi bi-clock-history me-2"></i>Horaires de travail et suivi des heures</h1><p class="text-muted mb-0">Période comptable : {{ period_start.strftime('%d/%m/%Y') }} au {{ period_end.strftime('%d/%m/%Y') }}.</p></div>
<div class="btn-group"><a class="btn btn-outline-secondary" href="{{ url_for('planning.time_tracking', year=year-1) }}">{{ year-1 }}</a><a class="btn btn-outline-secondary" href="{{ url_for('planning.time_tracking', year=year+1) }}">{{ year+1 }}</a></div> <div class="btn-group"><a class="btn btn-outline-secondary" href="{{ url_for('planning.time_tracking', year=year-1) }}">{{ year-1 }}</a><a class="btn btn-outline-secondary" href="{{ url_for('planning.time_tracking', year=year+1) }}">{{ year+1 }}</a></div>
</div> </div>
{% if managed_users %}
<div class="card border-primary mb-3"><div class="card-body py-2">
<form id="planning-user-selector" method="get">
<input type="hidden" name="year" value="{{ year }}">
<label class="form-label fw-semibold mb-1" for="planning-user">Horaires de travail de :</label>
<select id="planning-user" name="user_id" class="form-select" onchange="this.form.submit()">
{% for user in managed_users %}<option value="{{ user.id }}" {% if user.id == target_user_id %}selected{% endif %}>{{ user.full_name or user.username }}</option>{% endfor %}
</select>
</form>
<small class="text-muted">Les horaires et pauses affichés appartiennent à cette personne.</small>
</div></div>
{% else %}<p class="text-muted">Horaires affichés pour : <strong>{{ target_user.full_name or target_user.username }}</strong></p>{% endif %}
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-md-3"><div class="card border-primary h-100"><div class="card-body"><small>À réaliser</small><h3>{{ '%d h %02d'|format(required_minutes // 60, required_minutes % 60) }}</h3><small class="text-muted">1607 h moins pénibilité</small></div></div></div> <div class="col-md-3"><div class="card border-primary h-100"><div class="card-body"><small>À réaliser</small><h3>{{ '%d h %02d'|format(required_minutes // 60, required_minutes % 60) }}</h3><small class="text-muted">1607 h moins pénibilité</small></div></div></div>
<div class="col-md-3"><div class="card border-info h-100"><div class="card-body"><small>Prévu par calendrier</small><h3>{{ '%d h %02d'|format(planned_minutes // 60, planned_minutes % 60) }}</h3></div></div></div> <div class="col-md-3"><div class="card border-info h-100"><div class="card-body"><small>Prévu par calendrier</small><h3>{{ '%d h %02d'|format(planned_minutes // 60, planned_minutes % 60) }}</h3></div></div></div>
@ -19,7 +32,7 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-lg-5"> <div class="col-lg-5">
<div class="card mb-3"><div class="card-header"><strong>Objectif annuel {{ year }}</strong></div><div class="card-body"> <div class="card mb-3"><div class="card-header"><strong>Objectif annuel {{ year }}</strong></div><div class="card-body">
<form method="post" action="{{ url_for('planning.save_time_config') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="year" value="{{ year }}"> <form method="post" action="{{ url_for('planning.save_time_config') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="year" value="{{ year }}"><input type="hidden" name="user_id" value="{{ target_user_id }}">
<div class="row g-2"><div class="col-6"><label class="form-label">Durée annuelle (h)</label><input class="form-control" type="number" step="0.01" name="target_hours" value="{{ '%.2f'|format(time_config.target_minutes / 60) }}"></div><div class="col-6"><label class="form-label">Déduction pénibilité (h)</label><input class="form-control" type="number" step="0.01" min="0" name="hardship_hours" value="{{ '%.2f'|format(time_config.hardship_deduction_minutes / 60) }}"></div></div> <div class="row g-2"><div class="col-6"><label class="form-label">Durée annuelle (h)</label><input class="form-control" type="number" step="0.01" name="target_hours" value="{{ '%.2f'|format(time_config.target_minutes / 60) }}"></div><div class="col-6"><label class="form-label">Déduction pénibilité (h)</label><input class="form-control" type="number" step="0.01" min="0" name="hardship_hours" value="{{ '%.2f'|format(time_config.hardship_deduction_minutes / 60) }}"></div></div>
<label class="form-label mt-2">Notes</label><textarea class="form-control" name="notes" rows="2">{{ time_config.notes or '' }}</textarea><button class="btn btn-primary mt-2">Enregistrer</button> <label class="form-label mt-2">Notes</label><textarea class="form-control" name="notes" rows="2">{{ time_config.notes or '' }}</textarea><button class="btn btn-primary mt-2">Enregistrer</button>
</form> </form>
@ -53,6 +66,11 @@
</div> </div>
<script> <script>
document.querySelectorAll('form[action*="assign_time_template"]').forEach(function (form) { var input = document.createElement('input'); input.type = 'hidden'; input.name = 'year'; input.value = '{{ year }}'; form.appendChild(input); }); document.querySelectorAll('form[action*="assign_time_template"]').forEach(function (form) { var input = document.createElement('input'); input.type = 'hidden'; input.name = 'year'; input.value = '{{ year }}'; form.appendChild(input); });
document.querySelectorAll('form[action*="/planning/time/"]').forEach(function (form) {
if (!form.querySelector('input[name="user_id"]')) {
var input = document.createElement('input'); input.type = 'hidden'; input.name = 'user_id'; input.value = '{{ target_user_id }}'; form.appendChild(input);
}
});
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('input[name="work_dates"][checked]').forEach(function (input) { document.querySelectorAll('input[name="work_dates"][checked]').forEach(function (input) {
input.disabled = true; input.disabled = true;

View file

@ -1,7 +1,7 @@
"""Configuration des horaires et suivi annuel des heures de l'agent.""" """Configuration des horaires et suivi annuel des heures de l'agent."""
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from flask import flash, redirect, render_template, request, url_for from flask import abort, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required from flask_login import current_user, login_required
from ..extensions import db from ..extensions import db
@ -9,6 +9,8 @@ from ..core.models.planning import (
AnnualTimeConfig, ClosureWorkDay, CollegeClosure, TimeEntry, AnnualTimeConfig, ClosureWorkDay, CollegeClosure, TimeEntry,
WorkSchedule, WorkScheduleTemplate, WorkSchedule, WorkScheduleTemplate,
) )
from ..core.models.user import User
from ..core.authorization import has_permission
from .schedules import planning_bp from .schedules import planning_bp
@ -35,8 +37,11 @@ def _valid_schedule(start, end, lunch_start=None, lunch_end=None):
return _minutes(start, end, lunch_start, lunch_end) > 0 return _minutes(start, end, lunch_start, lunch_end) > 0
def _redirect(year=None): def _redirect(year=None, user_id=None):
return redirect(url_for("planning.time_tracking", year=year or _current_year())) return redirect(url_for(
"planning.time_tracking", year=year or _current_year(),
**({"user_id": user_id} if user_id and user_id != current_user.id else {}),
))
def _current_year(): def _current_year():
@ -49,6 +54,23 @@ def _period(year):
return date(year, 9, 1), date(year + 1, 8, 31) return date(year, 9, 1), date(year + 1, 8, 31)
def _target_user_id(value=None):
"""Retourne le technicien ciblé par l'écran, avec contrôle RBAC."""
if value in (None, ""):
return current_user.id
try:
requested = int(value)
except (TypeError, ValueError):
abort(400)
if requested != current_user.id and not (
current_user.is_admin() or has_permission("planning.manage", current_user)
):
abort(403)
if not User.query.filter_by(id=requested, is_active=True).first():
abort(404)
return requested
def _month_starts(start, end): def _month_starts(start, end):
"""Retourne les premiers jours de chaque mois de la période comptable.""" """Retourne les premiers jours de chaque mois de la période comptable."""
current = date(start.year, start.month, 1) current = date(start.year, start.month, 1)
@ -63,14 +85,16 @@ def _month_starts(start, end):
@login_required @login_required
def time_tracking(): def time_tracking():
year = request.args.get("year", _current_year(), type=int) year = request.args.get("year", _current_year(), type=int)
target_user_id = _target_user_id(request.args.get("user_id"))
target_user = User.query.get_or_404(target_user_id)
period_start, period_end = _period(year) period_start, period_end = _period(year)
config = AnnualTimeConfig.query.filter_by(user_id=current_user.id, year=year).first() config = AnnualTimeConfig.query.filter_by(user_id=target_user_id, year=year).first()
if not config: if not config:
config = AnnualTimeConfig(user_id=current_user.id, year=year) config = AnnualTimeConfig(user_id=target_user_id, year=year)
db.session.add(config) db.session.add(config)
db.session.commit() db.session.commit()
templates = WorkScheduleTemplate.query.filter_by(user_id=current_user.id, is_active=True).order_by(WorkScheduleTemplate.id).all() templates = WorkScheduleTemplate.query.filter_by(user_id=target_user_id, is_active=True).order_by(WorkScheduleTemplate.id).all()
all_assignments = WorkSchedule.query.filter_by(user_id=current_user.id, is_active=True).all() all_assignments = WorkSchedule.query.filter_by(user_id=target_user_id, is_active=True).all()
assignments = {item.day_of_week: item for item in all_assignments if item.academic_year == year} assignments = {item.day_of_week: item for item in all_assignments if item.academic_year == year}
# Compatibilité avec les affectations créées avant le suivi par année. # Compatibilité avec les affectations créées avant le suivi par année.
for item in all_assignments: for item in all_assignments:
@ -87,10 +111,10 @@ def time_tracking():
work_day_map = {item.work_date: item for item in work_days} work_day_map = {item.work_date: item for item in work_days}
closure_schedule_map = {(item.closure_id, item.day_of_week): item for item in closure_schedules} closure_schedule_map = {(item.closure_id, item.day_of_week): item for item in closure_schedules}
holidays = {item[0] for item in get_french_public_holidays(year)} | {item[0] for item in get_french_public_holidays(year + 1)} holidays = {item[0] for item in get_french_public_holidays(year)} | {item[0] for item in get_french_public_holidays(year + 1)}
leaves = PersonalLeave.query.filter(PersonalLeave.user_id == current_user.id, PersonalLeave.end_date >= period_start, PersonalLeave.start_date <= period_end).all() leaves = PersonalLeave.query.filter(PersonalLeave.user_id == target_user_id, PersonalLeave.end_date >= period_start, PersonalLeave.start_date <= period_end).all()
leave_dates = {day for leave in leaves for day in (leave.start_date + timedelta(days=i) for i in range((leave.end_date - leave.start_date).days + 1))} leave_dates = {day for leave in leaves for day in (leave.start_date + timedelta(days=i) for i in range((leave.end_date - leave.start_date).days + 1))}
entries = TimeEntry.query.filter( entries = TimeEntry.query.filter(
TimeEntry.user_id == current_user.id, TimeEntry.user_id == target_user_id,
TimeEntry.work_date >= period_start, TimeEntry.work_date <= period_end, TimeEntry.work_date >= period_start, TimeEntry.work_date <= period_end,
).order_by(TimeEntry.work_date.desc()).all() ).order_by(TimeEntry.work_date.desc()).all()
planned = 0 planned = 0
@ -148,7 +172,7 @@ def time_tracking():
# planifié habituel, et les formations viennent des convocations validées. # planifié habituel, et les formations viennent des convocations validées.
monthly_summary = [] monthly_summary = []
trainings = Training.query.join(TrainingParticipant).filter( trainings = Training.query.join(TrainingParticipant).filter(
TrainingParticipant.user_id == current_user.id, TrainingParticipant.user_id == target_user_id,
TrainingParticipant.is_confirmed.is_(True), TrainingParticipant.is_confirmed.is_(True),
Training.end_date >= period_start, Training.end_date >= period_start,
Training.start_date <= period_end, Training.start_date <= period_end,
@ -186,6 +210,9 @@ def time_tracking():
return render_template( return render_template(
"planning/time_tracking.html", year=year, period_start=period_start, period_end=period_end, time_config=config, templates=templates, "planning/time_tracking.html", year=year, period_start=period_start, period_end=period_end, time_config=config, templates=templates,
assignments=assignments, closures=closures, permanence_options=permanence_options, permanence_groups=permanence_groups, entries=entries, assignments=assignments, closures=closures, permanence_options=permanence_options, permanence_groups=permanence_groups, entries=entries,
target_user=target_user, target_user_id=target_user_id,
managed_users=(User.query.filter_by(is_active=True).order_by(User.full_name, User.username).all()
if (current_user.is_admin() or has_permission("planning.manage", current_user)) else []),
days={0: "Lundi", 1: "Mardi", 2: "Mercredi", 3: "Jeudi", 4: "Vendredi", 5: "Samedi", 6: "Dimanche"}, days={0: "Lundi", 1: "Mardi", 2: "Mercredi", 3: "Jeudi", 4: "Vendredi", 5: "Samedi", 6: "Dimanche"},
months={1: "Janvier", 2: "Février", 3: "Mars", 4: "Avril", 5: "Mai", 6: "Juin", 7: "Juillet", 8: "Août", 9: "Septembre", 10: "Octobre", 11: "Novembre", 12: "Décembre"}, months={1: "Janvier", 2: "Février", 3: "Mars", 4: "Avril", 5: "Mai", 6: "Juin", 7: "Juillet", 8: "Août", 9: "Septembre", 10: "Octobre", 11: "Novembre", 12: "Décembre"},
planned_minutes=planned, actual_minutes=actual, planned_minutes=planned, actual_minutes=actual,
@ -199,62 +226,66 @@ def time_tracking():
@login_required @login_required
def save_time_config(): def save_time_config():
year = request.form.get("year", date.today().year, type=int) year = request.form.get("year", date.today().year, type=int)
config = AnnualTimeConfig.query.filter_by(user_id=current_user.id, year=year).first() target_user_id = _target_user_id(request.form.get("user_id"))
config = AnnualTimeConfig.query.filter_by(user_id=target_user_id, year=year).first()
if not config: if not config:
config = AnnualTimeConfig(user_id=current_user.id, year=year) config = AnnualTimeConfig(user_id=target_user_id, year=year)
db.session.add(config) db.session.add(config)
config.target_minutes = round(float(request.form.get("target_hours") or 1607) * 60) config.target_minutes = round(float(request.form.get("target_hours") or 1607) * 60)
config.hardship_deduction_minutes = round(float(request.form.get("hardship_hours") or 0) * 60) config.hardship_deduction_minutes = round(float(request.form.get("hardship_hours") or 0) * 60)
config.notes = request.form.get("notes") or None config.notes = request.form.get("notes") or None
db.session.commit() db.session.commit()
flash("Objectif annuel et déduction de pénibilité enregistrés.", "success") flash("Objectif annuel et déduction de pénibilité enregistrés.", "success")
return _redirect(year) return _redirect(year, target_user_id)
@planning_bp.route("/time/template", methods=["POST"]) @planning_bp.route("/time/template", methods=["POST"])
@login_required @login_required
def save_time_template(): def save_time_template():
count = WorkScheduleTemplate.query.filter_by(user_id=current_user.id, is_active=True).count() target_user_id = _target_user_id(request.form.get("user_id"))
count = WorkScheduleTemplate.query.filter_by(user_id=target_user_id, is_active=True).count()
if count >= 6: if count >= 6:
flash("Vous pouvez définir au maximum six horaires types.", "warning") flash("Vous pouvez définir au maximum six horaires types.", "warning")
return _redirect() return _redirect(user_id=target_user_id)
start, end = _time(request.form.get("start_time")), _time(request.form.get("end_time")) start, end = _time(request.form.get("start_time")), _time(request.form.get("end_time"))
lunch_start, lunch_end = _time(request.form.get("lunch_start")), _time(request.form.get("lunch_end")) lunch_start, lunch_end = _time(request.form.get("lunch_start")), _time(request.form.get("lunch_end"))
if not _valid_schedule(start, end, lunch_start, lunch_end): if not _valid_schedule(start, end, lunch_start, lunch_end):
flash("Horaires invalides : la fin doit être après le début et la pause doit être complète et comprise dans la journée.", "danger") flash("Horaires invalides : la fin doit être après le début et la pause doit être complète et comprise dans la journée.", "danger")
return _redirect() return _redirect(user_id=target_user_id)
template = WorkScheduleTemplate( template = WorkScheduleTemplate(
user_id=current_user.id, name=(request.form.get("name") or f"Horaire {count + 1}").strip(), user_id=target_user_id, name=(request.form.get("name") or f"Horaire {count + 1}").strip(),
start_time=start, end_time=end, lunch_start=lunch_start, lunch_end=lunch_end, start_time=start, end_time=end, lunch_start=lunch_start, lunch_end=lunch_end,
) )
db.session.add(template) db.session.add(template)
db.session.commit() db.session.commit()
flash("Horaire type ajouté.", "success") flash("Horaire type ajouté.", "success")
return _redirect() return _redirect(user_id=target_user_id)
@planning_bp.route("/time/template/<int:id>/delete", methods=["POST"]) @planning_bp.route("/time/template/<int:id>/delete", methods=["POST"])
@login_required @login_required
def delete_time_template(id): def delete_time_template(id):
template = WorkScheduleTemplate.query.filter_by(id=id, user_id=current_user.id).first_or_404() target_user_id = _target_user_id(request.form.get("user_id"))
template = WorkScheduleTemplate.query.filter_by(id=id, user_id=target_user_id).first_or_404()
template.is_active = False template.is_active = False
for assignment in template.assignments: for assignment in template.assignments:
assignment.template_id = None assignment.template_id = None
db.session.commit() db.session.commit()
flash("Horaire type désactivé.", "success") flash("Horaire type désactivé.", "success")
return _redirect() return _redirect(user_id=target_user_id)
@planning_bp.route("/time/template/<int:id>/edit", methods=["POST"]) @planning_bp.route("/time/template/<int:id>/edit", methods=["POST"])
@login_required @login_required
def edit_time_template(id): def edit_time_template(id):
template = WorkScheduleTemplate.query.filter_by(id=id, user_id=current_user.id, is_active=True).first_or_404() target_user_id = _target_user_id(request.form.get("user_id"))
template = WorkScheduleTemplate.query.filter_by(id=id, user_id=target_user_id, is_active=True).first_or_404()
start, end = _time(request.form.get("start_time")), _time(request.form.get("end_time")) start, end = _time(request.form.get("start_time")), _time(request.form.get("end_time"))
lunch_start = _time(request.form.get("lunch_start")) lunch_start = _time(request.form.get("lunch_start"))
lunch_end = _time(request.form.get("lunch_end")) or template.lunch_end lunch_end = _time(request.form.get("lunch_end")) or template.lunch_end
if not _valid_schedule(start, end, lunch_start, lunch_end): if not _valid_schedule(start, end, lunch_start, lunch_end):
flash("Horaires invalides : la fin doit être après le début et la pause doit être complète et comprise dans la journée.", "danger") flash("Horaires invalides : la fin doit être après le début et la pause doit être complète et comprise dans la journée.", "danger")
return _redirect() return _redirect(user_id=target_user_id)
template.name = (request.form.get("name") or template.name).strip() template.name = (request.form.get("name") or template.name).strip()
template.start_time, template.end_time = start, end template.start_time, template.end_time = start, end
template.lunch_start, template.lunch_end = lunch_start, lunch_end template.lunch_start, template.lunch_end = lunch_start, lunch_end
@ -263,7 +294,7 @@ def edit_time_template(id):
assignment.lunch_start, assignment.lunch_end = lunch_start, lunch_end assignment.lunch_start, assignment.lunch_end = lunch_start, lunch_end
db.session.commit() db.session.commit()
flash("Horaire type modifié.", "success") flash("Horaire type modifié.", "success")
return _redirect() return _redirect(user_id=target_user_id)
@planning_bp.route("/time/assignment", methods=["POST"]) @planning_bp.route("/time/assignment", methods=["POST"])
@ -272,21 +303,22 @@ def assign_time_template():
day = request.form.get("day_of_week", type=int) day = request.form.get("day_of_week", type=int)
year = request.form.get("year", _current_year(), type=int) year = request.form.get("year", _current_year(), type=int)
template_id = request.form.get("template_id", type=int) template_id = request.form.get("template_id", type=int)
assignment = WorkSchedule.query.filter_by(user_id=current_user.id, day_of_week=day, academic_year=year).first() target_user_id = _target_user_id(request.form.get("user_id"))
assignment = WorkSchedule.query.filter_by(user_id=target_user_id, day_of_week=day, academic_year=year).first()
if not template_id: if not template_id:
if assignment: if assignment:
db.session.delete(assignment) db.session.delete(assignment)
else: else:
template = WorkScheduleTemplate.query.filter_by(id=template_id, user_id=current_user.id, is_active=True).first_or_404() template = WorkScheduleTemplate.query.filter_by(id=template_id, user_id=target_user_id, is_active=True).first_or_404()
if not assignment: if not assignment:
assignment = WorkSchedule(user_id=current_user.id, day_of_week=day, academic_year=year) assignment = WorkSchedule(user_id=target_user_id, day_of_week=day, academic_year=year)
db.session.add(assignment) db.session.add(assignment)
assignment.template_id = template.id assignment.template_id = template.id
assignment.start_time, assignment.end_time = template.start_time, template.end_time assignment.start_time, assignment.end_time = template.start_time, template.end_time
assignment.lunch_start, assignment.lunch_end = template.lunch_start, template.lunch_end assignment.lunch_start, assignment.lunch_end = template.lunch_start, template.lunch_end
assignment.is_active = True assignment.is_active = True
db.session.commit() db.session.commit()
return _redirect(year) return _redirect(year, target_user_id)
@planning_bp.route("/time/entry", methods=["POST"]) @planning_bp.route("/time/entry", methods=["POST"])

View file

@ -18,6 +18,8 @@
<li><a class="dropdown-item" href="{{ url_for('planning.scheduled') }}"><i class="bi bi-calendar-week"></i> Tâches planifiées</a></li> <li><a class="dropdown-item" href="{{ url_for('planning.scheduled') }}"><i class="bi bi-calendar-week"></i> Tâches planifiées</a></li>
<li><a class="dropdown-item" href="{{ url_for('planning.admin_tasks') }}"><i class="bi bi-clipboard"></i> Tâches administratives</a></li> <li><a class="dropdown-item" href="{{ url_for('planning.admin_tasks') }}"><i class="bi bi-clipboard"></i> Tâches administratives</a></li>
<li><a class="dropdown-item" href="{{ url_for('planning.availability') }}"><i class="bi bi-clock"></i> Disponibilités</a></li> <li><a class="dropdown-item" href="{{ url_for('planning.availability') }}"><i class="bi bi-clock"></i> Disponibilités</a></li>
<li><a class="dropdown-item" href="{{ url_for('planning.time_tracking') }}"><i class="bi bi-clock-history"></i> Horaires de travail</a></li>
<li><a class="dropdown-item" href="{{ url_for('lots.index') }}"><i class="bi bi-layers"></i> Lots préventifs</a></li>
{% endif %} {% endif %}
{% if has_permission('prevention.view') %}<li><a class="dropdown-item" href="{{ url_for('prevention.index') }}"><i class="bi bi-shield-check"></i> Prévention</a></li>{% endif %} {% if has_permission('prevention.view') %}<li><a class="dropdown-item" href="{{ url_for('prevention.index') }}"><i class="bi bi-shield-check"></i> Prévention</a></li>{% endif %}
</ul> </ul>

View file

@ -4,10 +4,20 @@
<div class="container-fluid"> <div class="container-fluid">
<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-boxes"></i> Gestion des Lots</h1> <h1><i class="bi bi-boxes"></i> Gestion des Lots</h1>
<div class="d-flex gap-2">
<a href="{{ url_for('wizard.equipment_wizard') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Créer un lot et son équipement
</a>
<a href="{{ url_for('equipments.index') }}" class="btn btn-secondary"> <a href="{{ url_for('equipments.index') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Retour aux équipements <i class="bi bi-arrow-left"></i> Retour aux équipements
</a> </a>
</div> </div>
</div>
<div class="alert alert-info" role="note">
Les lots regroupent les équipements suivis ensemble et leurs actions préventives.
Vous pouvez créer un premier lot depuis l'assistant d'ajout d'équipement.
</div>
<!-- Filtres --> <!-- Filtres -->
<div class="card mb-4"> <div class="card mb-4">

View file

@ -19,6 +19,7 @@
<strong>Horaires de travail non configurés pour cette journée.</strong> <strong>Horaires de travail non configurés pour cette journée.</strong>
La proposition automatique reste désactivée. Configurez les horaires du La proposition automatique reste désactivée. Configurez les horaires du
technicien ou utilisez la planification manuelle existante. technicien ou utilisez la planification manuelle existante.
<a class="alert-link d-block mt-1" href="{{ url_for('planning.time_tracking') }}">Configurer les horaires de travail</a>
</div> </div>
{% endif %} {% endif %}
@ -78,6 +79,7 @@
<i class="bi bi-calendar-check fs-1 text-muted"></i> <i class="bi bi-calendar-check fs-1 text-muted"></i>
<h2 class="h5 mt-3">Aucune tâche à afficher</h2> <h2 class="h5 mt-3">Aucune tâche à afficher</h2>
<p class="text-muted mb-0">Les interventions, maintenances préventives et tâches administratives du jour apparaîtront ici.</p> <p class="text-muted mb-0">Les interventions, maintenances préventives et tâches administratives du jour apparaîtront ici.</p>
{% if hours_configured %}<a class="btn btn-outline-primary btn-sm mt-3" href="{{ url_for('planning.room_schedules') }}">Vérifier le planning des salles</a>{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}

View file

@ -0,0 +1,35 @@
"""Rattacher les tâches administratives à un technicien."""
from alembic import op
import sqlalchemy as sa
revision = "g2b3c4d5e6f7"
down_revision = "g1a2b3c4d5e6"
branch_labels = None
depends_on = None
def _columns(bind):
return {item["name"] for item in sa.inspect(bind).get_columns("admin_tasks")}
def upgrade():
bind = op.get_bind()
if "user_id" not in _columns(bind):
op.add_column("admin_tasks", sa.Column("user_id", sa.Integer(), nullable=True))
op.create_index("ix_admin_tasks_user_id", "admin_tasks", ["user_id"])
op.create_foreign_key(
"fk_admin_tasks_user_id_users", "admin_tasks", "users", ["user_id"], ["id"]
)
def downgrade():
bind = op.get_bind()
if "user_id" in _columns(bind):
inspector = sa.inspect(bind)
if "ix_admin_tasks_user_id" in {item["name"] for item in inspector.get_indexes("admin_tasks")}:
op.drop_index("ix_admin_tasks_user_id", table_name="admin_tasks")
if "fk_admin_tasks_user_id_users" in {item["name"] for item in inspector.get_foreign_keys("admin_tasks")}:
op.drop_constraint("fk_admin_tasks_user_id_users", "admin_tasks", type_="foreignkey")
op.drop_column("admin_tasks", "user_id")

View file

@ -0,0 +1,38 @@
"""Requalification des prérequis réellement rattachés au technicien."""
from datetime import date, time
from app_new import db
from app_new.core.models.planning import AnnualTimeConfig, WorkSchedule
from app_new.core.models.user import User
from app_new.core.services.planning_service import PlanningService
def _user(username, name):
user = User(username=username, email=f"{username}@gmao.local", full_name=name, is_active=True)
user.set_password("phase21-requalification-password")
db.session.add(user)
db.session.flush()
return user
def test_working_hours_are_isolated_between_two_technicians(app):
with app.app_context():
tech_a = _user("phase21_tech_a", "Technicien A")
tech_b = _user("phase21_tech_b", "Technicien B")
db.session.add_all([
WorkSchedule(user_id=tech_a.id, day_of_week=0, start_time=time(8), end_time=time(17), lunch_start=time(12), lunch_end=time(13, 30)),
WorkSchedule(user_id=tech_b.id, day_of_week=0, start_time=time(7), end_time=time(15, 30), lunch_start=time(11, 30), lunch_end=time(12, 30)),
])
db.session.commit()
try:
monday = date(2026, 8, 24)
assert PlanningService.get_working_hours(monday, user_id=tech_a.id)[:2] == (time(8), time(17))
assert PlanningService.get_working_hours(monday, user_id=tech_b.id)[:2] == (time(7), time(15, 30))
finally:
WorkSchedule.query.filter(WorkSchedule.user_id.in_([tech_a.id, tech_b.id])).delete(synchronize_session=False)
AnnualTimeConfig.query.filter(AnnualTimeConfig.user_id.in_([tech_a.id, tech_b.id])).delete(synchronize_session=False)
db.session.delete(tech_a)
db.session.delete(tech_b)
db.session.commit()