Update opolka/ScoDoc from ScoDoc/ScoDoc #2

Merged
opolka merged 1272 commits from ScoDoc/ScoDoc:master into master 2024-05-27 09:11:04 +02:00
11 changed files with 188 additions and 179 deletions
Showing only changes of commit 2227c85250 - Show all commits

View File

@ -273,5 +273,5 @@ def evaluation_delete(evaluation_id: int):
sco_saisie_notes.evaluation_suppress_alln(
evaluation_id=evaluation_id, dialog_confirmed=True
)
sco_evaluation_db.do_evaluation_delete(evaluation_id)
evaluation.delete()
return "ok"

View File

@ -141,6 +141,44 @@ class Evaluation(db.Model):
n = 0 # the only one
return n
def delete(self):
"delete evaluation (commit) (check permission)"
from app.scodoc import sco_evaluation_db
modimpl: ModuleImpl = self.moduleimpl
if not modimpl.can_edit_evaluation(current_user):
raise AccessDenied(
f"Modification évaluation impossible pour {current_user.get_nomplogin()}"
)
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(
self.id
) # { etudid : value }
notes = [x["value"] for x in notes_db.values()]
if notes:
raise ScoValueError(
"Impossible de supprimer cette évaluation: il reste des notes"
)
log(f"deleting evaluation {self}")
db.session.delete(self)
db.session.commit()
# inval cache pour ce semestre
sco_cache.invalidate_formsemestre(formsemestre_id=modimpl.formsemestre_id)
# news
url = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
)
ScolarNews.add(
typ=ScolarNews.NEWS_NOTE,
obj=modimpl.id,
text=f"""Suppression d'une évaluation dans <a href="{
url
}">{modimpl.module.titre}</a>""",
url=url,
)
def to_dict(self) -> dict:
"Représentation dict (riche, compat ScoDoc 7)"
e_dict = dict(self.__dict__)

View File

