Envois de mail:

- réglage de l'adresse origine From au niveau global
 et systémtisation de son utilisation.
 - ajout de logs, réglage du log par défaut.
 - modernisation de code.
This commit is contained in:
Emmanuel Viennet 2023-02-28 19:43:48 +01:00
parent 50efadf421
commit 7fc3108886
14 changed files with 128 additions and 96 deletions

View File

@ -247,6 +247,7 @@ def create_app(config_class=DevConfig):
migrate.init_app(app, db) migrate.init_app(app, db)
login.init_app(app) login.init_app(app)
mail.init_app(app) mail.init_app(app)
app.extensions["mail"].debug = 0 # disable copy of mails to stderr
bootstrap.init_app(app) bootstrap.init_app(app)
moment.init_app(app) moment.init_app(app)
cache.init_app(app) cache.init_app(app)
@ -545,10 +546,9 @@ def log_call_stack():
# Alarms by email: # Alarms by email:
def send_scodoc_alarm(subject, txt): def send_scodoc_alarm(subject, txt):
from app.scodoc import sco_preferences
from app import email from app import email
sender = sco_preferences.get_preference("email_from_addr") sender = email.get_from_addr()
email.send_email(subject, sender, ["exception@scodoc.org"], txt) email.send_email(subject, sender, ["exception@scodoc.org"], txt)

View File

@ -1,14 +1,14 @@
# -*- coding: UTF-8 -* # -*- coding: UTF-8 -*
from flask import render_template, current_app from flask import render_template, current_app
from flask_babel import _ from flask_babel import _
from app.email import send_email from app.email import get_from_addr, send_email
def send_password_reset_email(user): def send_password_reset_email(user):
token = user.get_reset_password_token() token = user.get_reset_password_token()
send_email( send_email(
"[ScoDoc] Réinitialisation de votre mot de passe", "[ScoDoc] Réinitialisation de votre mot de passe",
sender=current_app.config["SCODOC_MAIL_FROM"], sender=get_from_addr(),
recipients=[user.email], recipients=[user.email],
text_body=render_template("email/reset_password.txt", user=user, token=token), text_body=render_template("email/reset_password.txt", user=user, token=token),
html_body=render_template("email/reset_password.j2", user=user, token=token), html_body=render_template("email/reset_password.j2", user=user, token=token),

View File

@ -11,6 +11,8 @@ from flask import current_app, g
from flask_mail import Message from flask_mail import Message
from app import mail from app import mail
from app.models.departements import Departement
from app.models.config import ScoDocSiteConfig
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
@ -56,6 +58,7 @@ def send_message(msg: Message):
In mail debug mode, addresses are discarded and all mails are sent to the In mail debug mode, addresses are discarded and all mails are sent to the
specified debugging address. specified debugging address.
""" """
email_test_mode_address = False
if hasattr(g, "scodoc_dept"): if hasattr(g, "scodoc_dept"):
# on est dans un département, on peut accéder aux préférences # on est dans un département, on peut accéder aux préférences
email_test_mode_address = sco_preferences.get_preference( email_test_mode_address = sco_preferences.get_preference(
@ -81,6 +84,35 @@ Adresses d'origine:
+ msg.body + msg.body
) )
current_app.logger.info(
f"""email sent to{' (mode test)' if email_test_mode_address else ''}: {msg.recipients}
from sender {msg.sender}
"""
)
Thread( Thread(
target=send_async_email, args=(current_app._get_current_object(), msg) target=send_async_email, args=(current_app._get_current_object(), msg)
).start() ).start()
def get_from_addr(dept_acronym: str = None):
"""L'adresse "from" à utiliser pour envoyer un mail
Si le departement est spécifié, ou si l'attribut `g.scodoc_dept`existe,
prend le `email_from_addr` des préférences de ce département si ce champ est non vide.
Sinon, utilise le paramètre global `email_from_addr`.
Sinon, la variable de config `SCODOC_MAIL_FROM`.
"""
dept_acronym = dept_acronym or getattr(g, "scodoc_dept", None)
if dept_acronym:
dept = Departement.query.filter_by(acronym=dept_acronym).first()
if dept:
from_addr = (
sco_preferences.get_preference("email_from_addr", dept_id=dept.id) or ""
).strip()
if from_addr:
return from_addr
return (
ScoDocSiteConfig.get("email_from_addr")
or current_app.config["SCODOC_MAIL_FROM"]
or "none"
)

View File

@ -216,7 +216,7 @@ def send_email_notifications_entreprise(subject: str, entreprise: Entreprise):
txt = "\n".join(txt) txt = "\n".join(txt)
email.send_email( email.send_email(
subject, subject,
sco_preferences.get_preference("email_from_addr"), email.get_from_addr(),
[EntreprisePreferences.get_email_notifications], [EntreprisePreferences.get_email_notifications],
txt, txt,
) )

View File

@ -31,8 +31,8 @@ Formulaires configuration Exports Apogée (codes)
from flask import flash, url_for, redirect, request, render_template from flask import flash, url_for, redirect, request, render_template
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import BooleanField, SelectField, SubmitField from wtforms import BooleanField, SelectField, StringField, SubmitField
from wtforms.validators import Email, Optional
import app import app
from app.models import ScoDocSiteConfig from app.models import ScoDocSiteConfig
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
@ -70,6 +70,12 @@ class ScoDocConfigurationForm(FlaskForm):
(i, name.capitalize()) for (i, name) in enumerate(scu.MONTH_NAMES, start=1) (i, name.capitalize()) for (i, name) in enumerate(scu.MONTH_NAMES, start=1)
], ],
) )
email_from_addr = StringField(
label="Adresse source des mails",
description="""adresse email source (from) des mails émis par ScoDoc.
Attention: si ce champ peut aussi être défini dans chaque département.""",
validators=[Optional(), Email()],
)
submit_scodoc = SubmitField("Valider") submit_scodoc = SubmitField("Valider")
cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True}) cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True})
@ -87,6 +93,7 @@ def configuration():
"enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled(), "enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled(),
"month_debut_annee_scolaire": ScoDocSiteConfig.get_month_debut_annee_scolaire(), "month_debut_annee_scolaire": ScoDocSiteConfig.get_month_debut_annee_scolaire(),
"month_debut_periode2": ScoDocSiteConfig.get_month_debut_periode2(), "month_debut_periode2": ScoDocSiteConfig.get_month_debut_periode2(),
"email_from_addr": ScoDocSiteConfig.get("email_from_addr"),
} }
) )
if request.method == "POST" and ( if request.method == "POST" and (
@ -130,6 +137,8 @@ def configuration():
scu.MONTH_NAMES[ScoDocSiteConfig.get_month_debut_periode2()-1] scu.MONTH_NAMES[ScoDocSiteConfig.get_month_debut_periode2()-1]
}""" }"""
) )
if ScoDocSiteConfig.set("email_from_addr", form_scodoc.data["email_from_addr"]):
flash("Adresse email origine enregistrée")
return redirect(url_for("scodoc.index")) return redirect(url_for("scodoc.index"))
return render_template( return render_template(

View File

@ -233,8 +233,7 @@ class ScolarNews(db.Model):
txt = re.sub(r'<a.*?href\s*=\s*"(.*?)".*?>(.*?)</a>', r"\2: \1", txt) txt = re.sub(r'<a.*?href\s*=\s*"(.*?)".*?>(.*?)</a>', r"\2: \1", txt)
subject = "[ScoDoc] " + self.NEWS_MAP.get(self.type, "?") subject = "[ScoDoc] " + self.NEWS_MAP.get(self.type, "?")
sender = prefs["email_from_addr"] sender = email.get_from_addr()
email.send_email(subject, sender, destinations, txt) email.send_email(subject, sender, destinations, txt)
@classmethod @classmethod

View File

@ -32,20 +32,21 @@
Il suffit d'appeler abs_notify() après chaque ajout d'absence. Il suffit d'appeler abs_notify() après chaque ajout d'absence.
""" """
import datetime import datetime
from typing import Optional
from flask import g, url_for from flask import g, url_for
from flask_mail import Message from flask_mail import Message
from app.models.formsemestre import FormSemestre
import app.scodoc.notesdb as ndb from app import db
import app.scodoc.sco_utils as scu from app import email
from app import log from app import log
from app.scodoc.scolog import logdb 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_etud
from app.scodoc import sco_formsemestre
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc import sco_users from app.scodoc import sco_users
from app import email
def abs_notify(etudid, date): def abs_notify(etudid, date):
@ -106,32 +107,24 @@ def do_abs_notify(formsemestre: FormSemestre, etudid, date, nbabs, nbabsjust):
def abs_notify_send(destinations, 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""" """Actually send the notification by email, and register it in database"""
cnx = ndb.GetDBConnexion() log(f"abs_notify: sending notification to {destinations}")
log("abs_notify: sending notification to %s" % destinations)
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
for dest_addr in destinations: for dest_addr in destinations:
msg.recipients = [dest_addr] msg.recipients = [dest_addr]
email.send_message(msg) email.send_message(msg)
ndb.SimpleQuery( notification = AbsenceNotification(
"""INSERT into absences_notifications etudid=etudid,
(etudid, email, nbabs, nbabsjust, formsemestre_id) email=dest_addr,
VALUES (%(etudid)s, %(email)s, %(nbabs)s, %(nbabsjust)s, %(formsemestre_id)s) nbabs=nbabs,
""", nbabsjust=nbabsjust,
{ formsemestre_id=formsemestre_id,
"etudid": etudid,
"email": dest_addr,
"nbabs": nbabs,
"nbabsjust": nbabsjust,
"formsemestre_id": formsemestre_id,
},
cursor=cursor,
) )
db.session.add(notification)
logdb( Scolog.logdb(
cnx=cnx,
method="abs_notify", method="abs_notify",
etudid=etudid, etudid=etudid,
msg="sent to %s (nbabs=%d)" % (destinations, nbabs), msg=f"sent to {destinations} (nbabs={nbabs})",
commit=True,
) )
@ -201,39 +194,32 @@ def abs_notify_is_above_threshold(etudid, nbabs, nbabsjust, formsemestre_id):
return False return False
def etud_nbabs_last_notified(etudid, formsemestre_id=None): 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 """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)""" ou sans semestre (ce dernier cas est nécessaire pour la transition au nouveau code)
cnx = ndb.GetDBConnexion() """
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) notifications = (
cursor.execute( AbsenceNotification.query.filter_by(etudid=etudid)
"""select * from absences_notifications where etudid = %(etudid)s and (formsemestre_id = %(formsemestre_id)s or formsemestre_id is NULL) order by notification_date desc""", .filter(
vars(), (AbsenceNotification.formsemestre_id == formsemestre_id)
| (AbsenceNotification.formsemestre_id.is_(None))
)
.order_by(AbsenceNotification.notification_date.desc())
) )
res = cursor.dictfetchone() last_notif = notifications.first()
if res: return last_notif.nbabs if last_notif else 0
return res["nbabs"]
else:
return 0
def user_nbdays_since_last_notif(email_addr, etudid): 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""" """nb days since last notification to this email, or None if no previous notification"""
cnx = ndb.GetDBConnexion() notifications = AbsenceNotification.query.filter_by(
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) etudid=etudid, email=email_addr
cursor.execute( ).order_by(AbsenceNotification.notification_date.desc())
"""SELECT * FROM absences_notifications last_notif = notifications.first()
WHERE email = %(email_addr)s and etudid=%(etudid)s if last_notif:
ORDER BY notification_date DESC now = datetime.datetime.now(last_notif.notification_date.tzinfo)
""", return (now - last_notif.notification_date).days
{"email_addr": email_addr, "etudid": etudid}, return None
)
res = cursor.dictfetchone()
if res:
now = datetime.datetime.now(res["notification_date"].tzinfo)
return (now - res["notification_date"]).days
else:
return None
def abs_notification_message( def abs_notification_message(
@ -264,19 +250,19 @@ def abs_notification_message(
log("abs_notification_message: empty template, not sending message") log("abs_notification_message: empty template, not sending message")
return None return None
subject = """[ScoDoc] Trop d'absences pour %(nomprenom)s""" % etud subject = f"""[ScoDoc] Trop d'absences pour {etud["nomprenom"]}"""
msg = Message(subject, sender=prefs["email_from_addr"]) msg = Message(subject, sender=email.get_from_addr(formsemestre.departement.acronym))
msg.body = txt msg.body = txt
return msg return msg
def retreive_current_formsemestre(etudid: int, cur_date) -> FormSemestre: def retreive_current_formsemestre(etudid: int, cur_date) -> Optional[FormSemestre]:
"""Get formsemestre dans lequel etudid est (ou était) inscrit a la date indiquée """Get formsemestre dans lequel etudid est (ou était) inscrit a la date indiquée
date est une chaine au format ISO (yyyy-mm-dd) date est une chaine au format ISO (yyyy-mm-dd)
Result: FormSemestre ou None si pas inscrit à la date indiquée Result: FormSemestre ou None si pas inscrit à la date indiquée
""" """
req = """SELECT i.formsemestre_id req = """SELECT i.formsemestre_id
FROM notes_formsemestre_inscription i, notes_formsemestre sem FROM notes_formsemestre_inscription i, notes_formsemestre sem
WHERE sem.id = i.formsemestre_id AND i.etudid = %(etudid)s 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) AND (%(cur_date)s >= sem.date_debut) AND (%(cur_date)s <= sem.date_fin)
@ -292,9 +278,8 @@ def retreive_current_formsemestre(etudid: int, cur_date) -> FormSemestre:
def mod_with_evals_at_date(date_abs, etudid): def mod_with_evals_at_date(date_abs, etudid):
"""Liste des moduleimpls avec des evaluations à la date indiquée""" """Liste des moduleimpls avec des evaluations à la date indiquée"""
req = """SELECT m.id AS moduleimpl_id, m.* req = """SELECT m.id AS moduleimpl_id, m.*
FROM notes_moduleimpl m, notes_evaluation e, notes_moduleimpl_inscription i FROM notes_moduleimpl m, notes_evaluation e, notes_moduleimpl_inscription i
WHERE m.id = e.moduleimpl_id AND e.moduleimpl_id = i.moduleimpl_id WHERE m.id = e.moduleimpl_id AND e.moduleimpl_id = i.moduleimpl_id
AND i.etudid = %(etudid)s AND e.jour = %(date_abs)s""" AND i.etudid = %(etudid)s AND e.jour = %(date_abs)s"""
r = ndb.SimpleDictFetch(req, {"etudid": etudid, "date_abs": date_abs}) return ndb.SimpleDictFetch(req, {"etudid": etudid, "date_abs": date_abs})
return r

View File

@ -1080,7 +1080,7 @@ def mail_bulletin(formsemestre_id, infos, pdfdata, filename, recipient_addr):
subject = f"""Relevé de notes de {etud["nomprenom"]}""" subject = f"""Relevé de notes de {etud["nomprenom"]}"""
recipients = [recipient_addr] recipients = [recipient_addr]
sender = sco_preferences.get_preference("email_from_addr", formsemestre_id) sender = email.get_from_addr()
if copy_addr: if copy_addr:
bcc = copy_addr.strip().split(",") bcc = copy_addr.strip().split(",")
else: else:

View File

@ -438,9 +438,7 @@ def notify_etud_change(email_addr, etud, before, after, subject):
log("notify_etud_change: sending notification to %s" % email_addr) log("notify_etud_change: sending notification to %s" % email_addr)
log("notify_etud_change: subject: %s" % subject) log("notify_etud_change: subject: %s" % subject)
log(txt) log(txt)
email.send_email( email.send_email(subject, email.get_from_addr(), [email_addr], txt)
subject, sco_preferences.get_preference("email_from_addr"), [email_addr], txt
)
return txt return txt

View File

@ -308,5 +308,4 @@ Pour plus d'informations sur ce logiciel, voir %s
subject = "Mot de passe ScoDoc" subject = "Mot de passe ScoDoc"
else: else:
subject = "Votre accès ScoDoc" subject = "Votre accès ScoDoc"
sender = sco_preferences.get_preference("email_from_addr") email.send_email(subject, email.get_from_addr(), [user["email"]], txt)
email.send_email(subject, sender, [user["email"]], txt)

View File

@ -360,12 +360,31 @@ class BasePreferences(object):
}, },
), ),
# ------------------ MISC # ------------------ MISC
(
"email_from_addr",
{
"initvalue": "",
"title": "Adresse mail origine",
"size": 40,
"explanation": """adresse expéditeur pour tous les envois par mails (bulletins,
comptes, etc.).
Si vide, utilise la config globale.""",
"category": "misc",
"only_global": True,
},
),
( (
"use_ue_coefs", "use_ue_coefs",
{ {
"initvalue": 0, "initvalue": 0,
"title": "Utiliser les coefficients d'UE pour calculer la moyenne générale (hors BUT)", "title": """Utiliser les coefficients d'UE pour calculer la moyenne générale
"explanation": """Calcule les moyennes dans chaque UE, puis pondère ces résultats pour obtenir la moyenne générale. Par défaut, le coefficient d'une UE est simplement la somme des coefficients des modules dans lesquels l'étudiant a des notes. <b>Attention: changer ce réglage va modifier toutes les moyennes du semestre !</b>. Aucun effet en BUT.""", (hors BUT)""",
"explanation": """Calcule les moyennes dans chaque UE, puis pondère ces
résultats pour obtenir la moyenne générale.
Par défaut, le coefficient d'une UE est simplement la somme des coefficients des modules
dans lesquels l'étudiant a des notes. <b>Attention: changer ce réglage va modifier toutes
les moyennes du semestre !</b>. Aucun effet en BUT.
""",
"input_type": "boolcheckbox", "input_type": "boolcheckbox",
"category": "misc", "category": "misc",
"labels": ["non", "oui"], "labels": ["non", "oui"],
@ -505,7 +524,7 @@ class BasePreferences(object):
{ {
"initvalue": 7, "initvalue": 7,
"title": "Fréquence maximale de notification", "title": "Fréquence maximale de notification",
"explanation": "en jours (pas plus de X envois de mail pour chaque étudiant/destinataire)", "explanation": "nb de jours minimum entre deux mails envoyés au même destinataire à propos d'un même étudiant ",
"size": 4, "size": 4,
"type": "int", "type": "int",
"convert_numbers": True, "convert_numbers": True,
@ -1569,17 +1588,6 @@ class BasePreferences(object):
"category": "bul_mail", "category": "bul_mail",
}, },
), ),
(
"email_from_addr",
{
"initvalue": current_app.config["SCODOC_MAIL_FROM"],
"title": "adresse mail origine",
"size": 40,
"explanation": "adresse expéditeur pour les envois par mails (bulletins)",
"category": "bul_mail",
"only_global": True,
},
),
( (
"bul_intro_mail", "bul_intro_mail",
{ {
@ -2073,7 +2081,7 @@ class BasePreferences(object):
page_title="Préférences", page_title="Préférences",
javascripts=["js/detail_summary_persistence.js"], javascripts=["js/detail_summary_persistence.js"],
), ),
f"<h2>Préférences globales pour {scu.ScoURL()}</h2>", f"<h2>Préférences globales pour le département {g.scodoc_dept}</h2>",
# f"""<p><a href="{url_for("scodoc.configure_logos", scodoc_dept=g.scodoc_dept) # f"""<p><a href="{url_for("scodoc.configure_logos", scodoc_dept=g.scodoc_dept)
# }">modification des logos du département (pour documents pdf)</a></p>""" # }">modification des logos du département (pour documents pdf)</a></p>"""
# if current_user.is_administrator() # if current_user.is_administrator()

View File

@ -65,7 +65,7 @@
<form id="configuration_form_scodoc" class="sco-form" action="" method="post" enctype="multipart/form-data" novalidate> <form id="configuration_form_scodoc" class="sco-form" action="" method="post" enctype="multipart/form-data" novalidate>
{{ form_scodoc.hidden_tag() }} {{ form_scodoc.hidden_tag() }}
<div class="row"> <div class="row">
<div class="col-md-4"> <div class="col-md-8">
{{ wtf.quick_form(form_scodoc) }} {{ wtf.quick_form(form_scodoc) }}
</div> </div>
</div> </div>

View File

@ -48,13 +48,13 @@ from wtforms import HiddenField, PasswordField, StringField, SubmitField
from wtforms.validators import DataRequired, Email, ValidationError, EqualTo from wtforms.validators import DataRequired, Email, ValidationError, EqualTo
from app import db from app import db
from app import email
from app.auth.forms import DeactivateUserForm from app.auth.forms import DeactivateUserForm
from app.auth.models import Permission from app.auth.models import Permission
from app.auth.models import User from app.auth.models import User
from app.auth.models import Role from app.auth.models import Role
from app.auth.models import UserRole from app.auth.models import UserRole
from app.auth.models import is_valid_password from app.auth.models import is_valid_password
from app.email import send_email
from app.models import Departement from app.models import Departement
from app.models.config import ScoDocSiteConfig from app.models.config import ScoDocSiteConfig
@ -212,7 +212,6 @@ def create_user_form(user_name=None, edit=0, all_roles=True):
user_name = str(user_name) user_name = str(user_name)
Role.ensure_standard_roles() # assure la présence des rôles en base Role.ensure_standard_roles() # assure la présence des rôles en base
auth_dept = current_user.dept auth_dept = current_user.dept
from_mail = current_app.config["SCODOC_MAIL_FROM"] # current_user.email
initvalues = {} initvalues = {}
edit = int(edit) edit = int(edit)
all_roles = int(all_roles) all_roles = int(all_roles)
@ -699,9 +698,10 @@ def create_user_form(user_name=None, edit=0, all_roles=True):
token = the_user.get_reset_password_token() token = the_user.get_reset_password_token()
else: else:
token = None token = None
send_email( # Le from doit utiliser la préférence du département de l'utilisateur
email.send_email(
"[ScoDoc] Création de votre compte", "[ScoDoc] Création de votre compte",
sender=from_mail, # current_app.config["ADMINS"][0], sender=email.get_from_addr(),
recipients=[the_user.email], recipients=[the_user.email],
text_body=render_template( text_body=render_template(
"email/welcome.txt", user=the_user, token=token "email/welcome.txt", user=the_user, token=token

View File

@ -32,7 +32,9 @@ def login():
the user's attributes are saved under the key the user's attributes are saved under the key
'CAS_USERNAME_ATTRIBUTE_KEY' 'CAS_USERNAME_ATTRIBUTE_KEY'
""" """
if not "CAS_SERVER" in current_app.config:
current_app.logger.info("cas_login: no configuration")
return "CAS configuration missing"
cas_token_session_key = current_app.config["CAS_TOKEN_SESSION_KEY"] cas_token_session_key = current_app.config["CAS_TOKEN_SESSION_KEY"]
redirect_url = create_cas_login_url( redirect_url = create_cas_login_url(