This commit is contained in:
IDK 2021-10-11 16:26:22 +02:00
commit aed2d6ce10
49 changed files with 726 additions and 278 deletions

View File

@ -106,13 +106,15 @@ Ou avec couverture (`pip install pytest-cov`)
#### Utilisation des tests unitaires pour initialiser la base de dev
On peut aussi utiliser les tests unitaires pour mettre la base
de données de développement dans un état connu, par exemple pour éviter de recréer à la main étudianst et semestres quand on développe.
de données de développement dans un état connu, par exemple pour éviter de
recréer à la main étudiants et semestres quand on développe.
Il suffit de positionner une variable d'environnement indiquant la BD utilisée par les tests:
export SCODOC_TEST_DATABASE_URI=postgresql:///SCODOC_DEV
puis de les lancer normalement, par exemple:
(si elle n'existe pas, voir plus loin pour la créer) puis de les lancer
normalement, par exemple:
pytest tests/unit/test_sco_basic.py
@ -133,7 +135,8 @@ On utilise SQLAlchemy avec Alembic et Flask-Migrate.
Ne pas oublier de commiter les migrations (`git add migrations` ...).
Mémo pour développeurs: séquence re-création d'une base:
Mémo pour développeurs: séquence re-création d'une base (vérifiez votre `.env`
ou variables d'environnement pour interroger la bonne base !).
dropdb SCODOC_DEV
tools/create_database.sh SCODOC_DEV # créé base SQL
@ -148,7 +151,25 @@ Si la base utilisée pour les dev n'est plus en phase avec les scripts de
migration, utiliser les commandes `flask db history`et `flask db stamp`pour se
positionner à la bonne étape.
# Paquet debian 11
### Profiling
Sur une machine de DEV, lancer
flask profile --host 0.0.0.0 --length 32 --profile-dir /opt/scodoc-data
le fichier `.prof` sera alors écrit dans `/opt/scoidoc-data` (on peut aussi utiliser `/tmp`).
Pour la visualisation, [snakeviz](https://jiffyclub.github.io/snakeviz/) est bien:
pip install snakeviz
puis
snakeviz -s --hostname 0.0.0.0 -p 5555 /opt/scodoc-data/GET.ScoDoc......prof
# Paquet Debian 11
Les scripts associés au paquet Debian (.deb) sont dans `tools/debian`. Le plus
important est `postinst`qui se charge de configurer le système (install ou

View File

@ -1,6 +1,7 @@
# -*- coding: UTF-8 -*
# pylint: disable=invalid-name
import datetime
import os
import socket
import sys
@ -24,7 +25,12 @@ from flask_moment import Moment
from flask_caching import Cache
import sqlalchemy
from app.scodoc.sco_exceptions import ScoGenError, ScoValueError, APIInvalidParams
from app.scodoc.sco_exceptions import (
AccessDenied,
ScoGenError,
ScoValueError,
APIInvalidParams,
)
from config import DevConfig
import sco_version
@ -50,10 +56,21 @@ def handle_sco_value_error(exc):
return render_template("sco_value_error.html", exc=exc), 404
def handle_access_denied(exc):
return render_template("error_access_denied.html", exc=exc), 403
def internal_server_error(e):
"""Bugs scodoc, erreurs 500"""
# note that we set the 500 status explicitly
return render_template("error_500.html", SCOVERSION=sco_version.SCOVERSION), 500
return (
render_template(
"error_500.html",
SCOVERSION=sco_version.SCOVERSION,
date=datetime.datetime.now().isoformat(),
),
500,
)
def handle_invalid_usage(error):
@ -93,6 +110,10 @@ class LogRequestFormatter(logging.Formatter):
record.url = None
record.remote_addr = None
record.sco_user = current_user
if has_request_context():
record.sco_admin_mail = current_app.config["SCODOC_ADMIN_MAIL"]
else:
record.sco_admin_mail = "(pas de requête)"
return super().format(record)
@ -121,6 +142,10 @@ class LogExceptionFormatter(logging.Formatter):
record.http_params = None
record.sco_user = current_user
if has_request_context():
record.sco_admin_mail = current_app.config["SCODOC_ADMIN_MAIL"]
else:
record.sco_admin_mail = "(pas de requête)"
return super().format(record)
@ -165,6 +190,7 @@ def create_app(config_class=DevConfig):
app.register_error_handler(ScoGenError, handle_sco_value_error)
app.register_error_handler(ScoValueError, handle_sco_value_error)
app.register_error_handler(AccessDenied, handle_access_denied)
app.register_error_handler(500, internal_server_error)
app.register_error_handler(503, postgresql_server_error)
app.register_error_handler(APIInvalidParams, handle_invalid_usage)
@ -197,12 +223,14 @@ def create_app(config_class=DevConfig):
"[%(asctime)s] %(sco_user)s@%(remote_addr)s requested %(url)s\n"
"%(levelname)s: %(message)s"
)
# les champs additionnels sont définis dans LogRequestFormatter
scodoc_exc_formatter = LogExceptionFormatter(
"[%(asctime)s] %(sco_user)s@%(remote_addr)s requested %(url)s\n"
"%(levelname)s: %(message)s\n"
"Referrer: %(http_referrer)s\n"
"Method: %(http_method)s\n"
"Params: %(http_params)s\n"
"Admin mail: %(sco_admin_mail)s\n"
)
if not app.testing:
if not app.debug:
@ -259,15 +287,19 @@ def create_app(config_class=DevConfig):
)
# ---- INITIALISATION SPECIFIQUES A SCODOC
from app.scodoc import sco_bulletins_generator
from app.scodoc.sco_bulletins_example import BulletinGeneratorExample
from app.scodoc.sco_bulletins_legacy import BulletinGeneratorLegacy
from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
from app.scodoc.sco_bulletins_ucac import BulletinGeneratorUCAC
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorExample)
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorLegacy)
# l'ordre est important, le premeir sera le "défaut" pour les nouveaux départements.
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorStandard)
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorLegacy)
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorUCAC)
if app.testing or app.debug:
from app.scodoc.sco_bulletins_example import BulletinGeneratorExample
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorExample)
return app

View File

