diff --git a/app/__init__.py b/app/__init__.py index 0943a91f..04e28207 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -190,6 +190,7 @@ def create_app(config_class=DevConfig): app.register_error_handler(ScoGenError, handle_sco_value_error) app.register_error_handler(ScoValueError, handle_sco_value_error) + app.register_error_handler(AccessDenied, handle_access_denied) app.register_error_handler(500, internal_server_error) app.register_error_handler(503, postgresql_server_error) diff --git a/app/but/bulletin_but_xml_compat.py b/app/but/bulletin_but_xml_compat.py index 4307788d..5b4a14ca 100644 --- a/app/but/bulletin_but_xml_compat.py +++ b/app/but/bulletin_but_xml_compat.py @@ -72,10 +72,10 @@ def bulletin_but_xml_compat( etud = Identite.query.get_or_404(etudid) results = bulletin_but.ResultatsSemestreBUT(sem) nb_inscrits = len(results.etuds) - if sem.bul_hide_xml or force_publishing: - published = "1" + if (not sem.bul_hide_xml) or force_publishing: + published = 1 else: - published = "0" + published = 0 if xml_nodate: docdate = "" else: @@ -84,7 +84,7 @@ def bulletin_but_xml_compat( "etudid": str(etudid), "formsemestre_id": str(formsemestre_id), "date": docdate, - "publie": published, + "publie": str(published), } if sem.etapes: el["etape_apo"] = sem.etapes[0].etape_apo or "" @@ -117,7 +117,9 @@ def bulletin_but_xml_compat( ) # Disponible pour publication ? if not published: - return doc # stop ! + return sco_xml.XML_HEADER + ElementTree.tostring(doc).decode( + scu.SCO_ENCODING + ) # stop ! # Moyenne générale: doc.append( Element( @@ -218,7 +220,8 @@ def bulletin_but_xml_compat( value=scu.fmt_note( results.modimpls_evals_notes[e.moduleimpl_id][ e.id - ][etud.id] + ][etud.id], + note_max=e.note_max, ), ) ) diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index 41000fd4..f21e6acd 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -36,6 +36,7 @@ import pandas as pd from pandas.core.frame import DataFrame from app import db +from app import log from app import models from app.models import ModuleImpl, Evaluation, EvaluationUEPoids from app.scodoc import sco_utils as scu @@ -79,7 +80,11 @@ def check_moduleimpl_conformity( if nb_ues == 0: return False # situation absurde (pas d'UE) if len(modules_coefficients) != nb_ues: - raise ValueError("check_moduleimpl_conformity: nb ue incoherent") + # bug ? + log( + "check_moduleimpl_conformity: nb ue incoherent (moduleimpl.id={moduleimpl.id})" + ) + return False module_evals_poids = evals_poids.transpose().sum(axis=1).to_numpy() != 0 check = all( (modules_coefficients[moduleimpl.module.id].to_numpy() != 0) diff --git a/app/forms/main/config_forms.py b/app/forms/main/config_forms.py index 03589795..16be8451 100644 --- a/app/forms/main/config_forms.py +++ b/app/forms/main/config_forms.py @@ -245,7 +245,7 @@ class DeptForm(FlaskForm): def _make_dept_id_name(): - """Cette section assute que tous les départements sont traités (y compris ceux qu'ont pas de logo au départ) + """Cette section assure que tous les départements sont traités (y compris ceux qu'ont pas de logo au départ) et détermine l'ordre d'affichage des DeptForm (GLOBAL d'abord, puis par ordre alpha de nom de département) -> [ (None, None), (dept_id, dept_name)... ]""" depts = [(None, GLOBAL)] diff --git a/app/forms/main/create_dept.py b/app/forms/main/create_dept.py index 7bc26b42..cd053405 100644 --- a/app/forms/main/create_dept.py +++ b/app/forms/main/create_dept.py @@ -31,16 +31,10 @@ Formulaires création département from flask import flash, url_for, redirect, render_template from flask_wtf import FlaskForm -from wtforms import SelectField, SubmitField, FormField, validators, FieldList -from wtforms.fields.simple import StringField, HiddenField +from wtforms import SubmitField, validators +from wtforms.fields.simple import StringField, BooleanField -from app import AccessDenied -from app.models import Departement -from app.models import ScoPreference from app.models import SHORT_STR_LEN -from app.scodoc import sco_utils as scu - -from flask_login import current_user class CreateDeptForm(FlaskForm): @@ -60,5 +54,10 @@ class CreateDeptForm(FlaskForm): validators.DataRequired("acronyme du département requis"), ], ) + # description = StringField(label="Description") + visible = BooleanField( + "Visible sur page d'accueil", + default=True, + ) submit = SubmitField("Valider") cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) diff --git a/app/models/departements.py b/app/models/departements.py index 0734e35b..1e6b5a1f 100644 --- a/app/models/departements.py +++ b/app/models/departements.py @@ -12,8 +12,10 @@ class Departement(db.Model): """Un département ScoDoc""" id = db.Column(db.Integer, primary_key=True) - acronym = db.Column(db.String(SHORT_STR_LEN), nullable=False, index=True) - description = db.Column(db.Text()) + acronym = db.Column( + db.String(SHORT_STR_LEN), nullable=False, index=True + ) # ne change jamais, voir la pref. DeptName + description = db.Column(db.Text()) # pas utilisé par ScoDoc : voir DeptFullName date_creation = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) visible = db.Column( db.Boolean(), nullable=False, default=True, server_default="true" @@ -49,11 +51,11 @@ class Departement(db.Model): return dept -def create_dept(acronym: str) -> Departement: +def create_dept(acronym: str, visible=True) -> Departement: "Create new departement" from app.models import ScoPreference - departement = Departement(acronym=acronym) + departement = Departement(acronym=acronym, visible=visible) p1 = ScoPreference(name="DeptName", value=acronym, departement=departement) db.session.add(p1) db.session.add(departement) diff --git a/app/models/formations.py b/app/models/formations.py index 3abbf388..1dfd8728 100644 --- a/app/models/formations.py +++ b/app/models/formations.py @@ -97,14 +97,18 @@ class Formation(db.Model): for sem in self.formsemestres: sco_cache.invalidate_formsemestre(formsemestre_id=sem.id) - def force_semestre_modules_aux_ues(self) -> None: + def sanitize_old_formation(self) -> None: """ - Affecte à chaque module de cette formation le semestre de son UE de rattachement, + Corrige si nécessaire certains champs issus d'anciennes versions de ScoDoc: + - affecte à chaque module de cette formation le semestre de son UE de rattachement, si elle en a une. + - si le module_type n'est pas renseigné, le met à STANDARD. + Devrait être appelé lorsqu'on change le type de formation vers le BUT, et aussi lorsqu'on change le semestre d'une UE BUT. Utile pour la migration des anciennes formations vers le BUT. - Invalide les caches coefs/poids. + + En cas de changement, invalide les caches coefs/poids. """ if not self.is_apc(): return @@ -118,6 +122,10 @@ class Formation(db.Model): mod.semestre_id = mod.ue.semestre_idx db.session.add(mod) change = True + if mod.module_type is None: + mod.module_type = scu.ModuleType.STANDARD + db.session.add(mod) + change = True db.session.commit() if change: self.invalidate_module_coefs() diff --git a/app/models/modules.py b/app/models/modules.py index 00a6d4c9..8f305507 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -49,9 +49,7 @@ class Module(db.Model): super(Module, self).__init__(**kwargs) def __repr__(self): - return ( - f"" - ) + return f"" def to_dict(self): e = dict(self.__dict__) diff --git a/app/models/ues.py b/app/models/ues.py index 5f99bdc2..26223eef 100644 --- a/app/models/ues.py +++ b/app/models/ues.py @@ -46,7 +46,10 @@ class UniteEns(db.Model): modules = db.relationship("Module", lazy="dynamic", backref="ue") def __repr__(self): - return f"<{self.__class__.__name__}(id={self.id}, formation_id={self.formation_id}, acronyme='{self.acronyme}')>" + return f"""<{self.__class__.__name__}(id={self.id}, formation_id={ + self.formation_id}, acronyme='{self.acronyme}', semestre_idx={ + self.semestre_idx} { + 'EXTERNE' if self.is_external else ''})>""" def to_dict(self): """as a dict, with the same conversions as in ScoDoc7""" diff --git a/app/scodoc/bonus_sport.py b/app/scodoc/bonus_sport.py index afc05e26..e34909e6 100644 --- a/app/scodoc/bonus_sport.py +++ b/app/scodoc/bonus_sport.py @@ -416,6 +416,20 @@ def bonus_iutbeziers(notes_sport, coefs, infos=None): return bonus +def bonus_iutlr(notes_sport, coefs, infos=None): + """Calcul bonus modules optionels (sport, culture), règle IUT La Rochelle + Si la note de sport est comprise entre 0 et 10 : pas d'ajout de point + Si la note de sport est comprise entre 10.1 et 20 : ajout de 1% de cette note sur la moyenne générale du semestre + """ + # les coefs sont ignorés + # une seule note + note_sport = notes_sport[0] + if note_sport <= 10: + return 0 + bonus = note_sport * 0.01 # 1% + return bonus + + def bonus_demo(notes_sport, coefs, infos=None): """Fausse fonction "bonus" pour afficher les informations disponibles et aider les développeurs. diff --git a/app/scodoc/sco_apogee_compare.py b/app/scodoc/sco_apogee_compare.py index 61647d70..86e2f334 100644 --- a/app/scodoc/sco_apogee_compare.py +++ b/app/scodoc/sco_apogee_compare.py @@ -108,13 +108,14 @@ def apo_compare_csv(A_file, B_file, autodetect=True): def _load_apo_data(csvfile, autodetect=True): "Read data from request variable and build ApoData" - data = csvfile.read() + data_b = csvfile.read() if autodetect: - data, message = sco_apogee_csv.fix_data_encoding(data) + data_b, message = sco_apogee_csv.fix_data_encoding(data_b) if message: log("apo_compare_csv: %s" % message) - if not data: + if not data_b: raise ScoValueError("apo_compare_csv: no data") + data = data_b.decode(sco_apogee_csv.APO_INPUT_ENCODING) apo_data = sco_apogee_csv.ApoData(data, orig_filename=csvfile.filename) return apo_data diff --git a/app/scodoc/sco_apogee_csv.py b/app/scodoc/sco_apogee_csv.py index e8569cde..f97cc895 100644 --- a/app/scodoc/sco_apogee_csv.py +++ b/app/scodoc/sco_apogee_csv.py @@ -173,8 +173,10 @@ def guess_data_encoding(text, threshold=0.6): def fix_data_encoding( - text, default_source_encoding=APO_INPUT_ENCODING, dest_encoding=APO_INPUT_ENCODING -): + text: bytes, + default_source_encoding=APO_INPUT_ENCODING, + dest_encoding=APO_INPUT_ENCODING, +) -> bytes: """Try to ensure that text is using dest_encoding returns converted text, and a message describing the conversion. """ @@ -200,7 +202,7 @@ def fix_data_encoding( class StringIOFileLineWrapper(object): - def __init__(self, data): + def __init__(self, data: str): self.f = io.StringIO(data) self.lineno = 0 @@ -655,7 +657,7 @@ class ApoEtud(dict): class ApoData(object): def __init__( self, - data, + data: str, periode=None, export_res_etape=True, export_res_sem=True, @@ -693,7 +695,7 @@ class ApoData(object): "

Erreur lecture du fichier Apogée %s

" % filename + e.args[0] + "

" - ) + ) from e self.etape_apogee = self.get_etape_apogee() # 'V1RT' self.vdi_apogee = self.get_vdi_apogee() # '111' self.etape = ApoEtapeVDI(etape=self.etape_apogee, vdi=self.vdi_apogee) @@ -760,7 +762,6 @@ class ApoData(object): def read_csv(self, data: str): if not data: raise ScoFormatError("Fichier Apogée vide !") - f = StringIOFileLineWrapper(data) # pour traiter comme un fichier # check that we are at the begining of Apogee CSV line = f.readline().strip() @@ -768,7 +769,10 @@ class ApoData(object): raise ScoFormatError("format incorrect: pas de XX-APO_TITRES-XX") # 1-- En-tête: du début jusqu'à la balise XX-APO_VALEURS-XX - idx = data.index("XX-APO_VALEURS-XX") + try: + idx = data.index("XX-APO_VALEURS-XX") + except ValueError as exc: + raise ScoFormatError("format incorrect: pas de XX-APO_VALEURS-XX") from exc self.header = data[:idx] # 2-- Titres: @@ -1178,7 +1182,7 @@ def nar_etuds_table(apo_data, NAR_Etuds): def export_csv_to_apogee( - apo_csv_data, + apo_csv_data: str, periode=None, dest_zip=None, export_res_etape=True, diff --git a/app/scodoc/sco_bulletins_xml.py b/app/scodoc/sco_bulletins_xml.py index bf9c63e9..6641c178 100644 --- a/app/scodoc/sco_bulletins_xml.py +++ b/app/scodoc/sco_bulletins_xml.py @@ -93,9 +93,9 @@ def make_xml_formsemestre_bulletinetud( ) if (not sem["bul_hide_xml"]) or force_publishing: - published = "1" + published = 1 else: - published = "0" + published = 0 if xml_nodate: docdate = "" else: @@ -105,7 +105,7 @@ def make_xml_formsemestre_bulletinetud( "etudid": str(etudid), "formsemestre_id": str(formsemestre_id), "date": docdate, - "publie": published, + "publie": str(published), } if sem["etapes"]: el["etape_apo"] = str(sem["etapes"][0]) or "" @@ -141,7 +141,9 @@ def make_xml_formsemestre_bulletinetud( # Disponible pour publication ? if not published: - return doc # stop ! + return sco_xml.XML_HEADER + ElementTree.tostring(doc).decode( + scu.SCO_ENCODING + ) # stop ! # Groupes: partitions = sco_groups.get_partitions_list(formsemestre_id, with_default=False) diff --git a/app/scodoc/sco_edit_apc.py b/app/scodoc/sco_edit_apc.py index 773cf81d..a9eff6de 100644 --- a/app/scodoc/sco_edit_apc.py +++ b/app/scodoc/sco_edit_apc.py @@ -62,7 +62,8 @@ def html_edit_formation_apc( else: semestre_ids = [semestre_idx] other_modules = formation.modules.filter( - Module.module_type != ModuleType.SAE, Module.module_type != ModuleType.RESSOURCE + Module.module_type.is_distinct_from(ModuleType.SAE), + Module.module_type.is_distinct_from(ModuleType.RESSOURCE), ).order_by( Module.semestre_id, Module.module_type.desc(), Module.numero, Module.code ) @@ -78,7 +79,8 @@ def html_edit_formation_apc( alt="supprimer", ), "delete_disabled": scu.icontag( - "delete_small_dis_img", title="Suppression impossible (module utilisé)" + "delete_small_dis_img", + title="Suppression impossible (utilisé dans des semestres)", ), } diff --git a/app/scodoc/sco_edit_formation.py b/app/scodoc/sco_edit_formation.py index 33390c1a..a126fceb 100644 --- a/app/scodoc/sco_edit_formation.py +++ b/app/scodoc/sco_edit_formation.py @@ -304,9 +304,9 @@ def do_formation_edit(args): cnx = ndb.GetDBConnexion() sco_formations._formationEditor.edit(cnx, args) - formation = Formation.query.get(args["formation_id"]) + formation: Formation = Formation.query.get(args["formation_id"]) formation.invalidate_cached_sems() - formation.force_semestre_modules_aux_ues() + formation.sanitize_old_formation() def module_move(module_id, after=0, redirect=True): diff --git a/app/scodoc/sco_edit_matiere.py b/app/scodoc/sco_edit_matiere.py index 62a7e1d2..13ddb2a9 100644 --- a/app/scodoc/sco_edit_matiere.py +++ b/app/scodoc/sco_edit_matiere.py @@ -30,13 +30,18 @@ """ import flask from flask import g, url_for, request +from app.models.formations import Matiere import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu from app import log from app.models import Formation from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message -from app.scodoc.sco_exceptions import ScoValueError, ScoLockedFormError +from app.scodoc.sco_exceptions import ( + ScoValueError, + ScoLockedFormError, + ScoNonEmptyFormationObject, +) from app.scodoc import html_sco_header _matiereEditor = ndb.EditableTable( @@ -156,6 +161,16 @@ associé. return flask.redirect(dest_url) +def can_delete_matiere(matiere: Matiere) -> tuple[bool, str]: + "True si la matiere n'est pas utilisée dans des formsemestre" + locked = matiere_is_locked(matiere.id) + if locked: + return False + if any(m.modimpls.all() for m in matiere.modules): + return False + return True + + def do_matiere_delete(oid): "delete matiere and attached modules" from app.scodoc import sco_formations @@ -165,17 +180,16 @@ def do_matiere_delete(oid): cnx = ndb.GetDBConnexion() # check - mat = matiere_list({"matiere_id": oid})[0] + matiere = Matiere.query.get_or_404(oid) + mat = matiere_list({"matiere_id": oid})[0] # compat sco7 ue = sco_edit_ue.ue_list({"ue_id": mat["ue_id"]})[0] - locked = matiere_is_locked(mat["matiere_id"]) - if locked: - log("do_matiere_delete: mat=%s" % mat) - log("do_matiere_delete: ue=%s" % ue) - log("do_matiere_delete: locked sems: %s" % locked) - raise ScoLockedFormError() - log("do_matiere_delete: matiere_id=%s" % oid) + if not can_delete_matiere(matiere): + # il y a au moins un modimpl dans un module de cette matière + raise ScoNonEmptyFormationObject("Matière", matiere.titre) + + log("do_matiere_delete: matiere_id=%s" % matiere.id) # delete all modules in this matiere - mods = sco_edit_module.module_list({"matiere_id": oid}) + mods = sco_edit_module.module_list({"matiere_id": matiere.id}) for mod in mods: sco_edit_module.do_module_delete(mod["module_id"]) _matiereEditor.delete(cnx, oid) @@ -194,11 +208,25 @@ def matiere_delete(matiere_id=None): """Delete matière""" from app.scodoc import sco_edit_ue - M = matiere_list(args={"matiere_id": matiere_id})[0] - UE = sco_edit_ue.ue_list(args={"ue_id": M["ue_id"]})[0] + matiere = Matiere.query.get_or_404(matiere_id) + if not can_delete_matiere(matiere): + # il y a au moins un modimpl dans un module de cette matière + raise ScoNonEmptyFormationObject( + "Matière", + matiere.titre, + dest_url=url_for( + "notes.ue_table", + formation_id=matiere.ue.formation_id, + semestre_idx=matiere.ue.semestre_idx, + scodoc_dept=g.scodoc_dept, + ), + ) + + mat = matiere_list(args={"matiere_id": matiere_id})[0] + UE = sco_edit_ue.ue_list(args={"ue_id": mat["ue_id"]})[0] H = [ html_sco_header.sco_header(page_title="Suppression d'une matière"), - "

Suppression de la matière %(titre)s" % M, + "

Suppression de la matière %(titre)s" % mat, " dans l'UE (%(acronyme)s))

" % UE, ] dest_url = url_for( @@ -210,7 +238,7 @@ def matiere_delete(matiere_id=None): request.base_url, scu.get_request_args(), (("matiere_id", {"input_type": "hidden"}),), - initvalues=M, + initvalues=mat, submitlabel="Confirmer la suppression", cancelbutton="Annuler", ) diff --git a/app/scodoc/sco_edit_module.py b/app/scodoc/sco_edit_module.py index 1c3481b0..813320d5 100644 --- a/app/scodoc/sco_edit_module.py +++ b/app/scodoc/sco_edit_module.py @@ -43,7 +43,12 @@ from app import models from app.models import Formation from app.scodoc.TrivialFormulator import TrivialFormulator from app.scodoc.sco_permissions import Permission -from app.scodoc.sco_exceptions import ScoValueError, ScoLockedFormError, ScoGenError +from app.scodoc.sco_exceptions import ( + ScoValueError, + ScoLockedFormError, + ScoGenError, + ScoNonEmptyFormationObject, +) from app.scodoc import html_sco_header from app.scodoc import sco_codes_parcours from app.scodoc import sco_edit_matiere @@ -330,20 +335,37 @@ def module_create(matiere_id=None, module_type=None, semestre_id=None): ) +def can_delete_module(module): + "True si le module n'est pas utilisée dans des formsemestre" + return len(module.modimpls.all()) == 0 + + def do_module_delete(oid): "delete module" from app.scodoc import sco_formations - mod = module_list({"module_id": oid})[0] - if module_is_locked(mod["module_id"]): + module = Module.query.get_or_404(oid) + mod = module_list({"module_id": oid})[0] # sco7 + if module_is_locked(module.id): raise ScoLockedFormError() + if not can_delete_module(module): + raise ScoNonEmptyFormationObject( + "Module", + msg=module.titre, + dest_url=url_for( + "notes.ue_table", + scodoc_dept=g.scodoc_dept, + formation_id=module.formation_id, + semestre_idx=module.ue.semestre_idx, + ), + ) # S'il y a des moduleimpls, on ne peut pas detruire le module ! mods = sco_moduleimpl.moduleimpl_list(module_id=oid) if mods: err_page = f"""

Destruction du module impossible car il est utilisé dans des semestres existants !

-

Il faut d'abord supprimer le semestre. Mais il est peut être préférable de - laisser ce programme intact et d'en créer une nouvelle version pour la modifier. +

Il faut d'abord supprimer le semestre (ou en retirer ce module). Mais il est peut être préférable de + laisser ce programme intact et d'en créer une nouvelle version pour la modifier sans affecter les semestres déjà en place.

reprendre @@ -365,12 +387,21 @@ def do_module_delete(oid): def module_delete(module_id=None): """Delete a module""" - if not module_id: - raise ScoValueError("invalid module !") - modules = module_list(args={"module_id": module_id}) - if not modules: - raise ScoValueError("Module inexistant !") - mod = modules[0] + module = Module.query.get_or_404(module_id) + mod = module_list(args={"module_id": module_id})[0] # sco7 + + if not can_delete_module(module): + raise ScoNonEmptyFormationObject( + "Module", + msg=module.titre, + dest_url=url_for( + "notes.ue_table", + scodoc_dept=g.scodoc_dept, + formation_id=module.formation_id, + semestre_idx=module.ue.semestre_idx, + ), + ) + H = [ html_sco_header.sco_header(page_title="Suppression d'un module"), """

Suppression du module %(titre)s (%(code)s)

""" % mod, diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index bdf37375..14f8a2c9 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -42,7 +42,12 @@ from app import log from app.scodoc.TrivialFormulator import TrivialFormulator, TF from app.scodoc.gen_tables import GenTable from app.scodoc.sco_permissions import Permission -from app.scodoc.sco_exceptions import ScoValueError, ScoLockedFormError +from app.scodoc.sco_exceptions import ( + ScoGenError, + ScoValueError, + ScoLockedFormError, + ScoNonEmptyFormationObject, +) from app.scodoc import html_sco_header from app.scodoc import sco_cache @@ -130,64 +135,83 @@ def do_ue_create(args): return ue_id +def can_delete_ue(ue: UniteEns) -> bool: + """True si l'UE n'est pas utilisée dans des formsemestre + et n'a pas de module rattachés + """ + # "pas un seul module de cette UE n'a de modimpl..."" + return (not len(ue.modules.all())) and not any(m.modimpls.all() for m in ue.modules) + + def do_ue_delete(ue_id, delete_validations=False, force=False): "delete UE and attached matieres (but not modules)" from app.scodoc import sco_formations from app.scodoc import sco_parcours_dut + ue = UniteEns.query.get_or_404(ue_id) + if not can_delete_ue(ue): + raise ScoNonEmptyFormationObject( + "UE", + msg=ue.titre, + dest_url=url_for( + "notes.ue_table", + scodoc_dept=g.scodoc_dept, + formation_id=ue.formation_id, + semestre_idx=ue.semestre_idx, + ), + ) + cnx = ndb.GetDBConnexion() - log("do_ue_delete: ue_id=%s, delete_validations=%s" % (ue_id, delete_validations)) + log("do_ue_delete: ue_id=%s, delete_validations=%s" % (ue.id, delete_validations)) # check - ue = ue_list({"ue_id": ue_id}) - if not ue: - raise ScoValueError("UE inexistante !") - ue = ue[0] - if ue_is_locked(ue["ue_id"]): - raise ScoLockedFormError() + # if ue_is_locked(ue.id): + # raise ScoLockedFormError() # Il y a-t-il des etudiants ayant validé cette UE ? # si oui, propose de supprimer les validations validations = sco_parcours_dut.scolar_formsemestre_validation_list( - cnx, args={"ue_id": ue_id} + cnx, args={"ue_id": ue.id} ) if validations and not delete_validations and not force: return scu.confirm_dialog( "

%d étudiants ont validé l'UE %s (%s)

Si vous supprimez cette UE, ces validations vont être supprimées !

" - % (len(validations), ue["acronyme"], ue["titre"]), + % (len(validations), ue.acronyme, ue.titre), dest_url="", target_variable="delete_validations", cancel_url=url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, - formation_id=str(ue["formation_id"]), + formation_id=ue.formation_id, + semestre_idx=ue.semestre_idx, ), - parameters={"ue_id": ue_id, "dialog_confirmed": 1}, + parameters={"ue_id": ue.id, "dialog_confirmed": 1}, ) if delete_validations: - log("deleting all validations of UE %s" % ue_id) + log("deleting all validations of UE %s" % ue.id) ndb.SimpleQuery( "DELETE FROM scolar_formsemestre_validation WHERE ue_id=%(ue_id)s", - {"ue_id": ue_id}, + {"ue_id": ue.id}, ) # delete all matiere in this UE - mats = sco_edit_matiere.matiere_list({"ue_id": ue_id}) + mats = sco_edit_matiere.matiere_list({"ue_id": ue.id}) for mat in mats: sco_edit_matiere.do_matiere_delete(mat["matiere_id"]) # delete uecoef and events ndb.SimpleQuery( "DELETE FROM notes_formsemestre_uecoef WHERE ue_id=%(ue_id)s", - {"ue_id": ue_id}, + {"ue_id": ue.id}, ) - ndb.SimpleQuery("DELETE FROM scolar_events WHERE ue_id=%(ue_id)s", {"ue_id": ue_id}) + ndb.SimpleQuery("DELETE FROM scolar_events WHERE ue_id=%(ue_id)s", {"ue_id": ue.id}) cnx = ndb.GetDBConnexion() - _ueEditor.delete(cnx, ue_id) - # > UE delete + supr. validations associées etudiants (cas compliqué, mais rarement utilisé: acceptable de tout invalider ?): + _ueEditor.delete(cnx, ue.id) + # > UE delete + supr. validations associées etudiants (cas compliqué, mais rarement + # utilisé: acceptable de tout invalider): sco_cache.invalidate_formsemestre() # news - F = sco_formations.formation_list(args={"formation_id": ue["formation_id"]})[0] + F = sco_formations.formation_list(args={"formation_id": ue.formation_id})[0] sco_news.add( typ=sco_news.NEWS_FORM, - object=ue["formation_id"], + object=ue.formation_id, text="Modification de la formation %(acronyme)s" % F, max_frequency=3, ) @@ -197,11 +221,11 @@ def do_ue_delete(ue_id, delete_validations=False, force=False): url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, - formation_id=ue["formation_id"], + formation_id=ue.formation_id, + semestre_idx=ue.semestre_idx, ) ) - else: - return None + return None def ue_create(formation_id=None): @@ -211,8 +235,6 @@ def ue_create(formation_id=None): def ue_edit(ue_id=None, create=False, formation_id=None): """Modification ou création d'une UE""" - from app.scodoc import sco_formations - create = int(create) if not create: U = ue_list(args={"ue_id": ue_id}) @@ -444,36 +466,50 @@ def next_ue_numero(formation_id, semestre_id=None): def ue_delete(ue_id=None, delete_validations=False, dialog_confirmed=False): """Delete an UE""" - ues = ue_list(args={"ue_id": ue_id}) - if not ues: - raise ScoValueError("UE inexistante !") - ue = ues[0] - - if not dialog_confirmed: - return scu.confirm_dialog( - "

Suppression de l'UE %(titre)s (%(acronyme)s))

" % ue, - dest_url="", - parameters={"ue_id": ue_id}, - cancel_url=url_for( + ue = UniteEns.query.get_or_404(ue_id) + if ue.modules.all(): + raise ScoValueError( + f"""Suppression de l'UE {ue.titre} impossible car + des modules (ou SAÉ ou ressources) lui sont rattachés.""" + ) + if not can_delete_ue(ue): + raise ScoNonEmptyFormationObject( + "UE", + msg=ue.titre, + dest_url=url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, - formation_id=str(ue["formation_id"]), + formation_id=ue.formation_id, + semestre_idx=ue.semestre_idx, ), ) - return do_ue_delete(ue_id, delete_validations=delete_validations) + if not dialog_confirmed: + return scu.confirm_dialog( + f"

Suppression de l'UE {ue.titre} ({ue.acronyme})

", + dest_url="", + parameters={"ue_id": ue.id}, + cancel_url=url_for( + "notes.ue_table", + scodoc_dept=g.scodoc_dept, + formation_id=ue.formation_id, + semestre_idx=ue.semestre_idx, + ), + ) + + return do_ue_delete(ue.id, delete_validations=delete_validations) def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list """Liste des matières et modules d'une formation, avec liens pour éditer (si non verrouillée). """ - from app.scodoc import sco_formations from app.scodoc import sco_formsemestre_validation - formation = Formation.query.get(formation_id) + formation: Formation = Formation.query.get(formation_id) if not formation: raise ScoValueError("invalid formation_id") + formation.sanitize_old_formation() parcours = formation.get_parcours() is_apc = parcours.APC_SAE locked = formation.has_locked_sems() @@ -1010,12 +1046,14 @@ def _ue_table_modules( H.append(arrow_none) im += 1 if mod["nb_moduleimpls"] == 0 and editable: - H.append( - '%s' - % (mod["module_id"], delete_icon) - ) + icon = delete_icon else: - H.append(delete_disabled_icon) + icon = delete_disabled_icon + H.append( + '%s' + % (mod["module_id"], icon) + ) + H.append("") mod_editable = ( @@ -1167,7 +1205,7 @@ def do_ue_edit(args, bypass_lock=False, dont_invalidate_cache=False): if not dont_invalidate_cache: # Invalide les semestres utilisant cette formation: formation.invalidate_cached_sems() - formation.force_semestre_modules_aux_ues() + formation.sanitize_old_formation() # essai edition en ligne: diff --git a/app/scodoc/sco_etape_apogee.py b/app/scodoc/sco_etape_apogee.py index e997ea66..da61421f 100644 --- a/app/scodoc/sco_etape_apogee.py +++ b/app/scodoc/sco_etape_apogee.py @@ -43,7 +43,7 @@ apo_csv_get() API: - apo_csv_store( annee_scolaire, sem_id) + # apo_csv_store(csv_data, annee_scolaire, sem_id) store maq file (archive) apo_csv_get(etape_apo, annee_scolaire, sem_id, vdi_apo=None) @@ -101,7 +101,7 @@ ApoCSVArchive = ApoCSVArchiver() def apo_csv_store(csv_data: str, annee_scolaire, sem_id): """ - csv_data: maquette content (string) + csv_data: maquette content (str)) annee_scolaire: int (2016) sem_id: 0 (année ?), 1 (premier semestre de l'année) ou 2 (deuxième semestre) :return: etape_apo du fichier CSV stocké @@ -378,7 +378,7 @@ e.associate_sco( apo_data) print apo_csv_list_stored_archives() -apo_csv_store(csv_data, annee_scolaire, sem_id) +# apo_csv_store(csv_data, annee_scolaire, sem_id) diff --git a/app/scodoc/sco_etape_apogee_view.py b/app/scodoc/sco_etape_apogee_view.py index 7660191c..b784e5f0 100644 --- a/app/scodoc/sco_etape_apogee_view.py +++ b/app/scodoc/sco_etape_apogee_view.py @@ -48,7 +48,7 @@ from app.scodoc import sco_preferences from app.scodoc import sco_semset from app.scodoc import sco_etud from app.scodoc.gen_tables import GenTable -from app.scodoc.sco_apogee_csv import APO_PORTAL_ENCODING, APO_INPUT_ENCODING +from app.scodoc.sco_apogee_csv import APO_INPUT_ENCODING, APO_OUTPUT_ENCODING from app.scodoc.sco_exceptions import ScoValueError @@ -585,7 +585,7 @@ def _view_etuds_page(semset_id, title="", etuds=[], keys=(), format="html"): return "\n".join(H) + html_sco_header.sco_footer() -def view_apo_csv_store(semset_id="", csvfile=None, data="", autodetect=False): +def view_apo_csv_store(semset_id="", csvfile=None, data: bytes = "", autodetect=False): """Store CSV data Le semset identifie l'annee scolaire et le semestre Si csvfile, lit depuis FILE, sinon utilise data @@ -593,9 +593,8 @@ def view_apo_csv_store(semset_id="", csvfile=None, data="", autodetect=False): if not semset_id: raise ValueError("invalid null semset_id") semset = sco_semset.SemSet(semset_id=semset_id) - if csvfile: - data = csvfile.read() + data = csvfile.read() # bytes if autodetect: # check encoding (although documentation states that users SHOULD upload LATIN1) data, message = sco_apogee_csv.fix_data_encoding(data) @@ -605,19 +604,26 @@ def view_apo_csv_store(semset_id="", csvfile=None, data="", autodetect=False): log("view_apo_csv_store: autodetection of encoding disabled by user") if not data: raise ScoValueError("view_apo_csv_store: no data") - + # data est du bytes, encodé en APO_INPUT_ENCODING + data_str = data.decode(APO_INPUT_ENCODING) # check si etape maquette appartient bien au semset apo_data = sco_apogee_csv.ApoData( - data, periode=semset["sem_id"] + data_str, periode=semset["sem_id"] ) # parse le fichier -> exceptions if apo_data.etape not in semset["etapes"]: raise ScoValueError( "Le code étape de ce fichier ne correspond pas à ceux de cet ensemble" ) - sco_etape_apogee.apo_csv_store(data, semset["annee_scolaire"], semset["sem_id"]) + sco_etape_apogee.apo_csv_store(data_str, semset["annee_scolaire"], semset["sem_id"]) - return flask.redirect("apo_semset_maq_status?semset_id=" + semset_id) + return flask.redirect( + url_for( + "notes.apo_semset_maq_status", + scodoc_dept=g.scodoc_dept, + semset_id=semset_id, + ) + ) def view_apo_csv_download_and_store(etape_apo="", semset_id=""): @@ -629,9 +635,9 @@ def view_apo_csv_download_and_store(etape_apo="", semset_id=""): data = sco_portal_apogee.get_maquette_apogee( etape=etape_apo, annee_scolaire=semset["annee_scolaire"] ) - # here, data is utf8 + # here, data is str # but we store and generate latin1 files, to ease further import in Apogée - data = data.decode(APO_PORTAL_ENCODING).encode(APO_INPUT_ENCODING) # XXX #py3 + data = data.encode(APO_OUTPUT_ENCODING) return view_apo_csv_store(semset_id, data=data, autodetect=False) @@ -669,7 +675,7 @@ def view_apo_csv(etape_apo="", semset_id="", format="html"): sem_id = semset["sem_id"] csv_data = sco_etape_apogee.apo_csv_get(etape_apo, annee_scolaire, sem_id) if format == "raw": - scu.send_file(csv_data, etape_apo, suffix=".txt", mime=scu.CSV_MIMETYPE) + return scu.send_file(csv_data, etape_apo, suffix=".txt", mime=scu.CSV_MIMETYPE) apo_data = sco_apogee_csv.ApoData(csv_data, periode=semset["sem_id"]) diff --git a/app/scodoc/sco_exceptions.py b/app/scodoc/sco_exceptions.py index 635a724b..1099986b 100644 --- a/app/scodoc/sco_exceptions.py +++ b/app/scodoc/sco_exceptions.py @@ -52,7 +52,7 @@ class InvalidNoteValue(ScoException): # Exception qui stoque dest_url, utilisee dans Zope standard_error_message class ScoValueError(ScoException): def __init__(self, msg, dest_url=None): - ScoException.__init__(self, msg) + super().__init__(msg) self.dest_url = dest_url @@ -72,20 +72,35 @@ class ScoConfigurationError(ScoValueError): pass -class ScoLockedFormError(ScoException): - def __init__(self, msg=""): +class ScoLockedFormError(ScoValueError): + "Modification d'une formation verrouillée" + + def __init__(self, msg="", dest_url=None): msg = ( "Cette formation est verrouillée (car il y a un semestre verrouillé qui s'y réfère). " + str(msg) ) - ScoException.__init__(self, msg) + super().__init__(msg=msg, dest_url=dest_url) + + +class ScoNonEmptyFormationObject(ScoValueError): + """On ne peut pas supprimer un module/matiere ou UE si des formsemestre s'y réfèrent""" + + def __init__(self, type_objet="objet'", msg="", dest_url=None): + msg = f"""

{type_objet} "{msg}" utilisé dans des semestres: suppression impossible.

+

Il faut d'abord supprimer le semestre (ou en retirer ce {type_objet}). + Mais il est peut-être préférable de laisser ce programme intact et d'en créer une + nouvelle version pour la modifier sans affecter les semestres déjà en place. +

+ """ + super().__init__(msg=msg, dest_url=dest_url) class ScoGenError(ScoException): "exception avec affichage d'une page explicative ad-hoc" def __init__(self, msg=""): - ScoException.__init__(self, msg) + super().__init__(msg) class AccessDenied(ScoGenError): @@ -101,7 +116,7 @@ class APIInvalidParams(Exception): status_code = 400 def __init__(self, message, status_code=None, payload=None): - Exception.__init__(self) + super().__init__() self.message = message if status_code is not None: self.status_code = status_code diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index 6c889c54..060289e9 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -198,10 +198,13 @@ def do_formsemestre_createwithmodules(edit=False): NB_SEM = parcours.NB_SEM else: NB_SEM = 10 # fallback, max 10 semestres - semestre_id_list = [-1] + list(range(1, NB_SEM + 1)) + if NB_SEM == 1: + semestre_id_list = [-1] + else: + semestre_id_list = [-1] + list(range(1, NB_SEM + 1)) semestre_id_labels = [] for sid in semestre_id_list: - if sid == "-1": + if sid == -1: semestre_id_labels.append("pas de semestres") else: semestre_id_labels.append(f"S{sid}") @@ -329,6 +332,8 @@ def do_formsemestre_createwithmodules(edit=False): "labels": modalites_titles, }, ), + ] + modform.append( ( "semestre_id", { @@ -338,7 +343,7 @@ def do_formsemestre_createwithmodules(edit=False): "labels": semestre_id_labels, }, ), - ] + ) etapes = sco_portal_apogee.get_etapes_apogee_dept() # Propose les etapes renvoyées par le portail # et ajoute les étapes du semestre qui ne sont pas dans la liste (soit la liste a changé, soit l'étape a été ajoutée manuellement) diff --git a/app/scodoc/sco_inscr_passage.py b/app/scodoc/sco_inscr_passage.py index d486b242..ab27ecda 100644 --- a/app/scodoc/sco_inscr_passage.py +++ b/app/scodoc/sco_inscr_passage.py @@ -292,6 +292,8 @@ def formsemestre_inscr_passage( etuds = [int(x) for x in etuds.split(",") if x] elif isinstance(etuds, int): etuds = [etuds] + elif etuds and isinstance(etuds[0], str): + etuds = [int(x) for x in etuds] auth_etuds_by_sem, inscrits, candidats = list_authorized_etuds_by_sem(sem) etuds_set = set(etuds) diff --git a/app/scodoc/sco_portal_apogee.py b/app/scodoc/sco_portal_apogee.py index 74015be1..f78e9003 100644 --- a/app/scodoc/sco_portal_apogee.py +++ b/app/scodoc/sco_portal_apogee.py @@ -544,7 +544,7 @@ def check_paiement_etuds(etuds): etud["paiementinscription_str"] = "(pb cnx Apogée)" -def get_maquette_apogee(etape="", annee_scolaire=""): +def get_maquette_apogee(etape="", annee_scolaire="") -> str: """Maquette CSV Apogee pour une étape et une annee scolaire""" maquette_url = get_maquette_url() if not maquette_url: diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index cc99be08..da3e1a4e 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -279,6 +279,7 @@ class BasePreferences(object): { "initvalue": "Dept", "title": "Nom abrégé du département", + "explanation": "acronyme: par exemple R&T, ORTF, HAL", "size": 12, "category": "general", "only_global": True, @@ -289,7 +290,7 @@ class BasePreferences(object): { "initvalue": "nom du département", "title": "Nom complet du département", - "explanation": "inutilisé par défaut", + "explanation": "apparaît sur la page d'accueil", "size": 40, "category": "general", "only_global": True, diff --git a/app/scodoc/sco_report.py b/app/scodoc/sco_report.py index d480679b..6c2582ef 100644 --- a/app/scodoc/sco_report.py +++ b/app/scodoc/sco_report.py @@ -49,16 +49,12 @@ from app.scodoc import sco_etud from app.scodoc import sco_excel from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre_inscriptions -from app.scodoc import sco_formsemestre_status from app.scodoc import sco_parcours_dut -from app.scodoc import sco_pdf from app.scodoc import sco_preferences import sco_version from app.scodoc.gen_tables import GenTable from app import log from app.scodoc.sco_codes_parcours import code_semestre_validant -from app.scodoc.sco_exceptions import ScoValueError -from app.scodoc.sco_pdf import SU MAX_ETUD_IN_DESCR = 20 @@ -121,9 +117,9 @@ def _categories_and_results(etuds, category, result): categories[etud[category]] = True results[etud[result]] = True categories = list(categories.keys()) - categories.sort() + categories.sort(key=scu.heterogeneous_sorting_key) results = list(results.keys()) - results.sort() + results.sort(key=scu.heterogeneous_sorting_key) return categories, results @@ -166,7 +162,7 @@ def _results_by_category( l["sumpercent"] = "%2.1f%%" % ((100.0 * l["sum"]) / tot) # codes = list(results.keys()) - codes.sort() + codes.sort(key=scu.heterogeneous_sorting_key) bottom_titles = [] if C: # ligne du bas avec totaux: @@ -314,7 +310,7 @@ def formsemestre_report_counts( "type_admission", "boursier_prec", ] - keys.sort() + keys.sort(key=scu.heterogeneous_sorting_key) F = [ """

Colonnes: