From b051930431a639256a13433842486cbe72d688bb Mon Sep 17 00:00:00 2001 From: root Date: Fri, 14 Aug 2026 18:43:10 +0000 Subject: [PATCH] =?UTF-8?q?G=C3=A8re=20les=20pr=C3=A9sences=20pendant=20le?= =?UTF-8?q?s=20vacances=20scolaires?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app_new/core/models/__init__.py | 6 +- app_new/core/models/planning.py | 33 ++++- app_new/core/routes/setup_wizard.py | 119 ++++++++++++++++-- app_new/core/services/planning_service.py | 18 ++- .../templates/planning/availability.html | 10 +- app_new/templates/setup_wizard/index.html | 66 +++++++++- docs/AUDIT_FONCTIONNEL_ET_ROUTES.md | 5 + .../f1a5b6c7d8e9_add_closure_work_days.py | 41 ++++++ tests/integration/test_auth_setup.py | 55 ++++++++ 9 files changed, 331 insertions(+), 22 deletions(-) create mode 100644 migrations/versions/f1a5b6c7d8e9_add_closure_work_days.py diff --git a/app_new/core/models/__init__.py b/app_new/core/models/__init__.py index 472a41d..29a5d31 100644 --- a/app_new/core/models/__init__.py +++ b/app_new/core/models/__init__.py @@ -11,7 +11,7 @@ from .maintenance import ( ) from .company import Company, Part, Alert, Service from .planning import ( - WorkSchedule, CollegeClosure, PersonalLeave, Training, TrainingParticipant, + WorkSchedule, CollegeClosure, ClosureSchedule, ClosureWorkDay, PersonalLeave, Training, TrainingParticipant, Meter, MeterReading, Consumable, ConsumableUsage, EquipmentConsumable, PreventiveTask, PreventiveTaskConsumable, ScheduledTask, TechnicianAvailability, AdminTask, ZoneAccessRule @@ -25,9 +25,9 @@ __all__ = [ 'Intervention', 'StatusChange', 'InterventionComment', 'InterventionDocument', 'Lot', 'LotTask', 'LotService', 'Company', 'Part', 'InterventionPart', 'Alert', 'Service', - 'WorkSchedule', 'CollegeClosure', 'PersonalLeave', 'Training', 'TrainingParticipant', + 'WorkSchedule', 'CollegeClosure', 'ClosureSchedule', 'ClosureWorkDay', 'PersonalLeave', 'Training', 'TrainingParticipant', 'Meter', 'MeterReading', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable', 'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask', 'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule', 'AppSettings', -] \ No newline at end of file +] diff --git a/app_new/core/models/planning.py b/app_new/core/models/planning.py index 61b3a31..73f47cb 100644 --- a/app_new/core/models/planning.py +++ b/app_new/core/models/planning.py @@ -72,6 +72,37 @@ class ClosureSchedule(db.Model): return f"" +class ClosureWorkDay(db.Model): + """Journée exceptionnellement travaillée pendant des vacances scolaires.""" + __tablename__ = "closure_work_days" + __table_args__ = ( + db.UniqueConstraint("closure_id", "work_date", name="uq_closure_work_day"), + ) + + id = db.Column(db.Integer, primary_key=True) + closure_id = db.Column( + db.Integer, + db.ForeignKey("college_closures.id", ondelete="CASCADE"), + nullable=False, + 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) + lunch_start = db.Column(db.Time, nullable=True) + lunch_end = db.Column(db.Time, nullable=True) + notes = db.Column(db.String(255), nullable=True) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + closure = db.relationship( + "CollegeClosure", + backref=db.backref("work_days", cascade="all, delete-orphan", order_by="ClosureWorkDay.work_date"), + ) + + def __repr__(self): + return f"" + + class PersonalLeave(db.Model): """Congés personnels.""" __tablename__ = "personal_leaves" @@ -597,4 +628,4 @@ class PlanningItem(db.Model): return self.priority in (1, 2) def __repr__(self): - return f"" \ No newline at end of file + return f"" diff --git a/app_new/core/routes/setup_wizard.py b/app_new/core/routes/setup_wizard.py index 0101241..bccb563 100644 --- a/app_new/core/routes/setup_wizard.py +++ b/app_new/core/routes/setup_wizard.py @@ -14,7 +14,7 @@ from ..models.user import User from ..models.college import College, Building, Zone, Room from ..models.equipment import EquipmentCategory from ..models.maintenance import Lot -from ..models.planning import WorkSchedule, CollegeClosure +from ..models.planning import WorkSchedule, CollegeClosure, ClosureWorkDay from ..setup_catalog import EQUIPMENT_CATEGORIES, LOTS, grouped_catalog setup_wizard_bp = Blueprint('setup_wizard', __name__, url_prefix='/setup-wizard') @@ -145,6 +145,28 @@ class SetupProgress(db.Model): } for item in Room.query.order_by(Room.building_id, Room.name, Room.id).all() ] + vacation_periods = [ + { + 'id': closure.id, + 'name': closure.name, + 'start_date': closure.start_date.isoformat(), + 'end_date': closure.end_date.isoformat(), + 'work_days': [ + { + 'id': work_day.id, + 'work_date': work_day.work_date.isoformat(), + 'start_time': work_day.start_time.strftime('%H:%M'), + 'end_time': work_day.end_time.strftime('%H:%M'), + 'lunch_start': work_day.lunch_start.strftime('%H:%M') if work_day.lunch_start else '', + 'lunch_end': work_day.lunch_end.strftime('%H:%M') if work_day.lunch_end else '', + 'notes': work_day.notes or '', + } + for work_day in closure.work_days + ], + } + for closure in CollegeClosure.query.filter_by(closure_type='vacances') + .order_by(CollegeClosure.start_date, CollegeClosure.id).all() + ] return { 'step': self.step, 'data': { @@ -162,6 +184,7 @@ class SetupProgress(db.Model): 'categories': json.loads(self.categories_json) if self.categories_json else [], 'vacances_zone': self.vacances_zone or '', 'vacances_annee': self.vacances_annee, + 'vacation_periods': vacation_periods, 'ent_configured': self.ent_configured, } } @@ -432,24 +455,98 @@ def api_save_step(step): elif step == 7: # Étape 7 : Vacances et horaires zone = data.get('zone', 'A') - annee = data.get('annee', 2024) + annee = data.get('annee', datetime.now().year) import_vacances = data.get('import_vacances', False) import_feries = data.get('import_feries', False) - work_schedule = data.get('work_schedule', {}) - custom_closures = data.get('custom_closures', []) - personal_leaves = data.get('personal_leaves', []) - + vacation_periods = data.get('vacation_periods', []) + if not isinstance(vacation_periods, list): + return jsonify({'error': 'Liste de vacances invalide'}), 400 + + saved_periods = [] + for period in vacation_periods: + if not isinstance(period, dict): + return jsonify({'error': 'Période de vacances invalide'}), 400 + name = str(period.get('name') or '').strip() + try: + start_date = datetime.strptime(period.get('start_date', ''), '%Y-%m-%d').date() + end_date = datetime.strptime(period.get('end_date', ''), '%Y-%m-%d').date() + except (TypeError, ValueError): + return jsonify({'error': f'Dates invalides pour {name or "une période"}'}), 400 + if not name or end_date < start_date: + return jsonify({'error': 'Chaque période doit avoir un nom et des dates cohérentes'}), 400 + + closure = db.session.get(CollegeClosure, period.get('id')) if period.get('id') else None + if closure is None: + closure = CollegeClosure.query.filter_by( + name=name, start_date=start_date, end_date=end_date + ).first() + if closure is None: + closure = CollegeClosure(name=name, start_date=start_date, end_date=end_date) + db.session.add(closure) + db.session.flush() + closure.name = name + closure.start_date = start_date + closure.end_date = end_date + closure.closure_type = 'vacances' + # Toute la période est fermée, sauf les dates exactes ci-dessous. + closure.work_hours_type = 'none' + + work_days = period.get('work_days', []) + if not isinstance(work_days, list): + db.session.rollback() + return jsonify({'error': f'Jours travaillés invalides pour {name}'}), 400 + ClosureWorkDay.query.filter_by(closure_id=closure.id).delete() + seen_dates = set() + saved_days = [] + for day in work_days: + if not isinstance(day, dict): + db.session.rollback() + return jsonify({'error': f'Jour travaillé invalide pour {name}'}), 400 + try: + work_date = datetime.strptime(day.get('work_date', ''), '%Y-%m-%d').date() + start_time = datetime.strptime(day.get('start_time', ''), '%H:%M').time() + end_time = datetime.strptime(day.get('end_time', ''), '%H:%M').time() + lunch_start = datetime.strptime(day['lunch_start'], '%H:%M').time() if day.get('lunch_start') else None + lunch_end = datetime.strptime(day['lunch_end'], '%H:%M').time() if day.get('lunch_end') else None + except (TypeError, ValueError): + db.session.rollback() + return jsonify({'error': f'Date ou horaires invalides pour un jour travaillé de {name}'}), 400 + if not start_date <= work_date <= end_date: + db.session.rollback() + return jsonify({'error': f'Le {work_date:%d/%m/%Y} est hors de la période {name}'}), 400 + if work_date in seen_dates or end_time <= start_time: + db.session.rollback() + return jsonify({'error': f'Jour dupliqué ou horaires incohérents dans {name}'}), 400 + if (lunch_start is None) != (lunch_end is None) or ( + lunch_start and not (start_time <= lunch_start < lunch_end <= end_time) + ): + db.session.rollback() + return jsonify({'error': f'Pause déjeuner incohérente le {work_date:%d/%m/%Y}'}), 400 + seen_dates.add(work_date) + work_day = ClosureWorkDay( + closure_id=closure.id, + work_date=work_date, + start_time=start_time, + end_time=end_time, + lunch_start=lunch_start, + lunch_end=lunch_end, + notes=str(day.get('notes') or '').strip() or None, + ) + db.session.add(work_day) + saved_days.append({'work_date': work_date.isoformat()}) + saved_periods.append({'id': closure.id, 'name': name, 'work_days': saved_days}) + progress.vacances_zone = zone progress.vacances_annee = annee progress.vacations_json = json.dumps({ - 'work_schedule': work_schedule, - 'custom_closures': custom_closures, - 'personal_leaves': personal_leaves - }) + 'import_vacances': bool(import_vacances), + 'import_feries': bool(import_feries), + 'vacation_periods': saved_periods, + }, ensure_ascii=False) progress.step = max(progress.step or 0, 7) db.session.commit() - return jsonify({'success': True, 'step': 7}) + return jsonify({'success': True, 'step': 7, 'vacation_periods': saved_periods}) elif step in (8, 9): # Intégrations facultatives : le paramétrage complet reste dans leurs pages dédiées. diff --git a/app_new/core/services/planning_service.py b/app_new/core/services/planning_service.py index bf5d8f9..a79e1fc 100644 --- a/app_new/core/services/planning_service.py +++ b/app_new/core/services/planning_service.py @@ -7,7 +7,7 @@ from typing import List, Dict, Optional, Tuple from app_new.extensions import db from app_new.core.models.planning import ( ScheduledTask, AdminTask, WorkSchedule, CollegeClosure, - ClosureSchedule, TechnicianAvailability, PlanningDay, PlanningItem + ClosureSchedule, ClosureWorkDay, TechnicianAvailability, PlanningDay, PlanningItem ) from app_new.core.models.maintenance import Intervention from app_new.core.models.equipment import Equipment @@ -126,6 +126,20 @@ class PlanningService: day_of_week = d.weekday() # 0=lundi, 6=dimanche if closure: + # Une fermeture scolaire est non travaillée par défaut. Une date + # exacte peut toutefois être rouverte avec ses propres horaires. + exceptional_day = ClosureWorkDay.query.filter_by( + closure_id=closure.id, + work_date=d, + ).first() + if exceptional_day: + return ( + exceptional_day.start_time, + exceptional_day.end_time, + exceptional_day.lunch_start, + exceptional_day.lunch_end, + ) + if closure.work_hours_type == 'none': return None @@ -439,4 +453,4 @@ class PlanningService: db.session.commit() return True - return False \ No newline at end of file + return False diff --git a/app_new/planning/templates/planning/availability.html b/app_new/planning/templates/planning/availability.html index 754f972..272c377 100644 --- a/app_new/planning/templates/planning/availability.html +++ b/app_new/planning/templates/planning/availability.html @@ -163,6 +163,14 @@ {% else %} - {% endif %} + {% if closure.work_days %} +
+ {{ closure.work_days|length }} présence(s) + {% for day in closure.work_days %} +
{{ day.work_date.strftime('%d/%m') }} : {{ day.start_time.strftime('%H:%M') }}–{{ day.end_time.strftime('%H:%M') }}
+ {% endfor %} +
+ {% endif %} {{ closure.notes or '-' }} @@ -341,4 +349,4 @@ -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/templates/setup_wizard/index.html b/app_new/templates/setup_wizard/index.html index 89ebd41..196f692 100644 --- a/app_new/templates/setup_wizard/index.html +++ b/app_new/templates/setup_wizard/index.html @@ -71,10 +71,16 @@
Calendrier
+
+ Les vacances scolaires sont considérées comme non travaillées. Ajoutez uniquement les journées où vous êtes présent ; chaque journée peut avoir des horaires différents. +
-
+
+
+
Périodes de vacances et présences exceptionnelles
+
ENT
Configuration facultative. Elle sera réalisée depuis la page dédiée après l’installation ; tant qu’elle est absente, son watchdog reste arrêté.
@@ -97,6 +103,7 @@ const setupData = {{ progress.data|tojson }}; let buildings = setupData.buildings || []; let zones = setupData.zones || []; let rooms = setupData.rooms || []; +let vacationPeriods = setupData.vacation_periods || []; function field(value, placeholder, type = 'text') { const input = document.createElement('input'); @@ -155,6 +162,57 @@ function renderRooms() { } function addRoom() { rooms.push({name: '', code: '', building_id: buildings[0]?.id || null, zone_id: null, floor: 0}); renderRooms(); } +function renderVacationPeriods() { + const root = document.getElementById('vacationPeriodsList'); root.replaceChildren(); + vacationPeriods.forEach((period, periodIndex) => { + period.work_days = period.work_days || []; + const card = document.createElement('div'); card.className = 'card mb-3'; + const body = document.createElement('div'); body.className = 'card-body'; + const periodRow = document.createElement('div'); periodRow.className = 'row g-2 align-items-end mb-3'; + const nameCol = document.createElement('div'); nameCol.className = 'col-md-5'; nameCol.append(labelFor('Nom de la période')); + const name = field(period.name, 'Ex. Vacances de la Toussaint'); name.oninput = () => period.name = name.value; nameCol.append(name); + const startCol = document.createElement('div'); startCol.className = 'col-md-3'; startCol.append(labelFor('Début')); + const start = field(period.start_date, '', 'date'); start.oninput = () => period.start_date = start.value; startCol.append(start); + const endCol = document.createElement('div'); endCol.className = 'col-md-3'; endCol.append(labelFor('Fin')); + const end = field(period.end_date, '', 'date'); end.oninput = () => period.end_date = end.value; endCol.append(end); + const removeCol = document.createElement('div'); removeCol.className = 'col-md-1'; removeCol.append(removeButton(() => { vacationPeriods.splice(periodIndex, 1); renderVacationPeriods(); })); + periodRow.append(nameCol, startCol, endCol, removeCol); body.append(periodRow); + + const heading = document.createElement('div'); heading.className = 'd-flex justify-content-between align-items-center mb-2'; + const text = document.createElement('strong'); text.textContent = 'Journées travaillées pendant cette période'; + const add = document.createElement('button'); add.type = 'button'; add.className = 'btn btn-outline-success btn-sm'; add.innerHTML = ' Ajouter une journée'; + add.onclick = () => { period.work_days.push({work_date: '', start_time: '08:00', end_time: '16:00', lunch_start: '12:00', lunch_end: '12:45', notes: ''}); renderVacationPeriods(); }; + heading.append(text, add); body.append(heading); + + const responsive = document.createElement('div'); responsive.className = 'table-responsive'; + const table = document.createElement('table'); table.className = 'table table-sm align-middle'; + table.innerHTML = 'DateDébutFinPause débutPause finNote'; + const tbody = document.createElement('tbody'); + period.work_days.forEach((day, dayIndex) => { + const row = document.createElement('tr'); + const values = [ + ['work_date', 'date'], ['start_time', 'time'], ['end_time', 'time'], + ['lunch_start', 'time'], ['lunch_end', 'time'], ['notes', 'text'], + ]; + values.forEach(([key, type]) => { + const cell = document.createElement('td'); const input = field(day[key], '', type); + if (key === 'notes') input.placeholder = 'Ex. remise en route'; + input.oninput = () => day[key] = input.value; cell.append(input); row.append(cell); + }); + const action = document.createElement('td'); action.append(removeButton(() => { period.work_days.splice(dayIndex, 1); renderVacationPeriods(); })); row.append(action); tbody.append(row); + }); + if (!period.work_days.length) { + const row = document.createElement('tr'); const cell = document.createElement('td'); cell.colSpan = 7; cell.className = 'text-muted text-center'; cell.textContent = 'Aucune présence : toute la période sera non travaillée.'; row.append(cell); tbody.append(row); + } + table.append(tbody); responsive.append(table); body.append(responsive); card.append(body); root.append(card); + }); + if (!vacationPeriods.length) { + const empty = document.createElement('p'); empty.className = 'text-muted'; empty.textContent = 'Ajoutez les périodes manuellement en attendant l’import des dates officielles.'; root.append(empty); + } +} +function labelFor(text) { const label = document.createElement('label'); label.className = 'form-label small mb-1'; label.textContent = text; return label; } +function addVacationPeriod() { vacationPeriods.push({name: '', start_date: '', end_date: '', work_days: []}); renderVacationPeriods(); } + function showStep(step) { displayStep = step; document.querySelectorAll('.step-panel').forEach((panel, index) => panel.classList.toggle('d-none', index + 1 !== step)); @@ -165,7 +223,7 @@ function showStep(step) { document.getElementById('prevBtn').classList.toggle('invisible', step === 1); document.getElementById('nextBtn').classList.toggle('d-none', step === totalSteps); document.getElementById('finishBtn').classList.toggle('d-none', step !== totalSteps); - if (step === 3) renderBuildings(); if (step === 4) renderZones(); if (step === 5) renderRooms(); + if (step === 3) renderBuildings(); if (step === 4) renderZones(); if (step === 5) renderRooms(); if (step === 7) renderVacationPeriods(); } async function saveStep(step, data) { @@ -195,7 +253,7 @@ async function nextStep() { } else if (displayStep === 6) { result = await saveStep(6, {}); } else if (displayStep === 7) { - result = await saveStep(7, {zone: document.getElementById('academiqueZone').value, annee: Number(document.getElementById('anneeScolaire').value), import_vacances: document.getElementById('importAutoVacances').checked, import_feries: document.getElementById('importAutoFeries').checked}); + result = await saveStep(7, {zone: document.getElementById('academiqueZone').value, annee: Number(document.getElementById('anneeScolaire').value), import_vacances: document.getElementById('importAutoVacances').checked, import_feries: document.getElementById('importAutoFeries').checked, vacation_periods: vacationPeriods}); } else { result = await saveStep(displayStep, {skipped: displayStep === 8 || displayStep === 9}); } @@ -212,6 +270,6 @@ async function finishSetup() { } catch (error) { alert(error.message); } } -renderBuildings(); renderZones(); renderRooms(); showStep(displayStep); +renderBuildings(); renderZones(); renderRooms(); renderVacationPeriods(); showStep(displayStep); {% endblock %} diff --git a/docs/AUDIT_FONCTIONNEL_ET_ROUTES.md b/docs/AUDIT_FONCTIONNEL_ET_ROUTES.md index 9ba72b7..7438acc 100644 --- a/docs/AUDIT_FONCTIONNEL_ET_ROUTES.md +++ b/docs/AUDIT_FONCTIONNEL_ET_ROUTES.md @@ -29,6 +29,8 @@ Le noyau fonctionnel doit reposer sur les relations suivantes : 7. intervention récurrente → analyse selon le type d'équipement et seuil d'alerte configurable ; 8. rôle assistant de prévention → plan d'actions, registres, contrôles, formations et suivi du temps dédié. +Règle de calendrier propre au collège : les vacances scolaires sont non travaillées par défaut. Le technicien peut toutefois travailler 2 à 3 jours au début ou à la fin des petites vacances, puis 5 à 10 jours au début et 5 à 10 jours à la fin des vacances d'été. Ces présences sont des dates exactes, pas une semaine-type : début, fin et pause peuvent changer chaque jour. Le moteur ne doit planifier aucune tâche pendant une fermeture, sauf sur une journée de présence explicitement déclarée et dans les horaires de cette date. + ## Priorités bloquantes ### P0 — Sécurité et permissions @@ -112,6 +114,7 @@ Reste à faire : - migrer `setup_progress.step` et déplacer le modèle dans les modèles/migrations ; - ajouter CSRF au wizard après autorisation par jeton ; - persister réellement horaires, vacances et jours fériés ; +- importer les périodes scolaires selon la zone académique, les fermer par défaut, puis permettre de rouvrir des dates de présence individuelles avec leurs horaires variables ; - supprimer les endpoints de test ENT/Pronote retournant 501 ou les implémenter ; - retirer les anciens templates `setup_wizard/admin.html` et routes imaginaires ; - ne jamais supprimer automatiquement le compte `admin` lors de la création d'un autre administrateur ; @@ -170,6 +173,7 @@ Le projet possède plusieurs moteurs concurrents : `LotTask`, `PreventiveTask`, - protéger les routes `/scheduler/run`, création mensuelle et changements de tâches ; - réactiver CSRF sur le blueprint scheduler ; - afficher charge disponible, congés, fermetures et temps prévention réservé. +- traiter séparément les journées de présence de début/fin de petites vacances et les deux plages de 5 à 10 jours des vacances d'été, sans recopier automatiquement les horaires d'un jour sur l'autre ; ### Patrimoine — `/equipments/*` @@ -404,6 +408,7 @@ Le projet pourra être considéré prêt lorsque : - tous les liens des templates actifs ciblent un endpoint existant ; - bâtiments, zones, salles, groupes et unités conservent leurs quantités et historiques ; - les tâches de lot génèrent des échéances fiables sans doublons ; +- aucune tâche n'est planifiée pendant les vacances hors des dates de présence saisies, et la capacité quotidienne respecte les horaires particuliers de chaque date ; - réparation, déplacement, réforme et récurrence sont testés ; - sauvegarde et restauration MariaDB + documents + configuration sont testées ; - les secrets ne sont ni affichés, ni journalisés, ni commités ; diff --git a/migrations/versions/f1a5b6c7d8e9_add_closure_work_days.py b/migrations/versions/f1a5b6c7d8e9_add_closure_work_days.py new file mode 100644 index 0000000..ecd12f2 --- /dev/null +++ b/migrations/versions/f1a5b6c7d8e9_add_closure_work_days.py @@ -0,0 +1,41 @@ +"""Ajoute les journées travaillées pendant les vacances. + +Revision ID: f1a5b6c7d8e9 +Revises: e0f4a5b6c7d8 +Create Date: 2026-08-14 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "f1a5b6c7d8e9" +down_revision = "e0f4a5b6c7d8" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "closure_work_days", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("closure_id", sa.Integer(), nullable=False), + sa.Column("work_date", sa.Date(), nullable=False), + sa.Column("start_time", sa.Time(), nullable=False), + sa.Column("end_time", sa.Time(), nullable=False), + sa.Column("lunch_start", sa.Time(), nullable=True), + sa.Column("lunch_end", sa.Time(), nullable=True), + sa.Column("notes", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["closure_id"], ["college_closures.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("closure_id", "work_date", name="uq_closure_work_day"), + ) + op.create_index("ix_closure_work_days_closure_id", "closure_work_days", ["closure_id"]) + op.create_index("ix_closure_work_days_work_date", "closure_work_days", ["work_date"]) + + +def downgrade(): + op.drop_index("ix_closure_work_days_work_date", table_name="closure_work_days") + op.drop_index("ix_closure_work_days_closure_id", table_name="closure_work_days") + op.drop_table("closure_work_days") diff --git a/tests/integration/test_auth_setup.py b/tests/integration/test_auth_setup.py index ee235ba..b2a7077 100644 --- a/tests/integration/test_auth_setup.py +++ b/tests/integration/test_auth_setup.py @@ -129,3 +129,58 @@ def test_setup_imports_embedded_lot_catalog_idempotently(client, app, monkeypatc for name, category_name in LOTS: lot = Lot.query.filter_by(name=name).one() assert lot.category.name == category_name + + +def test_setup_saves_exact_work_days_inside_school_holidays(client, app, monkeypatch, tmp_path): + from datetime import date, time + from app_new.core.models.planning import CollegeClosure, ClosureWorkDay + from app_new.core.services.planning_service import PlanningService + + _authorize_setup(client, monkeypatch, tmp_path) + response = client.post('/setup-wizard/api/step/7', json={ + 'zone': 'C', + 'annee': 2026, + 'import_vacances': True, + 'import_feries': True, + 'vacation_periods': [{ + 'name': 'Petites vacances test wizard', + 'start_date': '2026-10-19', + 'end_date': '2026-10-30', + 'work_days': [ + { + 'work_date': '2026-10-19', 'start_time': '08:00', 'end_time': '16:00', + 'lunch_start': '12:00', 'lunch_end': '12:45', 'notes': 'Début des vacances', + }, + { + 'work_date': '2026-10-30', 'start_time': '09:15', 'end_time': '13:30', + 'lunch_start': '', 'lunch_end': '', 'notes': 'Fin des vacances', + }, + ], + }], + }) + assert response.status_code == 200 + + with app.app_context(): + closure = CollegeClosure.query.filter_by(name='Petites vacances test wizard').one() + assert closure.work_hours_type == 'none' + assert ClosureWorkDay.query.filter_by(closure_id=closure.id).count() == 2 + assert PlanningService.get_working_hours(date(2026, 10, 20)) is None + assert PlanningService.get_working_hours(date(2026, 10, 19)) == ( + time(8, 0), time(16, 0), time(12, 0), time(12, 45) + ) + assert PlanningService.get_working_hours(date(2026, 10, 30)) == ( + time(9, 15), time(13, 30), None, None + ) + + +def test_setup_rejects_work_day_outside_vacation_period(client, monkeypatch, tmp_path): + _authorize_setup(client, monkeypatch, tmp_path) + response = client.post('/setup-wizard/api/step/7', json={ + 'zone': 'C', 'annee': 2026, + 'vacation_periods': [{ + 'name': 'Vacances invalides', 'start_date': '2026-12-20', 'end_date': '2027-01-03', + 'work_days': [{'work_date': '2026-12-10', 'start_time': '08:00', 'end_time': '16:00'}], + }], + }) + assert response.status_code == 400 + assert 'hors de la période' in response.get_json()['error']