From 3fdef1d4a011bbd07d0e41a4e50d14451838842d Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 8 Oct 2023 10:01:23 +0200 Subject: [PATCH] =?UTF-8?q?-=20Liste=20homonymes=20sur=20formulaire=20?= =?UTF-8?q?=C3=A9dition=20=C3=A9tudiant;=20-=20Ajoute=20helper=20class=20S?= =?UTF-8?q?coDocModel=20-=20Fix=20clonage=20=C3=A9tudiant=20-=20Tol=C3=A8r?= =?UTF-8?q?e=20donn=C3=A9es=20admission=20manquantes=20dans=20recherche=20?= =?UTF-8?q?get=5Fetud=20-=20Fix=20#771?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/tools.py | 6 +++- app/models/__init__.py | 62 ++++++++++++++++++++++++++++++++++ app/models/etudiants.py | 35 ++++--------------- app/scodoc/sco_etud.py | 27 +++++++-------- app/scodoc/sco_import_etuds.py | 17 +++++----- app/static/js/assiduites.js | 6 ++-- app/views/scolar.py | 36 ++++++++++++++------ 7 files changed, 123 insertions(+), 66 deletions(-) diff --git a/app/api/tools.py b/app/api/tools.py index ec0b8561a..10464e631 100644 --- a/app/api/tools.py +++ b/app/api/tools.py @@ -44,4 +44,8 @@ def get_etud(etudid=None, nip=None, ine=None) -> models.Identite: query = query.join(Departement).filter( 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 diff --git a/app/models/__init__.py b/app/models/__init__.py index 032ddc861..61371e389 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -4,6 +4,7 @@ """ import sqlalchemy +from app import db CODE_STR_LEN = 16 # chaine pour les codes SHORT_STR_LEN = 32 # courtes chaine, eg acronymes @@ -21,6 +22,67 @@ 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.departements import Departement from app.models.etudiants import ( diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 6dc036782..a68101d17 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -88,7 +88,7 @@ class Identite(db.Model): def clone(self, not_copying=(), new_dept_id: int = None): """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. 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: d.pop(k, None) copy = self.__class__(**d) - copy.adresses = [adr.clone() for adr in self.adresses] db.session.add(copy) + copy.adresses = [adr.clone() for adr in self.adresses] + for admission in self.admission: + copy.admission.append(admission.clone()) log( 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: "lien vers la fiche" return f"""{self.nomprenom}""" @classmethod @@ -677,7 +679,7 @@ def pivot_year(y) -> int: return y -class Adresse(db.Model): +class Adresse(db.Model, models.ScoDocModel): """Adresse d'un étudiant (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) - 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): """Représentation dictionnaire,""" e = dict(self.__dict__) @@ -726,7 +715,7 @@ class Adresse(db.Model): return e -class Admission(db.Model): +class Admission(db.Model, models.ScoDocModel): """Informations liées à l'admission d'un étudiant""" __tablename__ = "admissions" @@ -818,16 +807,6 @@ class Admission(db.Model): args_dict[key] = value 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 class ItemSuivi(db.Model): diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py index 9365ef7c5..71f53bbd5 100644 --- a/app/scodoc/sco_etud.py +++ b/app/scodoc/sco_etud.py @@ -267,33 +267,30 @@ def identite_edit_nocheck(cnx, args): 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. Also check for duplicates (homonyms), excluding etudid : in general, homonyms are allowed, but it may be useful to generate a warning. Returns: - True | False, NbHomonyms + True | False, homonyms """ if not nom or (not prenom and not scu.CONFIG.ALLOW_NULL_PRENOM): - return False, 0 + return False, [] nom = nom.lower().strip() if prenom: prenom = prenom.lower().strip() # 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): return False, 0 - # Now count homonyms (dans tous les départements): - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - req = """SELECT id - FROM identite - WHERE lower(nom) ~ %(nom)s - and lower(prenom) ~ %(prenom)s - """ - if etudid: - req += " and id <> %(etudid)s" - cursor.execute(req, {"nom": nom, "prenom": prenom, "etudid": etudid}) - res = cursor.dictfetchall() - return True, len(res) + # Liste homonymes (dans tous les départements): + query = Identite.query.filter( + Identite.nom.ilike(nom + "%"), Identite.prenom.ilike(prenom + "%") + ) + if etudid is not None: + query = query.filter(Identite.id != etudid) + return True, query.all() def _check_duplicate_code(cnx, args, code_name, disable_notify=False, edit=True): diff --git a/app/scodoc/sco_import_etuds.py b/app/scodoc/sco_import_etuds.py index c2b3b8096..0e554ae07 100644 --- a/app/scodoc/sco_import_etuds.py +++ b/app/scodoc/sco_import_etuds.py @@ -409,18 +409,19 @@ def scolars_import_excel_file( raise ScoValueError("Code INE dupliqué (%s)" % values["code_ine"]) # Check nom/prenom ok = False + homonyms = [] if "nom" in values and "prenom" in values: - ok, nb_homonyms = sco_etud.check_nom_prenom( - cnx, nom=values["nom"], prenom=values["prenom"] + ok, homonyms = sco_etud.check_nom_prenom_homonyms( + nom=values["nom"], prenom=values["prenom"] ) if not ok: 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 # Insert in DB tables - formsemestre_id_etud = _import_one_student( + _import_one_student( cnx, formsemestre_id, values, @@ -431,11 +432,11 @@ def scolars_import_excel_file( ) # 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: log("scolars_import_excel_file: too many homonyms") 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: cnx.rollback() @@ -444,7 +445,7 @@ def scolars_import_excel_file( # here we try to remove all created students cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) 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( "delete from notes_moduleimpl_inscription where etudid=%(etudid)s", {"etudid": etudid}, diff --git a/app/static/js/assiduites.js b/app/static/js/assiduites.js index b98a0296b..6b0e45464 100644 --- a/app/static/js/assiduites.js +++ b/app/static/js/assiduites.js @@ -1701,10 +1701,8 @@ function deleteJustificatif(justif_id) { function errorAlert() { const html = ` -

