FormSemestreDescription: champs pour spécifs EL. Formulaire saisie.

This commit is contained in:
ilona 2024-08-13 16:47:55 +02:00
parent 3971145abd
commit 513fb3d46d
9 changed files with 322 additions and 95 deletions

View File

@ -826,8 +826,25 @@ def formsemestre_get_description_image(formsemestre_id: int):
if not formsemestre.description or not formsemestre.description.image: if not formsemestre.description or not formsemestre.description.image:
return make_response("", 204) # 204 No Content status return make_response("", 204) # 204 No Content status
return _image_response(formsemestre.description.image)
@bp.route("/formsemestre/<int:formsemestre_id>/description/photo_ens")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/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 # Guess the mimetype based on the image data
image_data = formsemestre.description.image
mimetype = mimetypes.guess_type("image")[0] mimetype = mimetypes.guess_type("image")[0]
if not mimetype: if not mimetype:

View File

@ -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)

View File

@ -6,13 +6,42 @@
"""Formulaire édition description formsemestre """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 app.forms import ScoDocForm
from wtforms import StringField, TextAreaField, FileField, SubmitField from app.models import FORMSEMESTRE_DISPOSITIFS
from wtforms.validators import Optional 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" "Formulaire édition description formsemestre"
description = TextAreaField( description = TextAreaField(
"Description", "Description",
@ -23,12 +52,60 @@ class FormSemestreDescriptionForm(FlaskForm):
horaire = StringField( horaire = StringField(
"Horaire", validators=[Optional()], description="ex: les lundis 9h-12h" "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 = FileField(
"Image", validators=[Optional()], description="Image illustrant cette formation" "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 = 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") submit = SubmitField("Enregistrer")

View File

@ -188,7 +188,10 @@ from app.models.formsemestre import (
NotesSemSet, NotesSemSet,
notes_semset_formsemestre, notes_semset_formsemestre,
) )
from app.models.formsemestre_descr import FormSemestreDescription from app.models.formsemestre_descr import (
FormSemestreDescription,
FORMSEMESTRE_DISPOSITIFS,
)
from app.models.moduleimpls import ( from app.models.moduleimpls import (
ModuleImpl, ModuleImpl,
notes_modules_enseignants, notes_modules_enseignants,

View File

@ -22,15 +22,33 @@ class FormSemestreDescription(models.ScoDocModel):
__tablename__ = "notes_formsemestre_description" __tablename__ = "notes_formsemestre_description"
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
# Storing image data directly in the database: description = db.Column(db.Text(), nullable=False, default="", server_default="")
image = db.Column(db.LargeBinary(), nullable=True) "description du cours, html autorisé"
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="") 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( formsemestre_id = db.Column(
db.Integer, db.Integer,
db.ForeignKey("notes_formsemestre.id", ondelete="CASCADE"), db.ForeignKey("notes_formsemestre.id", ondelete="CASCADE"),
@ -40,20 +58,6 @@ class FormSemestreDescription(models.ScoDocModel):
"FormSemestre", back_populates="description", uselist=False "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): def __repr__(self):
return f"<FormSemestreDescription {self.id} {self.formsemestre}>" return f"<FormSemestreDescription {self.id} {self.formsemestre}>"
@ -61,11 +65,18 @@ class FormSemestreDescription(models.ScoDocModel):
"""clone instance""" """clone instance"""
return super().clone(not_copying=not_copying + ("formsemestre_id",)) return super().clone(not_copying=not_copying + ("formsemestre_id",))
def to_dict(self): def to_dict(self, exclude_images=True) -> dict:
return { "dict, tous les attributs sauf les images"
"formsemestre_id": self.formsemestre_id, d = dict(self.__dict__)
"description": self.description, d.pop("_sa_instance_state", None)
"responsable": self.responsable, if exclude_images:
"lieu": self.lieu, d.pop("image", None)
"horaire": self.horaire, d.pop("photo_ens", None)
} return d
FORMSEMESTRE_DISPOSITIFS = {
0: "présentiel",
1: "en ligne",
2: "hybride",
}

View File

@ -8,6 +8,9 @@
font-style: italic; font-style: italic;
color: green; color: green;
} }
div.field_descr {
margin-bottom: 16px;
}
.submit { .submit {
margin-top: 32px; margin-top: 32px;
display: flex; display: flex;
@ -16,6 +19,7 @@
} }
div.image { div.image {
margin-left: 32px; margin-left: 32px;
margin-bottom: 16px;
} }
div.image img { div.image img {
border: 1px dashed #b60c0c; border: 1px dashed #b60c0c;
@ -23,6 +27,29 @@ div.image img {
</style> </style>
{% endblock %} {% endblock %}
{% macro render_string_field(field, size=64) %}
<div>
<span class="wtf-field">{{ field.label }} :</span>
<span class="wtf-field">{{ field(size=size)|safe }}</span>
{% for error in field.errors %}
<span class="text-danger">{{ error }}</span>
{% endfor %}
{% if field.description %}
<div class="field_descr">{{ field.description }}</div>
{% endif %}
</div>
{% endmacro %}
{% macro render_textarea_field(field, cols=80, rows=12) %}
<div>
<div class="wtf-field">{{ field.label }} :</div>
<div class="wtf-field">{{ field(cols=cols, rows=rows)|safe }}</div>
{% if field.description %}
<div class="field_descr">{{ field.description }}</div>
{% endif %}
</div>
{% endmacro %}
{% block app_content %} {% block app_content %}
<div class="tab-content"> <div class="tab-content">
@ -32,35 +59,50 @@ div.image img {
<p>Les informations saisies ici ne sont pas utilisées par ScoDoc mais <p>Les informations saisies ici ne sont pas utilisées par ScoDoc mais
mises à disposition des applications tierces comme AutoSco. mises à disposition des applications tierces comme AutoSco.
</p> </p>
<p>La description du semestre est un </p>
<p>Il est possible d'ajouter une image pour illustrer le semestre.</p>
<p>Le responsable est généralement l'enseignant en charge de la formation, ou
la personne qui s'occupant de l'organisation du semestre.</p>
<p>Tous les champs sont optionnels.</p> <p>Tous les champs sont optionnels.</p>
</div> </div>
<form method="POST" enctype="multipart/form-data"> <form method="POST" enctype="multipart/form-data">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div>
{{ form.description.label }}<br> <span class="wtf-field">{{ form.wip.label }}</span> <span class="wtf-field">{{form.wip() }}</span>
{{ form.description(cols=80, rows=8) }} <span class="field_descr">{{ form.wip.description }}</span>
<div class="field_descr">{{ form.description.description }}</div> {{ 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 }}
<div class="image">
{% if formsemestre_description.photo_ens %}
<img src="{{ url_for('apiweb.formsemestre_get_photo_ens',
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id) }}"
alt="Current Image" style="max-width: 200px;">
<div>
Changer l'image: {{ form.photo_ens() }}
</div>
{% else %}
<em>Aucune photo ou illustration chargée.</em>
{{ form.photo_ens() }}
{% endif %}
</div> </div>
{{ render_string_field(form.campus) }}
{{ render_string_field(form.salle, size=32) }}
{{ render_string_field(form.horaire) }}
<div> <div>
{{ form.responsable.label }}<br> <span class="wtf-field">{{ form.dispositif.label }} :</span>
{{ form.responsable(size=64) }}<br> <span class="wtf-field">{{ form.dispositif }}</span>
<div class="field_descr">{{ form.responsable.description }}</div> {% if form.dispositif.description %}
</div> <div class="field_descr">{{ form.dispositif.description }}</div>
<div> {% endif %}
{{ form.lieu.label }}<br>
{{ form.lieu(size=48) }}
<div class="field_descr">{{ form.lieu.description }}</div>
</div>
<div>
{{ form.horaire.label }}<br>
{{ form.horaire(size=64) }}<br>
<div class="field_descr">{{ form.horaire.description }}</div>
</div> </div>
{{ render_string_field(form.public) }}
{{ render_textarea_field(form.modalites_mcc, rows=8) }}
{{ render_textarea_field(form.prerequis, rows=5) }}
{{ form.image.label }} {{ form.image.label }}
<div class="image"> <div class="image">
@ -68,7 +110,7 @@ div.image img {
<img src="{{ url_for('apiweb.formsemestre_get_description_image', <img src="{{ url_for('apiweb.formsemestre_get_description_image',
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id) }}" formsemestre_id=formsemestre.id) }}"
alt="Current Image" style="max-width: 200px;"> alt="Current Image" style="max-width: 400px;">
<div> <div>
Changer l'image: {{ form.image() }} Changer l'image: {{ form.image() }}
</div> </div>
@ -76,8 +118,8 @@ div.image img {
<em>Aucune image n'est actuellement associée à ce semestre.</em> <em>Aucune image n'est actuellement associée à ce semestre.</em>
{{ form.image() }} {{ form.image() }}
{% endif %} {% endif %}
</div> </div>
<div class="submit"> <div class="submit">
{{ form.submit }} {{ form.cancel }} {{ form.submit }} {{ form.cancel }}
</div> </div>

View File

@ -29,6 +29,8 @@ Vues "modernes" des formsemestres
Emmanuel Viennet, 2023 Emmanuel Viennet, 2023
""" """
import datetime
from flask import flash, redirect, render_template, url_for from flask import flash, redirect, render_template, url_for
from flask import current_app, g, request from flask import current_app, g, request
@ -46,6 +48,7 @@ from app.models import (
Formation, Formation,
FormSemestre, FormSemestre,
FormSemestreDescription, FormSemestreDescription,
FORMSEMESTRE_DISPOSITIFS,
ScoDocSiteConfig, ScoDocSiteConfig,
) )
from app.scodoc import ( from app.scodoc import (
@ -248,7 +251,7 @@ def edit_formsemestre_description(formsemestre_id: int):
db.session.commit() db.session.commit()
formsemestre_description = formsemestre.description formsemestre_description = formsemestre.description
form = edit_description.FormSemestreDescriptionForm(obj=formsemestre_description) form = edit_description.FormSemestreDescriptionForm(obj=formsemestre_description)
ok = True
if form.validate_on_submit(): if form.validate_on_submit():
if form.cancel.data: # cancel button if form.cancel.data: # cancel button
return redirect( return redirect(
@ -258,36 +261,75 @@ def edit_formsemestre_description(formsemestre_id: int):
formsemestre_id=formsemestre.id, formsemestre_id=formsemestre.id,
) )
) )
form_image = form.image # Vérification valeur dispositif
del form.image if form.dispositif.data not in FORMSEMESTRE_DISPOSITIFS:
form.populate_obj(formsemestre_description) flash("Dispositif inconnu", "danger")
ok = False
if form_image.data: # Vérification dates inscriptions
image_data = form_image.data.read() if form.date_debut_inscriptions.data:
max_length = current_app.config.get("MAX_CONTENT_LENGTH") try:
if max_length and len(image_data) > max_length: date_debut_inscriptions_dt = datetime.datetime.strptime(
flash( form.date_debut_inscriptions.data, scu.DATE_FMT
f"Image too large, max {max_length} bytes",
"danger",
) )
return redirect( except ValueError:
url_for( flash("Date de début des inscriptions invalide", "danger")
"notes.edit_formsemestre_description", form.set_error("date début invalide", form.date_debut_inscriptions)
formsemestre_id=formsemestre.id, ok = False
scodoc_dept=g.scodoc_dept, 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() db.session.commit()
flash("Description enregistrée", "success") flash("Description enregistrée", "success")
return redirect( return redirect(
url_for( url_for(
"notes.formsemestre_status", "notes.formsemestre_status",
formsemestre_id=formsemestre.id, formsemestre_id=formsemestre.id,
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
)
) )
)
return render_template( return render_template(
"formsemestre/edit_description.j2", "formsemestre/edit_description.j2",
@ -295,4 +337,5 @@ def edit_formsemestre_description(formsemestre_id: int):
formsemestre=formsemestre, formsemestre=formsemestre,
formsemestre_description=formsemestre_description, formsemestre_description=formsemestre_description,
sco=ScoData(formsemestre=formsemestre), sco=ScoData(formsemestre=formsemestre),
title="Modif. description semestre",
) )

View File

@ -21,11 +21,20 @@ def upgrade():
op.create_table( op.create_table(
"notes_formsemestre_description", "notes_formsemestre_description",
sa.Column("id", sa.Integer(), nullable=False), 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("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("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.Column("formsemestre_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(
["formsemestre_id"], ["notes_formsemestre.id"], ondelete="CASCADE" ["formsemestre_id"], ["notes_formsemestre.id"], ondelete="CASCADE"

View File

@ -223,11 +223,14 @@ def test_formsemestre_description(test_client):
db.session.commit() db.session.commit()
assert formsemestre.description is None assert formsemestre.description is None
# Association d'une description # Association d'une description
formsemestre.description = FormSemestreDescription( formsemestre.description = FormSemestreDescription.create_from_dict(
description="Description 2", {
responsable="Responsable 2", "description": "Description",
lieu="Lieu 2", "responsable": "Responsable",
horaire="Horaire 2", "campus": "Sorbonne",
"salle": "L214",
"horaire": "23h à l'aube",
}
) )
db.session.add(formsemestre) db.session.add(formsemestre)
db.session.commit() db.session.commit()