Adapter les horaires aux permanences scolaires
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run

This commit is contained in:
root 2026-08-20 17:39:51 +00:00
parent f017476ee1
commit e3db1794ca
5 changed files with 92 additions and 58 deletions

View file

@ -153,6 +153,7 @@ class ClosureWorkDay(db.Model):
nullable=False,
index=True,
)
template_id = db.Column(db.Integer, db.ForeignKey("work_schedule_templates.id"), nullable=True, index=True)
work_date = db.Column(db.Date, nullable=False, index=True)
start_time = db.Column(db.Time, nullable=False)
end_time = db.Column(db.Time, nullable=False)
@ -165,6 +166,7 @@ class ClosureWorkDay(db.Model):
"CollegeClosure",
backref=db.backref("work_days", cascade="all, delete-orphan", order_by="ClosureWorkDay.work_date"),
)
template = db.relationship("WorkScheduleTemplate")
def __repr__(self):
return f"<ClosureWorkDay {self.work_date} {self.start_time}-{self.end_time}>"

View file

@ -18,27 +18,8 @@ from .schedules import planning_bp
@planning_bp.route('/availability')
@login_required
def availability():
"""Gestion des disponibilités."""
from app_new.core.models.planning import WorkSchedule, CollegeClosure, PersonalLeave
from app_new.core.models.user import User
# Horaires de travail de l'utilisateur connecté
schedules = WorkSchedule.query.filter_by(user_id=current_user.id).order_by(WorkSchedule.day_of_week).all()
# Vacances scolaires
closures = CollegeClosure.query.order_by(CollegeClosure.start_date).all()
# Congés personnels
leaves = PersonalLeave.query.filter_by(user_id=current_user.id).order_by(PersonalLeave.start_date).all()
# Jours de la semaine en français
days = {0: 'Dimanche', 1: 'Lundi', 2: 'Mardi', 3: 'Mercredi', 4: 'Jeudi', 5: 'Vendredi', 6: 'Samedi'}
return render_template('planning/availability.html',
schedules=schedules,
closures=closures,
leaves=leaves,
days=days)
"""Ancienne URL conservée pour les favoris : renvoie vers le suivi unifié."""
return redirect(url_for('planning.time_tracking'))
@planning_bp.route('/availability/schedule/new', methods=['GET', 'POST'])
@ -242,5 +223,3 @@ def delete_leave(id):
db.session.commit()
flash('Congé supprimé', 'success')
return redirect(url_for('planning.availability'))

View file

