From 525d0446cc0a6a27f69bf0103940bfc31b15c338 Mon Sep 17 00:00:00 2001 From: ilona Date: Sun, 11 Aug 2024 21:39:43 +0200 Subject: [PATCH 1/8] =?UTF-8?q?FormSemestreDescription:=20informations=20p?= =?UTF-8?q?our=20applications=20tierces:=20Mod=C3=A8le,=20API,=20=C3=A9dit?= =?UTF-8?q?eur.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/formsemestres.py | 47 ++++++++++ app/forms/formsemestre/edit_description.py | 35 ++++++++ .../formsemestre/edit_modimpls_codes_apo.py | 3 +- app/models/__init__.py | 3 +- app/models/formsemestre.py | 6 ++ app/models/formsemestre_descr.py | 71 +++++++++++++++ app/scodoc/sco_formsemestre_edit.py | 13 ++- .../formsemestre/edit_description.j2 | 88 +++++++++++++++++++ app/views/notes_formsemestre.py | 81 ++++++++++++++++- .../2640b7686de6_formsemestre_description.py | 38 ++++++++ tests/unit/test_formsemestre.py | 40 ++++++++- 11 files changed, 415 insertions(+), 10 deletions(-) create mode 100644 app/forms/formsemestre/edit_description.py create mode 100644 app/models/formsemestre_descr.py create mode 100644 app/templates/formsemestre/edit_description.j2 create mode 100644 migrations/versions/2640b7686de6_formsemestre_description.py diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index 7834c4cb..1cfc8525 100644 --- a/app/api/formsemestres.py +++ b/app/api/formsemestres.py @@ -13,6 +13,7 @@ FormSemestre """ +import mimetypes from operator import attrgetter, itemgetter from flask import g, make_response, request @@ -790,3 +791,49 @@ 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.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 + + # Guess the mimetype based on the image data + image_data = formsemestre.description.image + mimetype = mimetypes.guess_type("image")[0] + + if not mimetype: + # 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/formsemestre/edit_description.py b/app/forms/formsemestre/edit_description.py new file mode 100644 index 00000000..2bf64c04 --- /dev/null +++ b/app/forms/formsemestre/edit_description.py @@ -0,0 +1,35 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Formulaire édition description formsemestre +""" + +from flask_wtf import FlaskForm +from wtforms import StringField, TextAreaField, FileField, SubmitField +from wtforms.validators import Optional + + +class FormSemestreDescriptionForm(FlaskForm): + "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" + ) + image = FileField( + "Image", validators=[Optional()], description="Image illustrant cette formation" + ) + lieu = StringField("Lieu", validators=[Optional()], description="ex: salle 123") + responsable = StringField( + "Responsable", validators=[Optional()], description="ex: nom de l'enseignant" + ) + + 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 cccf1978..d0cd8a37 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 0cf5498d..df83dfac 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,7 @@ from app.models.formsemestre import ( NotesSemSet, notes_semset_formsemestre, ) +from app.models.formsemestre_descr import FormSemestreDescription from app.models.moduleimpls import ( ModuleImpl, notes_modules_enseignants, diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 6be8fc61..09b08b2f 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -143,6 +143,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", diff --git a/app/models/formsemestre_descr.py b/app/models/formsemestre_descr.py new file mode 100644 index 00000000..8c73127a --- /dev/null +++ b/app/models/formsemestre_descr.py @@ -0,0 +1,71 @@ +############################################################################## +# 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) + # Storing image data directly in the database: + image = db.Column(db.LargeBinary(), nullable=True) + description = db.Column( + db.Text(), nullable=False, default="", server_default="" + ) # HTML allowed + responsable = db.Column(db.Text(), nullable=False, default="", server_default="") + lieu = db.Column(db.Text(), nullable=False, default="", server_default="") + horaire = db.Column(db.Text(), nullable=False, default="", server_default="") + + 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 __init__( + self, + image=None, + description="", + responsable="", + lieu="", + horaire="", + ): + self.description = description + self.horaire = horaire + self.image = image + self.lieu = lieu + self.responsable = responsable + + 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): + return { + "formsemestre_id": self.formsemestre_id, + "description": self.description, + "responsable": self.responsable, + "lieu": self.lieu, + "horaire": self.horaire, + } diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index dc066f21..c4cb7b84 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -508,6 +508,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:

""" @@ -1287,6 +1293,7 @@ def do_formsemestre_clone( clone_partitions=False, ): """Clone a semestre: make copy, same modules, same options, same resps, same partitions. + Clone description. New dates, responsable_id """ log(f"do_formsemestre_clone: {orig_formsemestre_id}") @@ -1375,10 +1382,14 @@ def do_formsemestre_clone( # 5- Copie les parcours formsemestre.parcours = formsemestre_orig.parcours + + # 6- Copy description + formsemestre.description = formsemestre_orig.description.clone() + db.session.add(formsemestre) db.session.commit() - # 6- Copy partitions and groups + # 7- Copy partitions and groups if clone_partitions: sco_groups_copy.clone_partitions_and_groups( orig_formsemestre_id, formsemestre.id diff --git a/app/templates/formsemestre/edit_description.j2 b/app/templates/formsemestre/edit_description.j2 new file mode 100644 index 00000000..637a0966 --- /dev/null +++ b/app/templates/formsemestre/edit_description.j2 @@ -0,0 +1,88 @@ +{% extends "sco_page.j2" %} +{% import 'wtf.j2' as wtf %} + +{% block styles %} +{{super()}} + +{% endblock %} + +{% 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. +

+

La description du semestre est un

+

Il est possible d'ajouter une image pour illustrer le semestre.

+

Le responsable est généralement l'enseignant en charge de la formation, ou + la personne qui s'occupant de l'organisation du semestre.

+

Tous les champs sont optionnels.

+
+ +
+ {{ form.hidden_tag() }} +
+ {{ form.description.label }}
+ {{ form.description(cols=80, rows=8) }} +
{{ form.description.description }}
+
+
+ {{ form.responsable.label }}
+ {{ form.responsable(size=64) }}
+
{{ form.responsable.description }}
+
+
+ {{ form.lieu.label }}
+ {{ form.lieu(size=48) }} +
{{ form.lieu.description }}
+
+
+ {{ form.horaire.label }}
+ {{ form.horaire(size=64) }}
+
{{ form.horaire.description }}
+
+ + {{ 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 4868d2ba..972d25bc 100644 --- a/app/views/notes_formsemestre.py +++ b/app/views/notes_formsemestre.py @@ -25,20 +25,29 @@ ############################################################################## """ -Vues "modernes" des formsemestre +Vues "modernes" des formsemestres Emmanuel Viennet, 2023 """ from flask import flash, redirect, render_template, url_for -from flask import g, request +from flask import current_app, g, request 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, + ScoDocSiteConfig, +) from app.scodoc import ( sco_edt_cal, sco_formations, @@ -223,3 +232,67 @@ 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) + + 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, + ) + ) + form_image = form.image + del form.image + form.populate_obj(formsemestre_description) + + if form_image.data: + image_data = form_image.data.read() + max_length = current_app.config.get("MAX_CONTENT_LENGTH") + if max_length and len(image_data) > max_length: + flash( + f"Image too large, max {max_length} bytes", + "danger", + ) + return redirect( + url_for( + "notes.edit_formsemestre_description", + formsemestre_id=formsemestre.id, + scodoc_dept=g.scodoc_dept, + ) + ) + formsemestre_description.image = 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), + ) diff --git a/migrations/versions/2640b7686de6_formsemestre_description.py b/migrations/versions/2640b7686de6_formsemestre_description.py new file mode 100644 index 00000000..88ec4388 --- /dev/null +++ b/migrations/versions/2640b7686de6_formsemestre_description.py @@ -0,0 +1,38 @@ +"""FormSemestreDescription + +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("image", sa.LargeBinary(), nullable=True), + sa.Column("description", sa.Text(), server_default="", nullable=False), + sa.Column("responsable", sa.Text(), server_default="", nullable=False), + sa.Column("lieu", sa.Text(), server_default="", nullable=False), + sa.Column("horaire", 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"), + ) + + +def downgrade(): + op.drop_table("notes_formsemestre_description") diff --git a/tests/unit/test_formsemestre.py b/tests/unit/test_formsemestre.py index e9677c4f..75ed6793 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 @@ -203,3 +204,36 @@ 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( + description="Description 2", + responsable="Responsable 2", + lieu="Lieu 2", + horaire="Horaire 2", + ) + 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 From 3d34f9330d980054a3f2b8f9ca6879cb47e85cb7 Mon Sep 17 00:00:00 2001 From: ilona Date: Sun, 11 Aug 2024 23:24:40 +0200 Subject: [PATCH 2/8] =?UTF-8?q?Capacit=C3=A9=20d'accueil=20des=20formsemes?= =?UTF-8?q?tre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/formsemestre.py | 65 +++++++++++++++++-- app/scodoc/sco_formsemestre.py | 1 + app/scodoc/sco_formsemestre_edit.py | 17 +++-- app/scodoc/sco_formsemestre_inscriptions.py | 47 ++------------ app/scodoc/sco_formsemestre_status.py | 17 +++-- app/static/css/scodoc.css | 9 +++ .../2640b7686de6_formsemestre_description.py | 6 +- 7 files changed, 106 insertions(+), 56 deletions(-) diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 09b08b2f..185e18a4 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -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) @@ -1019,20 +1022,74 @@ 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: + inscriptions = self.get_inscrits(etats={scu.INSCRIT, scu.DEMISSION}) + if len(inscriptions) >= self.capacite_accueil: + raise ScoValueError( + f"Semestre {self.titre} complet: {len(self.inscriptions)} inscrits" + ) + + 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, + ) + sco_cache.invalidate_formsemestre(formsemestre_id=self.id) + return inscr + def get_partitions_list( self, with_default=True, only_listed=False ) -> list[Partition]: diff --git a/app/scodoc/sco_formsemestre.py b/app/scodoc/sco_formsemestre.py index 00ea831e..5131947f 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 c4cb7b84..87cfadd0 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -350,8 +350,6 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N "labels": modalites_titles, }, ), - ] - modform.append( ( "semestre_id", { @@ -367,10 +365,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": "laisser vide si pas de limite au nombre d'inscrits non démissionnaires", + "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(): diff --git a/app/scodoc/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py index ec4bb930..e7f0ef9c 100644 --- a/app/scodoc/sco_formsemestre_inscriptions.py +++ b/app/scodoc/sco_formsemestre_inscriptions.py @@ -85,43 +85,6 @@ def do_formsemestre_inscription_listinscrits(formsemestre_id): return r -def do_formsemestre_inscription_create(args, method=None): - "create a formsemestre_inscription (and sco event)" - cnx = ndb.GetDBConnexion() - log(f"do_formsemestre_inscription_create: args={args}") - sems = sco_formsemestre.do_formsemestre_list( - {"formsemestre_id": args["formsemestre_id"]} - ) - if len(sems) != 1: - raise ScoValueError(f"code de semestre invalide: {args['formsemestre_id']}") - sem = sems[0] - # check lock - if not sem["etat"]: - raise ScoValueError("inscription: semestre verrouille") - # - r = _formsemestre_inscriptionEditor.create(cnx, args) - # Evenement - sco_etud.scolar_events_create( - cnx, - args={ - "etudid": args["etudid"], - "event_date": time.strftime(scu.DATE_FMT), - "formsemestre_id": args["formsemestre_id"], - "event_type": "INSCRIPTION", - }, - ) - # Log etudiant - Scolog.logdb( - method=method, - etudid=args["etudid"], - msg=f"inscription en semestre {args['formsemestre_id']}", - commit=True, - ) - # - sco_cache.invalidate_formsemestre(formsemestre_id=args["formsemestre_id"]) - return r - - def do_formsemestre_inscription_delete(oid, formsemestre_id=None): "delete formsemestre_inscription" cnx = ndb.GetDBConnexion() @@ -283,20 +246,18 @@ def do_formsemestre_inscription_with_modules( """Inscrit cet etudiant à ce semestre et TOUS ses modules STANDARDS (donc sauf le sport) Si dept_id est spécifié, utilise ce département au lieu du courant. + Vérifie la capacité d'accueil. """ + etud = Identite.get_etud(etudid) group_ids = group_ids or [] if isinstance(group_ids, int): group_ids = [group_ids] # Check that all groups exist before creating the inscription groups = [GroupDescr.query.get_or_404(group_id) for group_id in group_ids] formsemestre = FormSemestre.get_formsemestre(formsemestre_id, dept_id=dept_id) - # inscription au semestre + # Inscription au semestre args = {"formsemestre_id": formsemestre_id, "etudid": etudid} - if etat is not None: - args["etat"] = etat - if etape is not None: - args["etape"] = etape - do_formsemestre_inscription_create(args, method=method) + formsemestre.inscrit_etudiant(etud, etat=etat, etape=etape, method=method) log( f"""do_formsemestre_inscription_with_modules: etudid={ etudid} formsemestre_id={formsemestre_id}""" diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index db7143e1..1f45cd62 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -978,8 +978,8 @@ def formsemestre_status_head(formsemestre_id: int = None, page_title: str = None html_sco_header.html_sem_header( page_title, with_page_header=False, with_h2=False ), - 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/static/css/scodoc.css b/app/static/css/scodoc.css index b844a9d4..783ac666 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -1844,6 +1844,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; } diff --git a/migrations/versions/2640b7686de6_formsemestre_description.py b/migrations/versions/2640b7686de6_formsemestre_description.py index 88ec4388..c275d3e2 100644 --- a/migrations/versions/2640b7686de6_formsemestre_description.py +++ b/migrations/versions/2640b7686de6_formsemestre_description.py @@ -1,4 +1,4 @@ -"""FormSemestreDescription +"""FormSemestreDescription et capacité d'accueil Revision ID: 2640b7686de6 Revises: f6cb3d4e44ec @@ -32,7 +32,11 @@ def upgrade(): ), 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") From 3971145abda46cda83723d24cea6b97bc07eac67 Mon Sep 17 00:00:00 2001 From: ilona Date: Mon, 12 Aug 2024 14:40:35 +0200 Subject: [PATCH 3/8] =?UTF-8?q?Capacit=C3=A9=20d'accueil:=20v=C3=A9rif.=20?= =?UTF-8?q?=C3=A9dition=20semestre,=20messages.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/formsemestre.py | 10 ++++++++-- app/scodoc/sco_formsemestre_edit.py | 10 +++++++++- app/scodoc/sco_formsemestre_inscriptions.py | 12 ++++++------ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 185e18a4..34d34aad 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -1063,10 +1063,16 @@ class FormSemestre(models.ScoDocModel): return inscr if self.capacite_accueil is not None: - inscriptions = self.get_inscrits(etats={scu.INSCRIT, scu.DEMISSION}) + # 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" + 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( diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index 87cfadd0..f2b33b30 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -370,7 +370,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N { "title": "Capacité d'accueil", "size": 4, - "explanation": "laisser vide si pas de limite au nombre d'inscrits non démissionnaires", + "explanation": "nombre max d'inscrits (hors démissionnaires). Laisser vide si pas de limite.", "type": "int", "allow_null": True, }, @@ -858,6 +858,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"]} @@ -447,13 +447,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 "\n".join(H) + footer # From 513fb3d46d343f80a26cf8416af8b5e10db19597 Mon Sep 17 00:00:00 2001 From: ilona Date: Tue, 13 Aug 2024 16:47:55 +0200 Subject: [PATCH 4/8] =?UTF-8?q?FormSemestreDescription:=20champs=20pour=20?= =?UTF-8?q?sp=C3=A9cifs=20EL.=20Formulaire=20saisie.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/formsemestres.py | 19 +++- app/forms/__init__.py | 24 ++++- app/forms/formsemestre/edit_description.py | 89 +++++++++++++++-- app/models/__init__.py | 5 +- app/models/formsemestre_descr.py | 69 ++++++++------ .../formsemestre/edit_description.j2 | 88 ++++++++++++----- app/views/notes_formsemestre.py | 95 ++++++++++++++----- .../2640b7686de6_formsemestre_description.py | 15 ++- tests/unit/test_formsemestre.py | 13 ++- 9 files changed, 322 insertions(+), 95 deletions(-) diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index 1cfc8525..e46258f2 100644 --- a/app/api/formsemestres.py +++ b/app/api/formsemestres.py @@ -826,8 +826,25 @@ def formsemestre_get_description_image(formsemestre_id: int): 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): # Guess the mimetype based on the image data - image_data = formsemestre.description.image mimetype = mimetypes.guess_type("image")[0] if not mimetype: diff --git a/app/forms/__init__.py b/app/forms/__init__.py index f850a58c..3b8722da 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 index 2bf64c04..e0b49b2a 100644 --- a/app/forms/formsemestre/edit_description.py +++ b/app/forms/formsemestre/edit_description.py @@ -6,13 +6,42 @@ """Formulaire édition description formsemestre """ +from wtforms import ( + BooleanField, + FileField, + SelectField, + StringField, + TextAreaField, + SubmitField, +) +from wtforms.validators import AnyOf, Optional -from flask_wtf import FlaskForm -from wtforms import StringField, TextAreaField, FileField, SubmitField -from wtforms.validators import Optional +from app.forms import ScoDocForm +from app.models import FORMSEMESTRE_DISPOSITIFS +from app.scodoc import sco_utils as scu -class FormSemestreDescriptionForm(FlaskForm): +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", @@ -23,12 +52,60 @@ class FormSemestreDescriptionForm(FlaskForm): 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" ) - lieu = StringField("Lieu", validators=[Optional()], description="ex: salle 123") + 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="ex: nom de l'enseignant" + "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") diff --git a/app/models/__init__.py b/app/models/__init__.py index df83dfac..82e8416f 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -188,7 +188,10 @@ from app.models.formsemestre import ( NotesSemSet, notes_semset_formsemestre, ) -from app.models.formsemestre_descr import FormSemestreDescription +from app.models.formsemestre_descr import ( + FormSemestreDescription, + FORMSEMESTRE_DISPOSITIFS, +) from app.models.moduleimpls import ( ModuleImpl, notes_modules_enseignants, diff --git a/app/models/formsemestre_descr.py b/app/models/formsemestre_descr.py index 8c73127a..1e754c52 100644 --- a/app/models/formsemestre_descr.py +++ b/app/models/formsemestre_descr.py @@ -22,15 +22,33 @@ class FormSemestreDescription(models.ScoDocModel): __tablename__ = "notes_formsemestre_description" id = db.Column(db.Integer, primary_key=True) - # Storing image data directly in the database: - image = db.Column(db.LargeBinary(), nullable=True) - description = db.Column( - db.Text(), nullable=False, default="", server_default="" - ) # HTML allowed - responsable = db.Column(db.Text(), nullable=False, default="", server_default="") - lieu = db.Column(db.Text(), nullable=False, default="", server_default="") + 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"), @@ -40,20 +58,6 @@ class FormSemestreDescription(models.ScoDocModel): "FormSemestre", back_populates="description", uselist=False ) - def __init__( - self, - image=None, - description="", - responsable="", - lieu="", - horaire="", - ): - self.description = description - self.horaire = horaire - self.image = image - self.lieu = lieu - self.responsable = responsable - def __repr__(self): return f"" @@ -61,11 +65,18 @@ class FormSemestreDescription(models.ScoDocModel): """clone instance""" return super().clone(not_copying=not_copying + ("formsemestre_id",)) - def to_dict(self): - return { - "formsemestre_id": self.formsemestre_id, - "description": self.description, - "responsable": self.responsable, - "lieu": self.lieu, - "horaire": self.horaire, - } + 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/templates/formsemestre/edit_description.j2 b/app/templates/formsemestre/edit_description.j2 index 637a0966..aadfe8ee 100644 --- a/app/templates/formsemestre/edit_description.j2 +++ b/app/templates/formsemestre/edit_description.j2 @@ -8,6 +8,9 @@ font-style: italic; color: green; } +div.field_descr { + margin-bottom: 16px; +} .submit { margin-top: 32px; display: flex; @@ -16,6 +19,7 @@ } div.image { margin-left: 32px; + margin-bottom: 16px; } div.image img { border: 1px dashed #b60c0c; @@ -23,6 +27,29 @@ div.image img { {% 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 %}
    @@ -32,35 +59,50 @@ div.image img {

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

    -

    La description du semestre est un

    -

    Il est possible d'ajouter une image pour illustrer le semestre.

    -

    Le responsable est généralement l'enseignant en charge de la formation, ou - la personne qui s'occupant de l'organisation du semestre.

    Tous les champs sont optionnels.

    {{ form.hidden_tag() }} -
    - {{ form.description.label }}
    - {{ form.description(cols=80, rows=8) }} -
    {{ form.description.description }}
    + + {{ 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.responsable.label }}
    - {{ form.responsable(size=64) }}
    -
    {{ form.responsable.description }}
    -
    -
    - {{ form.lieu.label }}
    - {{ form.lieu(size=48) }} -
    {{ form.lieu.description }}
    -
    -
    - {{ form.horaire.label }}
    - {{ form.horaire(size=64) }}
    -
    {{ form.horaire.description }}
    + {{ 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 }}
    @@ -68,7 +110,7 @@ div.image img { Current Image + alt="Current Image" style="max-width: 400px;">
    Changer l'image: {{ form.image() }}
    @@ -76,8 +118,8 @@ div.image img { Aucune image n'est actuellement associée à ce semestre. {{ form.image() }} {% endif %} -
    +
    {{ form.submit }} {{ form.cancel }}
    diff --git a/app/views/notes_formsemestre.py b/app/views/notes_formsemestre.py index 972d25bc..1bafa62d 100644 --- a/app/views/notes_formsemestre.py +++ b/app/views/notes_formsemestre.py @@ -29,6 +29,8 @@ Vues "modernes" des formsemestres Emmanuel Viennet, 2023 """ +import datetime + from flask import flash, redirect, render_template, url_for from flask import current_app, g, request @@ -46,6 +48,7 @@ from app.models import ( Formation, FormSemestre, FormSemestreDescription, + FORMSEMESTRE_DISPOSITIFS, ScoDocSiteConfig, ) from app.scodoc import ( @@ -248,7 +251,7 @@ def edit_formsemestre_description(formsemestre_id: int): 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( @@ -258,36 +261,75 @@ def edit_formsemestre_description(formsemestre_id: int): formsemestre_id=formsemestre.id, ) ) - form_image = form.image - del form.image - form.populate_obj(formsemestre_description) + # Vérification valeur dispositif + if form.dispositif.data not in FORMSEMESTRE_DISPOSITIFS: + flash("Dispositif inconnu", "danger") + ok = False - if form_image.data: - image_data = form_image.data.read() - max_length = current_app.config.get("MAX_CONTENT_LENGTH") - if max_length and len(image_data) > max_length: - flash( - f"Image too large, max {max_length} bytes", - "danger", + # 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 ) - return redirect( - url_for( - "notes.edit_formsemestre_description", - formsemestre_id=formsemestre.id, - scodoc_dept=g.scodoc_dept, - ) + 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 ) - formsemestre_description.image = image_data + 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, + ) + ) + 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, + 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", @@ -295,4 +337,5 @@ def edit_formsemestre_description(formsemestre_id: int): formsemestre=formsemestre, formsemestre_description=formsemestre_description, sco=ScoData(formsemestre=formsemestre), + title="Modif. description semestre", ) diff --git a/migrations/versions/2640b7686de6_formsemestre_description.py b/migrations/versions/2640b7686de6_formsemestre_description.py index c275d3e2..c2533093 100644 --- a/migrations/versions/2640b7686de6_formsemestre_description.py +++ b/migrations/versions/2640b7686de6_formsemestre_description.py @@ -21,11 +21,20 @@ def upgrade(): op.create_table( "notes_formsemestre_description", sa.Column("id", sa.Integer(), nullable=False), - sa.Column("image", sa.LargeBinary(), nullable=True), sa.Column("description", sa.Text(), server_default="", nullable=False), - sa.Column("responsable", sa.Text(), server_default="", nullable=False), - sa.Column("lieu", 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" diff --git a/tests/unit/test_formsemestre.py b/tests/unit/test_formsemestre.py index 75ed6793..0e5e2f96 100644 --- a/tests/unit/test_formsemestre.py +++ b/tests/unit/test_formsemestre.py @@ -223,11 +223,14 @@ def test_formsemestre_description(test_client): db.session.commit() assert formsemestre.description is None # Association d'une description - formsemestre.description = FormSemestreDescription( - description="Description 2", - responsable="Responsable 2", - lieu="Lieu 2", - horaire="Horaire 2", + 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() From e6f86d655b4887800346b2f544a2d6cd3dc69d89 Mon Sep 17 00:00:00 2001 From: ilona Date: Wed, 14 Aug 2024 10:28:26 +0200 Subject: [PATCH 5/8] API FormSemestreDescription + test --- app/api/formsemestres.py | 25 ++++++++++++++++ tests/api/test_api_formsemestre.py | 48 ++++++++++++++++++++++++++++++ tests/api/test_api_permissions.py | 2 +- 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index e46258f2..24a98f33 100644 --- a/app/api/formsemestres.py +++ b/app/api/formsemestres.py @@ -33,6 +33,7 @@ from app.models import ( Departement, Evaluation, FormSemestre, + FormSemestreDescription, FormSemestreEtape, FormSemestreInscription, Identite, @@ -812,6 +813,30 @@ def formsemestre_get_description(formsemestre_id: int): 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 + + 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() + 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 diff --git a/tests/api/test_api_formsemestre.py b/tests/api/test_api_formsemestre.py index 23d55ccc..923f4c79 100644 --- a/tests/api/test_api_formsemestre.py +++ b/tests/api/test_api_formsemestre.py @@ -28,8 +28,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 +782,49 @@ 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 + 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 diff --git a/tests/api/test_api_permissions.py b/tests/api/test_api_permissions.py index ae3dad92..f94ce12e 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: From 4bfd0858a8210578455b99f42e9ac6622ea7607e Mon Sep 17 00:00:00 2001 From: ilona Date: Wed, 14 Aug 2024 15:39:57 +0200 Subject: [PATCH 6/8] API FormSemestreDescription: images: upload, tests. --- app/api/formsemestres.py | 21 ++++++++++++++------- app/views/notes_formsemestre.py | 16 ++++++++++++++++ tests/api/setup_test_api.py | 11 ++++++++--- tests/api/test_api_formsemestre.py | 16 ++++++++++++++++ 4 files changed, 54 insertions(+), 10 deletions(-) diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index 24a98f33..afd2e9ba 100644 --- a/app/api/formsemestres.py +++ b/app/api/formsemestres.py @@ -13,12 +13,14 @@ FormSemestre """ -import mimetypes +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 @@ -820,8 +822,8 @@ def formsemestre_get_description(formsemestre_id: int): @permission_required(Permission.ScoView) @as_json def formsemestre_edit_description(formsemestre_id: int): - """Modifie description externe du formsemestre - + """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 @@ -832,6 +834,10 @@ def formsemestre_edit_description(formsemestre_id: int): 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() @@ -868,11 +874,12 @@ def formsemestre_get_photo_ens(formsemestre_id: int): return _image_response(formsemestre.description.photo_ens) -def _image_response(image_data): +def _image_response(image_data: bytes): # Guess the mimetype based on the image data - mimetype = mimetypes.guess_type("image")[0] - - if not mimetype: + 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" diff --git a/app/views/notes_formsemestre.py b/app/views/notes_formsemestre.py index 1bafa62d..e3e25c90 100644 --- a/app/views/notes_formsemestre.py +++ b/app/views/notes_formsemestre.py @@ -30,9 +30,11 @@ Emmanuel Viennet, 2023 """ import datetime +import io from flask import flash, redirect, render_template, url_for from flask import current_app, g, request +import PIL from app import db, log from app.decorators import ( @@ -319,6 +321,20 @@ def edit_formsemestre_description(formsemestre_id: int): 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() diff --git a/tests/api/setup_test_api.py b/tests/api/setup_test_api.py index 2ce97eb8..126162e2 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 923f4c79..1395a6a3 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 @@ -813,6 +814,9 @@ def test_formsemestre_description(api_admin_headers): 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={ @@ -828,3 +832,15 @@ def test_formsemestre_description(api_admin_headers): 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 From 6a48d5bbcf114702929e5a7e1855b99547d877e8 Mon Sep 17 00:00:00 2001 From: ilona Date: Thu, 22 Aug 2024 16:42:38 +0200 Subject: [PATCH 7/8] =?UTF-8?q?Pr=C3=A9f.=20pour=20envoi=20d=E2=80=99une?= =?UTF-8?q?=20notification=20mail=20=C3=A0=20chaque=20(de)inscription?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/formsemestre.py | 51 ++++++++++++++++++++- app/scodoc/sco_formsemestre_exterieurs.py | 1 - app/scodoc/sco_formsemestre_inscriptions.py | 22 +++------ app/scodoc/sco_preferences.py | 21 +++++++-- app/static/css/scodoc.css | 6 +++ 5 files changed, 79 insertions(+), 22 deletions(-) diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 34d34aad..9c73cf58 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 @@ -1093,9 +1093,58 @@ class FormSemestre(models.ScoDocModel): 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/scodoc/sco_formsemestre_exterieurs.py b/app/scodoc/sco_formsemestre_exterieurs.py index 4ba6a47a..b4c49c21 100644 --- a/app/scodoc/sco_formsemestre_exterieurs.py +++ b/app/scodoc/sco_formsemestre_exterieurs.py @@ -51,7 +51,6 @@ import app.scodoc.sco_utils as scu from app import log from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message from app.scodoc import html_sco_header -from app.scodoc import sco_formations from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_formsemestre_validation diff --git a/app/scodoc/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py index f77d1d85..a7650be2 100644 --- a/app/scodoc/sco_formsemestre_inscriptions.py +++ b/app/scodoc/sco_formsemestre_inscriptions.py @@ -182,12 +182,11 @@ def do_formsemestre_desinscription( if check_has_dec_jury: check_if_has_decision_jury(formsemestre, [etudid]) - insem = do_formsemestre_inscription_list( - args={"formsemestre_id": formsemestre_id, "etudid": etudid} - ) - if not insem: + inscr_sem = FormSemestreInscription.query.filter_by( + etudid=etudid, formsemestre_id=formsemestre_id + ).first() + if not inscr_sem: raise ScoValueError(f"{etud.nomprenom} n'est pas inscrit au semestre !") - insem = insem[0] # -- desinscription de tous les modules cnx = ndb.GetDBConnexion() cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) @@ -211,10 +210,8 @@ def do_formsemestre_desinscription( Partition.formsemestre_remove_etud(formsemestre_id, etud) # -- désincription du semestre - do_formsemestre_inscription_delete( - insem["formsemestre_inscription_id"], formsemestre_id=formsemestre_id - ) - sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id) + formsemestre.desinscrit_etudiant(etud) + # --- Semestre extérieur if formsemestre.modalite == "EXT": if 0 == len(formsemestre.inscriptions): @@ -226,13 +223,6 @@ def do_formsemestre_desinscription( db.session.commit() flash(f"Semestre extérieur supprimé: {formsemestre.titre_annee()}") - Scolog.logdb( - method="formsemestre_desinscription", - etudid=etudid, - msg=f"desinscription semestre {formsemestre_id}", - commit=True, - ) - def do_formsemestre_inscription_with_modules( formsemestre_id, diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index 6b940076..90d4cc43 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 @@ -2321,6 +2334,7 @@ class BasePreferences: + {descr["comment"]} """ descr["explanation"] = menu_global @@ -2385,7 +2399,6 @@ class SemPreferences: def edit(self, categories=[]): """Dialog to edit semestre preferences in given categories""" from app.scodoc import html_sco_header - from app.scodoc import sco_formsemestre if not self.formsemestre_id: raise ScoValueError( diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 783ac666..5b80c194 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -3320,6 +3320,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; From d8f6fe35e902ee830f29337aa87e2d5f4421673d Mon Sep 17 00:00:00 2001 From: ilona Date: Fri, 23 Aug 2024 16:33:35 +0200 Subject: [PATCH 8/8] Liste groupe: affichage optionnel de la date d'inscription --- app/scodoc/sco_excel.py | 17 ++++++++++++++--- app/scodoc/sco_groups_view.py | 34 +++++++++++++++++++++++++++------- app/static/js/groups_view.js | 30 +++++++++++++++++++----------- app/views/scolar.py | 12 +++++++++--- 4 files changed, 69 insertions(+), 24 deletions(-) diff --git a/app/scodoc/sco_excel.py b/app/scodoc/sco_excel.py index 1ef1df49..8a30e92e 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_groups_view.py b/app/scodoc/sco_groups_view.py index d4817ee2..541589bb 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/static/js/groups_view.js b/app/static/js/groups_view.js index 7f16a580..43eb694b 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/views/scolar.py b/app/views/scolar.py index 1a55f771..31438ae8 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,