diff --git a/app/but/forms/refcomp_forms.py b/app/but/forms/refcomp_forms.py new file mode 100644 index 0000000000..a1cab459c9 --- /dev/null +++ b/app/but/forms/refcomp_forms.py @@ -0,0 +1,35 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2021 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""ScoDoc 9 : Formulaires / référentiel de compétence +""" + +from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileAllowed, FileRequired +from wtforms import SelectField, SubmitField + + +class FormationRefCompForm(FlaskForm): + referentiel_competence = SelectField("Référentiels déjà chargés") + submit = SubmitField("Valider") + cancel = SubmitField("Annuler") + + +class RefCompLoadForm(FlaskForm): + upload = FileField( + label="Sélectionner un fichier XML Orébut", + validators=[ + FileRequired(), + FileAllowed( + [ + "xml", + ], + "Fichier XML Orébut seulement", + ), + ], + ) + submit = SubmitField("Valider") + cancel = SubmitField("Annuler") diff --git a/app/but/import_refcomp.py b/app/but/import_refcomp.py index 6433ae54fc..6d871175a9 100644 --- a/app/but/import_refcomp.py +++ b/app/but/import_refcomp.py @@ -17,14 +17,15 @@ from app.models.but_refcomp import ( from app.scodoc.sco_exceptions import FormatError -def orebut_import_refcomp(xml_file: TextIO): +def orebut_import_refcomp(xml_file: TextIO, dept_id: int, orig_filename=None): tree = ElementTree.parse(xml_file) root = tree.getroot() if root.tag != "referentiel_competence": raise FormatError("élément racine 'referentiel_competence' manquant") - ref = ApcReferentielCompetences( - **ApcReferentielCompetences.attr_from_xml(root.attrib) - ) + args = ApcReferentielCompetences.attr_from_xml(root.attrib) + args["dept_id"] = dept_id + args["scodoc_orig_filename"] = orig_filename + ref = ApcReferentielCompetences(**args) db.session.add(ref) competences = root.find("competences") if not competences: @@ -99,7 +100,7 @@ competence = competences.findall("competence")[0] # XXX from app.but.import_refcomp import * f = open("but-RT-refcomp-30112021.xml") -ref = orebut_import_refcomp(f) +ref = orebut_import_refcomp(f, 0) #------ from app.but.import_refcomp import * ref = ApcReferentielCompetences.query.first() diff --git a/app/models/but_refcomp.py b/app/models/but_refcomp.py index 65eba115d1..e2a57849f8 100644 --- a/app/models/but_refcomp.py +++ b/app/models/but_refcomp.py @@ -1,5 +1,6 @@ """ScoDoc 9 models : Référentiel Compétence BUT 2021 """ +from datetime import datetime from enum import unique from typing import Any @@ -31,9 +32,13 @@ class ApcReferentielCompetences(db.Model, XMLModel): specialite = db.Column(db.Text()) specialite_long = db.Column(db.Text()) type_titre = db.Column(db.Text()) - _xml_attribs = { # xml_attrib : attribute + _xml_attribs = { # Orébut xml attrib : attribute "type": "type_titre", } + # ScoDoc specific fields: + scodoc_date_loaded = db.Column(db.DateTime, default=datetime.utcnow) + scodoc_orig_filename = db.Column(db.Text()) + # Relations: competences = db.relationship( "ApcCompetence", backref="referentiel", @@ -56,17 +61,26 @@ class ApcReferentielCompetences(db.Model, XMLModel): "specialite": self.specialite, "specialite_long": self.specialite_long, "type_titre": self.type_titre, + "scodoc_date_loaded": self.scodoc_date_loaded.isoformat() + "Z", + "scodoc_orig_filename": self.scodoc_orig_filename, "competences": {x.titre: x.to_dict() for x in self.competences}, "parcours": {x.code: x.to_dict() for x in self.parcours}, } class ApcCompetence(db.Model, XMLModel): + __table_args__ = ( + # les compétences dans Orébut sont identifiées par leur "titre" + # unique au sein d'un référentiel: + db.UniqueConstraint( + "referentiel_id", "titre", name="apc_competence_referentiel_id_titre_key" + ), + ) id = db.Column(db.Integer, primary_key=True) referentiel_id = db.Column( db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False ) - titre = db.Column(db.Text(), nullable=False) + titre = db.Column(db.Text(), nullable=False, index=True) titre_long = db.Column(db.Text()) couleur = db.Column(db.Text()) numero = db.Column(db.Integer) # ordre de présentation @@ -158,7 +172,7 @@ class ApcAppCritique(db.Model, XMLModel): "Apprentissage Critique BUT" id = db.Column(db.Integer, primary_key=True) niveau_id = db.Column(db.Integer, db.ForeignKey("apc_niveau.id"), nullable=False) - code = db.Column(db.Text(), nullable=False) + code = db.Column(db.Text(), nullable=False, index=True) libelle = db.Column(db.Text()) modules = db.relationship( @@ -175,14 +189,14 @@ class ApcAppCritique(db.Model, XMLModel): return self.code + " - " + self.titre def __repr__(self): - return "".format(self.code) + return "".format(self.code) def get_saes(self): """Liste des SAE associées""" return [m for m in self.modules if m.module_type == ModuleType.SAE] -ApcModulesACs = db.Table( +ApcAppCritiqueModules = db.Table( "apc_modules_acs", db.Column("module_id", db.ForeignKey("notes_modules.id")), db.Column("app_crit_id", db.ForeignKey("apc_app_critique.id")), diff --git a/app/models/formations.py b/app/models/formations.py index b456eb8259..19940fa4e3 100644 --- a/app/models/formations.py +++ b/app/models/formations.py @@ -39,7 +39,9 @@ class Formation(db.Model): referentiel_competence_id = db.Column( db.Integer, db.ForeignKey("apc_referentiel_competences.id") ) - + referentiel_competence = db.relationship( # one-to-one + "ApcReferentielCompetences", backref="formation", uselist=False + ) ues = db.relationship("UniteEns", backref="formation", lazy="dynamic") formsemestres = db.relationship("FormSemestre", lazy="dynamic", backref="formation") ues = db.relationship("UniteEns", lazy="dynamic", backref="formation") diff --git a/app/scodoc/sco_codes_parcours.py b/app/scodoc/sco_codes_parcours.py index c6294e73df..a1e18ca645 100644 --- a/app/scodoc/sco_codes_parcours.py +++ b/app/scodoc/sco_codes_parcours.py @@ -252,6 +252,7 @@ class TypeParcours(object): UE_TYPE_NAME.keys() ) # par defaut, autorise tous les types d'UE APC_SAE = False # Approche par compétences avec ressources et SAÉs + USE_REFERENTIEL_COMPETENCES = False # Lien avec ref. comp. def check(self, formation=None): return True, "" # status, diagnostic_message @@ -307,6 +308,7 @@ class ParcoursBUT(TypeParcours): NB_SEM = 6 COMPENSATION_UE = False APC_SAE = True + USE_REFERENTIEL_COMPETENCES = True ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT] diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index 7e89eaf236..23d63bff7e 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -575,12 +575,23 @@ du programme" (menu "Semestre") si vous avez un semestre en cours); """ ) + if formation.referentiel_competence is None: + descr_refcomp = "" + msg_refcomp = "associer à un référentiel de compétences" + else: + descr_refcomp = f"{formation.referentiel_competence.type_titre} {formation.referentiel_competence.specialite_long}" + msg_refcomp = "changer" H.append( f""" """ ) diff --git a/app/templates/but/refcomp_assoc.html b/app/templates/but/refcomp_assoc.html new file mode 100644 index 0000000000..1b30d46360 --- /dev/null +++ b/app/templates/but/refcomp_assoc.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block app_content %} +

Associer un référentiel de compétences

+
+ Association d'un référentiel de compétence à la formation + {{formation.titre}} ({{formation.acronyme}}) +
+
+
+ {{ wtf.quick_form(form) }} +
+
+ +
+ Pour charger un nouveau référentiel de compétences Orébut, + passer par cette page. +
+ +{% endblock %} \ No newline at end of file diff --git a/app/templates/but/refcomp_load.html b/app/templates/but/refcomp_load.html new file mode 100644 index 0000000000..caac1a5e36 --- /dev/null +++ b/app/templates/but/refcomp_load.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block app_content %} +

Charger un référentiel de compétences

+ +
+
+ {{ wtf.quick_form(form) }} +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/views/refcomp.py b/app/views/refcomp.py index d2d22482af..0d32b8a011 100644 --- a/app/views/refcomp.py +++ b/app/views/refcomp.py @@ -4,12 +4,13 @@ PN / Référentiel de compétences Emmanuel Viennet, 2021 """ -from flask import url_for +from flask import url_for, flash from flask import jsonify from flask import current_app, g, request from flask.templating import render_template from flask_login import current_user from werkzeug.utils import redirect +from werkzeug.utils import secure_filename from config import Config @@ -17,14 +18,88 @@ from app import db from app import models from app.decorators import scodoc, permission_required +from app.models.formations import Formation from app.models.but_refcomp import ApcReferentielCompetences +from app.but.import_refcomp import orebut_import_refcomp +from app.but.forms.refcomp_forms import FormationRefCompForm, RefCompLoadForm from app.scodoc.sco_permissions import Permission from app.views import notes_bp as bp -@bp.route("/pn/comp/") +@bp.route("/referentiel/comp/") @scodoc @permission_required(Permission.ScoView) def refcomp(refcomp_id): ref = ApcReferentielCompetences.query.get_or_404(refcomp_id) return jsonify(ref.to_dict()) + + +@bp.route("/refcomp_assoc/", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoChangeFormation) +def refcomp_assoc(formation_id: int): + """Formulaire association ref. compétence""" + formation = Formation.query.get_or_404(formation_id) + form = FormationRefCompForm() + form.referentiel_competence.choices = [ + (r.id, f"{r.type_titre} {r.specialite_long}") + for r in ApcReferentielCompetences.query.filter_by(dept_id=g.scodoc_dept_id) + ] + if request.method == "POST" and form.cancel.data: # cancel button + return redirect( + url_for( + "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation_id + ) + ) + if form.validate_on_submit(): + referentiel_competence_id = form.referentiel_competence.data + assert ( + ApcReferentielCompetences.query.get(referentiel_competence_id) is not None + ) + formation.referentiel_competence_id = referentiel_competence_id + db.session.add(formation) + db.session.commit() + flash("nouveau référentiel de compétences associé") + return redirect( + url_for( + "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation_id + ) + ) + return render_template( + "but/refcomp_assoc.html", + form=form, + referentiel_competence_id=formation.referentiel_competence_id, + formation=formation, + ) + + +@bp.route("/refcomp_load/", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoChangeFormation) +def refcomp_load(formation_id=None): + """Formulaire association ref. compétence""" + if formation_id is not None: + formation = Formation.query.get_or_404(formation_id) + else: + formation = None + form = RefCompLoadForm() + if form.validate_on_submit(): + f = form.upload.data + filename = secure_filename(f.filename) + ref = orebut_import_refcomp(f, dept_id=g.scodoc_dept_id, orig_filename=filename) + if formation is not None: + return redirect( + url_for( + "notes.refcomp_assoc", + scodoc_dept=g.scodoc_dept, + formation_id=formation.formation_id, + ) + ) + else: + return redirect(url_for("notes.index_html", scodoc_dept=g.scodoc_dept)) + + return render_template( + "but/refcomp_load.html", + form=form, + formation=formation, + ) diff --git a/migrations/versions/eed6d50fd9cb_refcomp.py b/migrations/versions/92789d50f6b6_refcomp_index.py similarity index 51% rename from migrations/versions/eed6d50fd9cb_refcomp.py rename to migrations/versions/92789d50f6b6_refcomp_index.py index 095454d690..8182597da7 100644 --- a/migrations/versions/eed6d50fd9cb_refcomp.py +++ b/migrations/versions/92789d50f6b6_refcomp_index.py @@ -1,8 +1,8 @@ -"""refcomp +"""refcomp index -Revision ID: eed6d50fd9cb +Revision ID: 92789d50f6b6 Revises: 00ad500fb118 -Create Date: 2021-12-02 17:34:10.999132 +Create Date: 2021-12-03 10:56:43.921559 """ from alembic import op @@ -10,7 +10,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = 'eed6d50fd9cb' +revision = '92789d50f6b6' down_revision = '00ad500fb118' branch_labels = None depends_on = None @@ -18,27 +18,38 @@ depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('app_crit') op.drop_table('modules_acs') + op.drop_table('app_crit') op.add_column('apc_annee_parcours', sa.Column('ordre', sa.Integer(), nullable=True)) op.drop_column('apc_annee_parcours', 'numero') + op.create_index(op.f('ix_apc_app_critique_code'), 'apc_app_critique', ['code'], unique=False) + op.create_unique_constraint('apc_competence_referentiel_id_titre_key', 'apc_competence', ['referentiel_id', 'titre']) + op.create_index(op.f('ix_apc_competence_titre'), 'apc_competence', ['titre'], unique=False) + op.add_column('apc_referentiel_competences', sa.Column('scodoc_date_loaded', sa.DateTime(), nullable=True)) + op.add_column('apc_referentiel_competences', sa.Column('scodoc_orig_filename', sa.Text(), nullable=True)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('apc_referentiel_competences', 'scodoc_orig_filename') + op.drop_column('apc_referentiel_competences', 'scodoc_date_loaded') + op.drop_index(op.f('ix_apc_competence_titre'), table_name='apc_competence') + op.drop_constraint('apc_competence_referentiel_id_titre_key', 'apc_competence', type_='unique') + op.drop_index(op.f('ix_apc_app_critique_code'), table_name='apc_app_critique') op.add_column('apc_annee_parcours', sa.Column('numero', sa.INTEGER(), autoincrement=False, nullable=True)) op.drop_column('apc_annee_parcours', 'ordre') + op.create_table('app_crit', + sa.Column('id', sa.INTEGER(), server_default=sa.text("nextval('app_crit_id_seq'::regclass)"), autoincrement=True, nullable=False), + sa.Column('code', sa.TEXT(), autoincrement=False, nullable=False), + sa.Column('titre', sa.TEXT(), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name='app_crit_pkey'), + postgresql_ignore_search_path=False + ) op.create_table('modules_acs', sa.Column('module_id', sa.INTEGER(), autoincrement=False, nullable=True), sa.Column('ac_id', sa.INTEGER(), autoincrement=False, nullable=True), sa.ForeignKeyConstraint(['ac_id'], ['app_crit.id'], name='modules_acs_ac_id_fkey'), sa.ForeignKeyConstraint(['module_id'], ['notes_modules.id'], name='modules_acs_module_id_fkey') ) - op.create_table('app_crit', - sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column('code', sa.TEXT(), autoincrement=False, nullable=False), - sa.Column('titre', sa.TEXT(), autoincrement=False, nullable=True), - sa.PrimaryKeyConstraint('id', name='app_crit_pkey') - ) # ### end Alembic commands ### diff --git a/tests/unit/test_refcomp.py b/tests/unit/test_refcomp.py index 5ba5635c2a..2b78760748 100644 --- a/tests/unit/test_refcomp.py +++ b/tests/unit/test_refcomp.py @@ -44,7 +44,7 @@ ref_xml = """ def test_but_refcomp(test_client): """modèles ref. comp.""" f = io.StringIO(ref_xml) - ref = orebut_import_refcomp(f) + ref = orebut_import_refcomp(0, f) assert ref.references.count() == 2 assert ref.competences[0].situations.count() == 2 assert ref.competences[0].situations[0].libelle.startswith("Conception ")