@ -422,7 +422,7 @@ class UserRole(db.Model):
def get_super_admin():
"""L'utilisateur admin (où le premier, s'il y en a plusieurs).
"""L'utilisateur admin (ou le premier, s'il y en a plusieurs).
Utilisé par les tests unitaires et le script de migration.
"""
admin_role = Role.query.filter_by(name="SuperAdmin").first()

View File

@ -273,6 +273,7 @@ class NotesModuleImplInscription(db.Model):
"""Inscription à un module (etudiants,moduleimpl)"""
__tablename__ = "notes_moduleimpl_inscription"
__table_args__ = (db.UniqueConstraint("moduleimpl_id", "etudid"),)
id = db.Column(db.Integer, primary_key=True)
moduleimpl_inscription_id = db.synonym("id")

View File

@ -40,7 +40,7 @@ from app.scodoc.sco_permissions import Permission
def sidebar_common():
"partie commune à toutes les sidebar"
H = [
f"""<a class="scodoc_title" href="about">ScoDoc 9</a>
f"""<a class="scodoc_title" href="{url_for("scodoc.about", scodoc_dept=g.scodoc_dept)}">ScoDoc 9</a>
<div id="authuser"><a id="authuserlink" href="{
url_for("users.user_info_page",
scodoc_dept=g.scodoc_dept, user_name=current_user.user_name)

View File

@ -1265,7 +1265,7 @@ class NotesTable(object):
),
self.get_nom_long(etudid),
url_for(
"scolar.formsemestre_edit_uecoefs",
"notes.formsemestre_edit_uecoefs",
scodoc_dept=g.scodoc_dept,
formsemestre_id=self.formsemestre_id,
err_ue_id=ue["ue_id"],

View File

@ -136,7 +136,8 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
prefs = sco_preferences.SemPreferences(formsemestre_id)
nt = sco_cache.NotesTableCache.get(formsemestre_id) # > toutes notes
if not nt.get_etud_etat(etudid):
raise ScoValueError("Etudiant non inscrit à ce semestre")
I = scu.DictDefault(defaultvalue="")
I["etudid"] = etudid
I["formsemestre_id"] = formsemestre_id
@ -774,8 +775,8 @@ def formsemestre_bulletinetud(
except:
sco_etud.log_unknown_etud()
raise ScoValueError("étudiant inconnu")
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
# API, donc erreurs admises en ScoValueError
sem = sco_formsemestre.get_formsemestre(formsemestre_id, raise_soft_exc=True)
bulletin = do_formsemestre_bulletinetud(
formsemestre_id,

View File

@ -348,7 +348,7 @@ def do_moduleimpl_moyennes(nt, mod):
if etudid in eval_rattr["notes"]:
note = eval_rattr["notes"][etudid]["value"]
if note != None and note != NOTES_NEUTRALISE and note != NOTES_ATTENTE:
if isinstance(R[etudid], float):
if not isinstance(R[etudid], float):
R[etudid] = note
else:
note_sur_20 = note * 20.0 / eval_rattr["note_max"]

View File

@ -48,9 +48,19 @@ import sco_version
def report_debouche_date(start_year=None, format="html"):
"""Rapport (table) pour les débouchés des étudiants sortis à partir de l'année indiquée."""
"""Rapport (table) pour les débouchés des étudiants sortis
à partir de l'année indiquée.
"""
if not start_year:
return report_debouche_ask_date()
return report_debouche_ask_date("Année de début de la recherche")
else:
try:
start_year = int(start_year)
except ValueError:
return report_debouche_ask_date(
"Année invalide. Année de début de la recherche"
)
if format == "xls":
keep_numeric = True # pas de conversion des notes en strings
else:
@ -96,8 +106,9 @@ def get_etudids_with_debouche(start_year):
FROM notes_formsemestre_inscription i, notes_formsemestre s, itemsuivi it
WHERE i.etudid = it.etudid
AND i.formsemestre_id = s.id AND s.date_fin >= %(start_date)s
AND s.dept_id = %(dept_id)s
""",
{"start_date": start_date},
{"start_date": start_date, "dept_id": g.scodoc_dept_id},
)
return [x["etudid"] for x in r]
@ -193,15 +204,16 @@ def table_debouche_etudids(etudids, keep_numeric=True):
return tab
def report_debouche_ask_date():
def report_debouche_ask_date(msg: str) -> str:
"""Formulaire demande date départ"""
return (
html_sco_header.sco_header()
+ """<form method="GET">
Date de départ de la recherche: <input type="text" name="start_year" value="" size=10/>
</form>"""
+ html_sco_header.sco_footer()
)
return f"""{html_sco_header.sco_header()}
<h2>Table des débouchés des étudiants</h2>
<form method="GET">
{msg}
<input type="text" name="start_year" value="" size=10/>
</form>
{html_sco_header.sco_footer()}
"""
# ----------------------------------------------------------------------------

View File

@ -48,6 +48,7 @@ from app.scodoc import sco_users
def index_html(showcodes=0, showsemtable=0):
"Page accueil département (liste des semestres)"
showcodes = int(showcodes)
showsemtable = int(showsemtable)
H = []
@ -78,7 +79,7 @@ def index_html(showcodes=0, showsemtable=0):
# Responsable de formation:
sco_formsemestre.sem_set_responsable_name(sem)
if showcodes == "1":
if showcodes:
sem["tmpcode"] = "<td><tt>%s</tt></td>" % sem["formsemestre_id"]
else:
sem["tmpcode"] = ""
@ -126,7 +127,7 @@ def index_html(showcodes=0, showsemtable=0):
"""
% sco_preferences.get_preference("DeptName")
)
H.append(_sem_table_gt(sems).html())
H.append(_sem_table_gt(sems, showcodes=showcodes).html())
H.append("</table>")
if not showsemtable:
H.append(

View File

@ -845,6 +845,7 @@ def ue_sharing_code(ue_code=None, ue_id=None, hide_ue_id=None):
"""
from app.scodoc import sco_formations
ue_code = str(ue_code)
if ue_id:
ue = do_ue_list(args={"ue_id": ue_id})[0]
if not ue_code:

View File

@ -640,7 +640,7 @@ def view_apo_csv_delete(etape_apo="", semset_id="", dialog_confirmed=False):
if not semset_id:
raise ValueError("invalid null semset_id")
semset = sco_semset.SemSet(semset_id=semset_id)
dest_url = "apo_semset_maq_status?semset_id=" + semset_id
dest_url = f"apo_semset_maq_status?semset_id={semset_id}"
if not dialog_confirmed:
return scu.confirm_dialog(
"""<h2>Confirmer la suppression du fichier étape <tt>%s</tt>?</h2>

View File

@ -655,7 +655,7 @@ def log_unknown_etud():
def get_etud_info(etudid=False, code_nip=False, filled=False) -> list:
"""infos sur un etudiant (API). If not foud, returns empty list.
"""infos sur un etudiant (API). If not found, returns empty list.
On peut specifier etudid ou code_nip
ou bien cherche dans les argumenst de la requête courante:
etudid, code_nip, code_ine (dans cet ordre).
@ -671,6 +671,19 @@ def get_etud_info(etudid=False, code_nip=False, filled=False) -> list:
return etud
# Optim par cache local, utilité non prouvée mais
# on s'oriente vers un cahce persistent dans Redis ou bien l'utilisation de NT
# def get_etud_info_filled_by_etudid(etudid, cnx=None) -> dict:
# """Infos sur un étudiant, avec cache local à la requête"""
# if etudid in g.stored_etud_info:
# return g.stored_etud_info[etudid]
# cnx = cnx or ndb.GetDBConnexion()
# etud = etudident_list(cnx, args={"etudid": etudid})
# fill_etuds_info(etud)
# g.stored_etud_info[etudid] = etud[0]
# return etud[0]
def create_etud(cnx, args={}):
"""Creation d'un étudiant. génère aussi évenement et "news".

View File

@ -35,7 +35,7 @@ from enum import Enum
from tempfile import NamedTemporaryFile
import openpyxl.utils.datetime
from openpyxl.styles.numbers import FORMAT_NUMBER_00, FORMAT_GENERAL
from openpyxl.styles.numbers import FORMAT_NUMBER_00, FORMAT_GENERAL, FORMAT_DATE_DDMMYY
from openpyxl.comments import Comment
from openpyxl import Workbook, load_workbook
from openpyxl.cell import WriteOnlyCell
@ -65,10 +65,16 @@ class COLORS(Enum):
def xldate_as_datetime(xldate, datemode=0):
"""Conversion d'une date Excel en date
"""Conversion d'une date Excel en datetime python
Deux formats de chaîne acceptés:
* JJ/MM/YYYY (chaîne naïve)
* Date ISO (valeur de type date lue dans la feuille)
Peut lever une ValueError
"""
return openpyxl.utils.datetime.from_ISO8601(xldate)
try:
return datetime.datetime.strptime(xldate, "%d/%m/%Y")
except:
return openpyxl.utils.datetime.from_ISO8601(xldate)
def adjust_sheetname(sheet_name):
@ -283,10 +289,6 @@ class ScoExcelSheet:
style -- style par défaut (dictionnaire cf. excel_make_style) de la feuille si non spécifié
"""
cell = WriteOnlyCell(self.ws, value or "")
if not (isinstance(value, int) or isinstance(value, float)):
cell.data_type = "s"
# if style is not None and "fill" in style:
# toto()
if style is None:
style = self.default_style
if "font" in style:
@ -308,6 +310,14 @@ class ScoExcelSheet:
lines = comment.splitlines()
cell.comment.width = 7 * max([len(line) for line in lines])
cell.comment.height = 20 * len(lines)
# test datatype at the end so that datetime format may be overwritten
if isinstance(value, datetime.date):
cell.data_type = "d"
cell.number_format = FORMAT_DATE_DDMMYY
elif isinstance(value, int) or isinstance(value, float):
cell.data_type = "n"
else:
cell.data_type = "s"
return cell
def make_row(self, values: list, style=None, comments=None):
@ -568,10 +578,9 @@ def excel_bytes_to_list(bytes_content):
return _excel_to_list(filelike)
except:
raise ScoValueError(
"""Le fichier xlsx attendu n'est pas lisible !
Peut-être avez-vous fourni un fichier au mauvais format (txt, xls, ..)
"""
scolars_import_excel_file: un contenu xlsx semble corrompu!
peut-être avez vous fourni un fichier au mauvais format (txt, xls, ..)
"""
)
@ -580,10 +589,9 @@ def excel_file_to_list(filename):
return _excel_to_list(filename)
except:
raise ScoValueError(
"""scolars_import_excel_file: un contenu xlsx
semble corrompu !
Peut-être avez-vous fourni un fichier au mauvais format (txt, xls, ...)
"""
"""Le fichier xlsx attendu n'est pas lisible !
Peut-être avez-vous fourni un fichier au mauvais format (txt, xls, ...)
"""
)

View File

@ -45,10 +45,6 @@ class InvalidEtudId(NoteProcessError):
pass
class AccessDenied(ScoException):
pass
class InvalidNoteValue(ScoException):
pass
@ -92,6 +88,10 @@ class ScoGenError(ScoException):
ScoException.__init__(self, msg)
class AccessDenied(ScoGenError):
pass
class ScoInvalidDateError(ScoValueError):
pass

View File

