diff --git a/app/forms/formsemestre/edit_modimpls_codes_apo.py b/app/forms/formsemestre/edit_modimpls_codes_apo.py new file mode 100644 index 000000000..bdd84a18d --- /dev/null +++ b/app/forms/formsemestre/edit_modimpls_codes_apo.py @@ -0,0 +1,52 @@ +""" +Formulaire configuration des codes Apo et EDT des modimps d'un formsemestre +""" + +from flask_wtf import FlaskForm +from wtforms import validators +from wtforms.fields.simple import BooleanField, StringField, SubmitField + +from app.models import FormSemestre, ModuleImpl + + +class _EditModimplsCodesForm(FlaskForm): + "form. définition des liens personnalisés" + # construit dynamiquement ci-dessous + + +def EditModimplsCodesForm(formsemestre: FormSemestre) -> _EditModimplsCodesForm: + "Création d'un formulaire pour éditer les codes" + + # Formulaire dynamique, on créé une classe ad-hoc + class F(_EditModimplsCodesForm): + pass + + def _gen_mod_form(modimpl: ModuleImpl): + field = StringField( + modimpl.module.code, + validators=[ + validators.Optional(), + validators.Length(min=1, max=80), + ], + default="", + render_kw={"size": 32}, + ) + setattr(F, f"modimpl_apo_{modimpl.id}", field) + field = StringField( + "", + validators=[ + validators.Optional(), + validators.Length(min=1, max=80), + ], + default="", + render_kw={"size": 12}, + ) + setattr(F, f"modimpl_edt_{modimpl.id}", field) + + for modimpl in formsemestre.modimpls_sorted: + _gen_mod_form(modimpl) + + F.submit = SubmitField("Valider") + F.cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) + + return F() diff --git a/app/forms/main/config_personalized_links.py b/app/forms/main/config_personalized_links.py index b2293a2c2..2a261e766 100644 --- a/app/forms/main/config_personalized_links.py +++ b/app/forms/main/config_personalized_links.py @@ -2,9 +2,8 @@ Formulaire configuration liens personalisés (menu "Liens") """ -from flask import g, url_for from flask_wtf import FlaskForm -from wtforms import FieldList, Form, validators +from wtforms import validators from wtforms.fields.simple import BooleanField, StringField, SubmitField from app.models import ScoDocSiteConfig diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index 741abda1b..468888e6a 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -7,6 +7,7 @@ from flask_sqlalchemy.query import Query from app import db from app.auth.models import User from app.comp import df_cache +from app.models import APO_CODE_STR_LEN from app.models.etudiants import Identite from app.models.modules import Module from app.scodoc.sco_exceptions import AccessDenied, ScoLockedSemError @@ -21,6 +22,10 @@ class ModuleImpl(db.Model): __table_args__ = (db.UniqueConstraint("formsemestre_id", "module_id"),) id = db.Column(db.Integer, primary_key=True) + code_apogee = db.Column(db.String(APO_CODE_STR_LEN), index=True, nullable=True) + "id de l'element pedagogique Apogee correspondant" + edt_id: str | None = db.Column(db.Text(), index=True, nullable=True) + "identifiant emplois du temps (unicité non imposée)" moduleimpl_id = db.synonym("id") module_id = db.Column(db.Integer, db.ForeignKey("notes_modules.id"), nullable=False) formsemestre_id = db.Column( @@ -45,11 +50,21 @@ class ModuleImpl(db.Model): def __repr__(self): return f"<{self.__class__.__name__} {self.id} module={repr(self.module)}>" + def get_codes_apogee(self) -> set[str]: + """Les codes Apogée (codés en base comme "VRT1,VRT2"). + (si non renseigné, ceux du module) + """ + if self.code_apogee: + return {x.strip() for x in self.code_apogee.split(",") if x} + return self.module.get_codes_apogee() + def get_edt_id(self) -> str: - "l'id pour l'emploi du temps: actuellement celui du module" + "l'id pour l'emploi du temps: à défaut, le 1er code Apogée" return ( - self.module.get_edt_id() - ) # TODO à décliner pour autoriser des codes différents ? + self.edt_id + or (self.code_apogee.split(",")[0] if self.code_apogee else "") + or self.module.get_edt_id() + ) def get_evaluations_poids(self) -> pd.DataFrame: """Les poids des évaluations vers les UE (accès via cache)""" @@ -102,6 +117,7 @@ class ModuleImpl(db.Model): d["module"] = self.module.to_dict(convert_objects=convert_objects) else: d.pop("module", None) + d["code_apogee"] = d["code_apogee"] or "" # pas de None return d def can_edit_evaluation(self, user) -> bool: diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index 4f5ae61de..bff18edb3 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -464,6 +464,10 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id) }">Modifier les coefficients des UE capitalisées

