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"
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"<AdminTask {self.name}>"

View file

@ -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)

View file

@ -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

View file

@ -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/<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.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/<int:id>/delete', methods=['POST'])

View file

@ -23,6 +23,14 @@
</select>
</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="col-md-6 mb-3">
@ -89,4 +97,4 @@ document.querySelector('select[name="frequency"]').addEventListener('change', fu
document.getElementById('monthlyOptions').style.display = this.value === 'monthly' ? 'block' : 'none';
});
</script>
{% endblock %}
{% endblock %}

View file

@ -16,6 +16,7 @@
<thead class="table-light">
<tr>
<th>Nom</th>
<th>Pour</th>
<th>Fréquence</th>
<th>Heure</th>
<th>Durée</th>
@ -26,6 +27,7 @@
{% for task in tasks if task.frequency in ['daily', 'weekly'] %}
<tr>
<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>
{% if task.frequency == 'daily' %}
<span class="badge bg-primary">Quotidien</span>
@ -43,7 +45,7 @@
</tr>
{% else %}
<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>
{% endfor %}
</tbody>
@ -56,6 +58,7 @@
<thead class="table-light">
<tr>
<th>Nom</th>
<th>Pour</th>
<th>Fréquence</th>
<th>Heure</th>
<th>Durée</th>
@ -66,6 +69,7 @@
{% for task in tasks if task.frequency in ['monthly', 'yearly'] %}
<tr>
<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>
{% if task.frequency == 'monthly' %}
<span class="badge bg-warning">Mensuel</span>
@ -83,11 +87,11 @@
</tr>
{% else %}
<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>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% endblock %}

View file

@ -3,10 +3,23 @@
{% block content %}
<div class="container-fluid">
<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>
{% 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="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>
@ -19,7 +32,7 @@
<div class="row g-3">
<div class="col-lg-5">
<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>
<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>
@ -53,6 +66,11 @@
</div>
<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*="/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.querySelectorAll('input[name="work_dates"][checked]').forEach(function (input) {
input.disabled = true;

View file

@ -1,7 +1,7 @@
"""Configuration des horaires et suivi annuel des heures de l'agent."""
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 ..extensions import db
@ -9,6 +9,8 @@ from ..core.models.planning import (
AnnualTimeConfig, ClosureWorkDay, CollegeClosure, TimeEntry,
WorkSchedule, WorkScheduleTemplate,
)
from ..core.models.user import User
from ..core.authorization import has_permission
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
def _redirect(year=None):
return redirect(url_for("planning.time_tracking", year=year or _current_year()))
def _redirect(year=None, user_id=None):
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():
@ -49,6 +54,23 @@ def _period(year):
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):
"""Retourne les premiers jours de chaque mois de la période comptable."""
current = date(start.year, start.month, 1)
@ -63,14 +85,16 @@ def _month_starts(start, end):
@login_required
def time_tracking():
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)
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:
config = AnnualTimeConfig(user_id=current_user.id, year=year)
config = AnnualTimeConfig(user_id=target_user_id, year=year)
db.session.add(config)
db.session.commit()
templates = WorkScheduleTemplate.query.filter_by(user_id=current_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()
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=target_user_id, is_active=True).all()
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.
for item in all_assignments:
@ -87,10 +111,10 @@ def time_tracking():
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}
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))}
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,
).order_by(TimeEntry.work_date.desc()).all()
planned = 0
@ -148,7 +172,7 @@ def time_tracking():
# planifié habituel, et les formations viennent des convocations validées.
monthly_summary = []
trainings = Training.query.join(TrainingParticipant).filter(
TrainingParticipant.user_id == current_user.id,
TrainingParticipant.user_id == target_user_id,
TrainingParticipant.is_confirmed.is_(True),
Training.end_date >= period_start,
Training.start_date <= period_end,
@ -186,6 +210,9 @@ def time_tracking():
return render_template(
"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,
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"},
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,
@ -199,62 +226,66 @@ def time_tracking():
@login_required
def save_time_config():
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:
config = AnnualTimeConfig(user_id=current_user.id, year=year)
config = AnnualTimeConfig(user_id=target_user_id, year=year)
db.session.add(config)
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.notes = request.form.get("notes") or None
db.session.commit()
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"])
@login_required
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:
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"))
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):
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(
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,
)
db.session.add(template)
db.session.commit()
flash("Horaire type ajouté.", "success")
return _redirect()
return _redirect(user_id=target_user_id)
@planning_bp.route("/time/template/<int:id>/delete", methods=["POST"])
@login_required
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
for assignment in template.assignments:
assignment.template_id = None
db.session.commit()
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"])
@login_required
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"))
lunch_start = _time(request.form.get("lunch_start"))
lunch_end = _time(request.form.get("lunch_end")) or template.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")
return _redirect()
return _redirect(user_id=target_user_id)
template.name = (request.form.get("name") or template.name).strip()
template.start_time, template.end_time = start, 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
db.session.commit()
flash("Horaire type modifié.", "success")
return _redirect()
return _redirect(user_id=target_user_id)
@planning_bp.route("/time/assignment", methods=["POST"])
@ -272,21 +303,22 @@ def assign_time_template():
day = request.form.get("day_of_week", type=int)
year = request.form.get("year", _current_year(), 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 assignment:
db.session.delete(assignment)
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:
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)
assignment.template_id = template.id
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.is_active = True
db.session.commit()
return _redirect(year)
return _redirect(year, target_user_id)
@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.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.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 %}
{% 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>

View file

@ -4,9 +4,19 @@
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-boxes"></i> Gestion des Lots</h1>
<a href="{{ url_for('equipments.index') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Retour aux équipements
</a>
<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">
<i class="bi bi-arrow-left"></i> Retour aux équipements
</a>
</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 -->

View file

@ -19,6 +19,7 @@
<strong>Horaires de travail non configurés pour cette journée.</strong>
La proposition automatique reste désactivée. Configurez les horaires du
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>
{% endif %}
@ -78,6 +79,7 @@
<i class="bi bi-calendar-check fs-1 text-muted"></i>
<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>
{% 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>
{% 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()