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"
|
__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)
|
||||||
|
|
@ -610,6 +613,8 @@ class AdminTask(db.Model):
|
||||||
is_active = db.Column(db.Boolean, default=True)
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
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}>"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'])
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,14 @@
|
||||||
</select>
|
</select>
|
||||||
</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">
|
||||||
|
|
@ -89,4 +97,4 @@ document.querySelector('select[name="frequency"]').addEventListener('change', fu
|
||||||
document.getElementById('monthlyOptions').style.display = this.value === 'monthly' ? 'block' : 'none';
|
document.getElementById('monthlyOptions').style.display = this.value === 'monthly' ? 'block' : 'none';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -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,11 +87,11 @@
|
||||||
</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>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,19 @@
|
||||||
<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>
|
||||||
<a href="{{ url_for('equipments.index') }}" class="btn btn-secondary">
|
<div class="d-flex gap-2">
|
||||||
<i class="bi bi-arrow-left"></i> Retour aux équipements
|
<a href="{{ url_for('wizard.equipment_wizard') }}" class="btn btn-primary">
|
||||||
</a>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Filtres -->
|
<!-- Filtres -->
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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