@ -93,16 +93,21 @@ _formsemestreEditor = ndb.EditableTable(
)
def get_formsemestre(formsemestre_id):
def get_formsemestre(formsemestre_id, raise_soft_exc=False):
"list ONE formsemestre"
if formsemestre_id in g.stored_get_formsemestre:
return g.stored_get_formsemestre[formsemestre_id]
if not isinstance(formsemestre_id, int):
raise ValueError("formsemestre_id must be an integer !")
try:
sem = do_formsemestre_list(args={"formsemestre_id": formsemestre_id})[0]
return sem
except:
sems = do_formsemestre_list(args={"formsemestre_id": formsemestre_id})
if not sems:
log("get_formsemestre: invalid formsemestre_id (%s)" % formsemestre_id)
raise
if raise_soft_exc:
raise ScoValueError(f"semestre {formsemestre_id} inconnu !")
else:
raise ValueError(f"semestre {formsemestre_id} inconnu !")
g.stored_get_formsemestre[formsemestre_id] = sems[0]
return sems[0]
def do_formsemestre_list(*a, **kw):

View File

@ -337,7 +337,7 @@ def formsemestre_status_menubar(sem):
submenu.append(
{
"title": "%s" % partition["partition_name"],
"endpoint": "scolar.affectGroups",
"endpoint": "scolar.affect_groups",
"args": {"partition_id": partition["partition_id"]},
"enabled": enabled,
}
@ -505,15 +505,29 @@ def formsemestre_page_title():
fill_formsemestre(sem)
H = [
"""<div class="formsemestre_page_title">""",
"""<div class="infos">
<span class="semtitle"><a class="stdlink" title="%(session_id)s" href="%(notes_url)s/formsemestre_status?formsemestre_id=%(formsemestre_id)s">%(titre)s</a><a title="%(etape_apo_str)s">%(num_sem)s</a>%(modalitestr)s</span><span class="dates"><a title="du %(date_debut)s au %(date_fin)s ">%(mois_debut)s - %(mois_fin)s</a></span><span class="resp"><a title="%(nomcomplet)s">%(resp)s</a></span><span class="nbinscrits"><a class="discretelink" href="%(notes_url)s/formsemestre_lists?formsemestre_id=%(formsemestre_id)s">%(nbinscrits)d inscrits</a></span><span class="lock">%(locklink)s</span><span class="eye">%(eyelink)s</span></div>"""
% sem,
formsemestre_status_menubar(sem),
"""</div>""",
]
return "\n".join(H)
h = f"""<div class="formsemestre_page_title">
<div class="infos">
<span class="semtitle"><a class="stdlink" title="{sem['session_id']}"
href="{url_for('notes.formsemestre_status',
scodoc_dept=g.scodoc_dept, formsemestre_id=sem['formsemestre_id'])}"
>{sem['titre']}</a><a
title="{sem['etape_apo_str']}">{sem['num_sem']}</a>{sem['modalitestr']}</span><span
class="dates"><a
title="du {sem['date_debut']} au {sem['date_fin']} "
>{sem['mois_debut']} - {sem['mois_fin']}</a></span><span
class="resp"><a title="{sem['nomcomplet']}">{sem['resp']}</a></span><span
class="nbinscrits"><a class="discretelink"
href="{url_for("scolar.groups_view",
scodoc_dept=g.scodoc_dept, formsemestre_id=sem['formsemestre_id'])}"
>{sem['nbinscrits']} inscrits</a></span><span
class="lock">{sem['locklink']}</span><span
class="eye">{sem['eyelink']}</span>
</div>
{formsemestre_status_menubar(sem)}
</div>
"""
return h
def fill_formsemestre(sem):
@ -843,7 +857,7 @@ def _make_listes_sem(sem, with_absences=True):
H.append('<p class="help indent">Aucun groupe dans cette partition')
if sco_groups.sco_permissions_check.can_change_groups(formsemestre_id):
H.append(
f""" (<a href="{url_for("scolar.affectGroups",
f""" (<a href="{url_for("scolar.affect_groups",
scodoc_dept=g.scodoc_dept,
partition_id=partition["partition_id"])
}" class="stdlink">créer</a>)"""
@ -967,7 +981,7 @@ def formsemestre_status(formsemestre_id=None):
"""Tableau de bord semestre HTML"""
# porté du DTML
cnx = ndb.GetDBConnexion()
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
sem = sco_formsemestre.get_formsemestre(formsemestre_id, raise_soft_exc=True)
Mlist = sco_moduleimpl.do_moduleimpl_withmodule_list(
formsemestre_id=formsemestre_id
)

View File

@ -492,6 +492,8 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
"""
from app.scodoc import sco_formsemestre
cnx = ndb.GetDBConnexion()
t0 = time.time()
partition = get_partition(partition_id)
formsemestre_id = partition["formsemestre_id"]
@ -500,6 +502,7 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
nt = sco_cache.NotesTableCache.get(formsemestre_id) # > inscrdict
etuds_set = set(nt.inscrdict)
# Build XML:
t1 = time.time()
doc = Element("ajax-response")
x_response = Element("response", type="object", id="MyUpdater")
doc.append(x_response)
@ -513,7 +516,8 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
)
x_response.append(x_group)
for e in get_group_members(group["group_id"]):
etud = sco_etud.get_etud_info(etudid=e["etudid"], filled=1)[0]
etud = sco_etud.get_etud_info(etudid=e["etudid"], filled=True)[0]
# etud = sco_etud.get_etud_info_filled_by_etudid(e["etudid"], cnx)
x_group.append(
Element(
"etud",
@ -540,6 +544,7 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
doc.append(x_group)
for etudid in etuds_set:
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
# etud = sco_etud.get_etud_info_filled_by_etudid(etudid, cnx)
x_group.append(
Element(
"etud",
@ -550,7 +555,8 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
origin=comp_origin(etud, sem),
)
)
log("XMLgetGroupsInPartition: %s seconds" % (time.time() - t0))
t2 = time.time()
log(f"XMLgetGroupsInPartition: {t2-t0} seconds ({t1-t0}+{t2-t1})")
# XML response:
data = sco_xml.XML_HEADER + ElementTree.tostring(doc).decode(scu.SCO_ENCODING)
response = make_response(data)
@ -911,7 +917,7 @@ def editPartitionForm(formsemestre_id=None):
H.append(", ".join(lg))
H.append(
f"""</td><td><a class="stdlink" href="{
url_for("scolar.affectGroups",
url_for("scolar.affect_groups",
scodoc_dept=g.scodoc_dept,
partition_id=p["partition_id"])
}">répartir</a></td>
@ -1173,7 +1179,7 @@ def group_set_name(group_id, group_name, redirect=1):
if redirect:
return flask.redirect(
url_for(
"scolar.affectGroups",
"scolar.affect_groups",
scodoc_dept=g.scodoc_dept,
partition_id=group["partition_id"],
)
@ -1216,7 +1222,7 @@ def group_rename(group_id):
elif tf[0] == -1:
return flask.redirect(
url_for(
"scolar.affectGroups",
"scolar.affect_groups",
scodoc_dept=g.scodoc_dept,
partition_id=group["partition_id"],
)
@ -1236,7 +1242,7 @@ def groups_auto_repartition(partition_id=None):
formsemestre_id = partition["formsemestre_id"]
# renvoie sur page édition groupes
dest_url = url_for(
"scolar.affectGroups", scodoc_dept=g.scodoc_dept, partition_id=partition_id
"scolar.affect_groups", scodoc_dept=g.scodoc_dept, partition_id=partition_id
)
if not sco_permissions_check.can_change_groups(formsemestre_id):
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")

View File

@ -27,70 +27,33 @@
"""Formulaires gestion des groupes
"""
from flask import render_template
from app.scodoc import html_sco_header
from app.scodoc import sco_groups
from app.scodoc.sco_exceptions import AccessDenied
def affectGroups(partition_id):
def affect_groups(partition_id):
"""Formulaire affectation des etudiants aux groupes de la partition.
Permet aussi la creation et la suppression de groupes.
"""
# Ported from DTML and adapted to new group management (nov 2009)
# réécrit pour 9.0.47 avec un template
partition = sco_groups.get_partition(partition_id)
formsemestre_id = partition["formsemestre_id"]
if not sco_groups.sco_permissions_check.can_change_groups(formsemestre_id):
raise AccessDenied("vous n'avez pas la permission d'effectuer cette opération")
H = [
html_sco_header.sco_header(
return render_template(
"scolar/affect_groups.html",
sco_header=html_sco_header.sco_header(
page_title="Affectation aux groupes",
javascripts=["js/groupmgr.js"],
cssstyles=["css/groups.css"],
),
"""<h2 class="formsemestre">Affectation aux groupes de %s</h2><form id="sp">"""
% partition["partition_name"],
]
H += [
"""</select></form>""",
"""<p>Faites glisser les étudiants d'un groupe à l'autre. Les modifications ne sont enregistrées que lorsque vous cliquez sur le bouton "<em>Enregistrer ces groupes</em>". Vous pouvez créer de nouveaux groupes. Pour <em>supprimer</em> un groupe, utiliser le lien "suppr." en haut à droite de sa boite. Vous pouvez aussi <a class="stdlink" href="groups_auto_repartition?partition_id=%(partition_id)s">répartir automatiquement les groupes</a>.
</p>"""
% partition,
"""<div id="gmsg" class="head_message"></div>""",
"""<div id="ginfo"></div>""",
"""<div id="savedinfo"></div>""",
"""<form name="formGroup" id="formGroup" onSubmit="return false;">""",
"""<input type="hidden" name="partition_id" value="%s"/>""" % partition_id,
"""<input name="groupName" size="6"/>
<input type="button" onClick="createGroup();" value="Créer groupe"/>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<input type="button" onClick="submitGroups( target='gmsg' );" value="Enregistrer ces groupes" />
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<input type="button" onClick="document.location = 'formsemestre_status?formsemestre_id=%s'" value="Annuler" />&nbsp;&nbsp;&nbsp;&nbsp;
Editer groupes de
<select name="other_partition_id" onchange="GotoAnother();">"""
% formsemestre_id,
]
for p in sco_groups.get_partitions_list(formsemestre_id, with_default=False):
H.append('<option value="%s"' % p["partition_id"])
if p["partition_id"] == partition_id:
H.append(" selected")
H.append(">%s</option>" % p["partition_name"])
H += [
"""</select>
</form>
<div id="groups">
</div>
<div style="clear: left; margin-top: 15px;">
<p class="help"></p>
</div>
</div>
""",
html_sco_header.sco_footer(),
]
return "\n".join(H)
sco_footer=html_sco_header.sco_footer(),
partition=partition,
partitions_list=sco_groups.get_partitions_list(
formsemestre_id, with_default=False
),
formsemestre_id=formsemestre_id,
)

View File

@ -489,7 +489,7 @@ def groups_table(
columns_ids += ["etape", "etudid", "code_nip", "code_ine"]
if with_paiement:
columns_ids += ["datefinalisationinscription_str", "paiementinscription_str"]
if with_paiement or with_codes:
if with_paiement: # or with_codes:
sco_portal_apogee.check_paiement_etuds(groups_infos.members)
if with_archives:
from app.scodoc import sco_archives_etud

View File

@ -25,16 +25,16 @@
#
##############################################################################
""" Importation des etudiants à partir de fichiers CSV
""" Importation des étudiants à partir de fichiers CSV
"""
import collections
import io
import os
import re
import time
from datetime import date
import flask
from flask import g, url_for
import app.scodoc.sco_utils as scu
@ -252,7 +252,7 @@ def students_import_excel(
def scolars_import_excel_file(
datafile,
datafile: io.BytesIO,
formsemestre_id=None,
check_homonyms=True,
require_ine=False,
@ -414,16 +414,14 @@ def scolars_import_excel_file(
if NbHomonyms:
NbImportedHomonyms += 1
# Insert in DB tables
formsemestre_to_invalidate.add(
_import_one_student(
cnx,
formsemestre_id,
values,
GroupIdInferers,
annee_courante,
created_etudids,
linenum,
)
formsemestre_id_etud = _import_one_student(
cnx,
formsemestre_id,
values,
GroupIdInferers,
annee_courante,
created_etudids,
linenum,
)
# Verification proportion d'homonymes: si > 10%, abandonne
@ -522,7 +520,7 @@ def _import_one_student(
annee_courante,
created_etudids,
linenum,
):
) -> int:
"""
Import d'un étudiant et inscription dans le semestre.
Return: id du semestre dans lequel il a été inscrit.
@ -550,6 +548,12 @@ def _import_one_student(
else:
args["formsemestre_id"] = values["codesemestre"]
formsemestre_id = values["codesemestre"]
try:
formsemestre_id = int(formsemestre_id)
except ValueError as exc:
raise ScoValueError(
f"valeur invalide dans la colonne codesemestre, ligne {linenum+1}"
) from exc
# recupere liste des groupes:
if formsemestre_id not in GroupIdInferers:
GroupIdInferers[formsemestre_id] = sco_groups.GroupIdInferer(formsemestre_id)
@ -566,7 +570,7 @@ def _import_one_student(
)
do_formsemestre_inscription_with_modules(
args["formsemestre_id"],
int(args["formsemestre_id"]),
etudid,
group_ids,
etat="I",

View File

@ -109,8 +109,11 @@ def import_excel_file(datafile):
if not exceldata:
raise ScoValueError("Ficher excel vide ou invalide")
_, data = sco_excel.excel_bytes_to_list(exceldata)
if not data: # probably a bug
raise ScoException("import_excel_file: empty file !")
if not data:
raise ScoValueError(
"""Le fichier xlsx attendu semble vide !
"""
)
# 1- --- check title line
fs = [scu.stripquotes(s).lower() for s in data[0]]
log("excel: fs='%s'\ndata=%s" % (str(fs), str(data)))
@ -179,11 +182,13 @@ def import_users(users):
line = line + 1
user_ok, msg = sco_users.check_modif_user(
0,
ignore_optionals=False,
user_name=u["user_name"],
nom=u["nom"],
prenom=u["prenom"],
email=u["email"],
roles=u["roles"].split(","),
dept=u["dept"],
)
if not user_ok:
append_msg("identifiant '%s' %s" % (u["user_name"], msg))
@ -193,39 +198,12 @@ def import_users(users):
u["passwd"] = generate_password()
#
# check identifiant
if not re.match(r"^[a-zA-Z0-9@\\\-_\\\.]*$", u["user_name"]):
user_ok = False
append_msg(
"identifiant '%s' invalide (pas d'accents ni de caractères spéciaux)"
% u["user_name"]
)
if len(u["user_name"]) > 64:
user_ok = False
append_msg(
"identifiant '%s' trop long (64 caractères)" % u["user_name"]
)
if len(u["nom"]) > 64:
user_ok = False
append_msg("nom '%s' trop long (64 caractères)" % u["nom"])
if len(u["prenom"]) > 64:
user_ok = False
append_msg("prenom '%s' trop long (64 caractères)" % u["prenom"])
if len(u["email"]) > 120:
user_ok = False
append_msg("email '%s' trop long (120 caractères)" % u["email"])
# check that tha same user_name has not already been described in this import
if u["user_name"] in created.keys():
user_ok = False
append_msg(
"l'utilisateur '%s' a déjà été décrit ligne %s"
% (u["user_name"], created[u["user_name"]]["line"])
)
# check département
if u["dept"] != "":
dept = Departement.query.filter_by(acronym=u["dept"]).first()
if dept is None:
user_ok = False
append_msg("département '%s' inexistant" % u["dept"])
# check roles / ignore whitespaces around roles / build roles_string
# roles_string (expected by User) appears as column 'roles' in excel file
roles_list = []

View File

@ -390,7 +390,7 @@ def formsemestre_inscr_passage(
): # il y a au moins une vraie partition
H.append(
f"""<li><a class="stdlink" href="{
url_for("scolar.affectGroups",
url_for("scolar.affect_groups",
scodoc_dept=g.scodoc_dept, partition_id=partition["partition_id"])
}">Répartir les groupes de {partition["partition_name"]}</a></li>
"""

View File

@ -315,7 +315,7 @@ def _make_table_notes(
rows.append(
{
"code": code,
"code": str(code), # INE, NIP ou etudid
"_code_td_attrs": 'style="padding-left: 1em; padding-right: 2em;"',
"etudid": etudid,
"nom": etud["nom"].upper(),
@ -374,9 +374,11 @@ def _make_table_notes(
columns_ids.append(e["evaluation_id"])
#
if anonymous_listing:
rows.sort(key=lambda x: x["code"])
rows.sort(key=lambda x: x["code"] or "")
else:
rows.sort(key=lambda x: (x["nom"], x["prenom"])) # sort by nom, prenom
rows.sort(
key=lambda x: (x["nom"] or "", x["prenom"] or "")
) # sort by nom, prenom
# Si module, ajoute moyenne du module:
if len(evals) > 1:

View File

@ -527,15 +527,15 @@ def do_etud_desinscrit_ue(etudid, formsemestre_id, ue_id):
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
cursor.execute(
"""DELETE FROM notes_moduleimpl_inscription
WHERE moduleimpl_inscription_id IN (
SELECT i.moduleimpl_inscription_id FROM
WHERE id IN (
SELECT i.id FROM
notes_moduleimpl mi, notes_modules mod,
notes_formsemestre sem, notes_moduleimpl_inscription i
WHERE sem.formsemestre_id = %(formsemestre_id)s
AND mi.formsemestre_id = sem.formsemestre_id
AND mod.module_id = mi.module_id
WHERE sem.id = %(formsemestre_id)s
AND mi.formsemestre_id = sem.id
AND mod.id = mi.module_id
AND mod.ue_id = %(ue_id)s
AND i.moduleimpl_id = mi.moduleimpl_id
AND i.moduleimpl_id = mi.id
AND i.etudid = %(etudid)s
)
""",

View File

@ -916,7 +916,7 @@ def formsemestre_validate_ues(formsemestre_id, etudid, code_etat_sem, assiduite)
and ue_status["moy"] >= nt.parcours.NOTES_BARRE_VALID_UE
):
code_ue = ADM
elif isinstance(ue_status["moy"], float):
elif not isinstance(ue_status["moy"], float):
# aucune note (pas de moyenne) dans l'UE: ne la valide pas
code_ue = None
elif valid_semestre:

View File

@ -66,7 +66,7 @@ from app.scodoc.sco_utils import (
LOGOS_IMAGES_ALLOWED_TYPES,
)
from app import log
from app.scodoc.sco_exceptions import ScoGenError
from app.scodoc.sco_exceptions import ScoGenError, ScoValueError
import sco_version
PAGE_HEIGHT = defaultPageSize[1]
@ -121,6 +121,7 @@ def makeParas(txt, style, suppress_empty=False):
"""Returns a list of Paragraph instances from a text
with one or more <para> ... </para>
"""
result = []
try:
paras = _splitPara(txt)
if suppress_empty:
@ -133,21 +134,30 @@ def makeParas(txt, style, suppress_empty=False):
if m.group(1): # non empty paragraph
r.append(para)
paras = r
return [Paragraph(SU(s), style) for s in paras]
result = [Paragraph(SU(s), style) for s in paras]
except OSError as e:
msg = str(e)
# If a file is missing, try to display the invalid name
m = re.match(r".*\sfilename=\'(.*?)\'.*", msg, re.DOTALL)
if m:
filename = os.path.split(m.group(1))[1]
if filename.startswith("logo_"):
filename = filename[len("logo_") :]
raise ScoValueError(
f"Erreur dans le format PDF paramétré: fichier logo <b>{filename}</b> non trouvé"
) from e
else:
raise e
except Exception as e:
detail = " " + str(e)
log(traceback.format_exc())
log("Invalid pdf para format: %s" % txt)
return [
result = [
Paragraph(
SU(
'<font color="red"><i>Erreur: format invalide{}</i></font>'.format(
detail
)
),
SU('<font color="red"><i>Erreur: format invalide</i></font>'),
style,
)
]
return result
def bold_paras(L, tag="b", close=None):

View File

@ -88,10 +88,9 @@ def _get_group_info(evaluation_id):
groups_tree[partition][group_name] = group_id
if partition != TOUS:
has_groups = True
nb_groups = len(groups_tree)
else:
has_groups = False
nb_groups = 1
nb_groups = sum([len(groups_tree[p]) for p in groups_tree])
return groups_tree, has_groups, nb_groups

View File

@ -827,7 +827,7 @@ def feuille_saisie_notes(evaluation_id, group_ids=[]):
]
)
filename = "notes_%s_%s.xlsx" % (evalname, gr_title_filename)
filename = "notes_%s_%s" % (evalname, gr_title_filename)
xls = sco_excel.excel_feuille_saisie(E, sem["titreannee"], description, lines=L)
return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE)
# return sco_excel.send_excel_file(xls, filename)

View File

@ -271,7 +271,7 @@ def formsemestre_synchro_etuds(
if partitions: # il y a au moins une vraie partition
H.append(
f"""<li><a class="stdlink" href="{
url_for("scolar.affectGroups",
url_for("scolar.affect_groups",
scodoc_dept=g.scodoc_dept,
partition_id=partitions[0]["partition_id"]
)}">Répartir les groupes de {partitions[0]["partition_name"]}</a></li>

View File

@ -29,13 +29,14 @@
"""
# Anciennement ZScoUsers.py, fonctions de gestion des données réécrite avec flask/SQLAlchemy
import re
from flask import url_for, g, request
from flask_login import current_user
import cracklib # pylint: disable=import-error
from app import db
from app import db, Departement
from app.auth.models import Permission
from app.auth.models import User
@ -171,21 +172,25 @@ def list_users(
if not can_modify:
d["date_modif_passwd"] = "(non visible)"
columns_ids = [
"user_name",
"nom_fmt",
"prenom_fmt",
"email",
"dept",
"roles_string",
"date_expiration",
"date_modif_passwd",
"passwd_temp",
"status_txt",
]
# Seul l'admin peut voir les dates de dernière connexion
if current_user.is_administrator():
columns_ids.append("last_seen")
title = "Utilisateurs définis dans ScoDoc"
tab = GenTable(
rows=r,
columns_ids=(
"user_name",
"nom_fmt",
"prenom_fmt",
"email",
"dept",
"roles_string",
"date_expiration",
"date_modif_passwd",
"passwd_temp",
"status_txt",
),
columns_ids=columns_ids,
titles={
"user_name": "Login",
"nom_fmt": "Nom",
@ -195,6 +200,7 @@ def list_users(
"roles_string": "Rôles",
"date_expiration": "Expiration",
"date_modif_passwd": "Modif. mot de passe",
"last_seen": "Dernière cnx.",
"passwd_temp": "Temp.",
"status_txt": "Etat",
},
@ -206,7 +212,7 @@ def list_users(
html_class="table_leftalign list_users",
html_with_td_classes=True,
html_sortable=True,
base_url="%s?all=%s" % (request.base_url, all),
base_url="%s?all_depts=%s" % (request.base_url, 1 if all_depts else 0),
pdf_link=False, # table is too wide to fit in a paper page => disable pdf
preferences=sco_preferences.SemPreferences(),
)
@ -379,7 +385,16 @@ def user_info_page(user_name=None):
return "\n".join(H) + F
def check_modif_user(edit, user_name="", nom="", prenom="", email="", roles=[]):
def check_modif_user(
edit,
ignore_optionals=False,
user_name="",
nom="",
prenom="",
email="",
dept="",
roles=[],
):
"""Vérifie que cet utilisateur peut être créé (edit=0) ou modifié (edit=1)
Cherche homonymes.
returns (ok, msg)
@ -387,17 +402,44 @@ def check_modif_user(edit, user_name="", nom="", prenom="", email="", roles=[]):
(si ok est faux, l'utilisateur peut quand même forcer la creation)
- msg: message warning a presenter l'utilisateur
"""
if not user_name or not nom or not prenom:
return False, "champ requis vide"
if not email:
return False, "vous devriez indiquer le mail de l'utilisateur créé !"
MSG_OPT = """Attention: %s (vous pouvez forcer l'opération en cochant "<em>Ignorer les avertissements</em>" en bas de page)"""
# ce login existe ?
user = _user_list(user_name)
if edit and not user: # safety net, le user_name ne devrait pas changer
return False, "identifiant %s inexistant" % user_name
if not edit and user:
return False, "identifiant %s déjà utilisé" % user_name
if not user_name or not nom or not prenom:
return False, "champ requis vide"
if not re.match(r"^[a-zA-Z0-9@\\\-_\\\.]*$", user_name):
return (
False,
"identifiant '%s' invalide (pas d'accents ni de caractères spéciaux)"
% user_name,
)
if ignore_optionals and len(user_name) > 64:
return False, "identifiant '%s' trop long (64 caractères)" % user_name
if ignore_optionals and len(nom) > 64:
return False, "nom '%s' trop long (64 caractères)" % nom + MSG_OPT
if ignore_optionals and len(prenom) > 64:
return False, "prenom '%s' trop long (64 caractères)" % prenom + MSG_OPT
# check that tha same user_name has not already been described in this import
if not email:
return False, "vous devriez indiquer le mail de l'utilisateur créé !"
if len(email) > 120:
return False, "email '%s' trop long (120 caractères)" % email
if not re.fullmatch(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", email):
return False, "l'adresse mail semble incorrecte"
# check département
if (
ignore_optionals
and dept != ""
and Departement.query.filter_by(acronym=dept).first() is None
):
return False, "département '%s' inexistant" % u["dept"] + MSG_OPT
if ignore_optionals and not roles:
return False, "aucun rôle sélectionné, êtes vous sûr ?" + MSG_OPT
# ok
# Des noms/prénoms semblables existent ?
nom = nom.lower().strip()
prenom = prenom.lower().strip()
@ -417,12 +459,10 @@ def check_modif_user(edit, user_name="", nom="", prenom="", email="", roles=[]):
"%s %s (pseudo=%s)" % (x.prenom, x.nom, x.user_name)
for x in similar_users
]
),
)
+ MSG_OPT,
)
# Roles ?
if not roles:
return False, "aucun rôle sélectionné, êtes vous sûr ?"
# ok
return True, ""

View File

@ -402,7 +402,7 @@ function GotoAnother() {
if (groups_unsaved) {
alert("Enregistrez ou annulez vos changement avant !");
} else
document.location = SCO_URL + '/affectGroups?partition_id=' + document.formGroup.other_partition_id.value;
document.location = SCO_URL + '/affect_groups?partition_id=' + document.formGroup.other_partition_id.value;
}

View File

@ -4,8 +4,9 @@
{% block title %}Une erreur est survenue !{% endblock %}
{% block body %}
<h1>Une erreur est survenue !</h1>
<p>Oops... <span style="color:red;"><b>ScoDoc version <span style="font-size: 120%;">{{SCOVERSION}}</span></b></span> a
<p>Oups...</tt> <span style="color:red;"><b>ScoDoc version <span style="font-size: 120%;">{{SCOVERSION}}</span></b></span> a
un problème, désolé.</p>
<p><tt style="font-size:60%">{{date}}</tt></p>
<p> Si le problème persiste, contacter l'administrateur de votre site,
ou écrire la liste "notes" <a href="mailto:notes@listes.univ-paris13.fr">notes@listes.univ-paris13.fr</a> en

View File

@ -0,0 +1,19 @@
{% extends 'base.html' %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<h2>Accès non autorisé</h2>
{{ exc | safe }}
<p class="footer">
{% if g.scodoc_dept %}
<a href="{{ exc.dest_url or url_for('scolar.index_html', scodoc_dept=g.scodoc_dept) }}">retour page d'accueil
departement {{ g.scodoc_dept }}</a>
{% else %}
<a href="{{ exc.dest_url or url_for('scodoc.index') }}">retour page d'accueil</a>
{% endif %}
</p>
{% endblock %}

View File

@ -3,7 +3,7 @@
{% macro render_field(field) %}
<tr>
<td class="wtf-field">{{ field.label }}</td>
<td class="wtf-field">{{ field()|safe }}
<td class="wtf-field">{{ field(**kwargs)|safe }}
{% if field.errors %}
<ul class=errors>
{% for error in field.errors %}
@ -27,7 +27,7 @@
{{ render_field(form.nb_rangs) }}
{{ render_field(form.etiquetage) }}
{% if form.has_groups %}
{{ render_field(form.groups) }}
{{ render_field(form.groups, size=form.nb_groups) }}
<!-- Tentative de recréer le choix des groupes sous forme de cases à cocher // demande à créer des champs wtf dynamiquement
{% for partition in form.groups_tree %}
<tr>

View File

@ -0,0 +1,43 @@
{{ sco_header|safe }}
<h2 class="formsemestre">Affectation aux groupes de {{ partition["partition_name"] }}</h2>
<p>Faites glisser les étudiants d'un groupe à l'autre. Les modifications ne
sont enregistrées que lorsque vous cliquez sur le bouton "<em>Enregistrer ces groupes</em>".
Vous pouvez créer de nouveaux groupes. Pour <em>supprimer</em> un groupe, utiliser le lien
"suppr." en haut à droite de sa boite.
Vous pouvez aussi <a class="stdlink"
href="{{ url_for('scolar.groups_auto_repartition', scodoc_dept=g.scodoc_dept, partition_id=partition['partition_id']) }}"
>répartir automatiquement les groupes</a>.
</p>
<div id="gmsg" class="head_message"></div>
<div id="ginfo"></div>
<div id="savedinfo"></div>
<form name="formGroup" id="formGroup" onSubmit="return false;">
<input type="hidden" name="partition_id" value="{{ partition['partition_id'] }}"/>
<input name="groupName" size="6"/>
<input type="button" onClick="createGroup();" value="Créer groupe"/>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<input type="button" onClick="submitGroups( target='gmsg' );" value="Enregistrer ces groupes" />
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<input type="button"
onClick="document.location = '{{ url_for( 'notes.formsemestre_status', scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id) }}'"
value="Annuler" />&nbsp;&nbsp;&nbsp;&nbsp;Éditer groupes de
<select name="other_partition_id" onchange="GotoAnother();">
{% for p in partitions_list %}
<option value="{{ p['id'] }}" {{"selected" if p["partition_id"] == partition_id }}>{{p["partition_name"]}}</option>
{% endfor %}
</select>
</form>
<div id="groups">
</div>
<div style="clear: left; margin-top: 15px;">
<p class="help"></p>
</div>
</div>
{{ sco_footer|safe }}

View File

@ -1,8 +1,13 @@
# -*- coding: UTF-8 -*
"""ScoDoc Flask views
"""
import datetime
from flask import Blueprint
from flask import g, current_app
from flask_login import current_user
from app import db
from app.scodoc import notesdb as ndb
scodoc_bp = Blueprint("scodoc", __name__)
@ -20,7 +25,14 @@ from app.views import scodoc, notes, scolar, absences, users
@scodoc_bp.before_app_request
def start_scodoc_request():
"""Affecte toutes les requêtes, de tous les blueprints"""
# current_app.logger.info(f"start_scodoc_request")
ndb.open_db_connection()
if current_user.is_authenticated:
current_user.last_seen = datetime.datetime.utcnow()
db.session.commit()
# caches locaux (durée de vie=la requête en cours)
g.stored_get_formsemestre = {}
# g.stored_etud_info = {} optim en cours, voir si utile
@scodoc_bp.teardown_app_request

View File

@ -1064,6 +1064,10 @@ def AddBilletAbsence(
begin et end sont au format ISO (eg "1999-01-08 04:05:06")
"""
t0 = time.time()
begin = str(begin)
end = str(end)
code_nip = str(code_nip) if code_nip else None
# check etudid
etuds = sco_etud.get_etud_info(etudid=etudid, code_nip=code_nip, filled=True)
if not etuds:

View File

@ -274,7 +274,12 @@ def formsemestre_bulletinetud(
xml_with_decisions=False,
force_publishing=False,
prefer_mail_perso=False,
code_nip=None,
):
if not (etudid or code_nip):
raise ScoValueError("Paramètre manquant: spécifier code_nip ou etudid")
if not formsemestre_id:
raise ScoValueError("Paramètre manquant: formsemestre_id est requis")
return sco_bulletins.formsemestre_bulletinetud(
etudid=etudid,
formsemestre_id=formsemestre_id,
@ -2268,6 +2273,7 @@ sco_publish(
"/view_apo_csv_delete",
sco_etape_apogee_view.view_apo_csv_delete,
Permission.ScoEditApo,
methods=["GET", "POST"],
)
sco_publish(
"/view_scodoc_etuds", sco_etape_apogee_view.view_scodoc_etuds, Permission.ScoEditApo

View File

@ -61,6 +61,7 @@ from app.decorators import (
scodoc,
permission_required_compat_scodoc7,
)
from app.scodoc.sco_exceptions import AccessDenied
from app.scodoc.sco_permissions import Permission
from app.views import scodoc_bp as bp
@ -82,6 +83,12 @@ def index():
)
# Renvoie les url /ScoDoc/RT/ vers /ScoDoc/RT/Scolarite
@bp.route("/ScoDoc/<scodoc_dept>/")
def index_dept(scodoc_dept):
return redirect(url_for("scolar.index_html", scodoc_dept=scodoc_dept))
@bp.route("/ScoDoc/table_etud_in_accessible_depts", methods=["POST"])
@login_required
def table_etud_in_accessible_depts():

View File

@ -651,8 +651,8 @@ def formChangeCoordonnees(etudid):
# --- Gestion des groupes:
sco_publish(
"/affectGroups",
sco_groups_edit.affectGroups,
"/affect_groups",
sco_groups_edit.affect_groups,
Permission.ScoView,
methods=["GET", "POST"],
)
@ -1589,6 +1589,7 @@ def etudident_delete(etudid, dialog_confirmed=False):
"admissions",
"adresse",
"absences",
"absences_notifications",
"billet_absence",
]
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
@ -1756,6 +1757,7 @@ def check_group_apogee(group_id, etat=None, fix=False, fixmail=False):
@scodoc7func
def form_students_import_excel(formsemestre_id=None):
"formulaire import xls"
formsemestre_id = int(formsemestre_id) if formsemestre_id else None
if formsemestre_id:
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
dest_url = (
@ -1888,7 +1890,7 @@ Les champs avec un astérisque (*) doivent être présents (nulls non autorisés
else:
return sco_import_etuds.students_import_excel(
tf[2]["csvfile"],
formsemestre_id=formsemestre_id,
formsemestre_id=int(formsemestre_id) if formsemestre_id else None,
check_homonyms=tf[2]["check_homonyms"],
require_ine=tf[2]["require_ine"],
)

View File

@ -178,7 +178,7 @@ def create_user_form(user_name=None, edit=0, all_roles=1):
orig_roles_strings = {r.name + "_" + (dept or "") for (r, dept) in orig_roles}
# add existing user roles
displayed_roles = list(editable_roles_set.union(orig_roles))
displayed_roles.sort(key=lambda x: (x[1], x[0].name))
displayed_roles.sort(key=lambda x: (x[1] or "", x[0].name or ""))
displayed_roles_strings = [
r.name + "_" + (dept or "") for (r, dept) in displayed_roles
]
@ -381,23 +381,29 @@ def create_user_form(user_name=None, edit=0, all_roles=1):
H.append(tf_error_message("""Erreur: %s""" % err))
return "\n".join(H) + "\n" + tf[1] + F
if not force:
ok, msg = sco_users.check_modif_user(
edit,
user_name=user_name,
nom=vals["nom"],
prenom=vals["prenom"],
email=vals["email"],
roles=vals["roles"],
)
if not ok:
H.append(
tf_error_message(
"""Attention: %s (vous pouvez forcer l'opération en cochant "<em>Ignorer les avertissements</em>" en bas de page)"""
% msg
)
)
ok, msg = sco_users.check_modif_user(
edit,
ignore_optionals=force,
user_name=user_name,
nom=vals["nom"],
prenom=vals["prenom"],
email=vals["email"],
roles=vals["roles"],
)
if not ok:
H.append(tf_error_message(msg))
return "\n".join(H) + "\n" + tf[1] + F
if "date_expiration" in vals:
try:
if vals["date_expiration"]:
vals["date_expiration"] = datetime.datetime.strptime(
vals["date_expiration"], "%d/%m/%Y"
)
else:
vals["date_expiration"] = None
except ValueError:
H.append(tf_error_message("date expiration invalide"))
return "\n".join(H) + "\n" + tf[1] + F
if edit: # modif utilisateur (mais pas password ni user_name !)
@ -411,17 +417,6 @@ def create_user_form(user_name=None, edit=0, all_roles=1):
del vals["user_name"]
if (current_user.user_name == user_name) and "status" in vals:
del vals["status"] # no one can't change its own status
if "date_expiration" in vals:
try:
if vals["date_expiration"]:
vals["date_expiration"] = datetime.datetime.strptime(
vals["date_expiration"], "%d/%m/%Y"
)
else:
vals["date_expiration"] = None
except ValueError:
H.append(tf_error_message("date expiration invalide"))
return "\n".join(H) + "\n" + tf[1] + F
if "status" in vals:
vals["active"] = vals["status"] == ""
# traitement des roles: ne doit pas affecter les roles

View File

@ -0,0 +1,46 @@
"""ScoDoc 9.0.51: add unicity constraint on notes_moduleimpl_inscription
Revision ID: d74b4e16fb3c
Revises: f86c013c9fbd
Create Date: 2021-10-09 20:08:50.927330
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.orm import sessionmaker # added by ev
# revision identifiers, used by Alembic.
revision = "d74b4e16fb3c"
down_revision = "f86c013c9fbd"
branch_labels = None
depends_on = None
Session = sessionmaker()
def upgrade():
# Added by ev: remove duplicates
bind = op.get_bind()
session = Session(bind=bind)
session.execute(
"""
DELETE FROM notes_moduleimpl_inscription i1
USING notes_moduleimpl_inscription i2
WHERE i1.id < i2.id
AND i1.moduleimpl_id = i2.moduleimpl_id
AND i1.etudid = i2.etudid;
"""
)
# ### commands auto generated by Alembic - please adjust! ###
op.create_unique_constraint(
None, "notes_moduleimpl_inscription", ["moduleimpl_id", "etudid"]
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, "notes_moduleimpl_inscription", type_="unique")
# ### end Alembic commands ###

View File

@ -274,6 +274,23 @@ def list_depts(depts=""): # list-dept
print(f"{dept.id}\t{dept.acronym}")
@app.cli.command()
@click.option(
"-n",
"--name",
is_flag=True,
help="show database name instead of connexion string (required for "
"dropdb/createddb commands)",
)
def scodoc_database(name): # list-dept
"""print the database connexion string"""
uri = app.config["SQLALCHEMY_DATABASE_URI"]
if name:
print(uri.split("/")[-1])
else:
print(uri)
@app.cli.command()
@with_appcontext
def import_scodoc7_users(): # import-scodoc7-users
@ -329,3 +346,27 @@ def recursive_help(cmd, parent=None):
@app.cli.command()
def dumphelp():
recursive_help(app.cli)
@app.cli.command()
@click.option("-h", "--host", default="127.0.0.1", help="The interface to bind to.")
@click.option("-p", "--port", default=5000, help="The port to bind to.")
@click.option(
"--length",
default=25,
help="Number of functions to include in the profiler report.",
)
@click.option(
"--profile-dir", default=None, help="Directory where profiler data files are saved."
)
def profile(host, port, length, profile_dir):
"""Start the application under the code profiler."""
from werkzeug.middleware.profiler import ProfilerMiddleware
from werkzeug.serving import run_simple
app.wsgi_app = ProfilerMiddleware(
app.wsgi_app, restrictions=[length], profile_dir=profile_dir
)
run_simple(
host, port, app, use_debugger=False
) # use run_simple instead of app.run()

View File

@ -22,6 +22,8 @@ def test_client():
with apptest.test_client() as client:
with apptest.app_context():
with apptest.test_request_context():
# initialize scodoc "g":
g.stored_get_formsemestre = {}
# erase and reset database:
initialize_scodoc_database(erase=True, create_all=True)
# Loge l'utilisateur super-admin

View File

@ -0,0 +1,100 @@
"""Test calculs rattrapages
"""
from config import TestConfig
from tests.unit import sco_fake_gen
from flask import g
import app
from app.scodoc import sco_bulletins
from app.scodoc import sco_utils as scu
DEPT = TestConfig.DEPT_TEST
def test_notes_rattrapage(test_client):
"""Test quelques opérations élémentaires de ScoDoc
Création 10 étudiants, formation, semestre, inscription etudiant,
creation 1 evaluation, saisie 10 notes.
"""
app.set_sco_dept(DEPT)
G = sco_fake_gen.ScoFake(verbose=False)
etuds = [G.create_etud(code_nip=None)] # un seul
f = G.create_formation(acronyme="")
ue = G.create_ue(formation_id=f["formation_id"], acronyme="TST1", titre="ue test")
mat = G.create_matiere(ue_id=ue["ue_id"], titre="matière test")
mod = G.create_module(
matiere_id=mat["matiere_id"],
code="TSM1",
coefficient=1.0,
titre="module test",
ue_id=ue["ue_id"],
formation_id=f["formation_id"],
)
# --- Mise place d'un semestre
sem = G.create_formsemestre(
formation_id=f["formation_id"],
semestre_id=1,
date_debut="01/01/2020",
date_fin="30/06/2020",
)
mi = G.create_moduleimpl(
module_id=mod["module_id"],
formsemestre_id=sem["formsemestre_id"],
)
# --- Inscription des étudiants
for etud in etuds:
G.inscrit_etudiant(sem, etud)
# --- Creation évaluation
e = G.create_evaluation(
moduleimpl_id=mi["moduleimpl_id"],
jour="01/01/2020",
description="evaluation test",
coefficient=1.0,
)
# --- Création d'une évaluation "de rattrapage"
e_rat = G.create_evaluation(
moduleimpl_id=mi["moduleimpl_id"],
jour="02/01/2020",
description="evaluation rattrapage",
coefficient=1.0,
evaluation_type=scu.EVALUATION_RATTRAPAGE,
)
etud = etuds[0]
_, _, _ = G.create_note(evaluation=e, etud=etud, note=12.0)
_, _, _ = G.create_note(evaluation=e_rat, etud=etud, note=11.0)
b = sco_bulletins.formsemestre_bulletinetud_dict(
sem["formsemestre_id"], etud["etudid"]
)
# Vérifie structure du bulletin:
assert b["etudid"] == etud["etudid"]
assert len(b["ues"][0]["modules"][0]["evaluations"]) == 2
assert len(b["ues"][0]["modules"]) == 1
# Note moyenne: ici le ratrapage est inférieur à la note:
assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(12.0)
# rattrapage > moyenne:
_, _, _ = G.create_note(evaluation=e_rat, etud=etud, note=18.0)
b = sco_bulletins.formsemestre_bulletinetud_dict(
sem["formsemestre_id"], etud["etudid"]
)
assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(18.0)
# rattrapage vs absences
_, _, _ = G.create_note(evaluation=e, etud=etud, note=None) # abs
_, _, _ = G.create_note(evaluation=e_rat, etud=etud, note=17.0)
b = sco_bulletins.formsemestre_bulletinetud_dict(
sem["formsemestre_id"], etud["etudid"]
)
assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(17.0)
# et sans note de rattrapage
_, _, _ = G.create_note(evaluation=e, etud=etud, note=10.0) # abs
_, _, _ = G.create_note(evaluation=e_rat, etud=etud, note=None)
b = sco_bulletins.formsemestre_bulletinetud_dict(
sem["formsemestre_id"], etud["etudid"]
)
assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(10.0)

View File

@ -27,6 +27,7 @@ from app.scodoc import sco_codes_parcours
from app.scodoc import sco_evaluations
from app.scodoc import sco_formsemestre_validation
from app.scodoc import sco_parcours_dut
from app.scodoc import sco_cache
from app.scodoc import sco_saisie_notes
from app.scodoc import sco_utils as scu
@ -197,3 +198,19 @@ def run_sco_basic(verbose=False):
assert not sco_parcours_dut.formsemestre_has_decisions(
sem["formsemestre_id"]
), "décisions non effacées"
# --- Décision de jury et validations des ECTS d'UE
for etud in etuds[:5]: # les etudiants notés
sco_formsemestre_validation.formsemestre_validation_etud_manu(
sem["formsemestre_id"],
etud["etudid"],
code_etat=sco_codes_parcours.ADJ,
assidu=True,
redirect=False,
)
# Vérifie que toutes les UE des étudiants notés ont été acquises:
nt = sco_cache.NotesTableCache.get(sem["formsemestre_id"])
for etud in etuds[:5]:
dec_ues = nt.get_etud_decision_ues(etud["etudid"])
for ue_id in dec_ues:
assert dec_ues[ue_id]["code"] in {"ADM", "CMP"}

View File

@ -15,29 +15,47 @@ source "$SCRIPT_DIR/utils.sh"
# Ce script doit tourner comme "root"
check_uid_root "$0"
# Usage
if [ ! $# -eq 2 ]
then
echo "Usage: $0 archive dbname"
echo "Exemple: $0 /tmp/mon-scodoc.tgz SCODOC"
usage() {
echo "Usage: $0 [ --keep-env ] archive"
echo "Exemple: $0 /tmp/mon-scodoc.tgz"
echo "OPTION"
echo "--keep_env garde la configuration courante"
exit 1
}
if (($# < 1 || $# > 2))
then
usage
elif [ $# -eq 2 -a $1 != '--keep-env' -a $2 != '--keep-env' ] ; then
usage
elif [ $# -eq 1 ] ; then
echo "restauration des données et de la configuration originale (production)"
SRC=$1
DB_DEST="SCODOC"
else
echo "restauration des données dans la configuration actuelle"
DB_CURRENT=$(su -c "(cd $SCODOC_DIR && source venv/bin/activate && flask scodoc-database -n)")
DB_DEST="$DB_CURRENT"
KEEP=1
if [ $1 = '--keep-env' ]; then
SRC=$2
else
SRC=$1
fi
fi
SRC=$1
DBNAME=$2
DB_DUMP="${SCODOC_VAR_DIR}"/SCODOC.dump
# Safety check
echo "Ce script va remplacer les donnees de votre installation ScoDoc par celles"
echo "enregistrees dans le fichier fourni."
echo "enregistrées dans le fichier fourni."
echo "Ce fichier doit avoir ete cree par le script save_scodoc9_data.sh."
echo
echo "Attention: TOUTES LES DONNEES DE CE SCODOC SERONT REMPLACEES !"
echo "Notamment, tous les utilisateurs et departements existants seront effaces !"
echo
echo "La base SQL $DBNAME sera effacée et remplacée !!!"
echo
echo "La base SQL $DB_CURRENT sera effacée et remplacée !!!"
echo
echo -n "Voulez vous poursuivre cette operation ? (y/n) [n]"
read -r ans
if [ ! "$(norm_ans "$ans")" = 'Y' ]
@ -47,8 +65,13 @@ then
fi
# -- Stop ScoDoc
echo "Arrêt de scodoc9..."
systemctl stop scodoc9
if [ $KEEP -ne 1 ]; then
echo "Arrêt de scodoc9..."
systemctl stop scodoc9
else
echo -n "Assurez-vous d'avoir arrété le serveur scodoc (validez pour continuer)"
read ans
fi
# Clear caches
echo "Purge des caches..."
@ -70,22 +93,31 @@ echo "Vérification du propriétaire..."
chown -R "${SCODOC_USER}:${SCODOC_GROUP}" "${SCODOC_VAR_DIR}" || die "Error chowning ${SCODOC_VAR_DIR}"
# --- La base SQL: nommée $(db_name).dump
nb=$(su -c "psql -l" "$SCODOC_USER" | awk '{print $1}' | grep -c -e '^'"$DBNAME"'$')
if [ "$nb" -gt 0 ]
nb=$(su -c "psql -l" "$SCODOC_USER" | awk '{print $1}' | grep -c -x "$DB_DEST")
if [ "$nb" -gt 0 ]
then
echo "Suppression de la base $DBNAME..."
su -c "dropdb $DBNAME" "$SCODOC_USER" || die "Erreur destruction db"
echo "Suppression de la base $DB_DEST..."
su -c "dropdb $DB_DEST" "$SCODOC_USER" || die "Erreur destruction db"
fi
su -c "createdb $DBNAME" "$SCODOC_USER" || die "Erreur création db"
su -c "createdb $DB_DEST" "$SCODOC_USER" || die "Erreur création db"
echo "Chargement de la base SQL..."
su -c "pg_restore -d $DBNAME ${SCODOC_VAR_DIR}/SCODOC.dump" "$SCODOC_USER" || die "Erreur chargement de la base SQL"
if [ ! -z $KEEP_ENV ] ; then
echo "conservation de la configuration actuelle"
cp "$SCODOC_VAR_DIR".old/.env "$SCODOC_VAR_DIR"/.env
echo "récupération des données..."
su -c "pg_restore -f - $DB_DUMP | psql -q $DB_DEST" "$SCODOC_USER" >/dev/null || die "Erreur chargement/renommage de la base SQL"
su -c "(cd $SCODOC_DIR && source venv/bin/activate && flask db upgrade)" "$SCODOC_USER"
echo "redémarrez scodoc selon votre configuration"
else
# -- Apply migrations if needed (only on "production" database, = SCODOC sauf config particulière)
echo "restauration environnement de production"
echo "Chargement de la base SQL..."
su -c "pg_restore -d $DB_DEST $DB_DUMP" "$SCODOC_USER" || die "Erreur chargement de la base SQL"
export FLASK_ENV="production" # peut-être pas utile? : .env a été recopié
su -c "(cd $SCODOC_DIR && source venv/bin/activate && flask db upgrade)" "$SCODOC_USER"
# -- Start ScoDoc
systemctl start scodoc9
fi
# -- Apply migrations if needed (only on "production" databse, = SCODOC sauf config particulière)
export FLASK_ENV="production"
su -c "(cd $SCODOC_DIR && source venv/bin/activate && flask db upgrade)" "$SCODOC_USER"
# -- Start ScoDoc
systemctl start scodoc9
echo "Terminé."

View File

@ -20,6 +20,14 @@ then
exit 1
fi
echo "vérification de la configuration..."
DB_CURRENT=$(cd $SCODOC_DIR && source venv/bin/activate && flask scodoc-database -n)
if [ $DB_CURRENT != 'SCODOC' ]; then
echo "Ce script ne peut transférer les données que depuis une base nommée SCODOC (c'est normalement le cas pour un serveur en production)"
echo "Annulation"
exit 1
fi
echo "Ce script est utile pour transférer toutes les données d'un serveur ScoDoc 9"
echo "à un autre ScoDoc 9."
echo "Il est vivement recommandé de mettre à jour votre ScoDoc avant."
@ -44,8 +52,10 @@ DEST=$1
db_name="$SCODOC_DB_PROD" # SCODOC
# dump dans /opt/scodoc-data/SCODOC.dump
echo "sauvegarde de la base de données"
pg_dump --format=custom --file="$SCODOC_VAR_DIR/$db_name.dump" "$db_name" || die "Error dumping database"
echo "création du fichier d'archivage..."
# tar scodoc-data vers le fichier indiqué ou stdout
(cd $(dirname "$SCODOC_VAR_DIR"); tar cfz "$DEST" $(basename "$SCODOC_VAR_DIR")) || die "Error archiving data"