1
0
forked from ScoDoc/ScoDoc

FormSemestreDescription: informations pour applications tierces: Modèle, API, éditeur.

This commit is contained in:
ilona 2024-08-11 21:39:43 +02:00
parent fc036705e8
commit 525d0446cc
11 changed files with 415 additions and 10 deletions

View File

@ -13,6 +13,7 @@
FormSemestre FormSemestre
""" """
import mimetypes
from operator import attrgetter, itemgetter from operator import attrgetter, itemgetter
from flask import g, make_response, request from flask import g, make_response, request
@ -790,3 +791,49 @@ def formsemestre_edt(formsemestre_id: int):
return sco_edt_cal.formsemestre_edt_dict( return sco_edt_cal.formsemestre_edt_dict(
formsemestre, group_ids=group_ids, show_modules_titles=show_modules_titles formsemestre, group_ids=group_ids, show_modules_titles=show_modules_titles
) )
@bp.route("/formsemestre/<int:formsemestre_id>/description")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/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/<int:formsemestre_id>/description/image")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/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

View File

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

View File

@ -14,12 +14,13 @@ class _EditModimplsCodesForm(FlaskForm):
# construit dynamiquement ci-dessous # construit dynamiquement ci-dessous
# pylint: disable=invalid-name
def EditModimplsCodesForm(formsemestre: FormSemestre) -> _EditModimplsCodesForm: def EditModimplsCodesForm(formsemestre: FormSemestre) -> _EditModimplsCodesForm:
"Création d'un formulaire pour éditer les codes" "Création d'un formulaire pour éditer les codes"
# Formulaire dynamique, on créé une classe ad-hoc # Formulaire dynamique, on créé une classe ad-hoc
class F(_EditModimplsCodesForm): class F(_EditModimplsCodesForm):
pass "class factory"
def _gen_mod_form(modimpl: ModuleImpl): def _gen_mod_form(modimpl: ModuleImpl):
field = StringField( field = StringField(

View File

@ -38,7 +38,7 @@ class ScoDocModel(db.Model):
__abstract__ = True # declare an abstract class for SQLAlchemy __abstract__ = True # declare an abstract class for SQLAlchemy
def clone(self, not_copying=()): 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. Attention: la copie n'a pas d'id avant le prochain flush ou commit.
""" """
d = dict(self.__dict__) d = dict(self.__dict__)
@ -188,6 +188,7 @@ from app.models.formsemestre import (
NotesSemSet, NotesSemSet,
notes_semset_formsemestre, notes_semset_formsemestre,
) )
from app.models.formsemestre_descr import FormSemestreDescription
from app.models.moduleimpls import ( from app.models.moduleimpls import (
ModuleImpl, ModuleImpl,
notes_modules_enseignants, notes_modules_enseignants,

View File

@ -143,6 +143,12 @@ class FormSemestre(models.ScoDocModel):
lazy="dynamic", lazy="dynamic",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
description = db.relationship(
"FormSemestreDescription",
back_populates="formsemestre",
cascade="all, delete-orphan",
uselist=False,
)
etuds = db.relationship( etuds = db.relationship(
"Identite", "Identite",
secondary="notes_formsemestre_inscription", secondary="notes_formsemestre_inscription",

View File

@ -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"<FormSemestreDescription {self.id} {self.formsemestre}>"
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,
}

View File

@ -508,6 +508,12 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id) scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
}">Modifier les codes Apogée et emploi du temps des modules</a> }">Modifier les codes Apogée et emploi du temps des modules</a>
</p> </p>
<p><a class="stdlink" href="{url_for("notes.edit_formsemestre_description",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
}">Éditer la description externe du semestre</a>
</p>
<h3>Sélectionner les modules, leurs responsables et les étudiants <h3>Sélectionner les modules, leurs responsables et les étudiants
à inscrire:</h3> à inscrire:</h3>
""" """
@ -1287,6 +1293,7 @@ def do_formsemestre_clone(
clone_partitions=False, clone_partitions=False,
): ):
"""Clone a semestre: make copy, same modules, same options, same resps, same partitions. """Clone a semestre: make copy, same modules, same options, same resps, same partitions.
Clone description.
New dates, responsable_id New dates, responsable_id
""" """
log(f"do_formsemestre_clone: {orig_formsemestre_id}") log(f"do_formsemestre_clone: {orig_formsemestre_id}")
@ -1375,10 +1382,14 @@ def do_formsemestre_clone(
# 5- Copie les parcours # 5- Copie les parcours
formsemestre.parcours = formsemestre_orig.parcours formsemestre.parcours = formsemestre_orig.parcours
# 6- Copy description
formsemestre.description = formsemestre_orig.description.clone()
db.session.add(formsemestre) db.session.add(formsemestre)
db.session.commit() db.session.commit()
# 6- Copy partitions and groups # 7- Copy partitions and groups
if clone_partitions: if clone_partitions:
sco_groups_copy.clone_partitions_and_groups( sco_groups_copy.clone_partitions_and_groups(
orig_formsemestre_id, formsemestre.id orig_formsemestre_id, formsemestre.id

View File

@ -0,0 +1,88 @@
{% extends "sco_page.j2" %}
{% import 'wtf.j2' as wtf %}
{% block styles %}
{{super()}}
<style>
.field_descr {
font-style: italic;
color: green;
}
.submit {
margin-top: 32px;
display: flex;
justify-content: start;
gap: 24px;
}
div.image {
margin-left: 32px;
}
div.image img {
border: 1px dashed #b60c0c;
}
</style>
{% endblock %}
{% block app_content %}
<div class="tab-content">
<h2>Édition de la description du semestre</h2>
<div class="help">
<p>Les informations saisies ici ne sont pas utilisées par ScoDoc mais
mises à disposition des applications tierces comme AutoSco.
</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>
</div>
<form method="POST" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<div>
{{ form.description.label }}<br>
{{ form.description(cols=80, rows=8) }}
<div class="field_descr">{{ form.description.description }}</div>
</div>
<div>
{{ form.responsable.label }}<br>
{{ form.responsable(size=64) }}<br>
<div class="field_descr">{{ form.responsable.description }}</div>
</div>
<div>
{{ 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>
{{ form.image.label }}
<div class="image">
{% if formsemestre_description.image %}
<img src="{{ url_for('apiweb.formsemestre_get_description_image',
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id) }}"
alt="Current Image" style="max-width: 200px;">
<div>
Changer l'image: {{ form.image() }}
</div>
{% else %}
<em>Aucune image n'est actuellement associée à ce semestre.</em>
{{ form.image() }}
{% endif %}
</div>
<div class="submit">
{{ form.submit }} {{ form.cancel }}
</div>
</form>
</div>
{% endblock %}

View File

@ -25,20 +25,29 @@
############################################################################## ##############################################################################
""" """
Vues "modernes" des formsemestre Vues "modernes" des formsemestres
Emmanuel Viennet, 2023 Emmanuel Viennet, 2023
""" """
from flask import flash, redirect, render_template, url_for 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 import db, log
from app.decorators import ( from app.decorators import (
scodoc, scodoc,
permission_required, permission_required,
) )
from app.forms.formsemestre import change_formation, edit_modimpls_codes_apo from app.forms.formsemestre import (
from app.models import Formation, FormSemestre, ScoDocSiteConfig change_formation,
edit_modimpls_codes_apo,
edit_description,
)
from app.models import (
Formation,
FormSemestre,
FormSemestreDescription,
ScoDocSiteConfig,
)
from app.scodoc import ( from app.scodoc import (
sco_edt_cal, sco_edt_cal,
sco_formations, sco_formations,
@ -223,3 +232,67 @@ def formsemestre_edt_help_config(formsemestre_id: int):
ScoDocSiteConfig=ScoDocSiteConfig, ScoDocSiteConfig=ScoDocSiteConfig,
title="Aide configuration EDT", title="Aide configuration EDT",
) )
@bp.route(
"/formsemestre_description/<int:formsemestre_id>/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),
)

View File

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

View File

@ -3,12 +3,12 @@
""" Test création/accès/clonage formsemestre """ Test création/accès/clonage formsemestre
""" """
from flask import Response from flask import g, Response
import pytest import pytest
from tests.unit import yaml_setup, call_view
import app import app
from app.models import Formation, FormSemestre from app import db
from app.models import Formation, FormSemestre, FormSemestreDescription
from app.scodoc import ( from app.scodoc import (
sco_archives_formsemestre, sco_archives_formsemestre,
sco_cost_formation, sco_cost_formation,
@ -35,6 +35,7 @@ from app.scodoc import (
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.views import notes, scolar from app.views import notes, scolar
from config import TestConfig from config import TestConfig
from tests.unit import yaml_setup, call_view
DEPT = TestConfig.DEPT_TEST 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_debouche.report_debouche_date(start_year=2000)
ans = sco_cost_formation.formsemestre_estim_cost(formsemestre.id) ans = sco_cost_formation.formsemestre_estim_cost(formsemestre.id)
# pas de test des indicateurs de suivi BUT # 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