diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index 8c1d68dab9..c533c21aa3 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -33,10 +33,7 @@ import pandas as pd from app import db from app import models from app.models import ( - DispenseUE, FormSemestre, - FormSemestreInscription, - Identite, Module, ModuleImpl, ModuleUECoef, @@ -218,31 +215,6 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple: ) -def load_dispense_ues( - formsemestre: FormSemestre, etudids: pd.Index, ues: list[UniteEns] -) -> set[tuple[int, int]]: - """Construit l'ensemble des - etudids = modimpl_inscr_df.index, # les etudids - ue_ids : modimpl_coefs_df.index, # les UE du formsemestre sans les UE bonus sport - - Résultat: set de (etudid, ue_id). - """ - dispense_ues = set() - ue_sem_by_code = {ue.ue_code: ue for ue in ues} - # Prend toutes les dispenses obtenues par des étudiants de ce formsemestre, - # puis filtre sur inscrits et code d'UE UE - for dispense_ue in DispenseUE.query.join( - Identite, FormSemestreInscription - ).filter_by(formsemestre_id=formsemestre.id): - if dispense_ue.etudid in etudids: - # UE dans le semestre avec même code ? - ue = ue_sem_by_code.get(dispense_ue.ue.ue_code) - if ue is not None: - dispense_ues.add((dispense_ue.etudid, ue.id)) - - return dispense_ues - - def compute_ue_moys_apc( sem_cube: np.array, etuds: list, diff --git a/app/comp/res_but.py b/app/comp/res_but.py index 28d8cf72df..f3f0c97dbd 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -16,7 +16,7 @@ from app.comp.res_compat import NotesTableCompat from app.comp.bonus_spo import BonusSport from app.models import ScoDocSiteConfig from app.models.moduleimpls import ModuleImpl -from app.models.ues import UniteEns +from app.models.ues import DispenseUE, UniteEns from app.scodoc.sco_codes_parcours import UE_SPORT from app.scodoc import sco_preferences @@ -72,7 +72,7 @@ class ResultatsSemestreBUT(NotesTableCompat): modimpl.module.ue.type != UE_SPORT for modimpl in self.formsemestre.modimpls_sorted ] - self.dispense_ues = moy_ue.load_dispense_ues( + self.dispense_ues = DispenseUE.load_formsemestre_dispense_ues_set( self.formsemestre, self.modimpl_inscr_df.index, self.ues ) self.etud_moy_ue = moy_ue.compute_ue_moys_apc( diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index e985a153e0..6654d691f7 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -59,9 +59,8 @@ class FormSemestre(db.Model): titre = db.Column(db.Text(), nullable=False) date_debut = db.Column(db.Date(), nullable=False) date_fin = db.Column(db.Date(), nullable=False) - etat = db.Column( - db.Boolean(), nullable=False, default=True, server_default="true" - ) # False si verrouillé + etat = db.Column(db.Boolean(), nullable=False, default=True, server_default="true") + "False si verrouillé" modalite = db.Column( db.String(SHORT_STR_LEN), db.ForeignKey("notes_form_modalites.modalite") ) # "FI", "FAP", "FC", ... diff --git a/app/models/ues.py b/app/models/ues.py index b56f209c54..387fc8d283 100644 --- a/app/models/ues.py +++ b/app/models/ues.py @@ -1,6 +1,8 @@ """ScoDoc 9 models : Unités d'Enseignement (UE) """ +import pandas as pd + from app import db, log from app.models import APO_CODE_STR_LEN from app.models import SHORT_STR_LEN @@ -249,12 +251,23 @@ class UniteEns(db.Model): class DispenseUE(db.Model): """Dispense d'UE - Utilisé en PCC (BUT) pour indiquer les étudiants redoublants avec une UE capitalisée + Utilisé en APC (BUT) pour indiquer les étudiants redoublants avec une UE capitalisée qu'ils ne refont pas. + La dispense d'UE n'est PAS une validation: + - elle n'est pas affectée par les décisions de jury (pas effacée) + - elle est associée à un formsemestre + - elle ne permet pas la délivrance d'ECTS ou du diplôme. + + On utilise cette dispense et non une "inscription" par souci d'efficacité: + en général, la grande majorité des étudiants suivront toutes les UEs de leur parcours, + la dispense étant une exception. """ - __table_args__ = (db.UniqueConstraint("ue_id", "etudid"),) + __table_args__ = (db.UniqueConstraint("formsemestre_id", "ue_id", "etudid"),) id = db.Column(db.Integer, primary_key=True) + formsemestre_id = formsemestre_id = db.Column( + db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, nullable=True + ) ue_id = db.Column( db.Integer, db.ForeignKey(UniteEns.id, ondelete="CASCADE"), @@ -273,3 +286,25 @@ class DispenseUE(db.Model): def __repr__(self) -> str: return f"""<{self.__class__.__name__} {self.id} etud={ repr(self.etud)} ue={repr(self.ue)}>""" + + @classmethod + def load_formsemestre_dispense_ues_set( + cls, formsemestre: "FormSemestre", etudids: pd.Index, ues: list[UniteEns] + ) -> set[tuple[int, int]]: + """Construit l'ensemble des + etudids = modimpl_inscr_df.index, # les etudids + ue_ids : modimpl_coefs_df.index, # les UE du formsemestre sans les UE bonus sport + + Résultat: set de (etudid, ue_id). + """ + # Prend toutes les dispenses obtenues par des étudiants de ce formsemestre, + # puis filtre sur inscrits et ues + ue_ids = {ue.id for ue in ues} + dispense_ues = { + (dispense_ue.etudid, dispense_ue.ue_id) + for dispense_ue in DispenseUE.query.filter_by( + formsemestre_id=formsemestre.id + ) + if dispense_ue.etudid in etudids and dispense_ue.ue_id in ue_ids + } + return dispense_ues diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 44f14954ac..af37125fc4 100644 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -1088,9 +1088,10 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True): """cliquez sur un module pour saisir des notes""" ) elif datetime.date.today() > formsemestre.date_fin: - H.append( - """semestre terminé""" - ) + if formsemestre.etat: + H.append( + """semestre du passé non verrouillé""" + ) else: H.append( """semestre pas encore commencé""" diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py index 115337b2ff..101de24d98 100644 --- a/app/scodoc/sco_moduleimpl_inscriptions.py +++ b/app/scodoc/sco_moduleimpl_inscriptions.py @@ -35,15 +35,13 @@ from flask_login import current_user from app.comp import res_sem from app.comp.res_compat import NotesTableCompat -from app.models import FormSemestre, Identite, UniteEns +from app.models import FormSemestre, Identite, ScolarFormSemestreValidation, UniteEns -import app.scodoc.notesdb as ndb -import app.scodoc.sco_utils as scu from app import log from app.scodoc.scolog import logdb from app.scodoc import html_sco_header from app.scodoc import htmlutils -from app.scodoc import sco_cache +from app.scodoc import sco_codes_parcours from app.scodoc import sco_edit_module from app.scodoc import sco_edit_ue from app.scodoc import sco_etud @@ -51,8 +49,10 @@ from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_groups from app.scodoc import sco_moduleimpl +import app.scodoc.notesdb as ndb from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_permissions import Permission +import app.scodoc.sco_utils as scu def moduleimpl_inscriptions_edit(moduleimpl_id, etuds=[], submitted=False): @@ -527,14 +527,39 @@ def _list_but_ue_inscriptions(res: NotesTableCompat, read_only: bool = True) -> >{etud.nomprenom}""" ) for ue in ues: + td_class = "" est_inscr = ues_etud.get(ue.id) # None si pas concerné if est_inscr is None: content = "" else: + # Validations d'UE déjà enregistrées dans d'autres semestres + validations_ue = ( + ScolarFormSemestreValidation.query.filter_by(etudid=etudid) + .filter( + ScolarFormSemestreValidation.formsemestre_id + != res.formsemestre.id, + ScolarFormSemestreValidation.code.in_( + sco_codes_parcours.CODES_UE_VALIDES + ), + ) + .join(UniteEns) + .filter_by(ue_code=ue.ue_code) + .all() + ) + validations_ue.sort( + key=lambda v: sco_codes_parcours.BUT_CODES_ORDERED.get(v.code, 0) + ) + validation = validations_ue[-1] if validations_ue else None + expl_validation = ( + f"""Validée ({validation.code}) le {validation.event_date.strftime("%d/%m/%Y")}""" + if validation + else "" + ) + td_class = ' class="ue_validee"' if validation else "" content = f""" /> """ - H.append(f"""{content}""") + H.append(f"""{content}""") H.append( """ diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 01b6ed0332..9e73cd9474 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -1992,6 +1992,10 @@ div.list_but_ue_inscriptions table td { border-bottom: 1px solid salmon; } +div.list_but_ue_inscriptions table td.ue_validee { + background-color: #a1f539; +} + form.list_but_ue_inscriptions { margin-bottom: 16px; } @@ -2004,7 +2008,6 @@ form.list_but_ue_inscriptions td { text-align: center; } - /* Formulaire edition des partitions */ form#editpart table { border: 1px solid gray; diff --git a/app/views/notes.py b/app/views/notes.py index 08f139a8dc..8f64e58948 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -1615,15 +1615,22 @@ def etud_desinscrit_ue(etudid, formsemestre_id, ue_id): ue = UniteEns.query.get_or_404(ue_id) formsemestre = FormSemestre.query.get_or_404(formsemestre_id) if ue.formation.is_apc(): - if DispenseUE.query.filter_by(etudid=etudid, ue_id=ue_id).count() == 0: - disp = DispenseUE(ue_id=ue_id, etudid=etudid) + if ( + DispenseUE.query.filter_by( + formsemestre_id=formsemestre_id, etudid=etudid, ue_id=ue_id + ).count() + == 0 + ): + disp = DispenseUE( + formsemestre_id=formsemestre_id, ue_id=ue_id, etudid=etudid + ) db.session.add(disp) db.session.commit() log(f"etud_desinscrit_ue {etud} {ue}") Scolog.logdb( method="etud_desinscrit_ue", etudid=etud.id, - msg=f"Désinscription de l'UE {ue.acronyme}", + msg=f"Désinscription de l'UE {ue.acronyme} de {formsemestre.titre_annee()}", commit=True, ) sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id) @@ -1658,13 +1665,15 @@ def etud_inscrit_ue(etudid, formsemestre_id, ue_id): etud = Identite.query.get_or_404(etudid) ue = UniteEns.query.get_or_404(ue_id) if ue.formation.is_apc(): - for disp in DispenseUE.query.filter_by(etudid=etud.id, ue_id=ue_id): + for disp in DispenseUE.query.filter_by( + formsemestre_id=formsemestre_id, etudid=etud.id, ue_id=ue_id + ): db.session.delete(disp) log(f"etud_inscrit_ue {etud} {ue}") Scolog.logdb( method="etud_inscrit_ue", etudid=etud.id, - msg=f"Inscription à l'UE {ue.acronyme}", + msg=f"Inscription à l'UE {ue.acronyme} de {formsemestre.titre_annee()}", commit=True, ) db.session.commit() diff --git a/migrations/versions/25e3ca6cc063_dispenseue_par_semestre.py b/migrations/versions/25e3ca6cc063_dispenseue_par_semestre.py new file mode 100644 index 0000000000..942ef77d58 --- /dev/null +++ b/migrations/versions/25e3ca6cc063_dispenseue_par_semestre.py @@ -0,0 +1,86 @@ +"""DispenseUE par semestre + +Revision ID: 25e3ca6cc063 +Revises: 7e5b519a27e1 +Create Date: 2023-01-13 17:19:49.431591 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.orm import sessionmaker # added by ev + + +# revision identifiers, used by Alembic. +revision = "25e3ca6cc063" +down_revision = "7e5b519a27e1" +branch_labels = None +depends_on = None + +Session = sessionmaker() + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "dispenseUE", sa.Column("formsemestre_id", sa.Integer(), nullable=True) + ) + op.drop_constraint("dispenseUE_ue_id_etudid_key", "dispenseUE", type_="unique") + op.create_index( + op.f("ix_dispenseUE_formsemestre_id"), + "dispenseUE", + ["formsemestre_id"], + unique=False, + ) + op.create_unique_constraint( + None, "dispenseUE", ["formsemestre_id", "ue_id", "etudid"] + ) + op.create_foreign_key( + None, "dispenseUE", "notes_formsemestre", ["formsemestre_id"], ["id"] + ) + # ### end Alembic commands ### + + # Affecte les dispenses au formsemestre le plus récent ayant cette UE + bind = op.get_bind() + session = Session(bind=bind) + dispenses = session.execute( + """SELECT id, ue_id, etudid FROM "dispenseUE" WHERE formsemestre_id IS NULL;""" + ).all() + for dispense_id, ue_id, etudid in dispenses: + formsemestre_ids = session.execute( + """ + SELECT notes_formsemestre.id + FROM notes_formsemestre, notes_formations, notes_ue, notes_formsemestre_inscription + WHERE notes_formsemestre.formation_id = notes_formations.id + and notes_ue.formation_id = notes_formations.id + and notes_ue.semestre_idx=notes_formsemestre.semestre_id + and notes_formsemestre_inscription.formsemestre_id=notes_formsemestre.id + and notes_ue.id = :ue_id + and notes_formsemestre_inscription.etudid = :etudid + ORDER BY notes_formsemestre.date_debut DESC + LIMIT 1; + """, + {"ue_id": ue_id, "etudid": etudid}, + ).all() + if formsemestre_ids: + formsemestre_id = formsemestre_ids[0][0] + session.execute( + """ + UPDATE "dispenseUE" SET formsemestre_id=:formsemestre_id WHERE id=:dispense_id""", + {"formsemestre_id": formsemestre_id, "dispense_id": dispense_id}, + ) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + "dispenseUE_formsemestre_id_fkey", "dispenseUE", type_="foreignkey" + ) + op.drop_constraint( + "dispenseUE_formsemestre_id_ue_id_etudid_key", "dispenseUE", type_="unique" + ) + op.drop_index(op.f("ix_dispenseUE_formsemestre_id"), table_name="dispenseUE") + op.create_unique_constraint( + "dispenseUE_ue_id_etudid_key", "dispenseUE", ["ue_id", "etudid"] + ) + op.drop_column("dispenseUE", "formsemestre_id") + # ### end Alembic commands ### diff --git a/scodoc.py b/scodoc.py index 52c4930228..1484883f08 100755 --- a/scodoc.py +++ b/scodoc.py @@ -24,7 +24,7 @@ from app import clear_scodoc_cache from app import models from app.auth.models import User, Role, UserRole -from app.scodoc.sco_logos import make_logo_local +from app.entreprises.models import entreprises_reset_database from app.models import departements from app.models import Formation, UniteEns, Matiere, Module from app.models import FormSemestre, FormSemestreInscription @@ -32,8 +32,9 @@ from app.models import GroupDescr from app.models import Identite from app.models import ModuleImpl, ModuleImplInscription from app.models import Partition +from app.models import ScolarFormSemestreValidation from app.models.evaluations import Evaluation -from app.entreprises.models import entreprises_reset_database +from app.scodoc.sco_logos import make_logo_local from app.scodoc.sco_permissions import Permission from app.views import notes, scolar import tools @@ -52,6 +53,7 @@ def make_shell_context(): import app as mapp # le package app from app.scodoc import notesdb as ndb from app.comp import res_sem + from app.comp.res_but import ResultatsSemestreBUT from app.scodoc import sco_utils as scu return { @@ -83,7 +85,9 @@ def make_shell_context(): "pp": pp, "Role": Role, "res_sem": res_sem, + "ResultatsSemestreBUT": ResultatsSemestreBUT, "scolar": scolar, + "ScolarFormSemestreValidation": ScolarFormSemestreValidation, "ScolarNews": models.ScolarNews, "scu": scu, "UniteEns": UniteEns,