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.
+
+
+
+
+
+
+{% 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