Avez vous les droits suffisant pour cette action ?

-

Si c'est bien le cas : demandez de l'aide sur le canal Assistance de ScoDoc

-
-

pour les développeurs : l'erreur est affichée dans la console JS

+

Il peut s'agir d'un problème de droits, ou d'une modification survenue sur le serveur.

+

Si le problème persiste, demandez de l'aide sur le Discord d'assistance de ScoDoc

`; const div = document.createElement("div"); diff --git a/app/views/scolar.py b/app/views/scolar.py index c16c0a0d9..e7ac1a4a6 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -54,6 +54,7 @@ from app.decorators import ( permission_required_compat_scodoc7, ) from app.models import ( + Admission, Departement, FormSemestre, Identite, @@ -1374,11 +1375,9 @@ def _etudident_create_or_edit_form(edit): # setup form init values if not etudid: raise ValueError("missing etudid parameter") + etud_o: Identite = Identite.get_etud(etudid) descr.append(("etudid", {"default": etudid, "input_type": "hidden"})) - H.append( - '

Modification d\'un étudiant (fiche)

' - % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) - ) + H.append(f"""

Modification des données de {etud_o.html_link_fiche()}

""") initvalues = sco_etud.etudident_list(cnx, {"etudid": etudid}) assert len(initvalues) == 1 initvalues = initvalues[0] @@ -1724,9 +1723,10 @@ def _etudident_create_or_edit_form(edit): etudid = tf[2]["etudid"] else: etudid = None - ok, NbHomonyms = sco_etud.check_nom_prenom( - cnx, nom=tf[2]["nom"], prenom=tf[2]["prenom"], etudid=etudid + ok, homonyms = sco_etud.check_nom_prenom_homonyms( + nom=tf[2]["nom"], prenom=tf[2]["prenom"], etudid=etudid ) + nb_homonyms = len(homonyms) if not ok: return ( "\n".join(H) @@ -1736,16 +1736,29 @@ def _etudident_create_or_edit_form(edit): + A + F ) - # log('NbHomonyms=%s' % NbHomonyms) - if not tf[2]["dont_check_homonyms"] and NbHomonyms > 0: + if not tf[2]["dont_check_homonyms"] and nb_homonyms > 0: + homonyms_html = f""" +
+
Homonymes (dans tous les départements)
+ +
+ """ return ( "\n".join(H) + 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] + "

" + A + + homonyms_html + F ) @@ -1754,10 +1767,13 @@ def _etudident_create_or_edit_form(edit): etudid = etud["etudid"] else: # modif d'un etudiant - etud_o = db.session.get(Identite, tf[2]["etudid"]) etud_o.from_dict(tf[2]) db.session.add(etud_o) 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]) db.session.add(admission) db.session.commit()