Gère les présences pendant les vacances scolaires
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
This commit is contained in:
parent
3622f0d045
commit
b051930431
9 changed files with 331 additions and 22 deletions
|
|
@ -11,7 +11,7 @@ from .maintenance import (
|
||||||
)
|
)
|
||||||
from .company import Company, Part, Alert, Service
|
from .company import Company, Part, Alert, Service
|
||||||
from .planning import (
|
from .planning import (
|
||||||
WorkSchedule, CollegeClosure, PersonalLeave, Training, TrainingParticipant,
|
WorkSchedule, CollegeClosure, ClosureSchedule, ClosureWorkDay, PersonalLeave, Training, TrainingParticipant,
|
||||||
Meter, MeterReading, Consumable, ConsumableUsage, EquipmentConsumable,
|
Meter, MeterReading, Consumable, ConsumableUsage, EquipmentConsumable,
|
||||||
PreventiveTask, PreventiveTaskConsumable, ScheduledTask,
|
PreventiveTask, PreventiveTaskConsumable, ScheduledTask,
|
||||||
TechnicianAvailability, AdminTask, ZoneAccessRule
|
TechnicianAvailability, AdminTask, ZoneAccessRule
|
||||||
|
|
@ -25,7 +25,7 @@ __all__ = [
|
||||||
'Intervention', 'StatusChange', 'InterventionComment', 'InterventionDocument',
|
'Intervention', 'StatusChange', 'InterventionComment', 'InterventionDocument',
|
||||||
'Lot', 'LotTask', 'LotService',
|
'Lot', 'LotTask', 'LotService',
|
||||||
'Company', 'Part', 'InterventionPart', 'Alert', 'Service',
|
'Company', 'Part', 'InterventionPart', 'Alert', 'Service',
|
||||||
'WorkSchedule', 'CollegeClosure', 'PersonalLeave', 'Training', 'TrainingParticipant',
|
'WorkSchedule', 'CollegeClosure', 'ClosureSchedule', 'ClosureWorkDay', 'PersonalLeave', 'Training', 'TrainingParticipant',
|
||||||
'Meter', 'MeterReading', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable',
|
'Meter', 'MeterReading', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable',
|
||||||
'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask',
|
'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask',
|
||||||
'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule',
|
'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule',
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,37 @@ class ClosureSchedule(db.Model):
|
||||||
return f"<ClosureSchedule {days.get(self.day_of_week)} {self.start_time}-{self.end_time}>"
|
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):
|
class PersonalLeave(db.Model):
|
||||||
"""Congés personnels."""
|
"""Congés personnels."""
|
||||||
__tablename__ = "personal_leaves"
|
__tablename__ = "personal_leaves"
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from ..models.user import User
|
||||||
from ..models.college import College, Building, Zone, Room
|
from ..models.college import College, Building, Zone, Room
|
||||||
from ..models.equipment import EquipmentCategory
|
from ..models.equipment import EquipmentCategory
|
||||||
from ..models.maintenance import Lot
|
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
|
from ..setup_catalog import EQUIPMENT_CATEGORIES, LOTS, grouped_catalog
|
||||||
|
|
||||||
setup_wizard_bp = Blueprint('setup_wizard', __name__, url_prefix='/setup-wizard')
|
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()
|
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 {
|
return {
|
||||||
'step': self.step,
|
'step': self.step,
|
||||||
'data': {
|
'data': {
|
||||||
|
|
@ -162,6 +184,7 @@ class SetupProgress(db.Model):
|
||||||
'categories': json.loads(self.categories_json) if self.categories_json else [],
|
'categories': json.loads(self.categories_json) if self.categories_json else [],
|
||||||
'vacances_zone': self.vacances_zone or '',
|
'vacances_zone': self.vacances_zone or '',
|
||||||
'vacances_annee': self.vacances_annee,
|
'vacances_annee': self.vacances_annee,
|
||||||
|
'vacation_periods': vacation_periods,
|
||||||
'ent_configured': self.ent_configured,
|
'ent_configured': self.ent_configured,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -432,24 +455,98 @@ def api_save_step(step):
|
||||||
elif step == 7:
|
elif step == 7:
|
||||||
# Étape 7 : Vacances et horaires
|
# Étape 7 : Vacances et horaires
|
||||||
zone = data.get('zone', 'A')
|
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_vacances = data.get('import_vacances', False)
|
||||||
import_feries = data.get('import_feries', False)
|
import_feries = data.get('import_feries', False)
|
||||||
work_schedule = data.get('work_schedule', {})
|
vacation_periods = data.get('vacation_periods', [])
|
||||||
custom_closures = data.get('custom_closures', [])
|
if not isinstance(vacation_periods, list):
|
||||||
personal_leaves = data.get('personal_leaves', [])
|
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_zone = zone
|
||||||
progress.vacances_annee = annee
|
progress.vacances_annee = annee
|
||||||
progress.vacations_json = json.dumps({
|
progress.vacations_json = json.dumps({
|
||||||
'work_schedule': work_schedule,
|
'import_vacances': bool(import_vacances),
|
||||||
'custom_closures': custom_closures,
|
'import_feries': bool(import_feries),
|
||||||
'personal_leaves': personal_leaves
|
'vacation_periods': saved_periods,
|
||||||
})
|
}, ensure_ascii=False)
|
||||||
progress.step = max(progress.step or 0, 7)
|
progress.step = max(progress.step or 0, 7)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return jsonify({'success': True, 'step': 7})
|
return jsonify({'success': True, 'step': 7, 'vacation_periods': saved_periods})
|
||||||
|
|
||||||
elif step in (8, 9):
|
elif step in (8, 9):
|
||||||
# Intégrations facultatives : le paramétrage complet reste dans leurs pages dédiées.
|
# Intégrations facultatives : le paramétrage complet reste dans leurs pages dédiées.
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from typing import List, Dict, Optional, Tuple
|
||||||
from app_new.extensions import db
|
from app_new.extensions import db
|
||||||
from app_new.core.models.planning import (
|
from app_new.core.models.planning import (
|
||||||
ScheduledTask, AdminTask, WorkSchedule, CollegeClosure,
|
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.maintenance import Intervention
|
||||||
from app_new.core.models.equipment import Equipment
|
from app_new.core.models.equipment import Equipment
|
||||||
|
|
@ -126,6 +126,20 @@ class PlanningService:
|
||||||
day_of_week = d.weekday() # 0=lundi, 6=dimanche
|
day_of_week = d.weekday() # 0=lundi, 6=dimanche
|
||||||
|
|
||||||
if closure:
|
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':
|
if closure.work_hours_type == 'none':
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,14 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary">-</span>
|
<span class="badge bg-secondary">-</span>
|
||||||
{% endif %}
|
{% 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>
|
||||||
<td>{{ closure.notes or '-' }}</td>
|
<td>{{ closure.notes or '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
|
||||||
|
|
@ -71,10 +71,16 @@
|
||||||
|
|
||||||
<div id="step7" class="step-panel d-none">
|
<div id="step7" class="step-panel d-none">
|
||||||
<h5>Calendrier</h5>
|
<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="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="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 lorsqu’un 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 l’importeur 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 lorsqu’un importeur sera configuré</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 lorsqu’un 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>
|
||||||
|
|
||||||
<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 l’installation ; tant qu’elle est absente, son watchdog reste arrêté.</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 l’installation ; tant qu’elle est absente, son watchdog reste arrêté.</div></div>
|
||||||
|
|
@ -97,6 +103,7 @@ const setupData = {{ progress.data|tojson }};
|
||||||
let buildings = setupData.buildings || [];
|
let buildings = setupData.buildings || [];
|
||||||
let zones = setupData.zones || [];
|
let zones = setupData.zones || [];
|
||||||
let rooms = setupData.rooms || [];
|
let rooms = setupData.rooms || [];
|
||||||
|
let vacationPeriods = setupData.vacation_periods || [];
|
||||||
|
|
||||||
function field(value, placeholder, type = 'text') {
|
function field(value, placeholder, type = 'text') {
|
||||||
const input = document.createElement('input');
|
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 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 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) {
|
function showStep(step) {
|
||||||
displayStep = step;
|
displayStep = step;
|
||||||
document.querySelectorAll('.step-panel').forEach((panel, index) => panel.classList.toggle('d-none', index + 1 !== 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('prevBtn').classList.toggle('invisible', step === 1);
|
||||||
document.getElementById('nextBtn').classList.toggle('d-none', step === totalSteps);
|
document.getElementById('nextBtn').classList.toggle('d-none', step === totalSteps);
|
||||||
document.getElementById('finishBtn').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) {
|
async function saveStep(step, data) {
|
||||||
|
|
@ -195,7 +253,7 @@ async function nextStep() {
|
||||||
} else if (displayStep === 6) {
|
} else if (displayStep === 6) {
|
||||||
result = await saveStep(6, {});
|
result = await saveStep(6, {});
|
||||||
} else if (displayStep === 7) {
|
} 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 {
|
} else {
|
||||||
result = await saveStep(displayStep, {skipped: displayStep === 8 || displayStep === 9});
|
result = await saveStep(displayStep, {skipped: displayStep === 8 || displayStep === 9});
|
||||||
}
|
}
|
||||||
|
|
@ -212,6 +270,6 @@ async function finishSetup() {
|
||||||
} catch (error) { alert(error.message); }
|
} catch (error) { alert(error.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
renderBuildings(); renderZones(); renderRooms(); showStep(displayStep);
|
renderBuildings(); renderZones(); renderRooms(); renderVacationPeriods(); showStep(displayStep);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -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 ;
|
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é.
|
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
|
## Priorités bloquantes
|
||||||
|
|
||||||
### P0 — Sécurité et permissions
|
### 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 ;
|
- migrer `setup_progress.step` et déplacer le modèle dans les modèles/migrations ;
|
||||||
- ajouter CSRF au wizard après autorisation par jeton ;
|
- ajouter CSRF au wizard après autorisation par jeton ;
|
||||||
- persister réellement horaires, vacances et jours fériés ;
|
- 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 ;
|
- supprimer les endpoints de test ENT/Pronote retournant 501 ou les implémenter ;
|
||||||
- retirer les anciens templates `setup_wizard/admin.html` et routes imaginaires ;
|
- 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 ;
|
- 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 ;
|
- protéger les routes `/scheduler/run`, création mensuelle et changements de tâches ;
|
||||||
- réactiver CSRF sur le blueprint scheduler ;
|
- réactiver CSRF sur le blueprint scheduler ;
|
||||||
- afficher charge disponible, congés, fermetures et temps prévention réservé.
|
- 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/*`
|
### 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 ;
|
- tous les liens des templates actifs ciblent un endpoint existant ;
|
||||||
- bâtiments, zones, salles, groupes et unités conservent leurs quantités et historiques ;
|
- 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 ;
|
- 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 ;
|
- réparation, déplacement, réforme et récurrence sont testés ;
|
||||||
- sauvegarde et restauration MariaDB + documents + configuration sont testées ;
|
- sauvegarde et restauration MariaDB + documents + configuration sont testées ;
|
||||||
- les secrets ne sont ni affichés, ni journalisés, ni commités ;
|
- les secrets ne sont ni affichés, ni journalisés, ni commités ;
|
||||||
|
|
|
||||||
41
migrations/versions/f1a5b6c7d8e9_add_closure_work_days.py
Normal file
41
migrations/versions/f1a5b6c7d8e9_add_closure_work_days.py
Normal 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")
|
||||||
|
|
@ -129,3 +129,58 @@ def test_setup_imports_embedded_lot_catalog_idempotently(client, app, monkeypatc
|
||||||
for name, category_name in LOTS:
|
for name, category_name in LOTS:
|
||||||
lot = Lot.query.filter_by(name=name).one()
|
lot = Lot.query.filter_by(name=name).one()
|
||||||
assert lot.category.name == category_name
|
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']
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue