1
0
forked from ScoDoc/ScoDoc

- Liste homonymes sur formulaire édition étudiant;

- Ajoute helper class ScoDocModel
- Fix clonage étudiant
- Tolère données admission manquantes dans recherche get_etud
- Fix #771
This commit is contained in:
Emmanuel Viennet 2023-10-08 10:01:23 +02:00
parent 574fae2170
commit 3fdef1d4a0
7 changed files with 123 additions and 66 deletions

View File

@ -44,4 +44,8 @@ def get_etud(etudid=None, nip=None, ine=None) -> models.Identite:
query = query.join(Departement).filter( query = query.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts) or_(Departement.acronym == acronym for acronym in allowed_depts)
) )
return query.join(Admission).order_by(desc(Admission.annee)).first() etud = query.join(Admission).order_by(desc(Admission.annee)).first()
# dans de rares cas (bricolages manuels, bugs), l'étudiant n'a pas de données d'admission
if etud is None:
etud = query.first()
return etud

View File

@ -4,6 +4,7 @@
""" """
import sqlalchemy import sqlalchemy
from app import db
CODE_STR_LEN = 16 # chaine pour les codes CODE_STR_LEN = 16 # chaine pour les codes
SHORT_STR_LEN = 32 # courtes chaine, eg acronymes SHORT_STR_LEN = 32 # courtes chaine, eg acronymes
@ -21,6 +22,67 @@ convention = {
metadata_obj = sqlalchemy.MetaData(naming_convention=convention) metadata_obj = sqlalchemy.MetaData(naming_convention=convention)
class ScoDocModel:
"Mixin class for our models. Add somme useful methods for editing, cloning, etc."
def clone(self, not_copying=()):
"""Clone, not copying the given attrs
Attention: la copie n'a pas d'id avant le prochain flush ou commit.
"""
d = dict(self.__dict__)
d.pop("id", None) # get rid of id
d.pop("_sa_instance_state", None) # get rid of SQLAlchemy special attr
for k in not_copying:
d.pop(k, None)
copy = self.__class__(**d)
db.session.add(copy)
return copy
@classmethod
def create_from_dict(cls, data: dict):
"""Create a new instance of the model with attributes given in dict.
The instance is added to the session (but not flushed nor committed).
Use only relevant arributes for the given model and ignore others.
"""
args = cls.filter_model_attributes(data)
obj = cls(**args)
db.session.add(obj)
return obj
@classmethod
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded.
By default, excluded == { 'id' }"""
excluded = {"id"} if excluded is None else set()
# Les attributs du modèle qui sont des variables: (élimine les __ et les alias comme adm_id)
my_attributes = [
a
for a in dir(cls)
if isinstance(
getattr(cls, a), sqlalchemy.orm.attributes.InstrumentedAttribute
)
]
# Filtre les arguments utiles
return {
k: v for k, v in data.items() if k in my_attributes and k not in excluded
}
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"Convert fields in the given dict. No side effect."
# virtual, by default, do nothing
return args
def from_dict(self, args: dict):
"Update object's fields given in dict. Add to session but don't commit."
args_dict = self.convert_dict_fields(self.filter_model_attributes(args))
for key, value in args_dict.items():
if hasattr(self, key):
setattr(self, key, value)
db.session.add(self)
from app.models.absences import Absence, AbsenceNotification, BilletAbsence from app.models.absences import Absence, AbsenceNotification, BilletAbsence
from app.models.departements import Departement from app.models.departements import Departement
from app.models.etudiants import ( from app.models.etudiants import (

View File

@ -88,7 +88,7 @@ class Identite(db.Model):
def clone(self, not_copying=(), new_dept_id: int = None): def clone(self, not_copying=(), new_dept_id: int = None):
"""Clone, not copying the given attrs """Clone, not copying the given attrs
Clone aussi les adresses. Clone aussi les adresses et infos d'admission.
Si new_dept_id est None, le nouvel étudiant n'a pas de département. Si new_dept_id est None, le nouvel étudiant n'a pas de département.
Attention: la copie n'a pas d'id avant le prochain flush ou commit. Attention: la copie n'a pas d'id avant le prochain flush ou commit.
""" """
@ -123,8 +123,10 @@ class Identite(db.Model):
for k in not_copying: for k in not_copying:
d.pop(k, None) d.pop(k, None)
copy = self.__class__(**d) copy = self.__class__(**d)
copy.adresses = [adr.clone() for adr in self.adresses]
db.session.add(copy) db.session.add(copy)
copy.adresses = [adr.clone() for adr in self.adresses]
for admission in self.admission:
copy.admission.append(admission.clone())
log( log(
f"cloning etud <{self.id} {self.nom!r} {self.prenom!r}> in dept_id={new_dept_id}" f"cloning etud <{self.id} {self.nom!r} {self.prenom!r}> in dept_id={new_dept_id}"
) )
@ -133,7 +135,7 @@ class Identite(db.Model):
def html_link_fiche(self) -> str: def html_link_fiche(self) -> str:
"lien vers la fiche" "lien vers la fiche"
return f"""<a class="stdlink" href="{ return f"""<a class="stdlink" href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id) url_for("scolar.ficheEtud", scodoc_dept=self.departement.acronym, etudid=self.id)
}">{self.nomprenom}</a>""" }">{self.nomprenom}</a>"""
@classmethod @classmethod
@ -677,7 +679,7 @@ def pivot_year(y) -> int:
return y return y
class Adresse(db.Model): class Adresse(db.Model, models.ScoDocModel):
"""Adresse d'un étudiant """Adresse d'un étudiant
(le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule) (le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule)
""" """
@ -704,19 +706,6 @@ class Adresse(db.Model):
) )
description = db.Column(db.Text) description = db.Column(db.Text)
def clone(self, not_copying=()):
"""Clone, not copying the given attrs
Attention: la copie n'a pas d'id avant le prochain flush ou commit.
"""
d = dict(self.__dict__)
d.pop("id", None) # get rid of id
d.pop("_sa_instance_state", None) # get rid of SQLAlchemy special attr
for k in not_copying:
d.pop(k, None)
copy = self.__class__(**d)
db.session.add(copy)
return copy
def to_dict(self, convert_nulls_to_str=False): def to_dict(self, convert_nulls_to_str=False):
"""Représentation dictionnaire,""" """Représentation dictionnaire,"""
e = dict(self.__dict__) e = dict(self.__dict__)
@ -726,7 +715,7 @@ class Adresse(db.Model):
return e return e
class Admission(db.Model): class Admission(db.Model, models.ScoDocModel):
"""Informations liées à l'admission d'un étudiant""" """Informations liées à l'admission d'un étudiant"""
__tablename__ = "admissions" __tablename__ = "admissions"
@ -818,16 +807,6 @@ class Admission(db.Model):
args_dict[key] = value args_dict[key] = value
return args_dict return args_dict
def from_dict(self, args: dict): # TODO à refactoriser dans une super-classe
"update fields given in dict. Add to session but don't commit."
args_dict = Admission.convert_dict_fields(args)
args_dict.pop("adm_id", None)
args_dict.pop("id", None)
for key, value in args_dict.items():
if hasattr(self, key):
setattr(self, key, value)
db.session.add(self)
# Suivi scolarité / débouchés # Suivi scolarité / débouchés
class ItemSuivi(db.Model): class ItemSuivi(db.Model):

