ScoDoc/app/models/evaluations.py

536 lines
19 KiB
Python
Raw Normal View History

2021-11-13 08:25:51 +01:00
# -*- coding: UTF-8 -*
"""ScoDoc models: evaluations
"""
2022-02-10 21:55:06 +01:00
import datetime
from operator import attrgetter
2021-11-13 08:25:51 +01:00
2023-08-22 17:02:00 +02:00
from flask import g, url_for
from flask_login import current_user
import sqlalchemy as sa
2021-11-13 08:25:51 +01:00
from app import db
from app.models.etudiants import Identite
2023-08-22 17:02:00 +02:00
from app.models.events import ScolarNews
2022-02-10 21:55:06 +01:00
from app.models.moduleimpls import ModuleImpl
from app.models.notes import NotesNotes
2022-02-10 21:55:06 +01:00
from app.models.ues import UniteEns
2021-11-13 08:25:51 +01:00
2023-08-22 17:02:00 +02:00
from app.scodoc import sco_cache
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
2021-11-13 08:25:51 +01:00
import app.scodoc.notesdb as ndb
2023-08-22 17:02:00 +02:00
import app.scodoc.sco_utils as scu
2021-11-13 08:25:51 +01:00
2023-08-22 17:02:00 +02:00
MAX_EVALUATION_DURATION = datetime.timedelta(days=365)
NOON = datetime.time(12, 00)
DEFAULT_EVALUATION_TIME = datetime.time(8, 0)
2023-08-22 17:02:00 +02:00
VALID_EVALUATION_TYPES = {0, 1, 2}
2021-11-13 08:25:51 +01:00
class Evaluation(db.Model):
"""Evaluation (contrôle, examen, ...)"""
__tablename__ = "notes_evaluation"
id = db.Column(db.Integer, primary_key=True)
evaluation_id = db.synonym("id")
moduleimpl_id = db.Column(
db.Integer, db.ForeignKey("notes_moduleimpl.id"), index=True
)
2023-08-22 17:02:00 +02:00
date_debut = db.Column(db.DateTime(timezone=True), nullable=True)
date_fin = db.Column(db.DateTime(timezone=True), nullable=True)
2021-11-13 08:25:51 +01:00
description = db.Column(db.Text)
note_max = db.Column(db.Float)
coefficient = db.Column(db.Float)
visibulletin = db.Column(
db.Boolean, nullable=False, default=True, server_default="true"
)
publish_incomplete = db.Column(
db.Boolean, nullable=False, default=False, server_default="false"
)
# type d'evaluation: 0 normale, 1 rattrapage, 2 "2eme session"
evaluation_type = db.Column(
db.Integer, nullable=False, default=0, server_default="0"
)
# ordre de presentation (par défaut, le plus petit numero
# est la plus ancienne eval):
numero = db.Column(db.Integer, nullable=False, default=0)
2021-11-13 08:25:51 +01:00
ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True)
def __repr__(self):
return f"""<Evaluation {self.id} {
2023-08-22 17:02:00 +02:00
self.date_debut.isoformat() if self.date_debut else ''} "{
self.description[:16] if self.description else ''}">"""
2023-08-22 17:02:00 +02:00
@classmethod
def create(
cls,
moduleimpl: ModuleImpl = None,
jour=None,
heure_debut=None,
heure_fin=None,
description=None,
note_max=None,
coefficient=None,
visibulletin=None,
publish_incomplete=None,
evaluation_type=None,
numero=None,
**kw, # ceci pour absorber les éventuel arguments excedentaires
):
"""Create an evaluation. Check permission and all arguments."""
if not moduleimpl.can_edit_evaluation(current_user):
raise AccessDenied(
f"Modification évaluation impossible pour {current_user.get_nomplogin()}"
)
args = locals()
del args["cls"]
del args["kw"]
check_convert_evaluation_args(moduleimpl, args)
# Check numeros
Evaluation.moduleimpl_evaluation_renumber(moduleimpl, only_if_unumbered=True)
if not "numero" in args or args["numero"] is None:
args["numero"] = cls.get_new_numero(moduleimpl, args["date_debut"])
#
evaluation = Evaluation(**args)
sco_cache.invalidate_formsemestre(formsemestre_id=moduleimpl.formsemestre_id)
url = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=moduleimpl.id,
)
ScolarNews.add(
typ=ScolarNews.NEWS_NOTE,
obj=moduleimpl.id,
text=f"""Création d'une évaluation dans <a href="{url}">{
moduleimpl.module.titre or '(module sans titre)'}</a>""",
url=url,
)
return evaluation
@classmethod
def get_new_numero(
cls, moduleimpl: ModuleImpl, date_debut: datetime.datetime
) -> int:
"""Get a new numero for an evaluation in this moduleimpl
If necessary, renumber existing evals to make room for a new one.
"""
n = None
# Détermine le numero grâce à la date
# Liste des eval existantes triées par date, la plus ancienne en tete
evaluations = moduleimpl.evaluations.order_by(Evaluation.date_debut).all()
if date_debut is not None:
next_eval = None
t = date_debut
for e in evaluations:
if e.date_debut > t:
next_eval = e
break
if next_eval:
n = _moduleimpl_evaluation_insert_before(evaluations, next_eval)
else:
n = None # à placer en fin
if n is None: # pas de date ou en fin:
if evaluations:
n = evaluations[-1].numero + 1
else:
n = 0 # the only one
return n
2022-05-19 04:15:26 +02:00
def to_dict(self) -> dict:
"Représentation dict (riche, compat ScoDoc 7)"
2021-11-13 08:25:51 +01:00
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
# ScoDoc7 output_formators
e["evaluation_id"] = self.id
2023-08-22 17:02:00 +02:00
e["date_debut"] = e.date_debut.isoformat() if e.date_debut else None
e["date_fin"] = e.date_debut.isoformat() if e.date_fin else None
2021-11-13 08:25:51 +01:00
e["numero"] = ndb.int_null_is_zero(e["numero"])
e["poids"] = self.get_ue_poids_dict() # { ue_id : poids }
2023-08-22 17:02:00 +02:00
# Deprecated
e["jour"] = e.date_debut.strftime("%d/%m/%Y") if e.date_debut else ""
2022-02-10 21:55:06 +01:00
return evaluation_enrich_dict(e)
2021-11-13 08:25:51 +01:00
def to_dict_api(self) -> dict:
"Représentation dict pour API JSON"
return {
"coefficient": self.coefficient,
2023-08-22 17:02:00 +02:00
"date_debut": self.date_debut.isoformat(),
"date_fin": self.date_fin.isoformat(),
"description": self.description,
"evaluation_type": self.evaluation_type,
"id": self.id,
"moduleimpl_id": self.moduleimpl_id,
"note_max": self.note_max,
"numero": self.numero,
"poids": self.get_ue_poids_dict(),
"publish_incomplete": self.publish_incomplete,
"visi_bulletin": self.visibulletin,
}
def from_dict(self, data):
"""Set evaluation attributes from given dict values."""
2023-08-22 17:02:00 +02:00
check_convert_evaluation_args(self.moduleimpl, data)
if data.get("numero") is None:
data["numero"] = Evaluation.get_max_numero() + 1
for k in self.__dict__.keys():
if k != "_sa_instance_state" and k != "id" and k in data:
setattr(self, k, data[k])
2023-08-22 17:02:00 +02:00
@classmethod
def get_max_numero(cls, moduleimpl_id: int) -> int:
"""Return max numero among evaluations in this
moduleimpl (0 if None)
"""
max_num = (
db.session.query(sa.sql.functions.max(Evaluation.numero))
.filter_by(moduleimpl_id=moduleimpl_id)
.first()[0]
)
return max_num or 0
@classmethod
def moduleimpl_evaluation_renumber(
cls, moduleimpl: ModuleImpl, only_if_unumbered=False
):
"""Renumber evaluations in this moduleimpl, according to their date. (numero=0: oldest one)
Needed because previous versions of ScoDoc did not have eval numeros
Note: existing numeros are ignored
"""
# Liste des eval existantes triées par date, la plus ancienne en tete
evaluations = moduleimpl.evaluations.order_by(
Evaluation.date_debut, Evaluation.numero
).all()
all_numbered = all(e.numero is not None for e in evaluations)
if all_numbered and only_if_unumbered:
return # all ok
# Reset all numeros:
i = 1
for e in evaluations:
e.numero = i
db.session.add(e)
i += 1
db.session.commit()
def descr_heure(self) -> str:
"Description de la plage horaire pour affichages"
if self.heure_debut and (
not self.heure_fin or self.heure_fin == self.heure_debut
):
return f"""à {self.heure_debut.strftime("%Hh%M")}"""
elif self.heure_debut and self.heure_fin:
return f"""de {self.heure_debut.strftime("%Hh%M")} à {self.heure_fin.strftime("%Hh%M")}"""
else:
return ""
def descr_duree(self) -> str:
"Description de la durée pour affichages"
if self.heure_debut is None and self.heure_fin is None:
return ""
debut = self.heure_debut or DEFAULT_EVALUATION_TIME
fin = self.heure_fin or DEFAULT_EVALUATION_TIME
d = (fin.hour * 60 + fin.minute) - (debut.hour * 60 + debut.minute)
duree = f"{d//60}h"
if d % 60:
duree += f"{d%60:02d}"
return duree
def clone(self, not_copying=()):
"""Clone, not copying the given attrs
Attention: la copie n'a pas d'id avant le prochain commit
"""
d = dict(self.__dict__)
d.pop("id") # get rid of id
d.pop("_sa_instance_state") # get rid of SQLAlchemy special attr
for k in not_copying:
d.pop(k)
copy = self.__class__(**d)
db.session.add(copy)
return copy
2021-11-13 08:25:51 +01:00
2023-05-29 16:04:41 +02:00
def is_matin(self) -> bool:
2023-08-22 17:02:00 +02:00
"Evaluation commençant le matin (faux si pas de date)"
if not self.date_debut:
return False
return self.date_debut.time() < NOON
2023-05-29 16:04:41 +02:00
def is_apresmidi(self) -> bool:
2023-08-22 17:02:00 +02:00
"Evaluation commençant l'après midi (faux si pas de date)"
if not self.date_debut:
return False
return self.date_debut.time() >= NOON
2023-05-29 16:04:41 +02:00
def set_default_poids(self) -> bool:
2023-08-22 17:02:00 +02:00
"""Initialize les poids vers les UE à leurs valeurs par défaut
C'est à dire à 1 si le coef. module/UE est non nul, 0 sinon.
Les poids existants ne sont pas modifiés.
Return True if (uncommited) modification, False otherwise.
"""
ue_coef_dict = self.moduleimpl.module.get_ue_coef_dict()
sem_ues = self.moduleimpl.formsemestre.get_ues(with_sport=False)
modified = False
for ue in sem_ues:
existing_poids = EvaluationUEPoids.query.filter_by(
ue=ue, evaluation=self
).first()
if existing_poids is None:
coef_ue = ue_coef_dict.get(ue.id, 0.0) or 0.0
if coef_ue > 0:
poids = 1.0 # par défaut au départ
else:
poids = 0.0
self.set_ue_poids(ue, poids)
modified = True
return modified
def set_ue_poids(self, ue, poids: float) -> None:
2021-11-13 08:25:51 +01:00
"""Set poids évaluation vers cette UE"""
self.update_ue_poids_dict({ue.id: poids})
def set_ue_poids_dict(self, ue_poids_dict: dict) -> None:
2021-11-13 08:25:51 +01:00
"""set poids vers les UE (remplace existants)
ue_poids_dict = { ue_id : poids }
"""
L = []
for ue_id, poids in ue_poids_dict.items():
ue = db.session.get(UniteEns, ue_id)
ue_poids = EvaluationUEPoids(evaluation=self, ue=ue, poids=poids)
L.append(ue_poids)
db.session.add(ue_poids)
2022-10-03 09:04:04 +02:00
self.ue_poids = L # backref # pylint:disable=attribute-defined-outside-init
self.moduleimpl.invalidate_evaluations_poids() # inval cache
2021-11-13 08:25:51 +01:00
def update_ue_poids_dict(self, ue_poids_dict: dict) -> None:
2021-11-13 08:25:51 +01:00
"""update poids vers UE (ajoute aux existants)"""
current = self.get_ue_poids_dict()
current.update(ue_poids_dict)
self.set_ue_poids_dict(current)
2022-10-03 09:04:04 +02:00
def get_ue_poids_dict(self, sort=False) -> dict:
"""returns { ue_id : poids }
Si sort, trie par UE
"""
if sort:
return {
p.ue.id: p.poids
for p in sorted(
2023-04-12 12:47:43 +02:00
self.ue_poids, key=attrgetter("ue.numero", "ue.acronyme")
2022-10-03 09:04:04 +02:00
)
}
2021-11-13 08:25:51 +01:00
return {p.ue.id: p.poids for p in self.ue_poids}
def get_ue_poids_str(self) -> str:
"""string describing poids, for excel cells and pdfs
Note: si les poids ne sont pas initialisés (poids par défaut),
ils ne sont pas affichés.
"""
# restreint aux UE du semestre dans lequel est cette évaluation
# au cas où le module ait changé de semestre et qu'il reste des poids
evaluation_semestre_idx = self.moduleimpl.module.semestre_id
return ", ".join(
[
f"{p.ue.acronyme}: {p.poids}"
for p in sorted(
self.ue_poids, key=lambda p: (p.ue.numero or 0, p.ue.acronyme)
)
if evaluation_semestre_idx == p.ue.semestre_idx
]
)
def get_etud_note(self, etud: Identite) -> NotesNotes:
"""La note de l'étudiant, ou None si pas noté.
(nb: pas de cache, lent, ne pas utiliser pour des calculs)
"""
return NotesNotes.query.filter_by(etudid=etud.id, evaluation_id=self.id).first()
2021-11-13 08:25:51 +01:00
class EvaluationUEPoids(db.Model):
"""Poids des évaluations (BUT)
association many to many
"""
evaluation_id = db.Column(
db.Integer,
db.ForeignKey("notes_evaluation.id", ondelete="CASCADE"),
primary_key=True,
)
ue_id = db.Column(
db.Integer,
db.ForeignKey("notes_ue.id", ondelete="CASCADE"),
primary_key=True,
2021-11-13 08:25:51 +01:00
)
poids = db.Column(
db.Float,
nullable=False,
)
evaluation = db.relationship(
Evaluation,
backref=db.backref("ue_poids", cascade="all, delete-orphan"),
)
ue = db.relationship(
UniteEns,
backref=db.backref("evaluation_ue_poids", cascade="all, delete-orphan"),
)
def __repr__(self):
return f"<EvaluationUEPoids {self.evaluation} {self.ue} poids={self.poids}>"
2022-02-10 21:55:06 +01:00
# Fonction héritée de ScoDoc7 à refactorer
def evaluation_enrich_dict(e: dict):
"""add or convert some fields in an evaluation dict"""
2022-02-10 21:55:06 +01:00
# For ScoDoc7 compat
2023-08-22 17:02:00 +02:00
heure_debut_dt = e["date_debut"].time()
heure_fin_dt = e["date_fin"].time()
e["heure_debut"] = heure_debut_dt.strftime("%Hh%M")
e["heure_fin"] = heure_fin_dt.strftime("%Hh%M")
e["jour_iso"] = e["date_debut"].isoformat() # XXX
2022-02-10 21:55:06 +01:00
heure_debut, heure_fin = e["heure_debut"], e["heure_fin"]
2023-08-22 17:02:00 +02:00
d = _time_duration_HhM(heure_debut, heure_fin)
2022-02-10 21:55:06 +01:00
if d is not None:
m = d % 60
e["duree"] = "%dh" % (d / 60)
if m != 0:
e["duree"] += "%02d" % m
else:
e["duree"] = ""
if heure_debut and (not heure_fin or heure_fin == heure_debut):
e["descrheure"] = " à " + heure_debut
elif heure_debut and heure_fin:
e["descrheure"] = " de %s à %s" % (heure_debut, heure_fin)
else:
e["descrheure"] = ""
# matin, apresmidi: utile pour se referer aux absences:
if e["jour"] and heure_debut_dt < datetime.time(12, 00):
2022-02-10 21:55:06 +01:00
e["matin"] = 1
else:
e["matin"] = 0
if e["jour"] and heure_fin_dt > datetime.time(12, 00):
2022-02-10 21:55:06 +01:00
e["apresmidi"] = 1
else:
e["apresmidi"] = 0
return e
2023-08-22 17:02:00 +02:00
def check_convert_evaluation_args(moduleimpl: ModuleImpl, data: dict):
"""Check coefficient, dates and duration, raises exception if invalid.
Convert date and time strings to date and time objects.
Set required default value for unspecified fields.
May raise ScoValueError.
"""
# --- description
description = data.get("description", "")
if len(description) > scu.MAX_TEXT_LEN:
raise ScoValueError("description too large")
# --- evaluation_type
try:
data["evaluation_type"] = int(data.get("evaluation_type", 0) or 0)
if not data["evaluation_type"] in VALID_EVALUATION_TYPES:
raise ScoValueError("Invalid evaluation_type value")
except ValueError:
raise ScoValueError("Invalid evaluation_type value")
# --- note_max (bareme)
note_max = data.get("note_max", 20.0) or 20.0
2022-02-10 21:55:06 +01:00
try:
note_max = float(note_max)
except ValueError:
raise ScoValueError("Invalid note_max value")
if note_max < 0:
raise ScoValueError("Invalid note_max value (must be positive or null)")
2023-08-22 17:02:00 +02:00
data["note_max"] = note_max
# --- coefficient
coef = data.get("coefficient", 1.0) or 1.0
2022-02-10 21:55:06 +01:00
try:
coef = float(coef)
except ValueError:
raise ScoValueError("Invalid coefficient value")
if coef < 0:
raise ScoValueError("Invalid coefficient value (must be positive or null)")
2023-08-22 17:02:00 +02:00
data["coefficient"] = coef
# --- jour (date de l'évaluation)
jour = data.get("jour", None)
if jour and not isinstance(jour, datetime.date):
if date_format == "dmy":
y, m, d = [int(x) for x in ndb.DateDMYtoISO(jour).split("-")]
jour = datetime.date(y, m, d)
else: # ISO
jour = datetime.date.fromisoformat(jour)
formsemestre = moduleimpl.formsemestre
2022-02-10 21:55:06 +01:00
if (jour > formsemestre.date_fin) or (jour < formsemestre.date_debut):
raise ScoValueError(
2023-08-22 17:02:00 +02:00
f"""La date de l'évaluation ({jour.strftime("%d/%m/%Y")}) n'est pas dans le semestre !""",
2022-02-10 21:55:06 +01:00
dest_url="javascript:history.back();",
)
2023-08-22 17:02:00 +02:00
data["jour"] = jour
# --- heures
heure_debut = data.get("heure_debut", None)
if heure_debut and not isinstance(heure_debut, datetime.time):
if date_format == "dmy":
data["heure_debut"] = heure_to_time(heure_debut)
else: # ISO
data["heure_debut"] = datetime.time.fromisoformat(heure_debut)
heure_fin = data.get("heure_fin", None)
if heure_fin and not isinstance(heure_fin, datetime.time):
if date_format == "dmy":
data["heure_fin"] = heure_to_time(heure_fin)
else: # ISO
data["heure_fin"] = datetime.time.fromisoformat(heure_fin)
2022-02-10 21:55:06 +01:00
if jour and ((not heure_debut) or (not heure_fin)):
raise ScoValueError("Les heures doivent être précisées")
2023-08-22 17:02:00 +02:00
if heure_debut and heure_fin:
duration = ((data["heure_fin"].hour * 60) + data["heure_fin"].minute) - (
(data["heure_debut"].hour * 60) + data["heure_debut"].minute
)
if duration < 0 or duration > 60 * 12:
raise ScoValueError("Heures de l'évaluation incohérentes !")
def heure_to_time(heure: str) -> datetime.time:
"Convert external heure ('10h22' or '10:22') to a time"
t = heure.strip().upper().replace("H", ":")
h, m = t.split(":")[:2]
return datetime.time(int(h), int(m))
def _time_duration_HhM(heure_debut: str, heure_fin: str) -> int:
"""duree (nb entier de minutes) entre deux heures a notre format
ie 12h23
"""
if heure_debut and heure_fin:
h0, m0 = [int(x) for x in heure_debut.split("h")]
h1, m1 = [int(x) for x in heure_fin.split("h")]
d = (h1 - h0) * 60 + (m1 - m0)
return d
else:
return None
def _moduleimpl_evaluation_insert_before(
evaluations: list[Evaluation], next_eval: Evaluation
) -> int:
"""Renumber evaluations such that an evaluation with can be inserted before next_eval
Returns numero suitable for the inserted evaluation
"""
if next_eval:
n = next_eval.numero
if n is None:
Evaluation.moduleimpl_evaluation_renumber(next_eval.moduleimpl)
n = next_eval.numero
else:
n = 1
# all numeros >= n are incremented
for e in evaluations:
if e.numero >= n:
e.numero += 1
db.session.add(e)
db.session.commit()
return n