230 lines
12 KiB
Python
230 lines
12 KiB
Python
|
|
from datetime import date, time
|
||
|
|
from time import perf_counter
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from sqlalchemy import event
|
||
|
|
|
||
|
|
from app_new import db
|
||
|
|
from app_new.core.models.college import Building, HousingUnit
|
||
|
|
from app_new.core.models.planning import (
|
||
|
|
Meter, MeterReading, MeterReadingOccurrence, MeterReadingRoundOccurrence,
|
||
|
|
MeterReadingSchedule, WorkSchedule,
|
||
|
|
)
|
||
|
|
from app_new.core.services.meter_reading_planning import (
|
||
|
|
CANCELLED, TREATED_READING, TREATED_WITHOUT_READING, add_round_member,
|
||
|
|
create_or_update_schedule, create_round, generate_occurrences,
|
||
|
|
operational_date_for, record_occurrence_reading, record_occurrence_without_reading,
|
||
|
|
)
|
||
|
|
from app_new.core.services.meter_service import create_meter, replace_meter
|
||
|
|
|
||
|
|
|
||
|
|
def _meter(name, building=None, housing=None):
|
||
|
|
if building is None:
|
||
|
|
building = Building(name=f"TEST_UI_C2_{name}_BUILDING")
|
||
|
|
db.session.add(building)
|
||
|
|
db.session.flush()
|
||
|
|
return create_meter(name=f"TEST_UI_C2_{name}", building_id=building.id if housing is None else None, housing_unit_id=housing.id if housing else None)
|
||
|
|
|
||
|
|
|
||
|
|
def _schedule(meter, admin_user, frequency, reference, **kwargs):
|
||
|
|
return create_or_update_schedule(
|
||
|
|
meter=meter, frequency=frequency, reference_date=reference,
|
||
|
|
assigned_to_id=admin_user["id"], **kwargs,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_c2_frequencies_and_generation_are_idempotent(app, admin_user):
|
||
|
|
with app.app_context():
|
||
|
|
frequencies = {
|
||
|
|
"WEEKLY": date(2026, 10, 5), "MONTHLY": date(2026, 10, 1),
|
||
|
|
"SEMI_ANNUAL": date(2026, 10, 1), "ANNUAL": date(2026, 10, 1),
|
||
|
|
"FIXED_ANNUAL_DATE": date(2026, 10, 1),
|
||
|
|
}
|
||
|
|
schedules = []
|
||
|
|
for frequency, reference in frequencies.items():
|
||
|
|
meter = _meter(f"FREQ_{frequency}")
|
||
|
|
schedules.append(_schedule(meter, admin_user, frequency, reference, fixed_month=10, fixed_day=1))
|
||
|
|
db.session.commit()
|
||
|
|
first = generate_occurrences(start_date=date(2026, 10, 1), end_date=date(2027, 10, 10))
|
||
|
|
counts = {schedule.id: MeterReadingOccurrence.query.filter_by(schedule_id=schedule.id).count() for schedule in schedules}
|
||
|
|
second = generate_occurrences(start_date=date(2026, 10, 1), end_date=date(2027, 10, 10))
|
||
|
|
assert first
|
||
|
|
assert second == []
|
||
|
|
assert all(MeterReadingOccurrence.query.filter_by(schedule_id=schedule.id).count() == counts[schedule.id] for schedule in schedules)
|
||
|
|
assert counts[schedules[0].id] == 53
|
||
|
|
assert counts[schedules[1].id] == 13
|
||
|
|
assert counts[schedules[2].id] == 3
|
||
|
|
assert counts[schedules[3].id] == 2
|
||
|
|
assert counts[schedules[4].id] == 2
|
||
|
|
|
||
|
|
|
||
|
|
def test_c2_target_and_operational_dates_and_housing_calendar(app, admin_user):
|
||
|
|
with app.app_context():
|
||
|
|
building = Building(name="TEST_UI_C2_CALENDAR_BUILDING")
|
||
|
|
housing = HousingUnit(name="TEST_UI_C2_HOUSING", building=building)
|
||
|
|
db.session.add_all([building, housing])
|
||
|
|
db.session.flush()
|
||
|
|
school_meter = _meter("SCHOOL_DATE", building=building)
|
||
|
|
schedule = _schedule(school_meter, admin_user, "FIXED_ANNUAL_DATE", date(2026, 10, 3))
|
||
|
|
db.session.add(WorkSchedule(user_id=admin_user["id"], day_of_week=4, start_time=time(8), end_time=time(17), is_active=True))
|
||
|
|
db.session.flush()
|
||
|
|
generate_occurrences(start_date=date(2026, 10, 3), end_date=date(2026, 10, 3), schedule_ids=[schedule.id])
|
||
|
|
occurrence = MeterReadingOccurrence.query.filter_by(schedule_id=schedule.id).one()
|
||
|
|
assert occurrence.target_date == date(2026, 10, 3)
|
||
|
|
assert occurrence.operational_date == date(2026, 10, 2)
|
||
|
|
|
||
|
|
housing_meter = _meter("HOUSING_DATE", housing=housing)
|
||
|
|
housing_schedule = _schedule(housing_meter, admin_user, "ANNUAL", date(2026, 10, 3))
|
||
|
|
generate_occurrences(start_date=date(2026, 10, 3), end_date=date(2026, 10, 3), schedule_ids=[housing_schedule.id])
|
||
|
|
housing_occurrence = MeterReadingOccurrence.query.filter_by(schedule_id=housing_schedule.id).one()
|
||
|
|
assert housing_occurrence.operational_date == date(2026, 10, 3)
|
||
|
|
|
||
|
|
|
||
|
|
def test_c2_processing_with_reading_and_without_reading_never_creates_zero(app, admin_user):
|
||
|
|
with app.app_context():
|
||
|
|
meter = _meter("PROCESSING")
|
||
|
|
schedule = _schedule(meter, admin_user, "MONTHLY", date(2026, 10, 1))
|
||
|
|
generate_occurrences(start_date=date(2026, 10, 1), end_date=date(2026, 10, 1), schedule_ids=[schedule.id])
|
||
|
|
occurrence = MeterReadingOccurrence.query.filter_by(schedule_id=schedule.id).one()
|
||
|
|
processed = record_occurrence_reading(occurrence_id=occurrence.id, value=42, user_id=admin_user["id"])
|
||
|
|
assert processed.status == TREATED_READING
|
||
|
|
assert MeterReading.query.filter_by(meter_id=meter.id).count() == 1
|
||
|
|
|
||
|
|
other = _meter("WITHOUT")
|
||
|
|
other_schedule = _schedule(other, admin_user, "MONTHLY", date(2026, 10, 1))
|
||
|
|
generate_occurrences(start_date=date(2026, 10, 1), end_date=date(2026, 10, 1), schedule_ids=[other_schedule.id])
|
||
|
|
other_occurrence = MeterReadingOccurrence.query.filter_by(schedule_id=other_schedule.id).one()
|
||
|
|
without = record_occurrence_without_reading(
|
||
|
|
occurrence_id=other_occurrence.id, reason="OCCUPANT_REFUSED", user_id=admin_user["id"], comment="TEST_UI_C2 refus"
|
||
|
|
)
|
||
|
|
assert without.status == TREATED_WITHOUT_READING
|
||
|
|
assert without.comment == "TEST_UI_C2 refus"
|
||
|
|
assert MeterReading.query.filter_by(meter_id=other.id).count() == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_c2_http_occurrence_actions_use_shared_service(app, admin_user, authenticated_client):
|
||
|
|
with app.app_context():
|
||
|
|
meter = _meter("HTTP_READING")
|
||
|
|
schedule = _schedule(meter, admin_user, "MONTHLY", date(2026, 10, 1))
|
||
|
|
generate_occurrences(start_date=date(2026, 10, 1), end_date=date(2026, 10, 1), schedule_ids=[schedule.id])
|
||
|
|
occurrence = MeterReadingOccurrence.query.filter_by(schedule_id=schedule.id).one()
|
||
|
|
|
||
|
|
response = authenticated_client.post(
|
||
|
|
f"/planning/meter-occurrences/{occurrence.id}/reading",
|
||
|
|
data={"value": "123.5", "notes": "TEST_UI_C2_HTTP"},
|
||
|
|
)
|
||
|
|
assert response.status_code == 302
|
||
|
|
db.session.refresh(occurrence)
|
||
|
|
assert occurrence.status == TREATED_READING
|
||
|
|
assert MeterReading.query.filter_by(meter_id=meter.id).count() == 1
|
||
|
|
|
||
|
|
other = _meter("HTTP_WITHOUT")
|
||
|
|
other_schedule = _schedule(other, admin_user, "MONTHLY", date(2026, 10, 1))
|
||
|
|
generate_occurrences(start_date=date(2026, 10, 1), end_date=date(2026, 10, 1), schedule_ids=[other_schedule.id])
|
||
|
|
other_occurrence = MeterReadingOccurrence.query.filter_by(schedule_id=other_schedule.id).one()
|
||
|
|
response = authenticated_client.post(
|
||
|
|
f"/planning/meter-occurrences/{other_occurrence.id}/without-reading",
|
||
|
|
data={"reason": "READING_UNKNOWN", "comment": "TEST_UI_C2_HTTP inconnu"},
|
||
|
|
)
|
||
|
|
assert response.status_code == 302
|
||
|
|
db.session.refresh(other_occurrence)
|
||
|
|
assert other_occurrence.status == TREATED_WITHOUT_READING
|
||
|
|
assert other_occurrence.no_reading_reason == "READING_UNKNOWN"
|
||
|
|
assert MeterReading.query.filter_by(meter_id=other.id).count() == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_c2_open_occurrence_stays_visible_as_overdue_in_my_day(app, admin_user, authenticated_client):
|
||
|
|
with app.app_context():
|
||
|
|
target = date(2026, 10, 5)
|
||
|
|
meter = _meter("MY_DAY")
|
||
|
|
_schedule(meter, admin_user, "WEEKLY", date(2026, 9, 28), duration_minutes=5)
|
||
|
|
db.session.add(WorkSchedule(user_id=admin_user["id"], day_of_week=0, start_time=time(8), end_time=time(17), is_active=True))
|
||
|
|
db.session.commit()
|
||
|
|
response = authenticated_client.get(f"/planning/my-day?date={target.isoformat()}")
|
||
|
|
assert response.status_code == 200
|
||
|
|
assert b"Relev\xc3\xa9 compteur" in response.data
|
||
|
|
assert b"Saisir le relev\xc3\xa9" in response.data
|
||
|
|
|
||
|
|
|
||
|
|
def test_c2_replaced_meter_cancels_open_occurrences(app, admin_user):
|
||
|
|
with app.app_context():
|
||
|
|
meter = _meter("REPLACED")
|
||
|
|
schedule = _schedule(meter, admin_user, "MONTHLY", date(2026, 10, 1))
|
||
|
|
generate_occurrences(start_date=date(2026, 10, 1), end_date=date(2026, 10, 1), schedule_ids=[schedule.id])
|
||
|
|
old_occurrence = MeterReadingOccurrence.query.filter_by(schedule_id=schedule.id).one()
|
||
|
|
replace_meter(old_meter=meter, name="TEST_UI_C2_REPLACEMENT", user_id=admin_user["id"])
|
||
|
|
generate_occurrences(start_date=date(2026, 10, 1), end_date=date(2027, 1, 1))
|
||
|
|
db.session.refresh(old_occurrence)
|
||
|
|
assert old_occurrence.status == CANCELLED
|
||
|
|
assert MeterReadingOccurrence.query.filter_by(schedule_id=schedule.id, status="À FAIRE").count() == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_c2_round_order_progress_and_resume(app, admin_user, authenticated_client):
|
||
|
|
with app.app_context():
|
||
|
|
first = _meter("ROUND_1")
|
||
|
|
second = _meter("ROUND_2")
|
||
|
|
first_schedule = _schedule(first, admin_user, "MONTHLY", date(2026, 10, 1))
|
||
|
|
second_schedule = _schedule(second, admin_user, "MONTHLY", date(2026, 10, 1))
|
||
|
|
round_ = create_round(name="TEST_UI_C2_TOURNEE", default_assigned_to_id=admin_user["id"])
|
||
|
|
add_round_member(round_=round_, schedule=first_schedule, position=1, commit=False)
|
||
|
|
add_round_member(round_=round_, schedule=second_schedule, position=2, commit=False)
|
||
|
|
db.session.commit()
|
||
|
|
generate_occurrences(start_date=date(2026, 10, 1), end_date=date(2026, 10, 1))
|
||
|
|
round_occurrence = MeterReadingRoundOccurrence.query.filter_by(round_id=round_.id, operational_date=date(2026, 10, 1)).one()
|
||
|
|
assert [item.schedule.meter.name for item in sorted(round_occurrence.occurrences, key=lambda item: item.schedule.meter.name)]
|
||
|
|
response = authenticated_client.get(f"/planning/meter-rounds/{round_.id}?date=2026-10-01")
|
||
|
|
assert response.status_code == 200
|
||
|
|
assert b"TEST_UI_C2_TOURNEE" in response.data
|
||
|
|
assert b"0 / 2" in response.data
|
||
|
|
record_occurrence_without_reading(
|
||
|
|
occurrence_id=round_occurrence.occurrences[0].id, reason="READING_UNKNOWN", user_id=admin_user["id"]
|
||
|
|
)
|
||
|
|
db.session.refresh(round_occurrence)
|
||
|
|
assert round_occurrence.status == "EN COURS"
|
||
|
|
|
||
|
|
|
||
|
|
def test_c2_permissions_keep_configuration_admin_only(app, client):
|
||
|
|
response = client.get('/planning/meter-schedules/new')
|
||
|
|
assert response.status_code in (302, 403)
|
||
|
|
|
||
|
|
|
||
|
|
def test_c2_planning_sql_smoke_is_measured(app, admin_user, authenticated_client):
|
||
|
|
with app.app_context():
|
||
|
|
schedules = []
|
||
|
|
for index in range(3):
|
||
|
|
meter = _meter(f"PERF_{index}")
|
||
|
|
schedules.append(_schedule(meter, admin_user, "MONTHLY", date(2026, 10, 1)))
|
||
|
|
round_ = create_round(name="TEST_UI_C2_PERF_ROUND", default_assigned_to_id=admin_user["id"])
|
||
|
|
for position, schedule in enumerate(schedules, 1):
|
||
|
|
add_round_member(round_=round_, schedule=schedule, position=position, commit=False)
|
||
|
|
db.session.commit()
|
||
|
|
|
||
|
|
query_count = 0
|
||
|
|
|
||
|
|
def count_queries(*_args, **_kwargs):
|
||
|
|
nonlocal query_count
|
||
|
|
query_count += 1
|
||
|
|
|
||
|
|
event.listen(db.engine, "before_cursor_execute", count_queries)
|
||
|
|
try:
|
||
|
|
start = perf_counter()
|
||
|
|
daily = authenticated_client.get("/planning/my-day?date=2026-10-01")
|
||
|
|
daily_seconds = perf_counter() - start
|
||
|
|
daily_queries = query_count
|
||
|
|
query_count = 0
|
||
|
|
start = perf_counter()
|
||
|
|
round_response = authenticated_client.get(
|
||
|
|
f"/planning/meter-rounds/{round_.id}?date=2026-10-01"
|
||
|
|
)
|
||
|
|
round_seconds = perf_counter() - start
|
||
|
|
round_queries = query_count
|
||
|
|
finally:
|
||
|
|
event.remove(db.engine, "before_cursor_execute", count_queries)
|
||
|
|
|
||
|
|
print(
|
||
|
|
f"C2_PERF my_day_seconds={daily_seconds:.4f} my_day_queries={daily_queries} "
|
||
|
|
f"round_seconds={round_seconds:.4f} round_queries={round_queries}"
|
||
|
|
)
|
||
|
|
assert daily.status_code == 200
|
||
|
|
assert round_response.status_code == 200
|