@ -118,6 +118,32 @@ class ModuleImpl(db.Model):
return False
def can_edit_notes(self, user: "User", allow_ens=True) -> bool:
"""True if authuser can enter or edit notes in this module.
If allow_ens, grant access to all ens in this module
Si des décisions de jury ont déjà été saisies dans ce semestre,
seul le directeur des études peut saisir des notes (et il ne devrait pas).
"""
# was sco_permissions_check.can_edit_notes
from app.scodoc import sco_cursus_dut
if not self.formsemestre.etat:
return False # semestre verrouillé
is_dir_etud = user.id in (u.id for u in self.formsemestre.responsables)
can_edit_all_notes = user.has_permission(Permission.ScoEditAllNotes)
if sco_cursus_dut.formsemestre_has_decisions(self.formsemestre_id):
# il y a des décisions de jury dans ce semestre !
return can_edit_all_notes or is_dir_etud
if (
not can_edit_all_notes
and user.id != self.responsable_id
and not is_dir_etud
):
# enseignant (chargé de TD) ?
return allow_ens and user.id in (ens.id for ens in self.enseignants)
return True
def can_change_ens_by(self, user: User, raise_exc=False) -> bool:
"""Check if user can modify module resp.
If raise_exc, raises exception (AccessDenied or ScoLockedSemError) if not.

View File

@ -28,19 +28,17 @@
"""Gestion évaluations (ScoDoc7, code en voie de modernisation)
"""
import pprint
import flask
from flask import url_for, g
from flask_login import current_user
from app import db, log
from app.models import Evaluation, ModuleImpl, ScolarNews
from app.models import Evaluation
from app.models.evaluations import check_convert_evaluation_args
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
from app.scodoc.sco_exceptions import AccessDenied
from app.scodoc import sco_cache
from app.scodoc import sco_moduleimpl
@ -119,42 +117,6 @@ def do_evaluation_edit(args):
)
def do_evaluation_delete(evaluation_id):
"delete evaluation"
evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id)
modimpl: ModuleImpl = evaluation.moduleimpl
if not modimpl.can_edit_evaluation(current_user):
raise AccessDenied(
f"Modification évaluation impossible pour {current_user.get_nomplogin()}"
)
notes_db = do_evaluation_get_all_notes(evaluation_id) # { etudid : value }
notes = [x["value"] for x in notes_db.values()]
if notes:
raise ScoValueError(
"Impossible de supprimer cette évaluation: il reste des notes"
)
log(f"deleting evaluation {evaluation}")
db.session.delete(evaluation)
db.session.commit()
# inval cache pour ce semestre
sco_cache.invalidate_formsemestre(formsemestre_id=modimpl.formsemestre_id)
# news
url = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
)
ScolarNews.add(
typ=ScolarNews.NEWS_NOTE,
obj=modimpl.id,
text=f"""Suppression d'une évaluation dans <a href="{
url
}">{modimpl.module.titre}</a>""",
url=url,
)
# ancien _notes_getall
def do_evaluation_get_all_notes(
evaluation_id, table="notes_notes", filter_suppressed=True, by_uid=None

View File

@ -30,16 +30,17 @@
import collections
import datetime
import operator
import time
from flask import url_for
from flask import g
from flask_login import current_user
from flask import request
from app import db
from app.auth.models import User
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre
from app.models import Evaluation, FormSemestre
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType
@ -645,78 +646,64 @@ def evaluation_describe(evaluation_id="", edit_in_place=True, link_saisie=True):
"""HTML description of evaluation, for page headers
edit_in_place: allow in-place editing when permitted (not implemented)
"""
E = sco_evaluation_db.get_evaluation_dict({"evaluation_id": evaluation_id})[0]
moduleimpl_id = E["moduleimpl_id"]
M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0]
Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
formsemestre_id = M["formsemestre_id"]
u = sco_users.user_info(M["responsable_id"])
resp = u["prenomnom"]
nomcomplet = u["nomcomplet"]
can_edit = sco_permissions_check.can_edit_notes(
current_user, moduleimpl_id, allow_ens=False
)
evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id)
modimpl = evaluation.moduleimpl
responsable: User = db.session.get(User, modimpl.responsable_id)
resp_nomprenom = responsable.get_prenomnom()
resp_nomcomplet = responsable.get_nomcomplet()
can_edit = modimpl.can_edit_notes(current_user, allow_ens=False)
link = (
'<span class="evallink"><a class="stdlink" href="evaluation_listenotes?moduleimpl_id=%s">voir toutes les notes du module</a></span>'
% moduleimpl_id
)
mod_descr = (
'<a href="moduleimpl_status?moduleimpl_id=%s">%s %s</a> <span class="resp">(resp. <a title="%s">%s</a>)</span> %s'
% (
moduleimpl_id,
Mod["code"] or "",
Mod["titre"] or "?",
nomcomplet,
resp,
link,
)
)
mod_descr = f"""<a class="stdlink" href="{url_for("notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
)}">{modimpl.module.code or ""} {modimpl.module.abbrev or modimpl.module.titre or "?"}</a>
<span class="resp">(resp. <a title="{resp_nomcomplet}">{resp_nomprenom}</a>)</span>
<span class="evallink"><a class="stdlink"
href="{url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl.id)
}">voir toutes les notes du module</a></span>
"""
etit = E["description"] or ""
if etit:
etit = ' "' + etit + '"'
if Mod["module_type"] == ModuleType.MALUS:
etit += ' <span class="eval_malus">(points de malus)</span>'
eval_titre = f' "{evaluation.description}"' if evaluation.description else ""
if modimpl.module.module_type == ModuleType.MALUS:
eval_titre += ' <span class="eval_malus">(points de malus)</span>'
H = [
'<span class="eval_title">Évaluation%s</span><p><b>Module : %s</b></p>'
% (etit, mod_descr)
f"""<span class="eval_title">Évaluation{eval_titre}</span>
<p><b>Module : {mod_descr}</b>
</p>"""
]
if Mod["module_type"] == ModuleType.MALUS:
if modimpl.module.module_type == ModuleType.MALUS:
# Indique l'UE
ue = sco_edit_ue.ue_list(args={"ue_id": Mod["ue_id"]})[0]
H.append("<p><b>UE : %(acronyme)s</b></p>" % ue)
ue = modimpl.module.ue
H.append(f"<p><b>UE : {ue.acronyme}</b></p>")
# store min/max values used by JS client-side checks:
H.append(
'<span id="eval_note_min" class="sco-hidden">-20.</span><span id="eval_note_max" class="sco-hidden">20.</span>'
"""<span id="eval_note_min" class="sco-hidden">-20.</span>
<span id="eval_note_max" class="sco-hidden">20.</span>"""
)
else:
# date et absences (pas pour evals de malus)
if E["jour"]:
jour = E["jour"]
H.append("<p>Réalisée le <b>%s</b> " % (jour))
if E["heure_debut"] != E["heure_fin"]:
H.append("de %s à %s " % (E["heure_debut"], E["heure_fin"]))
group_id = sco_groups.get_default_group(formsemestre_id)
if evaluation.date_debut is not None:
H.append(f"<p>Réalisée le <b>{evaluation.descr_date()}</b> ")
group_id = sco_groups.get_default_group(modimpl.formsemestre_id)
H.append(
f"""<span class="noprint"><a href="{url_for(
'assiduites.get_etat_abs_date',
f"""<span class="evallink"><a class="stdlink" href="{url_for(
'assiduites.etat_abs_date',
scodoc_dept=g.scodoc_dept,
group_ids=group_id,
desc=E["description"],
jour=E["jour"],
heure_debut=E["heure_debut"],
heure_fin=E["heure_fin"],
desc=evaluation.description or "",
date_debut=evaluation.date_debut.isoformat(),
date_fin=evaluation.date_fin.isoformat(),
)
}">(absences ce jour)</a></span>"""
}">absences ce jour</a></span>"""
)
else:
jour = "<em>pas de date</em>"
H.append("<p>Réalisée le <b>%s</b> " % (jour))
H.append("<p><em>sans date</em> ")
H.append(
'</p><p>Coefficient dans le module: <b>%s</b>, notes sur <span id="eval_note_max">%g</span> '
% (E["coefficient"], E["note_max"])
f"""</p><p>Coefficient dans le module: <b>{evaluation.coefficient or "0"}</b>,
notes sur <span id="eval_note_max">{(evaluation.note_max or 0):g}</span> """
)
H.append('<span id="eval_note_min" class="sco-hidden">0.</span>')
if can_edit:
@ -730,7 +717,7 @@ def evaluation_describe(evaluation_id="", edit_in_place=True, link_saisie=True):
if link_saisie:
H.append(
f"""
<a class="stdlink" href="{url_for(
<a style="margin-left: 12px;" class="stdlink" href="{url_for(
"notes.saisie_notes", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id)
}">saisie des notes</a>
"""

View File

@ -50,7 +50,6 @@ from app.scodoc import htmlutils
from app.scodoc import sco_cal
from app.scodoc import sco_compute_moy
from app.scodoc import sco_evaluations
from app.scodoc import sco_evaluation_db
from app.scodoc import sco_formsemestre_status
from app.scodoc import sco_groups
from app.scodoc import sco_moduleimpl
@ -59,19 +58,15 @@ from app.tables import list_etuds
# menu evaluation dans moduleimpl
def moduleimpl_evaluation_menu(evaluation_id, nbnotes=0) -> str:
def moduleimpl_evaluation_menu(evaluation: Evaluation, nbnotes: int = 0) -> str:
"Menu avec actions sur une evaluation"
E = sco_evaluation_db.get_evaluation_dict({"evaluation_id": evaluation_id})[0]
modimpl = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0]
modimpl: ModuleImpl = evaluation.moduleimpl
group_id = sco_groups.get_default_group(modimpl.formsemestre_id)
evaluation_id = evaluation.id
can_edit_notes = modimpl.can_edit_notes(current_user, allow_ens=False)
can_edit_notes_ens = modimpl.can_edit_notes(current_user)
group_id = sco_groups.get_default_group(modimpl["formsemestre_id"])
if (
sco_permissions_check.can_edit_notes(
current_user, E["moduleimpl_id"], allow_ens=False
)
and nbnotes != 0
):
if can_edit_notes and nbnotes != 0:
sup_label = "Supprimer évaluation impossible (il y a des notes)"
else:
sup_label = "Supprimer évaluation"
@ -83,9 +78,7 @@ def moduleimpl_evaluation_menu(evaluation_id, nbnotes=0) -> str:
"args": {
"evaluation_id": evaluation_id,
},
"enabled": sco_permissions_check.can_edit_notes(
current_user, E["moduleimpl_id"]
),
"enabled": can_edit_notes_ens,
},
{
"title": "Modifier évaluation",
@ -93,9 +86,7 @@ def moduleimpl_evaluation_menu(evaluation_id, nbnotes=0) -> str:
"args": {
"evaluation_id": evaluation_id,
},
"enabled": sco_permissions_check.can_edit_notes(
current_user, E["moduleimpl_id"], allow_ens=False
),
"enabled": can_edit_notes,
},
{
"title": sup_label,
@ -103,10 +94,7 @@ def moduleimpl_evaluation_menu(evaluation_id, nbnotes=0) -> str:
"args": {
"evaluation_id": evaluation_id,
},
"enabled": nbnotes == 0
and sco_permissions_check.can_edit_notes(
current_user, E["moduleimpl_id"], allow_ens=False
),
"enabled": nbnotes == 0 and can_edit_notes,
},
{
"title": "Supprimer toutes les notes",
@ -114,9 +102,7 @@ def moduleimpl_evaluation_menu(evaluation_id, nbnotes=0) -> str:
"args": {
"evaluation_id": evaluation_id,
},
"enabled": sco_permissions_check.can_edit_notes(
current_user, E["moduleimpl_id"], allow_ens=False
),
"enabled": can_edit_notes,
},
{
"title": "Afficher les notes",
@ -132,21 +118,18 @@ def moduleimpl_evaluation_menu(evaluation_id, nbnotes=0) -> str:
"args": {
"evaluation_id": evaluation_id,
},
"enabled": sco_permissions_check.can_edit_notes(
current_user, E["moduleimpl_id"]
),
"enabled": can_edit_notes_ens,
},
{
"title": "Absences ce jour",
"endpoint": "assiduites.get_etat_abs_date",
"endpoint": "assiduites.etat_abs_date",
"args": {
"group_ids": group_id,
"desc": E["description"],
"jour": E["jour"],
"heure_debut": E["heure_debut"],
"heure_fin": E["heure_fin"],
"desc": evaluation.description or "",
"date_debut": evaluation.date_debut.isoformat(),
"date_fin": evaluation.date_fin.isoformat(),
},
"enabled": E["jour"],
"enabled": evaluation.date_debut is not None,
},
{
"title": "Vérifier notes vs absents",
@ -154,7 +137,7 @@ def moduleimpl_evaluation_menu(evaluation_id, nbnotes=0) -> str:
"args": {
"evaluation_id": evaluation_id,
},
"enabled": nbnotes > 0 and E["jour"],
"enabled": nbnotes > 0 and evaluation.date_debut is not None,
},
]
@ -714,7 +697,7 @@ def _ligne_evaluation(
if can_edit_notes:
H.append(
moduleimpl_evaluation_menu(
evaluation.id,
evaluation,
nbnotes=etat["nb_notes"],
)
)

View File

@ -1315,7 +1315,7 @@ a.smallbutton {
}
span.evallink {
font-size: 80%;
margin-left: 16px;
font-weight: normal;
}

View File

@ -1,8 +1,8 @@
import datetime
from flask import g, request, render_template
from flask import abort, url_for
from flask_login import current_user
from app import db
from app.comp import res_sem
@ -25,14 +25,16 @@ from app.views import ScoData
# ---------------
from app.scodoc.sco_permissions import Permission
from app.scodoc import html_sco_header
from app.scodoc import safehtml
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_preferences
from app.scodoc import sco_groups_view
from app.scodoc import sco_etud
from app.scodoc import sco_find_etud
from flask_login import current_user
from app.scodoc import sco_utils as scu
from app.scodoc import sco_assiduites as scass
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoValueError
from app.tables.visu_assiduites import TableAssi, etuds_sorted_from_ids
@ -731,17 +733,23 @@ def visu_assiduites_group():
).build()
@bp.route("/EtatAbsencesDate")
@bp.route("/etat_abs_date")
@scodoc
@permission_required(Permission.ScoView)
def get_etat_abs_date():
infos_date = {
"jour": request.args.get("jour"),
"heure_debut": request.args.get("heure_debut"),
"heure_fin": request.args.get("heure_fin"),
"title": request.args.get("desc"),
}
def etat_abs_date():
"""date_debut, date_fin en ISO"""
date_debut_str = request.args.get("date_debut")
date_fin_str = request.args.get("date_fin")
title = request.args.get("desc")
group_ids: list[int] = request.args.get("group_ids", None)
try:
date_debut = datetime.datetime.fromisoformat(date_debut_str)
except ValueError as exc:
raise ScoValueError("date_debut invalide") from exc
try:
date_fin = datetime.datetime.fromisoformat(date_fin_str)
except ValueError as exc:
raise ScoValueError("date_fin invalide") from exc
if group_ids is None:
group_ids = []
else:
@ -754,14 +762,6 @@ def get_etat_abs_date():
sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0]
for m in groups_infos.members
]
date_debut = scu.is_iso_formated(
f"{infos_date['jour']}T{infos_date['heure_debut'].replace('h',':')}", True
)
date_fin = scu.is_iso_formated(
f"{infos_date['jour']}T{infos_date['heure_fin'].replace('h',':')}", True
)
assiduites: Assiduite = Assiduite.query.filter(
Assiduite.etudid.in_([e["etudid"] for e in etuds])
)
@ -791,7 +791,7 @@ def get_etat_abs_date():
etudiants = list(sorted(etudiants, key=lambda x: x["nom"]))
header: str = html_sco_header.sco_header(
page_title=infos_date["title"],
page_title=safehtml.html_to_safe_html(title),
init_qtip=True,
)

