From e3db1794ca8ee99a6f5fb815803e34f0a69e1169 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 20 Aug 2026 17:39:51 +0000 Subject: [PATCH] Adapter les horaires aux permanences scolaires --- app_new/core/models/planning.py | 2 + app_new/planning/closures.py | 25 +---- .../templates/planning/time_tracking.html | 8 +- app_new/planning/time_tracking.py | 95 +++++++++++++------ .../f9c3d4e5f6a7_add_permanence_template.py | 20 ++++ 5 files changed, 92 insertions(+), 58 deletions(-) create mode 100644 migrations/versions/f9c3d4e5f6a7_add_permanence_template.py diff --git a/app_new/core/models/planning.py b/app_new/core/models/planning.py index 297ce76..7bf0f04 100644 --- a/app_new/core/models/planning.py +++ b/app_new/core/models/planning.py @@ -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"" diff --git a/app_new/planning/closures.py b/app_new/planning/closures.py index da54909..e5a5f04 100644 --- a/app_new/planning/closures.py +++ b/app_new/planning/closures.py @@ -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')) - - diff --git a/app_new/planning/templates/planning/time_tracking.html b/app_new/planning/templates/planning/time_tracking.html index ad04f6c..03f09d4 100644 --- a/app_new/planning/templates/planning/time_tracking.html +++ b/app_new/planning/templates/planning/time_tracking.html @@ -3,7 +3,7 @@ {% block content %}
-

Horaires et suivi des heures

Configurez vos profils, permanences et écarts d'heures.

+

Horaires et suivi des heures

Période comptable : {{ period_start.strftime('%d/%m/%Y') }} au {{ period_end.strftime('%d/%m/%Y') }}.

@@ -23,9 +23,9 @@
-
Horaires types (maximum 3){{ templates|length }}/3
+
Horaires types (maximum 6){{ templates|length }}/6
{% for template in templates %}
{{ template.name }}
{{ 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 %}
{% else %}

Aucun profil défini.

{% endfor %} - {% if templates|length < 3 %}
{% endif %} + {% if templates|length < 6 %}
{% endif %}
@@ -36,7 +36,7 @@
Importer les calendriers
-
Ajouter une permanenceUne permanence ouvre une date précise dans une période de vacances.
+
Permanences dans les vacancesSélectionnez plusieurs dates puis l’horaire type à appliquer.
{% for option in permanence_options %}
{% else %}
Aucune période de vacances importée pour cette période. Importez d’abord le calendrier scolaire.
{% endfor %}
Saisir un retard ou une heure supplémentaire
diff --git a/app_new/planning/time_tracking.py b/app_new/planning/time_tracking.py index e9a4d20..7795fae 100644 --- a/app_new/planning/time_tracking.py +++ b/app_new/planning/time_tracking.py @@ -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() - if not day: - day = ClosureWorkDay(closure_id=closure.id, work_date=work_date) - 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" - closure.work_hours_type = "custom" + ).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=current_day) + db.session.add(day) + 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//delete", methods=["POST"]) diff --git a/migrations/versions/f9c3d4e5f6a7_add_permanence_template.py b/migrations/versions/f9c3d4e5f6a7_add_permanence_template.py new file mode 100644 index 0000000..6a8a6f3 --- /dev/null +++ b/migrations/versions/f9c3d4e5f6a7_add_permanence_template.py @@ -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")