diff --git a/app/__init__.py b/app/__init__.py index 0a589144..76f9471b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -253,7 +253,7 @@ def create_app(config_class=DevConfig): host_name = socket.gethostname() mail_handler = ScoSMTPHandler( mailhost=(app.config["MAIL_SERVER"], app.config["MAIL_PORT"]), - fromaddr="no-reply@" + app.config["MAIL_SERVER"], + fromaddr=app.config["SCODOC_MAIL_FROM"], toaddrs=["exception@scodoc.org"], subject="ScoDoc Exception", # unused see ScoSMTPHandler credentials=auth, diff --git a/app/auth/email.py b/app/auth/email.py index 9ac8a017..61759691 100644 --- a/app/auth/email.py +++ b/app/auth/email.py @@ -8,7 +8,7 @@ def send_password_reset_email(user): token = user.get_reset_password_token() send_email( "[ScoDoc] Réinitialisation de votre mot de passe", - sender=current_app.config["ADMINS"][0], + sender=current_app.config["SCODOC_MAIL_FROM"], recipients=[user.email], text_body=render_template("email/reset_password.txt", user=user, token=token), html_body=render_template("email/reset_password.html", user=user, token=token), diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index c3596f92..1d8ff453 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -40,6 +40,7 @@ import pandas as pd from app import db from app.models import ModuleImpl, Evaluation, EvaluationUEPoids from app.scodoc import sco_utils as scu +from app.scodoc.sco_exceptions import ScoValueError @dataclass @@ -280,7 +281,11 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]: for ue_poids in EvaluationUEPoids.query.join( EvaluationUEPoids.evaluation ).filter_by(moduleimpl_id=moduleimpl_id): - evals_poids[ue_poids.ue_id][ue_poids.evaluation_id] = ue_poids.poids + try: + evals_poids[ue_poids.ue_id][ue_poids.evaluation_id] = ue_poids.poids + except KeyError as exc: + pass # poids vers des UE qui n'existent plus ou sont dans un autre semestre... + # Initialise poids non enregistrés: if np.isnan(evals_poids.values.flat).any(): ue_coefs = modimpl.module.get_ue_coef_dict() diff --git a/app/forms/main/config_apo.py b/app/forms/main/config_apo.py new file mode 100644 index 00000000..a655f450 --- /dev/null +++ b/app/forms/main/config_apo.py @@ -0,0 +1,78 @@ +# -*- 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 +# +############################################################################## + +""" +Formulaires configuration Exports Apogée (codes) +""" +import re + +from flask import flash, url_for, redirect, render_template +from flask_wtf import FlaskForm +from wtforms import SubmitField, validators +from wtforms.fields.simple import StringField + +from app import models +from app.models import ScoDocSiteConfig +from app.models import SHORT_STR_LEN + +from app.scodoc import sco_codes_parcours +from app.scodoc import sco_utils as scu + + +def _build_code_field(code): + return StringField( + label=code, + description=sco_codes_parcours.CODES_EXPL[code], + validators=[ + validators.regexp( + r"^[A-Z0-9_]*$", + message="Ne doit comporter que majuscules et des chiffres", + ), + validators.Length( + max=SHORT_STR_LEN, + message=f"L'acronyme ne doit pas dépasser {SHORT_STR_LEN} caractères", + ), + validators.DataRequired("code requis"), + ], + ) + + +class CodesDecisionsForm(FlaskForm): + ADC = _build_code_field("ADC") + ADJ = _build_code_field("ADJ") + ADM = _build_code_field("ADM") + AJ = _build_code_field("AJ") + ATB = _build_code_field("ATB") + ATJ = _build_code_field("ATJ") + ATT = _build_code_field("ATT") + CMP = _build_code_field("CMP") + DEF = _build_code_field("DEF") + DEM = _build_code_field("DEF") + NAR = _build_code_field("NAR") + RAT = _build_code_field("RAT") + submit = SubmitField("Valider") + cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) diff --git a/app/models/__init__.py b/app/models/__init__.py index 642e3187..25ac6c11 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -6,13 +6,13 @@ XXX version préliminaire ScoDoc8 #sco8 sans département CODE_STR_LEN = 16 # chaine pour les codes SHORT_STR_LEN = 32 # courtes chaine, eg acronymes -APO_CODE_STR_LEN = 24 # nb de car max d'un code Apogée +APO_CODE_STR_LEN = 512 # nb de car max d'un code Apogée (il peut y en avoir plusieurs) GROUPNAME_STR_LEN = 64 from app.models.raw_sql_init import create_database_functions from app.models.absences import Absence, AbsenceNotification, BilletAbsence - +from app.models.config import ScoDocSiteConfig from app.models.departements import Departement from app.models.etudiants import ( Identite, @@ -57,7 +57,7 @@ from app.models.notes import ( NotesNotes, NotesNotesLog, ) -from app.models.preferences import ScoPreference, ScoDocSiteConfig +from app.models.preferences import ScoPreference from app.models.but_refcomp import ( ApcReferentielCompetences, diff --git a/app/models/config.py b/app/models/config.py new file mode 100644 index 00000000..f9345942 --- /dev/null +++ b/app/models/config.py @@ -0,0 +1,192 @@ +# -*- coding: UTF-8 -* + +"""Model : site config WORK IN PROGRESS #WIP +""" + +from app import db, log +from app.comp import bonus_spo +from app.scodoc.sco_exceptions import ScoValueError + +from app.scodoc.sco_codes_parcours import ( + ADC, + ADJ, + ADM, + AJ, + ATB, + ATJ, + ATT, + CMP, + DEF, + DEM, + NAR, + RAT, +) + +CODES_SCODOC_TO_APO = { + ADC: "ADMC", + ADJ: "ADM", + ADM: "ADM", + AJ: "AJ", + ATB: "AJAC", + ATJ: "AJAC", + ATT: "AJAC", + CMP: "COMP", + DEF: "NAR", + DEM: "NAR", + NAR: "NAR", + RAT: "ATT", +} + + +def code_scodoc_to_apo_default(code): + """Conversion code jury ScoDoc en code Apogée + (codes par défaut, c'est configurable via ScoDocSiteConfig.get_code_apo) + """ + return CODES_SCODOC_TO_APO.get(code, "DEF") + + +class ScoDocSiteConfig(db.Model): + """Config. d'un site + Nouveau en ScoDoc 9: va regrouper les paramètres qui dans les versions + antérieures étaient dans scodoc_config.py + """ + + __tablename__ = "scodoc_site_config" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128), nullable=False, index=True) + value = db.Column(db.Text()) + + BONUS_SPORT = "bonus_sport_func_name" + NAMES = { + BONUS_SPORT: str, + "always_require_ine": bool, + "SCOLAR_FONT": str, + "SCOLAR_FONT_SIZE": str, + "SCOLAR_FONT_SIZE_FOOT": str, + "INSTITUTION_NAME": str, + "INSTITUTION_ADDRESS": str, + "INSTITUTION_CITY": str, + "DEFAULT_PDF_FOOTER_TEMPLATE": str, + } + + def __init__(self, name, value): + self.name = name + self.value = value + + def __repr__(self): + return f"<{self.__class__.__name__}('{self.name}', '{self.value}')>" + + @classmethod + def get_dict(cls) -> dict: + "Returns all data as a dict name = value" + return { + c.name: cls.NAMES.get(c.name, lambda x: x)(c.value) + for c in ScoDocSiteConfig.query.all() + } + + @classmethod + def set_bonus_sport_class(cls, class_name): + """Record bonus_sport config. + If class_name not defined, raise NameError + """ + if class_name not in cls.get_bonus_sport_class_names(): + raise NameError("invalid class name for bonus_sport") + c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first() + if c: + log("setting to " + class_name) + c.value = class_name + else: + c = ScoDocSiteConfig(cls.BONUS_SPORT, class_name) + db.session.add(c) + db.session.commit() + + @classmethod + def get_bonus_sport_class_name(cls): + """Get configured bonus function name, or None if None.""" + klass = cls.get_bonus_sport_class_from_name() + if klass is None: + return "" + else: + return klass.name + + @classmethod + def get_bonus_sport_class_from_name(cls, class_name=None): + """returns bonus class with specified name. + If name not specified, return the configured function. + None if no bonus function configured. + Raises ScoValueError if class_name not found in module bonus_sport. + """ + if class_name is None: + c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first() + if c is None: + return None + class_name = c.value + if class_name == "": # pas de bonus défini + return None + klass = bonus_spo.get_bonus_class_dict().get(class_name) + if klass is None: + raise ScoValueError( + f"""Fonction de calcul bonus sport inexistante: {class_name}. + (contacter votre administrateur local).""" + ) + return klass + + @classmethod + def get_bonus_sport_class_names(cls): + """List available bonus class names + (starting with empty string to represent "no bonus function"). + """ + return [""] + sorted(bonus_spo.get_bonus_class_dict().keys()) + + @classmethod + def get_bonus_sport_func(cls): + """Fonction bonus_sport ScoDoc 7 XXX + Transitoire pour les tests durant la transition #sco92 + """ + """returns bonus func with specified name. + If name not specified, return the configured function. + None if no bonus function configured. + Raises ScoValueError if func_name not found in module bonus_sport. + """ + from app.scodoc import bonus_sport + + c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first() + if c is None: + return None + func_name = c.value + if func_name == "": # pas de bonus défini + return None + try: + return getattr(bonus_sport, func_name) + except AttributeError: + raise ScoValueError( + f"""Fonction de calcul maison inexistante: {func_name}. + (contacter votre administrateur local).""" + ) + + @classmethod + def get_code_apo(cls, code: str) -> str: + """La représentation d'un code pour les exports Apogée. + Par exemple, à l'iUT du H., le code ADM est réprésenté par VAL + Les codes par défaut sont donnés dans sco_apogee_csv. + + """ + cfg = ScoDocSiteConfig.query.filter_by(name=code).first() + if not cfg: + code_apo = code_scodoc_to_apo_default(code) + else: + code_apo = cfg.value + return code_apo + + @classmethod + def set_code_apo(cls, code: str, code_apo: str): + """Enregistre nouvelle représentation du code""" + if code_apo != cls.get_code_apo(code): + cfg = ScoDocSiteConfig.query.filter_by(name=code).first() + if cfg is None: + cfg = ScoDocSiteConfig(code, code_apo) + else: + cfg.value = code_apo + db.session.add(cfg) + db.session.commit() diff --git a/app/models/evaluations.py b/app/models/evaluations.py index 4f06fb75..b4e5f4e2 100644 --- a/app/models/evaluations.py +++ b/app/models/evaluations.py @@ -103,7 +103,16 @@ class Evaluation(db.Model): Note: si les poids ne sont pas initialisés (poids par défaut), ils ne sont pas affichés. """ - return ", ".join([f"{p.ue.acronyme}: {p.poids}" for p in self.ue_poids]) + # restreint aux UE du semestre dans lequel est cette évaluation + # au cas où le module ait changé de semestre et qu'il reste des poids + evaluation_semestre_idx = self.moduleimpl.module.semestre_id + return ", ".join( + [ + f"{p.ue.acronyme}: {p.poids}" + for p in self.ue_poids + if evaluation_semestre_idx == p.ue.semestre_idx + ] + ) class EvaluationUEPoids(db.Model): diff --git a/app/models/formations.py b/app/models/formations.py index e2273c3b..b69d566a 100644 --- a/app/models/formations.py +++ b/app/models/formations.py @@ -1,6 +1,7 @@ """ScoDoc 9 models : Formations """ +import app from app import db from app.comp import df_cache from app.models import SHORT_STR_LEN @@ -141,8 +142,7 @@ class Formation(db.Model): db.session.add(ue) db.session.commit() - if change: - self.invalidate_module_coefs() + app.clear_scodoc_cache() class Matiere(db.Model): diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index e1febc32..4d436f8e 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -287,7 +287,7 @@ class FormSemestre(db.Model): self.date_fin.year})""" def titre_num(self) -> str: - """Le titre est le semestre, ex ""DUT Informatique semestre 2"" """ + """Le titre et le semestre, ex ""DUT Informatique semestre 2"" """ if self.semestre_id == sco_codes_parcours.NO_SEMESTRE_ID: return self.titre return f"{self.titre} {self.formation.get_parcours().SESSION_NAME} {self.semestre_id}" diff --git a/app/models/preferences.py b/app/models/preferences.py index f220ee17..924f6e60 100644 --- a/app/models/preferences.py +++ b/app/models/preferences.py @@ -2,9 +2,8 @@ """Model : preferences """ -from app import db, log -from app.comp import bonus_spo -from app.scodoc.sco_exceptions import ScoValueError + +from app import db class ScoPreference(db.Model): @@ -19,128 +18,3 @@ class ScoPreference(db.Model): name = db.Column(db.String(128), nullable=False, index=True) value = db.Column(db.Text()) formsemestre_id = db.Column(db.Integer, db.ForeignKey("notes_formsemestre.id")) - - -class ScoDocSiteConfig(db.Model): - """Config. d'un site - Nouveau en ScoDoc 9: va regrouper les paramètres qui dans les versions - antérieures étaient dans scodoc_config.py - """ - - __tablename__ = "scodoc_site_config" - - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(128), nullable=False, index=True) - value = db.Column(db.Text()) - - BONUS_SPORT = "bonus_sport_func_name" - NAMES = { - BONUS_SPORT: str, - "always_require_ine": bool, - "SCOLAR_FONT": str, - "SCOLAR_FONT_SIZE": str, - "SCOLAR_FONT_SIZE_FOOT": str, - "INSTITUTION_NAME": str, - "INSTITUTION_ADDRESS": str, - "INSTITUTION_CITY": str, - "DEFAULT_PDF_FOOTER_TEMPLATE": str, - } - - def __init__(self, name, value): - self.name = name - self.value = value - - def __repr__(self): - return f"<{self.__class__.__name__}('{self.name}', '{self.value}')>" - - def get_dict(self) -> dict: - "Returns all data as a dict name = value" - return { - c.name: self.NAMES.get(c.name, lambda x: x)(c.value) - for c in ScoDocSiteConfig.query.all() - } - - @classmethod - def set_bonus_sport_class(cls, class_name): - """Record bonus_sport config. - If class_name not defined, raise NameError - """ - if class_name not in cls.get_bonus_sport_class_names(): - raise NameError("invalid class name for bonus_sport") - c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first() - if c: - log("setting to " + class_name) - c.value = class_name - else: - c = ScoDocSiteConfig(cls.BONUS_SPORT, class_name) - db.session.add(c) - db.session.commit() - - @classmethod - def get_bonus_sport_class_name(cls): - """Get configured bonus function name, or None if None.""" - klass = cls.get_bonus_sport_class_from_name() - if klass is None: - return "" - else: - return klass.name - - @classmethod - def get_bonus_sport_class(cls): - """Get configured bonus function, or None if None.""" - return cls.get_bonus_sport_class_from_name() - - @classmethod - def get_bonus_sport_class_from_name(cls, class_name=None): - """returns bonus class with specified name. - If name not specified, return the configured function. - None if no bonus function configured. - Raises ScoValueError if class_name not found in module bonus_sport. - """ - if class_name is None: - c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first() - if c is None: - return None - class_name = c.value - if class_name == "": # pas de bonus défini - return None - klass = bonus_spo.get_bonus_class_dict().get(class_name) - if klass is None: - raise ScoValueError( - f"""Fonction de calcul bonus sport inexistante: {class_name}. - (contacter votre administrateur local).""" - ) - return klass - - @classmethod - def get_bonus_sport_class_names(cls): - """List available functions names - (starting with empty string to represent "no bonus function"). - """ - return [""] + sorted(bonus_spo.get_bonus_class_dict().keys()) - - @classmethod - def get_bonus_sport_func(cls): - """Fonction bonus_sport ScoDoc 7 XXX - Transitoire pour les tests durant la transition #sco92 - """ - """returns bonus func with specified name. - If name not specified, return the configured function. - None if no bonus function configured. - Raises ScoValueError if func_name not found in module bonus_sport. - """ - from app.scodoc import bonus_sport - - c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first() - if c is None: - return None - func_name = c.value - if func_name == "": # pas de bonus défini - return None - try: - return getattr(bonus_sport, func_name) - except AttributeError: - raise ScoValueError( - f"""Fonction de calcul maison inexistante: {func_name}. - (contacter votre administrateur local).""" - ) diff --git a/app/scodoc/notes_table.py b/app/scodoc/notes_table.py index 090f18e0..07cbd133 100644 --- a/app/scodoc/notes_table.py +++ b/app/scodoc/notes_table.py @@ -788,7 +788,12 @@ class NotesTable: moy_ue_cap = ue_cap["moy"] mu["was_capitalized"] = True event_date = event_date or ue_cap["event_date"] - if (moy_ue_cap != "NA") and (moy_ue_cap > max_moy_ue): + if ( + (moy_ue_cap != "NA") + and isinstance(moy_ue_cap, float) + and isinstance(max_moy_ue, float) + and (moy_ue_cap > max_moy_ue) + ): # meilleure UE capitalisée event_date = ue_cap["event_date"] max_moy_ue = moy_ue_cap @@ -1329,7 +1334,11 @@ class NotesTable: t[0] = results.etud_moy_gen[etudid] for i, ue in enumerate(ues, start=1): if ue["type"] != UE_SPORT: - t[i] = results.etud_moy_ue[ue["id"]][etudid] + # temporaire pour 9.1.29 ! + if ue["id"] in results.etud_moy_ue: + t[i] = results.etud_moy_ue[ue["id"]][etudid] + else: + t[i] = "" # re-trie selon la nouvelle moyenne générale: self.T.sort(key=self._row_key) # Remplace aussi le rang: diff --git a/app/scodoc/sco_apogee_csv.py b/app/scodoc/sco_apogee_csv.py index 8fad07dd..565d1168 100644 --- a/app/scodoc/sco_apogee_csv.py +++ b/app/scodoc/sco_apogee_csv.py @@ -95,30 +95,21 @@ from flask import send_file # Pour la détection auto de l'encodage des fichiers Apogée: from chardet import detect as chardet_detect +from app.models.config import ScoDocSiteConfig import app.scodoc.sco_utils as scu -import app.scodoc.notesdb as ndb from app import log from app.scodoc.sco_exceptions import ScoValueError, ScoFormatError from app.scodoc.gen_tables import GenTable from app.scodoc.sco_vdi import ApoEtapeVDI from app.scodoc.sco_codes_parcours import code_semestre_validant from app.scodoc.sco_codes_parcours import ( - ADC, - ADJ, - ADM, - AJ, - ATB, - ATJ, - ATT, - CMP, DEF, + DEM, NAR, RAT, ) from app.scodoc import sco_cache -from app.scodoc import sco_codes_parcours from app.scodoc import sco_formsemestre -from app.scodoc import sco_formsemestre_status from app.scodoc import sco_parcours_dut from app.scodoc import sco_etud @@ -132,24 +123,6 @@ APO_SEP = "\t" APO_NEWLINE = "\r\n" -def code_scodoc_to_apo(code): - """Conversion code jury ScoDoc en code Apogée""" - return { - ATT: "AJAC", - ATB: "AJAC", - ATJ: "AJAC", - ADM: "ADM", - ADJ: "ADM", - ADC: "ADMC", - AJ: "AJ", - CMP: "COMP", - "DEM": "NAR", - DEF: "NAR", - NAR: "NAR", - RAT: "ATT", - }.get(code, "DEF") - - def _apo_fmt_note(note): "Formatte une note pour Apogée (séparateur décimal: ',')" if not note and isinstance(note, float): @@ -449,7 +422,7 @@ class ApoEtud(dict): N=_apo_fmt_note(ue_status["moy"]), B=20, J="", - R=code_scodoc_to_apo(code_decision_ue), + R=ScoDocSiteConfig.get_code_apo(code_decision_ue), M="", ) else: @@ -475,13 +448,9 @@ class ApoEtud(dict): def comp_elt_semestre(self, nt, decision, etudid): """Calcul résultat apo semestre""" # resultat du semestre - decision_apo = code_scodoc_to_apo(decision["code"]) + decision_apo = ScoDocSiteConfig.get_code_apo(decision["code"]) note = nt.get_etud_moy_gen(etudid) - if ( - decision_apo == "DEF" - or decision["code"] == "DEM" - or decision["code"] == DEF - ): + if decision_apo == "DEF" or decision["code"] == DEM or decision["code"] == DEF: note_str = "0,01" # note non nulle pour les démissionnaires else: note_str = _apo_fmt_note(note) @@ -520,21 +489,21 @@ class ApoEtud(dict): # ou jury intermediaire et etudiant non redoublant... return self.comp_elt_semestre(cur_nt, cur_decision, etudid) - decision_apo = code_scodoc_to_apo(cur_decision["code"]) + decision_apo = ScoDocSiteConfig.get_code_apo(cur_decision["code"]) autre_nt = sco_cache.NotesTableCache.get(autre_sem["formsemestre_id"]) autre_decision = autre_nt.get_etud_decision_sem(etudid) if not autre_decision: # pas de decision dans l'autre => pas de résultat annuel return VOID_APO_RES - autre_decision_apo = code_scodoc_to_apo(autre_decision["code"]) + autre_decision_apo = ScoDocSiteConfig.get_code_apo(autre_decision["code"]) if ( autre_decision_apo == "DEF" - or autre_decision["code"] == "DEM" + or autre_decision["code"] == DEM or autre_decision["code"] == DEF ) or ( decision_apo == "DEF" - or cur_decision["code"] == "DEM" + or cur_decision["code"] == DEM or cur_decision["code"] == DEF ): note_str = "0,01" # note non nulle pour les démissionnaires diff --git a/app/scodoc/sco_codes_parcours.py b/app/scodoc/sco_codes_parcours.py index 4ff29bb7..6bcb8cc3 100644 --- a/app/scodoc/sco_codes_parcours.py +++ b/app/scodoc/sco_codes_parcours.py @@ -125,6 +125,7 @@ CMP = "CMP" # utile pour UE seulement (indique UE acquise car semestre acquis) NAR = "NAR" RAT = "RAT" # en attente rattrapage, sera ATT dans Apogée DEF = "DEF" # défaillance (n'est pas un code jury dans scodoc mais un état, comme inscrit ou demission) +DEM = "DEM" # codes actions REDOANNEE = "REDOANNEE" # redouble annee (va en Sn-1) @@ -140,22 +141,26 @@ BUG = "BUG" ALL = "ALL" +# Explication des codes (de demestre ou d'UE) CODES_EXPL = { - ADM: "Validé", ADC: "Validé par compensation", ADJ: "Validé par le Jury", - ATT: "Décision en attente d'un autre semestre (faute d'atteindre la moyenne)", + ADM: "Validé", + AJ: "Ajourné", ATB: "Décision en attente d'un autre semestre (au moins une UE sous la barre)", ATJ: "Décision en attente d'un autre semestre (assiduité insuffisante)", - AJ: "Ajourné", - NAR: "Echec, non autorisé à redoubler", - RAT: "En attente d'un rattrapage", + ATT: "Décision en attente d'un autre semestre (faute d'atteindre la moyenne)", + CMP: "Code UE acquise car semestre acquis", DEF: "Défaillant", + NAR: "Échec, non autorisé à redoubler", + RAT: "En attente d'un rattrapage", } # Nota: ces explications sont personnalisables via le fichier # de config locale /opt/scodoc/var/scodoc/config/scodoc_local.py # variable: CONFIG.CODES_EXP +# Les codes de semestres: +CODES_JURY_SEM = {ADC, ADJ, ADM, AJ, ATB, ATJ, ATT, DEF, NAR, RAT} CODES_SEM_VALIDES = {ADM: True, ADC: True, ADJ: True} # semestre validé CODES_SEM_ATTENTES = {ATT: True, ATB: True, ATJ: True} # semestre en attente diff --git a/app/scodoc/sco_edit_module.py b/app/scodoc/sco_edit_module.py index fbc28db6..b575123e 100644 --- a/app/scodoc/sco_edit_module.py +++ b/app/scodoc/sco_edit_module.py @@ -32,15 +32,16 @@ import flask from flask import url_for, render_template from flask import g, request from flask_login import current_user + +from app import log +from app import models from app.models import APO_CODE_STR_LEN -from app.models import Matiere, Module, UniteEns +from app.models import Formation, Matiere, Module, UniteEns +from app.models import FormSemestre, ModuleImpl import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu from app.scodoc.sco_utils import ModuleType -from app import log -from app import models -from app.models import Formation from app.scodoc.TrivialFormulator import TrivialFormulator from app.scodoc.sco_permissions import Permission from app.scodoc.sco_exceptions import ( @@ -294,6 +295,7 @@ def module_create(matiere_id=None, module_type=None, semestre_id=None): "title": "Code Apogée", "size": 25, "explanation": "(optionnel) code élément pédagogique Apogée ou liste de codes ELP séparés par des virgules", + "validator": lambda val, _: len(val) < APO_CODE_STR_LEN, }, ), ( @@ -472,16 +474,31 @@ def module_edit(module_id=None): formation_id = module["formation_id"] formation = sco_formations.formation_list(args={"formation_id": formation_id})[0] parcours = sco_codes_parcours.get_parcours_from_code(formation["type_parcours"]) - is_apc = parcours.APC_SAE - ues_matieres = ndb.SimpleDictFetch( - """SELECT ue.acronyme, mat.*, mat.id AS matiere_id - FROM notes_matieres mat, notes_ue ue - WHERE mat.ue_id = ue.id - AND ue.formation_id = %(formation_id)s - ORDER BY ue.numero, mat.numero - """, - {"formation_id": formation_id}, - ) + is_apc = parcours.APC_SAE # BUT + in_use = len(a_module.modimpls.all()) > 0 # il y a des modimpls + if in_use: + # matières du même semestre seulement + ues_matieres = ndb.SimpleDictFetch( + """SELECT ue.acronyme, mat.*, mat.id AS matiere_id + FROM notes_matieres mat, notes_ue ue + WHERE mat.ue_id = ue.id + AND ue.formation_id = %(formation_id)s + AND ue.semestre_idx = %(semestre_idx)s + ORDER BY ue.numero, mat.numero + """, + {"formation_id": formation_id, "semestre_idx": a_module.ue.semestre_idx}, + ) + else: + # matières de la formation + ues_matieres = ndb.SimpleDictFetch( + """SELECT ue.acronyme, mat.*, mat.id AS matiere_id + FROM notes_matieres mat, notes_ue ue + WHERE mat.ue_id = ue.id + AND ue.formation_id = %(formation_id)s + ORDER BY ue.numero, mat.numero + """, + {"formation_id": formation_id}, + ) mat_names = ["%s / %s" % (x["acronyme"], x["titre"]) for x in ues_matieres] ue_mat_ids = ["%s!%s" % (x["ue_id"], x["matiere_id"]) for x in ues_matieres] module["ue_matiere_id"] = "%s!%s" % (module["ue_id"], module["matiere_id"]) @@ -500,12 +517,25 @@ def module_edit(module_id=None): ), """

Modification du module %(titre)s""" % module, """ (formation %(acronyme)s, version %(version)s)

""" % formation, - render_template("scodoc/help/modules.html", is_apc=is_apc), + render_template( + "scodoc/help/modules.html", + is_apc=is_apc, + formsemestres=FormSemestre.query.filter( + ModuleImpl.formsemestre_id == FormSemestre.id, + ModuleImpl.module_id == module_id, + ).all(), + ), ] if not unlocked: H.append( """
Formation verrouillée, seuls certains éléments peuvent être modifiés
""" ) + if in_use: + H.append( + """
Module déjà utilisé dans des semestres, + soyez prudents ! +
""" + ) descr = [ ( @@ -680,6 +710,13 @@ def module_edit(module_id=None): else: # l'UE peut changer tf[2]["ue_id"], tf[2]["matiere_id"] = tf[2]["ue_matiere_id"].split("!") + old_ue_id = a_module.ue.id + new_ue_id = int(tf[2]["ue_id"]) + if (old_ue_id != new_ue_id) and in_use: + # pas changer de semestre un module utilisé ! + raise ScoValueError( + "Module utilisé: il ne peut pas être changé de semestre !" + ) # En APC, force le semestre égal à celui de l'UE if is_apc: selected_ue = UniteEns.query.get(tf[2]["ue_id"]) diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index 5282c5ce..48ca2713 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -1229,7 +1229,8 @@ def do_ue_edit(args, bypass_lock=False, dont_invalidate_cache=False): def edit_ue_set_code_apogee(id=None, value=None): "set UE code apogee" ue_id = id - value = value.strip("-_ \t") + value = value.strip("-_ \t")[:APO_CODE_STR_LEN] # tronque + log("edit_ue_set_code_apogee: ue_id=%s code_apogee=%s" % (ue_id, value)) ues = ue_list(args={"ue_id": ue_id}) diff --git a/app/scodoc/sco_evaluation_edit.py b/app/scodoc/sco_evaluation_edit.py index c324a89b..ab178c15 100644 --- a/app/scodoc/sco_evaluation_edit.py +++ b/app/scodoc/sco_evaluation_edit.py @@ -143,6 +143,7 @@ def evaluation_create_form( if vals.get("tf_submitted", False) and "visibulletinlist" not in vals: vals["visibulletinlist"] = [] # + ue_coef_dict = {} if is_apc: # BUT: poids vers les UE ue_coef_dict = ModuleImpl.query.get(moduleimpl_id).module.get_ue_coef_dict() for ue in sem_ues: @@ -290,7 +291,10 @@ def evaluation_create_form( "title": f"Poids {ue.acronyme}", "size": 2, "type": "float", - "explanation": f"{ue.titre}", + "explanation": f""" + {ue_coef_dict.get(ue.id, 0.)} + {ue.titre} + """, "allow_null": False, }, ), diff --git a/app/scodoc/sco_exceptions.py b/app/scodoc/sco_exceptions.py index d975766e..5f64f57b 100644 --- a/app/scodoc/sco_exceptions.py +++ b/app/scodoc/sco_exceptions.py @@ -36,15 +36,6 @@ class ScoException(Exception): pass -class NoteProcessError(ScoException): - "misc errors in process" - pass - - -class InvalidEtudId(NoteProcessError): - pass - - class InvalidNoteValue(ScoException): pass @@ -56,6 +47,15 @@ class ScoValueError(ScoException): self.dest_url = dest_url +class NoteProcessError(ScoValueError): + "Valeurs notes invalides" + pass + + +class InvalidEtudId(NoteProcessError): + pass + + class ScoFormatError(ScoValueError): pass diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index 36549bb3..baee42f3 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -748,7 +748,7 @@ def form_decision_manuelle(Se, formsemestre_id, etudid, desturl="", sortcol=None ) # Choix code semestre: - codes = list(sco_codes_parcours.CODES_EXPL.keys()) + codes = list(sco_codes_parcours.CODES_JURY_SEM) codes.sort() # fortuitement, cet ordre convient bien ! H.append( diff --git a/app/scodoc/sco_groups.py b/app/scodoc/sco_groups.py index 2036a64d..d1cfb47d 100644 --- a/app/scodoc/sco_groups.py +++ b/app/scodoc/sco_groups.py @@ -87,7 +87,7 @@ groupEditor = ndb.EditableTable( group_list = groupEditor.list -def get_group(group_id): +def get_group(group_id: int): """Returns group object, with partition""" r = ndb.SimpleDictFetch( """SELECT gd.id AS group_id, gd.*, p.id AS partition_id, p.* @@ -687,6 +687,11 @@ def setGroups( group_id = fs[0].strip() if not group_id: continue + try: + group_id = int(group_id) + except ValueError as exc: + log("setGroups: ignoring invalid group_id={group_id}") + continue group = get_group(group_id) # Anciens membres du groupe: old_members = get_group_members(group_id) diff --git a/app/scodoc/sco_inscr_passage.py b/app/scodoc/sco_inscr_passage.py index 3875bbca..831cc87a 100644 --- a/app/scodoc/sco_inscr_passage.py +++ b/app/scodoc/sco_inscr_passage.py @@ -49,9 +49,11 @@ from app.scodoc import sco_etud from app.scodoc.sco_exceptions import ScoValueError -def list_authorized_etuds_by_sem(sem, delai=274): +def list_authorized_etuds_by_sem(sem, delai=274, ignore_jury=False): """Liste des etudiants autorisés à s'inscrire dans sem. delai = nb de jours max entre la date de l'autorisation et celle de debut du semestre cible. + ignore_jury: si vrai, considère tous les étudiants comem autorisés, même + s'ils n'ont pas de décision de jury. """ src_sems = list_source_sems(sem, delai=delai) inscrits = list_inscrits(sem["formsemestre_id"]) @@ -59,7 +61,12 @@ def list_authorized_etuds_by_sem(sem, delai=274): candidats = {} # etudid : etud (tous les etudiants candidats) nb = 0 # debug for src in src_sems: - liste = list_etuds_from_sem(src, sem) + if ignore_jury: + # liste de tous les inscrits au semestre (sans dems) + liste = list_inscrits(src["formsemestre_id"]).values() + else: + # liste des étudiants autorisés par le jury à s'inscrire ici + liste = list_etuds_from_sem(src, sem) liste_filtree = [] for e in liste: # Filtre ceux qui se sont déjà inscrit dans un semestre APRES le semestre src @@ -125,7 +132,7 @@ def list_inscrits(formsemestre_id, with_dems=False): return inscr -def list_etuds_from_sem(src, dst): +def list_etuds_from_sem(src, dst) -> list[dict]: """Liste des etudiants du semestre src qui sont autorisés à passer dans le semestre dst.""" target = dst["semestre_id"] dpv = sco_pvjury.dict_pvjury(src["formsemestre_id"]) @@ -224,7 +231,7 @@ def do_desinscrit(sem, etudids): ) -def list_source_sems(sem, delai=None): +def list_source_sems(sem, delai=None) -> list[dict]: """Liste des semestres sources sem est le semestre destination """ @@ -265,6 +272,7 @@ def formsemestre_inscr_passage( inscrit_groupes=False, submitted=False, dialog_confirmed=False, + ignore_jury=False, ): """Form. pour inscription des etudiants d'un semestre dans un autre (donné par formsemestre_id). @@ -280,6 +288,7 @@ def formsemestre_inscr_passage( """ inscrit_groupes = int(inscrit_groupes) + ignore_jury = int(ignore_jury) sem = sco_formsemestre.get_formsemestre(formsemestre_id) # -- check lock if not sem["etat"]: @@ -295,7 +304,9 @@ def formsemestre_inscr_passage( elif etuds and isinstance(etuds[0], str): etuds = [int(x) for x in etuds] - auth_etuds_by_sem, inscrits, candidats = list_authorized_etuds_by_sem(sem) + auth_etuds_by_sem, inscrits, candidats = list_authorized_etuds_by_sem( + sem, ignore_jury=ignore_jury + ) etuds_set = set(etuds) candidats_set = set(candidats) inscrits_set = set(inscrits) @@ -323,6 +334,7 @@ def formsemestre_inscr_passage( candidats_non_inscrits, inscrits_ailleurs, inscrit_groupes=inscrit_groupes, + ignore_jury=ignore_jury, ) else: if not dialog_confirmed: @@ -363,6 +375,7 @@ def formsemestre_inscr_passage( "formsemestre_id": formsemestre_id, "etuds": ",".join([str(x) for x in etuds]), "inscrit_groupes": inscrit_groupes, + "ignore_jury": ignore_jury, "submitted": 1, }, ) @@ -411,18 +424,23 @@ def build_page( candidats_non_inscrits, inscrits_ailleurs, inscrit_groupes=False, + ignore_jury=False, ): inscrit_groupes = int(inscrit_groupes) + ignore_jury = int(ignore_jury) if inscrit_groupes: inscrit_groupes_checked = " checked" else: inscrit_groupes_checked = "" - + if ignore_jury: + ignore_jury_checked = " checked" + else: + ignore_jury_checked = "" H = [ html_sco_header.html_sem_header( "Passages dans le semestre", with_page_header=False ), - """
""" % request.base_url, + """""" % request.base_url, """  aide @@ -430,6 +448,8 @@ def build_page( % sem, # " """inscrire aux mêmes groupes""" % inscrit_groupes_checked, + """inclure tous les étudiants (même sans décision de jury)""" + % ignore_jury_checked, """
Actuellement %s inscrits et %d candidats supplémentaires
""" diff --git a/app/scodoc/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py index af2e15dd..b664c0e6 100644 --- a/app/scodoc/sco_moduleimpl_status.py +++ b/app/scodoc/sco_moduleimpl_status.py @@ -401,7 +401,9 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None): eval_index = len(mod_evals) - 1 first_eval = True for eval in mod_evals: - evaluation = Evaluation.query.get(eval["evaluation_id"]) # TODO unifier + evaluation: Evaluation = Evaluation.query.get( + eval["evaluation_id"] + ) # TODO unifier etat = sco_evaluations.do_evaluation_etat( eval["evaluation_id"], partition_id=partition_id, diff --git a/app/scodoc/sco_portal_apogee.py b/app/scodoc/sco_portal_apogee.py index f78e9003..836be2ed 100644 --- a/app/scodoc/sco_portal_apogee.py +++ b/app/scodoc/sco_portal_apogee.py @@ -169,7 +169,9 @@ def get_inscrits_etape(code_etape, anneeapogee=None, ntrials=2): if doc: break if not doc: - raise ScoValueError("pas de réponse du portail ! (timeout=%s)" % portal_timeout) + raise ScoValueError( + f"pas de réponse du portail !
(timeout={portal_timeout}, requête: {req})" + ) etuds = _normalize_apo_fields(xml_to_list_of_dicts(doc, req=req)) # Filtre sur annee inscription Apogee: diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index a0d9b010..6aba9694 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -111,8 +111,9 @@ get_base_preferences(formsemestre_id) """ import flask -from flask import g, url_for, request -from flask_login import current_user +from flask import g, request, current_app + +# from flask_login import current_user from app.models import Departement from app.scodoc import sco_cache @@ -1537,7 +1538,7 @@ class BasePreferences(object): ( "email_from_addr", { - "initvalue": "noreply@scodoc.example.com", + "initvalue": current_app.config["SCODOC_MAIL_FROM"], "title": "adresse mail origine", "size": 40, "explanation": "adresse expéditeur pour les envois par mails (bulletins)", diff --git a/app/scodoc/sco_pvjury.py b/app/scodoc/sco_pvjury.py index 084b1ca0..ea43cb2d 100644 --- a/app/scodoc/sco_pvjury.py +++ b/app/scodoc/sco_pvjury.py @@ -566,7 +566,7 @@ def formsemestre_pvjury(formsemestre_id, format="html", publish=True): if "prev_decision" in row and row["prev_decision"]: counts[row["prev_decision"]] += 0 # Légende des codes - codes = list(counts.keys()) # sco_codes_parcours.CODES_EXPL.keys() + codes = list(counts.keys()) codes.sort() H.append("

Explication des codes

") lines = [] diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py index cbcc012d..cc159077 100644 --- a/app/scodoc/sco_saisie_notes.py +++ b/app/scodoc/sco_saisie_notes.py @@ -153,7 +153,10 @@ def _check_notes(notes, evaluation, mod): for (etudid, note) in notes: note = str(note).strip().upper() - etudid = int(etudid) # + try: + etudid = int(etudid) # + except ValueError as exc: + raise ScoValueError(f"Code étudiant ({etudid}) invalide") if note[:3] == "DEM": continue # skip ! if note: @@ -487,10 +490,10 @@ def notes_add( } for (etudid, value) in notes: if check_inscription and (etudid not in inscrits): - raise NoteProcessError("etudiant non inscrit dans ce module") - if not ((value is None) or (type(value) == type(1.0))): + raise NoteProcessError(f"etudiant {etudid} non inscrit dans ce module") + if (value is not None) and not isinstance(value, float): raise NoteProcessError( - "etudiant %s: valeur de note invalide (%s)" % (etudid, value) + f"etudiant {etudid}: valeur de note invalide ({value})" ) # Recherche notes existantes notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id) diff --git a/app/scodoc/sco_undo_notes.py b/app/scodoc/sco_undo_notes.py index 56684e39..5d169b8b 100644 --- a/app/scodoc/sco_undo_notes.py +++ b/app/scodoc/sco_undo_notes.py @@ -181,7 +181,7 @@ def formsemestre_list_saisies_notes(formsemestre_id, format="html"): """ sem = sco_formsemestre.get_formsemestre(formsemestre_id, raise_soft_exc=True) r = ndb.SimpleDictFetch( - """SELECT i.nom, i.prenom, code_nip, n.*, mod.titre, e.description, e.jour, u.user_name + """SELECT i.nom, i.prenom, code_nip, n.*, mod.titre, e.description, e.jour, u.user_name, e.id as evaluation_id FROM notes_notes n, notes_evaluation e, notes_moduleimpl mi, notes_modules mod, identite i, "user" u WHERE mi.id = e.moduleimpl_id @@ -202,6 +202,7 @@ def formsemestre_list_saisies_notes(formsemestre_id, format="html"): "value", "user_name", "titre", + "evaluation_id", "description", "jour", "comment", @@ -214,6 +215,7 @@ def formsemestre_list_saisies_notes(formsemestre_id, format="html"): "value": "Note", "comment": "Remarque", "user_name": "Enseignant", + "evaluation_id": "evaluation_id", "titre": "Module", "description": "Evaluation", "jour": "Date éval.", diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index d91ce733..684df8a0 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -1513,6 +1513,16 @@ table.moduleimpl_evaluations td.eval_poids { color:rgb(0, 0, 255); } +span.eval_coef_ue { + color:rgb(6, 73, 6); + font-style: normal; + font-size: 80%; + margin-right: 2em; +} +span.eval_coef_ue_titre { + +} + /* Formulaire edition des partitions */ form#editpart table { border: 1px solid gray; diff --git a/app/templates/config_codes_decisions.html b/app/templates/config_codes_decisions.html new file mode 100644 index 00000000..0c2f32b2 --- /dev/null +++ b/app/templates/config_codes_decisions.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block app_content %} +

Configuration des codes de décision exportés vers Apogée

+ + +
+

Ces codes (ADM, AJ, ...) sont utilisés pour représenter les décisions de jury +et les validations de semestres ou d'UE. les valeurs indiquées ici sont utilisées +dans les exports Apogée. +

+

Ne les modifier que si vous savez ce que vous faites ! +

+
+
+
+ {{ wtf.quick_form(form) }} +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/templates/configuration.html b/app/templates/configuration.html index d4df6ed4..1eee13d4 100644 --- a/app/templates/configuration.html +++ b/app/templates/configuration.html @@ -93,6 +93,8 @@
Les paramètres donnés ici s'appliquent à tout ScoDoc (tous les départements):
{{ render_field(form.bonus_sport_func_name, onChange="submit_form()")}} +

Exports Apogée

+

configuration des codes de décision

Bibliothèque de logos

{% for dept_entry in form.depts.entries %} {% set dept_form = dept_entry.form %} diff --git a/app/templates/scodoc/help/modules.html b/app/templates/scodoc/help/modules.html index d01a5d35..cd6e0767 100644 --- a/app/templates/scodoc/help/modules.html +++ b/app/templates/scodoc/help/modules.html @@ -24,4 +24,24 @@ la documentation.

{%endif%} + + {% if formsemestres %} +

+ Ce module est utilisé dans des semestres déjà mis en place, il faut prêter attention + aux conséquences des changements effectués ici: par exemple les coefficients vont modifier + les notes moyennes calculées. Les modules déjà utilisés ne peuvent pas être changés de semestre, ni détruits. + Si vous souhaitez faire cela, allez d'abord modifier les semestres concernés pour déselectionner le module. +

+

Semestres utilisant ce module:

+ + {%endif%} + \ No newline at end of file diff --git a/app/views/notes.py b/app/views/notes.py index be32ead8..b9d468aa 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -290,13 +290,17 @@ def formsemestre_bulletinetud( if etudid: etud = models.Identite.query.get_or_404(etudid) elif code_nip: - etud = models.Identite.query.filter_by( - code_nip=str(code_nip) - ).first_or_404() + etud = ( + models.Identite.query.filter_by(code_nip=str(code_nip)) + .filter_by(dept_id=formsemestre.dept_id) + .first_or_404() + ) elif code_ine: - etud = models.Identite.query.filter_by( - code_ine=str(code_ine) - ).first_or_404() + etud = ( + models.Identite.query.filter_by(code_ine=str(code_ine)) + .filter_by(dept_id=formsemestre.dept_id) + .first_or_404() + ) else: raise ScoValueError( "Paramètre manquant: spécifier code_nip ou etudid ou code_ine" diff --git a/app/views/scodoc.py b/app/views/scodoc.py index 6a9bb58a..ea2f2a41 100644 --- a/app/views/scodoc.py +++ b/app/views/scodoc.py @@ -33,49 +33,38 @@ Emmanuel Viennet, 2021 import datetime import io -import wtforms.validators - -from app.auth.models import User -import os - import flask from flask import abort, flash, url_for, redirect, render_template, send_file from flask import request -from flask.app import Flask import flask_login from flask_login.utils import login_required, current_user -from flask_wtf import FlaskForm -from flask_wtf.file import FileField, FileAllowed -from werkzeug.exceptions import BadRequest, NotFound -from wtforms import SelectField, SubmitField, FormField, validators, Form, FieldList -from wtforms.fields import IntegerField -from wtforms.fields.simple import BooleanField, StringField, TextAreaField, HiddenField -from wtforms.validators import ValidationError, DataRequired, Email, EqualTo +from PIL import Image as PILImage + +from werkzeug.exceptions import BadRequest, NotFound + -import app from app import db +from app.auth.models import User from app.forms.main import config_forms from app.forms.main.create_dept import CreateDeptForm +from app.forms.main.config_apo import CodesDecisionsForm +from app import models from app.models import Departement, Identite from app.models import departements from app.models import FormSemestre, FormSemestreInscription -import sco_version -from app.scodoc import sco_logos +from app.models import ScoDocSiteConfig +from app.scodoc import sco_codes_parcours, sco_logos from app.scodoc import sco_find_etud from app.scodoc import sco_utils as scu from app.decorators import ( admin_required, scodoc7func, scodoc, - permission_required_compat_scodoc7, - permission_required, ) from app.scodoc.sco_exceptions import AccessDenied -from app.scodoc.sco_logos import find_logo from app.scodoc.sco_permissions import Permission from app.views import scodoc_bp as bp - -from PIL import Image as PILImage +import sco_version @bp.route("/") @@ -133,6 +122,28 @@ def toggle_dept_vis(dept_id): return redirect(url_for("scodoc.index")) +@bp.route("/ScoDoc/config_codes_decisions", methods=["GET", "POST"]) +@admin_required +def config_codes_decisions(): + """Form config codes decisions""" + form = CodesDecisionsForm() + if request.method == "POST" and form.cancel.data: # cancel button + return redirect(url_for("scodoc.index")) + if form.validate_on_submit(): + for code in models.config.CODES_SCODOC_TO_APO: + ScoDocSiteConfig.set_code_apo(code, getattr(form, code).data) + flash(f"Codes décisions enregistrés.") + return redirect(url_for("scodoc.index")) + elif request.method == "GET": + for code in models.config.CODES_SCODOC_TO_APO: + getattr(form, code).data = ScoDocSiteConfig.get_code_apo(code) + return render_template( + "config_codes_decisions.html", + form=form, + title="Configuration des codes de décisions", + ) + + @bp.route("/ScoDoc/table_etud_in_accessible_depts", methods=["POST"]) @login_required def table_etud_in_accessible_depts(): @@ -257,14 +268,16 @@ def _return_logo(name="header", dept_id="", small=False, strict: bool = True): suffix = logo.suffix if small: with PILImage.open(logo.filepath) as im: - im.thumbnail(SMALL_SIZE) - stream = io.BytesIO() # on garde le même format (on pourrait plus simplement générer systématiquement du JPEG) fmt = { # adapt suffix to be compliant with PIL save format "PNG": "PNG", "JPG": "JPEG", "JPEG": "JPEG", }[suffix.upper()] + if fmt == "JPEG": + im = im.convert("RGB") + im.thumbnail(SMALL_SIZE) + stream = io.BytesIO() im.save(stream, fmt) stream.seek(0) return send_file(stream, mimetype=f"image/{fmt}") diff --git a/app/views/users.py b/app/views/users.py index cc9b9db0..fe65348c 100644 --- a/app/views/users.py +++ b/app/views/users.py @@ -81,7 +81,7 @@ _l = _ class ChangePasswordForm(FlaskForm): user_name = HiddenField() old_password = PasswordField(_l("Identifiez-vous")) - new_password = PasswordField(_l("Nouveau mot de passe")) + new_password = PasswordField(_l("Nouveau mot de passe de l'utilisateur")) bis_password = PasswordField( _l("Répéter"), validators=[ diff --git a/config.py b/config.py index ae507b1e..fca2fc51 100755 --- a/config.py +++ b/config.py @@ -26,6 +26,9 @@ class Config: SCODOC_ADMIN_LOGIN = os.environ.get("SCODOC_ADMIN_LOGIN") or "admin" ADMINS = [SCODOC_ADMIN_MAIL] SCODOC_ERR_MAIL = os.environ.get("SCODOC_ERR_MAIL") + # Le "from" des mails émis. Attention: peut être remplacée par la préférence email_from_addr: + SCODOC_MAIL_FROM = os.environ.get("SCODOC_MAIL_FROM") or ("no-reply@" + MAIL_SERVER) + BOOTSTRAP_SERVE_LOCAL = os.environ.get("BOOTSTRAP_SERVE_LOCAL") SCODOC_DIR = os.environ.get("SCODOC_DIR", "/opt/scodoc") SCODOC_VAR_DIR = os.environ.get("SCODOC_VAR_DIR", "/opt/scodoc-data") diff --git a/migrations/versions/28874ed6af64_augmente_taille_codes_apogee.py b/migrations/versions/28874ed6af64_augmente_taille_codes_apogee.py new file mode 100644 index 00000000..7ac8c8c3 --- /dev/null +++ b/migrations/versions/28874ed6af64_augmente_taille_codes_apogee.py @@ -0,0 +1,84 @@ +"""augmente taille codes Apogée + +Revision ID: 28874ed6af64 +Revises: f40fbaf5831c +Create Date: 2022-01-19 22:57:59.678313 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "28874ed6af64" +down_revision = "f40fbaf5831c" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + op.alter_column( + "notes_formsemestre_etapes", + "etape_apo", + existing_type=sa.VARCHAR(length=24), + type_=sa.String(length=512), + existing_nullable=True, + ) + op.alter_column( + "notes_formsemestre_inscription", + "etape", + existing_type=sa.VARCHAR(length=24), + type_=sa.String(length=512), + existing_nullable=True, + ) + op.alter_column( + "notes_modules", + "code_apogee", + existing_type=sa.VARCHAR(length=24), + type_=sa.String(length=512), + existing_nullable=True, + ) + op.alter_column( + "notes_ue", + "code_apogee", + existing_type=sa.VARCHAR(length=24), + type_=sa.String(length=512), + existing_nullable=True, + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "notes_ue", + "code_apogee", + existing_type=sa.String(length=512), + type_=sa.VARCHAR(length=24), + existing_nullable=True, + ) + op.alter_column( + "notes_modules", + "code_apogee", + existing_type=sa.String(length=512), + type_=sa.VARCHAR(length=24), + existing_nullable=True, + ) + op.alter_column( + "notes_formsemestre_inscription", + "etape", + existing_type=sa.String(length=512), + type_=sa.VARCHAR(length=24), + existing_nullable=True, + ) + op.alter_column( + "notes_formsemestre_etapes", + "etape_apo", + existing_type=sa.String(length=512), + type_=sa.VARCHAR(length=24), + existing_nullable=True, + ) + + # ### end Alembic commands ### diff --git a/scodoc.py b/scodoc.py index cba555fc..451ebee2 100755 --- a/scodoc.py +++ b/scodoc.py @@ -289,20 +289,28 @@ def user_role(username, dept_acronym=None, add_role_name=None, remove_role_name= db.session.commit() +def abort_if_false(ctx, param, value): + if not value: + ctx.abort() + + @app.cli.command() +@click.option( + "--yes", + is_flag=True, + callback=abort_if_false, + expose_value=False, + prompt=f"""Attention: Cela va effacer toutes les données du département + (étudiants, notes, formations, etc) + Voulez-vous vraiment continuer ? + """, +) @click.argument("dept") def delete_dept(dept): # delete-dept """Delete existing departement""" from app.scodoc import notesdb as ndb from app.scodoc import sco_dept - click.confirm( - f"""Attention: Cela va effacer toutes les données du département {dept} - (étudiants, notes, formations, etc) - Voulez-vous vraiment continuer ? - """, - abort=True, - ) db.reflect() ndb.open_db_connection() d = models.Departement.query.filter_by(acronym=dept).first() diff --git a/tools/import_scodoc7_dept.py b/tools/import_scodoc7_dept.py index 597b9baf..75f5e9a3 100644 --- a/tools/import_scodoc7_dept.py +++ b/tools/import_scodoc7_dept.py @@ -170,6 +170,11 @@ def import_scodoc7_dept(dept_id: str, dept_db_uri=None): logging.info(f"connecting to database {dept_db_uri}") cnx = psycopg2.connect(dept_db_uri) cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + # FIX : des dates aberrantes (dans le futur) peuvent tenir en SQL mais pas en Python + cursor.execute( + """UPDATE scolar_events SET event_date='2021-09-30' WHERE event_date > '2200-01-01'""" + ) + cnx.commit() # Create dept: dept = models.Departement(acronym=dept_id, description="migré de ScoDoc7") db.session.add(dept) @@ -374,6 +379,8 @@ def convert_object( new_ref = id_from_scodoc7[old_ref] elif (not is_table) and table_name in { "scolog", + "entreprise_correspondant", + "entreprise_contact", "etud_annotations", "notes_notes_log", "scolar_news", @@ -389,7 +396,6 @@ def convert_object( new_ref = None elif is_table and table_name in { "notes_semset_formsemestre", - "entreprise_contact", }: # pour anciennes installs où des relations n'avait pas été déclarées clés étrangères # eg: notes_semset_formsemestre.semset_id n'était pas une clé