diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index 1cfc85256..e46258f24 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 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 index 2bf64c044..e0b49b2a0 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 df83dfacf..82e8416ff 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 8c73127a4..1e754c526 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 637a0966a..aadfe8ee9 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 972d25bc5..1bafa62da 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 c275d3e22..c25330934 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 75ed67934..0e5e2f964 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()