fix(planning): isolate schedules and admin tasks by technician
This commit is contained in:
parent
0c4e107e06
commit
39a678235d
13 changed files with 227 additions and 47 deletions
|
|
@ -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}>"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
38
tests/integration/test_phase21_requalification.py
Normal file
38
tests/integration/test_phase21_requalification.py
Normal 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()
|
||||
|
||||
Loading…
Reference in a new issue