diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index 7834c4cb1..afd2e9bad 100644 --- a/app/api/formsemestres.py +++ b/app/api/formsemestres.py @@ -13,11 +13,14 @@ FormSemestre """ +import base64 +import io from operator import attrgetter, itemgetter from flask import g, make_response, request from flask_json import as_json from flask_login import current_user, login_required +import PIL import sqlalchemy as sa import app from app import db, log @@ -32,6 +35,7 @@ from app.models import ( Departement, Evaluation, FormSemestre, + FormSemestreDescription, FormSemestreEtape, FormSemestreInscription, Identite, @@ -790,3 +794,95 @@ def formsemestre_edt(formsemestre_id: int): return sco_edt_cal.formsemestre_edt_dict( formsemestre, group_ids=group_ids, show_modules_titles=show_modules_titles ) + + +@bp.route("/formsemestre//description") +@api_web_bp.route("/formsemestre//description") +@login_required +@scodoc +@permission_required(Permission.ScoView) +@as_json +def formsemestre_get_description(formsemestre_id: int): + """Description externe du formsemestre. Peut être vide. + + formsemestre_id : l'id du formsemestre + + SAMPLES + ------- + /formsemestre/1/description + """ + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + return formsemestre.description.to_dict() if formsemestre.description else {} + + +@bp.post("/formsemestre//description/edit") +@api_web_bp.post("/formsemestre//description/edit") +@login_required +@scodoc +@permission_required(Permission.ScoView) +@as_json +def formsemestre_edit_description(formsemestre_id: int): + """Modifie description externe du formsemestre. + Les images peuvent êtres passées dans el json, encodées en base64. + formsemestre_id : l'id du formsemestre + + SAMPLES + ------- + /formsemestre//description/edit;{""description"":""descriptif du semestre"", ""dispositif"":1} + """ + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + args = request.get_json(force=True) # may raise 400 Bad Request + if not formsemestre.description: + formsemestre.description = FormSemestreDescription() + # Decode images (base64) + for key in ["image", "photo_ens"]: + if key in args: + args[key] = base64.b64decode(args[key]) + formsemestre.description.from_dict(args) + db.session.commit() + return formsemestre.description.to_dict() + + +@bp.route("/formsemestre//description/image") +@api_web_bp.route("/formsemestre//description/image") +@login_required +@scodoc +@permission_required(Permission.ScoView) +def formsemestre_get_description_image(formsemestre_id: int): + """Image de la description externe du formsemestre. Peut être vide. + + formsemestre_id : l'id du formsemestre + """ + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + if not formsemestre.description or not formsemestre.description.image: + return make_response("", 204) # 204 No Content status + + return _image_response(formsemestre.description.image) + + +@bp.route("/formsemestre//description/photo_ens") +@api_web_bp.route("/formsemestre//description/photo_ens") +@login_required +@scodoc +@permission_required(Permission.ScoView) +def formsemestre_get_photo_ens(formsemestre_id: int): + """Photo du responsable, ou illustration du formsemestre. Peut être vide.""" + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + if not formsemestre.description or not formsemestre.description.photo_ens: + return make_response("", 204) # 204 No Content status + + return _image_response(formsemestre.description.photo_ens) + + +def _image_response(image_data: bytes): + # Guess the mimetype based on the image data + try: + image = PIL.Image.open(io.BytesIO(image_data)) + mimetype = image.get_format_mimetype() + except PIL.UnidentifiedImageError: + # Default to binary stream if mimetype cannot be determined + mimetype = "application/octet-stream" + + response = make_response(image_data) + response.headers["Content-Type"] = mimetype + return response diff --git a/app/forms/__init__.py b/app/forms/__init__.py index f850a58c3..3b8722daf 100644 --- a/app/forms/__init__.py +++ b/app/forms/__init__.py @@ -1 +1,23 @@ -# empty but required for pylint +"""WTF Forms for ScoDoc +""" + +from flask_wtf import FlaskForm + + +class ScoDocForm(FlaskForm): + """Super class for ScoDoc forms + (inspired by @iziram) + """ + + def __init__(self, *args, **kwargs): + "Init form, adding a filed for our error messages" + super().__init__(*args, **kwargs) + self.ok = True + self.error_messages: list[str] = [] # used to report our errors + + def set_error(self, err_msg, field=None): + "Set error message both in form and field" + self.ok = False + self.error_messages.append(err_msg) + if field: + field.errors.append(err_msg) diff --git a/app/forms/formsemestre/edit_description.py b/app/forms/formsemestre/edit_description.py new file mode 100644 index 000000000..e0b49b2a0 --- /dev/null +++ b/app/forms/formsemestre/edit_description.py @@ -0,0 +1,112 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Formulaire édition description formsemestre +""" +from wtforms import ( + BooleanField, + FileField, + SelectField, + StringField, + TextAreaField, + SubmitField, +) +from wtforms.validators import AnyOf, Optional + +from app.forms import ScoDocForm +from app.models import FORMSEMESTRE_DISPOSITIFS +from app.scodoc import sco_utils as scu + + +class DateDMYField(StringField): + "Champ date JJ/MM/AAAA" + + def __init__(self, *args, **kwargs): + render_kw = kwargs.pop("render_kw", {}) + render_kw.update({"class": "datepicker", "size": 10}) + super().__init__(*args, render_kw=render_kw, **kwargs) + + # note: process_formdata(self, valuelist) ne fonctionne pas + # en cas d'erreur de saisie les valeurs ne sont pas ré-affichées. + # On vérifie donc les valeurs dans le code de la vue. + + def process_data(self, value): + "Process data from model to form" + if value: + self.data = value.strftime(scu.DATE_FMT) + else: + self.data = "" + + +class FormSemestreDescriptionForm(ScoDocForm): + "Formulaire édition description formsemestre" + description = TextAreaField( + "Description", + validators=[Optional()], + description="""texte libre : informations + sur le contenu, les objectifs, les modalités d'évaluation, etc.""", + ) + horaire = StringField( + "Horaire", validators=[Optional()], description="ex: les lundis 9h-12h" + ) + date_debut_inscriptions = DateDMYField( + "Date de début des inscriptions", + description="""date d'ouverture des inscriptions + (laisser vide pour autoriser tout le temps)""", + render_kw={ + "id": "date_debut_inscriptions", + }, + ) + date_fin_inscriptions = DateDMYField( + "Date de fin des inscriptions", + render_kw={ + "id": "date_fin_inscriptions", + }, + ) + image = FileField( + "Image", validators=[Optional()], description="Image illustrant cette formation" + ) + campus = StringField( + "Campus", validators=[Optional()], description="ex: Villetaneuse" + ) + salle = StringField("Salle", validators=[Optional()], description="ex: salle 123") + dispositif = SelectField( + "Dispositif", + choices=FORMSEMESTRE_DISPOSITIFS.items(), + coerce=int, + description="modalité de formation", + validators=[AnyOf(FORMSEMESTRE_DISPOSITIFS.keys())], + ) + modalites_mcc = TextAreaField( + "Modalités de contrôle des connaissances", + validators=[Optional()], + description="texte libre", + ) + photo_ens = FileField( + "Photo de l'enseignant(e)", + validators=[Optional()], + description="ou autre illustration", + ) + public = StringField( + "Public visé", validators=[Optional()], description="ex: débutants" + ) + prerequis = TextAreaField( + "Prérequis", validators=[Optional()], description="texte libre" + ) + responsable = StringField( + "Responsable", + validators=[Optional()], + description="""nom de l'enseignant de la formation, ou personne + chargée de l'organisation du semestre.""", + ) + + wip = BooleanField( + "Travaux en cours", + description="work in progress: si coché, affichera juste le titre du semestre", + ) + + submit = SubmitField("Enregistrer") + cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) diff --git a/app/forms/formsemestre/edit_modimpls_codes_apo.py b/app/forms/formsemestre/edit_modimpls_codes_apo.py index cccf1978c..d0cd8a371 100644 --- a/app/forms/formsemestre/edit_modimpls_codes_apo.py +++ b/app/forms/formsemestre/edit_modimpls_codes_apo.py @@ -14,12 +14,13 @@ class _EditModimplsCodesForm(FlaskForm): # construit dynamiquement ci-dessous +# pylint: disable=invalid-name def EditModimplsCodesForm(formsemestre: FormSemestre) -> _EditModimplsCodesForm: "Création d'un formulaire pour éditer les codes" # Formulaire dynamique, on créé une classe ad-hoc class F(_EditModimplsCodesForm): - pass + "class factory" def _gen_mod_form(modimpl: ModuleImpl): field = StringField( diff --git a/app/models/__init__.py b/app/models/__init__.py index 0cf5498dd..82e8416ff 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -38,7 +38,7 @@ class ScoDocModel(db.Model): __abstract__ = True # declare an abstract class for SQLAlchemy def clone(self, not_copying=()): - """Clone, not copying the given attrs + """Clone, not copying the given attrs, and add to session. Attention: la copie n'a pas d'id avant le prochain flush ou commit. """ d = dict(self.__dict__) @@ -188,6 +188,10 @@ from app.models.formsemestre import ( NotesSemSet, notes_semset_formsemestre, ) +from app.models.formsemestre_descr import ( + FormSemestreDescription, + FORMSEMESTRE_DISPOSITIFS, +) from app.models.moduleimpls import ( ModuleImpl, notes_modules_enseignants, diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 6be8fc618..9c73cf58a 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -23,7 +23,7 @@ from sqlalchemy.sql import text from sqlalchemy import func import app.scodoc.sco_utils as scu -from app import db, log +from app import db, email, log from app.auth.models import User from app import models from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN @@ -36,7 +36,7 @@ from app.models.config import ScoDocSiteConfig from app.models.departements import Departement from app.models.etudiants import Identite from app.models.evaluations import Evaluation -from app.models.events import ScolarNews +from app.models.events import Scolog, ScolarNews from app.models.formations import Formation from app.models.groups import GroupDescr, Partition from app.models.moduleimpls import ( @@ -45,9 +45,10 @@ from app.models.moduleimpls import ( notes_modules_enseignants, ) from app.models.modules import Module +from app.models.scolar_event import ScolarEvent from app.models.ues import UniteEns from app.models.validations import ScolarFormSemestreValidation -from app.scodoc import codes_cursus, sco_preferences +from app.scodoc import codes_cursus, sco_cache, sco_preferences from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_permissions import Permission from app.scodoc.sco_utils import MONTH_NAMES_ABBREV, translate_assiduites_metric @@ -69,6 +70,8 @@ class FormSemestre(models.ScoDocModel): formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id")) semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1") titre = db.Column(db.Text(), nullable=False) + # nb max d'inscriptions (non DEM), null si illimité: + capacite_accueil = db.Column(db.Integer, nullable=True) date_debut = db.Column(db.Date(), nullable=False) date_fin = db.Column(db.Date(), nullable=False) # jour inclus edt_id: str | None = db.Column(db.Text(), index=True, nullable=True) @@ -143,6 +146,12 @@ class FormSemestre(models.ScoDocModel): lazy="dynamic", cascade="all, delete-orphan", ) + description = db.relationship( + "FormSemestreDescription", + back_populates="formsemestre", + cascade="all, delete-orphan", + uselist=False, + ) etuds = db.relationship( "Identite", secondary="notes_formsemestre_inscription", @@ -1013,20 +1022,129 @@ class FormSemestre(models.ScoDocModel): codes |= {x.strip() for x in self.elt_passage_apo.split(",") if x} return codes - def get_inscrits(self, include_demdef=False, order=False) -> list[Identite]: + def get_inscrits( + self, include_demdef=False, order=False, etats: set | None = None + ) -> list[Identite]: """Liste des étudiants inscrits à ce semestre Si include_demdef, tous les étudiants, avec les démissionnaires et défaillants. + Si etats, seuls les étudiants dans l'un des états indiqués. Si order, tri par clé sort_key """ if include_demdef: etuds = [ins.etud for ins in self.inscriptions] - else: + elif not etats: etuds = [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT] + else: + etuds = [ins.etud for ins in self.inscriptions if ins.etat in etats] if order: etuds.sort(key=lambda e: e.sort_key) return etuds + def inscrit_etudiant( + self, + etud: "Identite", + etat: str = scu.INSCRIT, + etape: str | None = None, + method: str | None = None, + ) -> "FormSemestreInscription": + """Inscrit l'étudiant au semestre, ou renvoie son inscription s'il l'est déjà. + Vérifie la capacité d'accueil si indiquée (non null): si le semestre est plein, + lève une exception. Génère un évènement et un log étudiant. + method: indique origine de l'inscription pour le log étudiant. + """ + # remplace ancien do_formsemestre_inscription_create() + if not self.etat: # check lock + raise ScoValueError("inscrit_etudiant: semestre verrouille") + inscr = FormSemestreInscription.query.filter_by( + formsemestre_id=self.id, etudid=etud.id + ).first() + if inscr is not None: + return inscr + + if self.capacite_accueil is not None: + # tous sauf démissionnaires: + inscriptions = self.get_inscrits(etats={scu.INSCRIT, scu.DEF}) + if len(inscriptions) >= self.capacite_accueil: + raise ScoValueError( + f"Semestre {self.titre} complet : {len(self.inscriptions)} inscrits", + dest_url=url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=self.id, + ), + ) + + inscr = FormSemestreInscription( + formsemestre_id=self.id, etudid=etud.id, etat=etat, etape=etape + ) + db.session.add(inscr) + # Évènement + event = ScolarEvent( + etudid=etud.id, + formsemestre_id=self.id, + event_type="INSCRIPTION", + ) + db.session.add(event) + # Log etudiant + Scolog.logdb( + method=method, + etudid=etud.id, + msg=f"inscription en semestre {self.titre_annee()}", + commit=True, + ) + log( + f"inscrit_etudiant: {etud.nomprenom} ({etud.id}) au semestre {self.titre_annee()}" + ) + # Notification mail + self._notify_inscription(etud) + sco_cache.invalidate_formsemestre(formsemestre_id=self.id) + return inscr + + def desinscrit_etudiant(self, etud: Identite): + "Désinscrit l'étudiant du semestre (et notifie le cas échéant)" + inscr_sem = FormSemestreInscription.query.filter_by( + etudid=etud.id, formsemestre_id=self.id + ).first() + if not inscr_sem: + raise ScoValueError( + f"{etud.nomprenom} ({etud.id}) n'est pas inscrit au semestre !" + ) + db.session.delete(inscr_sem) + Scolog.logdb( + method="desinscrit_etudiant", + etudid=etud.id, + msg=f"désinscription semestre {self.titre_annee()}", + commit=True, + ) + log( + f"desinscrit_etudiant: {etud.nomprenom} ({etud.id}) au semestre {self.titre_annee()}" + ) + self._notify_inscription(etud, action="désinscrit") + sco_cache.invalidate_formsemestre(formsemestre_id=self.id) + + def _notify_inscription(self, etud: Identite, action="inscrit") -> None: + "Notifie inscription d'un étudiant: envoie un mail selon paramétrage" + destinations = ( + sco_preferences.get_preference("emails_notifications_inscriptions", self.id) + or "" + ) + destinations = [x.strip() for x in destinations.split(",")] + destinations = [x for x in destinations if x] + if not destinations: + return + txt = f"""{etud.nom_prenom()} + s'est {action}{etud.e} + en {self.titre_annee()}""" + subject = f"""Inscription de {etud.nom_prenom()} en {self.titre_annee()}""" + # build mail + log(f"_notify_inscription: sending notification to {destinations}") + log(f"_notify_inscription: subject: {subject}") + log(txt) + email.send_email( + "[ScoDoc] " + subject, email.get_from_addr(), destinations, txt + ) + def get_partitions_list( self, with_default=True, only_listed=False ) -> list[Partition]: diff --git a/app/models/formsemestre_descr.py b/app/models/formsemestre_descr.py new file mode 100644 index 000000000..1e754c526 --- /dev/null +++ b/app/models/formsemestre_descr.py @@ -0,0 +1,82 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Description d'un formsemestre pour applications tierces. + +Ces informations sont éditables dans ScoDoc et publiés sur l'API +pour affichage dans l'application tierce. +""" + +from app import db +from app import models + + +class FormSemestreDescription(models.ScoDocModel): + """Informations décrivant un "semestre" (session) de formation + pour un apprenant. + """ + + __tablename__ = "notes_formsemestre_description" + + id = db.Column(db.Integer, primary_key=True) + description = db.Column(db.Text(), nullable=False, default="", server_default="") + "description du cours, html autorisé" + horaire = db.Column(db.Text(), nullable=False, default="", server_default="") + "indication sur l'horaire, texte libre" + date_debut_inscriptions = db.Column(db.DateTime(timezone=True), nullable=True) + date_fin_inscriptions = db.Column(db.DateTime(timezone=True), nullable=True) + + wip = db.Column(db.Boolean, nullable=False, default=False, server_default="false") + "work in progress: si vrai, affichera juste le titre du semestre" + + # Store image data directly in the database: + image = db.Column(db.LargeBinary(), nullable=True) + campus = db.Column(db.Text(), nullable=False, default="", server_default="") + salle = db.Column(db.Text(), nullable=False, default="", server_default="") + + dispositif = db.Column(db.Integer, nullable=False, default=0, server_default="0") + "0 présentiel, 1 online, 2 hybride" + modalites_mcc = db.Column(db.Text(), nullable=False, default="", server_default="") + "modalités de contrôle des connaissances" + photo_ens = db.Column(db.LargeBinary(), nullable=True) + "photo de l'enseignant(e)" + public = db.Column(db.Text(), nullable=False, default="", server_default="") + "public visé" + prerequis = db.Column(db.Text(), nullable=False, default="", server_default="") + "prérequis (texte libre, html autorisé)" + responsable = db.Column(db.Text(), nullable=False, default="", server_default="") + "responsable du cours (texte libre, html autorisé)" + formsemestre_id = db.Column( + db.Integer, + db.ForeignKey("notes_formsemestre.id", ondelete="CASCADE"), + nullable=False, + ) + formsemestre = db.relationship( + "FormSemestre", back_populates="description", uselist=False + ) + + def __repr__(self): + return f"" + + def clone(self, not_copying=()) -> "FormSemestreDescription": + """clone instance""" + return super().clone(not_copying=not_copying + ("formsemestre_id",)) + + def to_dict(self, exclude_images=True) -> dict: + "dict, tous les attributs sauf les images" + d = dict(self.__dict__) + d.pop("_sa_instance_state", None) + if exclude_images: + d.pop("image", None) + d.pop("photo_ens", None) + return d + + +FORMSEMESTRE_DISPOSITIFS = { + 0: "présentiel", + 1: "en ligne", + 2: "hybride", +} diff --git a/app/scodoc/sco_excel.py b/app/scodoc/sco_excel.py index 1ef1df495..8a30e92e3 100644 --- a/app/scodoc/sco_excel.py +++ b/app/scodoc/sco_excel.py @@ -45,6 +45,7 @@ from openpyxl.worksheet.worksheet import Worksheet import app.scodoc.sco_utils as scu from app import log +from app.models.scolar_event import ScolarEvent from app.scodoc.sco_exceptions import ScoValueError from app.scodoc import notesdb, sco_preferences @@ -638,11 +639,12 @@ def excel_feuille_listeappel( lines, partitions=None, with_codes=False, + with_date_inscription=False, with_paiement=False, server_name=None, edt_params: dict = None, ): - """generation feuille appel + """Génération feuille appel. edt_params : - "discipline" : Discipline @@ -763,7 +765,8 @@ def excel_feuille_listeappel( cells.append(ws.make_cell("etudid", style3)) cells.append(ws.make_cell("code_nip", style3)) cells.append(ws.make_cell("code_ine", style3)) - + if with_date_inscription: + cells.append(ws.make_cell("Date inscr.", style3)) # case Groupes cells.append(ws.make_cell("Groupes", style3)) letter_int += 1 @@ -805,7 +808,15 @@ def excel_feuille_listeappel( cells.append(ws.make_cell(code_nip, style2t3)) code_ine = t.get("code_ine", "") cells.append(ws.make_cell(code_ine, style2t3)) - + if with_date_inscription: + event = ScolarEvent.query.filter_by( + etudid=t["etudid"], + event_type="INSCRIPTION", + formsemestre_id=formsemestre_id, + ).first() + if event: + date_inscription = event.event_date + cells.append(ws.make_cell(date_inscription, style2t3)) cells.append(ws.make_cell(style=style2t3)) ws.append_row(cells) ws.set_row_dimension_height(row_id, 30) diff --git a/app/scodoc/sco_formsemestre.py b/app/scodoc/sco_formsemestre.py index 00ea831e3..5131947ff 100644 --- a/app/scodoc/sco_formsemestre.py +++ b/app/scodoc/sco_formsemestre.py @@ -53,6 +53,7 @@ _formsemestreEditor = ndb.EditableTable( "semestre_id", "formation_id", "titre", + "capacite_accueil", "date_debut", "date_fin", "gestion_compensation", diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index d5705b304..1058cf7af 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -345,8 +345,6 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N "labels": modalites_titles, }, ), - ] - modform.append( ( "semestre_id", { @@ -362,10 +360,21 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N "attributes": ['onchange="change_semestre_id();"'] if is_apc else "", }, ), - ) + ( + "capacite_accueil", + { + "title": "Capacité d'accueil", + "size": 4, + "explanation": "nombre max d'inscrits (hors démissionnaires). Laisser vide si pas de limite.", + "type": "int", + "allow_null": True, + }, + ), + ] etapes = sco_portal_apogee.get_etapes_apogee_dept() # Propose les etapes renvoyées par le portail - # et ajoute les étapes du semestre qui ne sont pas dans la liste (soit la liste a changé, soit l'étape a été ajoutée manuellement) + # et ajoute les étapes du semestre qui ne sont pas dans la liste + # (soit la liste a changé, soit l'étape a été ajoutée manuellement) etapes_set = {et[0] for et in etapes} if edit: for etape_vdi in formsemestre.etapes_apo_vdi(): @@ -503,6 +512,12 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id) }">Modifier les codes Apogée et emploi du temps des modules

