ScoDoc/app/scodoc/sco_abs_notification.py

326 lines
11 KiB
Python

# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""Système de notification par mail des excès d'absences
(see ticket #147)
Il suffit d'appeler abs_notify() après chaque ajout d'absence.
"""
import datetime
from typing import Optional
from flask import current_app, g, url_for
from flask_mail import Message
from app import db
from app import email
from app import log
from app.auth.models import User
from app.models.absences import AbsenceNotification
from app.models.events import Scolog
from app.models.formsemestre import FormSemestre
import app.scodoc.notesdb as ndb
from app.scodoc import sco_etud
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
def abs_notify(etudid: int, date: str | datetime.datetime):
"""Check if notifications are requested and send them
Considère le nombre d'absence dans le semestre courant
(s'il n'y a pas de semestre courant, ne fait rien,
car l'etudiant n'est pas inscrit au moment de l'absence!).
NE FAIT RIEN EN MODE DEBUG.
"""
from app.scodoc import sco_assiduites
# if current_app and current_app.config["DEBUG"]:
# return
formsemestre = retreive_current_formsemestre(etudid, date)
if not formsemestre:
return # non inscrit a la date, pas de notification
nbabs, nbabsjust = sco_assiduites.get_assiduites_count_in_interval(
etudid,
metrique=scu.translate_assiduites_metric(
sco_preferences.get_preference(
"assi_metrique", formsemestre.formsemestre_id
)
),
date_debut=datetime.datetime.combine(
formsemestre.date_debut, datetime.datetime.min.time()
),
date_fin=datetime.datetime.combine(
formsemestre.date_fin, datetime.datetime.min.time()
),
)
do_abs_notify(formsemestre, etudid, date, nbabs, nbabsjust)
def do_abs_notify(
formsemestre: FormSemestre,
etudid: int,
date: str | datetime.datetime,
nbabs: int,
nbabsjust: int,
):
"""Given new counts of absences, check if notifications are requested and send them."""
# prefs fallback to global pref if sem is None:
if formsemestre:
formsemestre_id = formsemestre.id
else:
formsemestre_id = None
prefs = sco_preferences.SemPreferences(formsemestre_id=formsemestre_id)
destinations = abs_notify_get_destinations(
formsemestre, prefs, etudid, date, nbabs, nbabsjust
)
msg = abs_notification_message(formsemestre, prefs, etudid, nbabs, nbabsjust)
if not msg:
return # abort
# Vérification fréquence (pour ne pas envoyer de mails trop souvent)
# TODO Mettre la fréquence dans les préférences assiduités
abs_notify_max_freq = sco_preferences.get_preference("abs_notify_max_freq")
destinations_filtered = []
for email_addr in destinations:
nbdays_since_last_notif = user_nbdays_since_last_notif(email_addr, etudid)
if (nbdays_since_last_notif is None) or (
nbdays_since_last_notif >= abs_notify_max_freq
):
destinations_filtered.append(email_addr)
if destinations_filtered:
abs_notify_send(
destinations_filtered,
etudid,
msg,
nbabs,
nbabsjust,
formsemestre_id,
)
def abs_notify_send(destinations, etudid, msg, nbabs, nbabsjust, formsemestre_id):
"""Actually send the notification by email, and register it in database"""
log(f"abs_notify: sending notification to {destinations}")
for dest_addr in destinations:
msg.recipients = [dest_addr]
email.send_message(msg)
notification = AbsenceNotification(
etudid=etudid,
email=dest_addr,
nbabs=nbabs,
nbabsjust=nbabsjust,
formsemestre_id=formsemestre_id,
)
db.session.add(notification)
Scolog.logdb(
method="abs_notify",
etudid=etudid,
msg=f"sent to {destinations} (nbabs={nbabs})",
commit=True,
)
def abs_notify_get_destinations(
formsemestre: FormSemestre,
prefs: dict,
etudid: int,
date: str | datetime.datetime,
nbabs: int,
nbabsjust: int,
) -> set[str]:
"""Returns set of destination emails to be notified"""
destinations = [] # list of email address to notify
if abs_notify_is_above_threshold(etudid, nbabs, nbabsjust, formsemestre.id):
if prefs["abs_notify_respsem"]:
# notifie chaque responsable du semestre
for responsable in formsemestre.responsables:
if responsable.email:
destinations.append(responsable.email)
if prefs["abs_notify_chief"] and prefs["email_chefdpt"]:
destinations.append(prefs["email_chefdpt"])
if prefs["abs_notify_email"]:
destinations.append(prefs["abs_notify_email"])
if prefs["abs_notify_etud"]:
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
if etud["email_default"]:
destinations.append(etud["email_default"])
# Notification (à chaque fois) des resp. de modules ayant des évaluations
# à cette date
# nb: on pourrait prevoir d'utiliser un autre format de message pour ce cas
if prefs["abs_notify_respeval"]:
mods = mod_with_evals_at_date(date, etudid)
for mod in mods:
u: User = db.session.get(User, mod["responsable_id"])
if u is not None and u.is_active and u.email:
destinations.append(u.email)
# uniq
destinations = set(destinations)
return destinations
def abs_notify_is_above_threshold(etudid, nbabs, nbabsjust, formsemestre_id):
"""True si il faut notifier les absences (indépendemment du destinataire)
nbabs: nombre d'absence (de tous types, unité de compte = demi-journée)
nbabsjust: nombre d'absences justifiées
(nbabs > abs_notify_abs_threshold)
(nbabs - nbabs_last_notified) > abs_notify_abs_increment
TODO Mettre à jour avec le module assiduité + fonctionnement métrique
"""
abs_notify_abs_threshold = sco_preferences.get_preference(
"abs_notify_abs_threshold", formsemestre_id
)
abs_notify_abs_increment = sco_preferences.get_preference(
"abs_notify_abs_increment", formsemestre_id
)
nbabs_last_notified = etud_nbabs_last_notified(etudid, formsemestre_id)
if nbabs_last_notified == 0:
if nbabs > abs_notify_abs_threshold:
return True # first notification
else:
return False
else:
if (nbabs - nbabs_last_notified) >= abs_notify_abs_increment:
return True
return False
def etud_nbabs_last_notified(etudid: int, formsemestre_id: int = None):
"""nbabs lors de la dernière notification envoyée pour cet étudiant dans ce semestre
ou sans semestre (ce dernier cas est nécessaire pour la transition au nouveau code)
"""
notifications = (
AbsenceNotification.query.filter_by(etudid=etudid)
.filter(
(AbsenceNotification.formsemestre_id == formsemestre_id)
| (AbsenceNotification.formsemestre_id.is_(None))
)
.order_by(AbsenceNotification.notification_date.desc())
)
last_notif = notifications.first()
return last_notif.nbabs if last_notif else 0
def user_nbdays_since_last_notif(email_addr, etudid) -> Optional[int]:
"""nb days since last notification to this email, or None if no previous notification"""
notifications = AbsenceNotification.query.filter_by(
etudid=etudid, email=email_addr
).order_by(AbsenceNotification.notification_date.desc())
last_notif = notifications.first()
if last_notif:
now = datetime.datetime.now(last_notif.notification_date.tzinfo)
return (now - last_notif.notification_date).days
return None
def abs_notification_message(
formsemestre: FormSemestre, prefs, etudid, nbabs, nbabsjust
):
"""Mime notification message based on template.
returns a Message instance
or None if sending should be canceled (empty template).
"""
from app.scodoc import sco_bulletins
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
# Variables accessibles dans les balises du template: %(nom_variable)s :
values = sco_bulletins.make_context_dict(formsemestre, etud)
values["nbabs"] = nbabs
values["nbabsjust"] = nbabsjust
values["nbabsnonjust"] = nbabs - nbabsjust
values["url_ficheetud"] = url_for(
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid, _external=True
)
template = prefs["abs_notification_mail_tmpl"]
if template:
txt = prefs["abs_notification_mail_tmpl"] % values
else:
log("abs_notification_message: empty template, not sending message")
return None
subject = f"""[ScoDoc] Trop d'absences pour {etud["nomprenom"]}"""
msg = Message(subject, sender=email.get_from_addr(formsemestre.departement.acronym))
msg.body = txt
return msg
def retreive_current_formsemestre(
etudid: int, cur_date: str | datetime.date
) -> Optional[FormSemestre]:
"""Get formsemestre dans lequel etudid est (ou était) inscrit a la date indiquée
date est une chaine au format ISO (yyyy-mm-dd) ou un datetime.date
Result: FormSemestre ou None si pas inscrit à la date indiquée
"""
req = """SELECT i.formsemestre_id
FROM notes_formsemestre_inscription i, notes_formsemestre sem
WHERE sem.id = i.formsemestre_id AND i.etudid = %(etudid)s
AND (%(cur_date)s >= sem.date_debut) AND (%(cur_date)s <= sem.date_fin)
"""
r = ndb.SimpleDictFetch(req, {"etudid": etudid, "cur_date": cur_date})
if not r:
return None
# s'il y a plusieurs semestres, prend le premier (rarissime et non significatif):
formsemestre = FormSemestre.get_formsemestre(r[0]["formsemestre_id"])
return formsemestre
def mod_with_evals_at_date(
date_abs: str | datetime.datetime, etudid: int
) -> list[dict]:
"""Liste des moduleimpls avec des evaluations à la date indiquée"""
req = """
SELECT m.id AS moduleimpl_id, m.*
FROM notes_moduleimpl m, notes_evaluation e, notes_moduleimpl_inscription i
WHERE m.id = e.moduleimpl_id
AND e.moduleimpl_id = i.moduleimpl_id
AND i.etudid = %(etudid)s
AND e.date_debut <= %(date_abs)s
AND e.date_fin >= %(date_abs)s
"""
return ndb.SimpleDictFetch(req, {"etudid": etudid, "date_abs": date_abs})