View File

@ -1656,30 +1656,37 @@ sco_publish(
@scodoc7func
def evaluation_delete(evaluation_id):
"""Form delete evaluation"""
El = sco_evaluation_db.get_evaluation_dict(args={"evaluation_id": evaluation_id})
if not El:
raise ScoValueError("Evaluation inexistante ! (%s)" % evaluation_id)
E = El[0]
M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0]
Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
tit = "Suppression de l'évaluation %(description)s (%(jour)s)" % E
etat = sco_evaluations.do_evaluation_etat(evaluation_id)
evaluation: Evaluation = (
Evaluation.query.filter_by(id=evaluation_id)
.join(ModuleImpl)
.join(FormSemestre)
.filter_by(dept_id=g.scodoc_dept_id)
.first_or_404()
)
tit = f"""Suppression de l'évaluation {evaluation.description or ""} ({evaluation.descr_date()})"""
etat = sco_evaluations.do_evaluation_etat(evaluation.id)
H = [
html_sco_header.html_sem_header(tit, with_h2=False),
"""<h2 class="formsemestre">Module <tt>%(code)s</tt> %(titre)s</h2>""" % Mod,
"""<h3>%s</h3>""" % tit,
"""<p class="help">Opération <span class="redboldtext">irréversible</span>. Si vous supprimez l'évaluation, vous ne pourrez pas retrouver les notes associées.</p>""",
f"""
{html_sco_header.html_sem_header(tit, with_h2=False)}
<h2 class="formsemestre">Module <tt>{evaluation.moduleimpl.module.code}</tt>
{evaluation.moduleimpl.module.titre_str()}</h2>
<h3>{tit}</h3>
<p class="help">Opération <span class="redboldtext">irréversible</span>.
Si vous supprimez l'évaluation, vous ne pourrez pas retrouver les notes associées.
</p>
""",
]
warning = False
if etat["nb_notes_total"]:
warning = True
nb_desinscrits = etat["nb_notes_total"] - etat["nb_notes"]
H.append(
"""<div class="ue_warning"><span>Il y a %s notes""" % etat["nb_notes_total"]
f"""<div class="ue_warning"><span>Il y a {etat["nb_notes_total"]} notes"""
)
if nb_desinscrits:
H.append(
""" (dont %s d'étudiants qui ne sont plus inscrits)""" % nb_desinscrits
""" (dont {nb_desinscrits} d'étudiants qui ne sont plus inscrits)"""
)
H.append(""" dans l'évaluation</span>""")
if etat["nb_notes"] == 0:
@ -1689,8 +1696,13 @@ def evaluation_delete(evaluation_id):
if etat["nb_notes"]:
H.append(
"""<p>Suppression impossible (effacer les notes d'abord)</p><p><a class="stdlink" href="moduleimpl_status?moduleimpl_id=%s">retour au tableau de bord du module</a></p></div>"""
% E["moduleimpl_id"]
f"""<p>Suppression impossible (effacer les notes d'abord)</p>
<p><a class="stdlink" href="{
url_for("notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept, moduleimpl_id=evaluation.moduleimpl_id)
}">retour au tableau de bord du module</a>
</p>
</div>"""
)
return "\n".join(H) + html_sco_header.sco_footer()
if warning:
@ -1700,7 +1712,7 @@ def evaluation_delete(evaluation_id):
request.base_url,
scu.get_request_args(),
(("evaluation_id", {"input_type": "hidden"}),),
initvalues=E,
initvalues={"evaluation_id": evaluation.id},
submitlabel="Confirmer la suppression",
cancelbutton="Annuler",
)
@ -1711,17 +1723,17 @@ def evaluation_delete(evaluation_id):
url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=E["moduleimpl_id"],
moduleimpl_id=evaluation.moduleimpl_id,
)
)
else:
sco_evaluation_db.do_evaluation_delete(E["evaluation_id"])
evaluation.delete()
return (
"\n".join(H)
+ f"""<p>OK, évaluation supprimée.</p>
<p><a class="stdlink" href="{
url_for("notes.moduleimpl_status", scodoc_dept=g.scodoc_dept,
moduleimpl_id=E["moduleimpl_id"])
moduleimpl_id=evaluation.moduleimpl_id)
}">Continuer</a></p>"""
+ html_sco_header.sco_footer()
)

View File

@ -1,7 +1,7 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
SCOVERSION = "9.6.15"
SCOVERSION = "9.6.16"
SCONAME = "ScoDoc"

View File

@ -6,10 +6,9 @@ import app
from app import db
from app.comp import res_sem
from app.comp.res_but import ResultatsSemestreBUT
from app.models import FormSemestre, ModuleImpl
from app.models import Evaluation, FormSemestre, ModuleImpl
from app.scodoc import (
sco_bulletins,
sco_evaluation_db,
sco_formsemestre,
sco_saisie_notes,
)
@ -131,7 +130,9 @@ def test_notes_rattrapage(test_client):
# Note moyenne: reviens à 10/20
assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(10.0)
# Supprime l'évaluation de rattrapage:
sco_evaluation_db.do_evaluation_delete(e_rat["id"])
evaluation = db.session.get(Evaluation, e_rat["id"])
assert evaluation
evaluation.delete()
b = sco_bulletins.formsemestre_bulletinetud_dict(
sem["formsemestre_id"], etud["etudid"]
)