+

Modifier les codes Apogée et emploi du temps des modules +

Sélectionner les modules, leurs responsables et les étudiants à inscrire:

""" diff --git a/app/templates/formsemestre/change_formation.j2 b/app/templates/formsemestre/change_formation.j2 index 0b3820cae..8c0b8b5b3 100644 --- a/app/templates/formsemestre/change_formation.j2 +++ b/app/templates/formsemestre/change_formation.j2 @@ -11,11 +11,11 @@

Changement de la formation du semestre

On ne peut pas changer la formation d'un semestre existant car -elle défini son organisation (modules, ...), SAUF si la nouvelle formation a -exactement le même contenu que l'existante. +elle définit son organisation (modules, ...), SAUF si la nouvelle formation a +exactement le même contenu que l'existante. Cela peut arriver par exemple lorsqu'on crée une nouvelle version (pas encore modifiée) et que l'on a oublié d'y rattacher un semestre. -

+

{% if formations %}
@@ -27,4 +27,4 @@ et que l'on a oublié d'y rattacher un semestre.
Aucune formation ne peut se substituer à celle de ce semestre.
{% endif %}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/templates/formsemestre/edit_modimpls_codes.j2 b/app/templates/formsemestre/edit_modimpls_codes.j2 new file mode 100644 index 000000000..d70b6e280 --- /dev/null +++ b/app/templates/formsemestre/edit_modimpls_codes.j2 @@ -0,0 +1,91 @@ +{% extends "sco_page.j2" %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block styles %} +{{super()}} + +{% endblock %} + +{% macro render_text_field(field_apo, field_edt, codes_apo_module) %} +
+ + {{ field_apo.label(class_="form-label") }} + {{codes_apo_module|join(", ") or ("non défini"|safe)}} + + {{field_apo(class_="form-field")}} + {{field_edt(class_="field-edt")}} + {%- for error in field_apo.errors %} + {{ error }} + {% endfor %} + {%- for error in field_edt.errors %} + {{ error }} + {% endfor %} +
+{% endmacro %} + + +{% block app_content %} + +
+

Codes Apogée et emploi du temps des modules du semestre

+ +

Les codes élément Apogée sont utilisés pour les exports des +résultats et peuvent aussi l'être pour connecter l'emploi du temps. Si votre +logiciel d'emploi du temps utilise des codes différents, vous pouvez aussi +indiquer un code EDT spécifique. +

+ +

Les codes Apogée modules rappelés à gauche sont ceux définis +dans la formation: il sont utilisés sauf si on spécifie un code ici. +Pour les modifier, aller dans l'édition de la formation. +

+ + +
+
+
+ {{ form.hidden_tag() }} + {{ wtf.form_errors(form, hiddens="only") }} +
+ + + Code Apo. Module + Code(s) Apogée + Code EDT + +
+ {% for modimpl in formsemestre.modimpls_sorted %} + {{ render_text_field(form["modimpl_apo_" ~ modimpl.id], form["modimpl_edt_" ~ modimpl.id], modimpl.module.get_codes_apogee()) }} + {% endfor %} +
+ {{ wtf.form_field(form.submit) }} + {{ wtf.form_field(form.cancel) }} +
+
+
+
+ +
+{% endblock %} diff --git a/app/views/notes_formsemestre.py b/app/views/notes_formsemestre.py index eac987603..84d1509a7 100644 --- a/app/views/notes_formsemestre.py +++ b/app/views/notes_formsemestre.py @@ -32,11 +32,12 @@ Emmanuel Viennet, 2023 from flask import flash, redirect, render_template, url_for from flask import g, request +from app import db, log from app.decorators import ( scodoc, permission_required, ) -from app.forms.formsemestre import change_formation +from app.forms.formsemestre import change_formation, edit_modimpls_codes_apo from app.models import Formation, FormSemestre from app.scodoc import sco_formations, sco_formation_versions from app.scodoc.sco_permissions import Permission @@ -107,6 +108,50 @@ def formsemestre_change_formation(formsemestre_id: int): ) +@bp.route( + "/formsemestre_edit_modimpls_codes/", methods=["GET", "POST"] +) +@scodoc +@permission_required(Permission.EditFormSemestre) +def formsemestre_edit_modimpls_codes(formsemestre_id: int): + """Edition des codes Apogée et EDT""" + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + form = edit_modimpls_codes_apo.EditModimplsCodesForm(formsemestre) + + if request.method == "POST" and form.validate: + if not form.cancel.data: + # record codes + for modimpl in formsemestre.modimpls_sorted: + field_apo = getattr(form, f"modimpl_apo_{modimpl.id}") + field_edt = getattr(form, f"modimpl_edt_{modimpl.id}") + if field_apo and field_edt: + modimpl.code_apogee = field_apo.data.strip() or None + modimpl.edt_id = field_edt.data.strip() or None + log(f"setting codes for {modimpl}: apo={field_apo} edt={field_edt}") + db.session.add(modimpl) + db.session.commit() + flash("Codes enregistrés") + return redirect( + url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) + ) + # GET + for modimpl in formsemestre.modimpls_sorted: + field_apo = getattr(form, f"modimpl_apo_{modimpl.id}") + field_edt = getattr(form, f"modimpl_edt_{modimpl.id}") + field_apo.data = modimpl.code_apogee or "" + field_edt.data = modimpl.edt_id or "" + return render_template( + "formsemestre/edit_modimpls_codes.j2", + form=form, + formsemestre=formsemestre, + sco=ScoData(formsemestre=formsemestre), + ) + + @bp.route("/formsemestre/edt/") @scodoc @permission_required(Permission.ScoView) diff --git a/migrations/versions/c8f66652c77f_code_apo_sur_modimpls.py b/migrations/versions/c8f66652c77f_code_apo_sur_modimpls.py new file mode 100644 index 000000000..5d818b767 --- /dev/null +++ b/migrations/versions/c8f66652c77f_code_apo_sur_modimpls.py @@ -0,0 +1,38 @@ +"""code apo sur modimpls + +Revision ID: c8f66652c77f +Revises: 6fb956addd69 +Create Date: 2023-11-12 10:01:42.424734 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "c8f66652c77f" +down_revision = "6fb956addd69" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("notes_moduleimpl", schema=None) as batch_op: + batch_op.add_column( + sa.Column("code_apogee", sa.String(length=512), nullable=True) + ) + batch_op.add_column(sa.Column("edt_id", sa.Text(), nullable=True)) + batch_op.create_index( + batch_op.f("ix_notes_moduleimpl_code_apogee"), ["code_apogee"], unique=False + ) + batch_op.create_index( + batch_op.f("ix_notes_moduleimpl_edt_id"), ["edt_id"], unique=False + ) + + +def downgrade(): + with op.batch_alter_table("notes_moduleimpl", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_notes_moduleimpl_edt_id")) + batch_op.drop_index(batch_op.f("ix_notes_moduleimpl_code_apogee")) + batch_op.drop_column("edt_id") + batch_op.drop_column("code_apogee")