This commit is contained in:
Emmanuel Viennet 2024-08-23 16:35:01 +02:00
commit 6ab027dffe
25 changed files with 1037 additions and 123 deletions

View File

@ -13,11 +13,14 @@
FormSemestre FormSemestre
""" """
import base64
import io
from operator import attrgetter, itemgetter from operator import attrgetter, itemgetter
from flask import g, make_response, request from flask import g, make_response, request
from flask_json import as_json from flask_json import as_json
from flask_login import current_user, login_required from flask_login import current_user, login_required
import PIL
import sqlalchemy as sa import sqlalchemy as sa
import app import app
from app import db, log from app import db, log
@ -32,6 +35,7 @@ from app.models import (
Departement, Departement,
Evaluation, Evaluation,
FormSemestre, FormSemestre,
FormSemestreDescription,
FormSemestreEtape, FormSemestreEtape,
FormSemestreInscription, FormSemestreInscription,
Identite, Identite,
@ -790,3 +794,95 @@ 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.post("/formsemestre/<int:formsemestre_id>/description/edit")
@api_web_bp.post("/formsemestre/<int:formsemestre_id>/description/edit")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def formsemestre_edit_description(formsemestre_id: int):
"""Modifie description externe du formsemestre.
Les images peuvent êtres passées dans el json, encodées en base64.
formsemestre_id : l'id du formsemestre
SAMPLES
-------
/formsemestre/<int:formsemestre_id>/description/edit;{""description"":""descriptif du semestre"", ""dispositif"":1}
"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
args = request.get_json(force=True) # may raise 400 Bad Request
if not formsemestre.description:
formsemestre.description = FormSemestreDescription()
# Decode images (base64)
for key in ["image", "photo_ens"]:
if key in args:
args[key] = base64.b64decode(args[key])
formsemestre.description.from_dict(args)
db.session.commit()
return formsemestre.description.to_dict()
@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
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: bytes):
# Guess the mimetype based on the image data
try:
image = PIL.Image.open(io.BytesIO(image_data))
mimetype = image.get_format_mimetype()
except PIL.UnidentifiedImageError:
# 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

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

@ -0,0 +1,112 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Formulaire édition description formsemestre
"""
from wtforms import (
BooleanField,
FileField,
SelectField,
StringField,
TextAreaField,
SubmitField,
)
from wtforms.validators import AnyOf, Optional
from app.forms import ScoDocForm
from app.models import FORMSEMESTRE_DISPOSITIFS
from app.scodoc import sco_utils as scu
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",
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"
)
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"
)
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="""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")
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,10 @@ from app.models.formsemestre import (
NotesSemSet, NotesSemSet,
notes_semset_formsemestre, notes_semset_formsemestre,
) )
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

@ -23,7 +23,7 @@ from sqlalchemy.sql import text
from sqlalchemy import func from sqlalchemy import func
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app import db, log from app import db, email, log
from app.auth.models import User from app.auth.models import User
from app import models from app import models
from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
@ -36,7 +36,7 @@ from app.models.config import ScoDocSiteConfig
from app.models.departements import Departement from app.models.departements import Departement
from app.models.etudiants import Identite from app.models.etudiants import Identite
from app.models.evaluations import Evaluation from app.models.evaluations import Evaluation
from app.models.events import ScolarNews from app.models.events import Scolog, ScolarNews
from app.models.formations import Formation from app.models.formations import Formation
from app.models.groups import GroupDescr, Partition from app.models.groups import GroupDescr, Partition
from app.models.moduleimpls import ( from app.models.moduleimpls import (
@ -45,9 +45,10 @@ from app.models.moduleimpls import (
notes_modules_enseignants, notes_modules_enseignants,
) )
from app.models.modules import Module from app.models.modules import Module
from app.models.scolar_event import ScolarEvent
from app.models.ues import UniteEns from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import codes_cursus, sco_preferences from app.scodoc import codes_cursus, sco_cache, sco_preferences
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV, translate_assiduites_metric from app.scodoc.sco_utils import MONTH_NAMES_ABBREV, translate_assiduites_metric
@ -69,6 +70,8 @@ class FormSemestre(models.ScoDocModel):
formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id")) formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id"))
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1") semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
titre = db.Column(db.Text(), nullable=False) titre = db.Column(db.Text(), nullable=False)
# nb max d'inscriptions (non DEM), null si illimité:
capacite_accueil = db.Column(db.Integer, nullable=True)
date_debut = db.Column(db.Date(), nullable=False) date_debut = db.Column(db.Date(), nullable=False)
date_fin = db.Column(db.Date(), nullable=False) # jour inclus date_fin = db.Column(db.Date(), nullable=False) # jour inclus
edt_id: str | None = db.Column(db.Text(), index=True, nullable=True) edt_id: str | None = db.Column(db.Text(), index=True, nullable=True)
@ -143,6 +146,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",
@ -1013,20 +1022,129 @@ class FormSemestre(models.ScoDocModel):
codes |= {x.strip() for x in self.elt_passage_apo.split(",") if x} codes |= {x.strip() for x in self.elt_passage_apo.split(",") if x}
return codes return codes
def get_inscrits(self, include_demdef=False, order=False) -> list[Identite]: def get_inscrits(
self, include_demdef=False, order=False, etats: set | None = None
) -> list[Identite]:
"""Liste des étudiants inscrits à ce semestre """Liste des étudiants inscrits à ce semestre
Si include_demdef, tous les étudiants, avec les démissionnaires Si include_demdef, tous les étudiants, avec les démissionnaires
et défaillants. et défaillants.
Si etats, seuls les étudiants dans l'un des états indiqués.
Si order, tri par clé sort_key Si order, tri par clé sort_key
""" """
if include_demdef: if include_demdef:
etuds = [ins.etud for ins in self.inscriptions] etuds = [ins.etud for ins in self.inscriptions]
else: elif not etats:
etuds = [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT] etuds = [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT]
else:
etuds = [ins.etud for ins in self.inscriptions if ins.etat in etats]
if order: if order:
etuds.sort(key=lambda e: e.sort_key) etuds.sort(key=lambda e: e.sort_key)
return etuds return etuds
def inscrit_etudiant(
self,
etud: "Identite",
etat: str = scu.INSCRIT,
etape: str | None = None,
method: str | None = None,
) -> "FormSemestreInscription":
"""Inscrit l'étudiant au semestre, ou renvoie son inscription s'il l'est déjà.
Vérifie la capacité d'accueil si indiquée (non null): si le semestre est plein,
lève une exception. Génère un évènement et un log étudiant.
method: indique origine de l'inscription pour le log étudiant.
"""
# remplace ancien do_formsemestre_inscription_create()
if not self.etat: # check lock
raise ScoValueError("inscrit_etudiant: semestre verrouille")
inscr = FormSemestreInscription.query.filter_by(
formsemestre_id=self.id, etudid=etud.id
).first()
if inscr is not None:
return inscr
if self.capacite_accueil is not None:
# tous sauf démissionnaires:
inscriptions = self.get_inscrits(etats={scu.INSCRIT, scu.DEF})
if len(inscriptions) >= self.capacite_accueil:
raise ScoValueError(
f"Semestre {self.titre} complet : {len(self.inscriptions)} inscrits",
dest_url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=self.id,
),
)
inscr = FormSemestreInscription(
formsemestre_id=self.id, etudid=etud.id, etat=etat, etape=etape
)
db.session.add(inscr)
# Évènement
event = ScolarEvent(
etudid=etud.id,
formsemestre_id=self.id,
event_type="INSCRIPTION",
)
db.session.add(event)
# Log etudiant
Scolog.logdb(
method=method,
etudid=etud.id,
msg=f"inscription en semestre {self.titre_annee()}",
commit=True,
)
log(
f"inscrit_etudiant: {etud.nomprenom} ({etud.id}) au semestre {self.titre_annee()}"
)
# Notification mail
self._notify_inscription(etud)
sco_cache.invalidate_formsemestre(formsemestre_id=self.id)
return inscr
def desinscrit_etudiant(self, etud: Identite):
"Désinscrit l'étudiant du semestre (et notifie le cas échéant)"
inscr_sem = FormSemestreInscription.query.filter_by(
etudid=etud.id, formsemestre_id=self.id
).first()
if not inscr_sem:
raise ScoValueError(
f"{etud.nomprenom} ({etud.id}) n'est pas inscrit au semestre !"
)
db.session.delete(inscr_sem)
Scolog.logdb(
method="desinscrit_etudiant",
etudid=etud.id,
msg=f"désinscription semestre {self.titre_annee()}",
commit=True,
)
log(
f"desinscrit_etudiant: {etud.nomprenom} ({etud.id}) au semestre {self.titre_annee()}"
)
self._notify_inscription(etud, action="désinscrit")
sco_cache.invalidate_formsemestre(formsemestre_id=self.id)
def _notify_inscription(self, etud: Identite, action="inscrit") -> None:
"Notifie inscription d'un étudiant: envoie un mail selon paramétrage"
destinations = (
sco_preferences.get_preference("emails_notifications_inscriptions", self.id)
or ""
)
destinations = [x.strip() for x in destinations.split(",")]
destinations = [x for x in destinations if x]
if not destinations:
return
txt = f"""{etud.nom_prenom()}
s'est {action}{etud.e}
en {self.titre_annee()}"""
subject = f"""Inscription de {etud.nom_prenom()} en {self.titre_annee()}"""
# build mail
log(f"_notify_inscription: sending notification to {destinations}")
log(f"_notify_inscription: subject: {subject}")
log(txt)
email.send_email(
"[ScoDoc] " + subject, email.get_from_addr(), destinations, txt
)
def get_partitions_list( def get_partitions_list(
self, with_default=True, only_listed=False self, with_default=True, only_listed=False
) -> list[Partition]: ) -> list[Partition]:

View File

@ -0,0 +1,82 @@
##############################################################################
# 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)
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"),
nullable=False,
)
formsemestre = db.relationship(
"FormSemestre", back_populates="description", uselist=False
)
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, 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",
}

View File

@ -45,6 +45,7 @@ from openpyxl.worksheet.worksheet import Worksheet
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app import log from app import log
from app.models.scolar_event import ScolarEvent
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import notesdb, sco_preferences from app.scodoc import notesdb, sco_preferences
@ -638,11 +639,12 @@ def excel_feuille_listeappel(
lines, lines,
partitions=None, partitions=None,
with_codes=False, with_codes=False,
with_date_inscription=False,
with_paiement=False, with_paiement=False,
server_name=None, server_name=None,
edt_params: dict = None, edt_params: dict = None,
): ):
"""generation feuille appel """Génération feuille appel.
edt_params : edt_params :
- "discipline" : Discipline - "discipline" : Discipline
@ -763,7 +765,8 @@ def excel_feuille_listeappel(
cells.append(ws.make_cell("etudid", style3)) cells.append(ws.make_cell("etudid", style3))
cells.append(ws.make_cell("code_nip", style3)) cells.append(ws.make_cell("code_nip", style3))
cells.append(ws.make_cell("code_ine", style3)) cells.append(ws.make_cell("code_ine", style3))
if with_date_inscription:
cells.append(ws.make_cell("Date inscr.", style3))
# case Groupes # case Groupes
cells.append(ws.make_cell("Groupes", style3)) cells.append(ws.make_cell("Groupes", style3))
letter_int += 1 letter_int += 1
@ -805,7 +808,15 @@ def excel_feuille_listeappel(
cells.append(ws.make_cell(code_nip, style2t3)) cells.append(ws.make_cell(code_nip, style2t3))
code_ine = t.get("code_ine", "") code_ine = t.get("code_ine", "")
cells.append(ws.make_cell(code_ine, style2t3)) cells.append(ws.make_cell(code_ine, style2t3))
if with_date_inscription:
event = ScolarEvent.query.filter_by(
etudid=t["etudid"],
event_type="INSCRIPTION",
formsemestre_id=formsemestre_id,
).first()
if event:
date_inscription = event.event_date
cells.append(ws.make_cell(date_inscription, style2t3))
cells.append(ws.make_cell(style=style2t3)) cells.append(ws.make_cell(style=style2t3))
ws.append_row(cells) ws.append_row(cells)
ws.set_row_dimension_height(row_id, 30) ws.set_row_dimension_height(row_id, 30)

View File

@ -53,6 +53,7 @@ _formsemestreEditor = ndb.EditableTable(
"semestre_id", "semestre_id",
"formation_id", "formation_id",
"titre", "titre",
"capacite_accueil",
"date_debut", "date_debut",
"date_fin", "date_fin",
"gestion_compensation", "gestion_compensation",

View File

@ -345,8 +345,6 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
"labels": modalites_titles, "labels": modalites_titles,
}, },
), ),
]
modform.append(
( (
"semestre_id", "semestre_id",
{ {
@ -362,10 +360,21 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
"attributes": ['onchange="change_semestre_id();"'] if is_apc else "", "attributes": ['onchange="change_semestre_id();"'] if is_apc else "",
}, },
), ),
) (
"capacite_accueil",
{
"title": "Capacité d'accueil",
"size": 4,
"explanation": "nombre max d'inscrits (hors démissionnaires). Laisser vide si pas de limite.",
"type": "int",
"allow_null": True,
},
),
]
etapes = sco_portal_apogee.get_etapes_apogee_dept() etapes = sco_portal_apogee.get_etapes_apogee_dept()
# Propose les etapes renvoyées par le portail # Propose les etapes renvoyées par le portail
# et ajoute les étapes du semestre qui ne sont pas dans la liste (soit la liste a changé, soit l'étape a été ajoutée manuellement) # et ajoute les étapes du semestre qui ne sont pas dans la liste
# (soit la liste a changé, soit l'étape a été ajoutée manuellement)
etapes_set = {et[0] for et in etapes} etapes_set = {et[0] for et in etapes}
if edit: if edit:
for etape_vdi in formsemestre.etapes_apo_vdi(): for etape_vdi in formsemestre.etapes_apo_vdi():
@ -503,6 +512,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>
""" """
@ -838,6 +853,14 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
): ):
msg = '<ul class="tf-msg"><li class="tf-msg">Code étape Apogée manquant</li></ul>' msg = '<ul class="tf-msg"><li class="tf-msg">Code étape Apogée manquant</li></ul>'
# check capacité accueil si indiquée
if edit and isinstance(tf[2]["capacite_accueil"], int):
new_capacite_accueil = tf[2]["capacite_accueil"]
inscriptions = formsemestre.get_inscrits(etats={scu.INSCRIT, scu.DEF})
if len(inscriptions) > new_capacite_accueil:
msg = f"""<ul class="tf-msg"><li class="tf-msg">Capacité d'accueil insuffisante
(il y a {len(inscriptions)} inscrits non démissionaires)</li></ul>"""
if tf[0] == 0 or msg: if tf[0] == 0 or msg:
return f"""<p>Formation <a class="discretelink" href="{ return f"""<p>Formation <a class="discretelink" href="{
url_for("notes.ue_table", scodoc_dept=g.scodoc_dept, url_for("notes.ue_table", scodoc_dept=g.scodoc_dept,
@ -1284,6 +1307,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}")
@ -1372,10 +1396,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

@ -51,7 +51,6 @@ import app.scodoc.sco_utils as scu
from app import log from app import log
from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import sco_formations
from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre
from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_formsemestre_validation from app.scodoc import sco_formsemestre_validation

View File

@ -85,43 +85,6 @@ def do_formsemestre_inscription_listinscrits(formsemestre_id):
return r return r
def do_formsemestre_inscription_create(args, method=None):
"create a formsemestre_inscription (and sco event)"
cnx = ndb.GetDBConnexion()
log(f"do_formsemestre_inscription_create: args={args}")
sems = sco_formsemestre.do_formsemestre_list(
{"formsemestre_id": args["formsemestre_id"]}
)
if len(sems) != 1:
raise ScoValueError(f"code de semestre invalide: {args['formsemestre_id']}")
sem = sems[0]
# check lock
if not sem["etat"]:
raise ScoValueError("inscription: semestre verrouille")
#
r = _formsemestre_inscriptionEditor.create(cnx, args)
# Evenement
sco_etud.scolar_events_create(
cnx,
args={
"etudid": args["etudid"],
"event_date": time.strftime(scu.DATE_FMT),
"formsemestre_id": args["formsemestre_id"],
"event_type": "INSCRIPTION",
},
)
# Log etudiant
Scolog.logdb(
method=method,
etudid=args["etudid"],
msg=f"inscription en semestre {args['formsemestre_id']}",
commit=True,
)
#
sco_cache.invalidate_formsemestre(formsemestre_id=args["formsemestre_id"])
return r
def do_formsemestre_inscription_delete(oid, formsemestre_id=None): def do_formsemestre_inscription_delete(oid, formsemestre_id=None):
"delete formsemestre_inscription" "delete formsemestre_inscription"
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
@ -219,12 +182,11 @@ def do_formsemestre_desinscription(
if check_has_dec_jury: if check_has_dec_jury:
check_if_has_decision_jury(formsemestre, [etudid]) check_if_has_decision_jury(formsemestre, [etudid])
insem = do_formsemestre_inscription_list( inscr_sem = FormSemestreInscription.query.filter_by(
args={"formsemestre_id": formsemestre_id, "etudid": etudid} etudid=etudid, formsemestre_id=formsemestre_id
) ).first()
if not insem: if not inscr_sem:
raise ScoValueError(f"{etud.nomprenom} n'est pas inscrit au semestre !") raise ScoValueError(f"{etud.nomprenom} n'est pas inscrit au semestre !")
insem = insem[0]
# -- desinscription de tous les modules # -- desinscription de tous les modules
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
@ -248,10 +210,8 @@ def do_formsemestre_desinscription(
Partition.formsemestre_remove_etud(formsemestre_id, etud) Partition.formsemestre_remove_etud(formsemestre_id, etud)
# -- désincription du semestre # -- désincription du semestre
do_formsemestre_inscription_delete( formsemestre.desinscrit_etudiant(etud)
insem["formsemestre_inscription_id"], formsemestre_id=formsemestre_id
)
sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id)
# --- Semestre extérieur # --- Semestre extérieur
if formsemestre.modalite == "EXT": if formsemestre.modalite == "EXT":
if 0 == len(formsemestre.inscriptions): if 0 == len(formsemestre.inscriptions):
@ -263,13 +223,6 @@ def do_formsemestre_desinscription(
db.session.commit() db.session.commit()
flash(f"Semestre extérieur supprimé: {formsemestre.titre_annee()}") flash(f"Semestre extérieur supprimé: {formsemestre.titre_annee()}")
Scolog.logdb(
method="formsemestre_desinscription",
etudid=etudid,
msg=f"desinscription semestre {formsemestre_id}",
commit=True,
)
def do_formsemestre_inscription_with_modules( def do_formsemestre_inscription_with_modules(
formsemestre_id, formsemestre_id,
@ -283,7 +236,9 @@ def do_formsemestre_inscription_with_modules(
"""Inscrit cet etudiant à ce semestre et TOUS ses modules STANDARDS """Inscrit cet etudiant à ce semestre et TOUS ses modules STANDARDS
(donc sauf le sport) (donc sauf le sport)
Si dept_id est spécifié, utilise ce département au lieu du courant. Si dept_id est spécifié, utilise ce département au lieu du courant.
Vérifie la capacité d'accueil.
""" """
etud = Identite.get_etud(etudid)
group_ids = group_ids or [] group_ids = group_ids or []
if isinstance(group_ids, int): if isinstance(group_ids, int):
group_ids = [group_ids] group_ids = [group_ids]
@ -294,13 +249,9 @@ def do_formsemestre_inscription_with_modules(
if group_id != "" if group_id != ""
] ]
formsemestre = FormSemestre.get_formsemestre(formsemestre_id, dept_id=dept_id) formsemestre = FormSemestre.get_formsemestre(formsemestre_id, dept_id=dept_id)
# inscription au semestre # Inscription au semestre
args = {"formsemestre_id": formsemestre_id, "etudid": etudid} args = {"formsemestre_id": formsemestre_id, "etudid": etudid}
if etat is not None: formsemestre.inscrit_etudiant(etud, etat=etat, etape=etape, method=method)
args["etat"] = etat
if etape is not None:
args["etape"] = etape
do_formsemestre_inscription_create(args, method=method)
log( log(
f"""do_formsemestre_inscription_with_modules: etudid={ f"""do_formsemestre_inscription_with_modules: etudid={
etudid} formsemestre_id={formsemestre_id}""" etudid} formsemestre_id={formsemestre_id}"""
@ -478,7 +429,7 @@ def formsemestre_inscription_with_modules(
H.append("<ul>") H.append("<ul>")
for s in others: for s in others:
H.append( H.append(
f"""<li><a href="{ f"""<li><a class="stdlink" href="{
url_for("notes.formsemestre_desinscription", scodoc_dept=g.scodoc_dept, url_for("notes.formsemestre_desinscription", scodoc_dept=g.scodoc_dept,
formsemestre_id=s["formsemestre_id"], etudid=etudid ) formsemestre_id=s["formsemestre_id"], etudid=etudid )
}" class="stdlink">désinscrire de {s["titreannee"]} }" class="stdlink">désinscrire de {s["titreannee"]}
@ -486,13 +437,13 @@ def formsemestre_inscription_with_modules(
) )
H.append("</ul>") H.append("</ul>")
H.append( H.append(
f"""<p><a href="{ url_for( "notes.formsemestre_inscription_with_modules", f"""<p><a class="stdlink" href="{
scodoc_dept=g.scodoc_dept, etudid=etudid, formsemestre_id=formsemestre_id, url_for( "notes.formsemestre_inscription_with_modules",
multiple_ok=1, scodoc_dept=g.scodoc_dept, etudid=etudid, formsemestre_id=formsemestre_id,
group_ids=group_ids ) multiple_ok=1,
group_ids=group_ids )
}">Continuer quand même l'inscription</a> }">Continuer quand même l'inscription</a>
</p>""" </p>"""
# was sco_groups.make_query_groups(group_ids)
) )
return render_template( return render_template(
"sco_page.j2", "sco_page.j2",

View File

@ -59,8 +59,6 @@ import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType from app.scodoc.sco_utils import ModuleType
from app.scodoc import codes_cursus from app.scodoc import codes_cursus
from app.scodoc import html_sco_header
from app.scodoc import htmlutils
from app.scodoc import sco_archives_formsemestre from app.scodoc import sco_archives_formsemestre
from app.scodoc import sco_assiduites as scass from app.scodoc import sco_assiduites as scass
from app.scodoc import sco_bulletins from app.scodoc import sco_bulletins
@ -989,8 +987,8 @@ def formsemestre_status_head(formsemestre_id: int = None, page_title: str = None
page_title = page_title or "Modules de " page_title = page_title or "Modules de "
H = [ H = [
f"""<table> f"""<table class="formsemestre_status_head">
<tr><td class="fichetitre2">Formation: </td><td> <tr><td class="fichetitre2">Formation&nbsp;: </td><td>
<a href="{url_for('notes.ue_table', <a href="{url_for('notes.ue_table',
scodoc_dept=g.scodoc_dept, formation_id=formsemestre.formation.id)}" scodoc_dept=g.scodoc_dept, formation_id=formsemestre.formation.id)}"
class="discretelink" title="Formation { class="discretelink" title="Formation {
@ -1013,15 +1011,24 @@ def formsemestre_status_head(formsemestre_id: int = None, page_title: str = None
sem_parcours = formsemestre.get_parcours_apc() sem_parcours = formsemestre.get_parcours_apc()
H.append( H.append(
f""" f"""
<tr><td class="fichetitre2">Parcours: </td> <tr><td class="fichetitre2">Parcours&nbsp;: </td>
<td style="color: blue;">{', '.join(parcours.code for parcours in sem_parcours)}</td> <td style="color: blue;">{', '.join(parcours.code for parcours in sem_parcours)}</td>
</tr> </tr>
""" """
) )
if formsemestre.capacite_accueil is not None:
H.append(
f"""
<tr><td class="fichetitre2">Capacité d'accueil&nbsp;: </td>
<td>{formsemestre.capacite_accueil}</td>
</tr>
"""
)
evals = sco_evaluations.do_evaluation_etat_in_sem(formsemestre) evals = sco_evaluations.do_evaluation_etat_in_sem(formsemestre)
H.append( H.append(
'<tr><td class="fichetitre2">Évaluations: </td><td> %(nb_evals_completes)s ok, %(nb_evals_en_cours)s en cours, %(nb_evals_vides)s vides' """<tr><td class="fichetitre2">Évaluations&nbsp;: </td>
<td> %(nb_evals_completes)s ok, %(nb_evals_en_cours)s en cours, %(nb_evals_vides)s vides"""
% evals % evals
) )
if evals["last_modif"]: if evals["last_modif"]:

View File

@ -40,7 +40,7 @@ from flask import url_for, g, render_template, request
from flask_login import current_user from flask_login import current_user
from app import db from app import db
from app.models import FormSemestre, Identite from app.models import FormSemestre, Identite, ScolarEvent
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import sco_assiduites as scass from app.scodoc import sco_assiduites as scass
@ -70,6 +70,7 @@ def groups_lists(
group_ids=(), group_ids=(),
fmt="html", fmt="html",
with_codes=0, with_codes=0,
with_date_inscription=0,
etat=None, etat=None,
with_paiement=0, with_paiement=0,
with_archives=0, with_archives=0,
@ -102,6 +103,7 @@ def groups_lists(
groups_infos=groups_infos, groups_infos=groups_infos,
fmt=fmt, fmt=fmt,
with_codes=with_codes, with_codes=with_codes,
with_date_inscription=with_date_inscription,
etat=etat, etat=etat,
with_paiement=with_paiement, with_paiement=with_paiement,
with_archives=with_archives, with_archives=with_archives,
@ -121,6 +123,7 @@ def groups_lists(
groups_infos=groups_infos, groups_infos=groups_infos,
fmt=fmt, fmt=fmt,
with_codes=with_codes, with_codes=with_codes,
with_date_inscription=with_date_inscription,
etat=etat, etat=etat,
with_paiement=with_paiement, with_paiement=with_paiement,
with_archives=with_archives, with_archives=with_archives,
@ -507,6 +510,7 @@ class DisplayedGroupsInfos:
def groups_table( def groups_table(
groups_infos: DisplayedGroupsInfos = None, groups_infos: DisplayedGroupsInfos = None,
with_codes=0, with_codes=0,
with_date_inscription=0,
etat=None, etat=None,
fmt="html", fmt="html",
with_paiement=0, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail) with_paiement=0, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail)
@ -522,15 +526,16 @@ def groups_table(
can_view_etud_data = int(current_user.has_permission(Permission.ViewEtudData)) can_view_etud_data = int(current_user.has_permission(Permission.ViewEtudData))
with_codes = int(with_codes) with_codes = int(with_codes)
with_date_inscription = int(with_date_inscription)
with_paiement = int(with_paiement) and can_view_etud_data with_paiement = int(with_paiement) and can_view_etud_data
with_archives = int(with_archives) and can_view_etud_data with_archives = int(with_archives) and can_view_etud_data
with_annotations = int(with_annotations) and can_view_etud_data with_annotations = int(with_annotations) and can_view_etud_data
with_bourse = int(with_bourse) and can_view_etud_data with_bourse = int(with_bourse) and can_view_etud_data
base_url_np = groups_infos.base_url + f"&with_codes={with_codes}"
base_url = ( base_url = (
base_url_np groups_infos.base_url
+ f"""&with_paiement={with_paiement}&with_archives={ + f"""&with_codes={with_codes}&with_date_inscription={
with_date_inscription}&with_paiement={with_paiement}&with_archives={
with_archives}&with_annotations={with_annotations with_archives}&with_annotations={with_annotations
}&with_bourse={with_bourse}""" }&with_bourse={with_bourse}"""
) )
@ -546,6 +551,7 @@ def groups_table(
"etudid": "etudid", "etudid": "etudid",
"code_nip": "code_nip", "code_nip": "code_nip",
"code_ine": "code_ine", "code_ine": "code_ine",
"date_inscription": "Date inscription",
"datefinalisationinscription_str": "Finalisation inscr.", "datefinalisationinscription_str": "Finalisation inscr.",
"paiementinscription_str": "Paiement", "paiementinscription_str": "Paiement",
"etudarchive": "Fichiers", "etudarchive": "Fichiers",
@ -579,9 +585,11 @@ def groups_table(
if with_codes: if with_codes:
columns_ids += ["etape", "etudid", "code_nip", "code_ine"] columns_ids += ["etape", "etudid", "code_nip", "code_ine"]
if with_date_inscription:
columns_ids += ["date_inscription"]
if with_paiement: if with_paiement:
columns_ids += ["datefinalisationinscription_str", "paiementinscription_str"] columns_ids += ["datefinalisationinscription_str", "paiementinscription_str"]
if with_paiement: # or with_codes: if with_paiement:
sco_portal_apogee.check_paiement_etuds(groups_infos.members) sco_portal_apogee.check_paiement_etuds(groups_infos.members)
if with_archives: if with_archives:
from app.scodoc import sco_archives_etud from app.scodoc import sco_archives_etud
@ -597,6 +605,16 @@ def groups_table(
moodle_groupenames = set() moodle_groupenames = set()
# ajoute liens # ajoute liens
for etud_info in groups_infos.members: for etud_info in groups_infos.members:
if with_date_inscription:
event = ScolarEvent.query.filter_by(
etudid=etud_info["etudid"],
event_type="INSCRIPTION",
formsemestre_id=groups_infos.formsemestre_id,
).first()
if event:
etud_info["date_inscription"] = event.event_date.strftime(scu.DATE_FMT)
etud_info["_date_inscription_xls"] = event.event_date
etud_info["_date_inscription_order"] = event.event_date.isoformat
if etud_info["email"]: if etud_info["email"]:
etud_info["_email_target"] = "mailto:" + etud_info["email"] etud_info["_email_target"] = "mailto:" + etud_info["email"]
else: else:
@ -612,8 +630,8 @@ def groups_table(
etud_info["_nom_disp_order"] = etud_sort_key(etud_info) etud_info["_nom_disp_order"] = etud_sort_key(etud_info)
etud_info["_prenom_target"] = fiche_url etud_info["_prenom_target"] = fiche_url
etud_info["_nom_disp_td_attrs"] = 'id="%s" class="etudinfo"' % ( etud_info["_nom_disp_td_attrs"] = (
etud_info["etudid"] f"""id="{etud_info['etudid']}" class="etudinfo" """
) )
etud_info["bourse_str"] = "oui" if etud_info["boursier"] else "non" etud_info["bourse_str"] = "oui" if etud_info["boursier"] else "non"
if etud_info["etat"] == "D": if etud_info["etat"] == "D":
@ -720,6 +738,7 @@ def groups_table(
if groups_infos.members: if groups_infos.members:
options = { options = {
"with_codes": "Affiche codes", "with_codes": "Affiche codes",
"with_date_inscription": "Date inscription",
} }
if can_view_etud_data: if can_view_etud_data:
options.update( options.update(
@ -824,6 +843,7 @@ def groups_table(
groups_infos.members, groups_infos.members,
partitions=groups_infos.partitions, partitions=groups_infos.partitions,
with_codes=with_codes, with_codes=with_codes,
with_date_inscription=with_date_inscription,
with_paiement=with_paiement, with_paiement=with_paiement,
server_name=request.url_root, server_name=request.url_root,
) )

View File

@ -369,10 +369,23 @@ class BasePreferences:
"emails_notifications", "emails_notifications",
{ {
"initvalue": "", "initvalue": "",
"title": "e-mails à qui notifier les opérations", "title": "e-mail(s) à qui notifier les opérations",
"size": 70, "size": 70,
"explanation": """adresses séparées par des virgules; notifie les opérations "explanation": """optionnel; adresses séparées par des virgules;
(saisies de notes, etc). notifie les opérations (saisies de notes, etc).
""",
"category": "general",
"only_global": False, # peut être spécifique à un semestre
},
),
(
"emails_notifications_inscriptions",
{
"initvalue": "",
"title": "e-mail(s) à qui notifier les inscriptions d'étudiants",
"size": 70,
"explanation": """optionnel; adresses séparées par des virgules;
notifie les inscriptions/désincriptions de chaque individu.
""", """,
"category": "general", "category": "general",
"only_global": False, # peut être spécifique à un semestre "only_global": False, # peut être spécifique à un semestre
@ -2320,6 +2333,7 @@ class BasePreferences:
<option value="create">Spécifier valeur pour ce <option value="create">Spécifier valeur pour ce
semestre seulement</option> semestre seulement</option>
</select> </select>
<span class="pref-comment">{descr["comment"]}</span>
""" """
descr["explanation"] = menu_global descr["explanation"] = menu_global

View File

@ -1843,6 +1843,15 @@ div.formsemestre_status {
/* EMO_WARNING, "&#9888;&#65039;" */ /* EMO_WARNING, "&#9888;&#65039;" */
} }
table.formsemestre_status_head {
border-collapse: collapse;
}
table.formsemestre_status_head tr td:nth-child(2) {
padding-left: 1em;
}
table.formsemestre_status { table.formsemestre_status {
border-collapse: collapse; border-collapse: collapse;
} }
@ -3310,6 +3319,12 @@ li.tf-msg {
padding-bottom: 5px; padding-bottom: 5px;
} }
.pref-comment {
font-style: italic;
font-size: small;
color: var(--sco-color-explication);
}
div.formsemestre-warning-box { div.formsemestre-warning-box {
background-color: yellow; background-color: yellow;
border-radius: 4px; border-radius: 4px;

View File

@ -48,13 +48,12 @@ function change_list_options(selected_options) {
"with_archives", "with_archives",
"with_annotations", "with_annotations",
"with_codes", "with_codes",
"with_date_inscription",
"with_bourse", "with_bourse",
]; ];
for (var i = 0; i < options.length; i++) { for (var i = 0; i < options.length; i++) {
var option = options[i]; let option = options[i];
if ($.inArray(option, selected_options) >= 0) { urlParams.set(option, selected_options.indexOf(option) >= 0 ? "1" : "0");
urlParams.set(option, "1");
}
} }
window.location = url.href; window.location = url.href;
} }
@ -62,23 +61,32 @@ function change_list_options(selected_options) {
// Menu choix groupe: // Menu choix groupe:
function toggle_visible_etuds() { function toggle_visible_etuds() {
// //
$(".etud_elem").hide(); document.querySelectorAll('.etud_elem').forEach(element => {
element.style.display = 'none';
});
var qargs = ""; var qargs = "";
$("#group_ids_sel option:selected").each(function (index, opt) { var selectedOptions = document.querySelectorAll("#group_ids_sel option:checked");
var qargs = "";
selectedOptions.forEach(function (opt) {
var group_id = opt.value; var group_id = opt.value;
$(".group-" + group_id).show(); var groupElements = document.querySelectorAll(".group-" + group_id);
groupElements.forEach(function (elem) {
elem.style.display = "block";
});
qargs += "&group_ids=" + group_id; qargs += "&group_ids=" + group_id;
}); });
// Update url saisie tableur: // Update url saisie tableur:
var input_eval = $("#formnotes_evaluation_id"); let input_eval = document.querySelectorAll("#formnotes_evaluation_id");
if (input_eval.length > 0) { if (input_eval.length > 0) {
var evaluation_id = input_eval[0].value; let evaluation_id = input_eval[0].value;
$("#menu_saisie_tableur a").attr( let menu_saisie_tableur_a = document.querySelector("#menu_saisie_tableur a");
menu_saisie_tableur_a.setAttribute(
"href", "href",
"saisie_notes_tableur?evaluation_id=" + evaluation_id + qargs "saisie_notes_tableur?evaluation_id=" + evaluation_id + qargs
); );
// lien feuille excel: // lien feuille excel:
$("#lnk_feuille_saisie").attr( let lnk_feuille_saisie = document.querySelector("#lnk_feuille_saisie");
lnk_feuille_saisie.setAttribute(
"href", "href",
"feuille_saisie_notes?evaluation_id=" + evaluation_id + qargs "feuille_saisie_notes?evaluation_id=" + evaluation_id + qargs
); );

View File

@ -0,0 +1,130 @@
{% extends "sco_page.j2" %}
{% import 'wtf.j2' as wtf %}
{% block styles %}
{{super()}}
<style>
.field_descr {
font-style: italic;
color: green;
}
div.field_descr {
margin-bottom: 16px;
}
.submit {
margin-top: 32px;
display: flex;
justify-content: start;
gap: 24px;
}
div.image {
margin-left: 32px;
margin-bottom: 16px;
}
div.image img {
border: 1px dashed #b60c0c;
}
</style>
{% 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 %}
<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>Tous les champs sont optionnels.</p>
</div>
<form method="POST" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<span class="wtf-field">{{ form.wip.label }}</span> <span class="wtf-field">{{form.wip() }}</span>
<span class="field_descr">{{ form.wip.description }}</span>
{{ 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>
{{ render_string_field(form.campus) }}
{{ render_string_field(form.salle, size=32) }}
{{ render_string_field(form.horaire) }}
<div>
<span class="wtf-field">{{ form.dispositif.label }} :</span>
<span class="wtf-field">{{ form.dispositif }}</span>
{% if form.dispositif.description %}
<div class="field_descr">{{ form.dispositif.description }}</div>
{% endif %}
</div>
{{ render_string_field(form.public) }}
{{ render_textarea_field(form.modalites_mcc, rows=8) }}
{{ render_textarea_field(form.prerequis, rows=5) }}
{{ 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: 400px;">
<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,34 @@
############################################################################## ##############################################################################
""" """
Vues "modernes" des formsemestre Vues "modernes" des formsemestres
Emmanuel Viennet, 2023 Emmanuel Viennet, 2023
""" """
import datetime
import io
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
import PIL
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,
FORMSEMESTRE_DISPOSITIFS,
ScoDocSiteConfig,
)
from app.scodoc import ( from app.scodoc import (
sco_edt_cal, sco_edt_cal,
sco_formations, sco_formations,
@ -223,3 +237,121 @@ 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)
ok = True
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,
)
)
# Vérification valeur dispositif
if form.dispositif.data not in FORMSEMESTRE_DISPOSITIFS:
flash("Dispositif inconnu", "danger")
ok = False
# 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
)
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
)
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,
)
)
try:
_ = PIL.Image.open(io.BytesIO(image_data))
except PIL.UnidentifiedImageError:
flash(
f"Image invalide ({field}), doit être une image",
"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,
)
)
return render_template(
"formsemestre/edit_description.j2",
form=form,
formsemestre=formsemestre,
formsemestre_description=formsemestre_description,
sco=ScoData(formsemestre=formsemestre),
title="Modif. description semestre",
)

View File

@ -471,18 +471,24 @@ def groups_lists(
fmt="html", fmt="html",
# Options pour listes: # Options pour listes:
with_codes=0, with_codes=0,
with_date_inscription=0,
etat=None, etat=None,
with_paiement=0, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail) with_paiement=0,
with_archives=0, # ajoute colonne avec noms fichiers archivés with_archives=0,
with_annotations=0, with_annotations=0,
with_bourse=0, with_bourse=0,
formsemestre_id=None, formsemestre_id=None,
): ):
"Listes des étudiants des groupes" """Listes des étudiants des groupes.
Si with_paiement, ajoute colonnes infos paiement droits et finalisation
inscription (lent car interrogation portail).
Si with_archives, ajoute colonne avec noms fichiers archivés.
"""
return sco_groups_view.groups_lists( return sco_groups_view.groups_lists(
group_ids=group_ids, group_ids=group_ids,
fmt=fmt, fmt=fmt,
with_codes=with_codes, with_codes=with_codes,
with_date_inscription=with_date_inscription,
etat=etat, etat=etat,
with_paiement=with_paiement, with_paiement=with_paiement,
with_archives=with_archives, with_archives=with_archives,

View File

@ -0,0 +1,51 @@
"""FormSemestreDescription et capacité d'accueil
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("description", 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"
),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("notes_formsemestre", schema=None) as batch_op:
batch_op.add_column(sa.Column("capacite_accueil", sa.Integer(), nullable=True))
def downgrade():
with op.batch_alter_table("notes_formsemestre", schema=None) as batch_op:
batch_op.drop_column("capacite_accueil")
op.drop_table("notes_formsemestre_description")

View File

@ -122,9 +122,14 @@ def GET(path: str, headers: dict = None, errmsg=None, dept=None, raw=False):
if reply.headers.get("Content-Type", None) == "application/json": if reply.headers.get("Content-Type", None) == "application/json":
return reply.json() # decode la reponse JSON return reply.json() # decode la reponse JSON
if reply.headers.get("Content-Type", None) in [ if reply.headers.get("Content-Type", None) in [
"image/jpg",
"image/png",
"application/pdf", "application/pdf",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
]: ]:
retval = { retval = {
"Content-Type": reply.headers.get("Content-Type", None), "Content-Type": reply.headers.get("Content-Type", None),
@ -132,7 +137,7 @@ def GET(path: str, headers: dict = None, errmsg=None, dept=None, raw=False):
} }
return retval return retval
raise APIError( raise APIError(
"Unknown returned content {r.headers.get('Content-Type', None} !\n", f"Unknown returned content {reply.headers.get('Content-Type', None)} !\n",
status_code=reply.status_code, status_code=reply.status_code,
) )

View File

@ -16,6 +16,7 @@ Utilisation :
Lancer : Lancer :
pytest tests/api/test_api_formsemestre.py pytest tests/api/test_api_formsemestre.py
""" """
import base64
import json import json
import requests import requests
from types import NoneType from types import NoneType
@ -28,8 +29,10 @@ from tests.api.setup_test_api import (
API_URL, API_URL,
CHECK_CERTIFICATE, CHECK_CERTIFICATE,
GET, GET,
POST,
api_headers, api_headers,
api_admin_headers, api_admin_headers,
set_headers,
) )
from tests.api.tools_test_api import ( from tests.api.tools_test_api import (
@ -780,3 +783,64 @@ def _compare_formsemestre_resultat(res: list[dict], ref: list[dict]):
if "nbabs" in k: if "nbabs" in k:
continue continue
assert res_d[k] == ref_d[k], f"values for key {k} differ." assert res_d[k] == ref_d[k], f"values for key {k} differ."
def test_formsemestre_description(api_admin_headers):
"""
Test accès et modification de la description
"""
set_headers(api_admin_headers)
formsemestre_id = 1
r = GET(f"/formsemestre/{formsemestre_id}")
assert "description" not in r
r = POST(
f"/formsemestre/{formsemestre_id}/description/edit",
data={
"description": "une description",
"horaire": "un horaire",
"salle": "une salle",
"dispositif": 1,
"wip": True,
},
)
assert r["description"] == "une description"
assert r["horaire"] == "un horaire"
assert r["salle"] == "une salle"
assert r["dispositif"] == 1
assert r["wip"] is True
r = GET(f"/formsemestre/{formsemestre_id}/description")
assert r["description"] == "une description"
assert r["horaire"] == "un horaire"
assert r["salle"] == "une salle"
assert r["dispositif"] == 1
assert r["wip"] is True
# La réponse ne contient pas les images, servies à part:
assert "image" not in r
assert "photo_ens" not in r
r = POST(
f"/formsemestre/{formsemestre_id}/description/edit",
data={
"description": "",
"horaire": "",
"salle": "",
"dispositif": 0,
"wip": False,
},
)
assert r["description"] == ""
assert r["horaire"] == ""
assert r["salle"] == ""
assert r["dispositif"] == 0
assert r["wip"] is False
# Upload image
with open("tests/ressources/images/papillon.jpg", "rb") as f:
img = f.read()
img_base64 = base64.b64encode(img).decode("utf-8")
r = POST(
f"/formsemestre/{formsemestre_id}/description/edit", data={"image": img_base64}
)
assert r["wip"] is False
r = GET(f"/formsemestre/{formsemestre_id}/description/image", raw=True)
assert r.status_code == 200
assert r.headers.get("Content-Type") == "image/jpeg"
assert r.content == img

View File

@ -100,7 +100,7 @@ def test_permissions(api_headers):
verify=CHECK_CERTIFICATE, verify=CHECK_CERTIFICATE,
timeout=scu.SCO_TEST_API_TIMEOUT, timeout=scu.SCO_TEST_API_TIMEOUT,
) )
assert r.status_code == 200 assert r.status_code // 100 == 2 # 2xx success
# Même chose sans le jeton: # Même chose sans le jeton:
for rule in api_rules: for rule in api_rules:

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
@ -205,3 +206,39 @@ 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.create_from_dict(
{
"description": "Description",
"responsable": "Responsable",
"campus": "Sorbonne",
"salle": "L214",
"horaire": "23h à l'aube",
}
)
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