2026-08-14 18:02:25 +02:00
|
|
|
"""
|
|
|
|
|
Meters Routes - GMAO Collège
|
|
|
|
|
Gestion des compteurs
|
|
|
|
|
"""
|
2026-08-24 00:55:17 +02:00
|
|
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app, send_file, abort
|
2026-08-14 18:02:25 +02:00
|
|
|
from flask_login import login_required, current_user
|
2026-08-24 00:55:17 +02:00
|
|
|
from datetime import datetime
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
from werkzeug.utils import secure_filename
|
|
|
|
|
from sqlalchemy.orm import joinedload
|
2026-08-14 18:02:25 +02:00
|
|
|
from app_new.extensions import db
|
|
|
|
|
from app_new.core.models.planning import Meter, MeterReading
|
|
|
|
|
from app_new.core.models.equipment import Equipment
|
2026-08-24 00:55:17 +02:00
|
|
|
from app_new.core.models.college import Building, HousingUnit, Room, Zone
|
|
|
|
|
from app_new.core.services.meter_service import (
|
|
|
|
|
MeterDomainError, correct_meter_reading, create_meter, record_meter_reading,
|
|
|
|
|
replace_meter,
|
|
|
|
|
)
|
2026-08-14 18:02:25 +02:00
|
|
|
|
|
|
|
|
meters_bp = Blueprint('meters', __name__, template_folder='templates')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@meters_bp.route('/')
|
|
|
|
|
@login_required
|
|
|
|
|
def index():
|
|
|
|
|
"""Liste de tous les compteurs."""
|
2026-08-24 00:55:17 +02:00
|
|
|
meters = Meter.query.order_by(Meter.name).all()
|
2026-08-14 18:02:25 +02:00
|
|
|
|
|
|
|
|
# Statistiques
|
|
|
|
|
total_count = len(meters)
|
|
|
|
|
warning_count = sum(1 for m in meters if m.warning_level == 'warning')
|
|
|
|
|
critical_count = sum(1 for m in meters if m.warning_level == 'critical')
|
|
|
|
|
maintenance_count = sum(1 for m in meters if m.warning_level == 'maintenance')
|
|
|
|
|
|
|
|
|
|
return render_template('meters/index.html',
|
|
|
|
|
meters=meters,
|
|
|
|
|
total_count=total_count,
|
|
|
|
|
warning_count=warning_count,
|
|
|
|
|
critical_count=critical_count,
|
|
|
|
|
maintenance_count=maintenance_count)
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 00:55:17 +02:00
|
|
|
@meters_bp.route('/new', methods=['GET', 'POST'])
|
|
|
|
|
@login_required
|
|
|
|
|
def new():
|
|
|
|
|
"""Créer un compteur rattaché à un élément patrimoine."""
|
|
|
|
|
if request.method == 'POST':
|
|
|
|
|
try:
|
|
|
|
|
scope_type = request.form.get('scope_type')
|
|
|
|
|
scope_id = request.form.get('scope_id', type=int)
|
|
|
|
|
scope_key = {
|
|
|
|
|
'equipment': 'equipment_id', 'building': 'building_id', 'zone': 'zone_id',
|
|
|
|
|
'room': 'room_id', 'housing_unit': 'housing_unit_id',
|
|
|
|
|
}.get(scope_type)
|
|
|
|
|
if not scope_key or not scope_id:
|
|
|
|
|
raise MeterDomainError('Un rattachement patrimoine est obligatoire.')
|
|
|
|
|
meter = create_meter(
|
|
|
|
|
name=(request.form.get('name') or '').strip(),
|
|
|
|
|
meter_type=request.form.get('meter_type') or 'hours',
|
|
|
|
|
unit=(request.form.get('unit') or '').strip() or 'h',
|
|
|
|
|
usage=(request.form.get('usage') or '').strip() or None,
|
|
|
|
|
initial_value=request.form.get('initial_value', type=float, default=0),
|
|
|
|
|
parent=Meter.query.get(request.form.get('parent_id', type=int)) if request.form.get('parent_id', type=int) else None,
|
|
|
|
|
remainder_label=(request.form.get('remainder_label') or '').strip() or None,
|
|
|
|
|
**{scope_key: scope_id},
|
|
|
|
|
)
|
|
|
|
|
flash(f'Compteur « {meter.name} » créé.', 'success')
|
|
|
|
|
return redirect(url_for('meters.detail', meter_id=meter.id))
|
|
|
|
|
except MeterDomainError as exc:
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
flash(str(exc), 'danger')
|
|
|
|
|
return render_template(
|
|
|
|
|
'meters/new.html', equipments=Equipment.query.filter_by(is_group=False).order_by(Equipment.name).all(),
|
|
|
|
|
buildings=Building.query.order_by(Building.name).all(), zones=Zone.query.order_by(Zone.name).all(),
|
|
|
|
|
rooms=Room.query.order_by(Room.name).all(), housing_units=HousingUnit.query.order_by(HousingUnit.name).all(),
|
|
|
|
|
parents=Meter.query.filter(Meter.status != 'replaced').order_by(Meter.name).all(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 18:02:25 +02:00
|
|
|
@meters_bp.route('/<int:meter_id>')
|
|
|
|
|
@login_required
|
|
|
|
|
def detail(meter_id):
|
|
|
|
|
"""Détails d'un compteur avec historique des relevés."""
|
|
|
|
|
meter = Meter.query.get_or_404(meter_id)
|
2026-08-24 00:55:17 +02:00
|
|
|
readings = MeterReading.query.options(joinedload(MeterReading.read_by)).filter_by(meter_id=meter_id)\
|
2026-08-14 18:02:25 +02:00
|
|
|
.order_by(MeterReading.reading_date.desc()).limit(50).all()
|
|
|
|
|
|
|
|
|
|
return render_template('meters/detail.html', meter=meter, readings=readings)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@meters_bp.route('/<int:meter_id>/reading', methods=['POST'])
|
|
|
|
|
@login_required
|
|
|
|
|
def add_reading(meter_id):
|
|
|
|
|
"""Ajouter un relevé à un compteur."""
|
|
|
|
|
meter = Meter.query.get_or_404(meter_id)
|
|
|
|
|
|
2026-08-15 01:26:22 +02:00
|
|
|
is_reset = request.form.get('is_reset') == '1'
|
|
|
|
|
reset_reason = (request.form.get('reset_reason') or '').strip()
|
2026-08-24 00:55:17 +02:00
|
|
|
photo_filename = photo_path = None
|
|
|
|
|
photo = request.files.get('photo')
|
|
|
|
|
if photo and photo.filename:
|
|
|
|
|
allowed = {'jpg', 'jpeg', 'png', 'gif'}
|
|
|
|
|
if '.' not in photo.filename or photo.filename.rsplit('.', 1)[1].lower() not in allowed:
|
|
|
|
|
flash('La photo doit être une image JPG, PNG ou GIF.', 'danger')
|
|
|
|
|
return redirect(url_for('meters.detail', meter_id=meter_id))
|
|
|
|
|
photo_filename = secure_filename(photo.filename)
|
|
|
|
|
stored_name = f'{uuid4().hex}_{photo_filename}'
|
|
|
|
|
upload_dir = Path(current_app.config.get('UPLOAD_FOLDER', 'uploads')) / 'meter-readings'
|
|
|
|
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
photo_path = str(upload_dir / stored_name)
|
|
|
|
|
photo.save(photo_path)
|
|
|
|
|
photo_filename = stored_name
|
|
|
|
|
try:
|
|
|
|
|
reading = record_meter_reading(
|
|
|
|
|
meter=meter, value=request.form.get('value'), user_id=current_user.id,
|
|
|
|
|
notes=request.form.get('notes', ''), is_reset=is_reset, reset_reason=reset_reason,
|
|
|
|
|
photo_filename=photo_filename, photo_path=photo_path,
|
|
|
|
|
)
|
|
|
|
|
except MeterDomainError as exc:
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
if photo_path:
|
|
|
|
|
Path(photo_path).unlink(missing_ok=True)
|
|
|
|
|
flash(str(exc), 'danger')
|
2026-08-15 01:26:22 +02:00
|
|
|
return redirect(url_for('meters.detail', meter_id=meter_id))
|
2026-08-24 00:55:17 +02:00
|
|
|
flash(f'Relevé enregistré: {reading.value} {meter.unit}', 'success')
|
2026-08-14 18:02:25 +02:00
|
|
|
return redirect(url_for('meters.detail', meter_id=meter_id))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@meters_bp.route('/<int:meter_id>/edit', methods=['GET', 'POST'])
|
|
|
|
|
@login_required
|
|
|
|
|
def edit(meter_id):
|
|
|
|
|
"""Modifier un compteur."""
|
|
|
|
|
from datetime import datetime, timedelta, date
|
|
|
|
|
|
|
|
|
|
meter = Meter.query.get_or_404(meter_id)
|
|
|
|
|
equipments = Equipment.query.filter_by(is_group=False).order_by(Equipment.name).all()
|
|
|
|
|
|
|
|
|
|
if request.method == 'POST':
|
|
|
|
|
meter.name = request.form.get('name')
|
|
|
|
|
meter.meter_type = request.form.get('meter_type', 'hours')
|
|
|
|
|
meter.unit = request.form.get('unit', 'h')
|
|
|
|
|
meter.warning_threshold = request.form.get('warning_threshold', type=float)
|
|
|
|
|
meter.critical_threshold = request.form.get('critical_threshold', type=float)
|
|
|
|
|
meter.maintenance_interval = request.form.get('maintenance_interval', type=float)
|
|
|
|
|
meter.last_maintenance_value = request.form.get('last_maintenance_value', type=float)
|
|
|
|
|
meter.reading_interval_days = request.form.get('reading_interval_days', type=int)
|
2026-08-24 00:55:17 +02:00
|
|
|
# L'index courant est piloté exclusivement par les relevés. Les
|
|
|
|
|
# corrections passent par l'historique afin de rester auditées.
|
2026-08-14 18:02:25 +02:00
|
|
|
|
|
|
|
|
# Date du dernier relevé
|
|
|
|
|
last_reading_date = request.form.get('last_reading_date')
|
|
|
|
|
if last_reading_date:
|
|
|
|
|
meter.last_reading_date = datetime.strptime(last_reading_date, '%Y-%m-%d').date()
|
|
|
|
|
else:
|
|
|
|
|
meter.last_reading_date = None
|
|
|
|
|
|
|
|
|
|
# Équipement
|
2026-08-24 00:55:17 +02:00
|
|
|
scope_type = request.form.get('scope_type')
|
|
|
|
|
scope_id = request.form.get('scope_id', type=int)
|
|
|
|
|
if scope_type and scope_id:
|
|
|
|
|
scope_keys = ('equipment_id', 'building_id', 'zone_id', 'room_id', 'housing_unit_id')
|
|
|
|
|
scope_key = {'equipment': 'equipment_id', 'building': 'building_id', 'zone': 'zone_id', 'room': 'room_id', 'housing_unit': 'housing_unit_id'}.get(scope_type)
|
|
|
|
|
if scope_key:
|
|
|
|
|
for key in scope_keys:
|
|
|
|
|
setattr(meter, key, scope_id if key == scope_key else None)
|
|
|
|
|
meter.usage = (request.form.get('usage') or '').strip() or None
|
|
|
|
|
meter.remainder_label = (request.form.get('remainder_label') or '').strip() or None
|
|
|
|
|
meter.status = request.form.get('status') or meter.status
|
|
|
|
|
try:
|
|
|
|
|
from app_new.core.services.meter_service import validate_meter_scope
|
|
|
|
|
validate_meter_scope(meter)
|
|
|
|
|
except MeterDomainError as exc:
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
flash(str(exc), 'danger')
|
|
|
|
|
return redirect(url_for('meters.edit', meter_id=meter_id))
|
2026-08-14 18:02:25 +02:00
|
|
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
flash('Compteur modifié avec succès', 'success')
|
|
|
|
|
return redirect(url_for('meters.detail', meter_id=meter_id))
|
|
|
|
|
|
2026-08-24 00:55:17 +02:00
|
|
|
return render_template('meters/edit.html',
|
2026-08-14 18:02:25 +02:00
|
|
|
meter=meter,
|
|
|
|
|
equipments=equipments,
|
2026-08-24 00:55:17 +02:00
|
|
|
buildings=Building.query.order_by(Building.name).all(),
|
|
|
|
|
zones=Zone.query.order_by(Zone.name).all(),
|
|
|
|
|
rooms=Room.query.order_by(Room.name).all(),
|
|
|
|
|
housing_units=HousingUnit.query.order_by(HousingUnit.name).all(),
|
2026-08-14 18:02:25 +02:00
|
|
|
timedelta=timedelta,
|
2026-08-15 01:26:22 +02:00
|
|
|
today=date.today())
|
2026-08-24 00:55:17 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@meters_bp.route('/<int:meter_id>/readings/<int:reading_id>/correct', methods=['POST'])
|
|
|
|
|
@login_required
|
|
|
|
|
def correct_reading(meter_id, reading_id):
|
|
|
|
|
reading = MeterReading.query.filter_by(id=reading_id, meter_id=meter_id).first_or_404()
|
|
|
|
|
try:
|
|
|
|
|
correct_meter_reading(
|
|
|
|
|
reading=reading, new_value=request.form.get('value'),
|
|
|
|
|
user_id=current_user.id, reason=request.form.get('reason'),
|
|
|
|
|
)
|
|
|
|
|
flash('Relevé corrigé et correction auditée.', 'success')
|
|
|
|
|
except MeterDomainError as exc:
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
flash(str(exc), 'danger')
|
|
|
|
|
return redirect(url_for('meters.detail', meter_id=meter_id))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@meters_bp.route('/<int:meter_id>/readings/<int:reading_id>/photo')
|
|
|
|
|
@login_required
|
|
|
|
|
def reading_photo(meter_id, reading_id):
|
|
|
|
|
reading = MeterReading.query.filter_by(id=reading_id, meter_id=meter_id).first_or_404()
|
|
|
|
|
upload_root = Path(current_app.config.get('UPLOAD_FOLDER', 'uploads')).resolve()
|
|
|
|
|
photo_path = Path(reading.photo_path or '').resolve()
|
|
|
|
|
if not reading.photo_path or upload_root not in photo_path.parents or not photo_path.is_file():
|
|
|
|
|
abort(404)
|
|
|
|
|
return send_file(photo_path, download_name=reading.photo_filename or photo_path.name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@meters_bp.route('/<int:meter_id>/replace', methods=['POST'])
|
|
|
|
|
@login_required
|
|
|
|
|
def replace(meter_id):
|
|
|
|
|
old_meter = Meter.query.get_or_404(meter_id)
|
|
|
|
|
try:
|
|
|
|
|
new_meter = replace_meter(
|
|
|
|
|
old_meter=old_meter, name=(request.form.get('name') or '').strip(),
|
|
|
|
|
user_id=current_user.id, meter_type=request.form.get('meter_type'),
|
|
|
|
|
unit=request.form.get('unit'), usage=request.form.get('usage'),
|
|
|
|
|
)
|
|
|
|
|
flash(f'Compteur remplacé par « {new_meter.name} ».', 'success')
|
|
|
|
|
except MeterDomainError as exc:
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
flash(str(exc), 'danger')
|
|
|
|
|
return redirect(url_for('meters.detail', meter_id=meter_id))
|