+ +

Éditer la description externe du semestre +

+

Sélectionner les modules, leurs responsables et les étudiants à inscrire:

""" @@ -838,6 +853,14 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N ): msg = '
  • Code étape Apogée manquant
' + # check capacité accueil si indiquée + if edit and isinstance(tf[2]["capacite_accueil"], int): + new_capacite_accueil = tf[2]["capacite_accueil"] + inscriptions = formsemestre.get_inscrits(etats={scu.INSCRIT, scu.DEF}) + if len(inscriptions) > new_capacite_accueil: + msg = f"""
  • Capacité d'accueil insuffisante + (il y a {len(inscriptions)} inscrits non démissionaires)
""" + if tf[0] == 0 or msg: return f"""

Formation ") for s in others: H.append( - f"""

  • désinscrire de {s["titreannee"]} @@ -486,13 +437,13 @@ def formsemestre_inscription_with_modules( ) H.append("") H.append( - f"""

    Continuer quand même l'inscription

    """ - # was sco_groups.make_query_groups(group_ids) ) return render_template( "sco_page.j2", diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index fe58bc15e..a6a0d7f32 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -59,8 +59,6 @@ import app.scodoc.sco_utils as scu from app.scodoc.sco_utils import ModuleType from app.scodoc import codes_cursus -from app.scodoc import html_sco_header -from app.scodoc import htmlutils from app.scodoc import sco_archives_formsemestre from app.scodoc import sco_assiduites as scass from app.scodoc import sco_bulletins @@ -989,8 +987,8 @@ def formsemestre_status_head(formsemestre_id: int = None, page_title: str = None page_title = page_title or "Modules de " H = [ - f""" -
    Formation: + f""" + + """ ) + if formsemestre.capacite_accueil is not None: + H.append( + f""" + + + + """ + ) evals = sco_evaluations.do_evaluation_etat_in_sem(formsemestre) H.append( - ' +
    Formation : Parcours:
    Parcours : {', '.join(parcours.code for parcours in sem_parcours)}
    Capacité d'accueil : {formsemestre.capacite_accueil}
    Évaluations: %(nb_evals_completes)s ok, %(nb_evals_en_cours)s en cours, %(nb_evals_vides)s vides' + """
    Évaluations : %(nb_evals_completes)s ok, %(nb_evals_en_cours)s en cours, %(nb_evals_vides)s vides""" % evals ) if evals["last_modif"]: diff --git a/app/scodoc/sco_groups_view.py b/app/scodoc/sco_groups_view.py index d4817ee28..541589bb5 100644 --- a/app/scodoc/sco_groups_view.py +++ b/app/scodoc/sco_groups_view.py @@ -40,7 +40,7 @@ from flask import url_for, g, render_template, request from flask_login import current_user from app import db -from app.models import FormSemestre, Identite +from app.models import FormSemestre, Identite, ScolarEvent import app.scodoc.sco_utils as scu from app.scodoc import html_sco_header from app.scodoc import sco_assiduites as scass @@ -70,6 +70,7 @@ def groups_lists( group_ids=(), fmt="html", with_codes=0, + with_date_inscription=0, etat=None, with_paiement=0, with_archives=0, @@ -102,6 +103,7 @@ def groups_lists( groups_infos=groups_infos, fmt=fmt, with_codes=with_codes, + with_date_inscription=with_date_inscription, etat=etat, with_paiement=with_paiement, with_archives=with_archives, @@ -121,6 +123,7 @@ def groups_lists( groups_infos=groups_infos, fmt=fmt, with_codes=with_codes, + with_date_inscription=with_date_inscription, etat=etat, with_paiement=with_paiement, with_archives=with_archives, @@ -507,6 +510,7 @@ class DisplayedGroupsInfos: def groups_table( groups_infos: DisplayedGroupsInfos = None, with_codes=0, + with_date_inscription=0, etat=None, fmt="html", with_paiement=0, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail) @@ -522,15 +526,16 @@ def groups_table( can_view_etud_data = int(current_user.has_permission(Permission.ViewEtudData)) with_codes = int(with_codes) + with_date_inscription = int(with_date_inscription) with_paiement = int(with_paiement) and can_view_etud_data with_archives = int(with_archives) and can_view_etud_data with_annotations = int(with_annotations) and can_view_etud_data with_bourse = int(with_bourse) and can_view_etud_data - base_url_np = groups_infos.base_url + f"&with_codes={with_codes}" base_url = ( - base_url_np - + f"""&with_paiement={with_paiement}&with_archives={ + groups_infos.base_url + + f"""&with_codes={with_codes}&with_date_inscription={ + with_date_inscription}&with_paiement={with_paiement}&with_archives={ with_archives}&with_annotations={with_annotations }&with_bourse={with_bourse}""" ) @@ -546,6 +551,7 @@ def groups_table( "etudid": "etudid", "code_nip": "code_nip", "code_ine": "code_ine", + "date_inscription": "Date inscription", "datefinalisationinscription_str": "Finalisation inscr.", "paiementinscription_str": "Paiement", "etudarchive": "Fichiers", @@ -579,9 +585,11 @@ def groups_table( if with_codes: columns_ids += ["etape", "etudid", "code_nip", "code_ine"] + if with_date_inscription: + columns_ids += ["date_inscription"] if with_paiement: columns_ids += ["datefinalisationinscription_str", "paiementinscription_str"] - if with_paiement: # or with_codes: + if with_paiement: sco_portal_apogee.check_paiement_etuds(groups_infos.members) if with_archives: from app.scodoc import sco_archives_etud @@ -597,6 +605,16 @@ def groups_table( moodle_groupenames = set() # ajoute liens for etud_info in groups_infos.members: + if with_date_inscription: + event = ScolarEvent.query.filter_by( + etudid=etud_info["etudid"], + event_type="INSCRIPTION", + formsemestre_id=groups_infos.formsemestre_id, + ).first() + if event: + etud_info["date_inscription"] = event.event_date.strftime(scu.DATE_FMT) + etud_info["_date_inscription_xls"] = event.event_date + etud_info["_date_inscription_order"] = event.event_date.isoformat if etud_info["email"]: etud_info["_email_target"] = "mailto:" + etud_info["email"] else: @@ -612,8 +630,8 @@ def groups_table( etud_info["_nom_disp_order"] = etud_sort_key(etud_info) etud_info["_prenom_target"] = fiche_url - etud_info["_nom_disp_td_attrs"] = 'id="%s" class="etudinfo"' % ( - etud_info["etudid"] + etud_info["_nom_disp_td_attrs"] = ( + f"""id="{etud_info['etudid']}" class="etudinfo" """ ) etud_info["bourse_str"] = "oui" if etud_info["boursier"] else "non" if etud_info["etat"] == "D": @@ -720,6 +738,7 @@ def groups_table( if groups_infos.members: options = { "with_codes": "Affiche codes", + "with_date_inscription": "Date inscription", } if can_view_etud_data: options.update( @@ -824,6 +843,7 @@ def groups_table( groups_infos.members, partitions=groups_infos.partitions, with_codes=with_codes, + with_date_inscription=with_date_inscription, with_paiement=with_paiement, server_name=request.url_root, ) diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index b7083eea1..72c9d705c 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -369,10 +369,23 @@ class BasePreferences: "emails_notifications", { "initvalue": "", - "title": "e-mails à qui notifier les opérations", + "title": "e-mail(s) à qui notifier les opérations", "size": 70, - "explanation": """adresses séparées par des virgules; notifie les opérations - (saisies de notes, etc). + "explanation": """optionnel; adresses séparées par des virgules; + notifie les opérations (saisies de notes, etc). + """, + "category": "general", + "only_global": False, # peut être spécifique à un semestre + }, + ), + ( + "emails_notifications_inscriptions", + { + "initvalue": "", + "title": "e-mail(s) à qui notifier les inscriptions d'étudiants", + "size": 70, + "explanation": """optionnel; adresses séparées par des virgules; + notifie les inscriptions/désincriptions de chaque individu. """, "category": "general", "only_global": False, # peut être spécifique à un semestre @@ -2320,6 +2333,7 @@ class BasePreferences: + {descr["comment"]} """ descr["explanation"] = menu_global diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 3f5f923b1..0e5a93fdb 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -1843,6 +1843,15 @@ div.formsemestre_status { /* EMO_WARNING, "⚠️" */ } +table.formsemestre_status_head { + border-collapse: collapse; + +} + +table.formsemestre_status_head tr td:nth-child(2) { + padding-left: 1em; +} + table.formsemestre_status { border-collapse: collapse; } @@ -3310,6 +3319,12 @@ li.tf-msg { padding-bottom: 5px; } +.pref-comment { + font-style: italic; + font-size: small; + color: var(--sco-color-explication); +} + div.formsemestre-warning-box { background-color: yellow; border-radius: 4px; diff --git a/app/static/js/groups_view.js b/app/static/js/groups_view.js index 7f16a580d..43eb694be 100644 --- a/app/static/js/groups_view.js +++ b/app/static/js/groups_view.js @@ -48,13 +48,12 @@ function change_list_options(selected_options) { "with_archives", "with_annotations", "with_codes", + "with_date_inscription", "with_bourse", ]; for (var i = 0; i < options.length; i++) { - var option = options[i]; - if ($.inArray(option, selected_options) >= 0) { - urlParams.set(option, "1"); - } + let option = options[i]; + urlParams.set(option, selected_options.indexOf(option) >= 0 ? "1" : "0"); } window.location = url.href; } @@ -62,23 +61,32 @@ function change_list_options(selected_options) { // Menu choix groupe: function toggle_visible_etuds() { // - $(".etud_elem").hide(); + document.querySelectorAll('.etud_elem').forEach(element => { + element.style.display = 'none'; + }); var qargs = ""; - $("#group_ids_sel option:selected").each(function (index, opt) { + var selectedOptions = document.querySelectorAll("#group_ids_sel option:checked"); + var qargs = ""; + selectedOptions.forEach(function (opt) { var group_id = opt.value; - $(".group-" + group_id).show(); + var groupElements = document.querySelectorAll(".group-" + group_id); + groupElements.forEach(function (elem) { + elem.style.display = "block"; + }); qargs += "&group_ids=" + group_id; }); // Update url saisie tableur: - var input_eval = $("#formnotes_evaluation_id"); + let input_eval = document.querySelectorAll("#formnotes_evaluation_id"); if (input_eval.length > 0) { - var evaluation_id = input_eval[0].value; - $("#menu_saisie_tableur a").attr( + let evaluation_id = input_eval[0].value; + let menu_saisie_tableur_a = document.querySelector("#menu_saisie_tableur a"); + menu_saisie_tableur_a.setAttribute( "href", "saisie_notes_tableur?evaluation_id=" + evaluation_id + qargs ); // lien feuille excel: - $("#lnk_feuille_saisie").attr( + let lnk_feuille_saisie = document.querySelector("#lnk_feuille_saisie"); + lnk_feuille_saisie.setAttribute( "href", "feuille_saisie_notes?evaluation_id=" + evaluation_id + qargs ); diff --git a/app/templates/formsemestre/edit_description.j2 b/app/templates/formsemestre/edit_description.j2 new file mode 100644 index 000000000..aadfe8ee9 --- /dev/null +++ b/app/templates/formsemestre/edit_description.j2 @@ -0,0 +1,130 @@ +{% extends "sco_page.j2" %} +{% import 'wtf.j2' as wtf %} + +{% block styles %} +{{super()}} + +{% endblock %} + +{% macro render_string_field(field, size=64) %} +
    + {{ field.label }} : + {{ field(size=size)|safe }} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% if field.description %} +
    {{ field.description }}
    + {% endif %} +
    +{% endmacro %} + +{% macro render_textarea_field(field, cols=80, rows=12) %} +
    +
    {{ field.label }} :
    +
    {{ field(cols=cols, rows=rows)|safe }}
    + {% if field.description %} +
    {{ field.description }}
    + {% endif %} +
    +{% endmacro %} + +{% block app_content %} + +
    +

    Édition de la description du semestre

    + +
    +

    Les informations saisies ici ne sont pas utilisées par ScoDoc mais + mises à disposition des applications tierces comme AutoSco. +

    +

    Tous les champs sont optionnels.

    +
    + +
    + {{ form.hidden_tag() }} + + {{ form.wip.label }} {{form.wip() }} + {{ form.wip.description }} + {{ render_string_field(form.date_debut_inscriptions, size=10) }} + {{ render_string_field(form.date_fin_inscriptions, size=10) }} + + {{ render_textarea_field(form.description) }} + {{ render_string_field(form.responsable) }} + + {{ form.photo_ens.label }} +
    + {% if formsemestre_description.photo_ens %} + Current Image +
    + Changer l'image: {{ form.photo_ens() }} +
    + {% else %} + Aucune photo ou illustration chargée. + {{ form.photo_ens() }} + {% endif %} +
    + + {{ render_string_field(form.campus) }} + {{ render_string_field(form.salle, size=32) }} + {{ render_string_field(form.horaire) }} + +
    + {{ form.dispositif.label }} : + {{ form.dispositif }} + {% if form.dispositif.description %} +
    {{ form.dispositif.description }}
    + {% endif %} +
    + {{ render_string_field(form.public) }} + {{ render_textarea_field(form.modalites_mcc, rows=8) }} + {{ render_textarea_field(form.prerequis, rows=5) }} + + {{ form.image.label }} +
    + {% if formsemestre_description.image %} + Current Image +
    + Changer l'image: {{ form.image() }} +
    + {% else %} + Aucune image n'est actuellement associée à ce semestre. + {{ form.image() }} + {% endif %} +
    + +
    + {{ form.submit }} {{ form.cancel }} +
    +
    + + +
    +{% endblock %} diff --git a/app/views/notes_formsemestre.py b/app/views/notes_formsemestre.py index 4868d2bad..e3e25c902 100644 --- a/app/views/notes_formsemestre.py +++ b/app/views/notes_formsemestre.py @@ -25,20 +25,34 @@ ############################################################################## """ -Vues "modernes" des formsemestre +Vues "modernes" des formsemestres Emmanuel Viennet, 2023 """ +import datetime +import io + from flask import flash, redirect, render_template, url_for -from flask import g, request +from flask import current_app, g, request +import PIL from app import db, log from app.decorators import ( scodoc, permission_required, ) -from app.forms.formsemestre import change_formation, edit_modimpls_codes_apo -from app.models import Formation, FormSemestre, ScoDocSiteConfig +from app.forms.formsemestre import ( + change_formation, + edit_modimpls_codes_apo, + edit_description, +) +from app.models import ( + Formation, + FormSemestre, + FormSemestreDescription, + FORMSEMESTRE_DISPOSITIFS, + ScoDocSiteConfig, +) from app.scodoc import ( sco_edt_cal, sco_formations, @@ -223,3 +237,121 @@ def formsemestre_edt_help_config(formsemestre_id: int): ScoDocSiteConfig=ScoDocSiteConfig, title="Aide configuration EDT", ) + + +@bp.route( + "/formsemestre_description//edit", methods=["GET", "POST"] +) +@scodoc +@permission_required(Permission.EditFormSemestre) +def edit_formsemestre_description(formsemestre_id: int): + "Edition de la description d'un formsemestre" + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + if not formsemestre.description: + formsemestre.description = FormSemestreDescription() + db.session.add(formsemestre) + db.session.commit() + formsemestre_description = formsemestre.description + form = edit_description.FormSemestreDescriptionForm(obj=formsemestre_description) + ok = True + if form.validate_on_submit(): + if form.cancel.data: # cancel button + return redirect( + url_for( + "notes.formsemestre_editwithmodules", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre.id, + ) + ) + # Vérification valeur dispositif + if form.dispositif.data not in FORMSEMESTRE_DISPOSITIFS: + flash("Dispositif inconnu", "danger") + ok = False + + # Vérification dates inscriptions + if form.date_debut_inscriptions.data: + try: + date_debut_inscriptions_dt = datetime.datetime.strptime( + form.date_debut_inscriptions.data, scu.DATE_FMT + ) + except ValueError: + flash("Date de début des inscriptions invalide", "danger") + form.set_error("date début invalide", form.date_debut_inscriptions) + ok = False + else: + date_debut_inscriptions_dt = None + if form.date_fin_inscriptions.data: + try: + date_fin_inscriptions_dt = datetime.datetime.strptime( + form.date_fin_inscriptions.data, scu.DATE_FMT + ) + except ValueError: + flash("Date de fin des inscriptions invalide", "danger") + form.set_error("date fin invalide", form.date_fin_inscriptions) + ok = False + else: + date_fin_inscriptions_dt = None + if ok: + # dates converties + form.date_debut_inscriptions.data = date_debut_inscriptions_dt + form.date_fin_inscriptions.data = date_fin_inscriptions_dt + # Affecte tous les champs sauf les images: + form_image = form.image + del form.image + form_photo_ens = form.photo_ens + del form.photo_ens + form.populate_obj(formsemestre_description) + # Affecte les images: + for field, form_field in ( + ("image", form_image), + ("photo_ens", form_photo_ens), + ): + if form_field.data: + image_data = form_field.data.read() + max_length = current_app.config.get("MAX_CONTENT_LENGTH") + if max_length and len(image_data) > max_length: + flash( + f"Image trop grande ({field}), max {max_length} octets", + "danger", + ) + return redirect( + url_for( + "notes.edit_formsemestre_description", + formsemestre_id=formsemestre.id, + scodoc_dept=g.scodoc_dept, + ) + ) + try: + _ = PIL.Image.open(io.BytesIO(image_data)) + except PIL.UnidentifiedImageError: + flash( + f"Image invalide ({field}), doit être une image", + "danger", + ) + return redirect( + url_for( + "notes.edit_formsemestre_description", + formsemestre_id=formsemestre.id, + scodoc_dept=g.scodoc_dept, + ) + ) + setattr(formsemestre_description, field, image_data) + + db.session.commit() + flash("Description enregistrée", "success") + return redirect( + url_for( + "notes.formsemestre_status", + formsemestre_id=formsemestre.id, + scodoc_dept=g.scodoc_dept, + ) + ) + + return render_template( + "formsemestre/edit_description.j2", + form=form, + formsemestre=formsemestre, + formsemestre_description=formsemestre_description, + sco=ScoData(formsemestre=formsemestre), + title="Modif. description semestre", + ) diff --git a/app/views/scolar.py b/app/views/scolar.py index e6c840cdf..455388aaa 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -471,18 +471,24 @@ def groups_lists( fmt="html", # Options pour listes: with_codes=0, + with_date_inscription=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_paiement=0, + with_archives=0, with_annotations=0, with_bourse=0, formsemestre_id=None, ): - "Listes des étudiants des groupes" + """Listes des étudiants des groupes. + Si with_paiement, ajoute colonnes infos paiement droits et finalisation + inscription (lent car interrogation portail). + Si with_archives, ajoute colonne avec noms fichiers archivés. + """ return sco_groups_view.groups_lists( group_ids=group_ids, fmt=fmt, with_codes=with_codes, + with_date_inscription=with_date_inscription, etat=etat, with_paiement=with_paiement, with_archives=with_archives, diff --git a/migrations/versions/2640b7686de6_formsemestre_description.py b/migrations/versions/2640b7686de6_formsemestre_description.py new file mode 100644 index 000000000..c25330934 --- /dev/null +++ b/migrations/versions/2640b7686de6_formsemestre_description.py @@ -0,0 +1,51 @@ +"""FormSemestreDescription et capacité d'accueil + +Revision ID: 2640b7686de6 +Revises: f6cb3d4e44ec +Create Date: 2024-08-11 15:44:32.560054 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "2640b7686de6" +down_revision = "f6cb3d4e44ec" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "notes_formsemestre_description", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("description", sa.Text(), server_default="", nullable=False), + sa.Column("horaire", sa.Text(), server_default="", nullable=False), + sa.Column("date_debut_inscriptions", sa.DateTime(timezone=True), nullable=True), + sa.Column("date_fin_inscriptions", sa.DateTime(timezone=True), nullable=True), + sa.Column("wip", sa.Boolean(), server_default="false", nullable=False), + sa.Column("image", sa.LargeBinary(), nullable=True), + sa.Column("campus", sa.Text(), server_default="", nullable=False), + sa.Column("salle", sa.Text(), server_default="", nullable=False), + sa.Column("dispositif", sa.Integer(), server_default="0", nullable=False), + sa.Column("modalites_mcc", sa.Text(), server_default="", nullable=False), + sa.Column("photo_ens", sa.LargeBinary(), nullable=True), + sa.Column("public", sa.Text(), server_default="", nullable=False), + sa.Column("prerequis", sa.Text(), server_default="", nullable=False), + sa.Column("responsable", sa.Text(), server_default="", nullable=False), + sa.Column("formsemestre_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["formsemestre_id"], ["notes_formsemestre.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("notes_formsemestre", schema=None) as batch_op: + batch_op.add_column(sa.Column("capacite_accueil", sa.Integer(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table("notes_formsemestre", schema=None) as batch_op: + batch_op.drop_column("capacite_accueil") + op.drop_table("notes_formsemestre_description") diff --git a/tests/api/setup_test_api.py b/tests/api/setup_test_api.py index 2ce97eb88..126162e29 100644 --- a/tests/api/setup_test_api.py +++ b/tests/api/setup_test_api.py @@ -122,9 +122,14 @@ def GET(path: str, headers: dict = None, errmsg=None, dept=None, raw=False): if reply.headers.get("Content-Type", None) == "application/json": return reply.json() # decode la reponse JSON if reply.headers.get("Content-Type", None) in [ - "image/jpg", - "image/png", "application/pdf", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", ]: retval = { "Content-Type": reply.headers.get("Content-Type", None), @@ -132,7 +137,7 @@ def GET(path: str, headers: dict = None, errmsg=None, dept=None, raw=False): } return retval raise APIError( - "Unknown returned content {r.headers.get('Content-Type', None} !\n", + f"Unknown returned content {reply.headers.get('Content-Type', None)} !\n", status_code=reply.status_code, ) diff --git a/tests/api/test_api_formsemestre.py b/tests/api/test_api_formsemestre.py index 23d55ccca..1395a6a33 100644 --- a/tests/api/test_api_formsemestre.py +++ b/tests/api/test_api_formsemestre.py @@ -16,6 +16,7 @@ Utilisation : Lancer : pytest tests/api/test_api_formsemestre.py """ +import base64 import json import requests from types import NoneType @@ -28,8 +29,10 @@ from tests.api.setup_test_api import ( API_URL, CHECK_CERTIFICATE, GET, + POST, api_headers, api_admin_headers, + set_headers, ) from tests.api.tools_test_api import ( @@ -780,3 +783,64 @@ def _compare_formsemestre_resultat(res: list[dict], ref: list[dict]): if "nbabs" in k: continue assert res_d[k] == ref_d[k], f"values for key {k} differ." + + +def test_formsemestre_description(api_admin_headers): + """ + Test accès et modification de la description + """ + set_headers(api_admin_headers) + formsemestre_id = 1 + r = GET(f"/formsemestre/{formsemestre_id}") + assert "description" not in r + r = POST( + f"/formsemestre/{formsemestre_id}/description/edit", + data={ + "description": "une description", + "horaire": "un horaire", + "salle": "une salle", + "dispositif": 1, + "wip": True, + }, + ) + assert r["description"] == "une description" + assert r["horaire"] == "un horaire" + assert r["salle"] == "une salle" + assert r["dispositif"] == 1 + assert r["wip"] is True + r = GET(f"/formsemestre/{formsemestre_id}/description") + assert r["description"] == "une description" + assert r["horaire"] == "un horaire" + assert r["salle"] == "une salle" + assert r["dispositif"] == 1 + assert r["wip"] is True + # La réponse ne contient pas les images, servies à part: + assert "image" not in r + assert "photo_ens" not in r + r = POST( + f"/formsemestre/{formsemestre_id}/description/edit", + data={ + "description": "", + "horaire": "", + "salle": "", + "dispositif": 0, + "wip": False, + }, + ) + assert r["description"] == "" + assert r["horaire"] == "" + assert r["salle"] == "" + assert r["dispositif"] == 0 + assert r["wip"] is False + # Upload image + with open("tests/ressources/images/papillon.jpg", "rb") as f: + img = f.read() + img_base64 = base64.b64encode(img).decode("utf-8") + r = POST( + f"/formsemestre/{formsemestre_id}/description/edit", data={"image": img_base64} + ) + assert r["wip"] is False + r = GET(f"/formsemestre/{formsemestre_id}/description/image", raw=True) + assert r.status_code == 200 + assert r.headers.get("Content-Type") == "image/jpeg" + assert r.content == img diff --git a/tests/api/test_api_permissions.py b/tests/api/test_api_permissions.py index ae3dad923..f94ce12e8 100755 --- a/tests/api/test_api_permissions.py +++ b/tests/api/test_api_permissions.py @@ -100,7 +100,7 @@ def test_permissions(api_headers): verify=CHECK_CERTIFICATE, timeout=scu.SCO_TEST_API_TIMEOUT, ) - assert r.status_code == 200 + assert r.status_code // 100 == 2 # 2xx success # Même chose sans le jeton: for rule in api_rules: diff --git a/tests/unit/test_formsemestre.py b/tests/unit/test_formsemestre.py index 928aae833..783c12320 100644 --- a/tests/unit/test_formsemestre.py +++ b/tests/unit/test_formsemestre.py @@ -3,12 +3,12 @@ """ Test création/accès/clonage formsemestre """ -from flask import Response +from flask import g, Response import pytest -from tests.unit import yaml_setup, call_view import app -from app.models import Formation, FormSemestre +from app import db +from app.models import Formation, FormSemestre, FormSemestreDescription from app.scodoc import ( sco_archives_formsemestre, sco_cost_formation, @@ -35,6 +35,7 @@ from app.scodoc import ( from app.scodoc import sco_utils as scu from app.views import notes, scolar from config import TestConfig +from tests.unit import yaml_setup, call_view DEPT = TestConfig.DEPT_TEST @@ -205,3 +206,39 @@ def test_formsemestre_misc_views(test_client): ans = sco_debouche.report_debouche_date(start_year=2000) ans = sco_cost_formation.formsemestre_estim_cost(formsemestre.id) # pas de test des indicateurs de suivi BUT + + +def test_formsemestre_description(test_client): + """Test FormSemestreDescription""" + app.set_sco_dept(DEPT) + # + nb_descriptions = FormSemestreDescription.query.count() + # Création d'un semestre + + formsemestre = FormSemestre( + dept_id=g.scodoc_dept_id, + titre="test description", + date_debut="2024-08-01", + date_fin="2024-08-31", + ) + db.session.add(formsemestre) + db.session.commit() + assert formsemestre.description is None + # Association d'une description + formsemestre.description = FormSemestreDescription.create_from_dict( + { + "description": "Description", + "responsable": "Responsable", + "campus": "Sorbonne", + "salle": "L214", + "horaire": "23h à l'aube", + } + ) + db.session.add(formsemestre) + db.session.commit() + assert formsemestre.description.formsemestre.id == formsemestre.id + assert FormSemestreDescription.query.count() == nb_descriptions + 1 + # Suppression / cascade + db.session.delete(formsemestre) + db.session.commit() + assert FormSemestreDescription.query.count() == nb_descriptions