@ -3,7 +3,7 @@
{% 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">Configurez vos profils, permanences et écarts d'heures.</p></div>
<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 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>
@ -23,9 +23,9 @@
</form>
</div></div>
<div class="card mb-3"><div class="card-header d-flex justify-content-between"><strong>Horaires types (maximum 3)</strong><span class="badge bg-primary">{{ templates|length }}/3</span></div><div class="card-body">
<div class="card mb-3"><div class="card-header d-flex justify-content-between"><strong>Horaires types (maximum 6)</strong><span class="badge bg-primary">{{ templates|length }}/6</span></div><div class="card-body">
{% for template in templates %}<div class="border-bottom py-2"><div class="d-flex justify-content-between"><span><strong>{{ template.name }}</strong><br><small>{{ template.start_time.strftime('%H:%M') }}{{ template.end_time.strftime('%H:%M') }}{% if template.lunch_start %} · pause {{ template.lunch_start.strftime('%H:%M') }}{{ template.lunch_end.strftime('%H:%M') }}{% endif %}</small></span><form method="post" action="{{ url_for('planning.delete_time_template', id=template.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button></form></div><form method="post" action="{{ url_for('planning.edit_time_template', id=template.id) }}" class="row g-1 mt-1"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="col-4"><input class="form-control form-control-sm" name="name" value="{{ template.name }}"></div><div class="col-2"><input class="form-control form-control-sm" type="time" name="start_time" value="{{ template.start_time.strftime('%H:%M') }}"></div><div class="col-2"><input class="form-control form-control-sm" type="time" name="end_time" value="{{ template.end_time.strftime('%H:%M') }}"></div><div class="col-2"><input class="form-control form-control-sm" type="time" name="lunch_start" value="{{ template.lunch_start.strftime('%H:%M') if template.lunch_start else '' }}"></div><div class="col-2"><button class="btn btn-sm btn-outline-primary w-100">Modifier</button></div></form></div>{% else %}<p class="text-muted">Aucun profil défini.</p>{% endfor %}
{% if templates|length < 3 %}<form method="post" action="{{ url_for('planning.save_time_template') }}" class="mt-3"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input class="form-control mb-2" name="name" placeholder="Nom : journée normale, été…" required><div class="row g-2"><div class="col-6"><input class="form-control" type="time" name="start_time" required></div><div class="col-6"><input class="form-control" type="time" name="end_time" required></div><div class="col-6"><input class="form-control" type="time" name="lunch_start" placeholder="Début pause"></div><div class="col-6"><input class="form-control" type="time" name="lunch_end" placeholder="Fin pause"></div></div><button class="btn btn-outline-primary btn-sm mt-2">Ajouter lhoraire type</button></form>{% endif %}
{% if templates|length < 6 %}<form method="post" action="{{ url_for('planning.save_time_template') }}" class="mt-3"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input class="form-control mb-2" name="name" placeholder="Nom : journée normale, été…" required><div class="row g-2"><div class="col-6"><input class="form-control" type="time" name="start_time" required></div><div class="col-6"><input class="form-control" type="time" name="end_time" required></div><div class="col-6"><input class="form-control" type="time" name="lunch_start" placeholder="Début pause"></div><div class="col-6"><input class="form-control" type="time" name="lunch_end" placeholder="Fin pause"></div></div><button class="btn btn-outline-primary btn-sm mt-2">Ajouter lhoraire type</button></form>{% endif %}
</div></div>
</div>
@ -36,7 +36,7 @@
<div class="card mb-3"><div class="card-header"><strong>Importer les calendriers</strong></div><div class="card-body"><div class="row g-2"><div class="col-md-6"><form method="post" action="{{ url_for('planning.import_school_vacations') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="year" value="{{ year }}"><div class="input-group"><select class="form-select" name="zone"><option value="B">Zone B (Seine-et-Marne)</option><option value="A">Zone A</option><option value="C">Zone C</option></select><button class="btn btn-outline-primary">Importer vacances</button></div></form></div><div class="col-md-6"><form method="post" action="{{ url_for('planning.import_public_holidays') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="year" value="{{ year }}"><button class="btn btn-outline-primary">Importer jours fériés {{ year }}</button></form></div></div></div></div>
<div class="card mb-3"><div class="card-header"><strong>Ajouter une permanence</strong><small class="d-block text-muted">Une permanence ouvre une date précise dans une période de vacances.</small></div><div class="card-body"><form method="post" action="{{ url_for('planning.add_permanence') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="row g-2"><div class="col-md-3"><input class="form-control" type="date" name="work_date" required></div><div class="col-md-2"><input class="form-control" type="time" name="start_time" required></div><div class="col-md-2"><input class="form-control" type="time" name="end_time" required></div><div class="col-md-3"><input class="form-control" name="notes" placeholder="Ex. permanence rentrée"></div><div class="col-md-2"><button class="btn btn-success w-100">Ajouter</button></div></div></form></div></div>
<div class="card mb-3"><div class="card-header"><strong>Permanences dans les vacances</strong><small class="d-block text-muted">Sélectionnez plusieurs dates puis lhoraire type à appliquer.</small></div><div class="card-body"><form method="post" action="{{ url_for('planning.save_permanences') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="year" value="{{ year }}"><div class="row g-2 align-items-end"><div class="col-md-8"><label class="form-label">Horaire type</label><select class="form-select" name="template_id" required><option value="">Choisir…</option>{% for template in templates %}<option value="{{ template.id }}">{{ template.name }} ({{ template.start_time.strftime('%H:%M') }}{{ template.end_time.strftime('%H:%M') }})</option>{% endfor %}</select></div><div class="col-md-4"><button class="btn btn-success w-100" {% if not templates %}disabled{% endif %}>Affecter les dates sélectionnées</button></div></div><div class="row g-2 mt-2">{% for option in permanence_options %}<div class="col-xl-3 col-lg-4 col-md-6"><label class="border rounded p-2 d-block"><input type="checkbox" name="work_dates" value="{{ option.date.isoformat() }}" {% if option.entry %}checked{% endif %}> <strong>{{ option.date.strftime('%d/%m/%Y') }}</strong><br><small>{{ option.closure.name }}{% if option.entry and option.entry.template %} · {{ option.entry.template.name }}{% endif %}</small></label></div>{% else %}<div class="col-12 text-muted">Aucune période de vacances importée pour cette période. Importez dabord le calendrier scolaire.</div>{% endfor %}</div></form></div></div>
<div class="card mb-3"><div class="card-header"><strong>Saisir un retard ou une heure supplémentaire</strong></div><div class="card-body"><form method="post" action="{{ url_for('planning.save_time_entry') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="row g-2"><div class="col-md-3"><input class="form-control" type="date" name="work_date" required></div><div class="col-md-2"><input class="form-control" type="number" name="planned_minutes" placeholder="Prévu (min)" value="0"></div><div class="col-md-2"><input class="form-control" type="number" name="actual_minutes" placeholder="Réalisé (min)" value="0"></div><div class="col-md-2"><select class="form-select" name="entry_type"><option value="travail">Travail</option><option value="retard">Retard</option><option value="supplementaire">Heures sup.</option><option value="permanence">Permanence</option></select></div><div class="col-md-3"><input class="form-control" name="note" placeholder="Note"></div></div><button class="btn btn-primary mt-2">Enregistrer la journée</button></form></div></div>
</div>

View file

@ -26,13 +26,24 @@ def _minutes(start, end, lunch_start=None, lunch_end=None):
def _redirect(year=None):
return redirect(url_for("planning.time_tracking", year=year or date.today().year))
return redirect(url_for("planning.time_tracking", year=year or _current_year()))
def _current_year():
today = date.today()
return today.year if today.month >= 9 else today.year - 1
def _period(year):
"""Année de travail : du 1er septembre au 31 août suivant."""
return date(year, 9, 1), date(year + 1, 8, 31)
@planning_bp.route("/time")
@login_required
def time_tracking():
year = request.args.get("year", date.today().year, type=int)
year = request.args.get("year", _current_year(), type=int)
period_start, period_end = _period(year)
config = AnnualTimeConfig.query.filter_by(user_id=current_user.id, year=year).first()
if not config:
config = AnnualTimeConfig(user_id=current_user.id, year=year)
@ -41,7 +52,7 @@ def time_tracking():
templates = WorkScheduleTemplate.query.filter_by(user_id=current_user.id, is_active=True).order_by(WorkScheduleTemplate.id).all()
assignments = {item.day_of_week: item for item in WorkSchedule.query.filter_by(user_id=current_user.id, is_active=True).all()}
closures = CollegeClosure.query.filter(
CollegeClosure.end_date >= date(year, 1, 1), CollegeClosure.start_date <= date(year, 12, 31)
CollegeClosure.end_date >= period_start, CollegeClosure.start_date <= period_end
).order_by(CollegeClosure.start_date).all()
from ..core.models.planning import ClosureSchedule, PersonalLeave, Training, TrainingParticipant
from ..core.services.planning_service import get_french_public_holidays
@ -50,16 +61,16 @@ def time_tracking():
closure_schedules = ClosureSchedule.query.filter(ClosureSchedule.closure_id.in_(closure_ids), ClosureSchedule.is_active.is_(True)).all() if closure_ids else []
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)}
leaves = PersonalLeave.query.filter(PersonalLeave.user_id == current_user.id, PersonalLeave.end_date >= date(year, 1, 1), PersonalLeave.start_date <= date(year, 12, 31)).all()
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()
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.work_date >= date(year, 1, 1), TimeEntry.work_date <= date(year, 12, 31),
TimeEntry.work_date >= period_start, TimeEntry.work_date <= period_end,
).order_by(TimeEntry.work_date.desc()).all()
planned = 0
current = date(year, 1, 1)
while current.year == year:
current = period_start
while current <= period_end:
hours = None
if current not in holidays and current not in leave_dates:
closure = next((item for item in closures if item.start_date <= current <= item.end_date), None)
@ -80,10 +91,21 @@ def time_tracking():
if hours:
planned += _minutes(*hours)
current += timedelta(days=1)
permanence_options = []
for closure in closures:
if closure.closure_type not in ("vacances", "vacances_scolaires"):
continue
current_day = max(closure.start_date, period_start)
end_day = min(closure.end_date, period_end)
while current_day <= end_day:
if current_day.weekday() < 5:
existing = work_day_map.get(current_day)
permanence_options.append({"date": current_day, "closure": closure, "entry": existing})
current_day += timedelta(days=1)
actual = sum(item.actual_minutes or 0 for item in entries)
return render_template(
"planning/time_tracking.html", year=year, time_config=config, templates=templates,
assignments=assignments, closures=closures, entries=entries,
"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, entries=entries,
days={0: "Lundi", 1: "Mardi", 2: "Mercredi", 3: "Jeudi", 4: "Vendredi", 5: "Samedi", 6: "Dimanche"},
planned_minutes=planned, actual_minutes=actual,
required_minutes=config.minutes_to_work, variance_minutes=actual - config.minutes_to_work,
@ -110,8 +132,8 @@ def save_time_config():
@login_required
def save_time_template():
count = WorkScheduleTemplate.query.filter_by(user_id=current_user.id, is_active=True).count()
if count >= 3:
flash("Vous pouvez définir au maximum trois horaires types.", "warning")
if count >= 6:
flash("Vous pouvez définir au maximum six horaires types.", "warning")
return _redirect()
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"))
@ -220,28 +242,39 @@ def import_public_holidays():
return _redirect(year)
@planning_bp.route("/time/permanence", methods=["POST"])
@planning_bp.route("/time/permanences", methods=["POST"])
@login_required
def add_permanence():
work_date = datetime.strptime(request.form["work_date"], "%Y-%m-%d").date()
closure = CollegeClosure.query.filter(
CollegeClosure.start_date <= work_date, CollegeClosure.end_date >= work_date,
def save_permanences():
year = request.form.get("year", date.today().year, type=int)
template = WorkScheduleTemplate.query.filter_by(id=request.form.get("template_id", type=int), user_id=current_user.id, is_active=True).first_or_404()
selected_dates = set(request.form.getlist("work_dates"))
period_start, period_end = _period(year)
closures = CollegeClosure.query.filter(
CollegeClosure.end_date >= period_start, CollegeClosure.start_date <= period_end,
CollegeClosure.closure_type.in_(["vacances", "vacances_scolaires"]),
).first()
if not closure:
flash("La date doit se situer dans une période de vacances scolaires.", "danger")
return _redirect(work_date.year)
day = ClosureWorkDay.query.filter_by(closure_id=closure.id, work_date=work_date).first()
).all()
for closure in closures:
current_day = max(closure.start_date, period_start)
end_day = min(closure.end_date, period_end)
while current_day <= end_day:
if current_day.weekday() < 5 and current_day.isoformat() in selected_dates:
day = ClosureWorkDay.query.filter_by(closure_id=closure.id, work_date=current_day).first()
if not day:
day = ClosureWorkDay(closure_id=closure.id, work_date=work_date)
day = ClosureWorkDay(closure_id=closure.id, work_date=current_day)
db.session.add(day)
day.start_time, day.end_time = _time(request.form.get("start_time")), _time(request.form.get("end_time"))
day.lunch_start, day.lunch_end = _time(request.form.get("lunch_start")), _time(request.form.get("lunch_end"))
day.notes = request.form.get("notes") or "Permanence"
day.template_id = template.id
day.start_time, day.end_time = template.start_time, template.end_time
day.lunch_start, day.lunch_end = template.lunch_start, template.lunch_end
day.notes = "Permanence"
closure.work_hours_type = "custom"
elif current_day.weekday() < 5:
existing = ClosureWorkDay.query.filter_by(closure_id=closure.id, work_date=current_day).first()
if existing:
db.session.delete(existing)
current_day += timedelta(days=1)
db.session.commit()
flash("Permanence enregistrée.", "success")
return _redirect(work_date.year)
flash(f"{len(selected_dates)} jour(s) de permanence enregistré(s) avec « {template.name} ».", "success")
return _redirect(year)
@planning_bp.route("/time/permanence/<int:id>/delete", methods=["POST"])

View file

@ -0,0 +1,20 @@
"""Lie les permanences à un horaire type."""
from alembic import op
import sqlalchemy as sa
revision = "f9c3d4e5f6a7"
down_revision = "f8b2c3d4e5f6"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("closure_work_days", sa.Column("template_id", sa.Integer(), nullable=True))
op.create_index("ix_closure_work_days_template_id", "closure_work_days", ["template_id"])
op.create_foreign_key("fk_closure_work_days_template_id", "closure_work_days", "work_schedule_templates", ["template_id"], ["id"])
def downgrade():
op.drop_constraint("fk_closure_work_days_template_id", "closure_work_days", type_="foreignkey")
op.drop_index("ix_closure_work_days_template_id", table_name="closure_work_days")
op.drop_column("closure_work_days", "template_id")