From bb5347514a69e23f28b1a7625a24b9ca9a4b822e Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Fri, 30 Sep 2022 16:20:51 +0200 Subject: [PATCH] =?UTF-8?q?Jury=20BUT=20sur=20semestres=20isol=C3=A9s.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/jury_but.py | 86 +- app/but/jury_but_view.py | 537 ++- app/models/etudiants.py | 1236 +++---- app/scodoc/sco_groups.py | 3546 +++++++++--------- app/scodoc/sco_groups_view.py | 1962 +++++----- app/views/notes.py | 6356 +++++++++++++++++---------------- 6 files changed, 6973 insertions(+), 6750 deletions(-) diff --git a/app/but/jury_but.py b/app/but/jury_but.py index 913297cc..401f8993 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -206,6 +206,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): ): super().__init__(etud=etud) self.formsemestre_id = formsemestre.id + "l'id du formsemestre utilisé pour construire ce deca" formsemestre_impair, formsemestre_pair = self.comp_formsemestres(formsemestre) assert ( (formsemestre_pair is None) @@ -461,22 +462,9 @@ class DecisionsProposeesAnnee(DecisionsProposees): if (formsemestre is None) or (not formsemestre.formation.is_apc()): ues = [] else: - formation: Formation = formsemestre.formation - # Parcour dans lequel l'étudiant est inscrit, et liste des UEs - if res.etuds_parcour_id[etudid] is None: - # pas de parcour: prend toutes les UEs (non bonus) - ues = [ue for ue in res.etud_ues(etudid) if ue.type == UE_STANDARD] - ues.sort(key=lambda u: u.numero) - else: - parcour = ApcParcours.query.get(res.etuds_parcour_id[etudid]) - if parcour is not None: - self.parcour = parcour - ues = ( - formation.query_ues_parcour(parcour) - .filter_by(semestre_idx=formsemestre.semestre_id) - .order_by(UniteEns.numero) - .all() - ) + parcour, ues = list_ue_parcour_etud(formsemestre, self.etud, res) + if parcour is not None: + self.parcour = parcour ues_sems.append(ues) return ues_sems @@ -689,30 +677,40 @@ class DecisionsProposeesAnnee(DecisionsProposees): # s'il n'y a pas de code, efface dec.record(code, no_overwrite=True) - def erase(self): + def erase(self, only_one_sem=False): """Efface les décisions de jury de cet étudiant pour cette année: décisions d'UE, de RCUE, d'année, et autorisations d'inscription émises. """ - for dec_ue in self.decisions_ues.values(): - dec_ue.erase() - for dec_rcue in self.decisions_rcue_by_niveau.values(): - dec_rcue.erase() - if self.formsemestre_impair: + if only_one_sem: + # N'efface que les autorisations venant de ce semestre, + # et les validations de ses UEs ScolarAutorisationInscription.delete_autorisation_etud( - self.etud.id, self.formsemestre_impair.id + self.etud.id, self.formsemestre_id ) - if self.formsemestre_pair: - ScolarAutorisationInscription.delete_autorisation_etud( - self.etud.id, self.formsemestre_pair.id + for dec_ue in self.decisions_ues.values(): + if dec_ue.formsemestre.id == self.formsemestre_id: + dec_ue.erase() + else: + for dec_ue in self.decisions_ues.values(): + dec_ue.erase() + for dec_rcue in self.decisions_rcue_by_niveau.values(): + dec_rcue.erase() + if self.formsemestre_impair: + ScolarAutorisationInscription.delete_autorisation_etud( + self.etud.id, self.formsemestre_impair.id + ) + if self.formsemestre_pair: + ScolarAutorisationInscription.delete_autorisation_etud( + self.etud.id, self.formsemestre_pair.id + ) + validations = ApcValidationAnnee.query.filter_by( + etudid=self.etud.id, + formsemestre_id=self.formsemestre_impair.id, + ordre=self.annee_but, ) - validations = ApcValidationAnnee.query.filter_by( - etudid=self.etud.id, - formsemestre_id=self.formsemestre_impair.id, - ordre=self.annee_but, - ) - for validation in validations: - db.session.delete(validation) + for validation in validations: + db.session.delete(validation) db.session.flush() self.invalidate_formsemestre_cache() @@ -757,6 +755,26 @@ class DecisionsProposeesAnnee(DecisionsProposees): return line_sep.join(validations) +def list_ue_parcour_etud( + formsemestre: FormSemestre, etud: Identite, res: ResultatsSemestreBUT +) -> tuple[ApcParcours, list[UniteEns]]: + """Parcour dans lequel l'étudiant est inscrit, et liste des UEs pour ce semestre""" + if res.etuds_parcour_id[etud.id] is None: + parcour = None + # pas de parcour: prend toutes les UEs (non bonus) + ues = [ue for ue in res.etud_ues(etud.id) if ue.type == UE_STANDARD] + ues.sort(key=lambda u: u.numero) + else: + parcour = ApcParcours.query.get(res.etuds_parcour_id[etud.id]) + ues = ( + formsemestre.formation.query_ues_parcour(parcour) + .filter_by(semestre_idx=formsemestre.semestre_id) + .order_by(UniteEns.numero) + .all() + ) + return parcour, ues + + class DecisionsProposeesRCUE(DecisionsProposees): """Liste des codes de décisions que l'on peut proposer pour le RCUE de cet étudiant dans cette année. @@ -995,8 +1013,8 @@ class DecisionsProposeesUE(DecisionsProposees): etudid=self.etud.id, msg=f"Validation UE {self.ue.id}", ) - log(f"DecisionsProposeesUE: recording {self.validation}") db.session.add(self.validation) + log(f"DecisionsProposeesUE: recording {self.validation}") sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre.id) self.recorded = True diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py index 6470723e..1aa72d1e 100644 --- a/app/but/jury_but_view.py +++ b/app/but/jury_but_view.py @@ -1,173 +1,364 @@ -############################################################################## -# ScoDoc -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# See LICENSE -############################################################################## - -"""Jury BUT: affichage/formulaire -""" -from flask import g, url_for -from app.models.etudiants import Identite - -from app.scodoc import sco_utils as scu -from app.but.jury_but import DecisionsProposeesAnnee, DecisionsProposeesUE -from app.models import FormSemestre, FormSemestreInscription, UniteEns -from app.scodoc.sco_exceptions import ScoValueError - - -def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str: - """Affichage des décisions annuelles BUT - Si pas read_only, menus sélection codes jury. - """ - H = [] - if deca.code_valide and not read_only: - erase_span = f"""effacer décisions""" - else: - erase_span = "" - - H.append( - f"""
-
- Décision de jury pour l'année : { - _gen_but_select("code_annee", deca.codes, deca.code_valide, - disabled=True, klass="manual") - } - ({'non ' if deca.code_valide is None else ''}enregistrée) - {erase_span} -
-
{deca.explanation}
-
""" - ) - - H.append( - f""" -
Niveaux de compétences et unités d'enseignement :
-
-
-
S{1}
-
S{2}
-
RCUE
- """ - ) - for niveau in deca.niveaux_competences: - H.append( - f"""
-
{niveau.competence.titre}
-
""" - ) - dec_rcue = deca.decisions_rcue_by_niveau.get(niveau.id) - if dec_rcue is None: - break - # Semestre impair - H.append( - _gen_but_niveau_ue( - dec_rcue.rcue.ue_1, - dec_rcue.rcue.moy_ue_1, - deca.decisions_ues[dec_rcue.rcue.ue_1.id], - disabled=read_only, - ) - ) - # Semestre pair - H.append( - _gen_but_niveau_ue( - dec_rcue.rcue.ue_2, - dec_rcue.rcue.moy_ue_2, - deca.decisions_ues[dec_rcue.rcue.ue_2.id], - disabled=read_only, - ) - ) - # RCUE - H.append( - f"""
-
{scu.fmt_note(dec_rcue.rcue.moy_rcue)}
-
{ - _gen_but_select("code_rcue_"+str(niveau.id), - dec_rcue.codes, - dec_rcue.code_valide, - disabled=True, klass="manual" - ) - }
-
""" - ) - H.append("
") # but_annee - return "\n".join(H) - - -def _gen_but_select( - name: str, - codes: list[str], - code_valide: str, - disabled: bool = False, - klass: str = "", -) -> str: - "Le menu html select avec les codes" - h = "\n".join( - [ - f"""""" - for code in codes - ] - ) - return f""" - """ - - -def _gen_but_niveau_ue( - ue: UniteEns, moy_ue: float, dec_ue: DecisionsProposeesUE, disabled=False -): - return f"""
-
{ue.acronyme}
-
{scu.fmt_note(moy_ue)}
-
{ - _gen_but_select("code_ue_"+str(ue.id), - dec_ue.codes, - dec_ue.code_valide, disabled=disabled - ) - }
-
""" - - -# -def infos_fiche_etud_html(etudid: int) -> str: - """Section html pour fiche etudiant - provisoire pour BUT 2022 - """ - etud: Identite = Identite.query.get_or_404(etudid) - inscriptions = ( - FormSemestreInscription.query.join(FormSemestreInscription.formsemestre) - .filter( - FormSemestreInscription.etudid == etud.id, - ) - .order_by(FormSemestre.date_debut) - ) - formsemestres_but = [ - i.formsemestre for i in inscriptions if i.formsemestre.formation.is_apc() - ] - if len(formsemestres_but) == 0: - return "" - - # temporaire quick & dirty: affiche le dernier - try: - deca = DecisionsProposeesAnnee(etud, formsemestres_but[-1]) - if len(deca.rcues_annee) > 0: - return f"""
- {show_etud(deca, read_only=True)} -
- """ - except ScoValueError: - pass - - return "" +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Jury BUT: affichage/formulaire +""" + +import re + +import flask +from flask import flash, url_for +from flask import g, request + +from app import db +from app.but import jury_but +from app.but.jury_but import DecisionsProposeesAnnee, DecisionsProposeesUE +from app.comp import res_sem +from app.comp.res_but import ResultatsSemestreBUT +from app.models import ( + FormSemestre, + FormSemestreInscription, + Identite, + UniteEns, + ScolarAutorisationInscription, +) +from app.scodoc import html_sco_header +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc import sco_utils as scu + + +def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str: + """Affichage des décisions annuelles BUT + Si pas read_only, menus sélection codes jury. + """ + H = [] + if deca.code_valide and not read_only: + erase_span = f"""effacer décisions""" + else: + erase_span = "" + + H.append( + f""" +
+
+ Décision de jury pour l'année : { + _gen_but_select("code_annee", deca.codes, deca.code_valide, + disabled=True, klass="manual") + } + ({'non ' if deca.code_valide is None else ''}enregistrée) + {erase_span} +
+
{deca.explanation}
+
+ """ + ) + + H.append( + f""" +
Niveaux de compétences et unités d'enseignement :
+
+
+
S{1}
+
S{2}
+
RCUE
+ """ + ) + for niveau in deca.niveaux_competences: + H.append( + f"""
+
{niveau.competence.titre}
+
""" + ) + dec_rcue = deca.decisions_rcue_by_niveau.get(niveau.id) + if dec_rcue is None: + break + # Semestre impair + H.append( + _gen_but_niveau_ue( + dec_rcue.rcue.ue_1, + dec_rcue.rcue.moy_ue_1, + deca.decisions_ues[dec_rcue.rcue.ue_1.id], + disabled=read_only, + ) + ) + # Semestre pair + H.append( + _gen_but_niveau_ue( + dec_rcue.rcue.ue_2, + dec_rcue.rcue.moy_ue_2, + deca.decisions_ues[dec_rcue.rcue.ue_2.id], + disabled=read_only, + ) + ) + # RCUE + H.append( + f"""
+
{scu.fmt_note(dec_rcue.rcue.moy_rcue)}
+
{ + _gen_but_select("code_rcue_"+str(niveau.id), + dec_rcue.codes, + dec_rcue.code_valide, + disabled=True, klass="manual" + ) + }
+
""" + ) + H.append("
") # but_annee + return "\n".join(H) + + +def _gen_but_select( + name: str, + codes: list[str], + code_valide: str, + disabled: bool = False, + klass: str = "", +) -> str: + "Le menu html select avec les codes" + h = "\n".join( + [ + f"""""" + for code in codes + ] + ) + return f""" + """ + + +def _gen_but_niveau_ue( + ue: UniteEns, moy_ue: float, dec_ue: DecisionsProposeesUE, disabled=False +): + return f"""
+
{ue.acronyme}
+
{scu.fmt_note(moy_ue)}
+
{ + _gen_but_select("code_ue_"+str(ue.id), + dec_ue.codes, + dec_ue.code_valide, disabled=disabled + ) + }
+
""" + + +def jury_but_semestriel( + formsemestre: FormSemestre, etud: Identite, read_only: bool +) -> str: + """Formulaire saisie décision d'UE d'un semestre BUT isolé (pas jury annuel)""" + res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre) + parcour, ues = jury_but.list_ue_parcour_etud(formsemestre, etud, res) + inscription_etat = etud.inscription_etat(formsemestre.id) + semestre_terminal = ( + formsemestre.semestre_id >= formsemestre.formation.get_parcours().NB_SEM + ) + est_autorise_a_passer = (formsemestre.semestre_id + 1) in ( + a.semestre_id + for a in ScolarAutorisationInscription.query.filter_by( + etudid=etud.id, + origin_formsemestre_id=formsemestre.id, + ) + ) + decisions_ues = { + ue.id: DecisionsProposeesUE(etud, formsemestre, ue, inscription_etat) + for ue in ues + } + for dec_ue in decisions_ues.values(): + dec_ue.compute_codes() + + if request.method == "POST": + if not read_only: + for key in request.form: + code = request.form[key] + # Codes d'UE + m = re.match(r"^code_ue_(\d+)$", key) + if m: + ue_id = int(m.group(1)) + dec_ue = decisions_ues.get(ue_id) + if not dec_ue: + raise ScoValueError(f"UE invalide ue_id={ue_id}") + dec_ue.record(code) + db.session.commit() + flash("codes enregistrés") + if not semestre_terminal: + if request.form.get("autorisation_passage"): + if not est_autorise_a_passer: + ScolarAutorisationInscription.autorise_etud( + etud.id, + formsemestre.formation.formation_code, + formsemestre.id, + formsemestre.semestre_id + 1, + ) + db.session.commit() + flash( + f"autorisation de passage en S{formsemestre.semestre_id + 1} enregistrée" + ) + else: + if est_autorise_a_passer: + ScolarAutorisationInscription.delete_autorisation_etud( + etud.id, formsemestre.id + ) + db.session.commit() + flash( + f"autorisation de passage en S{formsemestre.semestre_id + 1} annulée" + ) + return flask.redirect( + url_for( + "notes.formsemestre_validation_but", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre.id, + etudid=etud.id, + ) + ) + # GET + if formsemestre.semestre_id % 2 == 0: + warning = f"""
+ Cet étudiant de S{formsemestre.semestre_id} ne peut pas passer + en jury BUT annuel car il lui manque le semestre précédent. +
""" + else: + warning = "" + H = [ + html_sco_header.sco_header( + page_title="Validation BUT", + formsemestre_id=formsemestre.id, + etudid=etud.id, + cssstyles=("css/jury_but.css",), + javascripts=("js/jury_but.js",), + ), + f""" +
+
+
+
+
Jury BUT S{formsemestre.id} + - Parcours {(parcour.libelle if parcour else False) or "non spécifié"} +
+
{etud.nomprenom}
+
+ +
+

Jury sur un semestre BUT isolé

+ {warning} +
+ +
+ """, + ] + if (not read_only) and any([dec.code_valide for dec in decisions_ues.values()]): + erase_span = f"""effacer décisions""" + else: + erase_span = "aucune décision enregistrée pour ce semestre" + + H.append( + f""" +
+ {erase_span} +
+
Unités d'enseignement de S{formsemestre.semestre_id}:
+
+
+
+
+
+ """ + ) + for ue in ues: + dec_ue = decisions_ues[ue.id] + H.append("""
""") + H.append( + _gen_but_niveau_ue( + ue, + dec_ue.moy_ue, + dec_ue, + disabled=read_only, + ) + ) + H.append( + """
+
""" + ) + H.append("
") # but_annee + + if read_only: + H.append( + """
+ Vous n'avez pas la permission de modifier ces décisions. + Les champs entourés en vert sont enregistrés.
""" + ) + else: + if formsemestre.semestre_id < formsemestre.formation.get_parcours().NB_SEM: + H.append( + f""" +
+ + autoriser à passer dans le semestre S{formsemestre.semestre_id+1} + +
+ """ + ) + else: + H.append("""
dernier semestre de la formation.
""") + H.append( + """ +
+ +
+ """ + ) + return "\n".join(H) + + +# ------------- +def infos_fiche_etud_html(etudid: int) -> str: + """Section html pour fiche etudiant + provisoire pour BUT 2022 + """ + etud: Identite = Identite.query.get_or_404(etudid) + inscriptions = ( + FormSemestreInscription.query.join(FormSemestreInscription.formsemestre) + .filter( + FormSemestreInscription.etudid == etud.id, + ) + .order_by(FormSemestre.date_debut) + ) + formsemestres_but = [ + i.formsemestre for i in inscriptions if i.formsemestre.formation.is_apc() + ] + if len(formsemestres_but) == 0: + return "" + + # temporaire quick & dirty: affiche le dernier + try: + deca = DecisionsProposeesAnnee(etud, formsemestres_but[-1]) + if len(deca.rcues_annee) > 0: + return f"""
+ {show_etud(deca, read_only=True)} +
+ """ + except ScoValueError: + pass + + return "" diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 83ec2870..5a88d3fd 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -1,618 +1,618 @@ -# -*- coding: UTF-8 -* - -"""Définition d'un étudiant - et données rattachées (adresses, annotations, ...) -""" - -import datetime -from functools import cached_property -from flask import abort, has_request_context, url_for -from flask import g, request -import sqlalchemy -from sqlalchemy import desc, text - -from app import db, log -from app import models - -from app.scodoc import notesdb as ndb -from app.scodoc.sco_bac import Baccalaureat -from app.scodoc.sco_exceptions import ScoInvalidParamError -import app.scodoc.sco_utils as scu - - -class Identite(db.Model): - """étudiant""" - - __tablename__ = "identite" - __table_args__ = ( - db.UniqueConstraint("dept_id", "code_nip"), - db.UniqueConstraint("dept_id", "code_ine"), - ) - - id = db.Column(db.Integer, primary_key=True) - etudid = db.synonym("id") - dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True) - - nom = db.Column(db.Text()) - prenom = db.Column(db.Text()) - nom_usuel = db.Column(db.Text()) - # optionnel (si present, affiché à la place du nom) - civilite = db.Column(db.String(1), nullable=False) - __table_args__ = (db.CheckConstraint("civilite IN ('M', 'F', 'X')"),) - - date_naissance = db.Column(db.Date) - lieu_naissance = db.Column(db.Text()) - dept_naissance = db.Column(db.Text()) - nationalite = db.Column(db.Text()) - statut = db.Column(db.Text()) - boursier = db.Column(db.Boolean()) # True si boursier ('O' en ScoDoc7) - photo_filename = db.Column(db.Text()) - # Codes INE et NIP pas unique car le meme etud peut etre ds plusieurs dept - code_nip = db.Column(db.Text(), index=True) - code_ine = db.Column(db.Text(), index=True) - # Ancien id ScoDoc7 pour les migrations de bases anciennes - # ne pas utiliser après migrate_scodoc7_dept_archives - scodoc7_id = db.Column(db.Text(), nullable=True) - # - adresses = db.relationship("Adresse", lazy="dynamic", backref="etud") - billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic") - # - admission = db.relationship("Admission", backref="identite", lazy="dynamic") - - def __repr__(self): - return ( - f"" - ) - - @classmethod - def from_request(cls, etudid=None, code_nip=None): - """Étudiant à partir de l'etudid ou du code_nip, soit - passés en argument soit retrouvés directement dans la requête web. - Erreur 404 si inexistant. - """ - args = make_etud_args(etudid=etudid, code_nip=code_nip) - return Identite.query.filter_by(**args).first_or_404() - - @property - def civilite_str(self): - """returns 'M.' ou 'Mme' ou '' (pour le genre neutre, - personnes ne souhaitant pas d'affichage). - """ - return {"M": "M.", "F": "Mme", "X": ""}[self.civilite] - - def sex_nom(self, no_accents=False) -> str: - "'M. DUPONTÉ', ou si no_accents, 'M. DUPONTE'" - s = f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()}" - if no_accents: - return scu.suppress_accents(s) - return s - - @property - def e(self): - "terminaison en français: 'ne', '', 'ou '(e)'" - return {"M": "", "F": "e"}.get(self.civilite, "(e)") - - def nom_disp(self) -> str: - "Nom à afficher" - if self.nom_usuel: - return ( - (self.nom_usuel + " (" + self.nom + ")") if self.nom else self.nom_usuel - ) - else: - return self.nom - - @cached_property - def nomprenom(self, reverse=False) -> str: - """Civilité/nom/prenom pour affichages: "M. Pierre Dupont" - Si reverse, "Dupont Pierre", sans civilité. - """ - nom = self.nom_usuel or self.nom - prenom = self.prenom_str - if reverse: - fields = (nom, prenom) - else: - fields = (self.civilite_str, prenom, nom) - return " ".join([x for x in fields if x]) - - @property - def prenom_str(self): - """Prénom à afficher. Par exemple: "Jean-Christophe" """ - if not self.prenom: - return "" - frags = self.prenom.split() - r = [] - for frag in frags: - fields = frag.split("-") - r.append("-".join([x.lower().capitalize() for x in fields])) - return " ".join(r) - - @property - def nom_short(self): - "Nom et début du prénom pour table recap: 'DUPONT Pi.'" - return f"{(self.nom_usuel or self.nom or '?').upper()} {(self.prenom or '')[:2].capitalize()}." - - @cached_property - def sort_key(self) -> tuple: - "clé pour tris par ordre alphabétique" - return ( - scu.sanitize_string( - self.nom_usuel or self.nom or "", remove_spaces=False - ).lower(), - scu.sanitize_string(self.prenom or "", remove_spaces=False).lower(), - ) - - def get_first_email(self, field="email") -> str: - "Le mail associé à la première adrese de l'étudiant, ou None" - return getattr(self.adresses[0], field) if self.adresses.count() > 0 else None - - def to_dict_short(self) -> dict: - """Les champs essentiels""" - return { - "id": self.id, - "civilite": self.civilite, - "code_nip": self.code_nip, - "code_ine": self.code_ine, - "dept_id": self.dept_id, - "nom": self.nom, - "nom_usuel": self.nom_usuel, - "prenom": self.prenom, - "sort_key": self.sort_key, - } - - def to_dict_scodoc7(self) -> dict: - """Représentation dictionnaire, - compatible ScoDoc7 mais sans infos admission - """ - e = dict(self.__dict__) - e.pop("_sa_instance_state", None) - # ScoDoc7 output_formators: (backward compat) - e["etudid"] = self.id - e["date_naissance"] = ndb.DateISOtoDMY(e["date_naissance"]) - e["ne"] = self.e - return {k: e[k] or "" for k in e} # convert_null_outputs_to_empty - - def to_dict_bul(self, include_urls=True): - """Infos exportées dans les bulletins - L'étudiant, et sa première adresse. - """ - from app.scodoc import sco_photos - - d = { - "civilite": self.civilite, - "code_ine": self.code_ine or "", - "code_nip": self.code_nip or "", - "date_naissance": self.date_naissance.strftime("%d/%m/%Y") - if self.date_naissance - else "", - "dept_id": self.dept_id, - "dept_acronym": self.departement.acronym, - "email": self.get_first_email() or "", - "emailperso": self.get_first_email("emailperso"), - "etudid": self.id, - "nom": self.nom_disp(), - "prenom": self.prenom or "", - "nomprenom": self.nomprenom or "", - "lieu_naissance": self.lieu_naissance or "", - "dept_naissance": self.dept_naissance or "", - "nationalite": self.nationalite or "", - "boursier": self.boursier or "", - } - if include_urls and has_request_context(): - # test request context so we can use this func in tests under the flask shell - d["fiche_url"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id - ) - d["photo_url"] = sco_photos.get_etud_photo_url(self.id) - adresse = self.adresses.first() - if adresse: - d.update(adresse.to_dict(convert_nulls_to_str=True)) - d["id"] = self.id # a été écrasé par l'id de adresse - return d - - def to_dict_api(self) -> dict: - """Représentation dictionnaire pour export API, avec adresses et admission.""" - e = dict(self.__dict__) - e.pop("_sa_instance_state", None) - admission = self.admission.first() - e["admission"] = admission.to_dict() if admission is not None else None - e["adresses"] = [adr.to_dict() for adr in self.adresses] - e["dept_acronym"] = self.departement.acronym - e.pop("departement", None) - e["sort_key"] = self.sort_key - return e - - def inscriptions(self) -> list["FormSemestreInscription"]: - "Liste des inscriptions à des formsemestres, triée, la plus récente en tête" - from app.models.formsemestre import FormSemestre, FormSemestreInscription - - return ( - FormSemestreInscription.query.join(FormSemestreInscription.formsemestre) - .filter( - FormSemestreInscription.etudid == self.id, - ) - .order_by(desc(FormSemestre.date_debut)) - .all() - ) - - def inscription_courante(self): - """La première inscription à un formsemestre _actuellement_ en cours. - None s'il n'y en a pas (ou plus, ou pas encore). - """ - r = [ - ins - for ins in self.formsemestre_inscriptions - if ins.formsemestre.est_courant() - ] - return r[0] if r else None - - def inscriptions_courantes(self) -> list["FormSemestreInscription"]: - """Liste des inscriptions à des semestres _courants_ - (il est rare qu'il y en ai plus d'une, mais c'est possible). - Triées par date de début de semestre décroissante (le plus récent en premier). - """ - from app.models.formsemestre import FormSemestre, FormSemestreInscription - - return ( - FormSemestreInscription.query.join(FormSemestreInscription.formsemestre) - .filter( - FormSemestreInscription.etudid == self.id, - text("date_debut < now() and date_fin > now()"), - ) - .order_by(desc(FormSemestre.date_debut)) - .all() - ) - - def inscription_courante_date(self, date_debut, date_fin): - """La première inscription à un formsemestre incluant la - période [date_debut, date_fin] - """ - r = [ - ins - for ins in self.formsemestre_inscriptions - if ins.formsemestre.contient_periode(date_debut, date_fin) - ] - return r[0] if r else None - - def inscription_descr(self) -> dict: - """Description de l'état d'inscription""" - inscription_courante = self.inscription_courante() - if inscription_courante: - titre_sem = inscription_courante.formsemestre.titre_mois() - return { - "etat_in_cursem": inscription_courante.etat, - "inscription_courante": inscription_courante, - "inscription": titre_sem, - "inscription_str": "Inscrit en " + titre_sem, - "situation": self.descr_situation_etud(), - } - else: - if self.formsemestre_inscriptions: - # cherche l'inscription la plus récente: - fin_dernier_sem = max( - [ - inscr.formsemestre.date_debut - for inscr in self.formsemestre_inscriptions - ] - ) - if fin_dernier_sem > datetime.date.today(): - inscription = "futur" - situation = "futur élève" - else: - inscription = "ancien" - situation = "ancien élève" - else: - inscription = ("non inscrit",) - situation = inscription - return { - "etat_in_cursem": "?", - "inscription_courante": None, - "inscription": inscription, - "inscription_str": inscription, - "situation": situation, - } - - def inscription_etat(self, formsemestre_id): - """État de l'inscription de cet étudiant au semestre: - False si pas inscrit, ou scu.INSCRIT, DEMISSION, DEF - """ - # voir si ce n'est pas trop lent: - ins = models.FormSemestreInscription.query.filter_by( - etudid=self.id, formsemestre_id=formsemestre_id - ).first() - if ins: - return ins.etat - return False - - def descr_situation_etud(self) -> str: - """Chaîne décrivant la situation _actuelle_ de l'étudiant. - Exemple: - "inscrit en BUT R&T semestre 2 FI (Jan 2022 - Jul 2022) le 16/01/2022" - ou - "non inscrit" - """ - inscriptions_courantes = self.inscriptions_courantes() - if inscriptions_courantes: - inscr = inscriptions_courantes[0] - if inscr.etat == scu.INSCRIT: - situation = f"inscrit{self.e} en {inscr.formsemestre.titre_mois()}" - # Cherche la date d'inscription dans scolar_events: - events = models.ScolarEvent.query.filter_by( - etudid=self.id, - formsemestre_id=inscr.formsemestre.id, - event_type="INSCRIPTION", - ).all() - if not events: - log( - f"*** situation inconsistante pour {self} (inscrit mais pas d'event)" - ) - situation += " (inscription non enregistrée)" # ??? - else: - date_ins = events[0].event_date - situation += date_ins.strftime(" le %d/%m/%Y") - elif inscr.etat == scu.DEF: - situation = f"défaillant en {inscr.formsemestre.titre_mois()}" - event = ( - models.ScolarEvent.query.filter_by( - etudid=self.id, - formsemestre_id=inscr.formsemestre.id, - event_type="DEFAILLANCE", - ) - .order_by(models.ScolarEvent.event_date) - .first() - ) - if not event: - log( - f"*** situation inconsistante pour {self} (def mais pas d'event)" - ) - situation += "???" # ??? - else: - date_def = event.event_date - situation += date_def.strftime(" le %d/%m/%Y") - - else: - situation = f"démission de {inscr.formsemestre.titre_mois()}" - # Cherche la date de demission dans scolar_events: - event = ( - models.ScolarEvent.query.filter_by( - etudid=self.id, - formsemestre_id=inscr.formsemestre.id, - event_type="DEMISSION", - ) - .order_by(models.ScolarEvent.event_date) - .first() - ) - if not event: - log( - f"*** situation inconsistante pour {self} (demission mais pas d'event)" - ) - situation += "???" # ??? - else: - date_dem = event.event_date - situation += date_dem.strftime(" le %d/%m/%Y") - else: - situation = "non inscrit" + self.e - - return situation - - def etat_civil_pv(self, line_sep="\n") -> str: - """Présentation, pour PV jury - M. Pierre Dupont - n° 12345678 - né(e) le 7/06/1974 - à Paris - """ - return f"""{self.nomprenom}{line_sep}n°{self.code_nip or ""}{line_sep}né{self.e} le {self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{line_sep}à {self.lieu_naissance or ""}""" - - def photo_html(self, title=None, size="small") -> str: - """HTML img tag for the photo, either in small size (h90) - or original size (size=="orig") - """ - from app.scodoc import sco_photos - - # sco_photo traite des dicts: - return sco_photos.etud_photo_html( - etud=dict( - etudid=self.id, - code_nip=self.code_nip, - nomprenom=self.nomprenom, - nom_disp=self.nom_disp(), - photo_filename=self.photo_filename, - ), - title=title, - size=size, - ) - - -def make_etud_args( - etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True -) -> dict: - """forme args dict pour requete recherche etudiant - On peut specifier etudid - ou bien (si use_request) cherche dans la requete http: etudid, code_nip, code_ine - (dans cet ordre). - - Résultat: dict avec soit "etudid", soit "code_nip", soit "code_ine" - """ - args = None - if etudid: - try: - args = {"etudid": int(etudid)} - except ValueError as exc: - raise ScoInvalidParamError() from exc - elif code_nip: - args = {"code_nip": code_nip} - elif use_request: # use form from current request (Flask global) - if request.method == "POST": - vals = request.form - elif request.method == "GET": - vals = request.args - else: - vals = {} - try: - if "etudid" in vals: - args = {"etudid": int(vals["etudid"])} - elif "code_nip" in vals: - args = {"code_nip": str(vals["code_nip"])} - elif "code_ine" in vals: - args = {"code_ine": str(vals["code_ine"])} - except ValueError: - args = {} - if not args: - if abort_404: - abort(404, "pas d'étudiant sélectionné") - elif raise_exc: - raise ValueError("make_etud_args: pas d'étudiant sélectionné !") - return args - - -class Adresse(db.Model): - """Adresse d'un étudiant - (le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule) - """ - - __tablename__ = "adresse" - - id = db.Column(db.Integer, primary_key=True) - adresse_id = db.synonym("id") - etudid = db.Column( - db.Integer, - db.ForeignKey("identite.id", ondelete="CASCADE"), - ) - email = db.Column(db.Text()) # mail institutionnel - emailperso = db.Column(db.Text) # email personnel (exterieur) - domicile = db.Column(db.Text) - codepostaldomicile = db.Column(db.Text) - villedomicile = db.Column(db.Text) - paysdomicile = db.Column(db.Text) - telephone = db.Column(db.Text) - telephonemobile = db.Column(db.Text) - fax = db.Column(db.Text) - typeadresse = db.Column( - db.Text, default="domicile", server_default="domicile", nullable=False - ) - description = db.Column(db.Text) - - def to_dict(self, convert_nulls_to_str=False): - """Représentation dictionnaire,""" - e = dict(self.__dict__) - e.pop("_sa_instance_state", None) - if convert_nulls_to_str: - return {k: e[k] or "" for k in e} - return e - - -class Admission(db.Model): - """Informations liées à l'admission d'un étudiant""" - - __tablename__ = "admissions" - - id = db.Column(db.Integer, primary_key=True) - adm_id = db.synonym("id") - etudid = db.Column( - db.Integer, - db.ForeignKey("identite.id", ondelete="CASCADE"), - ) - # Anciens champs de ScoDoc7, à revoir pour être plus générique et souple - # notamment dans le cadre du bac 2021 - # de plus, certaines informations liées à APB ne sont plus disponibles - # avec Parcoursup - annee = db.Column(db.Integer) - bac = db.Column(db.Text) - specialite = db.Column(db.Text) - annee_bac = db.Column(db.Integer) - math = db.Column(db.Text) - physique = db.Column(db.Float) - anglais = db.Column(db.Float) - francais = db.Column(db.Float) - # Rang dans les voeux du candidat (inconnu avec APB et PS) - rang = db.Column(db.Integer) - # Qualité et décision du jury d'admission (ou de l'examinateur) - qualite = db.Column(db.Float) - rapporteur = db.Column(db.Text) - decision = db.Column(db.Text) - score = db.Column(db.Float) - commentaire = db.Column(db.Text) - # Lycée d'origine: - nomlycee = db.Column(db.Text) - villelycee = db.Column(db.Text) - codepostallycee = db.Column(db.Text) - codelycee = db.Column(db.Text) - # 'APB', 'APC-PC', 'CEF', 'Direct', '?' (autre) - type_admission = db.Column(db.Text) - # était boursier dans le cycle precedent (lycee) ? - boursier_prec = db.Column(db.Boolean()) - # classement par le jury d'admission (1 à N), - # global (pas celui d'APB si il y a des groupes) - classement = db.Column(db.Integer) - # code du groupe APB - apb_groupe = db.Column(db.Text) - # classement (1..Ngr) par le jury dans le groupe APB - apb_classement_gr = db.Column(db.Integer) - - def get_bac(self) -> Baccalaureat: - "Le bac. utiliser bac.abbrev() pour avoir une chaine de caractères." - return Baccalaureat(self.bac, specialite=self.specialite) - - def to_dict(self, no_nulls=False): - """Représentation dictionnaire,""" - d = dict(self.__dict__) - d.pop("_sa_instance_state", None) - if no_nulls: - for k in d.keys(): - if d[k] is None: - col_type = getattr( - sqlalchemy.inspect(models.Admission).columns, "apb_groupe" - ).expression.type - if isinstance(col_type, sqlalchemy.Text): - d[k] = "" - elif isinstance(col_type, sqlalchemy.Integer): - d[k] = 0 - elif isinstance(col_type, sqlalchemy.Boolean): - d[k] = False - return d - - -# Suivi scolarité / débouchés -class ItemSuivi(db.Model): - __tablename__ = "itemsuivi" - - id = db.Column(db.Integer, primary_key=True) - itemsuivi_id = db.synonym("id") - etudid = db.Column( - db.Integer, - db.ForeignKey("identite.id", ondelete="CASCADE"), - ) - item_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) - situation = db.Column(db.Text) - - -class ItemSuiviTag(db.Model): - __tablename__ = "itemsuivi_tags" - id = db.Column(db.Integer, primary_key=True) - dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True) - tag_id = db.synonym("id") - title = db.Column(db.Text(), nullable=False, unique=True) - - -# Association tag <-> module -itemsuivi_tags_assoc = db.Table( - "itemsuivi_tags_assoc", - db.Column( - "tag_id", db.Integer, db.ForeignKey("itemsuivi_tags.id", ondelete="CASCADE") - ), - db.Column( - "itemsuivi_id", db.Integer, db.ForeignKey("itemsuivi.id", ondelete="CASCADE") - ), -) - - -class EtudAnnotation(db.Model): - """Annotation sur un étudiant""" - - __tablename__ = "etud_annotations" - - id = db.Column(db.Integer, primary_key=True) - date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) - etudid = db.Column(db.Integer) # sans contrainte (compat ScoDoc 7)) - author = db.Column(db.Text) # le pseudo (user_name), was zope_authenticated_user - comment = db.Column(db.Text) +# -*- coding: UTF-8 -* + +"""Définition d'un étudiant + et données rattachées (adresses, annotations, ...) +""" + +import datetime +from functools import cached_property +from flask import abort, has_request_context, url_for +from flask import g, request +import sqlalchemy +from sqlalchemy import desc, text + +from app import db, log +from app import models + +from app.scodoc import notesdb as ndb +from app.scodoc.sco_bac import Baccalaureat +from app.scodoc.sco_exceptions import ScoInvalidParamError +import app.scodoc.sco_utils as scu + + +class Identite(db.Model): + """étudiant""" + + __tablename__ = "identite" + __table_args__ = ( + db.UniqueConstraint("dept_id", "code_nip"), + db.UniqueConstraint("dept_id", "code_ine"), + ) + + id = db.Column(db.Integer, primary_key=True) + etudid = db.synonym("id") + dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True) + + nom = db.Column(db.Text()) + prenom = db.Column(db.Text()) + nom_usuel = db.Column(db.Text()) + # optionnel (si present, affiché à la place du nom) + civilite = db.Column(db.String(1), nullable=False) + __table_args__ = (db.CheckConstraint("civilite IN ('M', 'F', 'X')"),) + + date_naissance = db.Column(db.Date) + lieu_naissance = db.Column(db.Text()) + dept_naissance = db.Column(db.Text()) + nationalite = db.Column(db.Text()) + statut = db.Column(db.Text()) + boursier = db.Column(db.Boolean()) # True si boursier ('O' en ScoDoc7) + photo_filename = db.Column(db.Text()) + # Codes INE et NIP pas unique car le meme etud peut etre ds plusieurs dept + code_nip = db.Column(db.Text(), index=True) + code_ine = db.Column(db.Text(), index=True) + # Ancien id ScoDoc7 pour les migrations de bases anciennes + # ne pas utiliser après migrate_scodoc7_dept_archives + scodoc7_id = db.Column(db.Text(), nullable=True) + # + adresses = db.relationship("Adresse", lazy="dynamic", backref="etud") + billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic") + # + admission = db.relationship("Admission", backref="identite", lazy="dynamic") + + def __repr__(self): + return ( + f"" + ) + + @classmethod + def from_request(cls, etudid=None, code_nip=None): + """Étudiant à partir de l'etudid ou du code_nip, soit + passés en argument soit retrouvés directement dans la requête web. + Erreur 404 si inexistant. + """ + args = make_etud_args(etudid=etudid, code_nip=code_nip) + return Identite.query.filter_by(**args).first_or_404() + + @property + def civilite_str(self): + """returns 'M.' ou 'Mme' ou '' (pour le genre neutre, + personnes ne souhaitant pas d'affichage). + """ + return {"M": "M.", "F": "Mme", "X": ""}[self.civilite] + + def sex_nom(self, no_accents=False) -> str: + "'M. DUPONTÉ', ou si no_accents, 'M. DUPONTE'" + s = f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()}" + if no_accents: + return scu.suppress_accents(s) + return s + + @property + def e(self): + "terminaison en français: 'ne', '', 'ou '(e)'" + return {"M": "", "F": "e"}.get(self.civilite, "(e)") + + def nom_disp(self) -> str: + "Nom à afficher" + if self.nom_usuel: + return ( + (self.nom_usuel + " (" + self.nom + ")") if self.nom else self.nom_usuel + ) + else: + return self.nom + + @cached_property + def nomprenom(self, reverse=False) -> str: + """Civilité/nom/prenom pour affichages: "M. Pierre Dupont" + Si reverse, "Dupont Pierre", sans civilité. + """ + nom = self.nom_usuel or self.nom + prenom = self.prenom_str + if reverse: + fields = (nom, prenom) + else: + fields = (self.civilite_str, prenom, nom) + return " ".join([x for x in fields if x]) + + @property + def prenom_str(self): + """Prénom à afficher. Par exemple: "Jean-Christophe" """ + if not self.prenom: + return "" + frags = self.prenom.split() + r = [] + for frag in frags: + fields = frag.split("-") + r.append("-".join([x.lower().capitalize() for x in fields])) + return " ".join(r) + + @property + def nom_short(self): + "Nom et début du prénom pour table recap: 'DUPONT Pi.'" + return f"{(self.nom_usuel or self.nom or '?').upper()} {(self.prenom or '')[:2].capitalize()}." + + @cached_property + def sort_key(self) -> tuple: + "clé pour tris par ordre alphabétique" + return ( + scu.sanitize_string( + self.nom_usuel or self.nom or "", remove_spaces=False + ).lower(), + scu.sanitize_string(self.prenom or "", remove_spaces=False).lower(), + ) + + def get_first_email(self, field="email") -> str: + "Le mail associé à la première adrese de l'étudiant, ou None" + return getattr(self.adresses[0], field) if self.adresses.count() > 0 else None + + def to_dict_short(self) -> dict: + """Les champs essentiels""" + return { + "id": self.id, + "civilite": self.civilite, + "code_nip": self.code_nip, + "code_ine": self.code_ine, + "dept_id": self.dept_id, + "nom": self.nom, + "nom_usuel": self.nom_usuel, + "prenom": self.prenom, + "sort_key": self.sort_key, + } + + def to_dict_scodoc7(self) -> dict: + """Représentation dictionnaire, + compatible ScoDoc7 mais sans infos admission + """ + e = dict(self.__dict__) + e.pop("_sa_instance_state", None) + # ScoDoc7 output_formators: (backward compat) + e["etudid"] = self.id + e["date_naissance"] = ndb.DateISOtoDMY(e["date_naissance"]) + e["ne"] = self.e + return {k: e[k] or "" for k in e} # convert_null_outputs_to_empty + + def to_dict_bul(self, include_urls=True): + """Infos exportées dans les bulletins + L'étudiant, et sa première adresse. + """ + from app.scodoc import sco_photos + + d = { + "civilite": self.civilite, + "code_ine": self.code_ine or "", + "code_nip": self.code_nip or "", + "date_naissance": self.date_naissance.strftime("%d/%m/%Y") + if self.date_naissance + else "", + "dept_id": self.dept_id, + "dept_acronym": self.departement.acronym, + "email": self.get_first_email() or "", + "emailperso": self.get_first_email("emailperso"), + "etudid": self.id, + "nom": self.nom_disp(), + "prenom": self.prenom or "", + "nomprenom": self.nomprenom or "", + "lieu_naissance": self.lieu_naissance or "", + "dept_naissance": self.dept_naissance or "", + "nationalite": self.nationalite or "", + "boursier": self.boursier or "", + } + if include_urls and has_request_context(): + # test request context so we can use this func in tests under the flask shell + d["fiche_url"] = url_for( + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id + ) + d["photo_url"] = sco_photos.get_etud_photo_url(self.id) + adresse = self.adresses.first() + if adresse: + d.update(adresse.to_dict(convert_nulls_to_str=True)) + d["id"] = self.id # a été écrasé par l'id de adresse + return d + + def to_dict_api(self) -> dict: + """Représentation dictionnaire pour export API, avec adresses et admission.""" + e = dict(self.__dict__) + e.pop("_sa_instance_state", None) + admission = self.admission.first() + e["admission"] = admission.to_dict() if admission is not None else None + e["adresses"] = [adr.to_dict() for adr in self.adresses] + e["dept_acronym"] = self.departement.acronym + e.pop("departement", None) + e["sort_key"] = self.sort_key + return e + + def inscriptions(self) -> list["FormSemestreInscription"]: + "Liste des inscriptions à des formsemestres, triée, la plus récente en tête" + from app.models.formsemestre import FormSemestre, FormSemestreInscription + + return ( + FormSemestreInscription.query.join(FormSemestreInscription.formsemestre) + .filter( + FormSemestreInscription.etudid == self.id, + ) + .order_by(desc(FormSemestre.date_debut)) + .all() + ) + + def inscription_courante(self): + """La première inscription à un formsemestre _actuellement_ en cours. + None s'il n'y en a pas (ou plus, ou pas encore). + """ + r = [ + ins + for ins in self.formsemestre_inscriptions + if ins.formsemestre.est_courant() + ] + return r[0] if r else None + + def inscriptions_courantes(self) -> list["FormSemestreInscription"]: + """Liste des inscriptions à des semestres _courants_ + (il est rare qu'il y en ai plus d'une, mais c'est possible). + Triées par date de début de semestre décroissante (le plus récent en premier). + """ + from app.models.formsemestre import FormSemestre, FormSemestreInscription + + return ( + FormSemestreInscription.query.join(FormSemestreInscription.formsemestre) + .filter( + FormSemestreInscription.etudid == self.id, + text("date_debut < now() and date_fin > now()"), + ) + .order_by(desc(FormSemestre.date_debut)) + .all() + ) + + def inscription_courante_date(self, date_debut, date_fin): + """La première inscription à un formsemestre incluant la + période [date_debut, date_fin] + """ + r = [ + ins + for ins in self.formsemestre_inscriptions + if ins.formsemestre.contient_periode(date_debut, date_fin) + ] + return r[0] if r else None + + def inscription_descr(self) -> dict: + """Description de l'état d'inscription""" + inscription_courante = self.inscription_courante() + if inscription_courante: + titre_sem = inscription_courante.formsemestre.titre_mois() + return { + "etat_in_cursem": inscription_courante.etat, + "inscription_courante": inscription_courante, + "inscription": titre_sem, + "inscription_str": "Inscrit en " + titre_sem, + "situation": self.descr_situation_etud(), + } + else: + if self.formsemestre_inscriptions: + # cherche l'inscription la plus récente: + fin_dernier_sem = max( + [ + inscr.formsemestre.date_debut + for inscr in self.formsemestre_inscriptions + ] + ) + if fin_dernier_sem > datetime.date.today(): + inscription = "futur" + situation = "futur élève" + else: + inscription = "ancien" + situation = "ancien élève" + else: + inscription = ("non inscrit",) + situation = inscription + return { + "etat_in_cursem": "?", + "inscription_courante": None, + "inscription": inscription, + "inscription_str": inscription, + "situation": situation, + } + + def inscription_etat(self, formsemestre_id: int) -> str: + """État de l'inscription de cet étudiant au semestre: + False si pas inscrit, ou scu.INSCRIT, DEMISSION, DEF + """ + # voir si ce n'est pas trop lent: + ins = models.FormSemestreInscription.query.filter_by( + etudid=self.id, formsemestre_id=formsemestre_id + ).first() + if ins: + return ins.etat + return False + + def descr_situation_etud(self) -> str: + """Chaîne décrivant la situation _actuelle_ de l'étudiant. + Exemple: + "inscrit en BUT R&T semestre 2 FI (Jan 2022 - Jul 2022) le 16/01/2022" + ou + "non inscrit" + """ + inscriptions_courantes = self.inscriptions_courantes() + if inscriptions_courantes: + inscr = inscriptions_courantes[0] + if inscr.etat == scu.INSCRIT: + situation = f"inscrit{self.e} en {inscr.formsemestre.titre_mois()}" + # Cherche la date d'inscription dans scolar_events: + events = models.ScolarEvent.query.filter_by( + etudid=self.id, + formsemestre_id=inscr.formsemestre.id, + event_type="INSCRIPTION", + ).all() + if not events: + log( + f"*** situation inconsistante pour {self} (inscrit mais pas d'event)" + ) + situation += " (inscription non enregistrée)" # ??? + else: + date_ins = events[0].event_date + situation += date_ins.strftime(" le %d/%m/%Y") + elif inscr.etat == scu.DEF: + situation = f"défaillant en {inscr.formsemestre.titre_mois()}" + event = ( + models.ScolarEvent.query.filter_by( + etudid=self.id, + formsemestre_id=inscr.formsemestre.id, + event_type="DEFAILLANCE", + ) + .order_by(models.ScolarEvent.event_date) + .first() + ) + if not event: + log( + f"*** situation inconsistante pour {self} (def mais pas d'event)" + ) + situation += "???" # ??? + else: + date_def = event.event_date + situation += date_def.strftime(" le %d/%m/%Y") + + else: + situation = f"démission de {inscr.formsemestre.titre_mois()}" + # Cherche la date de demission dans scolar_events: + event = ( + models.ScolarEvent.query.filter_by( + etudid=self.id, + formsemestre_id=inscr.formsemestre.id, + event_type="DEMISSION", + ) + .order_by(models.ScolarEvent.event_date) + .first() + ) + if not event: + log( + f"*** situation inconsistante pour {self} (demission mais pas d'event)" + ) + situation += "???" # ??? + else: + date_dem = event.event_date + situation += date_dem.strftime(" le %d/%m/%Y") + else: + situation = "non inscrit" + self.e + + return situation + + def etat_civil_pv(self, line_sep="\n") -> str: + """Présentation, pour PV jury + M. Pierre Dupont + n° 12345678 + né(e) le 7/06/1974 + à Paris + """ + return f"""{self.nomprenom}{line_sep}n°{self.code_nip or ""}{line_sep}né{self.e} le {self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{line_sep}à {self.lieu_naissance or ""}""" + + def photo_html(self, title=None, size="small") -> str: + """HTML img tag for the photo, either in small size (h90) + or original size (size=="orig") + """ + from app.scodoc import sco_photos + + # sco_photo traite des dicts: + return sco_photos.etud_photo_html( + etud=dict( + etudid=self.id, + code_nip=self.code_nip, + nomprenom=self.nomprenom, + nom_disp=self.nom_disp(), + photo_filename=self.photo_filename, + ), + title=title, + size=size, + ) + + +def make_etud_args( + etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True +) -> dict: + """forme args dict pour requete recherche etudiant + On peut specifier etudid + ou bien (si use_request) cherche dans la requete http: etudid, code_nip, code_ine + (dans cet ordre). + + Résultat: dict avec soit "etudid", soit "code_nip", soit "code_ine" + """ + args = None + if etudid: + try: + args = {"etudid": int(etudid)} + except ValueError as exc: + raise ScoInvalidParamError() from exc + elif code_nip: + args = {"code_nip": code_nip} + elif use_request: # use form from current request (Flask global) + if request.method == "POST": + vals = request.form + elif request.method == "GET": + vals = request.args + else: + vals = {} + try: + if "etudid" in vals: + args = {"etudid": int(vals["etudid"])} + elif "code_nip" in vals: + args = {"code_nip": str(vals["code_nip"])} + elif "code_ine" in vals: + args = {"code_ine": str(vals["code_ine"])} + except ValueError: + args = {} + if not args: + if abort_404: + abort(404, "pas d'étudiant sélectionné") + elif raise_exc: + raise ValueError("make_etud_args: pas d'étudiant sélectionné !") + return args + + +class Adresse(db.Model): + """Adresse d'un étudiant + (le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule) + """ + + __tablename__ = "adresse" + + id = db.Column(db.Integer, primary_key=True) + adresse_id = db.synonym("id") + etudid = db.Column( + db.Integer, + db.ForeignKey("identite.id", ondelete="CASCADE"), + ) + email = db.Column(db.Text()) # mail institutionnel + emailperso = db.Column(db.Text) # email personnel (exterieur) + domicile = db.Column(db.Text) + codepostaldomicile = db.Column(db.Text) + villedomicile = db.Column(db.Text) + paysdomicile = db.Column(db.Text) + telephone = db.Column(db.Text) + telephonemobile = db.Column(db.Text) + fax = db.Column(db.Text) + typeadresse = db.Column( + db.Text, default="domicile", server_default="domicile", nullable=False + ) + description = db.Column(db.Text) + + def to_dict(self, convert_nulls_to_str=False): + """Représentation dictionnaire,""" + e = dict(self.__dict__) + e.pop("_sa_instance_state", None) + if convert_nulls_to_str: + return {k: e[k] or "" for k in e} + return e + + +class Admission(db.Model): + """Informations liées à l'admission d'un étudiant""" + + __tablename__ = "admissions" + + id = db.Column(db.Integer, primary_key=True) + adm_id = db.synonym("id") + etudid = db.Column( + db.Integer, + db.ForeignKey("identite.id", ondelete="CASCADE"), + ) + # Anciens champs de ScoDoc7, à revoir pour être plus générique et souple + # notamment dans le cadre du bac 2021 + # de plus, certaines informations liées à APB ne sont plus disponibles + # avec Parcoursup + annee = db.Column(db.Integer) + bac = db.Column(db.Text) + specialite = db.Column(db.Text) + annee_bac = db.Column(db.Integer) + math = db.Column(db.Text) + physique = db.Column(db.Float) + anglais = db.Column(db.Float) + francais = db.Column(db.Float) + # Rang dans les voeux du candidat (inconnu avec APB et PS) + rang = db.Column(db.Integer) + # Qualité et décision du jury d'admission (ou de l'examinateur) + qualite = db.Column(db.Float) + rapporteur = db.Column(db.Text) + decision = db.Column(db.Text) + score = db.Column(db.Float) + commentaire = db.Column(db.Text) + # Lycée d'origine: + nomlycee = db.Column(db.Text) + villelycee = db.Column(db.Text) + codepostallycee = db.Column(db.Text) + codelycee = db.Column(db.Text) + # 'APB', 'APC-PC', 'CEF', 'Direct', '?' (autre) + type_admission = db.Column(db.Text) + # était boursier dans le cycle precedent (lycee) ? + boursier_prec = db.Column(db.Boolean()) + # classement par le jury d'admission (1 à N), + # global (pas celui d'APB si il y a des groupes) + classement = db.Column(db.Integer) + # code du groupe APB + apb_groupe = db.Column(db.Text) + # classement (1..Ngr) par le jury dans le groupe APB + apb_classement_gr = db.Column(db.Integer) + + def get_bac(self) -> Baccalaureat: + "Le bac. utiliser bac.abbrev() pour avoir une chaine de caractères." + return Baccalaureat(self.bac, specialite=self.specialite) + + def to_dict(self, no_nulls=False): + """Représentation dictionnaire,""" + d = dict(self.__dict__) + d.pop("_sa_instance_state", None) + if no_nulls: + for k in d.keys(): + if d[k] is None: + col_type = getattr( + sqlalchemy.inspect(models.Admission).columns, "apb_groupe" + ).expression.type + if isinstance(col_type, sqlalchemy.Text): + d[k] = "" + elif isinstance(col_type, sqlalchemy.Integer): + d[k] = 0 + elif isinstance(col_type, sqlalchemy.Boolean): + d[k] = False + return d + + +# Suivi scolarité / débouchés +class ItemSuivi(db.Model): + __tablename__ = "itemsuivi" + + id = db.Column(db.Integer, primary_key=True) + itemsuivi_id = db.synonym("id") + etudid = db.Column( + db.Integer, + db.ForeignKey("identite.id", ondelete="CASCADE"), + ) + item_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) + situation = db.Column(db.Text) + + +class ItemSuiviTag(db.Model): + __tablename__ = "itemsuivi_tags" + id = db.Column(db.Integer, primary_key=True) + dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True) + tag_id = db.synonym("id") + title = db.Column(db.Text(), nullable=False, unique=True) + + +# Association tag <-> module +itemsuivi_tags_assoc = db.Table( + "itemsuivi_tags_assoc", + db.Column( + "tag_id", db.Integer, db.ForeignKey("itemsuivi_tags.id", ondelete="CASCADE") + ), + db.Column( + "itemsuivi_id", db.Integer, db.ForeignKey("itemsuivi.id", ondelete="CASCADE") + ), +) + + +class EtudAnnotation(db.Model): + """Annotation sur un étudiant""" + + __tablename__ = "etud_annotations" + + id = db.Column(db.Integer, primary_key=True) + date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) + etudid = db.Column(db.Integer) # sans contrainte (compat ScoDoc 7)) + author = db.Column(db.Text) # le pseudo (user_name), was zope_authenticated_user + comment = db.Column(db.Text) diff --git a/app/scodoc/sco_groups.py b/app/scodoc/sco_groups.py index e100b519..ffee6a7e 100644 --- a/app/scodoc/sco_groups.py +++ b/app/scodoc/sco_groups.py @@ -1,1773 +1,1773 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@gmail.com -# -############################################################################## - -"""Gestion des groupes, nouvelle mouture (juin/nov 2009) - -TODO: -Optimisation possible: - revoir do_evaluation_listeetuds_groups() pour extraire aussi les groupes (de chaque etudiant) - et éviter ainsi l'appel ulterieur à get_etud_groups() dans _make_table_notes - -""" -import collections -import operator -import time - -from xml.etree import ElementTree -from xml.etree.ElementTree import Element - -import flask -from flask import g, request -from flask import url_for, make_response -from sqlalchemy.sql import text - -from app import db -from app.comp import res_sem -from app.comp.res_compat import NotesTableCompat -from app.models import FormSemestre, Identite -from app.models import GROUPNAME_STR_LEN, SHORT_STR_LEN -from app.models.groups import GroupDescr, Partition -import app.scodoc.sco_utils as scu -import app.scodoc.notesdb as ndb -from app import log, cache -from app.scodoc.scolog import logdb -from app.scodoc import html_sco_header -from app.scodoc import sco_cache -from app.scodoc import sco_codes_parcours -from app.scodoc import sco_cursus -from app.scodoc import sco_etud -from app.scodoc.sco_etud import etud_sort_key -from app.scodoc import sco_permissions_check -from app.scodoc import sco_xml -from app.scodoc.sco_exceptions import ScoException, AccessDenied, ScoValueError -from app.scodoc.TrivialFormulator import TrivialFormulator - - -partitionEditor = ndb.EditableTable( - "partition", - "partition_id", - ( - "partition_id", - "formsemestre_id", - "partition_name", - "compute_ranks", - "numero", - "bul_show_rank", - "show_in_lists", - "editable", - ), - input_formators={ - "bul_show_rank": bool, - "show_in_lists": bool, - "editable": bool, - }, -) - -groupEditor = ndb.EditableTable( - "group_descr", "group_id", ("group_id", "partition_id", "group_name", "numero") -) - -group_list = groupEditor.list - - -def get_group(group_id: int) -> dict: - """Returns group object, with partition""" - r = ndb.SimpleDictFetch( - """SELECT gd.id AS group_id, gd.*, p.id AS partition_id, p.* - FROM group_descr gd, partition p - WHERE gd.id=%(group_id)s - AND p.id = gd.partition_id - """, - {"group_id": group_id}, - ) - if not r: - raise ScoValueError(f"Groupe inexistant ! (id {group_id})") - return r[0] - - -def group_delete(group_id: int): - """Delete a group.""" - # if not group['group_name'] and not force: - # raise ValueError('cannot suppress this group') - # remove memberships: - ndb.SimpleQuery( - "DELETE FROM group_membership WHERE group_id=%(group_id)s", - {"group_id": group_id}, - ) - # delete group: - ndb.SimpleQuery( - "DELETE FROM group_descr WHERE id=%(group_id)s", {"group_id": group_id} - ) - - -def get_partition(partition_id): - r = ndb.SimpleDictFetch( - """SELECT p.id AS partition_id, p.* - FROM partition p - WHERE p.id = %(partition_id)s - """, - {"partition_id": partition_id}, - ) - if not r: - raise ScoValueError(f"Partition inconnue (déjà supprimée ?) ({partition_id})") - return r[0] - - -def get_partitions_list(formsemestre_id, with_default=True) -> list[dict]: - """Liste des partitions pour ce semestre (list of dicts), - triées par numéro, avec la partition par défaut en fin de liste. - """ - partitions = ndb.SimpleDictFetch( - """SELECT p.id AS partition_id, p.* - FROM partition p - WHERE formsemestre_id=%(formsemestre_id)s - ORDER BY numero""", - {"formsemestre_id": formsemestre_id}, - ) - # Move 'all' at end of list (for menus) - R = [p for p in partitions if p["partition_name"] is not None] - if with_default: - R += [p for p in partitions if p["partition_name"] is None] - return R - - -def get_default_partition(formsemestre_id): - """Get partition for 'all' students (this one always exists, with NULL name)""" - r = ndb.SimpleDictFetch( - """SELECT p.id AS partition_id, p.* FROM partition p - WHERE formsemestre_id=%(formsemestre_id)s - AND partition_name is NULL - """, - {"formsemestre_id": formsemestre_id}, - ) - if len(r) != 1: - raise ScoException( - "inconsistent partition: %d with NULL name for formsemestre_id=%s" - % (len(r), formsemestre_id) - ) - return r[0] - - -def get_formsemestre_groups(formsemestre_id, with_default=False): - """Returns ( partitions, { partition_id : { etudid : group } } ).""" - partitions = get_partitions_list(formsemestre_id, with_default=with_default) - partitions_etud_groups = {} # { partition_id : { etudid : group } } - for partition in partitions: - pid = partition["partition_id"] - partitions_etud_groups[pid] = get_etud_groups_in_partition(pid) - return partitions, partitions_etud_groups - - -def get_formsemestre_etuds_groups(formsemestre_id: int) -> dict: - """{ etudid : { partition_id : group_id } }""" - infos = ndb.SimpleDictFetch( - """SELECT etudid, p.id AS partition_id, gd.id AS group_id - FROM group_descr gd, group_membership gm, partition p - WHERE gd.partition_id = p.id - AND gm.group_id = gd.id - AND p.formsemestre_id = %(formsemestre_id)s - """, - {"formsemestre_id": formsemestre_id}, - ) - # -> {'etudid': 16483, 'group_id': 5317, 'partition_id': 2264}, - d = collections.defaultdict(lambda: {}) - for i in infos: - d[i["etudid"]][i["partition_id"]] = i["group_id"] - return d - - -def get_partition_groups(partition): - """List of groups in this partition (list of dicts). - Some groups may be empty.""" - return ndb.SimpleDictFetch( - """SELECT gd.id AS group_id, p.id AS partition_id, gd.*, p.* - FROM group_descr gd, partition p - WHERE gd.partition_id=%(partition_id)s - AND gd.partition_id=p.id - ORDER BY gd.numero - """, - partition, - ) - - -def get_default_group(formsemestre_id, fix_if_missing=False): - """Returns group_id for default ('tous') group""" - r = ndb.SimpleDictFetch( - """SELECT gd.id AS group_id - FROM group_descr gd, partition p - WHERE p.formsemestre_id=%(formsemestre_id)s - AND p.partition_name is NULL - AND p.id = gd.partition_id - """, - {"formsemestre_id": formsemestre_id}, - ) - if len(r) == 0 and fix_if_missing: - # No default group (problem during sem creation) - # Try to create it - log( - "*** Warning: get_default_group(formsemestre_id=%s): default group missing, recreating it" - % formsemestre_id - ) - try: - partition_id = get_default_partition(formsemestre_id)["partition_id"] - except ScoException: - log("creating default partition for %s" % formsemestre_id) - partition_id = partition_create( - formsemestre_id, default=True, redirect=False - ) - group = create_group(partition_id, default=True) - return group.id - # debug check - if len(r) != 1: - raise ScoException("invalid group structure for %s" % formsemestre_id) - group_id = r[0]["group_id"] - return group_id - - -def get_sem_groups(formsemestre_id): - """Returns groups for this sem (in all partitions).""" - return ndb.SimpleDictFetch( - """SELECT gd.id AS group_id, p.id AS partition_id, gd.*, p.* - FROM group_descr gd, partition p - WHERE p.formsemestre_id=%(formsemestre_id)s - AND p.id = gd.partition_id - """, - {"formsemestre_id": formsemestre_id}, - ) - - -def get_group_members(group_id, etat=None): - """Liste des etudiants d'un groupe. - Si etat, filtre selon l'état de l'inscription - Trié par nom_usuel (ou nom) puis prénom - """ - req = """SELECT i.id as etudid, i.*, a.*, gm.*, ins.etat - FROM identite i, adresse a, group_membership gm, - group_descr gd, partition p, notes_formsemestre_inscription ins - WHERE i.id = gm.etudid - and a.etudid = i.id - and ins.etudid = i.id - and ins.formsemestre_id = p.formsemestre_id - and p.id = gd.partition_id - and gd.id = gm.group_id - and gm.group_id=%(group_id)s - """ - if etat is not None: - req += " and ins.etat = %(etat)s" - - r = ndb.SimpleDictFetch(req, {"group_id": group_id, "etat": etat}) - - for etud in r: - sco_etud.format_etud_ident(etud) - - # tri selon nom_usuel ou nom, sans accents - r.sort(key=etud_sort_key) - - if scu.CONFIG.ALLOW_NULL_PRENOM: - for x in r: - x["prenom"] = x["prenom"] or "" - - return r - - -def get_group_infos(group_id, etat=None): # was _getlisteetud - """legacy code: used by group_list and trombino""" - from app.scodoc import sco_formsemestre - - cnx = ndb.GetDBConnexion() - group = get_group(group_id) - sem = sco_formsemestre.get_formsemestre( - group["formsemestre_id"], raise_soft_exc=True - ) - - members = get_group_members(group_id, etat=etat) - # add human readable description of state: - nbdem = 0 - for t in members: - if t["etat"] == "I": - t["etath"] = "" # etudiant inscrit, ne l'indique pas dans la liste HTML - elif t["etat"] == "D": - events = sco_etud.scolar_events_list( - cnx, - args={ - "etudid": t["etudid"], - "formsemestre_id": group["formsemestre_id"], - }, - ) - for event in events: - event_type = event["event_type"] - if event_type == "DEMISSION": - t["date_dem"] = event["event_date"] - break - if "date_dem" in t: - t["etath"] = "démission le %s" % t["date_dem"] - else: - t["etath"] = "(dem.)" - nbdem += 1 - elif t["etat"] == sco_codes_parcours.DEF: - t["etath"] = "Défaillant" - else: - t["etath"] = t["etat"] - # Add membership for all partitions, 'partition_id' : group - for etud in members: # long: comment eviter ces boucles ? - etud_add_group_infos(etud, sem["formsemestre_id"]) - - if group["group_name"] != None: - group_tit = "%s %s" % (group["partition_name"], group["group_name"]) - else: - group_tit = "tous" - - return members, group, group_tit, sem, nbdem - - -def get_group_other_partitions(group): - """Liste des partitions du même semestre que ce groupe, - sans celle qui contient ce groupe. - """ - other_partitions = [ - p - for p in get_partitions_list(group["formsemestre_id"]) - if p["partition_id"] != group["partition_id"] and p["partition_name"] - ] - return other_partitions - - -def get_etud_groups(etudid: int, formsemestre_id: int, exclude_default=False): - """Infos sur groupes de l'etudiant dans ce semestre - [ group + partition_name ] - """ - req = """SELECT p.id AS partition_id, p.*, - g.id AS group_id, g.numero as group_numero, g.group_name - FROM group_descr g, partition p, group_membership gm - WHERE gm.etudid=%(etudid)s - and gm.group_id = g.id - and g.partition_id = p.id - and p.formsemestre_id = %(formsemestre_id)s - """ - if exclude_default: - req += " and p.partition_name is not NULL" - groups = ndb.SimpleDictFetch( - req + " ORDER BY p.numero", - {"etudid": etudid, "formsemestre_id": formsemestre_id}, - ) - return _sortgroups(groups) - - -def get_etud_main_group(etudid: int, formsemestre_id: int): - """Return main group (the first one) for etud, or default one if no groups""" - groups = get_etud_groups(etudid, formsemestre_id, exclude_default=True) - if groups: - return groups[0] - else: - return get_group(get_default_group(formsemestre_id)) - - -def formsemestre_get_main_partition(formsemestre_id): - """Return main partition (the first one) for sem, or default one if no groups - (rappel: default == tous, main == principale (groupes TD habituellement) - """ - return get_partitions_list(formsemestre_id, with_default=True)[0] - - -def formsemestre_get_etud_groupnames(formsemestre_id, attr="group_name"): - """Recupere les groupes de tous les etudiants d'un semestre - { etudid : { partition_id : group_name }} (attr=group_name or group_id) - """ - infos = ndb.SimpleDictFetch( - """SELECT - i.etudid AS etudid, - p.id AS partition_id, - gd.group_name, - gd.id AS group_id - FROM - notes_formsemestre_inscription i, - partition p, - group_descr gd, - group_membership gm - WHERE - i.formsemestre_id=%(formsemestre_id)s - and i.formsemestre_id = p.formsemestre_id - and p.id = gd.partition_id - and gm.etudid = i.etudid - and gm.group_id = gd.id - and p.partition_name is not NULL - """, - {"formsemestre_id": formsemestre_id}, - ) - R = {} - for info in infos: - if info["etudid"] in R: - R[info["etudid"]][info["partition_id"]] = info[attr] - else: - R[info["etudid"]] = {info["partition_id"]: info[attr]} - return R - - -def get_etud_formsemestre_groups( - etud: Identite, formsemestre: FormSemestre, only_to_show=True -) -> list[GroupDescr]: - """Liste les groupes auxquels est inscrit. - Si only_to_show (défaut vrai), ne donne que les groupes "visiables", - c'est à dire des partitions avec show_in_lists True. - """ - # Note: je n'ai pas réussi à construire une requete SQLAlechemy avec - # la Table d'association group_membership - cursor = db.session.execute( - text( - """ - SELECT g.id - FROM group_descr g, group_membership gm, partition p - WHERE gm.etudid = :etudid - AND gm.group_id = g.id - AND g.partition_id = p.id - AND p.formsemestre_id = :formsemestre_id - AND p.partition_name is not NULL - """ - + (" and (p.show_in_lists is True) " if only_to_show else "") - + """ - ORDER BY p.numero - """ - ), - {"etudid": etud.id, "formsemestre_id": formsemestre.id}, - ) - return [GroupDescr.query.get(group_id) for group_id in cursor] - - -# Ancienne fonction: -def etud_add_group_infos(etud, formsemestre_id, sep=" ", only_to_show=False): - """Add informations on partitions and group memberships to etud - (a dict with an etudid) - If only_to_show, restrict to partions such that show_in_lists is True. - - etud['partitions'] = { partition_id : group + partition_name } - etud['groupes'] = "TDB, Gr2, TPB1" - etud['partitionsgroupes'] = "Groupes TD:TDB, Groupes TP:Gr2 (...)" - """ - etud[ - "partitions" - ] = collections.OrderedDict() # partition_id : group + partition_name - if not formsemestre_id: - etud["groupes"] = "" - return etud - - infos = ndb.SimpleDictFetch( - """SELECT p.partition_name, p.show_in_lists, g.*, g.id AS group_id - FROM group_descr g, partition p, group_membership gm WHERE gm.etudid=%(etudid)s - and gm.group_id = g.id - and g.partition_id = p.id - and p.formsemestre_id = %(formsemestre_id)s - """ - + (" and (p.show_in_lists is True) " if only_to_show else "") - + """ - ORDER BY p.numero - """, - {"etudid": etud["etudid"], "formsemestre_id": formsemestre_id}, - ) - - for info in infos: - if info["partition_name"]: - etud["partitions"][info["partition_id"]] = info - - # resume textuel des groupes: - etud["groupes"] = sep.join( - [gr["group_name"] for gr in infos if gr["group_name"] is not None] - ) - etud["partitionsgroupes"] = sep.join( - [ - (gr["partition_name"] or "") + ":" + gr["group_name"] - for gr in infos - if gr["group_name"] is not None - ] - ) - - return etud - - -@cache.memoize(timeout=50) # seconds -def get_etud_groups_in_partition(partition_id): - """Returns { etudid : group }, with all students in this partition""" - infos = ndb.SimpleDictFetch( - """SELECT gd.id AS group_id, gd.*, etudid - FROM group_descr gd, group_membership gm - WHERE gd.partition_id = %(partition_id)s - AND gm.group_id = gd.id - """, - {"partition_id": partition_id}, - ) - R = {} - for i in infos: - R[i["etudid"]] = i - return R - - -def formsemestre_partition_list(formsemestre_id, format="xml"): - """Get partitions and groups in this semestre - Supported formats: xml, json - """ - partitions = get_partitions_list(formsemestre_id, with_default=True) - # Ajoute les groupes - for p in partitions: - p["group"] = get_partition_groups(p) - return scu.sendResult(partitions, name="partition", format=format) - - -# Encore utilisé par groupmgr.js -def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD - """ - Deprecated: use group_list - Liste des étudiants dans chaque groupe de cette partition. - - - - - ... - """ - t0 = time.time() - partition = get_partition(partition_id) - formsemestre_id = partition["formsemestre_id"] - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - etuds_set = {ins.etudid for ins in formsemestre.inscriptions} - - groups = get_partition_groups(partition) - # Build XML: - t1 = time.time() - doc = Element("ajax-response") - x_response = Element("response", type="object", id="MyUpdater") - doc.append(x_response) - for group in groups: - x_group = Element( - "group", - partition_id=str(partition_id), - partition_name=partition["partition_name"], - groups_editable=str(int(partition["groups_editable"])), - group_id=str(group["group_id"]), - group_name=group["group_name"], - ) - x_response.append(x_group) - for e in get_group_members(group["group_id"]): - etud = sco_etud.get_etud_info(etudid=e["etudid"], filled=True)[0] - x_group.append( - Element( - "etud", - etudid=str(e["etudid"]), - civilite=etud["civilite_str"], - sexe=etud["civilite_str"], # compat - nom=sco_etud.format_nom(etud["nom"]), - prenom=sco_etud.format_prenom(etud["prenom"]), - origin=_comp_etud_origin(etud, formsemestre), - ) - ) - if e["etudid"] in etuds_set: - etuds_set.remove(e["etudid"]) # etudiant vu dans un groupe - - # Ajoute les etudiants inscrits au semestre mais dans aucun groupe de cette partition: - if etuds_set: - x_group = Element( - "group", - partition_id=str(partition_id), - partition_name=partition["partition_name"], - groups_editable=str(int(partition["groups_editable"])), - group_id="_none_", - group_name="", - ) - doc.append(x_group) - for etudid in etuds_set: - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - x_group.append( - Element( - "etud", - etudid=str(etud["etudid"]), - sexe=etud["civilite_str"], - nom=sco_etud.format_nom(etud["nom"]), - prenom=sco_etud.format_prenom(etud["prenom"]), - origin=_comp_etud_origin(etud, formsemestre), - ) - ) - t2 = time.time() - log(f"XMLgetGroupsInPartition: {t2-t0} seconds ({t1-t0}+{t2-t1})") - # XML response: - data = sco_xml.XML_HEADER + ElementTree.tostring(doc).decode(scu.SCO_ENCODING) - response = make_response(data) - response.headers["Content-Type"] = scu.XML_MIMETYPE - return response - - -def _comp_etud_origin(etud: dict, cur_formsemestre: FormSemestre): - """breve description de l'origine de l'étudiant (sem. precedent) - (n'indique l'origine que si ce n'est pas le semestre precedent normal) - """ - # cherche le semestre suivant le sem. courant dans la liste - cur_sem_idx = None - for i in range(len(etud["sems"])): - if etud["sems"][i]["formsemestre_id"] == cur_formsemestre.id: - cur_sem_idx = i - break - - if cur_sem_idx is None or (cur_sem_idx + 1) >= (len(etud["sems"]) - 1): - return "" # on pourrait indiquer le bac mais en general on ne l'a pas en debut d'annee - - prev_sem = etud["sems"][cur_sem_idx + 1] - if prev_sem["semestre_id"] != (cur_formsemestre.semestre_id - 1): - return f" (S{prev_sem['semestre_id']})" - else: - return "" # parcours normal, ne le signale pas - - -def set_group(etudid: int, group_id: int) -> bool: - """Inscrit l'étudiant au groupe. - Return True if ok, False si deja inscrit. - Warning: - - don't check if group_id exists (the caller should check). - - don't check if group's partition is editable - """ - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - args = {"etudid": etudid, "group_id": group_id} - # déjà inscrit ? - r = ndb.SimpleDictFetch( - "SELECT * FROM group_membership gm WHERE etudid=%(etudid)s and group_id=%(group_id)s", - args, - cursor=cursor, - ) - if len(r): - return False - # inscrit - ndb.SimpleQuery( - "INSERT INTO group_membership (etudid, group_id) VALUES (%(etudid)s, %(group_id)s)", - args, - cursor=cursor, - ) - return True - - -def change_etud_group_in_partition(etudid, group_id, partition=None): - """Inscrit etud au groupe de cette partition, et le desinscrit d'autres groupes de cette partition.""" - log("change_etud_group_in_partition: etudid=%s group_id=%s" % (etudid, group_id)) - - # 0- La partition - group = get_group(group_id) - if partition: - # verifie que le groupe est bien dans cette partition: - if group["partition_id"] != partition["partition_id"]: - raise ValueError( - "inconsistent group/partition (group_id=%s, partition_id=%s)" - % (group_id, partition["partition_id"]) - ) - else: - partition = get_partition(group["partition_id"]) - # 1- Supprime membership dans cette partition - ndb.SimpleQuery( - """DELETE FROM group_membership gm - WHERE EXISTS - (SELECT 1 FROM group_descr gd - WHERE gm.etudid = %(etudid)s - AND gm.group_id = gd.id - AND gd.partition_id = %(partition_id)s) - """, - {"etudid": etudid, "partition_id": partition["partition_id"]}, - ) - # 2- associe au nouveau groupe - set_group(etudid, group_id) - - # 3- log - formsemestre_id = partition["formsemestre_id"] - cnx = ndb.GetDBConnexion() - logdb( - cnx, - method="changeGroup", - etudid=etudid, - msg="formsemestre_id=%s,partition_name=%s, group_name=%s" - % (formsemestre_id, partition["partition_name"], group["group_name"]), - ) - cnx.commit() - - # 5- Update parcours - formsemestre = FormSemestre.query.get(formsemestre_id) - formsemestre.update_inscriptions_parcours_from_groups() - - # 6- invalidate cache - sco_cache.invalidate_formsemestre( - formsemestre_id=formsemestre_id - ) # > change etud group - - -def setGroups( - partition_id, - groupsLists="", # members of each existing group - groupsToCreate="", # name and members of new groups - groupsToDelete="", # groups to delete -): - """Affect groups (Ajax POST request): renvoie du XML - groupsLists: lignes de la forme "group_id;etudid;...\n" - groupsToCreate: lignes "group_name;etudid;...\n" - groupsToDelete: group_id;group_id;... - - Ne peux pas modifier les groupes des partitions non éditables. - """ - from app.scodoc import sco_formsemestre - - def xml_error(msg, code=404): - data = ( - f'Error: {msg}' - ) - response = make_response(data, code) - response.headers["Content-Type"] = scu.XML_MIMETYPE - return response - - partition = get_partition(partition_id) - if not partition["groups_editable"] and (groupsToCreate or groupsToDelete): - msg = "setGroups: partition non editable" - log(msg) - return xml_error(msg, code=403) - formsemestre_id = partition["formsemestre_id"] - if not sco_permissions_check.can_change_groups(formsemestre_id): - raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") - log("***setGroups: partition_id=%s" % partition_id) - log("groupsLists=%s" % groupsLists) - log("groupsToCreate=%s" % groupsToCreate) - log("groupsToDelete=%s" % groupsToDelete) - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - if not sem["etat"]: - raise AccessDenied("Modification impossible: semestre verrouillé") - - groupsToDelete = [g for g in groupsToDelete.split(";") if g] - - etud_groups = formsemestre_get_etud_groupnames(formsemestre_id, attr="group_id") - for line in groupsLists.split("\n"): # for each group_id (one per line) - fs = line.split(";") - group_id = fs[0].strip() - if not group_id: - continue - try: - group_id = int(group_id) - except ValueError: - log(f"setGroups: ignoring invalid group_id={group_id}") - continue - group = get_group(group_id) - # Anciens membres du groupe: - old_members = get_group_members(group_id) - old_members_set = set([x["etudid"] for x in old_members]) - # Place dans ce groupe les etudiants indiqués: - for etudid_str in fs[1:-1]: - etudid = int(etudid_str) - if etudid in old_members_set: - old_members_set.remove( - etudid - ) # a nouveau dans ce groupe, pas besoin de l'enlever - if (etudid not in etud_groups) or ( - group_id != etud_groups[etudid].get(partition_id, "") - ): # pas le meme groupe qu'actuel - change_etud_group_in_partition(etudid, group_id, partition) - # Retire les anciens membres: - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - for etudid in old_members_set: - log("removing %s from group %s" % (etudid, group_id)) - ndb.SimpleQuery( - "DELETE FROM group_membership WHERE etudid=%(etudid)s and group_id=%(group_id)s", - {"etudid": etudid, "group_id": group_id}, - cursor=cursor, - ) - logdb( - cnx, - method="removeFromGroup", - etudid=etudid, - msg="formsemestre_id=%s,partition_name=%s, group_name=%s" - % (formsemestre_id, partition["partition_name"], group["group_name"]), - ) - - # Supprime les groupes indiqués comme supprimés: - for group_id in groupsToDelete: - delete_group(group_id, partition_id=partition_id) - - # Crée les nouveaux groupes - for line in groupsToCreate.split("\n"): # for each group_name (one per line) - fs = line.split(";") - group_name = fs[0].strip() - if not group_name: - continue - try: - group = create_group(partition_id, group_name) - except ScoValueError as exc: - msg = exc.args[0] if len(exc.args) > 0 else "erreur inconnue" - return xml_error(msg, code=404) - # Place dans ce groupe les etudiants indiqués: - for etudid in fs[1:-1]: - change_etud_group_in_partition(etudid, group.id, partition) - - # Update parcours - formsemestre = FormSemestre.query.get(formsemestre_id) - formsemestre.update_inscriptions_parcours_from_groups() - - data = ( - 'Groupes enregistrés' - ) - response = make_response(data) - response.headers["Content-Type"] = scu.XML_MIMETYPE - return response - - -def create_group(partition_id, group_name="", default=False) -> GroupDescr: - """Create a new group in this partition. - If default, create default partition (with no name) - """ - partition = Partition.query.get_or_404(partition_id) - if not sco_permissions_check.can_change_groups(partition.formsemestre_id): - raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") - # - if group_name: - group_name = group_name.strip() - if not group_name and not default: - raise ValueError("invalid group name: ()") - - if not GroupDescr.check_name(partition, group_name, default=default): - raise ScoValueError(f"Le groupe {group_name} existe déjà dans cette partition") - - numeros = [g.numero if g.numero is not None else 0 for g in partition.groups] - if len(numeros) > 0: - new_numero = max(numeros) + 1 - else: - new_numero = 0 - group = GroupDescr(partition=partition, group_name=group_name, numero=new_numero) - db.session.add(group) - db.session.commit() - log("create_group: created group_id={group.id}") - # - return group - - -def delete_group(group_id, partition_id=None): - """form suppression d'un groupe. - (ne desinscrit pas les etudiants, change juste leur - affectation aux groupes) - partition_id est optionnel et ne sert que pour verifier que le groupe - est bien dans cette partition. - S'il s'agit d'un groupe de parcours, affecte l'inscription des étudiants aux parcours. - """ - group = GroupDescr.query.get_or_404(group_id) - if partition_id: - if partition_id != group.partition_id: - raise ValueError("inconsistent partition/group") - if not sco_permissions_check.can_change_groups(group.partition.formsemestre_id): - raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") - log(f"delete_group: group={group} partition={group.partition}") - formsemestre = group.partition.formsemestre - group_delete(group.id) - formsemestre.update_inscriptions_parcours_from_groups() - - -def partition_create( - formsemestre_id, - partition_name="", - default=False, - numero=None, - redirect=True, -): - """Create a new partition""" - if not sco_permissions_check.can_change_groups(formsemestre_id): - raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") - if partition_name: - partition_name = str(partition_name).strip() - if default: - partition_name = None - if not partition_name and not default: - raise ScoValueError("Nom de partition invalide (vide)") - redirect = int(redirect) - # checkGroupName(partition_name) - if partition_name in [ - p["partition_name"] for p in get_partitions_list(formsemestre_id) - ]: - raise ScoValueError( - "Il existe déjà une partition %s dans ce semestre" % partition_name - ) - - cnx = ndb.GetDBConnexion() - if numero is None: - numero = ( - ndb.SimpleQuery( - "SELECT MAX(id) FROM partition WHERE formsemestre_id=%(formsemestre_id)s", - {"formsemestre_id": formsemestre_id}, - ).fetchone()[0] - or 0 - ) - partition_id = partitionEditor.create( - cnx, - { - "formsemestre_id": formsemestre_id, - "partition_name": partition_name, - "numero": numero, - }, - ) - log("createPartition: created partition_id=%s" % partition_id) - # - if redirect: - return flask.redirect( - url_for( - "scolar.edit_partition_form", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ) - ) - else: - return partition_id - - -def get_arrow_icons_tags(): - """returns html tags for arrows""" - # - arrow_up = scu.icontag("arrow_up", title="remonter") - arrow_down = scu.icontag("arrow_down", title="descendre") - arrow_none = scu.icontag("arrow_none", title="") - - return arrow_up, arrow_down, arrow_none - - -def edit_partition_form(formsemestre_id=None): - """Form to create/suppress partitions""" - # ad-hoc form - if not sco_permissions_check.can_change_groups(formsemestre_id): - raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - partitions = get_partitions_list(formsemestre_id) - arrow_up, arrow_down, arrow_none = get_arrow_icons_tags() - suppricon = scu.icontag( - "delete_small_img", border="0", alt="supprimer", title="Supprimer" - ) - # - H = [ - html_sco_header.sco_header( - page_title="Partitions...", - javascripts=["js/edit_partition_form.js"], - ), - # limite à SHORT_STR_LEN - r""" - """, - r"""

Partitions du semestre

- -
- - """, - ] - i = 0 - for p in partitions: - if p["partition_name"] is not None: - H.append( - f""" - - - """ - ) - H.append("""""") - if p["groups_editable"]: - H.append( - f"""""" - ) - else: - H.append("""""") - # classement: - H.append('") - # - H.append("") - H.append("
PartitionGroupes
{suppricon} """ - ) - if i != 0: - H.append( - f"""{arrow_up}""" - ) - H.append('') - if i < len(partitions) - 2: - H.append( - f"""{arrow_down}""" - ) - i += 1 - H.append( - f"""{p["partition_name"] or ""}""" - ) - lg = [ - f"""{group["group_name"]} ({len(get_group_members(group["group_id"]))})""" - for group in get_partition_groups(p) - ] - H.append(", ".join(lg)) - H.append("""""") - H.append( - f"""répartirrenommernon éditable') - if p["bul_show_rank"]: - checked = 'checked="1"' - else: - checked = "" - H.append( - '
afficher rang sur bulletins
' - % (p["partition_id"], checked) - ) - if p["show_in_lists"]: - checked = 'checked="1"' - else: - checked = "" - H.append( - f"""
Afficher ces groupes sur les tableaux et bulletins
""" - ) - H.append("
") - H.append( - f"""
- - - - - """ - ) - if formsemestre.formation.is_apc() and scu.PARTITION_PARCOURS not in ( - p["partition_name"] for p in partitions - ): - # propose création partition "Parcours" - H.append( - f""" - - """ - ) - H.append( - """ -
- - """ - ) - H.append( - """
-

Les partitions sont des découpages de l'ensemble des étudiants. - Par exemple, les "groupes de TD" sont une partition. - On peut créer autant de partitions que nécessaire. -

-
    -
  • Dans chaque partition, un nombre de groupes quelconque peuvent - être créés (suivre le lien "répartir"). -
  • On peut faire afficher le classement de l'étudiant dans son - groupe d'une partition en cochant "afficher rang sur bulletins" - (ainsi, on peut afficher le classement en groupes de TD mais pas en - groupe de TP, si ce sont deux partitions). -
  • -
  • Décocher "Afficher ces groupes sur les tableaux et bulletins" pour ne pas que cette partition - apparaisse dans les noms de groupes -
  • -
-
- """ - ) - return "\n".join(H) + html_sco_header.sco_footer() - - -def partition_set_attr(partition_id, attr, value): - """Set partition attribute: bul_show_rank or show_in_lists""" - if attr not in {"bul_show_rank", "show_in_lists"}: - raise ValueError("invalid partition attribute: %s" % attr) - - partition = get_partition(partition_id) - formsemestre_id = partition["formsemestre_id"] - if not sco_permissions_check.can_change_groups(formsemestre_id): - raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") - - log("partition_set_attr(%s, %s, %s)" % (partition_id, attr, value)) - value = int(value) - - cnx = ndb.GetDBConnexion() - partition[attr] = value - partitionEditor.edit(cnx, partition) - # invalid bulletin cache - sco_cache.invalidate_formsemestre(formsemestre_id=partition["formsemestre_id"]) - return "enregistré" - - -def partition_delete(partition_id, force=False, redirect=1, dialog_confirmed=False): - """Suppress a partition (and all groups within). - The default partition cannot be suppressed (unless force). - Si la partition de parcours est supprimée, les étudiants sont désinscrits des parcours. - """ - partition = get_partition(partition_id) - formsemestre_id = partition["formsemestre_id"] - if not sco_permissions_check.can_change_groups(formsemestre_id): - raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - - if not partition["partition_name"] and not force: - raise ValueError("cannot suppress this partition") - redirect = int(redirect) - cnx = ndb.GetDBConnexion() - groups = get_partition_groups(partition) - - if not dialog_confirmed: - if groups: - grnames = "(" + ", ".join([g["group_name"] or "" for g in groups]) + ")" - else: - grnames = "" - return scu.confirm_dialog( - """

Supprimer la partition "%s" ?

-

Les groupes %s de cette partition seront supprimés

- """ - % (partition["partition_name"], grnames), - dest_url="", - cancel_url="edit_partition_form?formsemestre_id=%s" % formsemestre_id, - parameters={"redirect": redirect, "partition_id": partition_id}, - ) - - log("partition_delete: partition_id=%s" % partition_id) - # 1- groups - for group in groups: - group_delete(group["group_id"]) - # 2- partition - partitionEditor.delete(cnx, partition_id) - - formsemestre.update_inscriptions_parcours_from_groups() - - # redirect to partition edit page: - if redirect: - return flask.redirect( - "edit_partition_form?formsemestre_id=" + str(formsemestre_id) - ) - - -def partition_move(partition_id, after=0, redirect=1): - """Move before/after previous one (decrement/increment numero)""" - partition = get_partition(partition_id) - formsemestre_id = partition["formsemestre_id"] - if not sco_permissions_check.can_change_groups(formsemestre_id): - raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") - # - redirect = int(redirect) - after = int(after) # 0: deplace avant, 1 deplace apres - if after not in (0, 1): - raise ValueError('invalid value for "after"') - others = get_partitions_list(formsemestre_id) - - objs = ( - Partition.query.filter_by(formsemestre_id=formsemestre_id) - .order_by(Partition.numero, Partition.partition_name) - .all() - ) - if len({o.numero for o in objs}) != len(objs): - # il y a des numeros identiques ! - scu.objects_renumber(db, objs) - - if len(others) > 1: - pidx = [p["partition_id"] for p in others].index(partition_id) - # log("partition_move: after=%s pidx=%s" % (after, pidx)) - neigh = None # partition to swap with - if after == 0 and pidx > 0: - neigh = others[pidx - 1] - elif after == 1 and pidx < len(others) - 1: - neigh = others[pidx + 1] - if neigh: # - # swap numero between partition and its neighbor - # log("moving partition %s" % partition_id) - cnx = ndb.GetDBConnexion() - # Si aucun numéro n'a été affecté, le met au minimum - min_numero = ( - ndb.SimpleQuery( - "SELECT MIN(numero) FROM partition WHERE formsemestre_id=%(formsemestre_id)s", - {"formsemestre_id": formsemestre_id}, - ).fetchone()[0] - or 0 - ) - if neigh["numero"] is None: - neigh["numero"] = min_numero - 1 - if partition["numero"] is None: - partition["numero"] = min_numero - 1 - after - partition["numero"], neigh["numero"] = neigh["numero"], partition["numero"] - partitionEditor.edit(cnx, partition) - partitionEditor.edit(cnx, neigh) - - # redirect to partition edit page: - if redirect: - return flask.redirect( - "edit_partition_form?formsemestre_id=" + str(formsemestre_id) - ) - - -def partition_rename(partition_id): - """Form to rename a partition""" - partition = get_partition(partition_id) - formsemestre_id = partition["formsemestre_id"] - if not sco_permissions_check.can_change_groups(formsemestre_id): - raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") - H = ["

Renommer une partition

"] - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - ( - ("partition_id", {"default": partition_id, "input_type": "hidden"}), - ( - "partition_name", - { - "title": "Nouveau nom", - "default": partition["partition_name"], - "allow_null": False, - "size": 12, - "validator": lambda val, _: (len(val) < SHORT_STR_LEN) - and (val != scu.PARTITION_PARCOURS), - }, - ), - ), - submitlabel="Renommer", - cancelbutton="Annuler", - ) - if tf[0] == 0: - return ( - html_sco_header.sco_header() - + "\n".join(H) - + "\n" - + tf[1] - + html_sco_header.sco_footer() - ) - elif tf[0] == -1: - return flask.redirect( - "edit_partition_form?formsemestre_id=" + str(formsemestre_id) - ) - else: - # form submission - return partition_set_name(partition_id, tf[2]["partition_name"]) - - -def partition_set_name(partition_id, partition_name, redirect=1): - """Set partition name""" - partition_name = str(partition_name).strip() - if not partition_name: - raise ValueError("partition name must be non empty") - partition = get_partition(partition_id) - if partition["partition_name"] is None: - raise ValueError("can't set a name to default partition") - if partition_name == scu.PARTITION_PARCOURS: - raise ScoValueError(f"nom de partition {scu.PARTITION_PARCOURS} réservé.") - formsemestre_id = partition["formsemestre_id"] - - # check unicity - r = ndb.SimpleDictFetch( - """SELECT p.* FROM partition p - WHERE p.partition_name = %(partition_name)s - AND formsemestre_id = %(formsemestre_id)s - """, - {"partition_name": partition_name, "formsemestre_id": formsemestre_id}, - ) - if len(r) > 1 or (len(r) == 1 and r[0]["id"] != partition_id): - raise ScoValueError( - "Partition %s déjà existante dans ce semestre !" % partition_name - ) - - if not sco_permissions_check.can_change_groups(formsemestre_id): - raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") - redirect = int(redirect) - cnx = ndb.GetDBConnexion() - partitionEditor.edit( - cnx, {"partition_id": partition_id, "partition_name": partition_name} - ) - - # redirect to partition edit page: - if redirect: - return flask.redirect( - "edit_partition_form?formsemestre_id=" + str(formsemestre_id) - ) - - -def group_set_name(group: GroupDescr, group_name: str, redirect=True): - """Set group name""" - if not sco_permissions_check.can_change_groups(group.partition.formsemestre.id): - raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") - if group.group_name is None: - raise ValueError("can't set a name to default group") - destination = url_for( - "scolar.affect_groups", - scodoc_dept=g.scodoc_dept, - partition_id=group.partition_id, - ) - if group_name: - group_name = group_name.strip() - if not group_name: - raise ScoValueError("nom de groupe vide !", dest_url=destination) - if not GroupDescr.check_name(group.partition, group_name): - raise ScoValueError( - "Le nom de groupe existe déjà dans la partition", dest_url=destination - ) - - redirect = int(redirect) - group.group_name = group_name - db.session.add(group) - db.session.commit() - - # redirect to partition edit page: - if redirect: - return flask.redirect(destination) - - -def group_rename(group_id): - """Form to rename a group""" - group = GroupDescr.query.get_or_404(group_id) - formsemestre_id = group.partition.formsemestre_id - if not sco_permissions_check.can_change_groups(formsemestre_id): - raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") - H = [f"

Renommer un groupe de {group.partition.partition_name or '-'}

"] - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - ( - ("group_id", {"default": group_id, "input_type": "hidden"}), - ( - "group_name", - { - "title": "Nouveau nom", - "default": group.group_name, - "size": 12, - "allow_null": False, - "validator": lambda val, _: len(val) < GROUPNAME_STR_LEN, - }, - ), - ), - submitlabel="Renommer", - cancelbutton="Annuler", - ) - if tf[0] == 0: - return ( - html_sco_header.sco_header() - + "\n".join(H) - + "\n" - + tf[1] - + html_sco_header.sco_footer() - ) - elif tf[0] == -1: - return flask.redirect( - url_for( - "scolar.affect_groups", - scodoc_dept=g.scodoc_dept, - partition_id=group.partition_id, - ) - ) - else: - # form submission - return group_set_name(group, tf[2]["group_name"]) - - -def groups_auto_repartition(partition_id=None): - """Reparti les etudiants dans des groupes dans une partition, en respectant le niveau - et la mixité. - """ - from app.scodoc import sco_formsemestre - - partition = get_partition(partition_id) - if not partition["groups_editable"]: - raise AccessDenied("Partition non éditable") - formsemestre_id = partition["formsemestre_id"] - formsemestre = FormSemestre.query.get(formsemestre_id) - # renvoie sur page édition groupes - dest_url = url_for( - "scolar.affect_groups", scodoc_dept=g.scodoc_dept, partition_id=partition_id - ) - if not sco_permissions_check.can_change_groups(formsemestre_id): - raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") - - descr = [ - ("partition_id", {"input_type": "hidden"}), - ( - "groupNames", - { - "size": 40, - "title": "Groupes à créer", - "allow_null": False, - "explanation": "noms des groupes à former, séparés par des virgules (les groupes existants seront effacés)", - }, - ), - ] - - H = [ - html_sco_header.sco_header(page_title="Répartition des groupes"), - "

Répartition des groupes de %s

" % partition["partition_name"], - f"

Semestre {formsemestre.titre_annee()}

", - """

Les groupes existants seront effacés et remplacés par - ceux créés ici. La répartition aléatoire tente d'uniformiser le niveau - des groupes (en utilisant la dernière moyenne générale disponible pour - chaque étudiant) et de maximiser la mixité de chaque groupe.

""", - ] - - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - descr, - {}, - cancelbutton="Annuler", - method="GET", - submitlabel="Créer et peupler les groupes", - name="tf", - ) - if tf[0] == 0: - return "\n".join(H) + "\n" + tf[1] + html_sco_header.sco_footer() - elif tf[0] == -1: - return flask.redirect(dest_url) - else: - # form submission - log( - "groups_auto_repartition( partition_id=%s partition_name=%s" - % (partition_id, partition["partition_name"]) - ) - groupNames = tf[2]["groupNames"] - group_names = sorted(set([x.strip() for x in groupNames.split(",")])) - # Détruit les groupes existant de cette partition - for old_group in get_partition_groups(partition): - group_delete(old_group["group_id"]) - # Crée les nouveaux groupes - group_ids = [] - for group_name in group_names: - if group_name.strip(): - group_ids.append(create_group(partition_id, group_name).id) - # - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - identdict = nt.identdict - # build: { civilite : liste etudids trie par niveau croissant } - civilites = set([x["civilite"] for x in identdict.values()]) - listes = {} - for civilite in civilites: - listes[civilite] = [ - (_get_prev_moy(x["etudid"], formsemestre_id), x["etudid"]) - for x in identdict.values() - if x["civilite"] == civilite - ] - listes[civilite].sort() - log("listes[%s] = %s" % (civilite, listes[civilite])) - # affect aux groupes: - n = len(identdict) - igroup = 0 - nbgroups = len(group_ids) - while n > 0: - for civilite in civilites: - if len(listes[civilite]): - n -= 1 - etudid = listes[civilite].pop()[1] - group_id = group_ids[igroup] - igroup = (igroup + 1) % nbgroups - change_etud_group_in_partition(etudid, group_id, partition) - log("%s in group %s" % (etudid, group_id)) - return flask.redirect(dest_url) - - -def _get_prev_moy(etudid, formsemestre_id): - """Donne la derniere moyenne generale calculee pour cette étudiant, - ou 0 si on n'en trouve pas (nouvel inscrit,...). - """ - from app.scodoc import sco_cursus_dut - - info = sco_etud.get_etud_info(etudid=etudid, filled=True) - if not info: - raise ScoValueError("etudiant invalide: etudid=%s" % etudid) - etud = info[0] - Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id) - if Se.prev: - prev_sem = FormSemestre.query.get(Se.prev["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results(prev_sem) - return nt.get_etud_moy_gen(etudid) - else: - return 0.0 - - -def create_etapes_partition(formsemestre_id, partition_name="apo_etapes"): - """Crée une partition "apo_etapes" avec un groupe par étape Apogée. - Cette partition n'est crée que si plusieurs étapes différentes existent dans ce - semestre. - Si la partition existe déjà, ses groupes sont mis à jour (les groupes devenant - vides ne sont pas supprimés). - """ - from app.scodoc import sco_formsemestre_inscriptions - - partition_name = str(partition_name) - log("create_etapes_partition(%s)" % formsemestre_id) - ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( - args={"formsemestre_id": formsemestre_id} - ) - etapes = {i["etape"] for i in ins if i["etape"]} - partitions = get_partitions_list(formsemestre_id, with_default=False) - partition = None - for p in partitions: - if p["partition_name"] == partition_name: - partition = p - break - if len(etapes) < 2 and not partition: - return # moins de deux étapes, pas de création - if partition: - pid = partition["partition_id"] - else: - pid = partition_create( - formsemestre_id, partition_name=partition_name, redirect=False - ) - partition = get_partition(pid) - groups = get_partition_groups(partition) - groups_by_names = {g["group_name"]: g for g in groups} - for etape in etapes: - if not (etape in groups_by_names): - new_group = create_group(pid, etape) - g = get_group(new_group.id) # XXX transition: recupere old style dict - groups_by_names[etape] = g - # Place les etudiants dans les groupes - for i in ins: - if i["etape"]: - change_etud_group_in_partition( - i["etudid"], groups_by_names[i["etape"]]["group_id"], partition - ) - - -def do_evaluation_listeetuds_groups( - evaluation_id, groups=None, getallstudents=False, include_demdef=False -): - """Donne la liste des etudids inscrits a cette evaluation dans les - groupes indiqués. - Si getallstudents==True, donne tous les etudiants inscrits a cette - evaluation. - Si include_demdef, compte aussi les etudiants démissionnaires et défaillants - (sinon, par défaut, seulement les 'I') - - Résultat: [ (etudid, etat) ], où etat='I', 'D', 'DEF' - """ - # nb: pour notes_table / do_evaluation_etat, getallstudents est vrai et - # include_demdef faux - fromtables = [ - "notes_moduleimpl_inscription Im", - "notes_formsemestre_inscription Isem", - "notes_moduleimpl M", - "notes_evaluation E", - ] - # construit condition sur les groupes - if not getallstudents: - if not groups: - return [] # no groups, so no students - rg = ["gm.group_id = '%(group_id)s'" % g for g in groups] - rq = """and Isem.etudid = gm.etudid - and gd.partition_id = p.id - and p.formsemestre_id = Isem.formsemestre_id - """ - r = rq + " AND (" + " or ".join(rg) + " )" - fromtables += ["group_membership gm", "group_descr gd", "partition p"] - else: - r = "" - - # requete complete - req = ( - "SELECT distinct Im.etudid, Isem.etat FROM " - + ", ".join(fromtables) - + """ WHERE Isem.etudid = Im.etudid - and Im.moduleimpl_id = M.id - and Isem.formsemestre_id = M.formsemestre_id - and E.moduleimpl_id = M.id - and E.id = %(evaluation_id)s - """ - ) - if not include_demdef: - req += " and Isem.etat='I'" - req += r - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor() - cursor.execute(req, {"evaluation_id": evaluation_id}) - return cursor.fetchall() - - -def do_evaluation_listegroupes(evaluation_id, include_default=False): - """Donne la liste des groupes dans lesquels figurent des etudiants inscrits - au module/semestre auquel appartient cette evaluation. - Si include_default, inclue aussi le groupe par defaut ('tous') - [ group ] - """ - if include_default: - c = "" - else: - c = " AND p.partition_name is not NULL" - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor() - cursor.execute( - """SELECT DISTINCT gd.id AS group_id - FROM group_descr gd, group_membership gm, partition p, - notes_moduleimpl m, notes_evaluation e - WHERE gm.group_id = gd.id - and gd.partition_id = p.id - and p.formsemestre_id = m.formsemestre_id - and m.id = e.moduleimpl_id - and e.id = %(evaluation_id)s - """ - + c, - {"evaluation_id": evaluation_id}, - ) - group_ids = [x[0] for x in cursor] - return listgroups(group_ids) - - -def listgroups(group_ids): - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - groups = [] - for group_id in group_ids: - cursor.execute( - """SELECT gd.id AS group_id, gd.*, p.id AS partition_id, p.* - FROM group_descr gd, partition p - WHERE p.id = gd.partition_id - AND gd.id = %(group_id)s - """, - {"group_id": group_id}, - ) - r = cursor.dictfetchall() - if r: - groups.append(r[0]) - return _sortgroups(groups) - - -def _sortgroups(groups): - # Tri: place 'all' en tête, puis groupe par partition / nom de groupe - R = [g for g in groups if g["partition_name"] is None] - o = [g for g in groups if g["partition_name"] != None] - o.sort(key=lambda x: (x["numero"] or 0, x["group_name"])) - - return R + o - - -def listgroups_filename(groups): - """Build a filename representing groups""" - return "gr" + "+".join([g["group_name"] or "tous" for g in groups]) - - -def listgroups_abbrev(groups): - """Human readable abbreviation descring groups (eg "A / AB / B3") - Ne retient que les partitions avec show_in_lists - """ - return " / ".join( - [g["group_name"] for g in groups if g["group_name"] and g["show_in_lists"]] - ) - - -# form_group_choice replaces formChoixGroupe -def form_group_choice( - formsemestre_id, - allow_none=True, # offre un choix vide dans chaque partition - select_default=True, # Le groupe par defaut est mentionné (hidden). - display_sem_title=False, -): - """Partie de formulaire pour le choix d'un ou plusieurs groupes. - Variable : group_ids - """ - from app.scodoc import sco_formsemestre - - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - if display_sem_title: - sem_title = "%s: " % sem["titremois"] - else: - sem_title = "" - # - H = [""""""] - for p in get_partitions_list(formsemestre_id): - if p["partition_name"] is None: - if select_default: - H.append( - '' - % get_partition_groups(p)[0]["group_id"] - ) - else: - H.append("") - H.append("""
Groupe de %(partition_name)s" % p) - H.append('
""") - return "\n".join(H) - - -def make_query_groups(group_ids): - if group_ids: - return "&".join(["group_ids%3Alist=" + str(group_id) for group_id in group_ids]) - else: - return "" - - -class GroupIdInferer(object): - """Sert à retrouver l'id d'un groupe dans un semestre donné - à partir de son nom. - Attention: il peut y avoir plusieurs groupes de même nom - dans des partitions différentes. Dans ce cas, prend le dernier listé. - On peut indiquer la partition en écrivant - partition_name:group_name - """ - - def __init__(self, formsemestre_id): - groups = get_sem_groups(formsemestre_id) - self.name2group_id = {} - self.partitionname2group_id = {} - for group in groups: - self.name2group_id[group["group_name"]] = group["group_id"] - self.partitionname2group_id[ - (group["partition_name"], group["group_name"]) - ] = group["group_id"] - - def __getitem__(self, name): - """Get group_id from group_name, or None is nonexistent. - The group name can be prefixed by the partition's name, using - syntax partition_name:group_name - """ - l = name.split(":", 1) - if len(l) > 1: - partition_name, group_name = l - else: - partition_name = None - group_name = name - if partition_name is None: - group_id = self.name2group_id.get(group_name, None) - if group_id is None and name[-2:] == ".0": - # si nom groupe numerique, excel ajoute parfois ".0" ! - group_name = group_name[:-2] - group_id = self.name2group_id.get(group_name, None) - else: - group_id = self.partitionname2group_id.get( - (partition_name, group_name), None - ) - return group_id +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@gmail.com +# +############################################################################## + +"""Gestion des groupes, nouvelle mouture (juin/nov 2009) + +TODO: +Optimisation possible: + revoir do_evaluation_listeetuds_groups() pour extraire aussi les groupes (de chaque etudiant) + et éviter ainsi l'appel ulterieur à get_etud_groups() dans _make_table_notes + +""" +import collections +import operator +import time + +from xml.etree import ElementTree +from xml.etree.ElementTree import Element + +import flask +from flask import g, request +from flask import url_for, make_response +from sqlalchemy.sql import text + +from app import db +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import FormSemestre, Identite +from app.models import GROUPNAME_STR_LEN, SHORT_STR_LEN +from app.models.groups import GroupDescr, Partition +import app.scodoc.sco_utils as scu +import app.scodoc.notesdb as ndb +from app import log, cache +from app.scodoc.scolog import logdb +from app.scodoc import html_sco_header +from app.scodoc import sco_cache +from app.scodoc import sco_codes_parcours +from app.scodoc import sco_cursus +from app.scodoc import sco_etud +from app.scodoc.sco_etud import etud_sort_key +from app.scodoc import sco_permissions_check +from app.scodoc import sco_xml +from app.scodoc.sco_exceptions import ScoException, AccessDenied, ScoValueError +from app.scodoc.TrivialFormulator import TrivialFormulator + + +partitionEditor = ndb.EditableTable( + "partition", + "partition_id", + ( + "partition_id", + "formsemestre_id", + "partition_name", + "compute_ranks", + "numero", + "bul_show_rank", + "show_in_lists", + "editable", + ), + input_formators={ + "bul_show_rank": bool, + "show_in_lists": bool, + "editable": bool, + }, +) + +groupEditor = ndb.EditableTable( + "group_descr", "group_id", ("group_id", "partition_id", "group_name", "numero") +) + +group_list = groupEditor.list + + +def get_group(group_id: int) -> dict: + """Returns group object, with partition""" + r = ndb.SimpleDictFetch( + """SELECT gd.id AS group_id, gd.*, p.id AS partition_id, p.* + FROM group_descr gd, partition p + WHERE gd.id=%(group_id)s + AND p.id = gd.partition_id + """, + {"group_id": group_id}, + ) + if not r: + raise ScoValueError(f"Groupe inexistant ! (id {group_id})") + return r[0] + + +def group_delete(group_id: int): + """Delete a group.""" + # if not group['group_name'] and not force: + # raise ValueError('cannot suppress this group') + # remove memberships: + ndb.SimpleQuery( + "DELETE FROM group_membership WHERE group_id=%(group_id)s", + {"group_id": group_id}, + ) + # delete group: + ndb.SimpleQuery( + "DELETE FROM group_descr WHERE id=%(group_id)s", {"group_id": group_id} + ) + + +def get_partition(partition_id): + r = ndb.SimpleDictFetch( + """SELECT p.id AS partition_id, p.* + FROM partition p + WHERE p.id = %(partition_id)s + """, + {"partition_id": partition_id}, + ) + if not r: + raise ScoValueError(f"Partition inconnue (déjà supprimée ?) ({partition_id})") + return r[0] + + +def get_partitions_list(formsemestre_id, with_default=True) -> list[dict]: + """Liste des partitions pour ce semestre (list of dicts), + triées par numéro, avec la partition par défaut en fin de liste. + """ + partitions = ndb.SimpleDictFetch( + """SELECT p.id AS partition_id, p.* + FROM partition p + WHERE formsemestre_id=%(formsemestre_id)s + ORDER BY numero""", + {"formsemestre_id": formsemestre_id}, + ) + # Move 'all' at end of list (for menus) + R = [p for p in partitions if p["partition_name"] is not None] + if with_default: + R += [p for p in partitions if p["partition_name"] is None] + return R + + +def get_default_partition(formsemestre_id): + """Get partition for 'all' students (this one always exists, with NULL name)""" + r = ndb.SimpleDictFetch( + """SELECT p.id AS partition_id, p.* FROM partition p + WHERE formsemestre_id=%(formsemestre_id)s + AND partition_name is NULL + """, + {"formsemestre_id": formsemestre_id}, + ) + if len(r) != 1: + raise ScoException( + "inconsistent partition: %d with NULL name for formsemestre_id=%s" + % (len(r), formsemestre_id) + ) + return r[0] + + +def get_formsemestre_groups(formsemestre_id, with_default=False): + """Returns ( partitions, { partition_id : { etudid : group } } ).""" + partitions = get_partitions_list(formsemestre_id, with_default=with_default) + partitions_etud_groups = {} # { partition_id : { etudid : group } } + for partition in partitions: + pid = partition["partition_id"] + partitions_etud_groups[pid] = get_etud_groups_in_partition(pid) + return partitions, partitions_etud_groups + + +def get_formsemestre_etuds_groups(formsemestre_id: int) -> dict: + """{ etudid : { partition_id : group_id } }""" + infos = ndb.SimpleDictFetch( + """SELECT etudid, p.id AS partition_id, gd.id AS group_id + FROM group_descr gd, group_membership gm, partition p + WHERE gd.partition_id = p.id + AND gm.group_id = gd.id + AND p.formsemestre_id = %(formsemestre_id)s + """, + {"formsemestre_id": formsemestre_id}, + ) + # -> {'etudid': 16483, 'group_id': 5317, 'partition_id': 2264}, + d = collections.defaultdict(lambda: {}) + for i in infos: + d[i["etudid"]][i["partition_id"]] = i["group_id"] + return d + + +def get_partition_groups(partition): + """List of groups in this partition (list of dicts). + Some groups may be empty.""" + return ndb.SimpleDictFetch( + """SELECT gd.id AS group_id, p.id AS partition_id, gd.*, p.* + FROM group_descr gd, partition p + WHERE gd.partition_id=%(partition_id)s + AND gd.partition_id=p.id + ORDER BY gd.numero + """, + partition, + ) + + +def get_default_group(formsemestre_id, fix_if_missing=False): + """Returns group_id for default ('tous') group""" + r = ndb.SimpleDictFetch( + """SELECT gd.id AS group_id + FROM group_descr gd, partition p + WHERE p.formsemestre_id=%(formsemestre_id)s + AND p.partition_name is NULL + AND p.id = gd.partition_id + """, + {"formsemestre_id": formsemestre_id}, + ) + if len(r) == 0 and fix_if_missing: + # No default group (problem during sem creation) + # Try to create it + log( + "*** Warning: get_default_group(formsemestre_id=%s): default group missing, recreating it" + % formsemestre_id + ) + try: + partition_id = get_default_partition(formsemestre_id)["partition_id"] + except ScoException: + log("creating default partition for %s" % formsemestre_id) + partition_id = partition_create( + formsemestre_id, default=True, redirect=False + ) + group = create_group(partition_id, default=True) + return group.id + # debug check + if len(r) != 1: + raise ScoException("invalid group structure for %s" % formsemestre_id) + group_id = r[0]["group_id"] + return group_id + + +def get_sem_groups(formsemestre_id): + """Returns groups for this sem (in all partitions).""" + return ndb.SimpleDictFetch( + """SELECT gd.id AS group_id, p.id AS partition_id, gd.*, p.* + FROM group_descr gd, partition p + WHERE p.formsemestre_id=%(formsemestre_id)s + AND p.id = gd.partition_id + """, + {"formsemestre_id": formsemestre_id}, + ) + + +def get_group_members(group_id, etat=None): + """Liste des etudiants d'un groupe. + Si etat, filtre selon l'état de l'inscription + Trié par nom_usuel (ou nom) puis prénom + """ + req = """SELECT i.id as etudid, i.*, a.*, gm.*, ins.etat + FROM identite i, adresse a, group_membership gm, + group_descr gd, partition p, notes_formsemestre_inscription ins + WHERE i.id = gm.etudid + and a.etudid = i.id + and ins.etudid = i.id + and ins.formsemestre_id = p.formsemestre_id + and p.id = gd.partition_id + and gd.id = gm.group_id + and gm.group_id=%(group_id)s + """ + if etat is not None: + req += " and ins.etat = %(etat)s" + + r = ndb.SimpleDictFetch(req, {"group_id": group_id, "etat": etat}) + + for etud in r: + sco_etud.format_etud_ident(etud) + + # tri selon nom_usuel ou nom, sans accents + r.sort(key=etud_sort_key) + + if scu.CONFIG.ALLOW_NULL_PRENOM: + for x in r: + x["prenom"] = x["prenom"] or "" + + return r + + +def get_group_infos(group_id, etat=None): # was _getlisteetud + """legacy code: used by group_list and trombino""" + from app.scodoc import sco_formsemestre + + cnx = ndb.GetDBConnexion() + group = get_group(group_id) + sem = sco_formsemestre.get_formsemestre( + group["formsemestre_id"], raise_soft_exc=True + ) + + members = get_group_members(group_id, etat=etat) + # add human readable description of state: + nbdem = 0 + for t in members: + if t["etat"] == "I": + t["etath"] = "" # etudiant inscrit, ne l'indique pas dans la liste HTML + elif t["etat"] == "D": + events = sco_etud.scolar_events_list( + cnx, + args={ + "etudid": t["etudid"], + "formsemestre_id": group["formsemestre_id"], + }, + ) + for event in events: + event_type = event["event_type"] + if event_type == "DEMISSION": + t["date_dem"] = event["event_date"] + break + if "date_dem" in t: + t["etath"] = "démission le %s" % t["date_dem"] + else: + t["etath"] = "(dem.)" + nbdem += 1 + elif t["etat"] == sco_codes_parcours.DEF: + t["etath"] = "Défaillant" + else: + t["etath"] = t["etat"] + # Add membership for all partitions, 'partition_id' : group + for etud in members: # long: comment eviter ces boucles ? + etud_add_group_infos(etud, sem["formsemestre_id"]) + + if group["partition_name"] is None: + group_tit = "tous" + else: + group_tit = f"""{group["partition_name"]} {group["group_name"]}""" + + return members, group, group_tit, sem, nbdem + + +def get_group_other_partitions(group): + """Liste des partitions du même semestre que ce groupe, + sans celle qui contient ce groupe. + """ + other_partitions = [ + p + for p in get_partitions_list(group["formsemestre_id"]) + if p["partition_id"] != group["partition_id"] and p["partition_name"] + ] + return other_partitions + + +def get_etud_groups(etudid: int, formsemestre_id: int, exclude_default=False): + """Infos sur groupes de l'etudiant dans ce semestre + [ group + partition_name ] + """ + req = """SELECT p.id AS partition_id, p.*, + g.id AS group_id, g.numero as group_numero, g.group_name + FROM group_descr g, partition p, group_membership gm + WHERE gm.etudid=%(etudid)s + and gm.group_id = g.id + and g.partition_id = p.id + and p.formsemestre_id = %(formsemestre_id)s + """ + if exclude_default: + req += " and p.partition_name is not NULL" + groups = ndb.SimpleDictFetch( + req + " ORDER BY p.numero", + {"etudid": etudid, "formsemestre_id": formsemestre_id}, + ) + return _sortgroups(groups) + + +def get_etud_main_group(etudid: int, formsemestre_id: int): + """Return main group (the first one) for etud, or default one if no groups""" + groups = get_etud_groups(etudid, formsemestre_id, exclude_default=True) + if groups: + return groups[0] + else: + return get_group(get_default_group(formsemestre_id)) + + +def formsemestre_get_main_partition(formsemestre_id): + """Return main partition (the first one) for sem, or default one if no groups + (rappel: default == tous, main == principale (groupes TD habituellement) + """ + return get_partitions_list(formsemestre_id, with_default=True)[0] + + +def formsemestre_get_etud_groupnames(formsemestre_id, attr="group_name"): + """Recupere les groupes de tous les etudiants d'un semestre + { etudid : { partition_id : group_name }} (attr=group_name or group_id) + """ + infos = ndb.SimpleDictFetch( + """SELECT + i.etudid AS etudid, + p.id AS partition_id, + gd.group_name, + gd.id AS group_id + FROM + notes_formsemestre_inscription i, + partition p, + group_descr gd, + group_membership gm + WHERE + i.formsemestre_id=%(formsemestre_id)s + and i.formsemestre_id = p.formsemestre_id + and p.id = gd.partition_id + and gm.etudid = i.etudid + and gm.group_id = gd.id + and p.partition_name is not NULL + """, + {"formsemestre_id": formsemestre_id}, + ) + R = {} + for info in infos: + if info["etudid"] in R: + R[info["etudid"]][info["partition_id"]] = info[attr] + else: + R[info["etudid"]] = {info["partition_id"]: info[attr]} + return R + + +def get_etud_formsemestre_groups( + etud: Identite, formsemestre: FormSemestre, only_to_show=True +) -> list[GroupDescr]: + """Liste les groupes auxquels est inscrit. + Si only_to_show (défaut vrai), ne donne que les groupes "visiables", + c'est à dire des partitions avec show_in_lists True. + """ + # Note: je n'ai pas réussi à construire une requete SQLAlechemy avec + # la Table d'association group_membership + cursor = db.session.execute( + text( + """ + SELECT g.id + FROM group_descr g, group_membership gm, partition p + WHERE gm.etudid = :etudid + AND gm.group_id = g.id + AND g.partition_id = p.id + AND p.formsemestre_id = :formsemestre_id + AND p.partition_name is not NULL + """ + + (" and (p.show_in_lists is True) " if only_to_show else "") + + """ + ORDER BY p.numero + """ + ), + {"etudid": etud.id, "formsemestre_id": formsemestre.id}, + ) + return [GroupDescr.query.get(group_id) for group_id in cursor] + + +# Ancienne fonction: +def etud_add_group_infos(etud, formsemestre_id, sep=" ", only_to_show=False): + """Add informations on partitions and group memberships to etud + (a dict with an etudid) + If only_to_show, restrict to partions such that show_in_lists is True. + + etud['partitions'] = { partition_id : group + partition_name } + etud['groupes'] = "TDB, Gr2, TPB1" + etud['partitionsgroupes'] = "Groupes TD:TDB, Groupes TP:Gr2 (...)" + """ + etud[ + "partitions" + ] = collections.OrderedDict() # partition_id : group + partition_name + if not formsemestre_id: + etud["groupes"] = "" + return etud + + infos = ndb.SimpleDictFetch( + """SELECT p.partition_name, p.show_in_lists, g.*, g.id AS group_id + FROM group_descr g, partition p, group_membership gm WHERE gm.etudid=%(etudid)s + and gm.group_id = g.id + and g.partition_id = p.id + and p.formsemestre_id = %(formsemestre_id)s + """ + + (" and (p.show_in_lists is True) " if only_to_show else "") + + """ + ORDER BY p.numero + """, + {"etudid": etud["etudid"], "formsemestre_id": formsemestre_id}, + ) + + for info in infos: + if info["partition_name"]: + etud["partitions"][info["partition_id"]] = info + + # resume textuel des groupes: + etud["groupes"] = sep.join( + [gr["group_name"] for gr in infos if gr["group_name"] is not None] + ) + etud["partitionsgroupes"] = sep.join( + [ + (gr["partition_name"] or "") + ":" + gr["group_name"] + for gr in infos + if gr["group_name"] is not None + ] + ) + + return etud + + +@cache.memoize(timeout=50) # seconds +def get_etud_groups_in_partition(partition_id): + """Returns { etudid : group }, with all students in this partition""" + infos = ndb.SimpleDictFetch( + """SELECT gd.id AS group_id, gd.*, etudid + FROM group_descr gd, group_membership gm + WHERE gd.partition_id = %(partition_id)s + AND gm.group_id = gd.id + """, + {"partition_id": partition_id}, + ) + R = {} + for i in infos: + R[i["etudid"]] = i + return R + + +def formsemestre_partition_list(formsemestre_id, format="xml"): + """Get partitions and groups in this semestre + Supported formats: xml, json + """ + partitions = get_partitions_list(formsemestre_id, with_default=True) + # Ajoute les groupes + for p in partitions: + p["group"] = get_partition_groups(p) + return scu.sendResult(partitions, name="partition", format=format) + + +# Encore utilisé par groupmgr.js +def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD + """ + Deprecated: use group_list + Liste des étudiants dans chaque groupe de cette partition. + + + + + ... + """ + t0 = time.time() + partition = get_partition(partition_id) + formsemestre_id = partition["formsemestre_id"] + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + etuds_set = {ins.etudid for ins in formsemestre.inscriptions} + + groups = get_partition_groups(partition) + # Build XML: + t1 = time.time() + doc = Element("ajax-response") + x_response = Element("response", type="object", id="MyUpdater") + doc.append(x_response) + for group in groups: + x_group = Element( + "group", + partition_id=str(partition_id), + partition_name=partition["partition_name"], + groups_editable=str(int(partition["groups_editable"])), + group_id=str(group["group_id"]), + group_name=group["group_name"], + ) + x_response.append(x_group) + for e in get_group_members(group["group_id"]): + etud = sco_etud.get_etud_info(etudid=e["etudid"], filled=True)[0] + x_group.append( + Element( + "etud", + etudid=str(e["etudid"]), + civilite=etud["civilite_str"], + sexe=etud["civilite_str"], # compat + nom=sco_etud.format_nom(etud["nom"]), + prenom=sco_etud.format_prenom(etud["prenom"]), + origin=_comp_etud_origin(etud, formsemestre), + ) + ) + if e["etudid"] in etuds_set: + etuds_set.remove(e["etudid"]) # etudiant vu dans un groupe + + # Ajoute les etudiants inscrits au semestre mais dans aucun groupe de cette partition: + if etuds_set: + x_group = Element( + "group", + partition_id=str(partition_id), + partition_name=partition["partition_name"], + groups_editable=str(int(partition["groups_editable"])), + group_id="_none_", + group_name="", + ) + doc.append(x_group) + for etudid in etuds_set: + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + x_group.append( + Element( + "etud", + etudid=str(etud["etudid"]), + sexe=etud["civilite_str"], + nom=sco_etud.format_nom(etud["nom"]), + prenom=sco_etud.format_prenom(etud["prenom"]), + origin=_comp_etud_origin(etud, formsemestre), + ) + ) + t2 = time.time() + log(f"XMLgetGroupsInPartition: {t2-t0} seconds ({t1-t0}+{t2-t1})") + # XML response: + data = sco_xml.XML_HEADER + ElementTree.tostring(doc).decode(scu.SCO_ENCODING) + response = make_response(data) + response.headers["Content-Type"] = scu.XML_MIMETYPE + return response + + +def _comp_etud_origin(etud: dict, cur_formsemestre: FormSemestre): + """breve description de l'origine de l'étudiant (sem. precedent) + (n'indique l'origine que si ce n'est pas le semestre precedent normal) + """ + # cherche le semestre suivant le sem. courant dans la liste + cur_sem_idx = None + for i in range(len(etud["sems"])): + if etud["sems"][i]["formsemestre_id"] == cur_formsemestre.id: + cur_sem_idx = i + break + + if cur_sem_idx is None or (cur_sem_idx + 1) >= (len(etud["sems"]) - 1): + return "" # on pourrait indiquer le bac mais en general on ne l'a pas en debut d'annee + + prev_sem = etud["sems"][cur_sem_idx + 1] + if prev_sem["semestre_id"] != (cur_formsemestre.semestre_id - 1): + return f" (S{prev_sem['semestre_id']})" + else: + return "" # parcours normal, ne le signale pas + + +def set_group(etudid: int, group_id: int) -> bool: + """Inscrit l'étudiant au groupe. + Return True if ok, False si deja inscrit. + Warning: + - don't check if group_id exists (the caller should check). + - don't check if group's partition is editable + """ + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + args = {"etudid": etudid, "group_id": group_id} + # déjà inscrit ? + r = ndb.SimpleDictFetch( + "SELECT * FROM group_membership gm WHERE etudid=%(etudid)s and group_id=%(group_id)s", + args, + cursor=cursor, + ) + if len(r): + return False + # inscrit + ndb.SimpleQuery( + "INSERT INTO group_membership (etudid, group_id) VALUES (%(etudid)s, %(group_id)s)", + args, + cursor=cursor, + ) + return True + + +def change_etud_group_in_partition(etudid, group_id, partition=None): + """Inscrit etud au groupe de cette partition, et le desinscrit d'autres groupes de cette partition.""" + log("change_etud_group_in_partition: etudid=%s group_id=%s" % (etudid, group_id)) + + # 0- La partition + group = get_group(group_id) + if partition: + # verifie que le groupe est bien dans cette partition: + if group["partition_id"] != partition["partition_id"]: + raise ValueError( + "inconsistent group/partition (group_id=%s, partition_id=%s)" + % (group_id, partition["partition_id"]) + ) + else: + partition = get_partition(group["partition_id"]) + # 1- Supprime membership dans cette partition + ndb.SimpleQuery( + """DELETE FROM group_membership gm + WHERE EXISTS + (SELECT 1 FROM group_descr gd + WHERE gm.etudid = %(etudid)s + AND gm.group_id = gd.id + AND gd.partition_id = %(partition_id)s) + """, + {"etudid": etudid, "partition_id": partition["partition_id"]}, + ) + # 2- associe au nouveau groupe + set_group(etudid, group_id) + + # 3- log + formsemestre_id = partition["formsemestre_id"] + cnx = ndb.GetDBConnexion() + logdb( + cnx, + method="changeGroup", + etudid=etudid, + msg="formsemestre_id=%s,partition_name=%s, group_name=%s" + % (formsemestre_id, partition["partition_name"], group["group_name"]), + ) + cnx.commit() + + # 5- Update parcours + formsemestre = FormSemestre.query.get(formsemestre_id) + formsemestre.update_inscriptions_parcours_from_groups() + + # 6- invalidate cache + sco_cache.invalidate_formsemestre( + formsemestre_id=formsemestre_id + ) # > change etud group + + +def setGroups( + partition_id, + groupsLists="", # members of each existing group + groupsToCreate="", # name and members of new groups + groupsToDelete="", # groups to delete +): + """Affect groups (Ajax POST request): renvoie du XML + groupsLists: lignes de la forme "group_id;etudid;...\n" + groupsToCreate: lignes "group_name;etudid;...\n" + groupsToDelete: group_id;group_id;... + + Ne peux pas modifier les groupes des partitions non éditables. + """ + from app.scodoc import sco_formsemestre + + def xml_error(msg, code=404): + data = ( + f'Error: {msg}' + ) + response = make_response(data, code) + response.headers["Content-Type"] = scu.XML_MIMETYPE + return response + + partition = get_partition(partition_id) + if not partition["groups_editable"] and (groupsToCreate or groupsToDelete): + msg = "setGroups: partition non editable" + log(msg) + return xml_error(msg, code=403) + formsemestre_id = partition["formsemestre_id"] + if not sco_permissions_check.can_change_groups(formsemestre_id): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + log("***setGroups: partition_id=%s" % partition_id) + log("groupsLists=%s" % groupsLists) + log("groupsToCreate=%s" % groupsToCreate) + log("groupsToDelete=%s" % groupsToDelete) + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + if not sem["etat"]: + raise AccessDenied("Modification impossible: semestre verrouillé") + + groupsToDelete = [g for g in groupsToDelete.split(";") if g] + + etud_groups = formsemestre_get_etud_groupnames(formsemestre_id, attr="group_id") + for line in groupsLists.split("\n"): # for each group_id (one per line) + fs = line.split(";") + group_id = fs[0].strip() + if not group_id: + continue + try: + group_id = int(group_id) + except ValueError: + log(f"setGroups: ignoring invalid group_id={group_id}") + continue + group = get_group(group_id) + # Anciens membres du groupe: + old_members = get_group_members(group_id) + old_members_set = set([x["etudid"] for x in old_members]) + # Place dans ce groupe les etudiants indiqués: + for etudid_str in fs[1:-1]: + etudid = int(etudid_str) + if etudid in old_members_set: + old_members_set.remove( + etudid + ) # a nouveau dans ce groupe, pas besoin de l'enlever + if (etudid not in etud_groups) or ( + group_id != etud_groups[etudid].get(partition_id, "") + ): # pas le meme groupe qu'actuel + change_etud_group_in_partition(etudid, group_id, partition) + # Retire les anciens membres: + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + for etudid in old_members_set: + log("removing %s from group %s" % (etudid, group_id)) + ndb.SimpleQuery( + "DELETE FROM group_membership WHERE etudid=%(etudid)s and group_id=%(group_id)s", + {"etudid": etudid, "group_id": group_id}, + cursor=cursor, + ) + logdb( + cnx, + method="removeFromGroup", + etudid=etudid, + msg="formsemestre_id=%s,partition_name=%s, group_name=%s" + % (formsemestre_id, partition["partition_name"], group["group_name"]), + ) + + # Supprime les groupes indiqués comme supprimés: + for group_id in groupsToDelete: + delete_group(group_id, partition_id=partition_id) + + # Crée les nouveaux groupes + for line in groupsToCreate.split("\n"): # for each group_name (one per line) + fs = line.split(";") + group_name = fs[0].strip() + if not group_name: + continue + try: + group = create_group(partition_id, group_name) + except ScoValueError as exc: + msg = exc.args[0] if len(exc.args) > 0 else "erreur inconnue" + return xml_error(msg, code=404) + # Place dans ce groupe les etudiants indiqués: + for etudid in fs[1:-1]: + change_etud_group_in_partition(etudid, group.id, partition) + + # Update parcours + formsemestre = FormSemestre.query.get(formsemestre_id) + formsemestre.update_inscriptions_parcours_from_groups() + + data = ( + 'Groupes enregistrés' + ) + response = make_response(data) + response.headers["Content-Type"] = scu.XML_MIMETYPE + return response + + +def create_group(partition_id, group_name="", default=False) -> GroupDescr: + """Create a new group in this partition. + If default, create default partition (with no name) + """ + partition = Partition.query.get_or_404(partition_id) + if not sco_permissions_check.can_change_groups(partition.formsemestre_id): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + # + if group_name: + group_name = group_name.strip() + if not group_name and not default: + raise ValueError("invalid group name: ()") + + if not GroupDescr.check_name(partition, group_name, default=default): + raise ScoValueError(f"Le groupe {group_name} existe déjà dans cette partition") + + numeros = [g.numero if g.numero is not None else 0 for g in partition.groups] + if len(numeros) > 0: + new_numero = max(numeros) + 1 + else: + new_numero = 0 + group = GroupDescr(partition=partition, group_name=group_name, numero=new_numero) + db.session.add(group) + db.session.commit() + log("create_group: created group_id={group.id}") + # + return group + + +def delete_group(group_id, partition_id=None): + """form suppression d'un groupe. + (ne desinscrit pas les etudiants, change juste leur + affectation aux groupes) + partition_id est optionnel et ne sert que pour verifier que le groupe + est bien dans cette partition. + S'il s'agit d'un groupe de parcours, affecte l'inscription des étudiants aux parcours. + """ + group = GroupDescr.query.get_or_404(group_id) + if partition_id: + if partition_id != group.partition_id: + raise ValueError("inconsistent partition/group") + if not sco_permissions_check.can_change_groups(group.partition.formsemestre_id): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + log(f"delete_group: group={group} partition={group.partition}") + formsemestre = group.partition.formsemestre + group_delete(group.id) + formsemestre.update_inscriptions_parcours_from_groups() + + +def partition_create( + formsemestre_id, + partition_name="", + default=False, + numero=None, + redirect=True, +): + """Create a new partition""" + if not sco_permissions_check.can_change_groups(formsemestre_id): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + if partition_name: + partition_name = str(partition_name).strip() + if default: + partition_name = None + if not partition_name and not default: + raise ScoValueError("Nom de partition invalide (vide)") + redirect = int(redirect) + # checkGroupName(partition_name) + if partition_name in [ + p["partition_name"] for p in get_partitions_list(formsemestre_id) + ]: + raise ScoValueError( + "Il existe déjà une partition %s dans ce semestre" % partition_name + ) + + cnx = ndb.GetDBConnexion() + if numero is None: + numero = ( + ndb.SimpleQuery( + "SELECT MAX(id) FROM partition WHERE formsemestre_id=%(formsemestre_id)s", + {"formsemestre_id": formsemestre_id}, + ).fetchone()[0] + or 0 + ) + partition_id = partitionEditor.create( + cnx, + { + "formsemestre_id": formsemestre_id, + "partition_name": partition_name, + "numero": numero, + }, + ) + log("createPartition: created partition_id=%s" % partition_id) + # + if redirect: + return flask.redirect( + url_for( + "scolar.edit_partition_form", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) + ) + else: + return partition_id + + +def get_arrow_icons_tags(): + """returns html tags for arrows""" + # + arrow_up = scu.icontag("arrow_up", title="remonter") + arrow_down = scu.icontag("arrow_down", title="descendre") + arrow_none = scu.icontag("arrow_none", title="") + + return arrow_up, arrow_down, arrow_none + + +def edit_partition_form(formsemestre_id=None): + """Form to create/suppress partitions""" + # ad-hoc form + if not sco_permissions_check.can_change_groups(formsemestre_id): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + partitions = get_partitions_list(formsemestre_id) + arrow_up, arrow_down, arrow_none = get_arrow_icons_tags() + suppricon = scu.icontag( + "delete_small_img", border="0", alt="supprimer", title="Supprimer" + ) + # + H = [ + html_sco_header.sco_header( + page_title="Partitions...", + javascripts=["js/edit_partition_form.js"], + ), + # limite à SHORT_STR_LEN + r""" + """, + r"""

Partitions du semestre

+
+
+ + """, + ] + i = 0 + for p in partitions: + if p["partition_name"] is not None: + H.append( + f""" + + + """ + ) + H.append("""""") + if p["groups_editable"]: + H.append( + f"""""" + ) + else: + H.append("""""") + # classement: + H.append('") + # + H.append("") + H.append("
PartitionGroupes
{suppricon} """ + ) + if i != 0: + H.append( + f"""{arrow_up}""" + ) + H.append('') + if i < len(partitions) - 2: + H.append( + f"""{arrow_down}""" + ) + i += 1 + H.append( + f"""{p["partition_name"] or ""}""" + ) + lg = [ + f"""{group["group_name"]} ({len(get_group_members(group["group_id"]))})""" + for group in get_partition_groups(p) + ] + H.append(", ".join(lg)) + H.append("""""") + H.append( + f"""répartirrenommernon éditable') + if p["bul_show_rank"]: + checked = 'checked="1"' + else: + checked = "" + H.append( + '
afficher rang sur bulletins
' + % (p["partition_id"], checked) + ) + if p["show_in_lists"]: + checked = 'checked="1"' + else: + checked = "" + H.append( + f"""
Afficher ces groupes sur les tableaux et bulletins
""" + ) + H.append("
") + H.append( + f"""
+ + + + + """ + ) + if formsemestre.formation.is_apc() and scu.PARTITION_PARCOURS not in ( + p["partition_name"] for p in partitions + ): + # propose création partition "Parcours" + H.append( + f""" + + """ + ) + H.append( + """ +
+
+ """ + ) + H.append( + """
+

Les partitions sont des découpages de l'ensemble des étudiants. + Par exemple, les "groupes de TD" sont une partition. + On peut créer autant de partitions que nécessaire. +

+
    +
  • Dans chaque partition, un nombre de groupes quelconque peuvent + être créés (suivre le lien "répartir"). +
  • On peut faire afficher le classement de l'étudiant dans son + groupe d'une partition en cochant "afficher rang sur bulletins" + (ainsi, on peut afficher le classement en groupes de TD mais pas en + groupe de TP, si ce sont deux partitions). +
  • +
  • Décocher "Afficher ces groupes sur les tableaux et bulletins" pour ne pas que cette partition + apparaisse dans les noms de groupes +
  • +
+
+ """ + ) + return "\n".join(H) + html_sco_header.sco_footer() + + +def partition_set_attr(partition_id, attr, value): + """Set partition attribute: bul_show_rank or show_in_lists""" + if attr not in {"bul_show_rank", "show_in_lists"}: + raise ValueError("invalid partition attribute: %s" % attr) + + partition = get_partition(partition_id) + formsemestre_id = partition["formsemestre_id"] + if not sco_permissions_check.can_change_groups(formsemestre_id): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + + log("partition_set_attr(%s, %s, %s)" % (partition_id, attr, value)) + value = int(value) + + cnx = ndb.GetDBConnexion() + partition[attr] = value + partitionEditor.edit(cnx, partition) + # invalid bulletin cache + sco_cache.invalidate_formsemestre(formsemestre_id=partition["formsemestre_id"]) + return "enregistré" + + +def partition_delete(partition_id, force=False, redirect=1, dialog_confirmed=False): + """Suppress a partition (and all groups within). + The default partition cannot be suppressed (unless force). + Si la partition de parcours est supprimée, les étudiants sont désinscrits des parcours. + """ + partition = get_partition(partition_id) + formsemestre_id = partition["formsemestre_id"] + if not sco_permissions_check.can_change_groups(formsemestre_id): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + + if not partition["partition_name"] and not force: + raise ValueError("cannot suppress this partition") + redirect = int(redirect) + cnx = ndb.GetDBConnexion() + groups = get_partition_groups(partition) + + if not dialog_confirmed: + if groups: + grnames = "(" + ", ".join([g["group_name"] or "" for g in groups]) + ")" + else: + grnames = "" + return scu.confirm_dialog( + """

Supprimer la partition "%s" ?

+

Les groupes %s de cette partition seront supprimés

+ """ + % (partition["partition_name"], grnames), + dest_url="", + cancel_url="edit_partition_form?formsemestre_id=%s" % formsemestre_id, + parameters={"redirect": redirect, "partition_id": partition_id}, + ) + + log("partition_delete: partition_id=%s" % partition_id) + # 1- groups + for group in groups: + group_delete(group["group_id"]) + # 2- partition + partitionEditor.delete(cnx, partition_id) + + formsemestre.update_inscriptions_parcours_from_groups() + + # redirect to partition edit page: + if redirect: + return flask.redirect( + "edit_partition_form?formsemestre_id=" + str(formsemestre_id) + ) + + +def partition_move(partition_id, after=0, redirect=1): + """Move before/after previous one (decrement/increment numero)""" + partition = get_partition(partition_id) + formsemestre_id = partition["formsemestre_id"] + if not sco_permissions_check.can_change_groups(formsemestre_id): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + # + redirect = int(redirect) + after = int(after) # 0: deplace avant, 1 deplace apres + if after not in (0, 1): + raise ValueError('invalid value for "after"') + others = get_partitions_list(formsemestre_id) + + objs = ( + Partition.query.filter_by(formsemestre_id=formsemestre_id) + .order_by(Partition.numero, Partition.partition_name) + .all() + ) + if len({o.numero for o in objs}) != len(objs): + # il y a des numeros identiques ! + scu.objects_renumber(db, objs) + + if len(others) > 1: + pidx = [p["partition_id"] for p in others].index(partition_id) + # log("partition_move: after=%s pidx=%s" % (after, pidx)) + neigh = None # partition to swap with + if after == 0 and pidx > 0: + neigh = others[pidx - 1] + elif after == 1 and pidx < len(others) - 1: + neigh = others[pidx + 1] + if neigh: # + # swap numero between partition and its neighbor + # log("moving partition %s" % partition_id) + cnx = ndb.GetDBConnexion() + # Si aucun numéro n'a été affecté, le met au minimum + min_numero = ( + ndb.SimpleQuery( + "SELECT MIN(numero) FROM partition WHERE formsemestre_id=%(formsemestre_id)s", + {"formsemestre_id": formsemestre_id}, + ).fetchone()[0] + or 0 + ) + if neigh["numero"] is None: + neigh["numero"] = min_numero - 1 + if partition["numero"] is None: + partition["numero"] = min_numero - 1 - after + partition["numero"], neigh["numero"] = neigh["numero"], partition["numero"] + partitionEditor.edit(cnx, partition) + partitionEditor.edit(cnx, neigh) + + # redirect to partition edit page: + if redirect: + return flask.redirect( + "edit_partition_form?formsemestre_id=" + str(formsemestre_id) + ) + + +def partition_rename(partition_id): + """Form to rename a partition""" + partition = get_partition(partition_id) + formsemestre_id = partition["formsemestre_id"] + if not sco_permissions_check.can_change_groups(formsemestre_id): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + H = ["

Renommer une partition

"] + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + ( + ("partition_id", {"default": partition_id, "input_type": "hidden"}), + ( + "partition_name", + { + "title": "Nouveau nom", + "default": partition["partition_name"], + "allow_null": False, + "size": 12, + "validator": lambda val, _: (len(val) < SHORT_STR_LEN) + and (val != scu.PARTITION_PARCOURS), + }, + ), + ), + submitlabel="Renommer", + cancelbutton="Annuler", + ) + if tf[0] == 0: + return ( + html_sco_header.sco_header() + + "\n".join(H) + + "\n" + + tf[1] + + html_sco_header.sco_footer() + ) + elif tf[0] == -1: + return flask.redirect( + "edit_partition_form?formsemestre_id=" + str(formsemestre_id) + ) + else: + # form submission + return partition_set_name(partition_id, tf[2]["partition_name"]) + + +def partition_set_name(partition_id, partition_name, redirect=1): + """Set partition name""" + partition_name = str(partition_name).strip() + if not partition_name: + raise ValueError("partition name must be non empty") + partition = get_partition(partition_id) + if partition["partition_name"] is None: + raise ValueError("can't set a name to default partition") + if partition_name == scu.PARTITION_PARCOURS: + raise ScoValueError(f"nom de partition {scu.PARTITION_PARCOURS} réservé.") + formsemestre_id = partition["formsemestre_id"] + + # check unicity + r = ndb.SimpleDictFetch( + """SELECT p.* FROM partition p + WHERE p.partition_name = %(partition_name)s + AND formsemestre_id = %(formsemestre_id)s + """, + {"partition_name": partition_name, "formsemestre_id": formsemestre_id}, + ) + if len(r) > 1 or (len(r) == 1 and r[0]["id"] != partition_id): + raise ScoValueError( + "Partition %s déjà existante dans ce semestre !" % partition_name + ) + + if not sco_permissions_check.can_change_groups(formsemestre_id): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + redirect = int(redirect) + cnx = ndb.GetDBConnexion() + partitionEditor.edit( + cnx, {"partition_id": partition_id, "partition_name": partition_name} + ) + + # redirect to partition edit page: + if redirect: + return flask.redirect( + "edit_partition_form?formsemestre_id=" + str(formsemestre_id) + ) + + +def group_set_name(group: GroupDescr, group_name: str, redirect=True): + """Set group name""" + if not sco_permissions_check.can_change_groups(group.partition.formsemestre.id): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + if group.group_name is None: + raise ValueError("can't set a name to default group") + destination = url_for( + "scolar.affect_groups", + scodoc_dept=g.scodoc_dept, + partition_id=group.partition_id, + ) + if group_name: + group_name = group_name.strip() + if not group_name: + raise ScoValueError("nom de groupe vide !", dest_url=destination) + if not GroupDescr.check_name(group.partition, group_name): + raise ScoValueError( + "Le nom de groupe existe déjà dans la partition", dest_url=destination + ) + + redirect = int(redirect) + group.group_name = group_name + db.session.add(group) + db.session.commit() + + # redirect to partition edit page: + if redirect: + return flask.redirect(destination) + + +def group_rename(group_id): + """Form to rename a group""" + group = GroupDescr.query.get_or_404(group_id) + formsemestre_id = group.partition.formsemestre_id + if not sco_permissions_check.can_change_groups(formsemestre_id): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + H = [f"

Renommer un groupe de {group.partition.partition_name or '-'}

"] + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + ( + ("group_id", {"default": group_id, "input_type": "hidden"}), + ( + "group_name", + { + "title": "Nouveau nom", + "default": group.group_name, + "size": 12, + "allow_null": False, + "validator": lambda val, _: len(val) < GROUPNAME_STR_LEN, + }, + ), + ), + submitlabel="Renommer", + cancelbutton="Annuler", + ) + if tf[0] == 0: + return ( + html_sco_header.sco_header() + + "\n".join(H) + + "\n" + + tf[1] + + html_sco_header.sco_footer() + ) + elif tf[0] == -1: + return flask.redirect( + url_for( + "scolar.affect_groups", + scodoc_dept=g.scodoc_dept, + partition_id=group.partition_id, + ) + ) + else: + # form submission + return group_set_name(group, tf[2]["group_name"]) + + +def groups_auto_repartition(partition_id=None): + """Reparti les etudiants dans des groupes dans une partition, en respectant le niveau + et la mixité. + """ + from app.scodoc import sco_formsemestre + + partition = get_partition(partition_id) + if not partition["groups_editable"]: + raise AccessDenied("Partition non éditable") + formsemestre_id = partition["formsemestre_id"] + formsemestre = FormSemestre.query.get(formsemestre_id) + # renvoie sur page édition groupes + dest_url = url_for( + "scolar.affect_groups", scodoc_dept=g.scodoc_dept, partition_id=partition_id + ) + if not sco_permissions_check.can_change_groups(formsemestre_id): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + + descr = [ + ("partition_id", {"input_type": "hidden"}), + ( + "groupNames", + { + "size": 40, + "title": "Groupes à créer", + "allow_null": False, + "explanation": "noms des groupes à former, séparés par des virgules (les groupes existants seront effacés)", + }, + ), + ] + + H = [ + html_sco_header.sco_header(page_title="Répartition des groupes"), + "

Répartition des groupes de %s

" % partition["partition_name"], + f"

Semestre {formsemestre.titre_annee()}

", + """

Les groupes existants seront effacés et remplacés par + ceux créés ici. La répartition aléatoire tente d'uniformiser le niveau + des groupes (en utilisant la dernière moyenne générale disponible pour + chaque étudiant) et de maximiser la mixité de chaque groupe.

""", + ] + + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + descr, + {}, + cancelbutton="Annuler", + method="GET", + submitlabel="Créer et peupler les groupes", + name="tf", + ) + if tf[0] == 0: + return "\n".join(H) + "\n" + tf[1] + html_sco_header.sco_footer() + elif tf[0] == -1: + return flask.redirect(dest_url) + else: + # form submission + log( + "groups_auto_repartition( partition_id=%s partition_name=%s" + % (partition_id, partition["partition_name"]) + ) + groupNames = tf[2]["groupNames"] + group_names = sorted(set([x.strip() for x in groupNames.split(",")])) + # Détruit les groupes existant de cette partition + for old_group in get_partition_groups(partition): + group_delete(old_group["group_id"]) + # Crée les nouveaux groupes + group_ids = [] + for group_name in group_names: + if group_name.strip(): + group_ids.append(create_group(partition_id, group_name).id) + # + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + identdict = nt.identdict + # build: { civilite : liste etudids trie par niveau croissant } + civilites = set([x["civilite"] for x in identdict.values()]) + listes = {} + for civilite in civilites: + listes[civilite] = [ + (_get_prev_moy(x["etudid"], formsemestre_id), x["etudid"]) + for x in identdict.values() + if x["civilite"] == civilite + ] + listes[civilite].sort() + log("listes[%s] = %s" % (civilite, listes[civilite])) + # affect aux groupes: + n = len(identdict) + igroup = 0 + nbgroups = len(group_ids) + while n > 0: + for civilite in civilites: + if len(listes[civilite]): + n -= 1 + etudid = listes[civilite].pop()[1] + group_id = group_ids[igroup] + igroup = (igroup + 1) % nbgroups + change_etud_group_in_partition(etudid, group_id, partition) + log("%s in group %s" % (etudid, group_id)) + return flask.redirect(dest_url) + + +def _get_prev_moy(etudid, formsemestre_id): + """Donne la derniere moyenne generale calculee pour cette étudiant, + ou 0 si on n'en trouve pas (nouvel inscrit,...). + """ + from app.scodoc import sco_cursus_dut + + info = sco_etud.get_etud_info(etudid=etudid, filled=True) + if not info: + raise ScoValueError("etudiant invalide: etudid=%s" % etudid) + etud = info[0] + Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id) + if Se.prev: + prev_sem = FormSemestre.query.get(Se.prev["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(prev_sem) + return nt.get_etud_moy_gen(etudid) + else: + return 0.0 + + +def create_etapes_partition(formsemestre_id, partition_name="apo_etapes"): + """Crée une partition "apo_etapes" avec un groupe par étape Apogée. + Cette partition n'est crée que si plusieurs étapes différentes existent dans ce + semestre. + Si la partition existe déjà, ses groupes sont mis à jour (les groupes devenant + vides ne sont pas supprimés). + """ + from app.scodoc import sco_formsemestre_inscriptions + + partition_name = str(partition_name) + log("create_etapes_partition(%s)" % formsemestre_id) + ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( + args={"formsemestre_id": formsemestre_id} + ) + etapes = {i["etape"] for i in ins if i["etape"]} + partitions = get_partitions_list(formsemestre_id, with_default=False) + partition = None + for p in partitions: + if p["partition_name"] == partition_name: + partition = p + break + if len(etapes) < 2 and not partition: + return # moins de deux étapes, pas de création + if partition: + pid = partition["partition_id"] + else: + pid = partition_create( + formsemestre_id, partition_name=partition_name, redirect=False + ) + partition = get_partition(pid) + groups = get_partition_groups(partition) + groups_by_names = {g["group_name"]: g for g in groups} + for etape in etapes: + if not (etape in groups_by_names): + new_group = create_group(pid, etape) + g = get_group(new_group.id) # XXX transition: recupere old style dict + groups_by_names[etape] = g + # Place les etudiants dans les groupes + for i in ins: + if i["etape"]: + change_etud_group_in_partition( + i["etudid"], groups_by_names[i["etape"]]["group_id"], partition + ) + + +def do_evaluation_listeetuds_groups( + evaluation_id, groups=None, getallstudents=False, include_demdef=False +): + """Donne la liste des etudids inscrits a cette evaluation dans les + groupes indiqués. + Si getallstudents==True, donne tous les etudiants inscrits a cette + evaluation. + Si include_demdef, compte aussi les etudiants démissionnaires et défaillants + (sinon, par défaut, seulement les 'I') + + Résultat: [ (etudid, etat) ], où etat='I', 'D', 'DEF' + """ + # nb: pour notes_table / do_evaluation_etat, getallstudents est vrai et + # include_demdef faux + fromtables = [ + "notes_moduleimpl_inscription Im", + "notes_formsemestre_inscription Isem", + "notes_moduleimpl M", + "notes_evaluation E", + ] + # construit condition sur les groupes + if not getallstudents: + if not groups: + return [] # no groups, so no students + rg = ["gm.group_id = '%(group_id)s'" % g for g in groups] + rq = """and Isem.etudid = gm.etudid + and gd.partition_id = p.id + and p.formsemestre_id = Isem.formsemestre_id + """ + r = rq + " AND (" + " or ".join(rg) + " )" + fromtables += ["group_membership gm", "group_descr gd", "partition p"] + else: + r = "" + + # requete complete + req = ( + "SELECT distinct Im.etudid, Isem.etat FROM " + + ", ".join(fromtables) + + """ WHERE Isem.etudid = Im.etudid + and Im.moduleimpl_id = M.id + and Isem.formsemestre_id = M.formsemestre_id + and E.moduleimpl_id = M.id + and E.id = %(evaluation_id)s + """ + ) + if not include_demdef: + req += " and Isem.etat='I'" + req += r + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor() + cursor.execute(req, {"evaluation_id": evaluation_id}) + return cursor.fetchall() + + +def do_evaluation_listegroupes(evaluation_id, include_default=False): + """Donne la liste des groupes dans lesquels figurent des etudiants inscrits + au module/semestre auquel appartient cette evaluation. + Si include_default, inclue aussi le groupe par defaut ('tous') + [ group ] + """ + if include_default: + c = "" + else: + c = " AND p.partition_name is not NULL" + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor() + cursor.execute( + """SELECT DISTINCT gd.id AS group_id + FROM group_descr gd, group_membership gm, partition p, + notes_moduleimpl m, notes_evaluation e + WHERE gm.group_id = gd.id + and gd.partition_id = p.id + and p.formsemestre_id = m.formsemestre_id + and m.id = e.moduleimpl_id + and e.id = %(evaluation_id)s + """ + + c, + {"evaluation_id": evaluation_id}, + ) + group_ids = [x[0] for x in cursor] + return listgroups(group_ids) + + +def listgroups(group_ids): + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + groups = [] + for group_id in group_ids: + cursor.execute( + """SELECT gd.id AS group_id, gd.*, p.id AS partition_id, p.* + FROM group_descr gd, partition p + WHERE p.id = gd.partition_id + AND gd.id = %(group_id)s + """, + {"group_id": group_id}, + ) + r = cursor.dictfetchall() + if r: + groups.append(r[0]) + return _sortgroups(groups) + + +def _sortgroups(groups): + # Tri: place 'all' en tête, puis groupe par partition / nom de groupe + R = [g for g in groups if g["partition_name"] is None] + o = [g for g in groups if g["partition_name"] != None] + o.sort(key=lambda x: (x["numero"] or 0, x["group_name"])) + + return R + o + + +def listgroups_filename(groups): + """Build a filename representing groups""" + return "gr" + "+".join([g["group_name"] or "tous" for g in groups]) + + +def listgroups_abbrev(groups): + """Human readable abbreviation descring groups (eg "A / AB / B3") + Ne retient que les partitions avec show_in_lists + """ + return " / ".join( + [g["group_name"] for g in groups if g["group_name"] and g["show_in_lists"]] + ) + + +# form_group_choice replaces formChoixGroupe +def form_group_choice( + formsemestre_id, + allow_none=True, # offre un choix vide dans chaque partition + select_default=True, # Le groupe par defaut est mentionné (hidden). + display_sem_title=False, +): + """Partie de formulaire pour le choix d'un ou plusieurs groupes. + Variable : group_ids + """ + from app.scodoc import sco_formsemestre + + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + if display_sem_title: + sem_title = "%s: " % sem["titremois"] + else: + sem_title = "" + # + H = [""""""] + for p in get_partitions_list(formsemestre_id): + if p["partition_name"] is None: + if select_default: + H.append( + '' + % get_partition_groups(p)[0]["group_id"] + ) + else: + H.append("") + H.append("""
Groupe de %(partition_name)s" % p) + H.append('
""") + return "\n".join(H) + + +def make_query_groups(group_ids): + if group_ids: + return "&".join(["group_ids%3Alist=" + str(group_id) for group_id in group_ids]) + else: + return "" + + +class GroupIdInferer(object): + """Sert à retrouver l'id d'un groupe dans un semestre donné + à partir de son nom. + Attention: il peut y avoir plusieurs groupes de même nom + dans des partitions différentes. Dans ce cas, prend le dernier listé. + On peut indiquer la partition en écrivant + partition_name:group_name + """ + + def __init__(self, formsemestre_id): + groups = get_sem_groups(formsemestre_id) + self.name2group_id = {} + self.partitionname2group_id = {} + for group in groups: + self.name2group_id[group["group_name"]] = group["group_id"] + self.partitionname2group_id[ + (group["partition_name"], group["group_name"]) + ] = group["group_id"] + + def __getitem__(self, name): + """Get group_id from group_name, or None is nonexistent. + The group name can be prefixed by the partition's name, using + syntax partition_name:group_name + """ + l = name.split(":", 1) + if len(l) > 1: + partition_name, group_name = l + else: + partition_name = None + group_name = name + if partition_name is None: + group_id = self.name2group_id.get(group_name, None) + if group_id is None and name[-2:] == ".0": + # si nom groupe numerique, excel ajoute parfois ".0" ! + group_name = group_name[:-2] + group_id = self.name2group_id.get(group_name, None) + else: + group_id = self.partitionname2group_id.get( + (partition_name, group_name), None + ) + return group_id diff --git a/app/scodoc/sco_groups_view.py b/app/scodoc/sco_groups_view.py index 09f4b529..ad59d674 100644 --- a/app/scodoc/sco_groups_view.py +++ b/app/scodoc/sco_groups_view.py @@ -1,981 +1,981 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -"""Affichage étudiants d'un ou plusieurs groupes - sous forme: de liste html (table exportable), de trombinoscope (exportable en pdf) -""" - -# Re-ecriture en 2014 (re-organisation de l'interface, modernisation du code) - -import collections -import datetime -import operator -import urllib -from urllib.parse import parse_qs -import time - - -from flask import url_for, g, request -from flask_login import current_user - -import app.scodoc.sco_utils as scu -from app.scodoc import html_sco_header -from app.scodoc import sco_abs -from app.scodoc import sco_excel -from app.scodoc import sco_formsemestre -from app.scodoc import sco_groups -from app.scodoc import sco_moduleimpl -from app.scodoc import sco_cursus -from app.scodoc import sco_portal_apogee -from app.scodoc import sco_preferences -from app.scodoc import sco_etud -from app.scodoc.sco_etud import etud_sort_key -from app.scodoc.gen_tables import GenTable -from app.scodoc.sco_exceptions import ScoValueError -from app.scodoc.sco_permissions import Permission - -JAVASCRIPTS = html_sco_header.BOOTSTRAP_MULTISELECT_JS + [ - "js/etud_info.js", - "js/groups_view.js", -] - -CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS - -# view: -def groups_view( - group_ids=(), - format="html", - # Options pour listes: - with_codes=0, - etat=None, - with_paiement=0, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail) - with_archives=0, # ajoute colonne avec noms fichiers archivés - with_annotations=0, - formsemestre_id=None, # utilise si aucun groupe selectionné -): - """Affichage des étudiants des groupes indiqués - group_ids: liste de group_id - format: csv, json, xml, xls, allxls, xlsappel, moodlecsv, pdf - """ - # Informations sur les groupes à afficher: - groups_infos = DisplayedGroupsInfos( - group_ids, - formsemestre_id=formsemestre_id, - etat=etat, - select_all_when_unspecified=True, - ) - # Formats spéciaux: download direct - if format != "html": - return groups_table( - groups_infos=groups_infos, - format=format, - with_codes=with_codes, - etat=etat, - with_paiement=with_paiement, - with_archives=with_archives, - with_annotations=with_annotations, - ) - - H = [ - html_sco_header.sco_header( - javascripts=JAVASCRIPTS, - cssstyles=CSSSTYLES, - init_qtip=True, - ) - ] - # Menu choix groupe - H.append("""
""") - H.append(form_groups_choice(groups_infos, submit_on_change=True)) - # Note: le formulaire est soumis a chaque modif des groupes - # on pourrait faire comme pour le form de saisie des notes. Il faudrait pour cela: - # - charger tous les etudiants au debut, quels que soient les groupes selectionnés - # - ajouter du JS pour modifier les liens (arguments group_ids) quand le menu change - - # Tabs - # H.extend( ("""toto
  • item 1
  • item 2
""",) ) - H.extend( - ( - """ -
- -
-
- """, - groups_table( - groups_infos=groups_infos, - format=format, - with_codes=with_codes, - etat=etat, - with_paiement=with_paiement, - with_archives=with_archives, - with_annotations=with_annotations, - ), - "
", - """
""", - tab_photos_html(groups_infos, etat=etat), - #'

hello

', - "
", - '
', - tab_absences_html(groups_infos, etat=etat), - "
", - ) - ) - - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - -def form_groups_choice(groups_infos, with_selectall_butt=False, submit_on_change=False): - """form pour selection groupes - group_ids est la liste des groupes actuellement sélectionnés - et doit comporter au moins un élément, sauf si formsemestre_id est spécifié. - (utilisé pour retrouver le semestre et proposer la liste des autres groupes) - - Si submit_on_change, ajoute une classe "submit_on_change" qui est utilisee en JS - """ - default_group_id = sco_groups.get_default_group(groups_infos.formsemestre_id) - - H = [ - """
- - - Groupes: - """ - % (groups_infos.formsemestre_id, default_group_id) - ] - - H.append(menu_groups_choice(groups_infos, submit_on_change=submit_on_change)) - - if with_selectall_butt: - H.append( - """""" - ) - H.append("
") - - return "\n".join(H) - - -def menu_groups_choice(groups_infos, submit_on_change=False): - """menu pour selection groupes - group_ids est la liste des groupes actuellement sélectionnés - et doit comporter au moins un élément, sauf si formsemestre_id est spécifié. - (utilisé pour retrouver le semestre et proposer la liste des autres groupes) - """ - default_group_id = sco_groups.get_default_group(groups_infos.formsemestre_id) - - if submit_on_change: - klass = "submit_on_change" - else: - klass = "" - H = [ - """ ") - return "\n".join(H) - - -def menu_group_choice(group_id=None, formsemestre_id=None): - """Un bête menu pour choisir un seul groupe - group_id est le groupe actuellement sélectionné. - Si aucun groupe selectionné, utilise formsemestre_id pour lister les groupes. - """ - if group_id: - group = sco_groups.get_group(group_id) - formsemestre_id = group["formsemestre_id"] - elif not formsemestre_id: - raise ValueError("missing formsemestre_id") - H = [ - """ - - - """ - ) - return "\n".join(H) - - -class DisplayedGroupsInfos(object): - """Container with attributes describing groups to display in the page - .groups_query_args : 'group_ids=xxx&group_ids=yyy' - .base_url : url de la requete, avec les groupes, sans les autres paramètres - .formsemestre_id : semestre "principal" (en fait celui du 1er groupe de la liste) - .members - .groups_titles - """ - - def __init__( - self, - group_ids=(), # groupes specifies dans l'URL, ou un seul int - formsemestre_id=None, - etat=None, - select_all_when_unspecified=False, - moduleimpl_id=None, # used to find formsemestre when unspecified - ): - if isinstance(group_ids, int): - if group_ids: - group_ids = [group_ids] # cas ou un seul parametre, pas de liste - else: - try: - group_ids = [int(g) for g in group_ids] - except ValueError as exc: - raise ScoValueError( - "identifiant de groupe invalide (mettre à jour vos bookmarks ?)" - ) from exc - if not formsemestre_id and moduleimpl_id: - mods = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id) - if len(mods) != 1: - raise ValueError("invalid moduleimpl_id") - formsemestre_id = mods[0]["formsemestre_id"] - - if not group_ids: # appel sans groupe (eg page accueil) - if not formsemestre_id: - raise Exception("missing parameter formsemestre_id or group_ids") - if select_all_when_unspecified: - group_ids = [sco_groups.get_default_group(formsemestre_id)] - else: - # selectionne le premier groupe trouvé, s'il y en a un - partition = sco_groups.get_partitions_list( - formsemestre_id, with_default=True - )[0] - groups = sco_groups.get_partition_groups(partition) - if groups: - group_ids = [groups[0]["group_id"]] - else: - group_ids = [sco_groups.get_default_group(formsemestre_id)] - - gq = [] - for group_id in group_ids: - gq.append("group_ids=" + str(group_id)) - self.groups_query_args = "&".join(gq) - self.base_url = request.base_url + "?" + self.groups_query_args - self.group_ids = group_ids - self.groups = [] - groups_titles = [] - self.members = [] - self.tous_les_etuds_du_sem = ( - False # affiche tous les etuds du semestre ? (si un seul semestre) - ) - self.sems = collections.OrderedDict() # formsemestre_id : sem - self.formsemestre = None - self.formsemestre_id = formsemestre_id - self.nbdem = 0 # nombre d'étudiants démissionnaires en tout - sem = None - selected_partitions = set() - for group_id in group_ids: - group_members, group, group_tit, sem, nbdem = sco_groups.get_group_infos( - group_id, etat=etat - ) - self.groups.append(group) - self.nbdem += nbdem - self.sems[sem["formsemestre_id"]] = sem - if not self.formsemestre_id: - self.formsemestre_id = sem["formsemestre_id"] - self.formsemestre = sem - self.members.extend(group_members) - groups_titles.append(group_tit) - if group["group_name"] == None: - self.tous_les_etuds_du_sem = True - else: - # liste les partitions explicitement sélectionnés (= des groupes de group_ids) - selected_partitions.add((group["numero"], group["partition_id"])) - - self.selected_partitions = [ - x[1] for x in sorted(list(selected_partitions)) - ] # -> [ partition_id ] - - if not self.formsemestre: # aucun groupe selectionne - self.formsemestre = sco_formsemestre.get_formsemestre(formsemestre_id) - - self.sortuniq() - - if len(self.sems) > 1: - self.tous_les_etuds_du_sem = False # plusieurs semestres - if self.tous_les_etuds_du_sem: - if sem and sem["semestre_id"] >= 0: - self.groups_titles = "S%d" % sem["semestre_id"] - else: - self.groups_titles = "tous" - self.groups_filename = self.groups_titles - else: - self.groups_titles = ", ".join(groups_titles) - self.groups_filename = "_".join(groups_titles).replace(" ", "_") - # Sanitize filename: - self.groups_filename = scu.make_filename(self.groups_filename) - - # colonnes pour affichages nom des groupes: - # gère le cas où les étudiants appartiennent à des semestres différents - self.partitions = [] # les partitions, sans celle par defaut - for formsemestre_id in self.sems: - for partition in sco_groups.get_partitions_list(formsemestre_id): - if partition["partition_name"]: - self.partitions.append(partition) - - def sortuniq(self): - "Trie les étudiants (de plusieurs groupes) et élimine les doublons" - if (len(self.group_ids) <= 1) or len(self.members) <= 1: - return # on suppose que les etudiants d'un groupe sont deja triés - # tri selon nom_usuel ou nom, sans accents - self.members.sort(key=etud_sort_key) - to_remove = [] - T = self.members - for i in range(len(T) - 1, 0, -1): - if T[i - 1]["etudid"] == T[i]["etudid"]: - to_remove.append(i) - for i in to_remove: - del T[i] - - def get_form_elem(self): - """html hidden input with groups""" - H = [] - for group_id in self.group_ids: - H.append('' % group_id) - return "\n".join(H) - - -# Ancien ZScolar.group_list renommé ici en group_table -def groups_table( - groups_infos: DisplayedGroupsInfos = None, - with_codes=0, - etat=None, - format="html", - with_paiement=0, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail) - with_archives=0, # ajoute colonne avec noms fichiers archivés - with_annotations=0, -): - """liste etudiants inscrits dans ce semestre - format: csv, json, xml, xls, allxls, xlsappel, moodlecsv, pdf - Si with_codes, ajoute 4 colonnes avec les codes etudid, NIP, INE et etape - """ - from app.scodoc import sco_report - - # log( - # "enter groups_table %s: %s" - # % (groups_infos.members[0]["nom"], groups_infos.members[0].get("etape", "-")) - # ) - with_codes = int(with_codes) - with_paiement = int(with_paiement) - with_archives = int(with_archives) - with_annotations = int(with_annotations) - - base_url_np = groups_infos.base_url + "&with_codes=%s" % with_codes - base_url = ( - base_url_np - + "&with_paiement=%s&with_archives=%s&with_annotations=%s" - % (with_paiement, with_archives, with_annotations) - ) - # - columns_ids = ["civilite_str", "nom_disp", "prenom"] # colonnes a inclure - titles = { - "civilite_str": "Civ.", - "nom_disp": "Nom", - "prenom": "Prénom", - "email": "Mail", - "emailperso": "Personnel", - "etat": "Etat", - "etudid": "etudid", - "code_nip": "code_nip", - "code_ine": "code_ine", - "datefinalisationinscription_str": "Finalisation inscr.", - "paiementinscription_str": "Paiement", - "etudarchive": "Fichiers", - "annotations_str": "Annotations", - "etape": "Etape", - "semestre_groupe": "Semestre-Groupe", # pour Moodle - } - - # ajoute colonnes pour groupes - columns_ids.extend([p["partition_id"] for p in groups_infos.partitions]) - titles.update( - dict( - [(p["partition_id"], p["partition_name"]) for p in groups_infos.partitions] - ) - ) - partitions_name = { - p["partition_id"]: p["partition_name"] for p in groups_infos.partitions - } - - if format != "html": # ne mentionne l'état que en Excel (style en html) - columns_ids.append("etat") - columns_ids.append("email") - columns_ids.append("emailperso") - - if format == "moodlecsv": - columns_ids = ["email", "semestre_groupe"] - - if with_codes: - columns_ids += ["etape", "etudid", "code_nip", "code_ine"] - if with_paiement: - columns_ids += ["datefinalisationinscription_str", "paiementinscription_str"] - if with_paiement: # or with_codes: - sco_portal_apogee.check_paiement_etuds(groups_infos.members) - if with_archives: - from app.scodoc import sco_archives_etud - - sco_archives_etud.add_archives_info_to_etud_list(groups_infos.members) - columns_ids += ["etudarchive"] - if with_annotations: - sco_etud.add_annotations_to_etud_list(groups_infos.members) - columns_ids += ["annotations_str"] - moodle_sem_name = groups_infos.formsemestre["session_id"] - moodle_groupenames = set() - # ajoute liens - for etud in groups_infos.members: - if etud["email"]: - etud["_email_target"] = "mailto:" + etud["email"] - else: - etud["_email_target"] = "" - if etud["emailperso"]: - etud["_emailperso_target"] = "mailto:" + etud["emailperso"] - else: - etud["_emailperso_target"] = "" - fiche_url = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] - ) - etud["_nom_disp_target"] = fiche_url - etud["_nom_disp_order"] = etud_sort_key(etud) - etud["_prenom_target"] = fiche_url - - etud["_nom_disp_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"]) - - if etud["etat"] == "D": - etud["_css_row_class"] = "etuddem" - # et groupes: - for partition_id in etud["partitions"]: - etud[partition_id] = etud["partitions"][partition_id]["group_name"] - # Ajoute colonne pour moodle: semestre_groupe, de la forme - # RT-DUT-FI-S3-2021-PARTITION-GROUPE - moodle_groupename = [] - if groups_infos.selected_partitions: - # il y a des groupes selectionnes, utilise leurs partitions - for partition_id in groups_infos.selected_partitions: - if partition_id in etud["partitions"]: - moodle_groupename.append( - partitions_name[partition_id] - + "-" - + etud["partitions"][partition_id]["group_name"] - ) - else: - # pas de groupes sélectionnés: prend le premier s'il y en a un - moodle_groupename = ["tous"] - if etud["partitions"]: - for p in etud["partitions"].items(): # partitions is an OrderedDict - moodle_groupename = [ - partitions_name[p[0]] + "-" + p[1]["group_name"] - ] - break - - moodle_groupenames |= set(moodle_groupename) - etud["semestre_groupe"] = moodle_sem_name + "-" + "+".join(moodle_groupename) - - if groups_infos.nbdem > 1: - s = "s" - else: - s = "" - - if format == "moodlecsv": - # de la forme S1-[FI][FA]-groupe.csv - if not moodle_groupenames: - moodle_groupenames = {"tous"} - filename = ( - moodle_sem_name - + "-" - + groups_infos.formsemestre["modalite"] - + "-" - + "+".join(sorted(moodle_groupenames)) - ) - else: - filename = "etudiants_%s" % groups_infos.groups_filename - - prefs = sco_preferences.SemPreferences(groups_infos.formsemestre_id) - tab = GenTable( - rows=groups_infos.members, - columns_ids=columns_ids, - titles=titles, - caption="soit %d étudiants inscrits et %d démissionaire%s." - % (len(groups_infos.members) - groups_infos.nbdem, groups_infos.nbdem, s), - base_url=base_url, - filename=filename, - pdf_link=False, # pas d'export pdf - html_sortable=True, - html_class="table_leftalign table_listegroupe", - xml_outer_tag="group_list", - xml_row_tag="etud", - text_fields_separator=prefs["moodle_csv_separator"], - text_with_titles=prefs["moodle_csv_with_headerline"], - preferences=prefs, - ) - # - if format == "html": - amail_inst = [ - x["email"] for x in groups_infos.members if x["email"] and x["etat"] != "D" - ] - amail_perso = [ - x["emailperso"] - for x in groups_infos.members - if x["emailperso"] and x["etat"] != "D" - ] - - if len(groups_infos.members): - if groups_infos.tous_les_etuds_du_sem: - htitle = "Les %d étudiants inscrits" % len(groups_infos.members) - else: - htitle = "Groupe %s (%d étudiants)" % ( - groups_infos.groups_titles, - len(groups_infos.members), - ) - else: - htitle = "Aucun étudiant !" - H = [ - '
' '

', - htitle, - "", - ] - if groups_infos.members: - Of = [] - options = { - "with_paiement": "Paiement inscription", - "with_archives": "Fichiers archivés", - "with_annotations": "Annotations", - "with_codes": "Codes", - } - for option in options: - if locals().get(option, False): - selected = "selected" - else: - selected = "" - Of.append( - """""" - % (option, selected, options[option]) - ) - - H.extend( - [ - """ - - """, - ] - ) - H.append("

") - if groups_infos.members: - H.extend( - [ - tab.html(), - "") - - return "".join(H) + "
" - - elif ( - format == "pdf" - or format == "xml" - or format == "json" - or format == "xls" - or format == "moodlecsv" - ): - if format == "moodlecsv": - format = "csv" - return tab.make_page(format=format) - - elif format == "xlsappel": - xls = sco_excel.excel_feuille_listeappel( - groups_infos.formsemestre, - groups_infos.groups_titles, - groups_infos.members, - partitions=groups_infos.partitions, - with_codes=with_codes, - with_paiement=with_paiement, - server_name=request.url_root, - ) - filename = "liste_%s" % groups_infos.groups_filename - return scu.send_file(xls, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE) - elif format == "allxls": - # feuille Excel avec toutes les infos etudiants - if not groups_infos.members: - return "" - keys = [ - "etudid", - "code_nip", - "etat", - "civilite_str", - "nom", - "nom_usuel", - "prenom", - "inscriptionstr", - ] - if with_paiement: - keys.append("paiementinscription") - keys += [ - "email", - "emailperso", - "domicile", - "villedomicile", - "codepostaldomicile", - "paysdomicile", - "telephone", - "telephonemobile", - "fax", - "date_naissance", - "lieu_naissance", - "bac", - "specialite", - "annee_bac", - "nomlycee", - "villelycee", - "codepostallycee", - "codelycee", - "type_admission", - "boursier_prec", - "debouche", - "parcours", - "codeparcours", - ] - titles = keys[:] - other_partitions = sco_groups.get_group_other_partitions(groups_infos.groups[0]) - keys += [p["partition_id"] for p in other_partitions] - titles += [p["partition_name"] for p in other_partitions] - # remplis infos lycee si on a que le code lycée - # et ajoute infos inscription - for m in groups_infos.members: - etud = sco_etud.get_etud_info(m["etudid"], filled=True)[0] - m.update(etud) - sco_etud.etud_add_lycee_infos(etud) - # et ajoute le parcours - Se = sco_cursus.get_situation_etud_cursus( - etud, groups_infos.formsemestre_id - ) - m["parcours"] = Se.get_parcours_descr() - m["codeparcours"], _ = sco_report.get_codeparcoursetud(etud) - - L = [[m.get(k, "") for k in keys] for m in groups_infos.members] - title = "etudiants_%s" % groups_infos.groups_filename - xls = sco_excel.excel_simple_table(titles=titles, lines=L, sheet_name=title) - filename = title - return scu.send_file(xls, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE) - else: - raise ValueError("unsupported format") - - -def tab_absences_html(groups_infos, etat=None): - """contenu du tab "absences et feuilles diverses" """ - authuser = current_user - H = ['
'] - if not groups_infos.members: - return "".join(H) + "

Aucun étudiant !

" - H.extend( - [ - "

Absences

", - '
    ', - "
  • ", - form_choix_saisie_semaine(groups_infos), # Ajout Le Havre - "
  • ", - "
  • ", - form_choix_jour_saisie_hebdo(groups_infos), - "
  • ", - """
  • État des absences du groupe
  • """ - % ( - groups_infos.groups_query_args, - groups_infos.formsemestre["date_debut"], - groups_infos.formsemestre["date_fin"], - ), - "
", - "

Feuilles

", - '", - ] - ) - - H.append('

Opérations diverses

    ') - # Lien pour verif codes INE/NIP - # (pour tous les etudiants du semestre) - group_id = sco_groups.get_default_group(groups_infos.formsemestre_id) - if authuser.has_permission(Permission.ScoEtudInscrit): - H.append( - '
  • Vérifier codes Apogée (de tous les groupes)
  • ' - % (group_id, etat or "") - ) - # Lien pour ajout fichiers étudiants - if authuser.has_permission(Permission.ScoEtudAddAnnotations): - H.append( - """
  • Télécharger des fichiers associés aux étudiants (e.g. dossiers d'admission)
  • """ - % (group_id) - ) - - H.append("
") - return "".join(H) - - -def tab_photos_html(groups_infos, etat=None): - """contenu du tab "photos" """ - from app.scodoc import sco_trombino - - if not groups_infos.members: - return '

Aucun étudiant !

' - - return sco_trombino.trombino_html(groups_infos) - - -def form_choix_jour_saisie_hebdo(groups_infos, moduleimpl_id=None): - """Formulaire choix jour semaine pour saisie.""" - authuser = current_user - if not authuser.has_permission(Permission.ScoAbsChange): - return "" - sem = groups_infos.formsemestre - first_monday = sco_abs.ddmmyyyy(sem["date_debut"]).prev_monday() - today_idx = datetime.date.today().weekday() - - FA = [] # formulaire avec menu saisi absences - FA.append( - '
' - ) - FA.append('' % sem) - FA.append(groups_infos.get_form_elem()) - if moduleimpl_id: - FA.append( - '' % moduleimpl_id - ) - FA.append('') - - FA.append( - """""" - ) - FA.append("""") - FA.append("
") - return "\n".join(FA) - - -# Ajout Le Havre -# Formulaire saisie absences semaine -def form_choix_saisie_semaine(groups_infos): - authuser = current_user - if not authuser.has_permission(Permission.ScoAbsChange): - return "" - # construit l'URL "destination" - # (a laquelle on revient apres saisie absences) - query_args = parse_qs(request.query_string) - moduleimpl_id = query_args.get("moduleimpl_id", [""])[0] - if "head_message" in query_args: - del query_args["head_message"] - destination = "%s?%s" % ( - request.base_url, - urllib.parse.urlencode(query_args, True), - ) - destination = destination.replace( - "%", "%%" - ) # car ici utilisee dans un format string ! - - DateJour = time.strftime("%d/%m/%Y") - datelundi = sco_abs.ddmmyyyy(DateJour).prev_monday() - FA = [] # formulaire avec menu saisie hebdo des absences - FA.append('
') - FA.append('' % datelundi) - FA.append('' % moduleimpl_id) - FA.append('' % destination) - FA.append(groups_infos.get_form_elem()) - FA.append('') - FA.append("
") - return "\n".join(FA) - - -def export_groups_as_moodle_csv(formsemestre_id=None): - """Export all students/groups, in a CSV format suitable for Moodle - Each (student,group) will be listed on a separate line - jo@univ.fr,S3-A - jo@univ.fr,S3-B1 - if jo belongs to group A in a partition, and B1 in another one. - Caution: if groups in different partitions share the same name, there will be - duplicates... should we prefix the group names with the partition's name ? - """ - if not formsemestre_id: - raise ScoValueError("missing parameter: formsemestre_id") - _, partitions_etud_groups = sco_groups.get_formsemestre_groups( - formsemestre_id, with_default=True - ) - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - moodle_sem_name = sem["session_id"] - - columns_ids = ("email", "semestre_groupe") - T = [] - for partition_id in partitions_etud_groups: - partition = sco_groups.get_partition(partition_id) - members = partitions_etud_groups[partition_id] - for etudid in members: - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - group_name = members[etudid]["group_name"] - elts = [moodle_sem_name] - if partition["partition_name"]: - elts.append(partition["partition_name"]) - if group_name: - elts.append(group_name) - T.append({"email": etud["email"], "semestre_groupe": "-".join(elts)}) - # Make table - prefs = sco_preferences.SemPreferences(formsemestre_id) - tab = GenTable( - rows=T, - columns_ids=("email", "semestre_groupe"), - filename=moodle_sem_name + "-moodle", - titles={x: x for x in columns_ids}, - text_fields_separator=prefs["moodle_csv_separator"], - text_with_titles=prefs["moodle_csv_with_headerline"], - preferences=prefs, - ) - return tab.make_page(format="csv") +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""Affichage étudiants d'un ou plusieurs groupes + sous forme: de liste html (table exportable), de trombinoscope (exportable en pdf) +""" + +# Re-ecriture en 2014 (re-organisation de l'interface, modernisation du code) + +import collections +import datetime +import operator +import urllib +from urllib.parse import parse_qs +import time + + +from flask import url_for, g, request +from flask_login import current_user + +import app.scodoc.sco_utils as scu +from app.scodoc import html_sco_header +from app.scodoc import sco_abs +from app.scodoc import sco_excel +from app.scodoc import sco_formsemestre +from app.scodoc import sco_groups +from app.scodoc import sco_moduleimpl +from app.scodoc import sco_cursus +from app.scodoc import sco_portal_apogee +from app.scodoc import sco_preferences +from app.scodoc import sco_etud +from app.scodoc.sco_etud import etud_sort_key +from app.scodoc.gen_tables import GenTable +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.sco_permissions import Permission + +JAVASCRIPTS = html_sco_header.BOOTSTRAP_MULTISELECT_JS + [ + "js/etud_info.js", + "js/groups_view.js", +] + +CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS + +# view: +def groups_view( + group_ids=(), + format="html", + # Options pour listes: + with_codes=0, + etat=None, + with_paiement=0, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail) + with_archives=0, # ajoute colonne avec noms fichiers archivés + with_annotations=0, + formsemestre_id=None, # utilise si aucun groupe selectionné +): + """Affichage des étudiants des groupes indiqués + group_ids: liste de group_id + format: csv, json, xml, xls, allxls, xlsappel, moodlecsv, pdf + """ + # Informations sur les groupes à afficher: + groups_infos = DisplayedGroupsInfos( + group_ids, + formsemestre_id=formsemestre_id, + etat=etat, + select_all_when_unspecified=True, + ) + # Formats spéciaux: download direct + if format != "html": + return groups_table( + groups_infos=groups_infos, + format=format, + with_codes=with_codes, + etat=etat, + with_paiement=with_paiement, + with_archives=with_archives, + with_annotations=with_annotations, + ) + + H = [ + html_sco_header.sco_header( + javascripts=JAVASCRIPTS, + cssstyles=CSSSTYLES, + init_qtip=True, + ) + ] + # Menu choix groupe + H.append("""
""") + H.append(form_groups_choice(groups_infos, submit_on_change=True)) + # Note: le formulaire est soumis a chaque modif des groupes + # on pourrait faire comme pour le form de saisie des notes. Il faudrait pour cela: + # - charger tous les etudiants au debut, quels que soient les groupes selectionnés + # - ajouter du JS pour modifier les liens (arguments group_ids) quand le menu change + + # Tabs + # H.extend( ("""toto
  • item 1
  • item 2
""",) ) + H.extend( + ( + """ +
+ +
+
+ """, + groups_table( + groups_infos=groups_infos, + format=format, + with_codes=with_codes, + etat=etat, + with_paiement=with_paiement, + with_archives=with_archives, + with_annotations=with_annotations, + ), + "
", + """
""", + tab_photos_html(groups_infos, etat=etat), + #'

hello

', + "
", + '
', + tab_absences_html(groups_infos, etat=etat), + "
", + ) + ) + + H.append(html_sco_header.sco_footer()) + return "\n".join(H) + + +def form_groups_choice(groups_infos, with_selectall_butt=False, submit_on_change=False): + """form pour selection groupes + group_ids est la liste des groupes actuellement sélectionnés + et doit comporter au moins un élément, sauf si formsemestre_id est spécifié. + (utilisé pour retrouver le semestre et proposer la liste des autres groupes) + + Si submit_on_change, ajoute une classe "submit_on_change" qui est utilisee en JS + """ + default_group_id = sco_groups.get_default_group(groups_infos.formsemestre_id) + + H = [ + """
+ + + Groupes: + """ + % (groups_infos.formsemestre_id, default_group_id) + ] + + H.append(menu_groups_choice(groups_infos, submit_on_change=submit_on_change)) + + if with_selectall_butt: + H.append( + """""" + ) + H.append("
") + + return "\n".join(H) + + +def menu_groups_choice(groups_infos, submit_on_change=False): + """menu pour selection groupes + group_ids est la liste des groupes actuellement sélectionnés + et doit comporter au moins un élément, sauf si formsemestre_id est spécifié. + (utilisé pour retrouver le semestre et proposer la liste des autres groupes) + """ + default_group_id = sco_groups.get_default_group(groups_infos.formsemestre_id) + + if submit_on_change: + klass = "submit_on_change" + else: + klass = "" + H = [ + """ ") + return "\n".join(H) + + +def menu_group_choice(group_id=None, formsemestre_id=None): + """Un bête menu pour choisir un seul groupe + group_id est le groupe actuellement sélectionné. + Si aucun groupe selectionné, utilise formsemestre_id pour lister les groupes. + """ + if group_id: + group = sco_groups.get_group(group_id) + formsemestre_id = group["formsemestre_id"] + elif not formsemestre_id: + raise ValueError("missing formsemestre_id") + H = [ + """ + + + """ + ) + return "\n".join(H) + + +class DisplayedGroupsInfos(object): + """Container with attributes describing groups to display in the page + .groups_query_args : 'group_ids=xxx&group_ids=yyy' + .base_url : url de la requete, avec les groupes, sans les autres paramètres + .formsemestre_id : semestre "principal" (en fait celui du 1er groupe de la liste) + .members + .groups_titles + """ + + def __init__( + self, + group_ids=(), # groupes specifies dans l'URL, ou un seul int + formsemestre_id=None, + etat=None, + select_all_when_unspecified=False, + moduleimpl_id=None, # used to find formsemestre when unspecified + ): + if isinstance(group_ids, int): + if group_ids: + group_ids = [group_ids] # cas ou un seul parametre, pas de liste + else: + try: + group_ids = [int(g) for g in group_ids] + except ValueError as exc: + raise ScoValueError( + "identifiant de groupe invalide (mettre à jour vos bookmarks ?)" + ) from exc + if not formsemestre_id and moduleimpl_id: + mods = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id) + if len(mods) != 1: + raise ValueError("invalid moduleimpl_id") + formsemestre_id = mods[0]["formsemestre_id"] + + if not group_ids: # appel sans groupe (eg page accueil) + if not formsemestre_id: + raise Exception("missing parameter formsemestre_id or group_ids") + if select_all_when_unspecified: + group_ids = [sco_groups.get_default_group(formsemestre_id)] + else: + # selectionne le premier groupe trouvé, s'il y en a un + partition = sco_groups.get_partitions_list( + formsemestre_id, with_default=True + )[0] + groups = sco_groups.get_partition_groups(partition) + if groups: + group_ids = [groups[0]["group_id"]] + else: + group_ids = [sco_groups.get_default_group(formsemestre_id)] + + gq = [] + for group_id in group_ids: + gq.append("group_ids=" + str(group_id)) + self.groups_query_args = "&".join(gq) + self.base_url = request.base_url + "?" + self.groups_query_args + self.group_ids = group_ids + self.groups = [] + groups_titles = [] + self.members = [] + self.tous_les_etuds_du_sem = ( + False # affiche tous les etuds du semestre ? (si un seul semestre) + ) + self.sems = collections.OrderedDict() # formsemestre_id : sem + self.formsemestre = None + self.formsemestre_id = formsemestre_id + self.nbdem = 0 # nombre d'étudiants démissionnaires en tout + sem = None + selected_partitions = set() + for group_id in group_ids: + group_members, group, group_tit, sem, nbdem = sco_groups.get_group_infos( + group_id, etat=etat + ) + self.groups.append(group) + self.nbdem += nbdem + self.sems[sem["formsemestre_id"]] = sem + if not self.formsemestre_id: + self.formsemestre_id = sem["formsemestre_id"] + self.formsemestre = sem + self.members.extend(group_members) + groups_titles.append(group_tit) + if group["partition_name"] == None: + self.tous_les_etuds_du_sem = True + else: + # liste les partitions explicitement sélectionnés (= des groupes de group_ids) + selected_partitions.add((group["numero"], group["partition_id"])) + + self.selected_partitions = [ + x[1] for x in sorted(list(selected_partitions)) + ] # -> [ partition_id ] + + if not self.formsemestre: # aucun groupe selectionne + self.formsemestre = sco_formsemestre.get_formsemestre(formsemestre_id) + + self.sortuniq() + + if len(self.sems) > 1: + self.tous_les_etuds_du_sem = False # plusieurs semestres + if self.tous_les_etuds_du_sem: + if sem and sem["semestre_id"] >= 0: + self.groups_titles = "S%d" % sem["semestre_id"] + else: + self.groups_titles = "tous" + self.groups_filename = self.groups_titles + else: + self.groups_titles = ", ".join(groups_titles) + self.groups_filename = "_".join(groups_titles).replace(" ", "_") + # Sanitize filename: + self.groups_filename = scu.make_filename(self.groups_filename) + + # colonnes pour affichages nom des groupes: + # gère le cas où les étudiants appartiennent à des semestres différents + self.partitions = [] # les partitions, sans celle par defaut + for formsemestre_id in self.sems: + for partition in sco_groups.get_partitions_list(formsemestre_id): + if partition["partition_name"]: + self.partitions.append(partition) + + def sortuniq(self): + "Trie les étudiants (de plusieurs groupes) et élimine les doublons" + if (len(self.group_ids) <= 1) or len(self.members) <= 1: + return # on suppose que les etudiants d'un groupe sont deja triés + # tri selon nom_usuel ou nom, sans accents + self.members.sort(key=etud_sort_key) + to_remove = [] + T = self.members + for i in range(len(T) - 1, 0, -1): + if T[i - 1]["etudid"] == T[i]["etudid"]: + to_remove.append(i) + for i in to_remove: + del T[i] + + def get_form_elem(self): + """html hidden input with groups""" + H = [] + for group_id in self.group_ids: + H.append(f'') + return "\n".join(H) + + +# Ancien ZScolar.group_list renommé ici en group_table +def groups_table( + groups_infos: DisplayedGroupsInfos = None, + with_codes=0, + etat=None, + format="html", + with_paiement=0, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail) + with_archives=0, # ajoute colonne avec noms fichiers archivés + with_annotations=0, +): + """liste etudiants inscrits dans ce semestre + format: csv, json, xml, xls, allxls, xlsappel, moodlecsv, pdf + Si with_codes, ajoute 4 colonnes avec les codes etudid, NIP, INE et etape + """ + from app.scodoc import sco_report + + # log( + # "enter groups_table %s: %s" + # % (groups_infos.members[0]["nom"], groups_infos.members[0].get("etape", "-")) + # ) + with_codes = int(with_codes) + with_paiement = int(with_paiement) + with_archives = int(with_archives) + with_annotations = int(with_annotations) + + base_url_np = groups_infos.base_url + "&with_codes=%s" % with_codes + base_url = ( + base_url_np + + "&with_paiement=%s&with_archives=%s&with_annotations=%s" + % (with_paiement, with_archives, with_annotations) + ) + # + columns_ids = ["civilite_str", "nom_disp", "prenom"] # colonnes a inclure + titles = { + "civilite_str": "Civ.", + "nom_disp": "Nom", + "prenom": "Prénom", + "email": "Mail", + "emailperso": "Personnel", + "etat": "Etat", + "etudid": "etudid", + "code_nip": "code_nip", + "code_ine": "code_ine", + "datefinalisationinscription_str": "Finalisation inscr.", + "paiementinscription_str": "Paiement", + "etudarchive": "Fichiers", + "annotations_str": "Annotations", + "etape": "Etape", + "semestre_groupe": "Semestre-Groupe", # pour Moodle + } + + # ajoute colonnes pour groupes + columns_ids.extend([p["partition_id"] for p in groups_infos.partitions]) + titles.update( + dict( + [(p["partition_id"], p["partition_name"]) for p in groups_infos.partitions] + ) + ) + partitions_name = { + p["partition_id"]: p["partition_name"] for p in groups_infos.partitions + } + + if format != "html": # ne mentionne l'état que en Excel (style en html) + columns_ids.append("etat") + columns_ids.append("email") + columns_ids.append("emailperso") + + if format == "moodlecsv": + columns_ids = ["email", "semestre_groupe"] + + if with_codes: + columns_ids += ["etape", "etudid", "code_nip", "code_ine"] + if with_paiement: + columns_ids += ["datefinalisationinscription_str", "paiementinscription_str"] + if with_paiement: # or with_codes: + sco_portal_apogee.check_paiement_etuds(groups_infos.members) + if with_archives: + from app.scodoc import sco_archives_etud + + sco_archives_etud.add_archives_info_to_etud_list(groups_infos.members) + columns_ids += ["etudarchive"] + if with_annotations: + sco_etud.add_annotations_to_etud_list(groups_infos.members) + columns_ids += ["annotations_str"] + moodle_sem_name = groups_infos.formsemestre["session_id"] + moodle_groupenames = set() + # ajoute liens + for etud in groups_infos.members: + if etud["email"]: + etud["_email_target"] = "mailto:" + etud["email"] + else: + etud["_email_target"] = "" + if etud["emailperso"]: + etud["_emailperso_target"] = "mailto:" + etud["emailperso"] + else: + etud["_emailperso_target"] = "" + fiche_url = url_for( + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + ) + etud["_nom_disp_target"] = fiche_url + etud["_nom_disp_order"] = etud_sort_key(etud) + etud["_prenom_target"] = fiche_url + + etud["_nom_disp_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"]) + + if etud["etat"] == "D": + etud["_css_row_class"] = "etuddem" + # et groupes: + for partition_id in etud["partitions"]: + etud[partition_id] = etud["partitions"][partition_id]["group_name"] + # Ajoute colonne pour moodle: semestre_groupe, de la forme + # RT-DUT-FI-S3-2021-PARTITION-GROUPE + moodle_groupename = [] + if groups_infos.selected_partitions: + # il y a des groupes selectionnes, utilise leurs partitions + for partition_id in groups_infos.selected_partitions: + if partition_id in etud["partitions"]: + moodle_groupename.append( + partitions_name[partition_id] + + "-" + + etud["partitions"][partition_id]["group_name"] + ) + else: + # pas de groupes sélectionnés: prend le premier s'il y en a un + moodle_groupename = ["tous"] + if etud["partitions"]: + for p in etud["partitions"].items(): # partitions is an OrderedDict + moodle_groupename = [ + partitions_name[p[0]] + "-" + p[1]["group_name"] + ] + break + + moodle_groupenames |= set(moodle_groupename) + etud["semestre_groupe"] = moodle_sem_name + "-" + "+".join(moodle_groupename) + + if groups_infos.nbdem > 1: + s = "s" + else: + s = "" + + if format == "moodlecsv": + # de la forme S1-[FI][FA]-groupe.csv + if not moodle_groupenames: + moodle_groupenames = {"tous"} + filename = ( + moodle_sem_name + + "-" + + groups_infos.formsemestre["modalite"] + + "-" + + "+".join(sorted(moodle_groupenames)) + ) + else: + filename = "etudiants_%s" % groups_infos.groups_filename + + prefs = sco_preferences.SemPreferences(groups_infos.formsemestre_id) + tab = GenTable( + rows=groups_infos.members, + columns_ids=columns_ids, + titles=titles, + caption="soit %d étudiants inscrits et %d démissionaire%s." + % (len(groups_infos.members) - groups_infos.nbdem, groups_infos.nbdem, s), + base_url=base_url, + filename=filename, + pdf_link=False, # pas d'export pdf + html_sortable=True, + html_class="table_leftalign table_listegroupe", + xml_outer_tag="group_list", + xml_row_tag="etud", + text_fields_separator=prefs["moodle_csv_separator"], + text_with_titles=prefs["moodle_csv_with_headerline"], + preferences=prefs, + ) + # + if format == "html": + amail_inst = [ + x["email"] for x in groups_infos.members if x["email"] and x["etat"] != "D" + ] + amail_perso = [ + x["emailperso"] + for x in groups_infos.members + if x["emailperso"] and x["etat"] != "D" + ] + + if len(groups_infos.members): + if groups_infos.tous_les_etuds_du_sem: + htitle = "Les %d étudiants inscrits" % len(groups_infos.members) + else: + htitle = "Groupe %s (%d étudiants)" % ( + groups_infos.groups_titles, + len(groups_infos.members), + ) + else: + htitle = "Aucun étudiant !" + H = [ + '
' '

', + htitle, + "", + ] + if groups_infos.members: + Of = [] + options = { + "with_paiement": "Paiement inscription", + "with_archives": "Fichiers archivés", + "with_annotations": "Annotations", + "with_codes": "Codes", + } + for option in options: + if locals().get(option, False): + selected = "selected" + else: + selected = "" + Of.append( + """""" + % (option, selected, options[option]) + ) + + H.extend( + [ + """ + + """, + ] + ) + H.append("

") + if groups_infos.members: + H.extend( + [ + tab.html(), + "") + + return "".join(H) + "
" + + elif ( + format == "pdf" + or format == "xml" + or format == "json" + or format == "xls" + or format == "moodlecsv" + ): + if format == "moodlecsv": + format = "csv" + return tab.make_page(format=format) + + elif format == "xlsappel": + xls = sco_excel.excel_feuille_listeappel( + groups_infos.formsemestre, + groups_infos.groups_titles, + groups_infos.members, + partitions=groups_infos.partitions, + with_codes=with_codes, + with_paiement=with_paiement, + server_name=request.url_root, + ) + filename = "liste_%s" % groups_infos.groups_filename + return scu.send_file(xls, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE) + elif format == "allxls": + # feuille Excel avec toutes les infos etudiants + if not groups_infos.members: + return "" + keys = [ + "etudid", + "code_nip", + "etat", + "civilite_str", + "nom", + "nom_usuel", + "prenom", + "inscriptionstr", + ] + if with_paiement: + keys.append("paiementinscription") + keys += [ + "email", + "emailperso", + "domicile", + "villedomicile", + "codepostaldomicile", + "paysdomicile", + "telephone", + "telephonemobile", + "fax", + "date_naissance", + "lieu_naissance", + "bac", + "specialite", + "annee_bac", + "nomlycee", + "villelycee", + "codepostallycee", + "codelycee", + "type_admission", + "boursier_prec", + "debouche", + "parcours", + "codeparcours", + ] + titles = keys[:] + other_partitions = sco_groups.get_group_other_partitions(groups_infos.groups[0]) + keys += [p["partition_id"] for p in other_partitions] + titles += [p["partition_name"] for p in other_partitions] + # remplis infos lycee si on a que le code lycée + # et ajoute infos inscription + for m in groups_infos.members: + etud = sco_etud.get_etud_info(m["etudid"], filled=True)[0] + m.update(etud) + sco_etud.etud_add_lycee_infos(etud) + # et ajoute le parcours + Se = sco_cursus.get_situation_etud_cursus( + etud, groups_infos.formsemestre_id + ) + m["parcours"] = Se.get_parcours_descr() + m["codeparcours"], _ = sco_report.get_codeparcoursetud(etud) + + L = [[m.get(k, "") for k in keys] for m in groups_infos.members] + title = "etudiants_%s" % groups_infos.groups_filename + xls = sco_excel.excel_simple_table(titles=titles, lines=L, sheet_name=title) + filename = title + return scu.send_file(xls, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE) + else: + raise ValueError("unsupported format") + + +def tab_absences_html(groups_infos, etat=None): + """contenu du tab "absences et feuilles diverses" """ + authuser = current_user + H = ['
'] + if not groups_infos.members: + return "".join(H) + "

Aucun étudiant !

" + H.extend( + [ + "

Absences

", + '
    ', + "
  • ", + form_choix_saisie_semaine(groups_infos), # Ajout Le Havre + "
  • ", + "
  • ", + form_choix_jour_saisie_hebdo(groups_infos), + "
  • ", + """
  • État des absences du groupe
  • """ + % ( + groups_infos.groups_query_args, + groups_infos.formsemestre["date_debut"], + groups_infos.formsemestre["date_fin"], + ), + "
", + "

Feuilles

", + '", + ] + ) + + H.append('

Opérations diverses

    ') + # Lien pour verif codes INE/NIP + # (pour tous les etudiants du semestre) + group_id = sco_groups.get_default_group(groups_infos.formsemestre_id) + if authuser.has_permission(Permission.ScoEtudInscrit): + H.append( + '
  • Vérifier codes Apogée (de tous les groupes)
  • ' + % (group_id, etat or "") + ) + # Lien pour ajout fichiers étudiants + if authuser.has_permission(Permission.ScoEtudAddAnnotations): + H.append( + """
  • Télécharger des fichiers associés aux étudiants (e.g. dossiers d'admission)
  • """ + % (group_id) + ) + + H.append("
") + return "".join(H) + + +def tab_photos_html(groups_infos, etat=None): + """contenu du tab "photos" """ + from app.scodoc import sco_trombino + + if not groups_infos.members: + return '

Aucun étudiant !

' + + return sco_trombino.trombino_html(groups_infos) + + +def form_choix_jour_saisie_hebdo(groups_infos, moduleimpl_id=None): + """Formulaire choix jour semaine pour saisie.""" + authuser = current_user + if not authuser.has_permission(Permission.ScoAbsChange): + return "" + sem = groups_infos.formsemestre + first_monday = sco_abs.ddmmyyyy(sem["date_debut"]).prev_monday() + today_idx = datetime.date.today().weekday() + + FA = [] # formulaire avec menu saisi absences + FA.append( + '
' + ) + FA.append('' % sem) + FA.append(groups_infos.get_form_elem()) + if moduleimpl_id: + FA.append( + '' % moduleimpl_id + ) + FA.append('') + + FA.append( + """""" + ) + FA.append("""") + FA.append("
") + return "\n".join(FA) + + +# Ajout Le Havre +# Formulaire saisie absences semaine +def form_choix_saisie_semaine(groups_infos): + authuser = current_user + if not authuser.has_permission(Permission.ScoAbsChange): + return "" + # construit l'URL "destination" + # (a laquelle on revient apres saisie absences) + query_args = parse_qs(request.query_string) + moduleimpl_id = query_args.get("moduleimpl_id", [""])[0] + if "head_message" in query_args: + del query_args["head_message"] + destination = "%s?%s" % ( + request.base_url, + urllib.parse.urlencode(query_args, True), + ) + destination = destination.replace( + "%", "%%" + ) # car ici utilisee dans un format string ! + + DateJour = time.strftime("%d/%m/%Y") + datelundi = sco_abs.ddmmyyyy(DateJour).prev_monday() + FA = [] # formulaire avec menu saisie hebdo des absences + FA.append('
') + FA.append('' % datelundi) + FA.append('' % moduleimpl_id) + FA.append('' % destination) + FA.append(groups_infos.get_form_elem()) + FA.append('') + FA.append("
") + return "\n".join(FA) + + +def export_groups_as_moodle_csv(formsemestre_id=None): + """Export all students/groups, in a CSV format suitable for Moodle + Each (student,group) will be listed on a separate line + jo@univ.fr,S3-A + jo@univ.fr,S3-B1 + if jo belongs to group A in a partition, and B1 in another one. + Caution: if groups in different partitions share the same name, there will be + duplicates... should we prefix the group names with the partition's name ? + """ + if not formsemestre_id: + raise ScoValueError("missing parameter: formsemestre_id") + _, partitions_etud_groups = sco_groups.get_formsemestre_groups( + formsemestre_id, with_default=True + ) + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + moodle_sem_name = sem["session_id"] + + columns_ids = ("email", "semestre_groupe") + T = [] + for partition_id in partitions_etud_groups: + partition = sco_groups.get_partition(partition_id) + members = partitions_etud_groups[partition_id] + for etudid in members: + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + group_name = members[etudid]["group_name"] + elts = [moodle_sem_name] + if partition["partition_name"]: + elts.append(partition["partition_name"]) + if group_name: + elts.append(group_name) + T.append({"email": etud["email"], "semestre_groupe": "-".join(elts)}) + # Make table + prefs = sco_preferences.SemPreferences(formsemestre_id) + tab = GenTable( + rows=T, + columns_ids=("email", "semestre_groupe"), + filename=moodle_sem_name + "-moodle", + titles={x: x for x in columns_ids}, + text_fields_separator=prefs["moodle_csv_separator"], + text_with_titles=prefs["moodle_csv_with_headerline"], + preferences=prefs, + ) + return tab.make_page(format="csv") diff --git a/app/views/notes.py b/app/views/notes.py index c4c9b18a..e9dcfbe8 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -1,3171 +1,3185 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# ScoDoc -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -""" -Module notes: issu de ScoDoc7 / ZNotes.py - -Emmanuel Viennet, 2021 -""" - -from operator import itemgetter -import time -from xml.etree import ElementTree - -import flask -from flask import abort, flash, redirect, render_template, url_for -from flask import current_app, g, request -from flask_login import current_user - -from app import db -from app import models -from app.auth.models import User -from app.but import apc_edit_ue, jury_but_recap -from app.but import jury_but, jury_but_validation_auto -from app.but.forms import jury_but_forms -from app.but import jury_but_pv -from app.but import jury_but_view - -from app.comp import res_sem -from app.comp.res_compat import NotesTableCompat -from app.models import ScolarNews -from app.models.config import ScoDocSiteConfig -from app.models.etudiants import Identite -from app.models.formsemestre import FormSemestre -from app.models.formsemestre import FormSemestreUEComputationExpr -from app.models.moduleimpls import ModuleImpl -from app.models.modules import Module -from app.models.ues import UniteEns -from app.views import notes_bp as bp - -from app.decorators import ( - scodoc, - scodoc7func, - permission_required, - permission_required_compat_scodoc7, -) - - -# --------------- - -from app.scodoc import sco_bulletins_json, sco_utils as scu -from app.scodoc import notesdb as ndb -from app import log, send_scodoc_alarm - -from app.scodoc.scolog import logdb - -from app.scodoc.sco_exceptions import ( - AccessDenied, - ScoValueError, - ScoInvalidIdType, -) -from app.scodoc import html_sco_header -from app.pe import pe_view -from app.scodoc import sco_abs -from app.scodoc import sco_apogee_compare -from app.scodoc import sco_archives -from app.scodoc import sco_bulletins -from app.scodoc import sco_bulletins_pdf -from app.scodoc import sco_cache -from app.scodoc import sco_cost_formation -from app.scodoc import sco_debouche -from app.scodoc import sco_edit_apc -from app.scodoc import sco_edit_formation -from app.scodoc import sco_edit_matiere -from app.scodoc import sco_edit_module -from app.scodoc import sco_edit_ue -from app.scodoc import sco_etape_apogee_view -from app.scodoc import sco_etud -from app.scodoc import sco_evaluations -from app.scodoc import sco_evaluation_check_abs -from app.scodoc import sco_evaluation_db -from app.scodoc import sco_evaluation_edit -from app.scodoc import sco_evaluation_recap -from app.scodoc import sco_export_results -from app.scodoc import sco_formations -from app.scodoc import sco_formation_recap -from app.scodoc import sco_formsemestre -from app.scodoc import sco_formsemestre_custommenu -from app.scodoc import sco_formsemestre_edit -from app.scodoc import sco_formsemestre_exterieurs -from app.scodoc import sco_formsemestre_inscriptions -from app.scodoc import sco_formsemestre_status -from app.scodoc import sco_formsemestre_validation -from app.scodoc import sco_inscr_passage -from app.scodoc import sco_liste_notes -from app.scodoc import sco_lycee -from app.scodoc import sco_moduleimpl -from app.scodoc import sco_moduleimpl_inscriptions -from app.scodoc import sco_moduleimpl_status -from app.scodoc import sco_permissions_check -from app.scodoc import sco_placement -from app.scodoc import sco_poursuite_dut -from app.scodoc import sco_preferences -from app.scodoc import sco_prepajury -from app.scodoc import sco_pvjury -from app.scodoc import sco_recapcomplet -from app.scodoc import sco_report -from app.scodoc import sco_report_but -from app.scodoc import sco_saisie_notes -from app.scodoc import sco_semset -from app.scodoc import sco_synchro_etuds -from app.scodoc import sco_tag_module -from app.scodoc import sco_ue_external -from app.scodoc import sco_undo_notes -from app.scodoc import sco_users -from app.scodoc import sco_xml -from app.scodoc.gen_tables import GenTable -from app.scodoc.sco_permissions import Permission -from app.scodoc.TrivialFormulator import TrivialFormulator -from app.views import ScoData - - -def sco_publish(route, function, permission, methods=("GET",)): - """Declare a route for a python function, - protected by permission and called following ScoDoc 7 Zope standards. - """ - return bp.route(route, methods=methods)( - scodoc(permission_required(permission)(scodoc7func(function))) - ) - - -# -------------------------------------------------------------------- -# -# Notes/ methods -# -# -------------------------------------------------------------------- - -sco_publish( - "/formsemestre_status", - sco_formsemestre_status.formsemestre_status, - Permission.ScoView, -) - -sco_publish( - "/formsemestre_createwithmodules", - sco_formsemestre_edit.formsemestre_createwithmodules, - Permission.ScoImplement, - methods=["GET", "POST"], -) - -# controle d'acces specifique pour dir. etud: -sco_publish( - "/formsemestre_editwithmodules", - sco_formsemestre_edit.formsemestre_editwithmodules, - Permission.ScoView, - methods=["GET", "POST"], -) - -sco_publish( - "/formsemestre_clone", - sco_formsemestre_edit.formsemestre_clone, - Permission.ScoImplement, - methods=["GET", "POST"], -) -sco_publish( - "/formsemestre_associate_new_version", - sco_formsemestre_edit.formsemestre_associate_new_version, - Permission.ScoChangeFormation, - methods=["GET", "POST"], -) -sco_publish( - "/formsemestre_delete", - sco_formsemestre_edit.formsemestre_delete, - Permission.ScoImplement, - methods=["GET", "POST"], -) -sco_publish( - "/formsemestre_delete2", - sco_formsemestre_edit.formsemestre_delete2, - Permission.ScoImplement, - methods=["GET", "POST"], -) -sco_publish( - "/formsemestre_recapcomplet", - sco_recapcomplet.formsemestre_recapcomplet, - Permission.ScoView, -) -sco_publish( - "/evaluations_recap", - sco_evaluation_recap.evaluations_recap, - Permission.ScoView, -) -sco_publish( - "/formsemestres_bulletins", - sco_recapcomplet.formsemestres_bulletins, - Permission.ScoObservateur, -) -sco_publish( - "/moduleimpl_status", sco_moduleimpl_status.moduleimpl_status, Permission.ScoView -) -sco_publish( - "/formsemestre_description", - sco_formsemestre_status.formsemestre_description, - Permission.ScoView, -) - -sco_publish( - "/formation_create", - sco_edit_formation.formation_create, - Permission.ScoChangeFormation, - methods=["GET", "POST"], -) -sco_publish( - "/formation_delete", - sco_edit_formation.formation_delete, - Permission.ScoChangeFormation, - methods=["GET", "POST"], -) -sco_publish( - "/formation_edit", - sco_edit_formation.formation_edit, - Permission.ScoChangeFormation, - methods=["GET", "POST"], -) - - -@bp.route( - "/formsemestre_bulletinetud", methods=["GET", "POST"] -) # POST pour compat anciens clients PHP (deprecated) -@scodoc -@permission_required_compat_scodoc7(Permission.ScoView) -@scodoc7func -def formsemestre_bulletinetud( - etudid=None, - formsemestre_id=None, - format=None, - version="long", - xml_with_decisions=False, - force_publishing=False, - prefer_mail_perso=False, - code_nip=None, - code_ine=None, -): - format = format or "html" - - if formsemestre_id is not None and not isinstance(formsemestre_id, int): - raise ScoInvalidIdType( - "formsemestre_bulletinetud: formsemestre_id must be an integer !" - ) - formsemestre = FormSemestre.query.filter_by( - formsemestre_id=formsemestre_id, dept_id=g.scodoc_dept_id - ).first_or_404() - if etudid: - etud = models.Identite.query.filter_by( - etudid=etudid, dept_id=formsemestre.dept_id - ).first_or_404() - elif code_nip: - etud = models.Identite.query.filter_by( - code_nip=str(code_nip), dept_id=formsemestre.dept_id - ).first_or_404() - elif code_ine: - etud = models.Identite.query.filter_by( - code_ine=str(code_ine), dept_id=formsemestre.dept_id - ).first_or_404() - else: - raise ScoValueError( - "Paramètre manquant: spécifier etudid, code_nip ou code_ine" - ) - if format == "json": - return sco_bulletins.get_formsemestre_bulletin_etud_json( - formsemestre, etud, version=version, force_publishing=force_publishing - ) - if formsemestre.formation.is_apc() and format == "html": - return render_template( - "but/bulletin.html", - appreciations=models.BulAppreciations.query.filter_by( - etudid=etudid, formsemestre_id=formsemestre.id - ).order_by(models.BulAppreciations.date), - bul_url=url_for( - "notes.formsemestre_bulletinetud", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - etudid=etudid, - format="json", - force_publishing=1, # pour ScoDoc lui même - version=version, - ), - can_edit_appreciations=formsemestre.est_responsable(current_user) - or (current_user.has_permission(Permission.ScoEtudInscrit)), - etud=etud, - formsemestre=formsemestre, - inscription_courante=etud.inscription_courante(), - inscription_str=etud.inscription_descr()["inscription_str"], - is_apc=formsemestre.formation.is_apc(), - menu_autres_operations=sco_bulletins.make_menu_autres_operations( - formsemestre, etud, "notes.formsemestre_bulletinetud", version - ), - sco=ScoData(etud=etud), - scu=scu, - time=time, - title=f"Bul. {etud.nom} - BUT", - version=version, - ) - - if format == "oldjson": - format = "json" - r = sco_bulletins.formsemestre_bulletinetud( - etud, - formsemestre_id=formsemestre_id, - format=format, - version=version, - xml_with_decisions=xml_with_decisions, - force_publishing=force_publishing, - prefer_mail_perso=prefer_mail_perso, - ) - if format == "pdfmail": - return redirect( - url_for( - "notes.formsemestre_bulletinetud", - scodoc_dept=g.scodoc_dept, - etudid=etud.id, - formsemestre_id=formsemestre_id, - ) - ) - return r - - -sco_publish( - "/formsemestre_evaluations_cal", - sco_evaluations.formsemestre_evaluations_cal, - Permission.ScoView, -) -sco_publish( - "/formsemestre_evaluations_delai_correction", - sco_evaluations.formsemestre_evaluations_delai_correction, - Permission.ScoView, -) -sco_publish( - "/module_evaluation_renumber", - sco_evaluation_db.module_evaluation_renumber, - Permission.ScoView, -) -sco_publish( - "/module_evaluation_move", - sco_evaluation_db.module_evaluation_move, - Permission.ScoView, -) -sco_publish( - "/formsemestre_list_saisies_notes", - sco_undo_notes.formsemestre_list_saisies_notes, - Permission.ScoView, -) -sco_publish( - "/ue_create", - sco_edit_ue.ue_create, - Permission.ScoChangeFormation, - methods=["GET", "POST"], -) -sco_publish( - "/ue_delete", - sco_edit_ue.ue_delete, - Permission.ScoChangeFormation, - methods=["GET", "POST"], -) -sco_publish( - "/ue_edit", - sco_edit_ue.ue_edit, - Permission.ScoChangeFormation, - methods=["GET", "POST"], -) - - -@bp.route("/set_ue_niveau_competence", methods=["POST"]) -@scodoc -@permission_required(Permission.ScoChangeFormation) -def set_ue_niveau_competence(): - "associe UE et niveau" - ue_id = request.form.get("ue_id") - niveau_id = request.form.get("niveau_id") - return apc_edit_ue.set_ue_niveau_competence(ue_id, niveau_id) - - -@bp.route("/ue_list") # backward compat -@bp.route("/ue_table") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def ue_table(formation_id=None, semestre_idx=1, msg=""): - return sco_edit_ue.ue_table( - formation_id=formation_id, semestre_idx=semestre_idx, msg=msg - ) - - -@bp.route("/ue_infos/") -@scodoc -@permission_required(Permission.ScoView) -def ue_infos(ue_id): - ue = UniteEns.query.get_or_404(ue_id) - return sco_edit_apc.html_ue_infos(ue) - - -@bp.route("/ue_set_internal", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoChangeFormation) -@scodoc7func -def ue_set_internal(ue_id): - """""" - ue = models.UniteEns.query.get(ue_id) - if not ue: - raise ScoValueError("invalid ue_id") - ue.is_external = False - db.session.add(ue) - db.session.commit() - # Invalide les semestres de cette formation - ue.formation.invalidate_cached_sems() - - return redirect( - url_for( - "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=ue.formation_id - ) - ) - - -sco_publish("/ue_sharing_code", sco_edit_ue.ue_sharing_code, Permission.ScoView) -sco_publish( - "/edit_ue_set_code_apogee", - sco_edit_ue.edit_ue_set_code_apogee, - Permission.ScoChangeFormation, - methods=["POST"], -) -sco_publish( - "/formsemestre_edit_uecoefs", - sco_formsemestre_edit.formsemestre_edit_uecoefs, - Permission.ScoView, - methods=["GET", "POST"], -) - - -@bp.route("/formation_table_recap") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formation_table_recap(formation_id, format="html"): - return sco_formation_recap.formation_table_recap(formation_id, format="html") - - -sco_publish( - "/export_recap_formations_annee_scolaire", - sco_formation_recap.export_recap_formations_annee_scolaire, - Permission.ScoView, -) -sco_publish( - "/formation_add_malus_modules", - sco_edit_module.formation_add_malus_modules, - Permission.ScoChangeFormation, -) -sco_publish( - "/matiere_create", - sco_edit_matiere.matiere_create, - Permission.ScoChangeFormation, - methods=["GET", "POST"], -) -sco_publish( - "/matiere_delete", - sco_edit_matiere.matiere_delete, - Permission.ScoChangeFormation, - methods=["GET", "POST"], -) -sco_publish( - "/matiere_edit", - sco_edit_matiere.matiere_edit, - Permission.ScoChangeFormation, - methods=["GET", "POST"], -) -sco_publish( - "/module_create", - sco_edit_module.module_create, - Permission.ScoChangeFormation, - methods=["GET", "POST"], -) -sco_publish( - "/module_delete", - sco_edit_module.module_delete, - Permission.ScoChangeFormation, - methods=["GET", "POST"], -) -sco_publish( - "/module_edit", - sco_edit_module.module_edit, - Permission.ScoChangeFormation, - methods=["GET", "POST"], -) -sco_publish( - "/edit_module_set_code_apogee", - sco_edit_module.edit_module_set_code_apogee, - Permission.ScoChangeFormation, - methods=["GET", "POST"], -) -sco_publish("/module_list", sco_edit_module.module_table, Permission.ScoView) -sco_publish("/module_tag_search", sco_tag_module.module_tag_search, Permission.ScoView) - - -@bp.route("/module_tag_set", methods=["POST"]) -@scodoc -@permission_required(Permission.ScoEditFormationTags) -def module_tag_set(): - """Set tags on module""" - module_id = int(request.form.get("module_id")) - taglist = request.form.get("taglist") - return sco_tag_module.module_tag_set(module_id, taglist) - - -# -@bp.route("/") -@bp.route("/index_html") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def index_html(): - "Page accueil formations" - - editable = current_user.has_permission(Permission.ScoChangeFormation) - - H = [ - html_sco_header.sco_header(page_title="Programmes formations"), - """

Programmes pédagogiques

- """, - ] - T = sco_formations.formation_list_table() - - H.append(T.html()) - - if editable: - H.append( - f""" -

Une "formation" est un programme pédagogique structuré - en UE, matières et modules. Chaque semestre se réfère à une formation. - La modification d'une formation affecte tous les semestres qui s'y - réfèrent.

- - -

Référentiels de compétences

- - - """ - ) - - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - -# -------------------------------------------------------------------- -# -# Notes Methods -# -# -------------------------------------------------------------------- - -# --- Formations - -sco_publish( - "/do_formation_create", - sco_edit_formation.do_formation_create, - Permission.ScoChangeFormation, - methods=["GET", "POST"], -) - -sco_publish( - "/do_formation_delete", - sco_edit_formation.do_formation_delete, - Permission.ScoChangeFormation, - methods=["GET", "POST"], -) - - -@bp.route("/formation_list") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formation_list(format=None, formation_id=None, args={}): - """List formation(s) with given id, or matching args - (when args is given, formation_id is ignored). - """ - r = sco_formations.formation_list(formation_id=formation_id, args=args) - return scu.sendResult(r, name="formation", format=format) - - -@bp.route("/formation_export") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formation_export(formation_id, export_ids=False, format=None): - "Export de la formation au format indiqué (xml ou json)" - return sco_formations.formation_export( - formation_id, export_ids=export_ids, format=format - ) - - -@bp.route("/formation_import_xml_form", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoChangeFormation) -@scodoc7func -def formation_import_xml_form(): - "form import d'une formation en XML" - H = [ - html_sco_header.sco_header(page_title="Import d'une formation"), - """

Import d'une formation

-

Création d'une formation (avec UE, matières, modules) - à partir un fichier XML (réservé aux utilisateurs avertis)

- """, - ] - footer = html_sco_header.sco_footer() - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - (("xmlfile", {"input_type": "file", "title": "Fichier XML", "size": 30}),), - submitlabel="Importer", - cancelbutton="Annuler", - ) - if tf[0] == 0: - return "\n".join(H) + tf[1] + footer - elif tf[0] == -1: - return flask.redirect(scu.NotesURL()) - else: - formation_id, _, _ = sco_formations.formation_import_xml( - tf[2]["xmlfile"].read() - ) - - return ( - "\n".join(H) - + """

Import effectué !

-

Voir la formation

""" - % formation_id - + footer - ) - - -sco_publish( - "/formation_create_new_version", - sco_formations.formation_create_new_version, - Permission.ScoChangeFormation, -) - -# --- UE -sco_publish( - "/do_ue_create", - sco_edit_ue.do_ue_create, - Permission.ScoChangeFormation, - methods=["GET", "POST"], -) - -sco_publish( - "/ue_list", - sco_edit_ue.ue_list, - Permission.ScoView, -) - - -# --- Matieres -sco_publish( - "/do_matiere_create", - sco_edit_matiere.do_matiere_create, - Permission.ScoChangeFormation, - methods=["GET", "POST"], -) -sco_publish( - "/do_matiere_delete", - sco_edit_matiere.do_matiere_delete, - Permission.ScoChangeFormation, -) - - -# --- Modules -sco_publish( - "/do_module_delete", - sco_edit_module.do_module_delete, - Permission.ScoChangeFormation, -) - - -@bp.route("/formation_count_sems") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formation_count_sems(formation_id): - "Number of formsemestre in this formation (locked or not)" - sems = sco_formsemestre.do_formsemestre_list(args={"formation_id": formation_id}) - return len(sems) - - -sco_publish( - "/module_count_moduleimpls", - sco_edit_module.module_count_moduleimpls, - Permission.ScoView, -) - -sco_publish("/module_is_locked", sco_edit_module.module_is_locked, Permission.ScoView) - -sco_publish( - "/matiere_is_locked", sco_edit_matiere.matiere_is_locked, Permission.ScoView -) - -sco_publish( - "/module_move", sco_edit_formation.module_move, Permission.ScoChangeFormation -) -sco_publish("/ue_move", sco_edit_formation.ue_move, Permission.ScoChangeFormation) - - -# --- Semestres de formation - - -@bp.route( - "/formsemestre_list", methods=["GET", "POST"] -) # pour compat anciens clients PHP -@scodoc -@permission_required_compat_scodoc7(Permission.ScoView) -@scodoc7func -def formsemestre_list( - format="json", - formsemestre_id=None, - formation_id=None, - etape_apo=None, -): - """List formsemestres in given format. - kw can specify some conditions: examples: - formsemestre_list( format='json', formation_id='F777') - """ - try: - formsemestre_id = int(formsemestre_id) if formsemestre_id is not None else None - formation_id = int(formation_id) if formation_id is not None else None - except ValueError: - return scu.json_error(404, "invalid id") - # XAPI: new json api - args = {} - L = locals() - for argname in ("formsemestre_id", "formation_id", "etape_apo"): - if L[argname] is not None: - args[argname] = L[argname] - sems = sco_formsemestre.do_formsemestre_list(args=args) - # log('formsemestre_list: format="%s", %s semestres found' % (format,len(sems))) - return scu.sendResult(sems, name="formsemestre", format=format) - - -@bp.route( - "/XMLgetFormsemestres", methods=["GET", "POST"] -) # pour compat anciens clients PHP -@scodoc -@permission_required_compat_scodoc7(Permission.ScoView) -@scodoc7func -def XMLgetFormsemestres(etape_apo=None, formsemestre_id=None): - """List all formsemestres matching etape, XML format - DEPRECATED: use formsemestre_list() - """ - current_app.logger.debug("Warning: calling deprecated XMLgetFormsemestres") - if not formsemestre_id: - return flask.abort(404, "argument manquant: formsemestre_id") - if not isinstance(formsemestre_id, int): - return flask.abort( - 404, "XMLgetFormsemestres: formsemestre_id must be an integer !" - ) - args = {} - if etape_apo: - args["etape_apo"] = etape_apo - if formsemestre_id: - args["formsemestre_id"] = formsemestre_id - - doc = ElementTree.Element("formsemestrelist") - for sem in sco_formsemestre.do_formsemestre_list(args=args): - for k in sem: - if isinstance(sem[k], int): - sem[k] = str(sem[k]) - sem_elt = ElementTree.Element("formsemestre", **sem) - doc.append(sem_elt) - - data = sco_xml.XML_HEADER + ElementTree.tostring(doc).decode(scu.SCO_ENCODING) - return scu.send_file(data, mime=scu.XML_MIMETYPE) - - -sco_publish( - "/do_formsemestre_edit", - sco_formsemestre.do_formsemestre_edit, - Permission.ScoImplement, -) -sco_publish( - "/formsemestre_edit_options", - sco_formsemestre_edit.formsemestre_edit_options, - Permission.ScoView, - methods=["GET", "POST"], -) - - -@bp.route("/formsemestre_change_lock", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoView) # acces vérifié dans la fonction -@scodoc7func -def formsemestre_change_lock(formsemestre_id, dialog_confirmed=False): - "Changement de l'état de verrouillage du semestre" - - if not dialog_confirmed: - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - etat = not sem["etat"] - if etat: - msg = "déverrouillage" - else: - msg = "verrouillage" - return scu.confirm_dialog( - "

Confirmer le %s du semestre ?

" % msg, - helpmsg="""Les notes d'un semestre verrouillé ne peuvent plus être modifiées. - Un semestre verrouillé peut cependant être déverrouillé facilement à tout moment - (par son responsable ou un administrateur). -
- Le programme d'une formation qui a un semestre verrouillé ne peut plus être modifié. - """, - dest_url="", - cancel_url="formsemestre_status?formsemestre_id=%s" % formsemestre_id, - parameters={"formsemestre_id": formsemestre_id}, - ) - - sco_formsemestre_edit.formsemestre_change_lock(formsemestre_id) - - return flask.redirect( - url_for( - "notes.formsemestre_status", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ) - ) - - -sco_publish( - "/formsemestre_change_publication_bul", - sco_formsemestre_edit.formsemestre_change_publication_bul, - Permission.ScoView, - methods=["GET", "POST"], -) -sco_publish( - "/view_formsemestre_by_etape", - sco_formsemestre.view_formsemestre_by_etape, - Permission.ScoView, -) - - -@bp.route("/formsemestre_custommenu_edit", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formsemestre_custommenu_edit(formsemestre_id): - "Dialogue modif menu" - # accessible à tous ! - return sco_formsemestre_custommenu.formsemestre_custommenu_edit(formsemestre_id) - - -# --- dialogue modif enseignants/moduleimpl -@bp.route("/edit_enseignants_form", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def edit_enseignants_form(moduleimpl_id): - "modif liste enseignants/moduleimpl" - M, sem = sco_moduleimpl.can_change_ens(moduleimpl_id) - # -- - header = html_sco_header.html_sem_header( - 'Enseignants du module %s' - % (moduleimpl_id, M["module"]["titre"]), - page_title="Enseignants du module %s" % M["module"]["titre"], - javascripts=["libjs/AutoSuggest.js"], - cssstyles=["css/autosuggest_inquisitor.css"], - bodyOnLoad="init_tf_form('')", - ) - footer = html_sco_header.sco_footer() - - # Liste des enseignants avec forme pour affichage / saisie avec suggestion - userlist = sco_users.get_user_list() - uid2display = {} # uid : forme pour affichage = "NOM Prenom (login)"(login)" - for u in userlist: - uid2display[u.id] = u.get_nomplogin() - allowed_user_names = list(uid2display.values()) - - H = [ - "
  • %s (responsable)
  • " - % uid2display.get(M["responsable_id"], M["responsable_id"]) - ] - for ens in M["ens"]: - u = User.query.get(ens["ens_id"]) - if u: - nom = u.get_nomcomplet() - else: - nom = "? (compte inconnu)" - H.append( - f""" -
  • {nom} (supprimer) -
  • """ - ) - H.append("
") - F = """

Les enseignants d'un module ont le droit de - saisir et modifier toutes les notes des évaluations de ce module. -

-

Pour changer le responsable du module, passez par la - page "Modification du semestre", accessible uniquement au responsable de la formation (chef de département) -

- """ % ( - sem["formation_id"], - M["formsemestre_id"], - ) - - modform = [ - ("moduleimpl_id", {"input_type": "hidden"}), - ( - "ens_id", - { - "input_type": "text_suggest", - "size": 50, - "title": "Ajouter un enseignant", - "allowed_values": allowed_user_names, - "allow_null": False, - "text_suggest_options": { - "script": url_for( - "users.get_user_list_xml", scodoc_dept=g.scodoc_dept - ) - + "?", - "varname": "start", - "json": False, - "noresults": "Valeur invalide !", - "timeout": 60000, - }, - }, - ), - ] - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - modform, - submitlabel="Ajouter enseignant", - cancelbutton="Annuler", - ) - if tf[0] == 0: - return header + "\n".join(H) + tf[1] + F + footer - elif tf[0] == -1: - return flask.redirect( - url_for( - "notes.moduleimpl_status", - scodoc_dept=g.scodoc_dept, - moduleimpl_id=moduleimpl_id, - ) - ) - else: - ens_id = User.get_user_id_from_nomplogin(tf[2]["ens_id"]) - if not ens_id: - H.append( - '

Pour ajouter un enseignant, choisissez un nom dans le menu

' - ) - else: - # et qu'il n'est pas deja: - if ( - ens_id in [x["ens_id"] for x in M["ens"]] - or ens_id == M["responsable_id"] - ): - H.append( - '

Enseignant %s déjà dans la liste !

' % ens_id - ) - else: - sco_moduleimpl.do_ens_create( - {"moduleimpl_id": moduleimpl_id, "ens_id": ens_id} - ) - return flask.redirect( - "edit_enseignants_form?moduleimpl_id=%s" % moduleimpl_id - ) - return header + "\n".join(H) + tf[1] + F + footer - - -@bp.route("/edit_moduleimpl_resp", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def edit_moduleimpl_resp(moduleimpl_id): - """Changement d'un enseignant responsable de module - Accessible par Admin et dir des etud si flag resp_can_change_ens - """ - M, sem = sco_moduleimpl.can_change_module_resp(moduleimpl_id) - H = [ - html_sco_header.html_sem_header( - 'Modification du responsable du module %s' - % (moduleimpl_id, M["module"]["titre"]), - javascripts=["libjs/AutoSuggest.js"], - cssstyles=["css/autosuggest_inquisitor.css"], - bodyOnLoad="init_tf_form('')", - ) - ] - help_str = """

Taper le début du nom de l'enseignant.

""" - # Liste des enseignants avec forme pour affichage / saisie avec suggestion - userlist = [sco_users.user_info(user=u) for u in sco_users.get_user_list()] - uid2display = {} # uid : forme pour affichage = "NOM Prenom (login)" - for u in userlist: - uid2display[u["id"]] = u["nomplogin"] - allowed_user_names = list(uid2display.values()) - - initvalues = M - initvalues["responsable_id"] = uid2display.get( - M["responsable_id"], M["responsable_id"] - ) - form = [ - ("moduleimpl_id", {"input_type": "hidden"}), - ( - "responsable_id", - { - "input_type": "text_suggest", - "size": 50, - "title": "Responsable du module", - "allowed_values": allowed_user_names, - "allow_null": False, - "text_suggest_options": { - "script": url_for( - "users.get_user_list_xml", scodoc_dept=g.scodoc_dept - ) - + "?", - "varname": "start", - "json": False, - "noresults": "Valeur invalide !", - "timeout": 60000, - }, - }, - ), - ] - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - form, - submitlabel="Changer responsable", - cancelbutton="Annuler", - initvalues=initvalues, - ) - if tf[0] == 0: - return "\n".join(H) + tf[1] + help_str + html_sco_header.sco_footer() - elif tf[0] == -1: - return flask.redirect( - url_for( - "notes.moduleimpl_status", - scodoc_dept=g.scodoc_dept, - moduleimpl_id=moduleimpl_id, - ) - ) - else: - responsable_id = User.get_user_id_from_nomplogin(tf[2]["responsable_id"]) - if ( - not responsable_id - ): # presque impossible: tf verifie les valeurs (mais qui peuvent changer entre temps) - return flask.redirect( - url_for( - "notes.moduleimpl_status", - scodoc_dept=g.scodoc_dept, - moduleimpl_id=moduleimpl_id, - ) - ) - - sco_moduleimpl.do_moduleimpl_edit( - {"moduleimpl_id": moduleimpl_id, "responsable_id": responsable_id}, - formsemestre_id=sem["formsemestre_id"], - ) - return flask.redirect( - url_for( - "notes.moduleimpl_status", - scodoc_dept=g.scodoc_dept, - moduleimpl_id=moduleimpl_id, - head_message="responsable modifié", - ) - ) - - -_EXPR_HELP = """

Expérimental: formule de calcul de la moyenne %(target)s

-

Attention: l'utilisation de formules ralentit considérablement - les traitements. A utiliser uniquement dans les cas ne pouvant pas être traités autrement.

-

Dans la formule, les variables suivantes sont définies:

-
    -
  • moy la moyenne, calculée selon la règle standard (moyenne pondérée)
  • -
  • moy_is_valid vrai si la moyenne est valide (numérique)
  • -
  • moy_val la valeur de la moyenne (nombre, valant 0 si invalide)
  • -
  • notes vecteur des notes (/20) aux %(objs)s
  • -
  • coefs vecteur des coefficients des %(objs)s, les coefs des %(objs)s sans notes (ATT, EXC) étant mis à zéro
  • -
  • cmask vecteur de 0/1, 0 si le coef correspondant a été annulé
  • -
  • Nombre d'absences: nb_abs, nb_abs_just, nb_abs_nojust (en demi-journées)
  • -
-

Les éléments des vecteurs sont ordonnés dans l'ordre des %(objs)s%(ordre)s.

-

Les fonctions suivantes sont utilisables: abs, cmp, dot, len, map, max, min, pow, reduce, round, sum, ifelse.

-

La notation V(1,2,3) représente un vecteur (1,2,3).

-

Pour indiquer que la note calculée n'existe pas, utiliser la chaîne 'NA'.

-

Vous pouvez désactiver la formule (et revenir au mode de calcul "classique") - en supprimant le texte ou en faisant précéder la première ligne par #

-""" - - -@bp.route("/edit_moduleimpl_expr", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def edit_moduleimpl_expr(moduleimpl_id): - """Edition formule calcul moyenne module - Accessible par Admin, dir des etud et responsable module - - Inutilisé en ScoDoc 9. - """ - M, sem = sco_moduleimpl.can_change_ens(moduleimpl_id) - H = [ - html_sco_header.html_sem_header( - 'Modification règle de calcul du module %s' - % (moduleimpl_id, M["module"]["titre"]), - ), - _EXPR_HELP - % { - "target": "du module", - "objs": "évaluations", - "ordre": " (le premier élément est la plus ancienne évaluation)", - }, - ] - initvalues = M - form = [ - ("moduleimpl_id", {"input_type": "hidden"}), - ( - "computation_expr", - { - "title": "Formule de calcul", - "input_type": "textarea", - "rows": 4, - "cols": 60, - "explanation": "formule de calcul (expérimental)", - }, - ), - ] - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - form, - submitlabel="Modifier formule de calcul", - cancelbutton="Annuler", - initvalues=initvalues, - ) - if tf[0] == 0: - return "\n".join(H) + tf[1] + html_sco_header.sco_footer() - elif tf[0] == -1: - return flask.redirect( - url_for( - "notes.moduleimpl_status", - scodoc_dept=g.scodoc_dept, - moduleimpl_id=moduleimpl_id, - ) - ) - else: - sco_moduleimpl.do_moduleimpl_edit( - { - "moduleimpl_id": moduleimpl_id, - "computation_expr": tf[2]["computation_expr"], - }, - formsemestre_id=sem["formsemestre_id"], - ) - sco_cache.invalidate_formsemestre( - formsemestre_id=sem["formsemestre_id"] - ) # > modif regle calcul - return flask.redirect( - url_for( - "notes.moduleimpl_status", - scodoc_dept=g.scodoc_dept, - moduleimpl_id=moduleimpl_id, - head_message="règle%20de%20calcul%20modifiée", - ) - ) - - -@bp.route("/delete_moduleimpl_expr", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def delete_moduleimpl_expr(moduleimpl_id): - """Suppression formule calcul moyenne module - Accessible par Admin, dir des etud et responsable module - """ - modimpl = ModuleImpl.query.get_or_404(moduleimpl_id) - sco_moduleimpl.can_change_ens(moduleimpl_id) - modimpl.computation_expr = None - db.session.add(modimpl) - db.session.commit() - flash("Ancienne formule supprimée") - return flask.redirect( - url_for( - "notes.moduleimpl_status", - scodoc_dept=g.scodoc_dept, - moduleimpl_id=moduleimpl_id, - ) - ) - - -@bp.route("/view_module_abs") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def view_module_abs(moduleimpl_id, format="html"): - """Visualisation des absences a un module""" - M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=moduleimpl_id)[0] - sem = sco_formsemestre.get_formsemestre(M["formsemestre_id"]) - debut_sem = ndb.DateDMYtoISO(sem["date_debut"]) - fin_sem = ndb.DateDMYtoISO(sem["date_fin"]) - list_insc = sco_moduleimpl.moduleimpl_listeetuds(moduleimpl_id) - - T = [] - for etudid in list_insc: - nb_abs = sco_abs.count_abs( - etudid=etudid, - debut=debut_sem, - fin=fin_sem, - moduleimpl_id=moduleimpl_id, - ) - if nb_abs: - nb_abs_just = sco_abs.count_abs_just( - etudid=etudid, - debut=debut_sem, - fin=fin_sem, - moduleimpl_id=moduleimpl_id, - ) - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - T.append( - { - "nomprenom": etud["nomprenom"], - "just": nb_abs_just, - "nojust": nb_abs - nb_abs_just, - "total": nb_abs, - "_nomprenom_target": url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid - ), - } - ) - - H = [ - html_sco_header.html_sem_header( - 'Absences du module %s' - % (moduleimpl_id, M["module"]["titre"]), - page_title="Absences du module %s" % (M["module"]["titre"]), - ) - ] - if not T and format == "html": - return ( - "\n".join(H) - + "

Aucune absence signalée

" - + html_sco_header.sco_footer() - ) - - tab = GenTable( - titles={ - "nomprenom": "Nom", - "just": "Just.", - "nojust": "Non Just.", - "total": "Total", - }, - columns_ids=("nomprenom", "just", "nojust", "total"), - rows=T, - html_class="table_leftalign", - base_url="%s?moduleimpl_id=%s" % (request.base_url, moduleimpl_id), - filename="absmodule_" + scu.make_filename(M["module"]["titre"]), - caption="Absences dans le module %s" % M["module"]["titre"], - preferences=sco_preferences.SemPreferences(), - ) - - if format != "html": - return tab.make_page(format=format) - - return "\n".join(H) + tab.html() + html_sco_header.sco_footer() - - -@bp.route("/delete_ue_expr//", methods=["GET", "POST"]) -@scodoc -def delete_ue_expr(formsemestre_id: int, ue_id: int): - """Efface une expression de calcul d'UE""" - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - if not formsemestre.can_be_edited_by(current_user): - raise AccessDenied("vous n'avez pas le droit d'effectuer cette opération") - expr = FormSemestreUEComputationExpr.query.filter_by( - formsemestre_id=formsemestre_id, ue_id=ue_id - ).first() - if expr is not None: - db.session.delete(expr) - db.session.commit() - flash("formule supprimée") - return flask.redirect( - url_for( - "notes.formsemestre_status", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - head_message="formule supprimée", - ) - ) - - -@bp.route("/formsemestre_enseignants_list") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formsemestre_enseignants_list(formsemestre_id, format="html"): - """Liste les enseignants intervenants dans le semestre (resp. modules et chargés de TD) - et indique les absences saisies par chacun. - """ - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - # resp. de modules: - mods = sco_moduleimpl.moduleimpl_withmodule_list(formsemestre_id=formsemestre_id) - sem_ens = {} - for mod in mods: - if not mod["responsable_id"] in sem_ens: - sem_ens[mod["responsable_id"]] = {"mods": [mod]} - else: - sem_ens[mod["responsable_id"]]["mods"].append(mod) - # charges de TD: - for mod in mods: - for ensd in mod["ens"]: - if not ensd["ens_id"] in sem_ens: - sem_ens[ensd["ens_id"]] = {"mods": [mod]} - else: - sem_ens[ensd["ens_id"]]["mods"].append(mod) - # compte les absences ajoutées par chacun dans tout le semestre - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - for ens in sem_ens: - u = User.query.filter_by(id=ens).first() - if not u: - continue - cursor.execute( - """SELECT * FROM scolog L, notes_formsemestre_inscription I - WHERE method = 'AddAbsence' - and authenticated_user = %(authenticated_user)s - and L.etudid = I.etudid - and I.formsemestre_id = %(formsemestre_id)s - and date > %(date_debut)s - and date < %(date_fin)s - """, - { - "authenticated_user": u.user_name, - "formsemestre_id": formsemestre_id, - "date_debut": ndb.DateDMYtoISO(sem["date_debut"]), - "date_fin": ndb.DateDMYtoISO(sem["date_fin"]), - }, - ) - - events = cursor.dictfetchall() - sem_ens[ens]["nbabsadded"] = len(events) - - # description textuelle des modules - for ens in sem_ens: - sem_ens[ens]["descr_mods"] = ", ".join( - [x["module"]["code"] or "?" for x in sem_ens[ens]["mods"]] - ) - - # ajoute infos sur enseignant: - for ens in sem_ens: - sem_ens[ens].update(sco_users.user_info(ens)) - if sem_ens[ens]["email"]: - sem_ens[ens]["_email_target"] = "mailto:%s" % sem_ens[ens]["email"] - - sem_ens_list = list(sem_ens.values()) - sem_ens_list.sort(key=itemgetter("nomprenom")) - - # --- Generate page with table - title = "Enseignants de " + sem["titremois"] - T = GenTable( - columns_ids=["nom_fmt", "prenom_fmt", "descr_mods", "nbabsadded", "email"], - titles={ - "nom_fmt": "Nom", - "prenom_fmt": "Prénom", - "email": "Mail", - "descr_mods": "Modules", - "nbabsadded": "Saisies Abs.", - }, - rows=sem_ens_list, - html_sortable=True, - html_class="table_leftalign", - filename=scu.make_filename("Enseignants-" + sem["titreannee"]), - html_title=html_sco_header.html_sem_header( - "Enseignants du semestre", with_page_header=False - ), - base_url="%s?formsemestre_id=%s" % (request.base_url, formsemestre_id), - caption="Tous les enseignants (responsables ou associés aux modules de ce semestre) apparaissent. Le nombre de saisies d'absences est le nombre d'opérations d'ajout effectuées sur ce semestre, sans tenir compte des annulations ou double saisies.", - preferences=sco_preferences.SemPreferences(formsemestre_id), - ) - return T.make_page(page_title=title, title=title, format=format) - - -@bp.route("/edit_enseignants_form_delete", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def edit_enseignants_form_delete(moduleimpl_id, ens_id: int): - """remove ens from this modueimpl - - ens_id: user.id - """ - M, _ = sco_moduleimpl.can_change_ens(moduleimpl_id) - # search ens_id - ok = False - for ens in M["ens"]: - if ens["ens_id"] == ens_id: - ok = True - break - if not ok: - raise ScoValueError("invalid ens_id (%s)" % ens_id) - ndb.SimpleQuery( - """DELETE FROM notes_modules_enseignants - WHERE moduleimpl_id = %(moduleimpl_id)s - AND ens_id = %(ens_id)s - """, - {"moduleimpl_id": moduleimpl_id, "ens_id": ens_id}, - ) - return flask.redirect("edit_enseignants_form?moduleimpl_id=%s" % moduleimpl_id) - - -# --- Gestion des inscriptions aux semestres - -# Ancienne API, pas certain de la publier en ScoDoc8 -# sco_publish( -# "/do_formsemestre_inscription_create", -# sco_formsemestre_inscriptions.do_formsemestre_inscription_create, -# Permission.ScoEtudInscrit, -# ) -# sco_publish( -# "/do_formsemestre_inscription_edit", -# sco_formsemestre_inscriptions.do_formsemestre_inscription_edit, -# Permission.ScoEtudInscrit, -# ) - -sco_publish( - "/do_formsemestre_inscription_list", - sco_formsemestre_inscriptions.do_formsemestre_inscription_list, - Permission.ScoView, -) - - -@bp.route("/do_formsemestre_inscription_listinscrits") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def do_formsemestre_inscription_listinscrits(formsemestre_id, format=None): - """Liste les inscrits (état I) à ce semestre et cache le résultat""" - r = sco_formsemestre_inscriptions.do_formsemestre_inscription_listinscrits( - formsemestre_id - ) - return scu.sendResult(r, format=format, name="inscrits") - - -@bp.route("/formsemestre_desinscription", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoImplement) -@scodoc7func -def formsemestre_desinscription(etudid, formsemestre_id, dialog_confirmed=False): - """désinscrit l'etudiant de ce semestre (et donc de tous les modules). - A n'utiliser qu'en cas d'erreur de saisie. - S'il s'agit d'un semestre extérieur et qu'il n'y a plus d'inscrit, - le semestre sera supprimé. - """ - formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) - sem = formsemestre.to_dict() # compat - # -- check lock - if not formsemestre.etat: - raise ScoValueError("desinscription impossible: semestre verrouille") - - # -- Si décisions de jury, désinscription interdite - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - if nt.etud_has_decision(etudid): - raise ScoValueError( - f"""Désinscription impossible: l'étudiant a une décision de jury - (la supprimer avant si nécessaire: - supprimer décision jury - ) - """ - ) - if not dialog_confirmed: - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - if formsemestre.modalite != "EXT": - msg_ext = """ -

%s sera désinscrit de tous les modules du semestre %s (%s - %s).

-

Cette opération ne doit être utilisée que pour corriger une erreur ! - Un étudiant réellement inscrit doit le rester, le faire éventuellement démissionner. -

- """ % ( - etud["nomprenom"], - sem["titre_num"], - sem["date_debut"], - sem["date_fin"], - ) - else: # semestre extérieur - msg_ext = """ -

%s sera désinscrit du semestre extérieur %s (%s - %s).

- """ % ( - etud["nomprenom"], - sem["titre_num"], - sem["date_debut"], - sem["date_fin"], - ) - inscrits = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( - args={"formsemestre_id": formsemestre_id} - ) - nbinscrits = len(inscrits) - if nbinscrits <= 1: - msg_ext = """

Attention: le semestre extérieur - sera supprimé car il n'a pas d'autre étudiant inscrit. -

- """ - return scu.confirm_dialog( - """

Confirmer la demande de désinscription ?

""" + msg_ext, - dest_url="", - cancel_url=url_for( - "notes.formsemestre_status", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ), - parameters={"etudid": etudid, "formsemestre_id": formsemestre_id}, - ) - - sco_formsemestre_inscriptions.do_formsemestre_desinscription( - etudid, formsemestre_id - ) - - flash("Étudiant désinscrit") - return redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) - ) - - -sco_publish( - "/do_formsemestre_desinscription", - sco_formsemestre_inscriptions.do_formsemestre_desinscription, - Permission.ScoEtudInscrit, - methods=["GET", "POST"], -) - - -@bp.route("/etud_desinscrit_ue") -@scodoc -@permission_required(Permission.ScoEtudInscrit) -@scodoc7func -def etud_desinscrit_ue(etudid, formsemestre_id, ue_id): - """Desinscrit l'etudiant de tous les modules de cette UE dans ce semestre.""" - sco_moduleimpl_inscriptions.do_etud_desinscrit_ue(etudid, formsemestre_id, ue_id) - return flask.redirect( - scu.ScoURL() - + "/Notes/moduleimpl_inscriptions_stats?formsemestre_id=" - + str(formsemestre_id) - ) - - -@bp.route("/etud_inscrit_ue") -@scodoc -@permission_required(Permission.ScoEtudInscrit) -@scodoc7func -def etud_inscrit_ue(etudid, formsemestre_id, ue_id): - """Inscrit l'etudiant de tous les modules de cette UE dans ce semestre.""" - sco_moduleimpl_inscriptions.do_etud_inscrit_ue(etudid, formsemestre_id, ue_id) - return flask.redirect( - scu.ScoURL() - + "/Notes/moduleimpl_inscriptions_stats?formsemestre_id=" - + str(formsemestre_id) - ) - - -# --- Inscriptions -sco_publish( - "/formsemestre_inscription_with_modules_form", - sco_formsemestre_inscriptions.formsemestre_inscription_with_modules_form, - Permission.ScoEtudInscrit, -) -sco_publish( - "/formsemestre_inscription_with_modules_etud", - sco_formsemestre_inscriptions.formsemestre_inscription_with_modules_etud, - Permission.ScoEtudInscrit, -) -sco_publish( - "/formsemestre_inscription_with_modules", - sco_formsemestre_inscriptions.formsemestre_inscription_with_modules, - Permission.ScoEtudInscrit, -) -sco_publish( - "/formsemestre_inscription_option", - sco_formsemestre_inscriptions.formsemestre_inscription_option, - Permission.ScoEtudInscrit, - methods=["GET", "POST"], -) -sco_publish( - "/do_moduleimpl_incription_options", - sco_formsemestre_inscriptions.do_moduleimpl_incription_options, - Permission.ScoEtudInscrit, -) -sco_publish( - "/formsemestre_inscrits_ailleurs", - sco_formsemestre_inscriptions.formsemestre_inscrits_ailleurs, - Permission.ScoView, -) -sco_publish( - "/moduleimpl_inscriptions_edit", - sco_moduleimpl_inscriptions.moduleimpl_inscriptions_edit, - Permission.ScoEtudInscrit, - methods=["GET", "POST"], -) -sco_publish( - "/moduleimpl_inscriptions_stats", - sco_moduleimpl_inscriptions.moduleimpl_inscriptions_stats, - Permission.ScoView, -) - - -# --- Evaluations - - -@bp.route("/evaluation_delete", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoEnsView) -@scodoc7func -def evaluation_delete(evaluation_id): - """Form delete evaluation""" - El = sco_evaluation_db.do_evaluation_list(args={"evaluation_id": evaluation_id}) - if not El: - raise ScoValueError("Evaluation inexistante ! (%s)" % evaluation_id) - E = El[0] - M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] - Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] - tit = "Suppression de l'évaluation %(description)s (%(jour)s)" % E - etat = sco_evaluations.do_evaluation_etat(evaluation_id) - H = [ - html_sco_header.html_sem_header(tit, with_h2=False), - """

Module %(code)s %(titre)s

""" % Mod, - """

%s

""" % tit, - """

Opération irréversible. Si vous supprimez l'évaluation, vous ne pourrez pas retrouver les notes associées.

""", - ] - warning = False - if etat["nb_notes_total"]: - warning = True - nb_desinscrits = etat["nb_notes_total"] - etat["nb_notes"] - H.append( - """
Il y a %s notes""" % etat["nb_notes_total"] - ) - if nb_desinscrits: - H.append( - """ (dont %s d'étudiants qui ne sont plus inscrits)""" % nb_desinscrits - ) - H.append(""" dans l'évaluation""") - if etat["nb_notes"] == 0: - H.append( - """

Vous pouvez quand même supprimer l'évaluation, les notes des étudiants désincrits seront effacées.

""" - ) - - if etat["nb_notes"]: - H.append( - """

Suppression impossible (effacer les notes d'abord)

retour au tableau de bord du module

""" - % E["moduleimpl_id"] - ) - return "\n".join(H) + html_sco_header.sco_footer() - if warning: - H.append("""
""") - - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - (("evaluation_id", {"input_type": "hidden"}),), - initvalues=E, - submitlabel="Confirmer la suppression", - cancelbutton="Annuler", - ) - if tf[0] == 0: - return "\n".join(H) + tf[1] + html_sco_header.sco_footer() - elif tf[0] == -1: - return flask.redirect( - url_for( - "notes.moduleimpl_status", - scodoc_dept=g.scodoc_dept, - moduleimpl_id=E["moduleimpl_id"], - ) - ) - else: - sco_evaluation_db.do_evaluation_delete(E["evaluation_id"]) - return ( - "\n".join(H) - + f"""

OK, évaluation supprimée.

-

Continuer

""" - + html_sco_header.sco_footer() - ) - - -sco_publish( - "/do_evaluation_list", - sco_evaluation_db.do_evaluation_list, - Permission.ScoView, -) - - -@bp.route("/evaluation_edit", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoEnsView) -@scodoc7func -def evaluation_edit(evaluation_id): - "form edit evaluation" - return sco_evaluation_edit.evaluation_create_form( - evaluation_id=evaluation_id, edit=True - ) - - -@bp.route("/evaluation_create", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoEnsView) -@scodoc7func -def evaluation_create(moduleimpl_id): - "form create evaluation" - modimpl = ModuleImpl.query.get(moduleimpl_id) - if modimpl is None: - raise ScoValueError("Ce module n'existe pas ou plus !") - return sco_evaluation_edit.evaluation_create_form( - moduleimpl_id=moduleimpl_id, edit=False - ) - - -@bp.route("/evaluation_listenotes", methods=["GET", "POST"]) # API ScoDoc 7 compat -@scodoc -@permission_required_compat_scodoc7(Permission.ScoView) -@scodoc7func -def evaluation_listenotes(): - """Affichage des notes d'une évaluation""" - evaluation_id = None - moduleimpl_id = None - vals = scu.get_request_args() - try: - if "evaluation_id" in vals: - evaluation_id = int(vals["evaluation_id"]) - if "moduleimpl_id" in vals and vals["moduleimpl_id"]: - moduleimpl_id = int(vals["moduleimpl_id"]) - except ValueError as exc: - raise ScoValueError("evaluation_listenotes: id invalides !") from exc - - format = vals.get("format", "html") - html_content, page_title = sco_liste_notes.do_evaluation_listenotes( - evaluation_id=evaluation_id, moduleimpl_id=moduleimpl_id, format=format - ) - if format == "html": - H = html_sco_header.sco_header( - page_title=page_title, - cssstyles=["css/verticalhisto.css"], - javascripts=["js/etud_info.js"], - init_qtip=True, - ) - F = html_sco_header.sco_footer() - return H + html_content + F - else: - return html_content - - -sco_publish( - "/evaluation_list_operations", - sco_undo_notes.evaluation_list_operations, - Permission.ScoView, -) -sco_publish( - "/evaluation_check_absences_html", - sco_evaluation_check_abs.evaluation_check_absences_html, - Permission.ScoView, -) -sco_publish( - "/formsemestre_check_absences_html", - sco_evaluation_check_abs.formsemestre_check_absences_html, - Permission.ScoView, -) - -# --- Placement des étudiants pour l'évaluation -sco_publish( - "/placement_eval_selectetuds", - sco_placement.placement_eval_selectetuds, - Permission.ScoEnsView, - methods=["GET", "POST"], -) - -# --- Saisie des notes -sco_publish( - "/saisie_notes_tableur", - sco_saisie_notes.saisie_notes_tableur, - Permission.ScoEnsView, - methods=["GET", "POST"], -) -sco_publish( - "/feuille_saisie_notes", - sco_saisie_notes.feuille_saisie_notes, - Permission.ScoEnsView, -) -sco_publish("/saisie_notes", sco_saisie_notes.saisie_notes, Permission.ScoEnsView) -sco_publish( - "/save_note", - sco_saisie_notes.save_note, - Permission.ScoEnsView, - methods=["GET", "POST"], -) -sco_publish( - "/do_evaluation_set_missing", - sco_saisie_notes.do_evaluation_set_missing, - Permission.ScoEnsView, - methods=["GET", "POST"], -) -sco_publish( - "/evaluation_suppress_alln", - sco_saisie_notes.evaluation_suppress_alln, - Permission.ScoView, - methods=["GET", "POST"], -) - - -# --- Bulletins -@bp.route("/formsemestre_bulletins_pdf") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"): - "Publie les bulletins dans un classeur PDF" - pdfdoc, filename = sco_bulletins_pdf.get_formsemestre_bulletins_pdf( - formsemestre_id, version=version - ) - return scu.sendPDFFile(pdfdoc, filename) - - -_EXPL_BULL = """Versions des bulletins: -
    -
  • courte: moyennes des modules (en BUT: seulement les moyennes d'UE)
  • -
  • intermédiaire: moyennes des modules et notes des évaluations sélectionnées
  • -
  • complète: toutes les notes
  • -
""" - - -@bp.route("/formsemestre_bulletins_pdf_choice") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formsemestre_bulletins_pdf_choice(formsemestre_id, version=None): - """Choix version puis envois classeur bulletins pdf""" - if version: - pdfdoc, filename = sco_bulletins_pdf.get_formsemestre_bulletins_pdf( - formsemestre_id, version=version - ) - return scu.sendPDFFile(pdfdoc, filename) - return formsemestre_bulletins_choice( - formsemestre_id, - title="Choisir la version des bulletins à générer", - explanation=_EXPL_BULL, - ) - - -@bp.route("/etud_bulletins_pdf") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def etud_bulletins_pdf(etudid, version="selectedevals"): - "Publie tous les bulletins d'un etudiants dans un classeur PDF" - pdfdoc, filename = sco_bulletins_pdf.get_etud_bulletins_pdf(etudid, version=version) - return scu.sendPDFFile(pdfdoc, filename) - - -@bp.route("/formsemestre_bulletins_mailetuds_choice") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formsemestre_bulletins_mailetuds_choice( - formsemestre_id, - version=None, - dialog_confirmed=False, - prefer_mail_perso=0, -): - """Choix version puis envoi classeur bulletins pdf""" - if version: - # XXX à tester - return flask.redirect( - url_for( - "notes.formsemestre_bulletins_mailetuds", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - version=version, - dialog_confirmed=dialog_confirmed, - prefer_mail_perso=prefer_mail_perso, - ) - ) - - expl_bull = """Versions des bulletins:
  • courte: moyennes des modules
  • intermédiaire: moyennes des modules et notes des évaluations sélectionnées
  • complète: toutes les notes
    • """ - return formsemestre_bulletins_choice( - formsemestre_id, - title="Choisir la version des bulletins à envoyer par mail", - explanation="Chaque étudiant ayant une adresse mail connue de ScoDoc recevra une copie PDF de son bulletin de notes, dans la version choisie.

      " - + expl_bull, - choose_mail=True, - ) - - -# not published -def formsemestre_bulletins_choice( - formsemestre_id, title="", explanation="", choose_mail=False -): - """Choix d'une version de bulletin""" - H = [ - html_sco_header.html_sem_header(title), - """ -

      - - """ - % (request.base_url, formsemestre_id), - ] - H.append("""  """) - if choose_mail: - H.append( - """
      Utiliser si possible les adresses personnelles
      """ - ) - - H.append("""

      """ + explanation + """

      """) - - return "\n".join(H) + html_sco_header.sco_footer() - - -@bp.route("/formsemestre_bulletins_mailetuds") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formsemestre_bulletins_mailetuds( - formsemestre_id, - version="long", - dialog_confirmed=False, - prefer_mail_perso=0, -): - "envoi a chaque etudiant (inscrit et ayant un mail) son bulletin" - prefer_mail_perso = int(prefer_mail_perso) - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - etudids = nt.get_etudids() - # - if not sco_bulletins.can_send_bulletin_by_mail(formsemestre_id): - raise AccessDenied("vous n'avez pas le droit d'envoyer les bulletins") - # Confirmation dialog - if not dialog_confirmed: - return scu.confirm_dialog( - "

      Envoyer les %d bulletins par e-mail aux étudiants ?" % len(etudids), - dest_url="", - cancel_url="formsemestre_status?formsemestre_id=%s" % formsemestre_id, - parameters={ - "version": version, - "formsemestre_id": formsemestre_id, - "prefer_mail_perso": prefer_mail_perso, - }, - ) - - # Make each bulletin - nb_sent = 0 - for etudid in etudids: - sent, _ = sco_bulletins.do_formsemestre_bulletinetud( - formsemestre, - etudid, - version=version, - prefer_mail_perso=prefer_mail_perso, - format="pdfmail", - ) - if sent: - nb_sent += 1 - # - return ( - html_sco_header.sco_header() - + '

      %d bulletins sur %d envoyés par mail !

      continuer

      ' - % (nb_sent, len(etudids), formsemestre_id) - + html_sco_header.sco_footer() - ) - - -sco_publish( - "/external_ue_create_form", - sco_ue_external.external_ue_create_form, - Permission.ScoView, - methods=["GET", "POST"], -) - - -@bp.route("/appreciation_add_form", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoEnsView) -@scodoc7func -def appreciation_add_form( - etudid=None, - formsemestre_id=None, - id=None, # si id, edit - suppress=False, # si true, supress id -): - "form ajout ou edition d'une appreciation" - cnx = ndb.GetDBConnexion() - if id: # edit mode - apps = sco_etud.appreciations_list(cnx, args={"id": id}) - if not apps: - raise ScoValueError("id d'appreciation invalide !") - app = apps[0] - formsemestre_id = app["formsemestre_id"] - etudid = app["etudid"] - vals = scu.get_request_args() - if "edit" in vals: - edit = int(vals["edit"]) - elif id: - edit = 1 - else: - edit = 0 - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - # check custom access permission - can_edit_app = (current_user.id in sem["responsables"]) or ( - current_user.has_permission(Permission.ScoEtudInscrit) - ) - if not can_edit_app: - raise AccessDenied("vous n'avez pas le droit d'ajouter une appreciation") - # - bull_url = "formsemestre_bulletinetud?formsemestre_id=%s&etudid=%s" % ( - formsemestre_id, - etudid, - ) - if suppress: - sco_etud.appreciations_delete(cnx, id) - logdb(cnx, method="appreciation_suppress", etudid=etudid, msg="") - return flask.redirect(bull_url) - # - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - if id: - a = "Edition" - else: - a = "Ajout" - H = [ - html_sco_header.sco_header() - + "

      %s d'une appréciation sur %s

      " % (a, etud["nomprenom"]) - ] - F = html_sco_header.sco_footer() - descr = [ - ("edit", {"input_type": "hidden", "default": edit}), - ("etudid", {"input_type": "hidden"}), - ("formsemestre_id", {"input_type": "hidden"}), - ("id", {"input_type": "hidden"}), - ("comment", {"title": "", "input_type": "textarea", "rows": 4, "cols": 60}), - ] - if id: - initvalues = { - "etudid": etudid, - "formsemestre_id": formsemestre_id, - "comment": app["comment"], - } - else: - initvalues = {} - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - descr, - initvalues=initvalues, - cancelbutton="Annuler", - submitlabel="Ajouter appréciation", - ) - if tf[0] == 0: - return "\n".join(H) + "\n" + tf[1] + F - elif tf[0] == -1: - return flask.redirect(bull_url) - else: - args = { - "etudid": etudid, - "formsemestre_id": formsemestre_id, - "author": current_user.user_name, - "comment": tf[2]["comment"], - } - if edit: - args["id"] = id - sco_etud.appreciations_edit(cnx, args) - else: # nouvelle - sco_etud.appreciations_create(cnx, args) - # log - logdb( - cnx, - method="appreciation_add", - etudid=etudid, - msg=tf[2]["comment"], - ) - # ennuyeux mais necessaire (pour le PDF seulement) - sco_cache.invalidate_formsemestre( - pdfonly=True, formsemestre_id=formsemestre_id - ) # > appreciation_add - return flask.redirect(bull_url) - - -# --- FORMULAIRE POUR VALIDATION DES UE ET SEMESTRES - - -@bp.route("/formsemestre_validation_etud_form") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formsemestre_validation_etud_form( - formsemestre_id, - etudid=None, - etud_index=None, - check=0, - desturl="", - sortcol=None, -): - "Formulaire choix jury pour un étudiant" - readonly = not sco_permissions_check.can_validate_sem(formsemestre_id) - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - if formsemestre.formation.is_apc(): - return redirect( - url_for( - "notes.formsemestre_validation_but", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - etudid=etudid, - ) - ) - return sco_formsemestre_validation.formsemestre_validation_etud_form( - formsemestre_id, - etudid=etudid, - etud_index=etud_index, - check=check, - readonly=readonly, - desturl=desturl, - sortcol=sortcol, - ) - - -@bp.route("/formsemestre_validation_etud") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formsemestre_validation_etud( - formsemestre_id, - etudid=None, - codechoice=None, - desturl="", - sortcol=None, -): - "Enregistre choix jury pour un étudiant" - if not sco_permissions_check.can_validate_sem(formsemestre_id): - return scu.confirm_dialog( - message="

      Opération non autorisée pour %s" % current_user, - dest_url=scu.ScoURL(), - ) - - return sco_formsemestre_validation.formsemestre_validation_etud( - formsemestre_id, - etudid=etudid, - codechoice=codechoice, - desturl=desturl, - sortcol=sortcol, - ) - - -@bp.route("/formsemestre_validation_etud_manu") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formsemestre_validation_etud_manu( - formsemestre_id, - etudid=None, - code_etat="", - new_code_prev="", - devenir="", - assidu=False, - desturl="", - sortcol=None, -): - "Enregistre choix jury pour un étudiant" - if not sco_permissions_check.can_validate_sem(formsemestre_id): - return scu.confirm_dialog( - message="

      Opération non autorisée pour %s" % current_user, - dest_url=scu.ScoURL(), - ) - - return sco_formsemestre_validation.formsemestre_validation_etud_manu( - formsemestre_id, - etudid=etudid, - code_etat=code_etat, - new_code_prev=new_code_prev, - devenir=devenir, - assidu=assidu, - desturl=desturl, - sortcol=sortcol, - ) - - -# --- Jurys BUT -@bp.route( - "/formsemestre_validation_but//", - methods=["GET", "POST"], -) -@scodoc -@permission_required(Permission.ScoView) -def formsemestre_validation_but( - formsemestre_id: int, - etudid: int, -): - "Form. saisie décision jury semestre BUT" - # la route ne donne pas le type d'etudid pour pouvoir construire des URLs - # provisoires avec NEXT et PREV - try: - etudid = int(etudid) - except: - abort(404, "invalid etudid") - read_only = not sco_permissions_check.can_validate_sem(formsemestre_id) - - H = [ - html_sco_header.sco_header( - page_title="Validation BUT", - formsemestre_id=formsemestre_id, - etudid=etudid, - cssstyles=("css/jury_but.css",), - javascripts=("js/jury_but.js",), - ), - f""" -

      - """, - ] - - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - etud = Identite.query.get_or_404(etudid) - if formsemestre.etuds_inscriptions[etudid].etat != scu.INSCRIT: - return ( - "\n".join(H) - + f"""
      Impossible de statuer sur cet étudiant: - il est démissionnaire ou défaillant (voir sa fiche) -
      - -
      - """ - + html_sco_header.sco_footer() - ) - - deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre) - if len(deca.rcues_annee) == 0: - raise ScoValueError("année incomplète: pas de jury BUT annuel possible") - if request.method == "POST": - if not read_only: - deca.record_form(request.form) - flash("codes enregistrés") - return flask.redirect( - url_for( - "notes.formsemestre_validation_but", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - etudid=etudid, - ) - ) - - warning = "" - if len(deca.niveaux_competences) != len(deca.decisions_rcue_by_niveau): - warning += f"""
      Attention: {len(deca.niveaux_competences)} - niveaux mais {len(deca.decisions_rcue_by_niveau)} regroupements RCUE.
      """ - if deca.parcour is None: - warning += """
      L'étudiant n'est pas inscrit à un parcours.
      """ - H.append( - f""" -
      -
      -
      -
      Jury BUT{deca.annee_but} - - Parcours {(deca.parcour.libelle if deca.parcour else False) or "non spécifié"} - - {deca.annee_scolaire_str()}
      -
      {etud.nomprenom}
      -
      - -
      - {warning} -
      - - - """ - ) - - H.append(jury_but_view.show_etud(deca, read_only=read_only)) - - if read_only: - H.append( - """
      Vous n'avez pas la permission de modifier ces décisions. - Les champs entourés en vert sont enregistrés.
      """ - ) - else: - H.append( - f"""
      - - permettre la saisie manuelles des codes d'année et de niveaux. - Dans ce cas, il vous revient de vous assurer de la cohérence entre - vos codes d'UE/RCUE/Année ! - -
      - -
      - -
      - """ - ) - # --- Navigation - prev = f"""{scu.EMO_PREV_ARROW} précédent - """ - next = f"""suivant {scu.EMO_NEXT_ARROW} - """ - H.append( - f""" -
      - - - -
      - """ - ) - H.append("
      ") - - H.append( - render_template( - "but/documentation_codes_jury.html", - nom_univ=f"""Export {sco_preferences.get_preference("InstituteName") - or sco_preferences.get_preference("UnivName") - or "Apogée"}""", - codes=ScoDocSiteConfig.get_codes_apo_dict(), - ) - ) - - return "\n".join(H) + html_sco_header.sco_footer() - - -@bp.route( - "/formsemestre_validation_auto_but/", methods=["GET", "POST"] -) -@scodoc -@permission_required(Permission.ScoView) -def formsemestre_validation_auto_but(formsemestre_id: int = None): - "Saisie automatique des décisions de jury BUT" - if not sco_permissions_check.can_validate_sem(formsemestre_id): - return scu.confirm_dialog( - message=f"

      Opération non autorisée pour {current_user}", - dest_url=url_for( - "notes.formsemestre_status", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ), - ) - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - form = jury_but_forms.FormSemestreValidationAutoBUTForm() - if request.method == "POST": - if not form.cancel.data: - nb_admis = jury_but_validation_auto.formsemestre_validation_auto_but( - formsemestre - ) - flash(f"Décisions enregistrées ({nb_admis} admis)") - return redirect( - url_for( - "notes.formsemestre_saisie_jury", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ) - ) - return render_template( - "but/formsemestre_validation_auto_but.html", - form=form, - sco=ScoData(formsemestre=formsemestre), - title=f"Calcul automatique jury BUT", - ) - - -@bp.route("/formsemestre_validate_previous_ue", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formsemestre_validate_previous_ue(formsemestre_id, etudid=None): - "Form. saisie UE validée hors ScoDoc" - if not sco_permissions_check.can_validate_sem(formsemestre_id): - return scu.confirm_dialog( - message="

      Opération non autorisée pour %s" % current_user, - dest_url=scu.ScoURL(), - ) - return sco_formsemestre_validation.formsemestre_validate_previous_ue( - formsemestre_id, etudid - ) - - -sco_publish( - "/formsemestre_ext_create_form", - sco_formsemestre_exterieurs.formsemestre_ext_create_form, - Permission.ScoView, - methods=["GET", "POST"], -) - - -@bp.route("/formsemestre_ext_edit_ue_validations", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formsemestre_ext_edit_ue_validations(formsemestre_id, etudid=None): - "Form. edition UE semestre extérieur" - if not sco_permissions_check.can_validate_sem(formsemestre_id): - return scu.confirm_dialog( - message="

      Opération non autorisée pour %s" % current_user, - dest_url=scu.ScoURL(), - ) - return sco_formsemestre_exterieurs.formsemestre_ext_edit_ue_validations( - formsemestre_id, etudid - ) - - -sco_publish( - "/get_etud_ue_cap_html", - sco_formsemestre_validation.get_etud_ue_cap_html, - Permission.ScoView, -) - - -@bp.route("/etud_ue_suppress_validation") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def etud_ue_suppress_validation(etudid, formsemestre_id, ue_id): - """Suppress a validation (ue_id, etudid) and redirect to formsemestre""" - if not sco_permissions_check.can_validate_sem(formsemestre_id): - return scu.confirm_dialog( - message="

      Opération non autorisée pour %s" % current_user, - dest_url=scu.ScoURL(), - ) - return sco_formsemestre_validation.etud_ue_suppress_validation( - etudid, formsemestre_id, ue_id - ) - - -@bp.route("/formsemestre_validation_auto") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formsemestre_validation_auto(formsemestre_id): - "Formulaire saisie automatisee des decisions d'un semestre" - if not sco_permissions_check.can_validate_sem(formsemestre_id): - return scu.confirm_dialog( - message="

      Opération non autorisée pour %s" % current_user, - dest_url=scu.ScoURL(), - ) - - return sco_formsemestre_validation.formsemestre_validation_auto(formsemestre_id) - - -@bp.route("/do_formsemestre_validation_auto") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def do_formsemestre_validation_auto(formsemestre_id): - "Formulaire saisie automatisee des decisions d'un semestre" - if not sco_permissions_check.can_validate_sem(formsemestre_id): - return scu.confirm_dialog( - message="

      Opération non autorisée pour %s" % current_user, - dest_url=scu.ScoURL(), - ) - - return sco_formsemestre_validation.do_formsemestre_validation_auto(formsemestre_id) - - -@bp.route("/formsemestre_validation_suppress_etud", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formsemestre_validation_suppress_etud( - formsemestre_id, etudid, dialog_confirmed=False -): - """Suppression des décisions de jury pour un étudiant.""" - if not sco_permissions_check.can_validate_sem(formsemestre_id): - return scu.confirm_dialog( - message="

      Opération non autorisée pour %s" % current_user, - dest_url=scu.ScoURL(), - ) - etud = Identite.query.get_or_404(etudid) - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - if formsemestre.formation.is_apc(): - next_url = url_for( - "scolar.ficheEtud", - scodoc_dept=g.scodoc_dept, - etudid=etudid, - ) - else: - next_url = url_for( - "notes.formsemestre_validation_etud_form", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - etudid=etudid, - ) - if not dialog_confirmed: - d = sco_bulletins_json.dict_decision_jury( - etud, formsemestre, with_decisions=True - ) - - descr_ues = [f"{u['acronyme']}: {u['code']}" for u in d.get("decision_ue", [])] - dec_annee = d.get("decision_annee") - if dec_annee: - descr_annee = dec_annee.get("code", "-") - else: - descr_annee = "-" - - existing = f""" -

        -
      • Semestre : {d.get("decision", {"code":"-"})['code'] or "-"}
      • -
      • Année BUT: {descr_annee}
      • -
      • UEs : {", ".join(descr_ues)}
      • -
      • RCUEs: {len(d.get("decision_rcue", []))} décisions
      • -
      - """ - return scu.confirm_dialog( - f"""

      Confirmer la suppression des décisions du semestre - {formsemestre.titre_mois()} pour {etud.nomprenom} -

      -

      Cette opération est irréversible.

      -
      - {existing} -
      - """, - OK="Supprimer", - dest_url="", - cancel_url=next_url, - parameters={"etudid": etudid, "formsemestre_id": formsemestre_id}, - ) - - sco_formsemestre_validation.formsemestre_validation_suppress_etud( - formsemestre_id, etudid - ) - flash("Décisions supprimées") - return flask.redirect(next_url) - - -# ------------- PV de JURY et archives -sco_publish("/formsemestre_pvjury", sco_pvjury.formsemestre_pvjury, Permission.ScoView) - -sco_publish("/pvjury_table_but", jury_but_pv.pvjury_table_but, Permission.ScoView) - - -@bp.route("/formsemestre_saisie_jury") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formsemestre_saisie_jury(formsemestre_id: int, selected_etudid: int = None): - """Page de saisie: liste des étudiants et lien vers page jury - en semestres pairs de BUT, table spécifique avec l'année - sinon, redirect vers page recap en mode jury - """ - read_only = not sco_permissions_check.can_validate_sem(formsemestre_id) - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - if formsemestre.formation.is_apc() and formsemestre.semestre_id % 2 == 0: - return jury_but_recap.formsemestre_saisie_jury_but( - formsemestre, read_only, selected_etudid=selected_etudid - ) - return redirect( - url_for( - "notes.formsemestre_recapcomplet", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - mode_jury=1, - ) - ) - - -@bp.route("/formsemestre_jury_but_recap") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formsemestre_jury_but_recap(formsemestre_id: int, selected_etudid: int = None): - """Tableau affichage des codes""" - read_only = not sco_permissions_check.can_validate_sem(formsemestre_id) - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - if not (formsemestre.formation.is_apc() and formsemestre.semestre_id % 2 == 0): - raise ScoValueError( - "formsemestre_jury_but_recap: réservé aux semestres pairs de BUT" - ) - return jury_but_recap.formsemestre_saisie_jury_but( - formsemestre, read_only=read_only, selected_etudid=selected_etudid, mode="recap" - ) - - -@bp.route( - "/formsemestre_jury_but_erase//", - methods=["GET", "POST"], -) -@scodoc -@permission_required(Permission.ScoView) -def formsemestre_jury_but_erase(formsemestre_id: int, etudid: int = None): - """Supprime la décision de jury BUT pour cette année""" - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - if not formsemestre.formation.is_apc(): - raise ScoValueError("semestre non BUT") - etud: Identite = Identite.query.get_or_404(etudid) - if not sco_permissions_check.can_validate_sem(formsemestre_id): - raise ScoValueError("opération non autorisée") - dest_url = url_for( - "notes.formsemestre_validation_but", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - etudid=etudid, - ) - if request.method == "POST": - deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre) - deca.erase() - db.session.commit() - log(f"formsemestre_jury_but_erase({formsemestre_id}, {etudid})") - flash("décisions de jury effacées") - return redirect(dest_url) - - return render_template( - "confirm_dialog.html", - title=f"Effacer les validations de jury de {etud.nomprenom} ?", - explanation="""Les validations de toutes les UE, RCUE (compétences) et année seront effacées.""", - cancel_url=dest_url, - ) - - -sco_publish( - "/formsemestre_lettres_individuelles", - sco_pvjury.formsemestre_lettres_individuelles, - Permission.ScoView, - methods=["GET", "POST"], -) -sco_publish( - "/formsemestre_pvjury_pdf", sco_pvjury.formsemestre_pvjury_pdf, Permission.ScoView -) -sco_publish( - "/feuille_preparation_jury", - sco_prepajury.feuille_preparation_jury, - Permission.ScoView, -) -sco_publish( - "/formsemestre_archive", - sco_archives.formsemestre_archive, - Permission.ScoView, - methods=["GET", "POST"], -) -sco_publish( - "/formsemestre_delete_archive", - sco_archives.formsemestre_delete_archive, - Permission.ScoView, - methods=["GET", "POST"], -) -sco_publish( - "/formsemestre_list_archives", - sco_archives.formsemestre_list_archives, - Permission.ScoView, -) -sco_publish( - "/formsemestre_get_archived_file", - sco_archives.formsemestre_get_archived_file, - Permission.ScoView, -) -sco_publish("/view_apo_csv", sco_etape_apogee_view.view_apo_csv, Permission.ScoEditApo) -sco_publish( - "/view_apo_csv_store", - sco_etape_apogee_view.view_apo_csv_store, - Permission.ScoEditApo, - methods=["GET", "POST"], -) -sco_publish( - "/view_apo_csv_download_and_store", - sco_etape_apogee_view.view_apo_csv_download_and_store, - Permission.ScoEditApo, - methods=["GET", "POST"], -) -sco_publish( - "/view_apo_csv_delete", - sco_etape_apogee_view.view_apo_csv_delete, - Permission.ScoEditApo, - methods=["GET", "POST"], -) -sco_publish( - "/view_scodoc_etuds", sco_etape_apogee_view.view_scodoc_etuds, Permission.ScoEditApo -) -sco_publish( - "/view_apo_etuds", sco_etape_apogee_view.view_apo_etuds, Permission.ScoEditApo -) -sco_publish( - "/apo_semset_maq_status", - sco_etape_apogee_view.apo_semset_maq_status, - Permission.ScoEditApo, -) -sco_publish( - "/apo_csv_export_results", - sco_etape_apogee_view.apo_csv_export_results, - Permission.ScoEditApo, -) - - -@bp.route("/formsemestre_set_apo_etapes", methods=["POST"]) -@scodoc -@permission_required(Permission.ScoEditApo) -def formsemestre_set_apo_etapes(): - """Change les codes étapes du semestre indiqué. - Args: oid=formsemestre_id, value=chaine "V1RT, V1RT2", codes séparés par des virgules - """ - formsemestre_id = int(request.form.get("oid")) - etapes_apo_str = request.form.get("value") - formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) - current_etapes = {e.etape_apo for e in formsemestre.etapes} - new_etapes = {s.strip() for s in etapes_apo_str.split(",")} - - if new_etapes != current_etapes: - formsemestre.etapes = [] - for etape_apo in new_etapes: - etape = models.FormSemestreEtape( - formsemestre_id=formsemestre_id, etape_apo=etape_apo - ) - formsemestre.etapes.append(etape) - db.session.add(formsemestre) - db.session.commit() - ScolarNews.add( - typ=ScolarNews.NEWS_APO, - text=f"Modification code Apogée du semestre {formsemestre.titre_annee()})", - max_frequency=10 * 60, - ) - return ("", 204) - - -@bp.route("/formsemestre_set_elt_annee_apo", methods=["POST"]) -@scodoc -@permission_required(Permission.ScoEditApo) -def formsemestre_set_elt_annee_apo(): - """Change les codes étapes du semestre indiqué. - Args: oid=formsemestre_id, value=chaine "V3ONM, V3ONM1, V3ONM2", codes séparés par des virgules - """ - oid = int(request.form.get("oid")) - value = (request.form.get("value") or "").strip() - formsemestre: FormSemestre = FormSemestre.query.get_or_404(oid) - if value != formsemestre.elt_annee_apo: - formsemestre.elt_annee_apo = value - db.session.add(formsemestre) - db.session.commit() - ScolarNews.add( - typ=ScolarNews.NEWS_APO, - text=f"Modification code Apogée du semestre {formsemestre.titre_annee()})", - max_frequency=10 * 60, - ) - return ("", 204) - - -@bp.route("/formsemestre_set_elt_sem_apo", methods=["POST"]) -@scodoc -@permission_required(Permission.ScoEditApo) -def formsemestre_set_elt_sem_apo(): - """Change les codes étapes du semestre indiqué. - Args: oid=formsemestre_id, value=chaine "V3ONM, V3ONM1, V3ONM2", codes séparés par des virgules - """ - oid = int(request.form.get("oid")) - value = (request.form.get("value") or "").strip() - formsemestre: FormSemestre = FormSemestre.query.get_or_404(oid) - if value != formsemestre.elt_sem_apo: - formsemestre.elt_sem_apo = value - db.session.add(formsemestre) - db.session.commit() - ScolarNews.add( - typ=ScolarNews.NEWS_APO, - text=f"Modification code Apogée du semestre {formsemestre.titre_annee()})", - max_frequency=10 * 60, - ) - return ("", 204) - - -@bp.route("/ue_set_apo", methods=["POST"]) -@scodoc -@permission_required(Permission.ScoEditApo) -def ue_set_apo(): - """Change le code APO de l'UE - Args: oid=ue_id, value=chaine "VRTU12" (1 seul code / UE) - """ - ue_id = int(request.form.get("oid")) - code_apo = (request.form.get("value") or "").strip() - ue = UniteEns.query.get_or_404(ue_id) - if code_apo != ue.code_apogee: - ue.code_apogee = code_apo - db.session.add(ue) - db.session.commit() - ScolarNews.add( - typ=ScolarNews.NEWS_FORM, - text=f"Modification code Apogée d'UE dans la formation {ue.formation.titre} ({ue.formation.acronyme})", - max_frequency=10 * 60, - ) - return ("", 204) - - -@bp.route("/module_set_apo", methods=["POST"]) -@scodoc -@permission_required(Permission.ScoEditApo) -def module_set_apo(): - """Change le code APO du module - Args: oid=ue_id, value=chaine "VRTU12" (1 seul code / UE) - """ - oid = int(request.form.get("oid")) - code_apo = (request.form.get("value") or "").strip() - mod = Module.query.get_or_404(oid) - if code_apo != mod.code_apogee: - mod.code_apogee = code_apo - db.session.add(mod) - db.session.commit() - ScolarNews.add( - typ=ScolarNews.NEWS_FORM, - text=f"Modification code Apogée d'UE dans la formation {mod.formation.titre} ({mod.formation.acronyme})", - max_frequency=10 * 60, - ) - return ("", 204) - - -# sco_semset -sco_publish("/semset_page", sco_semset.semset_page, Permission.ScoEditApo) -sco_publish( - "/do_semset_create", - sco_semset.do_semset_create, - Permission.ScoEditApo, - methods=["GET", "POST"], -) -sco_publish( - "/do_semset_delete", - sco_semset.do_semset_delete, - Permission.ScoEditApo, - methods=["GET", "POST"], -) -sco_publish( - "/edit_semset_set_title", - sco_semset.edit_semset_set_title, - Permission.ScoEditApo, - methods=["GET", "POST"], -) -sco_publish( - "/do_semset_add_sem", - sco_semset.do_semset_add_sem, - Permission.ScoEditApo, - methods=["GET", "POST"], -) -sco_publish( - "/do_semset_remove_sem", - sco_semset.do_semset_remove_sem, - Permission.ScoEditApo, - methods=["GET", "POST"], -) - -# sco_export_result -sco_publish( - "/scodoc_table_results", - sco_export_results.scodoc_table_results, - Permission.ScoEditApo, -) - -sco_publish( - "/apo_compare_csv_form", - sco_apogee_compare.apo_compare_csv_form, - Permission.ScoView, - methods=["GET", "POST"], -) -sco_publish( - "/apo_compare_csv", - sco_apogee_compare.apo_compare_csv, - Permission.ScoView, - methods=["GET", "POST"], -) - -# ------------- INSCRIPTIONS: PASSAGE D'UN SEMESTRE A UN AUTRE -sco_publish( - "/formsemestre_inscr_passage", - sco_inscr_passage.formsemestre_inscr_passage, - Permission.ScoEtudInscrit, - methods=["GET", "POST"], -) -sco_publish( - "/formsemestre_synchro_etuds", - sco_synchro_etuds.formsemestre_synchro_etuds, - Permission.ScoView, - methods=["GET", "POST"], -) - -# ------------- RAPPORTS STATISTIQUES -sco_publish( - "/formsemestre_report_counts", - sco_report.formsemestre_report_counts, - Permission.ScoView, -) -sco_publish( - "/formsemestre_suivi_cohorte", - sco_report.formsemestre_suivi_cohorte, - Permission.ScoView, -) -sco_publish( - "/formsemestre_suivi_parcours", - sco_report.formsemestre_suivi_parcours, - Permission.ScoView, -) -sco_publish( - "/formsemestre_etuds_lycees", - sco_lycee.formsemestre_etuds_lycees, - Permission.ScoView, -) -sco_publish( - "/scodoc_table_etuds_lycees", - sco_lycee.scodoc_table_etuds_lycees, - Permission.ScoView, -) -sco_publish( - "/formsemestre_graph_parcours", - sco_report.formsemestre_graph_parcours, - Permission.ScoView, -) -sco_publish( - "/formsemestre_but_indicateurs", - sco_report_but.formsemestre_but_indicateurs, - Permission.ScoView, -) -sco_publish( - "/formsemestre_poursuite_report", - sco_poursuite_dut.formsemestre_poursuite_report, - Permission.ScoView, -) -sco_publish( - "/pe_view_sem_recap", - pe_view.pe_view_sem_recap, - Permission.ScoView, - methods=["GET", "POST"], -) -sco_publish( - "/report_debouche_date", sco_debouche.report_debouche_date, Permission.ScoView -) -sco_publish( - "/formsemestre_estim_cost", - sco_cost_formation.formsemestre_estim_cost, - Permission.ScoView, -) - -# -------------------------------------------------------------------- -# DEBUG - - -@bp.route("/check_sem_integrity") -@scodoc -@permission_required(Permission.ScoImplement) -@scodoc7func -def check_sem_integrity(formsemestre_id, fix=False): - """Debug. - Check that ue and module formations are consistents - """ - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - - modimpls = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id) - bad_ue = [] - bad_sem = [] - formations_set = set() # les formations mentionnées dans les UE et modules - for modimpl in modimpls: - mod = sco_edit_module.module_list({"module_id": modimpl["module_id"]})[0] - formations_set.add(mod["formation_id"]) - ue = sco_edit_ue.ue_list({"ue_id": mod["ue_id"]})[0] - formations_set.add(ue["formation_id"]) - if ue["formation_id"] != mod["formation_id"]: - modimpl["mod"] = mod - modimpl["ue"] = ue - bad_ue.append(modimpl) - if sem["formation_id"] != mod["formation_id"]: - bad_sem.append(modimpl) - modimpl["mod"] = mod - - H = [ - html_sco_header.sco_header(), - "

      formation_id=%s" % sem["formation_id"], - ] - if bad_ue: - H += [ - "

      Modules d'une autre formation que leur UE:

      ", - "
      ".join([str(x) for x in bad_ue]), - ] - if bad_sem: - H += [ - "

      Module du semestre dans une autre formation:

      ", - "
      ".join([str(x) for x in bad_sem]), - ] - if not bad_ue and not bad_sem: - H.append("

      Aucun problème à signaler !

      ") - else: - log("check_sem_integrity: problem detected: formations_set=%s" % formations_set) - if sem["formation_id"] in formations_set: - formations_set.remove(sem["formation_id"]) - if len(formations_set) == 1: - if fix: - log("check_sem_integrity: trying to fix %s" % formsemestre_id) - formation_id = formations_set.pop() - if sem["formation_id"] != formation_id: - sem["formation_id"] = formation_id - sco_formsemestre.do_formsemestre_edit(sem) - H.append("""

      Problème réparé: vérifiez

      """) - else: - H.append( - """ -

      Problème détecté réparable: - réparer maintenant

      - """ - % (formsemestre_id,) - ) - else: - H.append("""

      Problème détecté !

      """) - - return "\n".join(H) + html_sco_header.sco_footer() - - -@bp.route("/check_form_integrity") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def check_form_integrity(formation_id, fix=False): - "debug" - log("check_form_integrity: formation_id=%s fix=%s" % (formation_id, fix)) - ues = sco_edit_ue.ue_list(args={"formation_id": formation_id}) - bad = [] - for ue in ues: - mats = sco_edit_matiere.matiere_list(args={"ue_id": ue["ue_id"]}) - for mat in mats: - mods = sco_edit_module.module_list({"matiere_id": mat["matiere_id"]}) - for mod in mods: - if mod["ue_id"] != ue["ue_id"]: - if fix: - # fix mod.ue_id - log( - "fix: mod.ue_id = %s (was %s)" % (ue["ue_id"], mod["ue_id"]) - ) - mod["ue_id"] = ue["ue_id"] - sco_edit_module.do_module_edit(mod) - bad.append(mod) - if mod["formation_id"] != formation_id: - bad.append(mod) - if bad: - txth = "
      ".join([str(x) for x in bad]) - txt = "\n".join([str(x) for x in bad]) - log("check_form_integrity: formation_id=%s\ninconsistencies:" % formation_id) - log(txt) - # Notify by e-mail - send_scodoc_alarm("Notes: formation incoherente !", txt) - else: - txth = "OK" - log("ok") - return html_sco_header.sco_header() + txth + html_sco_header.sco_footer() - - -@bp.route("/check_formsemestre_integrity") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def check_formsemestre_integrity(formsemestre_id): - "debug" - log("check_formsemestre_integrity: formsemestre_id=%s" % (formsemestre_id)) - # verifie que tous les moduleimpl d'un formsemestre - # se réfèrent à un module dont l'UE appartient a la même formation - # Ancien bug: les ue_id étaient mal copiés lors des création de versions - # de formations - diag = [] - - Mlist = sco_moduleimpl.moduleimpl_withmodule_list(formsemestre_id=formsemestre_id) - for mod in Mlist: - if mod["module"]["ue_id"] != mod["matiere"]["ue_id"]: - diag.append( - "moduleimpl %s: module.ue_id=%s != matiere.ue_id=%s" - % ( - mod["moduleimpl_id"], - mod["module"]["ue_id"], - mod["matiere"]["ue_id"], - ) - ) - if mod["ue"]["formation_id"] != mod["module"]["formation_id"]: - diag.append( - "moduleimpl %s: ue.formation_id=%s != mod.formation_id=%s" - % ( - mod["moduleimpl_id"], - mod["ue"]["formation_id"], - mod["module"]["formation_id"], - ) - ) - if diag: - send_scodoc_alarm( - "Notes: formation incoherente dans semestre %s !" % formsemestre_id, - "\n".join(diag), - ) - log("check_formsemestre_integrity: formsemestre_id=%s" % formsemestre_id) - log("inconsistencies:\n" + "\n".join(diag)) - else: - diag = ["OK"] - log("ok") - return ( - html_sco_header.sco_header() + "
      ".join(diag) + html_sco_header.sco_footer() - ) - - -@bp.route("/check_integrity_all") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def check_integrity_all(): - "debug: verifie tous les semestres et tt les formations" - # formations - for F in sco_formations.formation_list(): - check_form_integrity(F["formation_id"]) - # semestres - for sem in sco_formsemestre.do_formsemestre_list(): - check_formsemestre_integrity(sem["formsemestre_id"]) - return ( - html_sco_header.sco_header() - + "

      empty page: see logs and mails

      " - + html_sco_header.sco_footer() - ) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +""" +Module notes: issu de ScoDoc7 / ZNotes.py + +Emmanuel Viennet, 2021 +""" + +from operator import itemgetter +import time +from xml.etree import ElementTree + +import flask +from flask import abort, flash, redirect, render_template, url_for +from flask import current_app, g, request +from flask_login import current_user + +from app import db +from app import models +from app.auth.models import User +from app.but import apc_edit_ue, jury_but_recap +from app.but import jury_but, jury_but_validation_auto +from app.but.forms import jury_but_forms +from app.but import jury_but_pv +from app.but import jury_but_view + +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import ScolarNews +from app.models.config import ScoDocSiteConfig +from app.models.etudiants import Identite +from app.models.formsemestre import FormSemestre +from app.models.formsemestre import FormSemestreUEComputationExpr +from app.models.moduleimpls import ModuleImpl +from app.models.modules import Module +from app.models.ues import UniteEns +from app.views import notes_bp as bp + +from app.decorators import ( + scodoc, + scodoc7func, + permission_required, + permission_required_compat_scodoc7, +) + + +# --------------- + +from app.scodoc import sco_bulletins_json, sco_utils as scu +from app.scodoc import notesdb as ndb +from app import log, send_scodoc_alarm + +from app.scodoc.scolog import logdb + +from app.scodoc.sco_exceptions import ( + AccessDenied, + ScoValueError, + ScoInvalidIdType, +) +from app.scodoc import html_sco_header +from app.pe import pe_view +from app.scodoc import sco_abs +from app.scodoc import sco_apogee_compare +from app.scodoc import sco_archives +from app.scodoc import sco_bulletins +from app.scodoc import sco_bulletins_pdf +from app.scodoc import sco_cache +from app.scodoc import sco_cost_formation +from app.scodoc import sco_debouche +from app.scodoc import sco_edit_apc +from app.scodoc import sco_edit_formation +from app.scodoc import sco_edit_matiere +from app.scodoc import sco_edit_module +from app.scodoc import sco_edit_ue +from app.scodoc import sco_etape_apogee_view +from app.scodoc import sco_etud +from app.scodoc import sco_evaluations +from app.scodoc import sco_evaluation_check_abs +from app.scodoc import sco_evaluation_db +from app.scodoc import sco_evaluation_edit +from app.scodoc import sco_evaluation_recap +from app.scodoc import sco_export_results +from app.scodoc import sco_formations +from app.scodoc import sco_formation_recap +from app.scodoc import sco_formsemestre +from app.scodoc import sco_formsemestre_custommenu +from app.scodoc import sco_formsemestre_edit +from app.scodoc import sco_formsemestre_exterieurs +from app.scodoc import sco_formsemestre_inscriptions +from app.scodoc import sco_formsemestre_status +from app.scodoc import sco_formsemestre_validation +from app.scodoc import sco_inscr_passage +from app.scodoc import sco_liste_notes +from app.scodoc import sco_lycee +from app.scodoc import sco_moduleimpl +from app.scodoc import sco_moduleimpl_inscriptions +from app.scodoc import sco_moduleimpl_status +from app.scodoc import sco_permissions_check +from app.scodoc import sco_placement +from app.scodoc import sco_poursuite_dut +from app.scodoc import sco_preferences +from app.scodoc import sco_prepajury +from app.scodoc import sco_pvjury +from app.scodoc import sco_recapcomplet +from app.scodoc import sco_report +from app.scodoc import sco_report_but +from app.scodoc import sco_saisie_notes +from app.scodoc import sco_semset +from app.scodoc import sco_synchro_etuds +from app.scodoc import sco_tag_module +from app.scodoc import sco_ue_external +from app.scodoc import sco_undo_notes +from app.scodoc import sco_users +from app.scodoc import sco_xml +from app.scodoc.gen_tables import GenTable +from app.scodoc.sco_permissions import Permission +from app.scodoc.TrivialFormulator import TrivialFormulator +from app.views import ScoData + + +def sco_publish(route, function, permission, methods=("GET",)): + """Declare a route for a python function, + protected by permission and called following ScoDoc 7 Zope standards. + """ + return bp.route(route, methods=methods)( + scodoc(permission_required(permission)(scodoc7func(function))) + ) + + +# -------------------------------------------------------------------- +# +# Notes/ methods +# +# -------------------------------------------------------------------- + +sco_publish( + "/formsemestre_status", + sco_formsemestre_status.formsemestre_status, + Permission.ScoView, +) + +sco_publish( + "/formsemestre_createwithmodules", + sco_formsemestre_edit.formsemestre_createwithmodules, + Permission.ScoImplement, + methods=["GET", "POST"], +) + +# controle d'acces specifique pour dir. etud: +sco_publish( + "/formsemestre_editwithmodules", + sco_formsemestre_edit.formsemestre_editwithmodules, + Permission.ScoView, + methods=["GET", "POST"], +) + +sco_publish( + "/formsemestre_clone", + sco_formsemestre_edit.formsemestre_clone, + Permission.ScoImplement, + methods=["GET", "POST"], +) +sco_publish( + "/formsemestre_associate_new_version", + sco_formsemestre_edit.formsemestre_associate_new_version, + Permission.ScoChangeFormation, + methods=["GET", "POST"], +) +sco_publish( + "/formsemestre_delete", + sco_formsemestre_edit.formsemestre_delete, + Permission.ScoImplement, + methods=["GET", "POST"], +) +sco_publish( + "/formsemestre_delete2", + sco_formsemestre_edit.formsemestre_delete2, + Permission.ScoImplement, + methods=["GET", "POST"], +) +sco_publish( + "/formsemestre_recapcomplet", + sco_recapcomplet.formsemestre_recapcomplet, + Permission.ScoView, +) +sco_publish( + "/evaluations_recap", + sco_evaluation_recap.evaluations_recap, + Permission.ScoView, +) +sco_publish( + "/formsemestres_bulletins", + sco_recapcomplet.formsemestres_bulletins, + Permission.ScoObservateur, +) +sco_publish( + "/moduleimpl_status", sco_moduleimpl_status.moduleimpl_status, Permission.ScoView +) +sco_publish( + "/formsemestre_description", + sco_formsemestre_status.formsemestre_description, + Permission.ScoView, +) + +sco_publish( + "/formation_create", + sco_edit_formation.formation_create, + Permission.ScoChangeFormation, + methods=["GET", "POST"], +) +sco_publish( + "/formation_delete", + sco_edit_formation.formation_delete, + Permission.ScoChangeFormation, + methods=["GET", "POST"], +) +sco_publish( + "/formation_edit", + sco_edit_formation.formation_edit, + Permission.ScoChangeFormation, + methods=["GET", "POST"], +) + + +@bp.route( + "/formsemestre_bulletinetud", methods=["GET", "POST"] +) # POST pour compat anciens clients PHP (deprecated) +@scodoc +@permission_required_compat_scodoc7(Permission.ScoView) +@scodoc7func +def formsemestre_bulletinetud( + etudid=None, + formsemestre_id=None, + format=None, + version="long", + xml_with_decisions=False, + force_publishing=False, + prefer_mail_perso=False, + code_nip=None, + code_ine=None, +): + format = format or "html" + + if formsemestre_id is not None and not isinstance(formsemestre_id, int): + raise ScoInvalidIdType( + "formsemestre_bulletinetud: formsemestre_id must be an integer !" + ) + formsemestre = FormSemestre.query.filter_by( + formsemestre_id=formsemestre_id, dept_id=g.scodoc_dept_id + ).first_or_404() + if etudid: + etud = models.Identite.query.filter_by( + etudid=etudid, dept_id=formsemestre.dept_id + ).first_or_404() + elif code_nip: + etud = models.Identite.query.filter_by( + code_nip=str(code_nip), dept_id=formsemestre.dept_id + ).first_or_404() + elif code_ine: + etud = models.Identite.query.filter_by( + code_ine=str(code_ine), dept_id=formsemestre.dept_id + ).first_or_404() + else: + raise ScoValueError( + "Paramètre manquant: spécifier etudid, code_nip ou code_ine" + ) + if format == "json": + return sco_bulletins.get_formsemestre_bulletin_etud_json( + formsemestre, etud, version=version, force_publishing=force_publishing + ) + if formsemestre.formation.is_apc() and format == "html": + return render_template( + "but/bulletin.html", + appreciations=models.BulAppreciations.query.filter_by( + etudid=etudid, formsemestre_id=formsemestre.id + ).order_by(models.BulAppreciations.date), + bul_url=url_for( + "notes.formsemestre_bulletinetud", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + etudid=etudid, + format="json", + force_publishing=1, # pour ScoDoc lui même + version=version, + ), + can_edit_appreciations=formsemestre.est_responsable(current_user) + or (current_user.has_permission(Permission.ScoEtudInscrit)), + etud=etud, + formsemestre=formsemestre, + inscription_courante=etud.inscription_courante(), + inscription_str=etud.inscription_descr()["inscription_str"], + is_apc=formsemestre.formation.is_apc(), + menu_autres_operations=sco_bulletins.make_menu_autres_operations( + formsemestre, etud, "notes.formsemestre_bulletinetud", version + ), + sco=ScoData(etud=etud), + scu=scu, + time=time, + title=f"Bul. {etud.nom} - BUT", + version=version, + ) + + if format == "oldjson": + format = "json" + r = sco_bulletins.formsemestre_bulletinetud( + etud, + formsemestre_id=formsemestre_id, + format=format, + version=version, + xml_with_decisions=xml_with_decisions, + force_publishing=force_publishing, + prefer_mail_perso=prefer_mail_perso, + ) + if format == "pdfmail": + return redirect( + url_for( + "notes.formsemestre_bulletinetud", + scodoc_dept=g.scodoc_dept, + etudid=etud.id, + formsemestre_id=formsemestre_id, + ) + ) + return r + + +sco_publish( + "/formsemestre_evaluations_cal", + sco_evaluations.formsemestre_evaluations_cal, + Permission.ScoView, +) +sco_publish( + "/formsemestre_evaluations_delai_correction", + sco_evaluations.formsemestre_evaluations_delai_correction, + Permission.ScoView, +) +sco_publish( + "/module_evaluation_renumber", + sco_evaluation_db.module_evaluation_renumber, + Permission.ScoView, +) +sco_publish( + "/module_evaluation_move", + sco_evaluation_db.module_evaluation_move, + Permission.ScoView, +) +sco_publish( + "/formsemestre_list_saisies_notes", + sco_undo_notes.formsemestre_list_saisies_notes, + Permission.ScoView, +) +sco_publish( + "/ue_create", + sco_edit_ue.ue_create, + Permission.ScoChangeFormation, + methods=["GET", "POST"], +) +sco_publish( + "/ue_delete", + sco_edit_ue.ue_delete, + Permission.ScoChangeFormation, + methods=["GET", "POST"], +) +sco_publish( + "/ue_edit", + sco_edit_ue.ue_edit, + Permission.ScoChangeFormation, + methods=["GET", "POST"], +) + + +@bp.route("/set_ue_niveau_competence", methods=["POST"]) +@scodoc +@permission_required(Permission.ScoChangeFormation) +def set_ue_niveau_competence(): + "associe UE et niveau" + ue_id = request.form.get("ue_id") + niveau_id = request.form.get("niveau_id") + return apc_edit_ue.set_ue_niveau_competence(ue_id, niveau_id) + + +@bp.route("/ue_list") # backward compat +@bp.route("/ue_table") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def ue_table(formation_id=None, semestre_idx=1, msg=""): + return sco_edit_ue.ue_table( + formation_id=formation_id, semestre_idx=semestre_idx, msg=msg + ) + + +@bp.route("/ue_infos/") +@scodoc +@permission_required(Permission.ScoView) +def ue_infos(ue_id): + ue = UniteEns.query.get_or_404(ue_id) + return sco_edit_apc.html_ue_infos(ue) + + +@bp.route("/ue_set_internal", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoChangeFormation) +@scodoc7func +def ue_set_internal(ue_id): + """""" + ue = models.UniteEns.query.get(ue_id) + if not ue: + raise ScoValueError("invalid ue_id") + ue.is_external = False + db.session.add(ue) + db.session.commit() + # Invalide les semestres de cette formation + ue.formation.invalidate_cached_sems() + + return redirect( + url_for( + "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=ue.formation_id + ) + ) + + +sco_publish("/ue_sharing_code", sco_edit_ue.ue_sharing_code, Permission.ScoView) +sco_publish( + "/edit_ue_set_code_apogee", + sco_edit_ue.edit_ue_set_code_apogee, + Permission.ScoChangeFormation, + methods=["POST"], +) +sco_publish( + "/formsemestre_edit_uecoefs", + sco_formsemestre_edit.formsemestre_edit_uecoefs, + Permission.ScoView, + methods=["GET", "POST"], +) + + +@bp.route("/formation_table_recap") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formation_table_recap(formation_id, format="html"): + return sco_formation_recap.formation_table_recap(formation_id, format="html") + + +sco_publish( + "/export_recap_formations_annee_scolaire", + sco_formation_recap.export_recap_formations_annee_scolaire, + Permission.ScoView, +) +sco_publish( + "/formation_add_malus_modules", + sco_edit_module.formation_add_malus_modules, + Permission.ScoChangeFormation, +) +sco_publish( + "/matiere_create", + sco_edit_matiere.matiere_create, + Permission.ScoChangeFormation, + methods=["GET", "POST"], +) +sco_publish( + "/matiere_delete", + sco_edit_matiere.matiere_delete, + Permission.ScoChangeFormation, + methods=["GET", "POST"], +) +sco_publish( + "/matiere_edit", + sco_edit_matiere.matiere_edit, + Permission.ScoChangeFormation, + methods=["GET", "POST"], +) +sco_publish( + "/module_create", + sco_edit_module.module_create, + Permission.ScoChangeFormation, + methods=["GET", "POST"], +) +sco_publish( + "/module_delete", + sco_edit_module.module_delete, + Permission.ScoChangeFormation, + methods=["GET", "POST"], +) +sco_publish( + "/module_edit", + sco_edit_module.module_edit, + Permission.ScoChangeFormation, + methods=["GET", "POST"], +) +sco_publish( + "/edit_module_set_code_apogee", + sco_edit_module.edit_module_set_code_apogee, + Permission.ScoChangeFormation, + methods=["GET", "POST"], +) +sco_publish("/module_list", sco_edit_module.module_table, Permission.ScoView) +sco_publish("/module_tag_search", sco_tag_module.module_tag_search, Permission.ScoView) + + +@bp.route("/module_tag_set", methods=["POST"]) +@scodoc +@permission_required(Permission.ScoEditFormationTags) +def module_tag_set(): + """Set tags on module""" + module_id = int(request.form.get("module_id")) + taglist = request.form.get("taglist") + return sco_tag_module.module_tag_set(module_id, taglist) + + +# +@bp.route("/") +@bp.route("/index_html") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def index_html(): + "Page accueil formations" + + editable = current_user.has_permission(Permission.ScoChangeFormation) + + H = [ + html_sco_header.sco_header(page_title="Programmes formations"), + """

      Programmes pédagogiques

      + """, + ] + T = sco_formations.formation_list_table() + + H.append(T.html()) + + if editable: + H.append( + f""" +

      Une "formation" est un programme pédagogique structuré + en UE, matières et modules. Chaque semestre se réfère à une formation. + La modification d'une formation affecte tous les semestres qui s'y + réfèrent.

      + + +

      Référentiels de compétences

      + + + """ + ) + + H.append(html_sco_header.sco_footer()) + return "\n".join(H) + + +# -------------------------------------------------------------------- +# +# Notes Methods +# +# -------------------------------------------------------------------- + +# --- Formations + +sco_publish( + "/do_formation_create", + sco_edit_formation.do_formation_create, + Permission.ScoChangeFormation, + methods=["GET", "POST"], +) + +sco_publish( + "/do_formation_delete", + sco_edit_formation.do_formation_delete, + Permission.ScoChangeFormation, + methods=["GET", "POST"], +) + + +@bp.route("/formation_list") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formation_list(format=None, formation_id=None, args={}): + """List formation(s) with given id, or matching args + (when args is given, formation_id is ignored). + """ + r = sco_formations.formation_list(formation_id=formation_id, args=args) + return scu.sendResult(r, name="formation", format=format) + + +@bp.route("/formation_export") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formation_export(formation_id, export_ids=False, format=None): + "Export de la formation au format indiqué (xml ou json)" + return sco_formations.formation_export( + formation_id, export_ids=export_ids, format=format + ) + + +@bp.route("/formation_import_xml_form", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoChangeFormation) +@scodoc7func +def formation_import_xml_form(): + "form import d'une formation en XML" + H = [ + html_sco_header.sco_header(page_title="Import d'une formation"), + """

      Import d'une formation

      +

      Création d'une formation (avec UE, matières, modules) + à partir un fichier XML (réservé aux utilisateurs avertis)

      + """, + ] + footer = html_sco_header.sco_footer() + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + (("xmlfile", {"input_type": "file", "title": "Fichier XML", "size": 30}),), + submitlabel="Importer", + cancelbutton="Annuler", + ) + if tf[0] == 0: + return "\n".join(H) + tf[1] + footer + elif tf[0] == -1: + return flask.redirect(scu.NotesURL()) + else: + formation_id, _, _ = sco_formations.formation_import_xml( + tf[2]["xmlfile"].read() + ) + + return ( + "\n".join(H) + + """

      Import effectué !

      +

      Voir la formation

      """ + % formation_id + + footer + ) + + +sco_publish( + "/formation_create_new_version", + sco_formations.formation_create_new_version, + Permission.ScoChangeFormation, +) + +# --- UE +sco_publish( + "/do_ue_create", + sco_edit_ue.do_ue_create, + Permission.ScoChangeFormation, + methods=["GET", "POST"], +) + +sco_publish( + "/ue_list", + sco_edit_ue.ue_list, + Permission.ScoView, +) + + +# --- Matieres +sco_publish( + "/do_matiere_create", + sco_edit_matiere.do_matiere_create, + Permission.ScoChangeFormation, + methods=["GET", "POST"], +) +sco_publish( + "/do_matiere_delete", + sco_edit_matiere.do_matiere_delete, + Permission.ScoChangeFormation, +) + + +# --- Modules +sco_publish( + "/do_module_delete", + sco_edit_module.do_module_delete, + Permission.ScoChangeFormation, +) + + +@bp.route("/formation_count_sems") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formation_count_sems(formation_id): + "Number of formsemestre in this formation (locked or not)" + sems = sco_formsemestre.do_formsemestre_list(args={"formation_id": formation_id}) + return len(sems) + + +sco_publish( + "/module_count_moduleimpls", + sco_edit_module.module_count_moduleimpls, + Permission.ScoView, +) + +sco_publish("/module_is_locked", sco_edit_module.module_is_locked, Permission.ScoView) + +sco_publish( + "/matiere_is_locked", sco_edit_matiere.matiere_is_locked, Permission.ScoView +) + +sco_publish( + "/module_move", sco_edit_formation.module_move, Permission.ScoChangeFormation +) +sco_publish("/ue_move", sco_edit_formation.ue_move, Permission.ScoChangeFormation) + + +# --- Semestres de formation + + +@bp.route( + "/formsemestre_list", methods=["GET", "POST"] +) # pour compat anciens clients PHP +@scodoc +@permission_required_compat_scodoc7(Permission.ScoView) +@scodoc7func +def formsemestre_list( + format="json", + formsemestre_id=None, + formation_id=None, + etape_apo=None, +): + """List formsemestres in given format. + kw can specify some conditions: examples: + formsemestre_list( format='json', formation_id='F777') + """ + try: + formsemestre_id = int(formsemestre_id) if formsemestre_id is not None else None + formation_id = int(formation_id) if formation_id is not None else None + except ValueError: + return scu.json_error(404, "invalid id") + # XAPI: new json api + args = {} + L = locals() + for argname in ("formsemestre_id", "formation_id", "etape_apo"): + if L[argname] is not None: + args[argname] = L[argname] + sems = sco_formsemestre.do_formsemestre_list(args=args) + # log('formsemestre_list: format="%s", %s semestres found' % (format,len(sems))) + return scu.sendResult(sems, name="formsemestre", format=format) + + +@bp.route( + "/XMLgetFormsemestres", methods=["GET", "POST"] +) # pour compat anciens clients PHP +@scodoc +@permission_required_compat_scodoc7(Permission.ScoView) +@scodoc7func +def XMLgetFormsemestres(etape_apo=None, formsemestre_id=None): + """List all formsemestres matching etape, XML format + DEPRECATED: use formsemestre_list() + """ + current_app.logger.debug("Warning: calling deprecated XMLgetFormsemestres") + if not formsemestre_id: + return flask.abort(404, "argument manquant: formsemestre_id") + if not isinstance(formsemestre_id, int): + return flask.abort( + 404, "XMLgetFormsemestres: formsemestre_id must be an integer !" + ) + args = {} + if etape_apo: + args["etape_apo"] = etape_apo + if formsemestre_id: + args["formsemestre_id"] = formsemestre_id + + doc = ElementTree.Element("formsemestrelist") + for sem in sco_formsemestre.do_formsemestre_list(args=args): + for k in sem: + if isinstance(sem[k], int): + sem[k] = str(sem[k]) + sem_elt = ElementTree.Element("formsemestre", **sem) + doc.append(sem_elt) + + data = sco_xml.XML_HEADER + ElementTree.tostring(doc).decode(scu.SCO_ENCODING) + return scu.send_file(data, mime=scu.XML_MIMETYPE) + + +sco_publish( + "/do_formsemestre_edit", + sco_formsemestre.do_formsemestre_edit, + Permission.ScoImplement, +) +sco_publish( + "/formsemestre_edit_options", + sco_formsemestre_edit.formsemestre_edit_options, + Permission.ScoView, + methods=["GET", "POST"], +) + + +@bp.route("/formsemestre_change_lock", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) # acces vérifié dans la fonction +@scodoc7func +def formsemestre_change_lock(formsemestre_id, dialog_confirmed=False): + "Changement de l'état de verrouillage du semestre" + + if not dialog_confirmed: + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + etat = not sem["etat"] + if etat: + msg = "déverrouillage" + else: + msg = "verrouillage" + return scu.confirm_dialog( + "

      Confirmer le %s du semestre ?

      " % msg, + helpmsg="""Les notes d'un semestre verrouillé ne peuvent plus être modifiées. + Un semestre verrouillé peut cependant être déverrouillé facilement à tout moment + (par son responsable ou un administrateur). +
      + Le programme d'une formation qui a un semestre verrouillé ne peut plus être modifié. + """, + dest_url="", + cancel_url="formsemestre_status?formsemestre_id=%s" % formsemestre_id, + parameters={"formsemestre_id": formsemestre_id}, + ) + + sco_formsemestre_edit.formsemestre_change_lock(formsemestre_id) + + return flask.redirect( + url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) + ) + + +sco_publish( + "/formsemestre_change_publication_bul", + sco_formsemestre_edit.formsemestre_change_publication_bul, + Permission.ScoView, + methods=["GET", "POST"], +) +sco_publish( + "/view_formsemestre_by_etape", + sco_formsemestre.view_formsemestre_by_etape, + Permission.ScoView, +) + + +@bp.route("/formsemestre_custommenu_edit", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formsemestre_custommenu_edit(formsemestre_id): + "Dialogue modif menu" + # accessible à tous ! + return sco_formsemestre_custommenu.formsemestre_custommenu_edit(formsemestre_id) + + +# --- dialogue modif enseignants/moduleimpl +@bp.route("/edit_enseignants_form", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def edit_enseignants_form(moduleimpl_id): + "modif liste enseignants/moduleimpl" + M, sem = sco_moduleimpl.can_change_ens(moduleimpl_id) + # -- + header = html_sco_header.html_sem_header( + 'Enseignants du module %s' + % (moduleimpl_id, M["module"]["titre"]), + page_title="Enseignants du module %s" % M["module"]["titre"], + javascripts=["libjs/AutoSuggest.js"], + cssstyles=["css/autosuggest_inquisitor.css"], + bodyOnLoad="init_tf_form('')", + ) + footer = html_sco_header.sco_footer() + + # Liste des enseignants avec forme pour affichage / saisie avec suggestion + userlist = sco_users.get_user_list() + uid2display = {} # uid : forme pour affichage = "NOM Prenom (login)"(login)" + for u in userlist: + uid2display[u.id] = u.get_nomplogin() + allowed_user_names = list(uid2display.values()) + + H = [ + "
      • %s (responsable)
      • " + % uid2display.get(M["responsable_id"], M["responsable_id"]) + ] + for ens in M["ens"]: + u = User.query.get(ens["ens_id"]) + if u: + nom = u.get_nomcomplet() + else: + nom = "? (compte inconnu)" + H.append( + f""" +
      • {nom} (supprimer) +
      • """ + ) + H.append("
      ") + F = """

      Les enseignants d'un module ont le droit de + saisir et modifier toutes les notes des évaluations de ce module. +

      +

      Pour changer le responsable du module, passez par la + page "Modification du semestre", accessible uniquement au responsable de la formation (chef de département) +

      + """ % ( + sem["formation_id"], + M["formsemestre_id"], + ) + + modform = [ + ("moduleimpl_id", {"input_type": "hidden"}), + ( + "ens_id", + { + "input_type": "text_suggest", + "size": 50, + "title": "Ajouter un enseignant", + "allowed_values": allowed_user_names, + "allow_null": False, + "text_suggest_options": { + "script": url_for( + "users.get_user_list_xml", scodoc_dept=g.scodoc_dept + ) + + "?", + "varname": "start", + "json": False, + "noresults": "Valeur invalide !", + "timeout": 60000, + }, + }, + ), + ] + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + modform, + submitlabel="Ajouter enseignant", + cancelbutton="Annuler", + ) + if tf[0] == 0: + return header + "\n".join(H) + tf[1] + F + footer + elif tf[0] == -1: + return flask.redirect( + url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=moduleimpl_id, + ) + ) + else: + ens_id = User.get_user_id_from_nomplogin(tf[2]["ens_id"]) + if not ens_id: + H.append( + '

      Pour ajouter un enseignant, choisissez un nom dans le menu

      ' + ) + else: + # et qu'il n'est pas deja: + if ( + ens_id in [x["ens_id"] for x in M["ens"]] + or ens_id == M["responsable_id"] + ): + H.append( + '

      Enseignant %s déjà dans la liste !

      ' % ens_id + ) + else: + sco_moduleimpl.do_ens_create( + {"moduleimpl_id": moduleimpl_id, "ens_id": ens_id} + ) + return flask.redirect( + "edit_enseignants_form?moduleimpl_id=%s" % moduleimpl_id + ) + return header + "\n".join(H) + tf[1] + F + footer + + +@bp.route("/edit_moduleimpl_resp", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def edit_moduleimpl_resp(moduleimpl_id): + """Changement d'un enseignant responsable de module + Accessible par Admin et dir des etud si flag resp_can_change_ens + """ + M, sem = sco_moduleimpl.can_change_module_resp(moduleimpl_id) + H = [ + html_sco_header.html_sem_header( + 'Modification du responsable du module %s' + % (moduleimpl_id, M["module"]["titre"]), + javascripts=["libjs/AutoSuggest.js"], + cssstyles=["css/autosuggest_inquisitor.css"], + bodyOnLoad="init_tf_form('')", + ) + ] + help_str = """

      Taper le début du nom de l'enseignant.

      """ + # Liste des enseignants avec forme pour affichage / saisie avec suggestion + userlist = [sco_users.user_info(user=u) for u in sco_users.get_user_list()] + uid2display = {} # uid : forme pour affichage = "NOM Prenom (login)" + for u in userlist: + uid2display[u["id"]] = u["nomplogin"] + allowed_user_names = list(uid2display.values()) + + initvalues = M + initvalues["responsable_id"] = uid2display.get( + M["responsable_id"], M["responsable_id"] + ) + form = [ + ("moduleimpl_id", {"input_type": "hidden"}), + ( + "responsable_id", + { + "input_type": "text_suggest", + "size": 50, + "title": "Responsable du module", + "allowed_values": allowed_user_names, + "allow_null": False, + "text_suggest_options": { + "script": url_for( + "users.get_user_list_xml", scodoc_dept=g.scodoc_dept + ) + + "?", + "varname": "start", + "json": False, + "noresults": "Valeur invalide !", + "timeout": 60000, + }, + }, + ), + ] + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + form, + submitlabel="Changer responsable", + cancelbutton="Annuler", + initvalues=initvalues, + ) + if tf[0] == 0: + return "\n".join(H) + tf[1] + help_str + html_sco_header.sco_footer() + elif tf[0] == -1: + return flask.redirect( + url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=moduleimpl_id, + ) + ) + else: + responsable_id = User.get_user_id_from_nomplogin(tf[2]["responsable_id"]) + if ( + not responsable_id + ): # presque impossible: tf verifie les valeurs (mais qui peuvent changer entre temps) + return flask.redirect( + url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=moduleimpl_id, + ) + ) + + sco_moduleimpl.do_moduleimpl_edit( + {"moduleimpl_id": moduleimpl_id, "responsable_id": responsable_id}, + formsemestre_id=sem["formsemestre_id"], + ) + return flask.redirect( + url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=moduleimpl_id, + head_message="responsable modifié", + ) + ) + + +_EXPR_HELP = """

      Expérimental: formule de calcul de la moyenne %(target)s

      +

      Attention: l'utilisation de formules ralentit considérablement + les traitements. A utiliser uniquement dans les cas ne pouvant pas être traités autrement.

      +

      Dans la formule, les variables suivantes sont définies:

      +
        +
      • moy la moyenne, calculée selon la règle standard (moyenne pondérée)
      • +
      • moy_is_valid vrai si la moyenne est valide (numérique)
      • +
      • moy_val la valeur de la moyenne (nombre, valant 0 si invalide)
      • +
      • notes vecteur des notes (/20) aux %(objs)s
      • +
      • coefs vecteur des coefficients des %(objs)s, les coefs des %(objs)s sans notes (ATT, EXC) étant mis à zéro
      • +
      • cmask vecteur de 0/1, 0 si le coef correspondant a été annulé
      • +
      • Nombre d'absences: nb_abs, nb_abs_just, nb_abs_nojust (en demi-journées)
      • +
      +

      Les éléments des vecteurs sont ordonnés dans l'ordre des %(objs)s%(ordre)s.

      +

      Les fonctions suivantes sont utilisables: abs, cmp, dot, len, map, max, min, pow, reduce, round, sum, ifelse.

      +

      La notation V(1,2,3) représente un vecteur (1,2,3).

      +

      Pour indiquer que la note calculée n'existe pas, utiliser la chaîne 'NA'.

      +

      Vous pouvez désactiver la formule (et revenir au mode de calcul "classique") + en supprimant le texte ou en faisant précéder la première ligne par #

      +""" + + +@bp.route("/edit_moduleimpl_expr", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def edit_moduleimpl_expr(moduleimpl_id): + """Edition formule calcul moyenne module + Accessible par Admin, dir des etud et responsable module + + Inutilisé en ScoDoc 9. + """ + M, sem = sco_moduleimpl.can_change_ens(moduleimpl_id) + H = [ + html_sco_header.html_sem_header( + 'Modification règle de calcul du module %s' + % (moduleimpl_id, M["module"]["titre"]), + ), + _EXPR_HELP + % { + "target": "du module", + "objs": "évaluations", + "ordre": " (le premier élément est la plus ancienne évaluation)", + }, + ] + initvalues = M + form = [ + ("moduleimpl_id", {"input_type": "hidden"}), + ( + "computation_expr", + { + "title": "Formule de calcul", + "input_type": "textarea", + "rows": 4, + "cols": 60, + "explanation": "formule de calcul (expérimental)", + }, + ), + ] + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + form, + submitlabel="Modifier formule de calcul", + cancelbutton="Annuler", + initvalues=initvalues, + ) + if tf[0] == 0: + return "\n".join(H) + tf[1] + html_sco_header.sco_footer() + elif tf[0] == -1: + return flask.redirect( + url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=moduleimpl_id, + ) + ) + else: + sco_moduleimpl.do_moduleimpl_edit( + { + "moduleimpl_id": moduleimpl_id, + "computation_expr": tf[2]["computation_expr"], + }, + formsemestre_id=sem["formsemestre_id"], + ) + sco_cache.invalidate_formsemestre( + formsemestre_id=sem["formsemestre_id"] + ) # > modif regle calcul + return flask.redirect( + url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=moduleimpl_id, + head_message="règle%20de%20calcul%20modifiée", + ) + ) + + +@bp.route("/delete_moduleimpl_expr", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def delete_moduleimpl_expr(moduleimpl_id): + """Suppression formule calcul moyenne module + Accessible par Admin, dir des etud et responsable module + """ + modimpl = ModuleImpl.query.get_or_404(moduleimpl_id) + sco_moduleimpl.can_change_ens(moduleimpl_id) + modimpl.computation_expr = None + db.session.add(modimpl) + db.session.commit() + flash("Ancienne formule supprimée") + return flask.redirect( + url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=moduleimpl_id, + ) + ) + + +@bp.route("/view_module_abs") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def view_module_abs(moduleimpl_id, format="html"): + """Visualisation des absences a un module""" + M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=moduleimpl_id)[0] + sem = sco_formsemestre.get_formsemestre(M["formsemestre_id"]) + debut_sem = ndb.DateDMYtoISO(sem["date_debut"]) + fin_sem = ndb.DateDMYtoISO(sem["date_fin"]) + list_insc = sco_moduleimpl.moduleimpl_listeetuds(moduleimpl_id) + + T = [] + for etudid in list_insc: + nb_abs = sco_abs.count_abs( + etudid=etudid, + debut=debut_sem, + fin=fin_sem, + moduleimpl_id=moduleimpl_id, + ) + if nb_abs: + nb_abs_just = sco_abs.count_abs_just( + etudid=etudid, + debut=debut_sem, + fin=fin_sem, + moduleimpl_id=moduleimpl_id, + ) + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + T.append( + { + "nomprenom": etud["nomprenom"], + "just": nb_abs_just, + "nojust": nb_abs - nb_abs_just, + "total": nb_abs, + "_nomprenom_target": url_for( + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + ), + } + ) + + H = [ + html_sco_header.html_sem_header( + 'Absences du module %s' + % (moduleimpl_id, M["module"]["titre"]), + page_title="Absences du module %s" % (M["module"]["titre"]), + ) + ] + if not T and format == "html": + return ( + "\n".join(H) + + "

      Aucune absence signalée

      " + + html_sco_header.sco_footer() + ) + + tab = GenTable( + titles={ + "nomprenom": "Nom", + "just": "Just.", + "nojust": "Non Just.", + "total": "Total", + }, + columns_ids=("nomprenom", "just", "nojust", "total"), + rows=T, + html_class="table_leftalign", + base_url="%s?moduleimpl_id=%s" % (request.base_url, moduleimpl_id), + filename="absmodule_" + scu.make_filename(M["module"]["titre"]), + caption="Absences dans le module %s" % M["module"]["titre"], + preferences=sco_preferences.SemPreferences(), + ) + + if format != "html": + return tab.make_page(format=format) + + return "\n".join(H) + tab.html() + html_sco_header.sco_footer() + + +@bp.route("/delete_ue_expr//", methods=["GET", "POST"]) +@scodoc +def delete_ue_expr(formsemestre_id: int, ue_id: int): + """Efface une expression de calcul d'UE""" + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + if not formsemestre.can_be_edited_by(current_user): + raise AccessDenied("vous n'avez pas le droit d'effectuer cette opération") + expr = FormSemestreUEComputationExpr.query.filter_by( + formsemestre_id=formsemestre_id, ue_id=ue_id + ).first() + if expr is not None: + db.session.delete(expr) + db.session.commit() + flash("formule supprimée") + return flask.redirect( + url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + head_message="formule supprimée", + ) + ) + + +@bp.route("/formsemestre_enseignants_list") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formsemestre_enseignants_list(formsemestre_id, format="html"): + """Liste les enseignants intervenants dans le semestre (resp. modules et chargés de TD) + et indique les absences saisies par chacun. + """ + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + # resp. de modules: + mods = sco_moduleimpl.moduleimpl_withmodule_list(formsemestre_id=formsemestre_id) + sem_ens = {} + for mod in mods: + if not mod["responsable_id"] in sem_ens: + sem_ens[mod["responsable_id"]] = {"mods": [mod]} + else: + sem_ens[mod["responsable_id"]]["mods"].append(mod) + # charges de TD: + for mod in mods: + for ensd in mod["ens"]: + if not ensd["ens_id"] in sem_ens: + sem_ens[ensd["ens_id"]] = {"mods": [mod]} + else: + sem_ens[ensd["ens_id"]]["mods"].append(mod) + # compte les absences ajoutées par chacun dans tout le semestre + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + for ens in sem_ens: + u = User.query.filter_by(id=ens).first() + if not u: + continue + cursor.execute( + """SELECT * FROM scolog L, notes_formsemestre_inscription I + WHERE method = 'AddAbsence' + and authenticated_user = %(authenticated_user)s + and L.etudid = I.etudid + and I.formsemestre_id = %(formsemestre_id)s + and date > %(date_debut)s + and date < %(date_fin)s + """, + { + "authenticated_user": u.user_name, + "formsemestre_id": formsemestre_id, + "date_debut": ndb.DateDMYtoISO(sem["date_debut"]), + "date_fin": ndb.DateDMYtoISO(sem["date_fin"]), + }, + ) + + events = cursor.dictfetchall() + sem_ens[ens]["nbabsadded"] = len(events) + + # description textuelle des modules + for ens in sem_ens: + sem_ens[ens]["descr_mods"] = ", ".join( + [x["module"]["code"] or "?" for x in sem_ens[ens]["mods"]] + ) + + # ajoute infos sur enseignant: + for ens in sem_ens: + sem_ens[ens].update(sco_users.user_info(ens)) + if sem_ens[ens]["email"]: + sem_ens[ens]["_email_target"] = "mailto:%s" % sem_ens[ens]["email"] + + sem_ens_list = list(sem_ens.values()) + sem_ens_list.sort(key=itemgetter("nomprenom")) + + # --- Generate page with table + title = "Enseignants de " + sem["titremois"] + T = GenTable( + columns_ids=["nom_fmt", "prenom_fmt", "descr_mods", "nbabsadded", "email"], + titles={ + "nom_fmt": "Nom", + "prenom_fmt": "Prénom", + "email": "Mail", + "descr_mods": "Modules", + "nbabsadded": "Saisies Abs.", + }, + rows=sem_ens_list, + html_sortable=True, + html_class="table_leftalign", + filename=scu.make_filename("Enseignants-" + sem["titreannee"]), + html_title=html_sco_header.html_sem_header( + "Enseignants du semestre", with_page_header=False + ), + base_url="%s?formsemestre_id=%s" % (request.base_url, formsemestre_id), + caption="Tous les enseignants (responsables ou associés aux modules de ce semestre) apparaissent. Le nombre de saisies d'absences est le nombre d'opérations d'ajout effectuées sur ce semestre, sans tenir compte des annulations ou double saisies.", + preferences=sco_preferences.SemPreferences(formsemestre_id), + ) + return T.make_page(page_title=title, title=title, format=format) + + +@bp.route("/edit_enseignants_form_delete", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def edit_enseignants_form_delete(moduleimpl_id, ens_id: int): + """remove ens from this modueimpl + + ens_id: user.id + """ + M, _ = sco_moduleimpl.can_change_ens(moduleimpl_id) + # search ens_id + ok = False + for ens in M["ens"]: + if ens["ens_id"] == ens_id: + ok = True + break + if not ok: + raise ScoValueError("invalid ens_id (%s)" % ens_id) + ndb.SimpleQuery( + """DELETE FROM notes_modules_enseignants + WHERE moduleimpl_id = %(moduleimpl_id)s + AND ens_id = %(ens_id)s + """, + {"moduleimpl_id": moduleimpl_id, "ens_id": ens_id}, + ) + return flask.redirect("edit_enseignants_form?moduleimpl_id=%s" % moduleimpl_id) + + +# --- Gestion des inscriptions aux semestres + +# Ancienne API, pas certain de la publier en ScoDoc8 +# sco_publish( +# "/do_formsemestre_inscription_create", +# sco_formsemestre_inscriptions.do_formsemestre_inscription_create, +# Permission.ScoEtudInscrit, +# ) +# sco_publish( +# "/do_formsemestre_inscription_edit", +# sco_formsemestre_inscriptions.do_formsemestre_inscription_edit, +# Permission.ScoEtudInscrit, +# ) + +sco_publish( + "/do_formsemestre_inscription_list", + sco_formsemestre_inscriptions.do_formsemestre_inscription_list, + Permission.ScoView, +) + + +@bp.route("/do_formsemestre_inscription_listinscrits") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def do_formsemestre_inscription_listinscrits(formsemestre_id, format=None): + """Liste les inscrits (état I) à ce semestre et cache le résultat""" + r = sco_formsemestre_inscriptions.do_formsemestre_inscription_listinscrits( + formsemestre_id + ) + return scu.sendResult(r, format=format, name="inscrits") + + +@bp.route("/formsemestre_desinscription", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoImplement) +@scodoc7func +def formsemestre_desinscription(etudid, formsemestre_id, dialog_confirmed=False): + """désinscrit l'etudiant de ce semestre (et donc de tous les modules). + A n'utiliser qu'en cas d'erreur de saisie. + S'il s'agit d'un semestre extérieur et qu'il n'y a plus d'inscrit, + le semestre sera supprimé. + """ + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + sem = formsemestre.to_dict() # compat + # -- check lock + if not formsemestre.etat: + raise ScoValueError("desinscription impossible: semestre verrouille") + + # -- Si décisions de jury, désinscription interdite + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + if nt.etud_has_decision(etudid): + raise ScoValueError( + f"""Désinscription impossible: l'étudiant a une décision de jury + (la supprimer avant si nécessaire: + supprimer décision jury + ) + """ + ) + if not dialog_confirmed: + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + if formsemestre.modalite != "EXT": + msg_ext = """ +

      %s sera désinscrit de tous les modules du semestre %s (%s - %s).

      +

      Cette opération ne doit être utilisée que pour corriger une erreur ! + Un étudiant réellement inscrit doit le rester, le faire éventuellement démissionner. +

      + """ % ( + etud["nomprenom"], + sem["titre_num"], + sem["date_debut"], + sem["date_fin"], + ) + else: # semestre extérieur + msg_ext = """ +

      %s sera désinscrit du semestre extérieur %s (%s - %s).

      + """ % ( + etud["nomprenom"], + sem["titre_num"], + sem["date_debut"], + sem["date_fin"], + ) + inscrits = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( + args={"formsemestre_id": formsemestre_id} + ) + nbinscrits = len(inscrits) + if nbinscrits <= 1: + msg_ext = """

      Attention: le semestre extérieur + sera supprimé car il n'a pas d'autre étudiant inscrit. +

      + """ + return scu.confirm_dialog( + """

      Confirmer la demande de désinscription ?

      """ + msg_ext, + dest_url="", + cancel_url=url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ), + parameters={"etudid": etudid, "formsemestre_id": formsemestre_id}, + ) + + sco_formsemestre_inscriptions.do_formsemestre_desinscription( + etudid, formsemestre_id + ) + + flash("Étudiant désinscrit") + return redirect( + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + ) + + +sco_publish( + "/do_formsemestre_desinscription", + sco_formsemestre_inscriptions.do_formsemestre_desinscription, + Permission.ScoEtudInscrit, + methods=["GET", "POST"], +) + + +@bp.route("/etud_desinscrit_ue") +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@scodoc7func +def etud_desinscrit_ue(etudid, formsemestre_id, ue_id): + """Desinscrit l'etudiant de tous les modules de cette UE dans ce semestre.""" + sco_moduleimpl_inscriptions.do_etud_desinscrit_ue(etudid, formsemestre_id, ue_id) + return flask.redirect( + scu.ScoURL() + + "/Notes/moduleimpl_inscriptions_stats?formsemestre_id=" + + str(formsemestre_id) + ) + + +@bp.route("/etud_inscrit_ue") +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@scodoc7func +def etud_inscrit_ue(etudid, formsemestre_id, ue_id): + """Inscrit l'etudiant de tous les modules de cette UE dans ce semestre.""" + sco_moduleimpl_inscriptions.do_etud_inscrit_ue(etudid, formsemestre_id, ue_id) + return flask.redirect( + scu.ScoURL() + + "/Notes/moduleimpl_inscriptions_stats?formsemestre_id=" + + str(formsemestre_id) + ) + + +# --- Inscriptions +sco_publish( + "/formsemestre_inscription_with_modules_form", + sco_formsemestre_inscriptions.formsemestre_inscription_with_modules_form, + Permission.ScoEtudInscrit, +) +sco_publish( + "/formsemestre_inscription_with_modules_etud", + sco_formsemestre_inscriptions.formsemestre_inscription_with_modules_etud, + Permission.ScoEtudInscrit, +) +sco_publish( + "/formsemestre_inscription_with_modules", + sco_formsemestre_inscriptions.formsemestre_inscription_with_modules, + Permission.ScoEtudInscrit, +) +sco_publish( + "/formsemestre_inscription_option", + sco_formsemestre_inscriptions.formsemestre_inscription_option, + Permission.ScoEtudInscrit, + methods=["GET", "POST"], +) +sco_publish( + "/do_moduleimpl_incription_options", + sco_formsemestre_inscriptions.do_moduleimpl_incription_options, + Permission.ScoEtudInscrit, +) +sco_publish( + "/formsemestre_inscrits_ailleurs", + sco_formsemestre_inscriptions.formsemestre_inscrits_ailleurs, + Permission.ScoView, +) +sco_publish( + "/moduleimpl_inscriptions_edit", + sco_moduleimpl_inscriptions.moduleimpl_inscriptions_edit, + Permission.ScoEtudInscrit, + methods=["GET", "POST"], +) +sco_publish( + "/moduleimpl_inscriptions_stats", + sco_moduleimpl_inscriptions.moduleimpl_inscriptions_stats, + Permission.ScoView, +) + + +# --- Evaluations + + +@bp.route("/evaluation_delete", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoEnsView) +@scodoc7func +def evaluation_delete(evaluation_id): + """Form delete evaluation""" + El = sco_evaluation_db.do_evaluation_list(args={"evaluation_id": evaluation_id}) + if not El: + raise ScoValueError("Evaluation inexistante ! (%s)" % evaluation_id) + E = El[0] + M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] + Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] + tit = "Suppression de l'évaluation %(description)s (%(jour)s)" % E + etat = sco_evaluations.do_evaluation_etat(evaluation_id) + H = [ + html_sco_header.html_sem_header(tit, with_h2=False), + """

      Module %(code)s %(titre)s

      """ % Mod, + """

      %s

      """ % tit, + """

      Opération irréversible. Si vous supprimez l'évaluation, vous ne pourrez pas retrouver les notes associées.

      """, + ] + warning = False + if etat["nb_notes_total"]: + warning = True + nb_desinscrits = etat["nb_notes_total"] - etat["nb_notes"] + H.append( + """
      Il y a %s notes""" % etat["nb_notes_total"] + ) + if nb_desinscrits: + H.append( + """ (dont %s d'étudiants qui ne sont plus inscrits)""" % nb_desinscrits + ) + H.append(""" dans l'évaluation""") + if etat["nb_notes"] == 0: + H.append( + """

      Vous pouvez quand même supprimer l'évaluation, les notes des étudiants désincrits seront effacées.

      """ + ) + + if etat["nb_notes"]: + H.append( + """

      Suppression impossible (effacer les notes d'abord)

      retour au tableau de bord du module

      """ + % E["moduleimpl_id"] + ) + return "\n".join(H) + html_sco_header.sco_footer() + if warning: + H.append("""""") + + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + (("evaluation_id", {"input_type": "hidden"}),), + initvalues=E, + submitlabel="Confirmer la suppression", + cancelbutton="Annuler", + ) + if tf[0] == 0: + return "\n".join(H) + tf[1] + html_sco_header.sco_footer() + elif tf[0] == -1: + return flask.redirect( + url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=E["moduleimpl_id"], + ) + ) + else: + sco_evaluation_db.do_evaluation_delete(E["evaluation_id"]) + return ( + "\n".join(H) + + f"""

      OK, évaluation supprimée.

      +

      Continuer

      """ + + html_sco_header.sco_footer() + ) + + +sco_publish( + "/do_evaluation_list", + sco_evaluation_db.do_evaluation_list, + Permission.ScoView, +) + + +@bp.route("/evaluation_edit", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoEnsView) +@scodoc7func +def evaluation_edit(evaluation_id): + "form edit evaluation" + return sco_evaluation_edit.evaluation_create_form( + evaluation_id=evaluation_id, edit=True + ) + + +@bp.route("/evaluation_create", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoEnsView) +@scodoc7func +def evaluation_create(moduleimpl_id): + "form create evaluation" + modimpl = ModuleImpl.query.get(moduleimpl_id) + if modimpl is None: + raise ScoValueError("Ce module n'existe pas ou plus !") + return sco_evaluation_edit.evaluation_create_form( + moduleimpl_id=moduleimpl_id, edit=False + ) + + +@bp.route("/evaluation_listenotes", methods=["GET", "POST"]) # API ScoDoc 7 compat +@scodoc +@permission_required_compat_scodoc7(Permission.ScoView) +@scodoc7func +def evaluation_listenotes(): + """Affichage des notes d'une évaluation""" + evaluation_id = None + moduleimpl_id = None + vals = scu.get_request_args() + try: + if "evaluation_id" in vals: + evaluation_id = int(vals["evaluation_id"]) + if "moduleimpl_id" in vals and vals["moduleimpl_id"]: + moduleimpl_id = int(vals["moduleimpl_id"]) + except ValueError as exc: + raise ScoValueError("evaluation_listenotes: id invalides !") from exc + + format = vals.get("format", "html") + html_content, page_title = sco_liste_notes.do_evaluation_listenotes( + evaluation_id=evaluation_id, moduleimpl_id=moduleimpl_id, format=format + ) + if format == "html": + H = html_sco_header.sco_header( + page_title=page_title, + cssstyles=["css/verticalhisto.css"], + javascripts=["js/etud_info.js"], + init_qtip=True, + ) + F = html_sco_header.sco_footer() + return H + html_content + F + else: + return html_content + + +sco_publish( + "/evaluation_list_operations", + sco_undo_notes.evaluation_list_operations, + Permission.ScoView, +) +sco_publish( + "/evaluation_check_absences_html", + sco_evaluation_check_abs.evaluation_check_absences_html, + Permission.ScoView, +) +sco_publish( + "/formsemestre_check_absences_html", + sco_evaluation_check_abs.formsemestre_check_absences_html, + Permission.ScoView, +) + +# --- Placement des étudiants pour l'évaluation +sco_publish( + "/placement_eval_selectetuds", + sco_placement.placement_eval_selectetuds, + Permission.ScoEnsView, + methods=["GET", "POST"], +) + +# --- Saisie des notes +sco_publish( + "/saisie_notes_tableur", + sco_saisie_notes.saisie_notes_tableur, + Permission.ScoEnsView, + methods=["GET", "POST"], +) +sco_publish( + "/feuille_saisie_notes", + sco_saisie_notes.feuille_saisie_notes, + Permission.ScoEnsView, +) +sco_publish("/saisie_notes", sco_saisie_notes.saisie_notes, Permission.ScoEnsView) +sco_publish( + "/save_note", + sco_saisie_notes.save_note, + Permission.ScoEnsView, + methods=["GET", "POST"], +) +sco_publish( + "/do_evaluation_set_missing", + sco_saisie_notes.do_evaluation_set_missing, + Permission.ScoEnsView, + methods=["GET", "POST"], +) +sco_publish( + "/evaluation_suppress_alln", + sco_saisie_notes.evaluation_suppress_alln, + Permission.ScoView, + methods=["GET", "POST"], +) + + +# --- Bulletins +@bp.route("/formsemestre_bulletins_pdf") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"): + "Publie les bulletins dans un classeur PDF" + pdfdoc, filename = sco_bulletins_pdf.get_formsemestre_bulletins_pdf( + formsemestre_id, version=version + ) + return scu.sendPDFFile(pdfdoc, filename) + + +_EXPL_BULL = """Versions des bulletins: +
        +
      • courte: moyennes des modules (en BUT: seulement les moyennes d'UE)
      • +
      • intermédiaire: moyennes des modules et notes des évaluations sélectionnées
      • +
      • complète: toutes les notes
      • +
      """ + + +@bp.route("/formsemestre_bulletins_pdf_choice") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formsemestre_bulletins_pdf_choice(formsemestre_id, version=None): + """Choix version puis envois classeur bulletins pdf""" + if version: + pdfdoc, filename = sco_bulletins_pdf.get_formsemestre_bulletins_pdf( + formsemestre_id, version=version + ) + return scu.sendPDFFile(pdfdoc, filename) + return formsemestre_bulletins_choice( + formsemestre_id, + title="Choisir la version des bulletins à générer", + explanation=_EXPL_BULL, + ) + + +@bp.route("/etud_bulletins_pdf") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def etud_bulletins_pdf(etudid, version="selectedevals"): + "Publie tous les bulletins d'un etudiants dans un classeur PDF" + pdfdoc, filename = sco_bulletins_pdf.get_etud_bulletins_pdf(etudid, version=version) + return scu.sendPDFFile(pdfdoc, filename) + + +@bp.route("/formsemestre_bulletins_mailetuds_choice") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formsemestre_bulletins_mailetuds_choice( + formsemestre_id, + version=None, + dialog_confirmed=False, + prefer_mail_perso=0, +): + """Choix version puis envoi classeur bulletins pdf""" + if version: + # XXX à tester + return flask.redirect( + url_for( + "notes.formsemestre_bulletins_mailetuds", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + version=version, + dialog_confirmed=dialog_confirmed, + prefer_mail_perso=prefer_mail_perso, + ) + ) + + expl_bull = """Versions des bulletins:
      • courte: moyennes des modules
      • intermédiaire: moyennes des modules et notes des évaluations sélectionnées
      • complète: toutes les notes
        • """ + return formsemestre_bulletins_choice( + formsemestre_id, + title="Choisir la version des bulletins à envoyer par mail", + explanation="Chaque étudiant ayant une adresse mail connue de ScoDoc recevra une copie PDF de son bulletin de notes, dans la version choisie.

          " + + expl_bull, + choose_mail=True, + ) + + +# not published +def formsemestre_bulletins_choice( + formsemestre_id, title="", explanation="", choose_mail=False +): + """Choix d'une version de bulletin""" + H = [ + html_sco_header.html_sem_header(title), + """ +

          + + """ + % (request.base_url, formsemestre_id), + ] + H.append("""  """) + if choose_mail: + H.append( + """
          Utiliser si possible les adresses personnelles
          """ + ) + + H.append("""

          """ + explanation + """

          """) + + return "\n".join(H) + html_sco_header.sco_footer() + + +@bp.route("/formsemestre_bulletins_mailetuds") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formsemestre_bulletins_mailetuds( + formsemestre_id, + version="long", + dialog_confirmed=False, + prefer_mail_perso=0, +): + "envoi a chaque etudiant (inscrit et ayant un mail) son bulletin" + prefer_mail_perso = int(prefer_mail_perso) + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + etudids = nt.get_etudids() + # + if not sco_bulletins.can_send_bulletin_by_mail(formsemestre_id): + raise AccessDenied("vous n'avez pas le droit d'envoyer les bulletins") + # Confirmation dialog + if not dialog_confirmed: + return scu.confirm_dialog( + "

          Envoyer les %d bulletins par e-mail aux étudiants ?" % len(etudids), + dest_url="", + cancel_url="formsemestre_status?formsemestre_id=%s" % formsemestre_id, + parameters={ + "version": version, + "formsemestre_id": formsemestre_id, + "prefer_mail_perso": prefer_mail_perso, + }, + ) + + # Make each bulletin + nb_sent = 0 + for etudid in etudids: + sent, _ = sco_bulletins.do_formsemestre_bulletinetud( + formsemestre, + etudid, + version=version, + prefer_mail_perso=prefer_mail_perso, + format="pdfmail", + ) + if sent: + nb_sent += 1 + # + return ( + html_sco_header.sco_header() + + '

          %d bulletins sur %d envoyés par mail !

          continuer

          ' + % (nb_sent, len(etudids), formsemestre_id) + + html_sco_header.sco_footer() + ) + + +sco_publish( + "/external_ue_create_form", + sco_ue_external.external_ue_create_form, + Permission.ScoView, + methods=["GET", "POST"], +) + + +@bp.route("/appreciation_add_form", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoEnsView) +@scodoc7func +def appreciation_add_form( + etudid=None, + formsemestre_id=None, + id=None, # si id, edit + suppress=False, # si true, supress id +): + "form ajout ou edition d'une appreciation" + cnx = ndb.GetDBConnexion() + if id: # edit mode + apps = sco_etud.appreciations_list(cnx, args={"id": id}) + if not apps: + raise ScoValueError("id d'appreciation invalide !") + app = apps[0] + formsemestre_id = app["formsemestre_id"] + etudid = app["etudid"] + vals = scu.get_request_args() + if "edit" in vals: + edit = int(vals["edit"]) + elif id: + edit = 1 + else: + edit = 0 + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + # check custom access permission + can_edit_app = (current_user.id in sem["responsables"]) or ( + current_user.has_permission(Permission.ScoEtudInscrit) + ) + if not can_edit_app: + raise AccessDenied("vous n'avez pas le droit d'ajouter une appreciation") + # + bull_url = "formsemestre_bulletinetud?formsemestre_id=%s&etudid=%s" % ( + formsemestre_id, + etudid, + ) + if suppress: + sco_etud.appreciations_delete(cnx, id) + logdb(cnx, method="appreciation_suppress", etudid=etudid, msg="") + return flask.redirect(bull_url) + # + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + if id: + a = "Edition" + else: + a = "Ajout" + H = [ + html_sco_header.sco_header() + + "

          %s d'une appréciation sur %s

          " % (a, etud["nomprenom"]) + ] + F = html_sco_header.sco_footer() + descr = [ + ("edit", {"input_type": "hidden", "default": edit}), + ("etudid", {"input_type": "hidden"}), + ("formsemestre_id", {"input_type": "hidden"}), + ("id", {"input_type": "hidden"}), + ("comment", {"title": "", "input_type": "textarea", "rows": 4, "cols": 60}), + ] + if id: + initvalues = { + "etudid": etudid, + "formsemestre_id": formsemestre_id, + "comment": app["comment"], + } + else: + initvalues = {} + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + descr, + initvalues=initvalues, + cancelbutton="Annuler", + submitlabel="Ajouter appréciation", + ) + if tf[0] == 0: + return "\n".join(H) + "\n" + tf[1] + F + elif tf[0] == -1: + return flask.redirect(bull_url) + else: + args = { + "etudid": etudid, + "formsemestre_id": formsemestre_id, + "author": current_user.user_name, + "comment": tf[2]["comment"], + } + if edit: + args["id"] = id + sco_etud.appreciations_edit(cnx, args) + else: # nouvelle + sco_etud.appreciations_create(cnx, args) + # log + logdb( + cnx, + method="appreciation_add", + etudid=etudid, + msg=tf[2]["comment"], + ) + # ennuyeux mais necessaire (pour le PDF seulement) + sco_cache.invalidate_formsemestre( + pdfonly=True, formsemestre_id=formsemestre_id + ) # > appreciation_add + return flask.redirect(bull_url) + + +# --- FORMULAIRE POUR VALIDATION DES UE ET SEMESTRES + + +@bp.route("/formsemestre_validation_etud_form") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formsemestre_validation_etud_form( + formsemestre_id, + etudid=None, + etud_index=None, + check=0, + desturl="", + sortcol=None, +): + "Formulaire choix jury pour un étudiant" + readonly = not sco_permissions_check.can_validate_sem(formsemestre_id) + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + if formsemestre.formation.is_apc(): + return redirect( + url_for( + "notes.formsemestre_validation_but", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + etudid=etudid, + ) + ) + return sco_formsemestre_validation.formsemestre_validation_etud_form( + formsemestre_id, + etudid=etudid, + etud_index=etud_index, + check=check, + readonly=readonly, + desturl=desturl, + sortcol=sortcol, + ) + + +@bp.route("/formsemestre_validation_etud") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formsemestre_validation_etud( + formsemestre_id, + etudid=None, + codechoice=None, + desturl="", + sortcol=None, +): + "Enregistre choix jury pour un étudiant" + if not sco_permissions_check.can_validate_sem(formsemestre_id): + return scu.confirm_dialog( + message="

          Opération non autorisée pour %s" % current_user, + dest_url=scu.ScoURL(), + ) + + return sco_formsemestre_validation.formsemestre_validation_etud( + formsemestre_id, + etudid=etudid, + codechoice=codechoice, + desturl=desturl, + sortcol=sortcol, + ) + + +@bp.route("/formsemestre_validation_etud_manu") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formsemestre_validation_etud_manu( + formsemestre_id, + etudid=None, + code_etat="", + new_code_prev="", + devenir="", + assidu=False, + desturl="", + sortcol=None, +): + "Enregistre choix jury pour un étudiant" + if not sco_permissions_check.can_validate_sem(formsemestre_id): + return scu.confirm_dialog( + message="

          Opération non autorisée pour %s" % current_user, + dest_url=scu.ScoURL(), + ) + + return sco_formsemestre_validation.formsemestre_validation_etud_manu( + formsemestre_id, + etudid=etudid, + code_etat=code_etat, + new_code_prev=new_code_prev, + devenir=devenir, + assidu=assidu, + desturl=desturl, + sortcol=sortcol, + ) + + +# --- Jurys BUT +@bp.route( + "/formsemestre_validation_but//", + methods=["GET", "POST"], +) +@scodoc +@permission_required(Permission.ScoView) +def formsemestre_validation_but( + formsemestre_id: int, + etudid: int, +): + "Form. saisie décision jury semestre BUT" + # la route ne donne pas le type d'etudid pour pouvoir construire des URLs + # provisoires avec NEXT et PREV + try: + etudid = int(etudid) + except: + abort(404, "invalid etudid") + read_only = not sco_permissions_check.can_validate_sem(formsemestre_id) + + H = [ + html_sco_header.sco_header( + page_title="Validation BUT", + formsemestre_id=formsemestre_id, + etudid=etudid, + cssstyles=("css/jury_but.css",), + javascripts=("js/jury_but.js",), + ), + f""" +

          + """, + ] + + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + etud = Identite.query.get_or_404(etudid) + if formsemestre.etuds_inscriptions[etudid].etat != scu.INSCRIT: + return ( + "\n".join(H) + + f"""
          Impossible de statuer sur cet étudiant: + il est démissionnaire ou défaillant (voir sa fiche) +
          + +
          + """ + + html_sco_header.sco_footer() + ) + + deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre) + if len(deca.rcues_annee) == 0: + # raise ScoValueError("année incomplète: pas de jury BUT annuel possible") + return jury_but_view.jury_but_semestriel(formsemestre, etud, read_only) + if request.method == "POST": + if not read_only: + deca.record_form(request.form) + flash("codes enregistrés") + return flask.redirect( + url_for( + "notes.formsemestre_validation_but", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + etudid=etudid, + ) + ) + + warning = "" + if len(deca.niveaux_competences) != len(deca.decisions_rcue_by_niveau): + warning += f"""
          Attention: {len(deca.niveaux_competences)} + niveaux mais {len(deca.decisions_rcue_by_niveau)} regroupements RCUE.
          """ + if deca.parcour is None: + warning += """
          L'étudiant n'est pas inscrit à un parcours.
          """ + H.append( + f""" +
          +
          +
          +
          Jury BUT{deca.annee_but} + - Parcours {(deca.parcour.libelle if deca.parcour else False) or "non spécifié"} + - {deca.annee_scolaire_str()}
          +
          {etud.nomprenom}
          +
          + +
          + {warning} +
          + + + """ + ) + + H.append(jury_but_view.show_etud(deca, read_only=read_only)) + + if read_only: + H.append( + """
          + Vous n'avez pas la permission de modifier ces décisions. + Les champs entourés en vert sont enregistrés.
          """ + ) + else: + H.append( + f"""
          + + permettre la saisie manuelles des codes d'année et de niveaux. + Dans ce cas, il vous revient de vous assurer de la cohérence entre + vos codes d'UE/RCUE/Année ! + +
          + +
          + +
          + """ + ) + # --- Navigation + prev = f"""{scu.EMO_PREV_ARROW} précédent + """ + next = f"""suivant {scu.EMO_NEXT_ARROW} + """ + H.append( + f""" +
          + + + +
          + """ + ) + H.append("
          ") + + H.append( + render_template( + "but/documentation_codes_jury.html", + nom_univ=f"""Export {sco_preferences.get_preference("InstituteName") + or sco_preferences.get_preference("UnivName") + or "Apogée"}""", + codes=ScoDocSiteConfig.get_codes_apo_dict(), + ) + ) + + return "\n".join(H) + html_sco_header.sco_footer() + + +@bp.route( + "/formsemestre_validation_auto_but/", methods=["GET", "POST"] +) +@scodoc +@permission_required(Permission.ScoView) +def formsemestre_validation_auto_but(formsemestre_id: int = None): + "Saisie automatique des décisions de jury BUT" + if not sco_permissions_check.can_validate_sem(formsemestre_id): + return scu.confirm_dialog( + message=f"

          Opération non autorisée pour {current_user}", + dest_url=url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ), + ) + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + form = jury_but_forms.FormSemestreValidationAutoBUTForm() + if request.method == "POST": + if not form.cancel.data: + nb_admis = jury_but_validation_auto.formsemestre_validation_auto_but( + formsemestre + ) + flash(f"Décisions enregistrées ({nb_admis} admis)") + return redirect( + url_for( + "notes.formsemestre_saisie_jury", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) + ) + return render_template( + "but/formsemestre_validation_auto_but.html", + form=form, + sco=ScoData(formsemestre=formsemestre), + title=f"Calcul automatique jury BUT", + ) + + +@bp.route("/formsemestre_validate_previous_ue", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formsemestre_validate_previous_ue(formsemestre_id, etudid=None): + "Form. saisie UE validée hors ScoDoc" + if not sco_permissions_check.can_validate_sem(formsemestre_id): + return scu.confirm_dialog( + message="

          Opération non autorisée pour %s" % current_user, + dest_url=scu.ScoURL(), + ) + return sco_formsemestre_validation.formsemestre_validate_previous_ue( + formsemestre_id, etudid + ) + + +sco_publish( + "/formsemestre_ext_create_form", + sco_formsemestre_exterieurs.formsemestre_ext_create_form, + Permission.ScoView, + methods=["GET", "POST"], +) + + +@bp.route("/formsemestre_ext_edit_ue_validations", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formsemestre_ext_edit_ue_validations(formsemestre_id, etudid=None): + "Form. edition UE semestre extérieur" + if not sco_permissions_check.can_validate_sem(formsemestre_id): + return scu.confirm_dialog( + message="

          Opération non autorisée pour %s" % current_user, + dest_url=scu.ScoURL(), + ) + return sco_formsemestre_exterieurs.formsemestre_ext_edit_ue_validations( + formsemestre_id, etudid + ) + + +sco_publish( + "/get_etud_ue_cap_html", + sco_formsemestre_validation.get_etud_ue_cap_html, + Permission.ScoView, +) + + +@bp.route("/etud_ue_suppress_validation") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def etud_ue_suppress_validation(etudid, formsemestre_id, ue_id): + """Suppress a validation (ue_id, etudid) and redirect to formsemestre""" + if not sco_permissions_check.can_validate_sem(formsemestre_id): + return scu.confirm_dialog( + message="

          Opération non autorisée pour %s" % current_user, + dest_url=scu.ScoURL(), + ) + return sco_formsemestre_validation.etud_ue_suppress_validation( + etudid, formsemestre_id, ue_id + ) + + +@bp.route("/formsemestre_validation_auto") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formsemestre_validation_auto(formsemestre_id): + "Formulaire saisie automatisee des decisions d'un semestre" + if not sco_permissions_check.can_validate_sem(formsemestre_id): + return scu.confirm_dialog( + message="

          Opération non autorisée pour %s" % current_user, + dest_url=scu.ScoURL(), + ) + + return sco_formsemestre_validation.formsemestre_validation_auto(formsemestre_id) + + +@bp.route("/do_formsemestre_validation_auto") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def do_formsemestre_validation_auto(formsemestre_id): + "Formulaire saisie automatisee des decisions d'un semestre" + if not sco_permissions_check.can_validate_sem(formsemestre_id): + return scu.confirm_dialog( + message="

          Opération non autorisée pour %s" % current_user, + dest_url=scu.ScoURL(), + ) + + return sco_formsemestre_validation.do_formsemestre_validation_auto(formsemestre_id) + + +@bp.route("/formsemestre_validation_suppress_etud", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formsemestre_validation_suppress_etud( + formsemestre_id, etudid, dialog_confirmed=False +): + """Suppression des décisions de jury pour un étudiant.""" + if not sco_permissions_check.can_validate_sem(formsemestre_id): + return scu.confirm_dialog( + message="

          Opération non autorisée pour %s" % current_user, + dest_url=scu.ScoURL(), + ) + etud = Identite.query.get_or_404(etudid) + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + if formsemestre.formation.is_apc(): + next_url = url_for( + "scolar.ficheEtud", + scodoc_dept=g.scodoc_dept, + etudid=etudid, + ) + else: + next_url = url_for( + "notes.formsemestre_validation_etud_form", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + etudid=etudid, + ) + if not dialog_confirmed: + d = sco_bulletins_json.dict_decision_jury( + etud, formsemestre, with_decisions=True + ) + + descr_ues = [f"{u['acronyme']}: {u['code']}" for u in d.get("decision_ue", [])] + dec_annee = d.get("decision_annee") + if dec_annee: + descr_annee = dec_annee.get("code", "-") + else: + descr_annee = "-" + + existing = f""" +

            +
          • Semestre : {d.get("decision", {"code":"-"})['code'] or "-"}
          • +
          • Année BUT: {descr_annee}
          • +
          • UEs : {", ".join(descr_ues)}
          • +
          • RCUEs: {len(d.get("decision_rcue", []))} décisions
          • +
          + """ + return scu.confirm_dialog( + f"""

          Confirmer la suppression des décisions du semestre + {formsemestre.titre_mois()} pour {etud.nomprenom} +

          +

          Cette opération est irréversible.

          +
          + {existing} +
          + """, + OK="Supprimer", + dest_url="", + cancel_url=next_url, + parameters={"etudid": etudid, "formsemestre_id": formsemestre_id}, + ) + + sco_formsemestre_validation.formsemestre_validation_suppress_etud( + formsemestre_id, etudid + ) + flash("Décisions supprimées") + return flask.redirect(next_url) + + +# ------------- PV de JURY et archives +sco_publish("/formsemestre_pvjury", sco_pvjury.formsemestre_pvjury, Permission.ScoView) + +sco_publish("/pvjury_table_but", jury_but_pv.pvjury_table_but, Permission.ScoView) + + +@bp.route("/formsemestre_saisie_jury") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formsemestre_saisie_jury(formsemestre_id: int, selected_etudid: int = None): + """Page de saisie: liste des étudiants et lien vers page jury + en semestres pairs de BUT, table spécifique avec l'année + sinon, redirect vers page recap en mode jury + """ + read_only = not sco_permissions_check.can_validate_sem(formsemestre_id) + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + if formsemestre.formation.is_apc() and formsemestre.semestre_id % 2 == 0: + return jury_but_recap.formsemestre_saisie_jury_but( + formsemestre, read_only, selected_etudid=selected_etudid + ) + return redirect( + url_for( + "notes.formsemestre_recapcomplet", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + mode_jury=1, + ) + ) + + +@bp.route("/formsemestre_jury_but_recap") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formsemestre_jury_but_recap(formsemestre_id: int, selected_etudid: int = None): + """Tableau affichage des codes""" + read_only = not sco_permissions_check.can_validate_sem(formsemestre_id) + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + if not (formsemestre.formation.is_apc() and formsemestre.semestre_id % 2 == 0): + raise ScoValueError( + "formsemestre_jury_but_recap: réservé aux semestres pairs de BUT" + ) + return jury_but_recap.formsemestre_saisie_jury_but( + formsemestre, read_only=read_only, selected_etudid=selected_etudid, mode="recap" + ) + + +@bp.route( + "/formsemestre_jury_but_erase//", + methods=["GET", "POST"], +) +@scodoc +@permission_required(Permission.ScoView) +def formsemestre_jury_but_erase( + formsemestre_id: int, etudid: int = None, only_one_sem=False +): + """Supprime la décision de jury BUT pour cette année. + Si only_one_sem, n'efface que pour le formsemestre indiqué, pas les deux de l'année. + """ + only_one_sem = int(request.args.get("only_one_sem") or False) + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + if not formsemestre.formation.is_apc(): + raise ScoValueError("semestre non BUT") + etud: Identite = Identite.query.get_or_404(etudid) + if not sco_permissions_check.can_validate_sem(formsemestre_id): + raise ScoValueError("opération non autorisée") + dest_url = url_for( + "notes.formsemestre_validation_but", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + etudid=etudid, + ) + if request.method == "POST": + deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre) + deca.erase(only_one_sem=only_one_sem) + db.session.commit() + log(f"formsemestre_jury_but_erase({formsemestre_id}, {etudid})") + flash( + "décisions de jury du semestre effacées" + if only_one_sem + else "décisions de jury des semestres de l'année BUT effacées" + ) + return redirect(dest_url) + + return render_template( + "confirm_dialog.html", + title=f"Effacer les validations de jury de {etud.nomprenom} ?", + explanation=f"""Les validations d'UE et autorisations de passage + du semestre S{formsemestre.semestre_id} seront effacées.""" + if only_one_sem + else """Les validations de toutes les UE, RCUE (compétences) et année seront effacées.""", + cancel_url=dest_url, + ) + + +sco_publish( + "/formsemestre_lettres_individuelles", + sco_pvjury.formsemestre_lettres_individuelles, + Permission.ScoView, + methods=["GET", "POST"], +) +sco_publish( + "/formsemestre_pvjury_pdf", sco_pvjury.formsemestre_pvjury_pdf, Permission.ScoView +) +sco_publish( + "/feuille_preparation_jury", + sco_prepajury.feuille_preparation_jury, + Permission.ScoView, +) +sco_publish( + "/formsemestre_archive", + sco_archives.formsemestre_archive, + Permission.ScoView, + methods=["GET", "POST"], +) +sco_publish( + "/formsemestre_delete_archive", + sco_archives.formsemestre_delete_archive, + Permission.ScoView, + methods=["GET", "POST"], +) +sco_publish( + "/formsemestre_list_archives", + sco_archives.formsemestre_list_archives, + Permission.ScoView, +) +sco_publish( + "/formsemestre_get_archived_file", + sco_archives.formsemestre_get_archived_file, + Permission.ScoView, +) +sco_publish("/view_apo_csv", sco_etape_apogee_view.view_apo_csv, Permission.ScoEditApo) +sco_publish( + "/view_apo_csv_store", + sco_etape_apogee_view.view_apo_csv_store, + Permission.ScoEditApo, + methods=["GET", "POST"], +) +sco_publish( + "/view_apo_csv_download_and_store", + sco_etape_apogee_view.view_apo_csv_download_and_store, + Permission.ScoEditApo, + methods=["GET", "POST"], +) +sco_publish( + "/view_apo_csv_delete", + sco_etape_apogee_view.view_apo_csv_delete, + Permission.ScoEditApo, + methods=["GET", "POST"], +) +sco_publish( + "/view_scodoc_etuds", sco_etape_apogee_view.view_scodoc_etuds, Permission.ScoEditApo +) +sco_publish( + "/view_apo_etuds", sco_etape_apogee_view.view_apo_etuds, Permission.ScoEditApo +) +sco_publish( + "/apo_semset_maq_status", + sco_etape_apogee_view.apo_semset_maq_status, + Permission.ScoEditApo, +) +sco_publish( + "/apo_csv_export_results", + sco_etape_apogee_view.apo_csv_export_results, + Permission.ScoEditApo, +) + + +@bp.route("/formsemestre_set_apo_etapes", methods=["POST"]) +@scodoc +@permission_required(Permission.ScoEditApo) +def formsemestre_set_apo_etapes(): + """Change les codes étapes du semestre indiqué. + Args: oid=formsemestre_id, value=chaine "V1RT, V1RT2", codes séparés par des virgules + """ + formsemestre_id = int(request.form.get("oid")) + etapes_apo_str = request.form.get("value") + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + current_etapes = {e.etape_apo for e in formsemestre.etapes} + new_etapes = {s.strip() for s in etapes_apo_str.split(",")} + + if new_etapes != current_etapes: + formsemestre.etapes = [] + for etape_apo in new_etapes: + etape = models.FormSemestreEtape( + formsemestre_id=formsemestre_id, etape_apo=etape_apo + ) + formsemestre.etapes.append(etape) + db.session.add(formsemestre) + db.session.commit() + ScolarNews.add( + typ=ScolarNews.NEWS_APO, + text=f"Modification code Apogée du semestre {formsemestre.titre_annee()})", + max_frequency=10 * 60, + ) + return ("", 204) + + +@bp.route("/formsemestre_set_elt_annee_apo", methods=["POST"]) +@scodoc +@permission_required(Permission.ScoEditApo) +def formsemestre_set_elt_annee_apo(): + """Change les codes étapes du semestre indiqué. + Args: oid=formsemestre_id, value=chaine "V3ONM, V3ONM1, V3ONM2", codes séparés par des virgules + """ + oid = int(request.form.get("oid")) + value = (request.form.get("value") or "").strip() + formsemestre: FormSemestre = FormSemestre.query.get_or_404(oid) + if value != formsemestre.elt_annee_apo: + formsemestre.elt_annee_apo = value + db.session.add(formsemestre) + db.session.commit() + ScolarNews.add( + typ=ScolarNews.NEWS_APO, + text=f"Modification code Apogée du semestre {formsemestre.titre_annee()})", + max_frequency=10 * 60, + ) + return ("", 204) + + +@bp.route("/formsemestre_set_elt_sem_apo", methods=["POST"]) +@scodoc +@permission_required(Permission.ScoEditApo) +def formsemestre_set_elt_sem_apo(): + """Change les codes étapes du semestre indiqué. + Args: oid=formsemestre_id, value=chaine "V3ONM, V3ONM1, V3ONM2", codes séparés par des virgules + """ + oid = int(request.form.get("oid")) + value = (request.form.get("value") or "").strip() + formsemestre: FormSemestre = FormSemestre.query.get_or_404(oid) + if value != formsemestre.elt_sem_apo: + formsemestre.elt_sem_apo = value + db.session.add(formsemestre) + db.session.commit() + ScolarNews.add( + typ=ScolarNews.NEWS_APO, + text=f"Modification code Apogée du semestre {formsemestre.titre_annee()})", + max_frequency=10 * 60, + ) + return ("", 204) + + +@bp.route("/ue_set_apo", methods=["POST"]) +@scodoc +@permission_required(Permission.ScoEditApo) +def ue_set_apo(): + """Change le code APO de l'UE + Args: oid=ue_id, value=chaine "VRTU12" (1 seul code / UE) + """ + ue_id = int(request.form.get("oid")) + code_apo = (request.form.get("value") or "").strip() + ue = UniteEns.query.get_or_404(ue_id) + if code_apo != ue.code_apogee: + ue.code_apogee = code_apo + db.session.add(ue) + db.session.commit() + ScolarNews.add( + typ=ScolarNews.NEWS_FORM, + text=f"Modification code Apogée d'UE dans la formation {ue.formation.titre} ({ue.formation.acronyme})", + max_frequency=10 * 60, + ) + return ("", 204) + + +@bp.route("/module_set_apo", methods=["POST"]) +@scodoc +@permission_required(Permission.ScoEditApo) +def module_set_apo(): + """Change le code APO du module + Args: oid=ue_id, value=chaine "VRTU12" (1 seul code / UE) + """ + oid = int(request.form.get("oid")) + code_apo = (request.form.get("value") or "").strip() + mod = Module.query.get_or_404(oid) + if code_apo != mod.code_apogee: + mod.code_apogee = code_apo + db.session.add(mod) + db.session.commit() + ScolarNews.add( + typ=ScolarNews.NEWS_FORM, + text=f"Modification code Apogée d'UE dans la formation {mod.formation.titre} ({mod.formation.acronyme})", + max_frequency=10 * 60, + ) + return ("", 204) + + +# sco_semset +sco_publish("/semset_page", sco_semset.semset_page, Permission.ScoEditApo) +sco_publish( + "/do_semset_create", + sco_semset.do_semset_create, + Permission.ScoEditApo, + methods=["GET", "POST"], +) +sco_publish( + "/do_semset_delete", + sco_semset.do_semset_delete, + Permission.ScoEditApo, + methods=["GET", "POST"], +) +sco_publish( + "/edit_semset_set_title", + sco_semset.edit_semset_set_title, + Permission.ScoEditApo, + methods=["GET", "POST"], +) +sco_publish( + "/do_semset_add_sem", + sco_semset.do_semset_add_sem, + Permission.ScoEditApo, + methods=["GET", "POST"], +) +sco_publish( + "/do_semset_remove_sem", + sco_semset.do_semset_remove_sem, + Permission.ScoEditApo, + methods=["GET", "POST"], +) + +# sco_export_result +sco_publish( + "/scodoc_table_results", + sco_export_results.scodoc_table_results, + Permission.ScoEditApo, +) + +sco_publish( + "/apo_compare_csv_form", + sco_apogee_compare.apo_compare_csv_form, + Permission.ScoView, + methods=["GET", "POST"], +) +sco_publish( + "/apo_compare_csv", + sco_apogee_compare.apo_compare_csv, + Permission.ScoView, + methods=["GET", "POST"], +) + +# ------------- INSCRIPTIONS: PASSAGE D'UN SEMESTRE A UN AUTRE +sco_publish( + "/formsemestre_inscr_passage", + sco_inscr_passage.formsemestre_inscr_passage, + Permission.ScoEtudInscrit, + methods=["GET", "POST"], +) +sco_publish( + "/formsemestre_synchro_etuds", + sco_synchro_etuds.formsemestre_synchro_etuds, + Permission.ScoView, + methods=["GET", "POST"], +) + +# ------------- RAPPORTS STATISTIQUES +sco_publish( + "/formsemestre_report_counts", + sco_report.formsemestre_report_counts, + Permission.ScoView, +) +sco_publish( + "/formsemestre_suivi_cohorte", + sco_report.formsemestre_suivi_cohorte, + Permission.ScoView, +) +sco_publish( + "/formsemestre_suivi_parcours", + sco_report.formsemestre_suivi_parcours, + Permission.ScoView, +) +sco_publish( + "/formsemestre_etuds_lycees", + sco_lycee.formsemestre_etuds_lycees, + Permission.ScoView, +) +sco_publish( + "/scodoc_table_etuds_lycees", + sco_lycee.scodoc_table_etuds_lycees, + Permission.ScoView, +) +sco_publish( + "/formsemestre_graph_parcours", + sco_report.formsemestre_graph_parcours, + Permission.ScoView, +) +sco_publish( + "/formsemestre_but_indicateurs", + sco_report_but.formsemestre_but_indicateurs, + Permission.ScoView, +) +sco_publish( + "/formsemestre_poursuite_report", + sco_poursuite_dut.formsemestre_poursuite_report, + Permission.ScoView, +) +sco_publish( + "/pe_view_sem_recap", + pe_view.pe_view_sem_recap, + Permission.ScoView, + methods=["GET", "POST"], +) +sco_publish( + "/report_debouche_date", sco_debouche.report_debouche_date, Permission.ScoView +) +sco_publish( + "/formsemestre_estim_cost", + sco_cost_formation.formsemestre_estim_cost, + Permission.ScoView, +) + +# -------------------------------------------------------------------- +# DEBUG + + +@bp.route("/check_sem_integrity") +@scodoc +@permission_required(Permission.ScoImplement) +@scodoc7func +def check_sem_integrity(formsemestre_id, fix=False): + """Debug. + Check that ue and module formations are consistents + """ + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + + modimpls = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id) + bad_ue = [] + bad_sem = [] + formations_set = set() # les formations mentionnées dans les UE et modules + for modimpl in modimpls: + mod = sco_edit_module.module_list({"module_id": modimpl["module_id"]})[0] + formations_set.add(mod["formation_id"]) + ue = sco_edit_ue.ue_list({"ue_id": mod["ue_id"]})[0] + formations_set.add(ue["formation_id"]) + if ue["formation_id"] != mod["formation_id"]: + modimpl["mod"] = mod + modimpl["ue"] = ue + bad_ue.append(modimpl) + if sem["formation_id"] != mod["formation_id"]: + bad_sem.append(modimpl) + modimpl["mod"] = mod + + H = [ + html_sco_header.sco_header(), + "

          formation_id=%s" % sem["formation_id"], + ] + if bad_ue: + H += [ + "

          Modules d'une autre formation que leur UE:

          ", + "
          ".join([str(x) for x in bad_ue]), + ] + if bad_sem: + H += [ + "

          Module du semestre dans une autre formation:

          ", + "
          ".join([str(x) for x in bad_sem]), + ] + if not bad_ue and not bad_sem: + H.append("

          Aucun problème à signaler !

          ") + else: + log("check_sem_integrity: problem detected: formations_set=%s" % formations_set) + if sem["formation_id"] in formations_set: + formations_set.remove(sem["formation_id"]) + if len(formations_set) == 1: + if fix: + log("check_sem_integrity: trying to fix %s" % formsemestre_id) + formation_id = formations_set.pop() + if sem["formation_id"] != formation_id: + sem["formation_id"] = formation_id + sco_formsemestre.do_formsemestre_edit(sem) + H.append("""

          Problème réparé: vérifiez

          """) + else: + H.append( + """ +

          Problème détecté réparable: + réparer maintenant

          + """ + % (formsemestre_id,) + ) + else: + H.append("""

          Problème détecté !

          """) + + return "\n".join(H) + html_sco_header.sco_footer() + + +@bp.route("/check_form_integrity") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def check_form_integrity(formation_id, fix=False): + "debug" + log("check_form_integrity: formation_id=%s fix=%s" % (formation_id, fix)) + ues = sco_edit_ue.ue_list(args={"formation_id": formation_id}) + bad = [] + for ue in ues: + mats = sco_edit_matiere.matiere_list(args={"ue_id": ue["ue_id"]}) + for mat in mats: + mods = sco_edit_module.module_list({"matiere_id": mat["matiere_id"]}) + for mod in mods: + if mod["ue_id"] != ue["ue_id"]: + if fix: + # fix mod.ue_id + log( + "fix: mod.ue_id = %s (was %s)" % (ue["ue_id"], mod["ue_id"]) + ) + mod["ue_id"] = ue["ue_id"] + sco_edit_module.do_module_edit(mod) + bad.append(mod) + if mod["formation_id"] != formation_id: + bad.append(mod) + if bad: + txth = "
          ".join([str(x) for x in bad]) + txt = "\n".join([str(x) for x in bad]) + log("check_form_integrity: formation_id=%s\ninconsistencies:" % formation_id) + log(txt) + # Notify by e-mail + send_scodoc_alarm("Notes: formation incoherente !", txt) + else: + txth = "OK" + log("ok") + return html_sco_header.sco_header() + txth + html_sco_header.sco_footer() + + +@bp.route("/check_formsemestre_integrity") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def check_formsemestre_integrity(formsemestre_id): + "debug" + log("check_formsemestre_integrity: formsemestre_id=%s" % (formsemestre_id)) + # verifie que tous les moduleimpl d'un formsemestre + # se réfèrent à un module dont l'UE appartient a la même formation + # Ancien bug: les ue_id étaient mal copiés lors des création de versions + # de formations + diag = [] + + Mlist = sco_moduleimpl.moduleimpl_withmodule_list(formsemestre_id=formsemestre_id) + for mod in Mlist: + if mod["module"]["ue_id"] != mod["matiere"]["ue_id"]: + diag.append( + "moduleimpl %s: module.ue_id=%s != matiere.ue_id=%s" + % ( + mod["moduleimpl_id"], + mod["module"]["ue_id"], + mod["matiere"]["ue_id"], + ) + ) + if mod["ue"]["formation_id"] != mod["module"]["formation_id"]: + diag.append( + "moduleimpl %s: ue.formation_id=%s != mod.formation_id=%s" + % ( + mod["moduleimpl_id"], + mod["ue"]["formation_id"], + mod["module"]["formation_id"], + ) + ) + if diag: + send_scodoc_alarm( + "Notes: formation incoherente dans semestre %s !" % formsemestre_id, + "\n".join(diag), + ) + log("check_formsemestre_integrity: formsemestre_id=%s" % formsemestre_id) + log("inconsistencies:\n" + "\n".join(diag)) + else: + diag = ["OK"] + log("ok") + return ( + html_sco_header.sco_header() + "
          ".join(diag) + html_sco_header.sco_footer() + ) + + +@bp.route("/check_integrity_all") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def check_integrity_all(): + "debug: verifie tous les semestres et tt les formations" + # formations + for F in sco_formations.formation_list(): + check_form_integrity(F["formation_id"]) + # semestres + for sem in sco_formsemestre.do_formsemestre_list(): + check_formsemestre_integrity(sem["formsemestre_id"]) + return ( + html_sco_header.sco_header() + + "

          empty page: see logs and mails

          " + + html_sco_header.sco_footer() + )