diff --git a/app/models/__init__.py b/app/models/__init__.py index 84f1332e..c7a183ec 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -78,4 +78,6 @@ from app.models.but_refcomp import ( ApcAppCritique, ApcParcours, ) +from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE + from app.models.config import ScoDocSiteConfig diff --git a/app/models/but_validations.py b/app/models/but_validations.py new file mode 100644 index 00000000..6b5bb6e9 --- /dev/null +++ b/app/models/but_validations.py @@ -0,0 +1,100 @@ +# -*- coding: UTF-8 -* + +"""Décisions de jury validations) des RCUE et années du BUT +""" + +from app import db +from app import log + +from app.models import CODE_STR_LEN +from app.models.ues import UniteEns +from app.models.formsemestre import FormSemestre, FormSemestreInscription + + +class ApcValidationRCUE(db.Model): + """Validation des niveaux de compétences + + aka "regroupements cohérents d'UE" dans le jargon BUT. + + """ + + __tablename__ = "apc_validation_rcue" + # Assure unicité de la décision: + __table_args__ = ( + db.UniqueConstraint("etudid", "formsemestre_id", "ue1_id", "ue2_id"), + ) + + id = db.Column(db.Integer, primary_key=True) + etudid = db.Column( + db.Integer, + db.ForeignKey("identite.id", ondelete="CASCADE"), + index=True, + nullable=False, + ) + formsemestre_id = db.Column( + db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, nullable=True + ) + # Les deux UE associées à ce niveau: + ue1_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False) + ue2_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False) + # optionnel, le parcours dans lequel se trouve la compétence: + parcours_id = db.Column(db.Integer, db.ForeignKey("apc_parcours.id"), nullable=True) + date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) + code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True) + + etud = db.relationship("Identite", backref="apc_validations_rcues") + formsemestre = db.relationship("FormSemestre", backref="apc_validations_rcues") + ue1 = db.relationship("UniteEns", foreign_keys=ue1_id) + ue2 = db.relationship("UniteEns", foreign_keys=ue2_id) + parcour = db.relationship("ApcParcours") + + def __repr__(self): + return f"<{self.__class__.__name__} {self.id} {self.etud} {self.ue1}/{self.ue2}:{self.code!r}>" + + +def get_other_ue_rcue(ue: UniteEns, etudid: int) -> UniteEns: + """L'autre UE du RCUE (niveau de compétence) pour cet étudiant, + None si pas trouvée. + """ + if (ue.niveau_competence is None) or (ue.semestre_idx is None): + return None + q = UniteEns.query.filter( + FormSemestreInscription.etudid == etudid, + FormSemestreInscription.formsemestre_id == FormSemestre.id, + FormSemestre.formation_id == UniteEns.formation_id, + FormSemestre.semestre_id == UniteEns.semestre_idx, + UniteEns.niveau_competence_id == ue.niveau_competence_id, + UniteEns.semestre_idx != ue.semestre_idx, + ) + if q.count() > 1: + log("Warning: get_other_ue_rcue: {q.count()} candidates UE") + return q.first() + + +class ApcValidationAnnee(db.Model): + """Validation des années du BUT""" + + __tablename__ = "apc_validation_annee" + # Assure unicité de la décision: + __table_args__ = (db.UniqueConstraint("etudid", "annee_scolaire"),) + id = db.Column(db.Integer, primary_key=True) + etudid = db.Column( + db.Integer, + db.ForeignKey("identite.id", ondelete="CASCADE"), + index=True, + nullable=False, + ) + ordre = db.Column(db.Integer, nullable=False) + "numéro de l'année: 1, 2, 3" + formsemestre_id = db.Column( + db.Integer, db.ForeignKey("notes_formsemestre.id"), nullable=True + ) + annee_scolaire = db.Column(db.Integer, nullable=False) # 2021 + date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) + code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True) + + etud = db.relationship("Identite", backref="apc_validations_annees") + formsemestre = db.relationship("FormSemestre", backref="apc_validations_annees") + + def __repr__(self): + return f"<{self.__class__.__name__} {self.id} {self.etud} BUT{self.ordre}:{self.code!r}>" diff --git a/app/models/formations.py b/app/models/formations.py index 5933bcbf..725dd7e7 100644 --- a/app/models/formations.py +++ b/app/models/formations.py @@ -1,10 +1,17 @@ """ScoDoc 9 models : Formations """ +import flask_sqlalchemy import app from app import db from app.comp import df_cache from app.models import SHORT_STR_LEN +from app.models.but_refcomp import ( + ApcAnneeParcours, + ApcNiveau, + ApcParcours, + ApcParcoursNiveauCompetence, +) from app.models.modules import Module from app.models.ues import UniteEns from app.scodoc import sco_cache @@ -148,6 +155,18 @@ class Formation(db.Model): if change: app.clear_scodoc_cache() + def query_ues_parcour(self, parcour: ApcParcours) -> flask_sqlalchemy.BaseQuery:: + """Les UEs d'un parcours de la formation. + Exemple: pour avoir les UE du semestre 3, faire + `formation.query_ues_parcour(parcour).filter_by(semestre_idx=3)` + """ + return UniteEns.query.filter_by(formation=self).filter( + UniteEns.niveau_competence_id == ApcNiveau.id, + ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id, + ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id, + ApcAnneeParcours.parcours_id == parcour.id, + ) + class Matiere(db.Model): """Matières: regroupe les modules d'une UE diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 393e1178..d3375a63 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -14,6 +14,12 @@ from app import log from app.models import APO_CODE_STR_LEN from app.models import SHORT_STR_LEN from app.models import CODE_STR_LEN +from app.models.but_refcomp import ( + ApcAnneeParcours, + ApcNiveau, + ApcParcours, + ApcParcoursNiveauCompetence, +) from app.models.groups import GroupDescr, Partition import app.scodoc.sco_utils as scu @@ -211,6 +217,22 @@ class FormSemestre(db.Model): sem_ues = sem_ues.filter(UniteEns.type != sco_codes_parcours.UE_SPORT) return sem_ues.order_by(UniteEns.numero) + def query_ues_parcours_etud(self, etudid: int) -> flask_sqlalchemy.BaseQuery: + """UE que suit l'étudiant dans ce semestre BUT + en fonction du parcours dans lequel il est inscrit. + + Si voulez les UE d'un parcour, il est plus efficace de passer par + `formation.query_ues_parcour(parcour)`. + """ + return self.query_ues().filter( + FormSemestreInscription.etudid == etudid, + FormSemestreInscription.formsemestre == self, + UniteEns.niveau_competence_id == ApcNiveau.id, + ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id, + ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id, + ApcAnneeParcours.parcours_id == FormSemestreInscription.parcour_id, + ) + @cached_property def modimpls_sorted(self) -> list[ModuleImpl]: """Liste des modimpls du semestre (y compris bonus) diff --git a/app/scodoc/sco_codes_parcours.py b/app/scodoc/sco_codes_parcours.py index e77c711d..17c2dae7 100644 --- a/app/scodoc/sco_codes_parcours.py +++ b/app/scodoc/sco_codes_parcours.py @@ -35,7 +35,7 @@ from app import log @enum.unique class CodesParcours(enum.IntEnum): - """Codes numériques de sparcours, enregistrés en base + """Codes numériques des parcours, enregistrés en base dans notes_formations.type_parcours Ne pas modifier. """ @@ -122,10 +122,12 @@ ATJ = "ATJ" # pb assiduité: décision repoussée au semestre suivant ATB = "ATB" AJ = "AJ" CMP = "CMP" # utile pour UE seulement (indique UE acquise car semestre acquis) -NAR = "NAR" -RAT = "RAT" # en attente rattrapage, sera ATT dans Apogée DEF = "DEF" # défaillance (n'est pas un code jury dans scodoc mais un état, comme inscrit ou demission) DEM = "DEM" +JSD = "JSD" # jurytenu mais pas de code (Jury Sans Décision) +NAR = "NAR" +RAT = "RAT" # en attente rattrapage, sera ATT dans Apogée + # codes actions REDOANNEE = "REDOANNEE" # redouble annee (va en Sn-1) @@ -156,9 +158,7 @@ CODES_EXPL = { RAT: "En attente d'un rattrapage", DEM: "Démission", } -# Nota: ces explications sont personnalisables via le fichier -# de config locale /opt/scodoc/var/scodoc/config/scodoc_local.py -# variable: CONFIG.CODES_EXP + # Les codes de semestres: CODES_JURY_SEM = {ADC, ADJ, ADM, AJ, ATB, ATJ, ATT, DEF, NAR, RAT} @@ -169,6 +169,9 @@ CODES_SEM_REO = {NAR: 1} # reorientation CODES_UE_VALIDES = {ADM: True, CMP: True} # UE validée +# Pour le BUT: +CODES_RCUE = {ADM, AJ, CMP} + def code_semestre_validant(code: str) -> bool: "Vrai si ce CODE entraine la validation du semestre" diff --git a/app/scodoc/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py index 394af3be..3f95add7 100644 --- a/app/scodoc/sco_formsemestre_inscriptions.py +++ b/app/scodoc/sco_formsemestre_inscriptions.py @@ -258,6 +258,7 @@ def do_formsemestre_inscription_with_modules( """Inscrit cet etudiant à ce semestre et TOUS ses modules STANDARDS (donc sauf le sport) """ + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) # inscription au semestre args = {"formsemestre_id": formsemestre_id, "etudid": etudid} if etat is not None: @@ -284,7 +285,7 @@ def do_formsemestre_inscription_with_modules( f"do_formsemestre_inscription_with_modules: group {group:r} belongs to non editable partition" ) - # inscription a tous les modules de ce semestre + # Inscription à tous les modules de ce semestre modimpls = sco_moduleimpl.moduleimpl_withmodule_list( formsemestre_id=formsemestre_id ) @@ -294,6 +295,8 @@ def do_formsemestre_inscription_with_modules( {"moduleimpl_id": mod["moduleimpl_id"], "etudid": etudid}, formsemestre_id=formsemestre_id, ) + # Mise à jour des inscriptions aux parcours: + formsemestre.update_inscriptions_parcours_from_groups() def formsemestre_inscription_with_modules_etud( diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 5cc5c47a..672108a8 100644 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -149,7 +149,10 @@ def formsemestre_status_menubar(sem): { "title": "Voir la formation %(acronyme)s (v%(version)s)" % F, "endpoint": "notes.ue_table", - "args": {"formation_id": sem["formation_id"]}, + "args": { + "formation_id": sem["formation_id"], + "semestre_idx": sem["semestre_id"], + }, "enabled": True, "helpmsg": "Tableau de bord du semestre", }, diff --git a/app/scodoc/sco_groups_edit.py b/app/scodoc/sco_groups_edit.py index b4fa4511..088f74e5 100644 --- a/app/scodoc/sco_groups_edit.py +++ b/app/scodoc/sco_groups_edit.py @@ -29,6 +29,7 @@ """ from flask import render_template +from app.models import Partition from app.scodoc import html_sco_header from app.scodoc import sco_groups from app.scodoc.sco_exceptions import AccessDenied @@ -39,8 +40,8 @@ def affect_groups(partition_id): Permet aussi la creation et la suppression de groupes. """ # réécrit pour 9.0.47 avec un template - partition = sco_groups.get_partition(partition_id) - formsemestre_id = partition["formsemestre_id"] + partition = Partition.query.get_or_404(partition_id) + formsemestre_id = partition.formsemestre_id if not sco_groups.sco_permissions_check.can_change_groups(formsemestre_id): raise AccessDenied("vous n'avez pas la permission de modifier les groupes") partition.formsemestre.setup_parcours_groups() @@ -53,8 +54,9 @@ def affect_groups(partition_id): ), sco_footer=html_sco_header.sco_footer(), partition=partition, - partitions_list=sco_groups.get_partitions_list( - formsemestre_id, with_default=False + # Liste des partitions sans celle par defaut: + partitions_list=partition.formsemestre.partitions.filter( + Partition.partition_name != None ), formsemestre_id=formsemestre_id, ) diff --git a/app/templates/scolar/affect_groups.html b/app/templates/scolar/affect_groups.html index 8d01eca3..323a42e5 100644 --- a/app/templates/scolar/affect_groups.html +++ b/app/templates/scolar/affect_groups.html @@ -1,13 +1,13 @@ {# -*- mode: jinja-html -*- #} {{ sco_header|safe }} -

