From 525d0446cc0a6a27f69bf0103940bfc31b15c338 Mon Sep 17 00:00:00 2001 From: ilona Date: Sun, 11 Aug 2024 21:39:43 +0200 Subject: [PATCH] =?UTF-8?q?FormSemestreDescription:=20informations=20pour?= =?UTF-8?q?=20applications=20tierces:=20Mod=C3=A8le,=20API,=20=C3=A9diteur?= =?UTF-8?q?.?= 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 7834c4cb1..1cfc85256 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 000000000..2bf64c044 --- /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 cccf1978c..d0cd8a371 100644 --- a/app/forms/formsemestre/edit_modimpls_codes_apo.py +++ b/app/forms/formsemestre/edit_modimpls_codes_apo.py @@ -14,12 +14,13 @@ class _EditModimplsCodesForm(FlaskForm): # construit dynamiquement ci-dessous +# pylint: disable=invalid-name def EditModimplsCodesForm(formsemestre: FormSemestre) -> _EditModimplsCodesForm: "Création d'un formulaire pour éditer les codes" # Formulaire dynamique, on créé une classe ad-hoc class F(_EditModimplsCodesForm): - pass + "class factory" def _gen_mod_form(modimpl: ModuleImpl): field = StringField( diff --git a/app/models/__init__.py b/app/models/__init__.py index 0cf5498dd..df83dfacf 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 6be8fc618..09b08b2fb 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 000000000..8c73127a4 --- /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 dc066f219..c4cb7b84a 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 000000000..637a0966a --- /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 4868d2bad..972d25bc5 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 000000000..88ec43884 --- /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 e9677c4f8..75ed67934 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