Gère les présences pendant les vacances scolaires
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run

This commit is contained in:
root 2026-08-14 18:43:10 +00:00
parent 3622f0d045
commit b051930431
9 changed files with 331 additions and 22 deletions

View file

@ -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,7 +25,7 @@ __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',

View file

@ -72,6 +72,37 @@ class ClosureSchedule(db.Model):
return f"<ClosureSchedule {days.get(self.day_of_week)} {self.start_time}-{self.end_time}>"
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"<ClosureWorkDay {self.work_date} {self.start_time}-{self.end_time}>"
class PersonalLeave(db.Model):
"""Congés personnels."""
__tablename__ = "personal_leaves"

View file

@ -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.

View file

@ -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

View file

@ -163,6 +163,14 @@
{% else %}
<span class="badge bg-secondary">-</span>
{% endif %}
{% if closure.work_days %}
<div class="small mt-1">
<span class="badge bg-success">{{ closure.work_days|length }} présence(s)</span>
{% for day in closure.work_days %}
<div>{{ day.work_date.strftime('%d/%m') }} : {{ day.start_time.strftime('%H:%M') }}{{ day.end_time.strftime('%H:%M') }}</div>
{% endfor %}
</div>
{% endif %}
</td>
<td>{{ closure.notes or '-' }}</td>
<td>

View file

@ -71,10 +71,16 @@
<div id="step7" class="step-panel d-none">
<h5>Calendrier</h5>
<div class="alert alert-info">
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.
</div>
<div class="row"><div class="col-md-6"><label class="form-label" for="academiqueZone">Zone académique</label><select class="form-select" id="academiqueZone"><option value="A">Zone A</option><option value="B">Zone B</option><option value="C">Zone C</option></select></div>
<div class="col-md-6"><label class="form-label" for="anneeScolaire">Année de début</label><input type="number" class="form-control" id="anneeScolaire" min="2024" value="{{ progress.data.vacances_annee or 2026 }}"></div></div>
<div class="form-check mt-3"><input class="form-check-input" type="checkbox" id="importAutoVacances" checked><label class="form-check-label" for="importAutoVacances">Importer les vacances officielles lorsquun importeur sera configuré</label></div>
<div class="form-check mt-3"><input class="form-check-input" type="checkbox" id="importAutoVacances" checked><label class="form-check-label" for="importAutoVacances">Importer les dates officielles dès que limporteur académique sera disponible</label></div>
<div class="form-check"><input class="form-check-input" type="checkbox" id="importAutoFeries" checked><label class="form-check-label" for="importAutoFeries">Importer les jours fériés lorsquun importeur sera configuré</label></div>
<hr>
<div class="d-flex justify-content-between align-items-center mb-2"><h6 class="mb-0">Périodes de vacances et présences exceptionnelles</h6><button type="button" class="btn btn-outline-primary btn-sm" onclick="addVacationPeriod()"><i class="bi bi-plus-circle"></i> Ajouter une période</button></div>
<div id="vacationPeriodsList"></div>
</div>
<div id="step8" class="step-panel d-none"><h5>ENT</h5><div class="alert alert-secondary">Configuration facultative. Elle sera réalisée depuis la page dédiée après linstallation ; tant quelle est absente, son watchdog reste arrêté.</div></div>
@ -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 = '<i class="bi bi-calendar-plus"></i> 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 = '<thead><tr><th>Date</th><th>Début</th><th>Fin</th><th>Pause début</th><th>Pause fin</th><th>Note</th><th></th></tr></thead>';
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 limport 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);
</script>
{% endblock %}

View file

@ -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 ;

View file

@ -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")

View file

@ -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']