Affectation aux groupes de {{ partition["partition_name"] }}

+

Affectation aux groupes de {{ partition.partition_name }}

Faites glisser les étudiants d'un groupe à l'autre. Les modifications ne sont enregistrées que lorsque vous cliquez sur le bouton "Enregistrer ces groupes". Vous pouvez créer de nouveaux groupes. Pour supprimer un groupe, utiliser le lien "suppr." en haut à droite de sa boite. Vous pouvez aussi répartir automatiquement les groupes.

@@ -15,24 +15,24 @@ href="{{ url_for('scolar.groups_auto_repartition', scodoc_dept=g.scodoc_dept, pa
- - - -       - -       -    Éditer groupes de - + + + +        + +        +     Éditer groupes de +
diff --git a/migrations/versions/4311cc342dbd_validations_but.py b/migrations/versions/4311cc342dbd_validations_but.py new file mode 100644 index 00000000..7020eff1 --- /dev/null +++ b/migrations/versions/4311cc342dbd_validations_but.py @@ -0,0 +1,128 @@ +"""Validations BUT + +Revision ID: 4311cc342dbd +Revises: a2771105c21c +Create Date: 2022-05-28 16:46:09.861248 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "4311cc342dbd" +down_revision = "a2771105c21c" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "apc_validation_annee", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("etudid", sa.Integer(), nullable=False), + sa.Column("ordre", sa.Integer(), nullable=False), + sa.Column("formsemestre_id", sa.Integer(), nullable=True), + sa.Column("annee_scolaire", sa.Integer(), nullable=False), + sa.Column( + "date", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("code", sa.String(length=16), nullable=False), + sa.ForeignKeyConstraint(["etudid"], ["identite.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["formsemestre_id"], + ["notes_formsemestre.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("etudid", "annee_scolaire"), + ) + op.create_index( + op.f("ix_apc_validation_annee_code"), + "apc_validation_annee", + ["code"], + unique=False, + ) + op.create_index( + op.f("ix_apc_validation_annee_etudid"), + "apc_validation_annee", + ["etudid"], + unique=False, + ) + op.create_table( + "apc_validation_rcue", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("etudid", sa.Integer(), nullable=False), + sa.Column("formsemestre_id", sa.Integer(), nullable=True), + sa.Column("ue1_id", sa.Integer(), nullable=False), + sa.Column("ue2_id", sa.Integer(), nullable=False), + sa.Column("parcours_id", sa.Integer(), nullable=True), + sa.Column( + "date", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("code", sa.String(length=16), nullable=False), + sa.ForeignKeyConstraint(["etudid"], ["identite.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["formsemestre_id"], + ["notes_formsemestre.id"], + ), + sa.ForeignKeyConstraint( + ["parcours_id"], + ["apc_parcours.id"], + ), + sa.ForeignKeyConstraint( + ["ue1_id"], + ["notes_ue.id"], + ), + sa.ForeignKeyConstraint( + ["ue2_id"], + ["notes_ue.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("etudid", "formsemestre_id", "ue1_id", "ue2_id"), + ) + op.create_index( + op.f("ix_apc_validation_rcue_code"), + "apc_validation_rcue", + ["code"], + unique=False, + ) + op.create_index( + op.f("ix_apc_validation_rcue_etudid"), + "apc_validation_rcue", + ["etudid"], + unique=False, + ) + op.create_index( + op.f("ix_apc_validation_rcue_formsemestre_id"), + "apc_validation_rcue", + ["formsemestre_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_apc_validation_rcue_formsemestre_id"), table_name="apc_validation_rcue" + ) + op.drop_index( + op.f("ix_apc_validation_rcue_etudid"), table_name="apc_validation_rcue" + ) + op.drop_index(op.f("ix_apc_validation_rcue_code"), table_name="apc_validation_rcue") + op.drop_table("apc_validation_rcue") + op.drop_index( + op.f("ix_apc_validation_annee_etudid"), table_name="apc_validation_annee" + ) + op.drop_index( + op.f("ix_apc_validation_annee_code"), table_name="apc_validation_annee" + ) + op.drop_table("apc_validation_annee") + # ### end Alembic commands ###