diff --git a/app/__init__.py b/app/__init__.py index 0ea6bb52c2..0a58914476 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/auth/models.py b/app/auth/models.py index bc4edb65fd..8f187b7e94 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -11,7 +11,7 @@ from time import time from typing import Optional import cracklib # pylint: disable=import-error -from flask import current_app, url_for, g +from flask import current_app, g from flask_login import UserMixin, AnonymousUserMixin from werkzeug.security import generate_password_hash, check_password_hash @@ -136,6 +136,7 @@ class User(UserMixin, db.Model): return check_password_hash(self.password_hash, password) def get_reset_password_token(self, expires_in=600): + "Un token pour réinitialiser son mot de passe" return jwt.encode( {"reset_password": self.id, "exp": time() + expires_in}, current_app.config["SECRET_KEY"], @@ -144,15 +145,17 @@ class User(UserMixin, db.Model): @staticmethod def verify_reset_password_token(token): + "Vérification du token de reéinitialisation du mot de passe" try: - id = jwt.decode( + user_id = jwt.decode( token, current_app.config["SECRET_KEY"], algorithms=["HS256"] )["reset_password"] except: return - return User.query.get(id) + return User.query.get(user_id) def to_dict(self, include_email=True): + """l'utilisateur comme un dict, avec des champs supplémentaires""" data = { "date_expiration": self.date_expiration.isoformat() + "Z" if self.date_expiration @@ -472,5 +475,5 @@ def get_super_admin(): @login.user_loader -def load_user(id): - return User.query.get(int(id)) +def load_user(uid): + return User.query.get(int(uid)) diff --git a/app/but/bulletin_but_xml_compat.py b/app/but/bulletin_but_xml_compat.py index 9743c2181b..69fc4ef16c 100644 --- a/app/but/bulletin_but_xml_compat.py +++ b/app/but/bulletin_but_xml_compat.py @@ -72,7 +72,7 @@ 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: + if (not sem.bul_hide_xml) or force_publishing: published = "1" else: published = "0" diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index 855e3b7fae..d4022dd360 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -121,6 +121,8 @@ def notes_sem_assemble_cube(modimpls_notes: list[pd.DataFrame]) -> np.ndarray: (DataFrames rendus par compute_module_moy, (etud x UE)) Resultat: ndarray (etud x module x UE) """ + if len(modimpls_notes) == 0: + return np.zeros((0, 0, 0), dtype=float) modimpls_notes_arr = [df.values for df in modimpls_notes] modimpls_notes = np.stack(modimpls_notes_arr) # passe de (mod x etud x ue) à (etud x mod x UE) diff --git a/app/comp/res_sem.py b/app/comp/res_sem.py index 4f3a5dda2b..5eccd32535 100644 --- a/app/comp/res_sem.py +++ b/app/comp/res_sem.py @@ -230,7 +230,7 @@ class NotesTableCompat(ResultatsSemestre): def get_etud_ue_status(self, etudid: int, ue_id: int): return { - "cur_moy_ue": self.results.etud_moy_ue[ue_id][etudid], + "cur_moy_ue": self.etud_moy_ue[ue_id][etudid], "is_capitalized": False, # XXX TODO } @@ -242,9 +242,9 @@ class NotesTableCompat(ResultatsSemestre): def get_evals_in_mod(self, moduleimpl_id: int) -> list[dict]: "liste des évaluations valides dans un module" - mi = ModuleImpl.query.get(moduleimpl_id) + modimpl = ModuleImpl.query.get(moduleimpl_id) evals_results = [] - for e in mi.evaluations: + for e in modimpl.evaluations: d = e.to_dict() d["heure_debut"] = e.heure_debut # datetime.time d["heure_fin"] = e.heure_fin diff --git a/app/entreprises/models.py b/app/entreprises/models.py index b0cd25de72..e743df3d60 100644 --- a/app/entreprises/models.py +++ b/app/entreprises/models.py @@ -57,6 +57,23 @@ class EntrepriseContact(db.Model): "service": self.service, } + def to_dict_export(self): + entreprise = Entreprise.query.get(self.entreprise_id) + return { + "nom": self.nom, + "prenom": self.prenom, + "telephone": self.telephone, + "mail": self.mail, + "poste": self.poste, + "service": self.service, + "siret": entreprise.siret, + "nom_entreprise": entreprise.nom, + "adresse_entreprise": entreprise.adresse, + "codepostal": entreprise.codepostal, + "ville": entreprise.ville, + "pays": entreprise.pays, + } + class EntrepriseOffre(db.Model): __tablename__ = "entreprise_offre" @@ -71,6 +88,15 @@ class EntrepriseOffre(db.Model): missions = db.Column(db.Text) duree = db.Column(db.Text) + def to_dict(self): + return { + "intitule": self.intitule, + "description": self.description, + "type_offre": self.type_offre, + "missions": self.missions, + "duree": self.duree, + } + class EntrepriseLog(db.Model): __tablename__ = "entreprise_log" @@ -100,3 +126,12 @@ class EntrepriseEnvoiOffre(db.Model): receiver_id = db.Column(db.Integer, db.ForeignKey("user.id")) offre_id = db.Column(db.Integer, db.ForeignKey("entreprise_offre.id")) date_envoi = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) + + +class EntrepriseEnvoiOffreEtudiant(db.Model): + __tablename__ = "entreprise_envoi_offre_etudiant" + id = db.Column(db.Integer, primary_key=True) + sender_id = db.Column(db.Integer, db.ForeignKey("user.id")) + receiver_id = db.Column(db.Integer, db.ForeignKey("identite.id")) + offre_id = db.Column(db.Integer, db.ForeignKey("entreprise_offre.id")) + date_envoi = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) diff --git a/app/entreprises/routes.py b/app/entreprises/routes.py index 590dd313c6..f99fa8eef7 100644 --- a/app/entreprises/routes.py +++ b/app/entreprises/routes.py @@ -1,6 +1,6 @@ import os from config import Config -from datetime import datetime +from datetime import datetime, timedelta import glob import shutil @@ -45,6 +45,18 @@ from werkzeug.utils import secure_filename @bp.route("/", methods=["GET"]) def index(): + """ + Permet d'afficher une page avec la liste des entreprises et une liste des dernières opérations + + Retourne: template de la page (entreprises.html) + Arguments du template: + title: + titre de la page + entreprises: + liste des entreprises + logs: + liste des logs + """ entreprises = Entreprise.query.all() logs = EntrepriseLog.query.order_by(EntrepriseLog.date.desc()).limit(LOGS_LEN).all() return render_template( @@ -57,6 +69,18 @@ def index(): @bp.route("/contacts", methods=["GET"]) def contacts(): + """ + Permet d'afficher une page la liste des contacts et une liste des dernières opérations + + Retourne: template de la page (contacts.html) + Arguments du template: + title: + titre de la page + contacts: + liste des contacts + logs: + liste des logs + """ contacts = ( db.session.query(EntrepriseContact, Entreprise) .join(Entreprise, EntrepriseContact.entreprise_id == Entreprise.id) @@ -70,10 +94,39 @@ def contacts(): @bp.route("/fiche_entreprise/", methods=["GET"]) def fiche_entreprise(id): + """ + Permet d'afficher la fiche entreprise d'une entreprise avec une liste des dernières opérations et + l'historique des étudiants ayant réaliser un stage ou une alternance dans cette entreprise. + La fiche entreprise comporte les informations de l'entreprise, les contacts de l'entreprise et + les offres de l'entreprise. + + Arguments: + id: + l'id de l'entreprise + + Retourne: template de la page (fiche_entreprise.html) + Arguments du template: + title: + titre de la page + entreprise: + un objet entreprise + contacts: + liste des contacts de l'entreprise + offres: + liste des offres de l'entreprise avec leurs fichiers + logs: + liste des logs + historique: + liste des étudiants ayant réaliser un stage ou une alternance dans l'entreprise + """ entreprise = Entreprise.query.filter_by(id=id).first_or_404() offres = entreprise.offres offres_with_files = [] for offre in offres: + if datetime.now() - offre.date_ajout.replace(tzinfo=None) >= timedelta( + days=90 + ): # pour une date d'expiration ? + break files = [] path = os.path.join( Config.SCODOC_VAR_DIR, @@ -116,19 +169,32 @@ def fiche_entreprise(id): @bp.route("/offres", methods=["GET"]) def offres(): - offres_recus = ( + """ + Permet d'afficher la page où l'on recoit les offres + + Retourne: template de la page (offres.html) + Arguments du template: + title: + titre de la page + offres_recus: + liste des offres reçues + """ + offres_recues = ( db.session.query(EntrepriseEnvoiOffre, EntrepriseOffre) .filter(EntrepriseEnvoiOffre.receiver_id == current_user.id) .join(EntrepriseOffre, EntrepriseOffre.id == EntrepriseEnvoiOffre.offre_id) .all() ) return render_template( - "entreprises/offres.html", title=("Offres"), offres_recus=offres_recus + "entreprises/offres.html", title=("Offres"), offres_recues=offres_recues ) @bp.route("/add_entreprise", methods=["GET", "POST"]) def add_entreprise(): + """ + Permet d'ajouter une entreprise dans la base avec un formulaire + """ form = EntrepriseCreationForm() if form.validate_on_submit(): entreprise = Entreprise( @@ -170,6 +236,13 @@ def add_entreprise(): @bp.route("/edit_entreprise/", methods=["GET", "POST"]) def edit_entreprise(id): + """ + Permet de modifier une entreprise de la base avec un formulaire + + Arguments: + id: + l'id de l'entreprise + """ entreprise = Entreprise.query.filter_by(id=id).first_or_404() form = EntrepriseModificationForm() if form.validate_on_submit(): @@ -231,6 +304,13 @@ def edit_entreprise(id): @bp.route("/delete_entreprise/", methods=["GET", "POST"]) def delete_entreprise(id): + """ + Permet de supprimer une entreprise de la base avec un formulaire de confirmation + + Arguments: + id: + l'id de l'entreprise + """ entreprise = Entreprise.query.filter_by(id=id).first_or_404() form = SuppressionConfirmationForm() if form.validate_on_submit(): @@ -253,6 +333,13 @@ def delete_entreprise(id): @bp.route("/add_offre/", methods=["GET", "POST"]) def add_offre(id): + """ + Permet d'ajouter une offre a une entreprise + + Arguments: + id: + l'id de l'entreprise + """ entreprise = Entreprise.query.filter_by(id=id).first_or_404() form = OffreCreationForm() if form.validate_on_submit(): @@ -279,6 +366,13 @@ def add_offre(id): @bp.route("/edit_offre/", methods=["GET", "POST"]) def edit_offre(id): + """ + Permet de modifier une offre + + Arguments: + id: + l'id de l'offre + """ offre = EntrepriseOffre.query.filter_by(id=id).first_or_404() form = OffreModificationForm() if form.validate_on_submit(): @@ -309,6 +403,13 @@ def edit_offre(id): @bp.route("/delete_offre/", methods=["GET", "POST"]) def delete_offre(id): + """ + Permet de supprimer une offre + + Arguments: + id: + l'id de l'offre + """ offre = EntrepriseOffre.query.filter_by(id=id).first_or_404() entreprise_id = offre.entreprise.id form = SuppressionConfirmationForm() @@ -330,6 +431,13 @@ def delete_offre(id): @bp.route("/add_contact/", methods=["GET", "POST"]) def add_contact(id): + """ + Permet d'ajouter un contact a une entreprise + + Arguments: + id: + l'id de l'entreprise + """ entreprise = Entreprise.query.filter_by(id=id).first_or_404() form = ContactCreationForm(hidden_entreprise_id=entreprise.id) if form.validate_on_submit(): @@ -357,6 +465,13 @@ def add_contact(id): @bp.route("/edit_contact/", methods=["GET", "POST"]) def edit_contact(id): + """ + Permet de modifier un contact + + Arguments: + id: + l'id du contact + """ contact = EntrepriseContact.query.filter_by(id=id).first_or_404() form = ContactModificationForm() if form.validate_on_submit(): @@ -391,6 +506,13 @@ def edit_contact(id): @bp.route("/delete_contact/", methods=["GET", "POST"]) def delete_contact(id): + """ + Permet de supprimer un contact + + Arguments: + id: + l'id du contact + """ contact = EntrepriseContact.query.filter_by(id=id).first_or_404() entreprise_id = contact.entreprise.id form = SuppressionConfirmationForm() @@ -421,6 +543,13 @@ def delete_contact(id): @bp.route("/add_historique/", methods=["GET", "POST"]) def add_historique(id): + """ + Permet d'ajouter un étudiant ayant réalisé un stage ou une alternance sur la fiche entreprise de l'entreprise + + Arguments: + id: + l'id de l'entreprise + """ entreprise = Entreprise.query.filter_by(id=id).first_or_404() form = HistoriqueCreationForm() if form.validate_on_submit(): @@ -458,6 +587,13 @@ def add_historique(id): @bp.route("/envoyer_offre/", methods=["GET", "POST"]) def envoyer_offre(id): + """ + Permet d'envoyer une offre à un utilisateur + + Arguments: + id: + l'id de l'offre + """ offre = EntrepriseOffre.query.filter_by(id=id).first_or_404() form = EnvoiOffreForm() if form.validate_on_submit(): @@ -484,6 +620,18 @@ def envoyer_offre(id): @bp.route("/etudiants") def json_etudiants(): + """ + Permet de récuperer un JSON avec tous les étudiants + + Arguments: + term: + le terme utilisé pour le filtre de l'autosuggest + + Retourne: + le JSON de tous les étudiants (nom, prenom, formation actuelle?) correspondant au terme + """ + if request.args.get("term") == None: + abort(400) term = request.args.get("term").strip() etudiants = Identite.query.filter(Identite.nom.ilike(f"%{term}%")).all() list = [] @@ -505,6 +653,18 @@ def json_etudiants(): @bp.route("/responsables") def json_responsables(): + """ + Permet de récuperer un JSON avec tous les étudiants + + Arguments: + term: + le terme utilisé pour le filtre de l'autosuggest + + Retourne: + le JSON de tous les utilisateurs (nom, prenom, login) correspondant au terme + """ + if request.args.get("term") == None: + abort(400) term = request.args.get("term").strip() responsables = User.query.filter( User.nom.ilike(f"%{term}%"), User.nom.is_not(None), User.prenom.is_not(None) @@ -521,6 +681,9 @@ def json_responsables(): @bp.route("/export_entreprises") def export_entreprises(): + """ + Permet d'exporter la liste des entreprises sous format excel (.xlsx) + """ entreprises = Entreprise.query.all() if entreprises: keys = ["siret", "nom", "adresse", "ville", "codepostal", "pays"] @@ -539,6 +702,9 @@ def export_entreprises(): @bp.route("/export_contacts") def export_contacts(): + """ + Permet d'exporter la liste des contacts sous format excel (.xlsx) + """ contacts = EntrepriseContact.query.all() if contacts: keys = ["nom", "prenom", "telephone", "mail", "poste", "service"] @@ -552,10 +718,56 @@ def export_contacts(): abort(404) +@bp.route("/export_contacts_bis") +def export_contacts_bis(): + """ + Permet d'exporter la liste des contacts avec leur entreprise sous format excel (.xlsx) + """ + contacts = EntrepriseContact.query.all() + if contacts: + keys = [ + "nom", + "prenom", + "telephone", + "mail", + "poste", + "service", + "nom_entreprise", + "siret", + "adresse_entreprise", + "ville", + "codepostal", + "pays", + ] + titles = keys[:] + L = [ + [contact.to_dict_export().get(k, "") for k in keys] for contact in contacts + ] + title = "contacts" + xlsx = sco_excel.excel_simple_table(titles=titles, lines=L, sheet_name=title) + filename = title + return scu.send_file(xlsx, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE) + else: + abort(404) + + @bp.route( "/get_offre_file////" ) def get_offre_file(entreprise_id, offre_id, filedir, filename): + """ + Permet de télécharger un fichier d'une offre + + Arguments: + entreprise_id: + l'id de l'entreprise + offre_id: + l'id de l'offre + filedir: + le répertoire du fichier + filename: + le nom du fichier + """ if os.path.isfile( os.path.join( Config.SCODOC_VAR_DIR, @@ -583,6 +795,13 @@ def get_offre_file(entreprise_id, offre_id, filedir, filename): @bp.route("/add_offre_file/", methods=["GET", "POST"]) def add_offre_file(offre_id): + """ + Permet d'ajouter un fichier à une offre + + Arguments: + offre_id: + l'id de l'offre + """ offre = EntrepriseOffre.query.filter_by(id=offre_id).first_or_404() form = AjoutFichierForm() if form.validate_on_submit(): @@ -607,6 +826,15 @@ def add_offre_file(offre_id): @bp.route("/delete_offre_file//", methods=["GET", "POST"]) def delete_offre_file(offre_id, filedir): + """ + Permet de supprimer un fichier d'une offre + + Arguments: + offre_id: + l'id de l'offre + filedir: + le répertoire du fichier + """ offre = EntrepriseOffre.query.filter_by(id=offre_id).first_or_404() form = SuppressionConfirmationForm() if form.validate_on_submit(): diff --git a/app/forms/main/config_forms.py b/app/forms/main/config_forms.py index 035897950a..16be845184 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 7bc26b42a6..cd05340574 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 7ed2f4b56e..ebe5cc1451 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/modules.py b/app/models/modules.py index 00a6d4c9ba..8f30550743 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/scodoc/sco_edit_apc.py b/app/scodoc/sco_edit_apc.py index 773cf81d1e..05b72b011f 100644 --- a/app/scodoc/sco_edit_apc.py +++ b/app/scodoc/sco_edit_apc.py @@ -78,7 +78,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_matiere.py b/app/scodoc/sco_edit_matiere.py index 62a7e1d201..13ddb2a99e 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 1c3481b066..813320d534 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 bdf37375bb..766fbd9b84 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,24 +466,38 @@ 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 @@ -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 = ( diff --git a/app/scodoc/sco_exceptions.py b/app/scodoc/sco_exceptions.py index 635a724b55..1099986bf0 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 6c889c54a6..060289e95f 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_report.py b/app/scodoc/sco_report.py index d480679b7c..6c2582ef10 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: