BUT: ECTS par UE dépendant du parcours.

This commit is contained in:
Emmanuel Viennet 2023-04-11 13:48:57 +02:00 committed by iziram
parent 657b1e1f1e
commit a533c40267
15 changed files with 236 additions and 135 deletions

View File

@ -37,6 +37,7 @@ from jinja2 import select_autoescape
import sqlalchemy as sa import sqlalchemy as sa
from flask_cas import CAS from flask_cas import CAS
import werkzeug.debug
from app.scodoc.sco_exceptions import ( from app.scodoc.sco_exceptions import (
AccessDenied, AccessDenied,
@ -274,6 +275,14 @@ def create_app(config_class=DevConfig):
# flask_sqlalchemy/query (pb deprecation du model.get()) # flask_sqlalchemy/query (pb deprecation du model.get())
warnings.filterwarnings("error", module="flask_sqlalchemy/query") warnings.filterwarnings("error", module="flask_sqlalchemy/query")
# warnings.filterwarnings("ignore", module="json/provider.py") xxx sans effet en test # warnings.filterwarnings("ignore", module="json/provider.py") xxx sans effet en test
if app.config["DEBUG"]:
# comme on a désactivé ci-dessus les logs de werkzeug,
# on affiche nous même le PIN en mode debug:
print(
f""" * Debugger is active!
* Debugger PIN: {werkzeug.debug.get_pin_and_cookie_name(app)[0]}
"""
)
# Vérifie/crée lien sym pour les URL statiques # Vérifie/crée lien sym pour les URL statiques
link_filename = f"{app.root_path}/static/links/{sco_version.SCOVERSION}" link_filename = f"{app.root_path}/static/links/{sco_version.SCOVERSION}"
if not os.path.exists(link_filename): if not os.path.exists(link_filename):

View File

@ -7,10 +7,10 @@
""" """
Edition associations UE <-> Ref. Compétence Edition associations UE <-> Ref. Compétence
""" """
from flask import g, render_template, url_for from flask import g, url_for
from app.models import ApcReferentielCompetences, UniteEns from app.models import ApcReferentielCompetences, UniteEns
from app.scodoc import codes_cursus from app.scodoc import codes_cursus
from app.forms.formation.ue_parcours_niveau import UEParcoursNiveauForm
def form_ue_choix_niveau(ue: UniteEns) -> str: def form_ue_choix_niveau(ue: UniteEns) -> str:
@ -28,69 +28,17 @@ def form_ue_choix_niveau(ue: UniteEns) -> str:
}">associer un référentiel de compétence</a> }">associer un référentiel de compétence</a>
</div> </div>
</div>""" </div>"""
# Les parcours:
parcours_options = []
for parcour in ref_comp.parcours:
parcours_options.append(
f"""<option value="{parcour.id}" {
'selected' if parcour in ue.parcours else ''}
>{parcour.libelle} ({parcour.code})
</option>"""
)
newline = "\n"
return f""" return f"""
<div class="ue_choix_niveau"> <div class="ue_advanced">
<form class="form_ue_choix_niveau"> <ul>
<div class="cont_ue_choix_niveau"> <li>
<div> <a class="stdlink" href="{
<b>Parcours&nbsp;:</b> url_for("notes.ue_parcours_ects",
<select class="select_parcour multiselect" scodoc_dept=g.scodoc_dept, ue_id=ue.id)
onchange="set_ue_parcour(this);" }">définir des ECTS différents dans chaque parcours</a>
data-ue_id="{ue.id}" </li>
data-setter="{ </ul>
url_for( "apiweb.set_ue_parcours", scodoc_dept=g.scodoc_dept, ue_id=ue.id)
}">
<option value="" {
'selected' if not ue.parcours else ''
}>Tous</option>
{newline.join(parcours_options)}
</select>
</div>
<div>
<b>Niveau de compétence&nbsp;:</b>
<select class="select_niveau_ue"
onchange="set_ue_niveau_competence(this);"
data-ue_id="{ue.id}"
data-setter="{
url_for( "notes.set_ue_niveau_competence", scodoc_dept=g.scodoc_dept)
}">
</select>
</div>
</div>
</form>
</div>
"""
# Nouvelle version XXX WIP
def form_ue_choix_parcours_niveau(ue: UniteEns):
"""formulaire (div) pour choix association des parcours et du niveau de compétence d'une UE"""
if ue.type != codes_cursus.UE_STANDARD:
return ""
ref_comp = ue.formation.referentiel_competence
if ref_comp is None:
return f"""<div class="ue_choix_niveau">
<div class="warning">Pas de référentiel de compétence associé à cette formation !</div>
<div><a class="stdlink" href="{ url_for('notes.refcomp_assoc_formation',
scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)
}">associer un référentiel de compétence</a>
</div>
</div>"""
parcours = ue.formation.referentiel_competence.parcours
form = UEParcoursNiveauForm(ue, parcours)
return f"""<div class="ue_choix_niveau">
{ render_template( "pn/ue_choix_parcours_niveau.j2", form_ue_parcours_niveau=form ) }
</div> </div>
""" """

View File

@ -0,0 +1,35 @@
from flask import g, url_for
from flask_wtf import FlaskForm
from wtforms import FieldList, Form, DecimalField, validators
from app.models import ApcParcours, ApcReferentielCompetences, UniteEns
class _UEParcoursECTSForm(FlaskForm):
"Formulaire association ECTS par parcours à une UE"
# construit dynamiquement ci-dessous
def UEParcoursECTSForm(ue: UniteEns) -> FlaskForm:
"Génère formulaire association ECTS par parcours à une UE"
class F(_UEParcoursECTSForm):
pass
parcours: list[ApcParcours] = ue.formation.referentiel_competence.parcours
# Initialise un champs de saisie par parcours
for parcour in parcours:
ects = ue.get_ects(parcour, only_parcours=True)
setattr(
F,
f"ects_parcour_{parcour.id}",
DecimalField(
f"Parcours {parcour.code}",
validators=[
validators.Optional(),
validators.NumberRange(min=0, max=30),
],
default=ects,
),
)
return F()

View File

@ -1,39 +0,0 @@
from flask import g, url_for
from flask_wtf import FlaskForm
from wtforms.fields import SelectField, SelectMultipleField
from app.models import ApcParcours, ApcReferentielCompetences, UniteEns
class UEParcoursNiveauForm(FlaskForm):
"Formulaire association parcours et niveau de compétence à une UE"
niveau_select = SelectField(
"Niveau de compétence:", render_kw={"class": "niveau_select"}
)
parcours_multiselect = SelectMultipleField(
"Parcours :",
coerce=int,
option_widget={"class": "form-check-input"},
# widget_attrs={"class": "form-check"},
render_kw={"class": "multiselect select_ue_parcours", "multiple": "multiple"},
)
def __init__(self, ue: UniteEns, parcours: list[ApcParcours], *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialise le menu des niveaux:
self.niveau_select.render_kw["data-ue_id"] = ue.id
self.niveau_select.choices = [
(r.id, f"{r.type_titre} {r.specialite_long} ({r.get_version()})")
for r in ApcReferentielCompetences.query.filter_by(dept_id=g.scodoc_dept_id)
]
# Initialise le menu des parcours
self.parcours_multiselect.render_kw["data-set_ue_parcours"] = url_for(
"apiweb.set_ue_parcours", ue_id=ue.id, scodoc_dept=g.scodoc_dept
)
parcours_options = [(str(p.id), f"{p.libelle} ({p.code})") for p in parcours]
self.parcours_multiselect.choices = parcours_options
# initialize checked items based on u instance
parcours_selected = [str(p.id) for p in ue.parcours]
self.parcours_multiselect.process_data(parcours_selected)

View File

@ -38,7 +38,7 @@ class UniteEns(db.Model):
server_default=db.text("notes_newid_ucod()"), server_default=db.text("notes_newid_ucod()"),
nullable=False, nullable=False,
) )
ects = db.Column(db.Float) # nombre de credits ECTS ects = db.Column(db.Float) # nombre de credits ECTS (sauf si parcours spécifié)
is_external = db.Column(db.Boolean(), default=False, server_default="false") is_external = db.Column(db.Boolean(), default=False, server_default="false")
# id de l'element pedagogique Apogee correspondant: # id de l'element pedagogique Apogee correspondant:
code_apogee = db.Column(db.String(APO_CODE_STR_LEN)) code_apogee = db.Column(db.String(APO_CODE_STR_LEN))
@ -100,8 +100,7 @@ class UniteEns(db.Model):
return ue return ue
def to_dict(self, convert_objects=False, with_module_ue_coefs=True): def to_dict(self, convert_objects=False, with_module_ue_coefs=True):
"""as a dict, with the same conversions as in ScoDoc7 """as a dict, with the same conversions as in ScoDoc7.
(except ECTS: keep None)
If convert_objects, convert all attributes to native types If convert_objects, convert all attributes to native types
(suitable for json encoding). (suitable for json encoding).
""" """
@ -111,7 +110,12 @@ class UniteEns(db.Model):
# ScoDoc7 output_formators # ScoDoc7 output_formators
e["ue_id"] = self.id e["ue_id"] = self.id
e["numero"] = e["numero"] if e["numero"] else 0 e["numero"] = e["numero"] if e["numero"] else 0
e["ects"] = e["ects"] e["ects"] = e["ects"] # legacy
e["ects_by_parcours"] = {}
for up in UEParcours.query.filter_by(ue_id=self.id):
p = ApcParcours.query.get(up.parcours_id)
e["ects_by_parcours"][p.code] = self.get_ects(p)
e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0 e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0
e["code_apogee"] = e["code_apogee"] or "" # pas de None e["code_apogee"] = e["code_apogee"] or "" # pas de None
e["parcours"] = [ e["parcours"] = [
@ -164,6 +168,44 @@ class UniteEns(db.Model):
db.session.add(self) db.session.add(self)
db.session.commit() db.session.commit()
def get_ects(self, parcour: ApcParcours = None, only_parcours=False) -> float:
"""Crédits ECTS associés à cette UE.
En BUT, cela peut quelquefois dépendre du parcours.
Si only_parcours, renvoie None si pas de valeur spéciquement définie dans
le parcours indiqué.
"""
if parcour is not None:
ue_parcour = UEParcours.query.filter_by(
ue_id=self.id, parcours_id=parcour.id
).first()
if ue_parcour is not None and ue_parcour.ects is not None:
return ue_parcour.ects
if only_parcours:
return None
return self.ects
def set_ects(self, ects: float, parcour: ApcParcours = None):
"""Fixe les crédits. Do not commit.
Si le parcours n'est pas spécifié, affecte les ECTS par défaut de l'UE.
Si ects est None et parcours indiqué, efface l'association.
"""
if parcour is not None:
ue_parcour = UEParcours.query.filter_by(
ue_id=self.id, parcours_id=parcour.id
).first()
if ects is None:
if ue_parcour:
db.session.delete(ue_parcour)
else:
if ue_parcour is None:
ue_parcour = UEParcours(parcours_id=parcour.id, ue_id=self.id)
ue_parcour.ects = float(ects)
db.session.add(ue_parcour)
else:
self.ects = ects
log(f"ue.set_ects( ue_id={self.id}, acronyme={self.acronyme}, ects={ects} )")
db.session.add(self)
def get_ressources(self): def get_ressources(self):
"Liste des modules ressources rattachés à cette UE" "Liste des modules ressources rattachés à cette UE"
return self.modules.filter_by(module_type=scu.ModuleType.RESSOURCE).all() return self.modules.filter_by(module_type=scu.ModuleType.RESSOURCE).all()
@ -334,12 +376,21 @@ class UEParcours(db.Model):
"""Association ue <-> parcours, indiquant les ECTS""" """Association ue <-> parcours, indiquant les ECTS"""
__tablename__ = "ue_parcours" __tablename__ = "ue_parcours"
ue_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), primary_key=True) ue_id = db.Column(
db.Integer,
db.ForeignKey("notes_ue.id", ondelete="CASCADE"),
primary_key=True,
)
parcours_id = db.Column( parcours_id = db.Column(
db.Integer, db.ForeignKey("apc_parcours.id"), primary_key=True db.Integer,
db.ForeignKey("apc_parcours.id", ondelete="CASCADE"),
primary_key=True,
) )
ects = db.Column(db.Float, nullable=True) # si NULL, on prendra les ECTS de l'UE ects = db.Column(db.Float, nullable=True) # si NULL, on prendra les ECTS de l'UE
def __repr__(self):
return f"<UEParcours( ue_id={self.ue_id}, parcours_id={self.parcours_id}, ects={self.ects})>"
class DispenseUE(db.Model): class DispenseUE(db.Model):
"""Dispense d'UE """Dispense d'UE

View File

@ -111,7 +111,6 @@ def html_edit_formation_apc(
icons=icons, icons=icons,
ues_by_sem=ues_by_sem, ues_by_sem=ues_by_sem,
ects_by_sem=ects_by_sem, ects_by_sem=ects_by_sem,
form_ue_choix_parcours_niveau=apc_edit_ue.form_ue_choix_parcours_niveau,
scu=scu, scu=scu,
codes_cursus=codes_cursus, codes_cursus=codes_cursus,
), ),

View File

@ -369,7 +369,12 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
"min_value": 0, "min_value": 0,
"max_value": 1000, "max_value": 1000,
"title": "ECTS", "title": "ECTS",
"explanation": "nombre de crédits ECTS (indiquer 0 si UE bonus)", "explanation": "nombre de crédits ECTS (indiquer 0 si UE bonus)"
+ (
". (si les ECTS dépendent du parcours, voir plus bas.)"
if is_apc
else ""
),
"allow_null": not is_apc, # ects requis en APC "allow_null": not is_apc, # ects requis en APC
}, },
), ),

View File

@ -103,12 +103,12 @@ div.competence {
padding-bottom: 6px; padding-bottom: 6px;
} }
.titre_niveau span.parcs { span.parcs {
margin-left: 12px; margin-left: 12px;
display: inline-block; display: inline-block;
} }
.titre_niveau span.parc { span.parc {
font-size: 75%; font-size: 75%;
font-weight: bold; font-weight: bold;
/* color: rgb(92, 87, 255); */ /* color: rgb(92, 87, 255); */

View File

@ -2530,6 +2530,15 @@ div.cont_ue_choix_niveau select.select_niveau_ue {
width: 490px; width: 490px;
} }
div.ue_advanced {
background-color: rgb(244, 253, 255);
border: 1px solid blue;
border-radius: 10px;
padding: 10px;
margin-top: 10px;
margin-right: 15px;
}
div#ue_list_modules { div#ue_list_modules {
background-color: rgb(251, 225, 165); background-color: rgb(251, 225, 165);
border: 1px solid blue; border: 1px solid blue;

View File

@ -116,11 +116,32 @@ Choisissez un parcours...
</div> </div>
<div><a class="stdlink" href="{{ <div><a class="stdlink" href="{{
url_for('notes.refcomp_show', url_for('notes.refcomp_show',
scodoc_dept=g.scodoc_dept, refcomp_id=parcour.referentiel.id ) scodoc_dept=g.scodoc_dept, refcomp_id=formation.referentiel_competence.id )
}}">Référentiel de compétences</a> }}">Référentiel de compétences</a>
</div> </div>
</div> </div>
{% if parcour %}
<div class="help">
<p> Cette page représente le parcours <span class="parc">{{parcour.code}}</span>
du référentiel de compétence {{formation.referentiel_competence.specialite}}, et permet
d'associer à chaque semestre d'un niveau de compétence une UE de la formation
<a class="stdlink"
href="{{url_for('notes.ue_table', scodoc_dept=g.scodoc_dept, formation_id=formation.id )
}}">{{formation.to_html()}}
</a>.</p>
<p>Le symbole <span class="parc">TC</span> désigne un niveau du tronc commun
(c'est à dire présent dans tous les parcours de la spécialité). </p>
<p>Ce formulaire ne vérifie pas si l'UE est bien conçue pour ce parcours.</p>
<p>Les modifications sont enregistrées au fur et à mesure.</p>
</div>
{% endif %}
<script> <script>
function assoc_ue_niveau(event, niveau_id) { function assoc_ue_niveau(event, niveau_id) {
let ue_id = event.target.value; let ue_id = event.target.value;

View File

@ -0,0 +1,40 @@
{# Association d'ECTS à une UE par parcours #}
{% extends "sco_page.j2" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block styles %}
{{super()}}
{% endblock %}
{% block app_content %}
<h2>ECTS par parcours pour l'UE {{ue.acronyme}}</h2>
<div class="help">
<p>
Utilisez ce formulaire dans le cas (assez rare) où l'UE {{ue.acronyme}}
doit avoir un nombre d'ECTS qui dépend du parcours de l'étudiant.
<p>
Si un champ est laissé sont vide, la valeur par défaut spécifiée pour l'UE
(actuellement {{ue.ects or 0}} ECTS) sera utilisée pour ce parcours.
</p>
</div>
<form method="POST">
{% for field in form %}
{% if field.name != 'csrf_token' %}
<div>
<label for="{{ field.id }}">{{ field.label }}</label>
{{ field }}
{% for error in field.errors %}
<div class="error-message">{{ error }}</div>
{% endfor %}
</div>
{% endif %}
{% endfor %}
{{ form.csrf_token }}
<input type="submit" name="submit" value="Enregistrer">
<input type="submit" name="cancel" value="Annuler">
</form>
{% endblock %}

View File

@ -65,7 +65,6 @@
}}">modifier</a> }}">modifier</a>
{% endif %} {% endif %}
{# form_ue_choix_parcours_niveau(ue)|safe #}
{% if ue.type != codes_cursus.UE_SPORT %} {% if ue.type != codes_cursus.UE_SPORT %}
<div class="ue_choix_niveau"> <div class="ue_choix_niveau">
{% if ue.niveau_competence %} {% if ue.niveau_competence %}

View File

@ -1,13 +0,0 @@
{# inclu par form_ues.j2 #}
<form method="POST" action="">
{{ form_ue_parcours_niveau.csrf_token }}
<div class="form-group">
{{ form_ue_parcours_niveau.niveau_select.label }}
{{ form_ue_parcours_niveau.niveau_select }}
{{ form_ue_parcours_niveau.parcours_multiselect.label }}
{{ form_ue_parcours_niveau.parcours_multiselect }}
</div>
</form>

View File

@ -28,20 +28,23 @@ Vues sur les formations BUT
Emmanuel Viennet, 2023 Emmanuel Viennet, 2023
""" """
from flask import g, render_template from flask import flash, g, redirect, render_template, request, url_for
from app import log from app import db, log
from app.decorators import ( from app.decorators import (
scodoc, scodoc,
permission_required, permission_required,
) )
from app.forms.formation.ue_parcours_ects import UEParcoursECTSForm
from app.models import ( from app.models import (
ApcCompetence, ApcCompetence,
ApcNiveau, ApcNiveau,
ApcParcours, ApcParcours,
ApcReferentielCompetences, ApcReferentielCompetences,
Formation, Formation,
UniteEns,
) )
from app.scodoc.codes_cursus import UE_STANDARD from app.scodoc.codes_cursus import UE_STANDARD
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
@ -168,3 +171,40 @@ def parcour_formation_competences(parcour: ApcParcours, formation: Formation) ->
for competence in parcour.query_competences() for competence in parcour.query_competences()
] ]
return competences return competences
@bp.route("/ue_parcours_ects/<int:ue_id>", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.ScoChangeFormation)
def ue_parcours_ects(ue_id: int):
"""formulaire (div) pour associer des ECTS par parcours d'une UE"""
ue: UniteEns = (
UniteEns.query.filter_by(id=ue_id)
.join(Formation)
.filter_by(dept_id=g.scodoc_dept_id)
.first_or_404()
)
if ue.type != UE_STANDARD:
raise ScoValueError("Pas d'ECTS / Parcours pour ce type d'UE")
ref_comp = ue.formation.referentiel_competence
if ref_comp is None:
raise ScoValueError("Pas référentiel de compétence pour cette UE !")
form = UEParcoursECTSForm(ue)
edit_url = url_for("notes.ue_edit", scodoc_dept=g.scodoc_dept, ue_id=ue.id)
if request.method == "POST":
if request.form.get("submit"):
if form.validate():
for parcour in ue.formation.referentiel_competence.parcours:
field = getattr(form, f"ects_parcour_{parcour.id}")
if field:
ue.set_ects(field.data, parcour=parcour)
db.session.commit()
flash("ECTS enregistrés")
return redirect(edit_url)
elif request.form.get("cancel"):
return redirect(edit_url)
return render_template(
"formation/ue_assoc_parcours_ects.j2", form=form, sco=ScoData(), ue=ue
)

View File

@ -30,15 +30,12 @@ def upgrade():
sa.Column("parcours_id", sa.Integer(), nullable=False), sa.Column("parcours_id", sa.Integer(), nullable=False),
sa.Column("ects", sa.Float(), nullable=True), sa.Column("ects", sa.Float(), nullable=True),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(
["parcours_id"], ["parcours_id"], ["apc_parcours.id"], ondelete="CASCADE"
["apc_parcours.id"],
),
sa.ForeignKeyConstraint(
["ue_id"],
["notes_ue.id"],
), ),
sa.ForeignKeyConstraint(["ue_id"], ["notes_ue.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("ue_id", "parcours_id"), sa.PrimaryKeyConstraint("ue_id", "parcours_id"),
) )
# #
bind = op.get_bind() bind = op.get_bind()
session = Session(bind=bind) session = Session(bind=bind)