View File

@ -267,33 +267,30 @@ def identite_edit_nocheck(cnx, args):
db.session.commit() db.session.commit()
def check_nom_prenom(cnx, nom="", prenom="", etudid=None): def check_nom_prenom_homonyms(
nom: str = "", prenom: str = "", etudid=None
) -> tuple[bool, list[Identite]]:
"""Check if nom and prenom are valid. """Check if nom and prenom are valid.
Also check for duplicates (homonyms), excluding etudid : Also check for duplicates (homonyms), excluding etudid :
in general, homonyms are allowed, but it may be useful to generate a warning. in general, homonyms are allowed, but it may be useful to generate a warning.
Returns: Returns:
True | False, NbHomonyms True | False, homonyms
""" """
if not nom or (not prenom and not scu.CONFIG.ALLOW_NULL_PRENOM): if not nom or (not prenom and not scu.CONFIG.ALLOW_NULL_PRENOM):
return False, 0 return False, []
nom = nom.lower().strip() nom = nom.lower().strip()
if prenom: if prenom:
prenom = prenom.lower().strip() prenom = prenom.lower().strip()
# Don't allow some special cars (eg used in sql regexps) # Don't allow some special cars (eg used in sql regexps)
if scu.FORBIDDEN_CHARS_EXP.search(nom) or scu.FORBIDDEN_CHARS_EXP.search(prenom): if scu.FORBIDDEN_CHARS_EXP.search(nom) or scu.FORBIDDEN_CHARS_EXP.search(prenom):
return False, 0 return False, 0
# Now count homonyms (dans tous les départements): # Liste homonymes (dans tous les départements):
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) query = Identite.query.filter(
req = """SELECT id Identite.nom.ilike(nom + "%"), Identite.prenom.ilike(prenom + "%")
FROM identite )
WHERE lower(nom) ~ %(nom)s if etudid is not None:
and lower(prenom) ~ %(prenom)s query = query.filter(Identite.id != etudid)
""" return True, query.all()
if etudid:
req += " and id <> %(etudid)s"
cursor.execute(req, {"nom": nom, "prenom": prenom, "etudid": etudid})
res = cursor.dictfetchall()
return True, len(res)
def _check_duplicate_code(cnx, args, code_name, disable_notify=False, edit=True): def _check_duplicate_code(cnx, args, code_name, disable_notify=False, edit=True):

View File

@ -409,18 +409,19 @@ def scolars_import_excel_file(
raise ScoValueError("Code INE dupliqué (%s)" % values["code_ine"]) raise ScoValueError("Code INE dupliqué (%s)" % values["code_ine"])
# Check nom/prenom # Check nom/prenom
ok = False ok = False
homonyms = []
if "nom" in values and "prenom" in values: if "nom" in values and "prenom" in values:
ok, nb_homonyms = sco_etud.check_nom_prenom( ok, homonyms = sco_etud.check_nom_prenom_homonyms(
cnx, nom=values["nom"], prenom=values["prenom"] nom=values["nom"], prenom=values["prenom"]
) )
if not ok: if not ok:
raise ScoValueError( raise ScoValueError(
"nom ou prénom invalide sur la ligne %d" % (linenum) f"nom ou prénom invalide sur la ligne {linenum}"
) )
if nb_homonyms: if homonyms:
np_imported_homonyms += 1 np_imported_homonyms += 1
# Insert in DB tables # Insert in DB tables
formsemestre_id_etud = _import_one_student( _import_one_student(
cnx, cnx,
formsemestre_id, formsemestre_id,
values, values,
@ -431,11 +432,11 @@ def scolars_import_excel_file(
) )
# Verification proportion d'homonymes: si > 10%, abandonne # Verification proportion d'homonymes: si > 10%, abandonne
log("scolars_import_excel_file: detected %d homonyms" % np_imported_homonyms) log(f"scolars_import_excel_file: detected {np_imported_homonyms} homonyms")
if check_homonyms and np_imported_homonyms > len(created_etudids) / 10: if check_homonyms and np_imported_homonyms > len(created_etudids) / 10:
log("scolars_import_excel_file: too many homonyms") log("scolars_import_excel_file: too many homonyms")
raise ScoValueError( raise ScoValueError(
"Il y a trop d'homonymes (%d étudiants)" % np_imported_homonyms f"Il y a trop d'homonymes ({np_imported_homonyms} étudiants)"
) )
except: except:
cnx.rollback() cnx.rollback()
@ -444,7 +445,7 @@ def scolars_import_excel_file(
# here we try to remove all created students # here we try to remove all created students
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
for etudid in created_etudids: for etudid in created_etudids:
log("scolars_import_excel_file: deleting etudid=%s" % etudid) log(f"scolars_import_excel_file: deleting etudid={etudid}")
cursor.execute( cursor.execute(
"delete from notes_moduleimpl_inscription where etudid=%(etudid)s", "delete from notes_moduleimpl_inscription where etudid=%(etudid)s",
{"etudid": etudid}, {"etudid": etudid},

View File

@ -1701,10 +1701,8 @@ function deleteJustificatif(justif_id) {
function errorAlert() { function errorAlert() {
const html = ` const html = `
<h3>Avez vous les droits suffisant pour cette action ?</h3> <h4>Il peut s'agir d'un problème de droits, ou d'une modification survenue sur le serveur.</h4>
<p>Si c'est bien le cas : demandez de l'aide sur le canal Assistance de ScoDoc</p> <p>Si le problème persiste, demandez de l'aide sur le Discord d'assistance de ScoDoc</p>
<br>
<p><i>pour les développeurs : l'erreur est affichée dans la console JS</i></p>
`; `;
const div = document.createElement("div"); const div = document.createElement("div");

View File

@ -54,6 +54,7 @@ from app.decorators import (
permission_required_compat_scodoc7, permission_required_compat_scodoc7,
) )
from app.models import ( from app.models import (
Admission,
Departement, Departement,
FormSemestre, FormSemestre,
Identite, Identite,
@ -1374,11 +1375,9 @@ def _etudident_create_or_edit_form(edit):
# setup form init values # setup form init values
if not etudid: if not etudid:
raise ValueError("missing etudid parameter") raise ValueError("missing etudid parameter")
etud_o: Identite = Identite.get_etud(etudid)
descr.append(("etudid", {"default": etudid, "input_type": "hidden"})) descr.append(("etudid", {"default": etudid, "input_type": "hidden"}))
H.append( H.append(f"""<h2>Modification des données de {etud_o.html_link_fiche()}</h2>""")
'<h2>Modification d\'un étudiant (<a href="%s">fiche</a>)</h2>'
% url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
)
initvalues = sco_etud.etudident_list(cnx, {"etudid": etudid}) initvalues = sco_etud.etudident_list(cnx, {"etudid": etudid})
assert len(initvalues) == 1 assert len(initvalues) == 1
initvalues = initvalues[0] initvalues = initvalues[0]
@ -1724,9 +1723,10 @@ def _etudident_create_or_edit_form(edit):
etudid = tf[2]["etudid"] etudid = tf[2]["etudid"]
else: else:
etudid = None etudid = None
ok, NbHomonyms = sco_etud.check_nom_prenom( ok, homonyms = sco_etud.check_nom_prenom_homonyms(
cnx, nom=tf[2]["nom"], prenom=tf[2]["prenom"], etudid=etudid nom=tf[2]["nom"], prenom=tf[2]["prenom"], etudid=etudid
) )
nb_homonyms = len(homonyms)
if not ok: if not ok:
return ( return (
"\n".join(H) "\n".join(H)
@ -1736,16 +1736,29 @@ def _etudident_create_or_edit_form(edit):
+ A + A
+ F + F
) )
# log('NbHomonyms=%s' % NbHomonyms) if not tf[2]["dont_check_homonyms"] and nb_homonyms > 0:
if not tf[2]["dont_check_homonyms"] and NbHomonyms > 0: homonyms_html = f"""
<div class="homonyms"
style="border-radius: 8px; border: 1px solid black; background-color: #fdd6ad; padding: 8px; max-width: 80%;">
<div><b>Homonymes</b> (dans tous les départements)</div>
<ul>
<li>{'</li><li>'.join( [ '<b style="margin-right: 2em;">' + e.departement.acronym + "</b>" + e.html_link_fiche() for e in homonyms ])}
</ul>
</div>
"""
return ( return (
"\n".join(H) "\n".join(H)
+ tf_error_message( + tf_error_message(
"""Attention: il y a déjà un étudiant portant des noms et prénoms proches. Vous pouvez forcer la présence d'un homonyme en cochant "autoriser les homonymes" en bas du formulaire.""" """Attention: il y a déjà un étudiant portant des noms et prénoms proches
(voir liste en bas de page).
Vous pouvez forcer la présence d'un homonyme en cochant
"autoriser les homonymes" en bas du formulaire.
"""
) )
+ tf[1] + tf[1]
+ "<p>" + "<p>"
+ A + A
+ homonyms_html
+ F + F
) )
@ -1754,10 +1767,13 @@ def _etudident_create_or_edit_form(edit):
etudid = etud["etudid"] etudid = etud["etudid"]
else: else:
# modif d'un etudiant # modif d'un etudiant
etud_o = db.session.get(Identite, tf[2]["etudid"])
etud_o.from_dict(tf[2]) etud_o.from_dict(tf[2])
db.session.add(etud_o) db.session.add(etud_o)
admission = etud_o.admission.first() admission = etud_o.admission.first()
if admission is None:
# ? ne devrait pas arriver mais...
admission = Admission()
admission.etudid = etud_o.id
admission.from_dict(tf[2]) admission.from_dict(tf[2])
db.session.add(admission) db.session.add(admission)
db.session.commit() db.session.commit()