Merge branch 'master' into clean

This commit is contained in:
Jean-Marie Place 2021-09-11 10:21:54 +02:00
commit 37484b7fc9
57 changed files with 1149 additions and 505 deletions

View File

@ -117,6 +117,7 @@ def create_app(config_class=DevConfig):
from app.views import notes_bp
from app.views import users_bp
from app.views import absences_bp
from app.api import bp as api_bp
# https://scodoc.fr/ScoDoc
app.register_blueprint(scodoc_bp)
@ -130,6 +131,7 @@ def create_app(config_class=DevConfig):
app.register_blueprint(
absences_bp, url_prefix="/ScoDoc/<scodoc_dept>/Scolarite/Absences"
)
app.register_blueprint(api_bp, url_prefix="/ScoDoc/api")
scodoc_exc_formatter = RequestFormatter(
"[%(asctime)s] %(remote_addr)s requested %(url)s\n"
"%(levelname)s in %(module)s: %(message)s"
@ -190,9 +192,7 @@ def create_app(config_class=DevConfig):
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorLegacy)
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorStandard)
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorUCAC)
app.logger.info(
f"registered bulletin classes {[ k for k in sco_bulletins_generator.BULLETIN_CLASSES ]}"
)
return app

8
app/api/__init__.py Normal file
View File

@ -0,0 +1,8 @@
"""api.__init__
"""
from flask import Blueprint
bp = Blueprint("api", __name__)
from app.api import sco_api

53
app/api/auth.py Normal file
View File

@ -0,0 +1,53 @@
# -*- coding: UTF-8 -*
# Authentication code borrowed from Miguel Grinberg's Mega Tutorial
# (see https://github.com/miguelgrinberg/microblog)
# Under The MIT License (MIT)
# Copyright (c) 2017 Miguel Grinberg
# Permission is hereby granted, free of charge, to any person obtaining a copy of
# this software and associated documentation files (the "Software"), to deal in
# the Software without restriction, including without limitation the rights to
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
# the Software, and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
from app.auth.models import User
from app.api.errors import error_response
basic_auth = HTTPBasicAuth()
token_auth = HTTPTokenAuth()
@basic_auth.verify_password
def verify_password(username, password):
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
return user
@basic_auth.error_handler
def basic_auth_error(status):
return error_response(status)
@token_auth.verify_token
def verify_token(token):
return User.check_token(token) if token else None
@token_auth.error_handler
def token_auth_error(status):
return error_response(status)

37
app/api/errors.py Normal file
View File

@ -0,0 +1,37 @@
# Authentication code borrowed from Miguel Grinberg's Mega Tutorial
# (see https://github.com/miguelgrinberg/microblog)
# Under The MIT License (MIT)
# Copyright (c) 2017 Miguel Grinberg
# Permission is hereby granted, free of charge, to any person obtaining a copy of
# this software and associated documentation files (the "Software"), to deal in
# the Software without restriction, including without limitation the rights to
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
# the Software, and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.from flask import jsonify
from werkzeug.http import HTTP_STATUS_CODES
def error_response(status_code, message=None):
payload = {"error": HTTP_STATUS_CODES.get(status_code, "Unknown error")}
if message:
payload["message"] = message
response = jsonify(payload)
response.status_code = status_code
return response
def bad_request(message):
return error_response(400, message)

56
app/api/sco_api.py Normal file
View File

@ -0,0 +1,56 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2021 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""API ScoDoc 9
"""
# PAS ENCORE IMPLEMENTEE, juste un essai
# Pour P. Bouron, il faudrait en priorité l'équivalent de
# Scolarite/Notes/do_moduleimpl_withmodule_list
# Scolarite/Notes/evaluation_create
# Scolarite/Notes/evaluation_delete
# Scolarite/Notes/formation_list
# Scolarite/Notes/formsemestre_list
# Scolarite/Notes/formsemestre_partition_list
# Scolarite/Notes/groups_view
# Scolarite/Notes/moduleimpl_status
# Scolarite/setGroups
from flask import jsonify, request, url_for, abort
from app import db
from app.api import bp
from app.api.auth import token_auth
from app.api.errors import bad_request
from app import models
@bp.route("/ScoDoc/api/list_depts", methods=["GET"])
@token_auth.login_required
def list_depts():
depts = models.Departement.query.filter_by(visible=True).all()
data = {"items": [d.to_dict() for d in depts]}
return jsonify(data)

20
app/api/tokens.py Normal file
View File

@ -0,0 +1,20 @@
from flask import jsonify
from app import db
from app.api import bp
from app.api.auth import basic_auth, token_auth
@bp.route("/tokens", methods=["POST"])
@basic_auth.login_required
def get_token():
token = basic_auth.current_user().get_token()
db.session.commit()
return jsonify({"token": token})
@bp.route("/tokens", methods=["DELETE"])
@token_auth.login_required
def revoke_token():
token_auth.current_user().revoke_token()
db.session.commit()
return "", 204

View File

@ -37,9 +37,11 @@ def login():
if form.validate_on_submit():
user = User.query.filter_by(user_name=form.user_name.data).first()
if user is None or not user.check_password(form.password.data):
current_app.logger.info("login: invalid (%s)", form.user_name.data)
flash(_("Invalid user name or password"))
return redirect(url_for("auth.login"))
login_user(user, remember=form.remember_me.data)
current_app.logger.info("login: success (%s)", form.user_name.data)
next_page = request.args.get("next")
if not next_page or url_parse(next_page).netloc != "":
next_page = url_for("scodoc.index")

View File

@ -16,8 +16,10 @@ from flask import request
from flask_login import current_user
from flask_login import login_required
from flask import current_app
import flask_login
import app
from app.auth.models import User
class ZUser(object):
@ -141,6 +143,48 @@ def permission_required(permission):
return decorator
def permission_required_compat_scodoc7(permission):
"""Décorateur pour les fonctions utilisée comme API dans ScoDoc 7
Comme @permission_required mais autorise de passer directement
les informations d'auth en paramètres:
__ac_name, __ac_password
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
# current_app.logger.warning("PERMISSION; kwargs=%s" % str(kwargs))
# cherche les paramètre d'auth:
auth_ok = False
if request.method == "GET":
user_name = request.args.get("__ac_name")
user_password = request.args.get("__ac_password")
elif request.method == "POST":
user_name = request.form.get("__ac_name")
user_password = request.form.get("__ac_password")
else:
abort(405) # method not allowed
if user_name and user_password:
u = User.query.filter_by(user_name=user_name).first()
if u and u.check_password(user_password):
auth_ok = True
flask_login.login_user(u)
# reprend le chemin classique:
scodoc_dept = getattr(g, "scodoc_dept", None)
if not current_user.has_permission(permission, scodoc_dept):
abort(403)
if auth_ok:
return f(*args, **kwargs)
else:
return login_required(f)(*args, **kwargs)
return decorated_function
return decorator
def admin_required(f):
from app.auth.models import Permission

View File

@ -34,3 +34,13 @@ class Departement(db.Model):
def __repr__(self):
return f"<Departement {self.acronym}>"
def to_dict(self):
data = {
"id": self.id,
"acronym": self.acronym,
"description": self.description,
"visible": self.visible,
"date_creation": self.date_creation,
}
return data

View File

@ -325,10 +325,15 @@ class NotesSemSet(db.Model):
sem_id = db.Column(db.Integer, nullable=True, default=None)
# Association:
# Association: many to many
notes_semset_formsemestre = db.Table(
"notes_semset_formsemestre",
db.Column("formsemestre_id", db.Integer, db.ForeignKey("notes_formsemestre.id")),
db.Column("semset_id", db.Integer, db.ForeignKey("notes_semset.id")),
db.Column(
"semset_id",
db.Integer,
db.ForeignKey("notes_semset.id", ondelete="CASCADE"),
nullable=False,
),
db.UniqueConstraint("formsemestre_id", "semset_id"),
)

View File

@ -33,6 +33,17 @@ class ScoDocSiteConfig(db.Model):
value = db.Column(db.Text())
BONUS_SPORT = "bonus_sport_func_name"
NAMES = {
BONUS_SPORT: str,
"always_require_ine": bool,
"SCOLAR_FONT": str,
"SCOLAR_FONT_SIZE": str,
"SCOLAR_FONT_SIZE_FOOT": str,
"INSTITUTION_NAME": str,
"INSTITUTION_ADDRESS": str,
"INSTITUTION_CITY": str,
"DEFAULT_PDF_FOOTER_TEMPLATE": str,
}
def __init__(self, name, value):
self.name = name
@ -41,6 +52,13 @@ class ScoDocSiteConfig(db.Model):
def __repr__(self):
return f"<{self.__class__.__name__}('{self.name}', '{self.value}')>"
def get_dict(self) -> dict:
"Returns all data as a dict name = value"
return {
c.name: self.NAMES.get(c.name, lambda x: x)(c.value)
for c in ScoDocSiteConfig.query.all()
}
@classmethod
def set_bonus_sport_func(cls, func_name):
"""Record bonus_sport config.

View File

@ -83,22 +83,19 @@ def sidebar():
from app.scodoc import sco_abs
from app.scodoc import sco_etud
params = {
"ScoURL": scu.ScoURL(),
"SCO_USER_MANUAL": scu.SCO_USER_MANUAL,
}
params = {}
H = ['<div class="sidebar">', sidebar_common()]
H.append(
"""<div class="box-chercheetud">Chercher étudiant:<br/>
<form method="get" id="form-chercheetud" action="%(ScoURL)s/search_etud_in_dept">
<div><input type="text" size="12" id="in-expnom" name="expnom" spellcheck="false"></input></div>
</form></div>
<div class="etud-insidebar">
"""
% params
)
H = [
f"""<div class="sidebar">
{ sidebar_common() }
<div class="box-chercheetud">Chercher étudiant:<br/>
<form method="get" id="form-chercheetud"
action="{ url_for('scolar.search_etud_in_dept', scodoc_dept=g.scodoc_dept) }">
<div><input type="text" size="12" id="in-expnom" name="expnom" spellcheck="false"></input></div>
</form></div>
<div class="etud-insidebar">
"""
]
# ---- Il y-a-t-il un etudiant selectionné ?
etudid = None
if request.method == "GET":
@ -121,59 +118,50 @@ def sidebar():
% params
)
if etud["cursem"]:
params["nbabs"], params["nbabsjust"] = sco_abs.get_abs_count(
etudid, etud["cursem"]
)
params["nbabsnj"] = params["nbabs"] - params["nbabsjust"]
params["date_debut"] = etud["cursem"]["date_debut"]
params["date_fin"] = etud["cursem"]["date_fin"]
cur_sem = etud["cursem"]
nbabs, nbabsjust = sco_abs.get_abs_count(etudid, cur_sem)
nbabsnj = nbabs - nbabsjust
H.append(
"""<span title="absences du %(date_debut)s au %(date_fin)s">(1/2 j.)<br/>%(nbabsjust)s J., %(nbabsnj)s N.J.</span>"""
% params
f"""<span title="absences du { cur_sem["date_debut"] } au { cur_sem["date_fin"] }">(1/2 j.)
<br/>{ nbabsjust } J., { nbabsnj } N.J.</span>"""
)
H.append("<ul>")
if current_user.has_permission(Permission.ScoAbsChange):
H.append(
f"""
<li><a href="{ url_for('absences.SignaleAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Ajouter</a></li>
<li><a href="{ url_for('absences.JustifAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Justifier</a></li>
<li><a href="{ url_for('absences.AnnuleAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Supprimer</a></li>
"""
<li> <a href="%(ScoURL)s/Absences/SignaleAbsenceEtud?etudid=%(etudid)s">Ajouter</a></li>
<li> <a href="%(ScoURL)s/Absences/JustifAbsenceEtud?etudid=%(etudid)s">Justifier</a></li>
<li> <a href="%(ScoURL)s/Absences/AnnuleAbsenceEtud?etudid=%(etudid)s">Supprimer</a></li>
"""
% params
)
if sco_preferences.get_preference("handle_billets_abs"):
H.append(
"""<li> <a href="%(ScoURL)s/Absences/listeBilletsEtud?etudid=%(etudid)s">Billets</a></li>"""
% params
f"""<li><a href="{ url_for('absences.listeBilletsEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Billets</a></li>"""
)
H.append(
f"""
<li><a href="{ url_for('absences.CalAbs', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Calendrier</a></li>
<li><a href="{ url_for('absences.ListeAbsEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Liste</a></li>
</ul>
"""
<li> <a href="%(ScoURL)s/Absences/CalAbs?etudid=%(etudid)s">Calendrier</a></li>
<li> <a href="%(ScoURL)s/Absences/ListeAbsEtud?etudid=%(etudid)s">Liste</a></li>
</ul>
"""
% params
)
else:
pass # H.append("(pas d'étudiant en cours)")
# ---------
H.append("</div>") # /etud-insidebar
# Logo
scologo_img = scu.icontag("scologo_img")
H.append(
'<div class="logo-insidebar"><div class="logo-logo">%s</div>' % scologo_img
f"""<div class="logo-insidebar">
<div class="sidebar-bottom"><a href="{ url_for( 'scolar.about', scodoc_dept=g.scodoc_dept ) }" class="sidebar">À propos</a><br/>
<a href="{ scu.SCO_USER_MANUAL }" target="_blank" class="sidebar">Aide</a>
</div></div>
<div class="logo-logo"><a href= { url_for( 'scolar.about', scodoc_dept=g.scodoc_dept ) }
">{ scu.icontag("scologo_img", no_size=True) }</a>
</div>
</div>
<!-- end of sidebar -->
"""
)
H.append(
"""<div class="logo-logo"><a href="%(ScoURL)s/about" class="sidebar">A propos</a><br/>
<a href="%(SCO_USER_MANUAL)s" class="sidebar">Aide</a><br/>
</div></div>
</div> <!-- end of sidebar -->
"""
% params
) # '
#
return "".join(H)

View File

@ -287,7 +287,7 @@ class EditableTable(object):
input_formators={},
aux_tables=[],
convert_null_outputs_to_empty=True,
html_quote=True,
html_quote=False, # changed in 9.0.10
fields_creators={}, # { field : [ sql_command_to_create_it ] }
filter_nulls=True, # dont allow to set fields to null
filter_dept=False, # ajoute selection sur g.scodoc_dept_id
@ -321,8 +321,10 @@ class EditableTable(object):
del vals["id"]
if self.filter_dept:
vals["dept_id"] = g.scodoc_dept_id
if self.html_quote:
quote_dict(vals) # quote all HTML markup
if (
self.html_quote
): # quote all HTML markup (une bien mauvaise idée venue des ages obscurs)
quote_dict(vals)
# format value
for title in vals:
if title in self.input_formators:

View File

@ -163,7 +163,7 @@ class TableTag(object):
# *****************************************************************************************************************
# -----------------------------------------------------------------------------------------------------------
def add_moyennesTag(self, tag, listMoyEtCoeff):
def add_moyennesTag(self, tag, listMoyEtCoeff) -> bool:
"""
Mémorise les moyennes, les coeffs de pondération et les etudid dans resultats
avec calcul du rang
@ -181,7 +181,9 @@ class TableTag(object):
lesMoyennesTriees = sorted(
listMoyEtCoeff,
reverse=True,
key=lambda col: col[0] or 0, # remplace les None par des zéros
key=lambda col: col[0]
if isinstance(col[0], float)
else 0, # remplace les None et autres chaines par des zéros
) # triées
self.rangs[tag] = notes_table.comp_ranks(lesMoyennesTriees) # les rangs

View File

@ -313,7 +313,7 @@ def do_formsemestre_archive(
# tous les inscrits du semestre
group_ids = [sco_groups.get_default_group(formsemestre_id)]
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids, formsemestre_id=formsemestre_id, REQUEST=REQUEST
group_ids, formsemestre_id=formsemestre_id
)
groups_filename = "-" + groups_infos.groups_filename
etudids = [m["etudid"] for m in groups_infos.members]
@ -403,7 +403,7 @@ def formsemestre_archive(REQUEST, formsemestre_id, group_ids=[]):
# tous les inscrits du semestre
group_ids = [sco_groups.get_default_group(formsemestre_id)]
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids, formsemestre_id=formsemestre_id, REQUEST=REQUEST
group_ids, formsemestre_id=formsemestre_id
)
H = [

View File

@ -183,7 +183,7 @@ def _sem_table(sems):
"""Affiche liste des semestres, utilisée pour semestres en cours"""
tmpl = """<tr class="%(trclass)s">%(tmpcode)s
<td class="semicon">%(lockimg)s <a href="%(notes_url)s/formsemestre_status?formsemestre_id=%(formsemestre_id)s#groupes">%(groupicon)s</a></td>
<td class="datesem">%(mois_debut)s</td><td class="datesem"><a title="%(session_id)s">-</a> %(mois_fin)s</td>
<td class="datesem">%(mois_debut)s <a title="%(session_id)s">-</a> %(mois_fin)s</td>
<td><a class="stdlink" href="%(notes_url)s/formsemestre_status?formsemestre_id=%(formsemestre_id)s">%(titre_num)s</a>
<span class="respsem">(%(responsable_name)s)</span>
</td>
@ -196,7 +196,7 @@ def _sem_table(sems):
H = ['<table class="listesems">']
for modalite in modalites:
if len(modalites) > 1:
H.append('<tr><th colspan="4">%s</th></tr>' % modalite["titre"])
H.append('<tr><th colspan="3">%s</th></tr>' % modalite["titre"])
if sems_by_mod[modalite["modalite"]]:
cur_idx = sems_by_mod[modalite["modalite"]][0]["semestre_id"]

View File

@ -85,6 +85,9 @@ def send_excel_file(request, data, filename, mime=scu.XLSX_MIMETYPE):
def xldate_as_datetime(xldate, datemode=0):
"""Conversion d'une date Excel en date
Peut lever une ValueError
"""
return openpyxl.utils.datetime.from_ISO8601(xldate)

View File

@ -120,10 +120,11 @@ def search_etud_in_dept(expnom=""):
if etudid is not None:
etuds = sco_etud.get_etud_info(filled=True, etudid=expnom)
if (etudid is None) or len(etuds) != 1:
if scu.is_valid_code_nip(expnom):
etuds = search_etuds_infos(code_nip=expnom)
expnom_str = str(expnom)
if scu.is_valid_code_nip(expnom_str):
etuds = search_etuds_infos(code_nip=expnom_str)
else:
etuds = search_etuds_infos(expnom=expnom)
etuds = search_etuds_infos(expnom=expnom_str)
else:
etuds = [] # si expnom est trop court, n'affiche rien
@ -151,7 +152,7 @@ def search_etud_in_dept(expnom=""):
H = [
html_sco_header.sco_header(
page_title="Recherche d'un étudiant",
no_side_bar=True,
no_side_bar=False,
init_qtip=True,
javascripts=["js/etud_info.js"],
)
@ -250,10 +251,12 @@ def search_etud_by_name(term: str) -> list:
r = ndb.SimpleDictFetch(
"""SELECT nom, prenom, code_nip
FROM identite
WHERE code_nip
LIKE %(beginning)s ORDER BY nom
WHERE
dept_id = %(dept_id)s
AND code_nip LIKE %(beginning)s
ORDER BY nom
""",
{"beginning": term + "%"},
{"beginning": term + "%", "dept_id": g.scodoc_dept_id},
)
data = [
{
@ -267,10 +270,12 @@ def search_etud_by_name(term: str) -> list:
r = ndb.SimpleDictFetch(
"""SELECT id AS etudid, nom, prenom
FROM identite
WHERE nom LIKE %(beginning)s
WHERE
dept_id = %(dept_id)s
AND nom LIKE %(beginning)s
ORDER BY nom
""",
{"beginning": term + "%"},
{"beginning": term + "%", "dept_id": g.scodoc_dept_id},
)
data = [

View File

@ -34,12 +34,12 @@
import collections
import datetime
import operator
import six.moves.urllib.request, six.moves.urllib.parse, six.moves.urllib.error
import urllib
from urllib.parse import parse_qs
import time
from flask import url_for, g
from flask import url_for, g, request
import app.scodoc.sco_utils as scu
from app.scodoc import html_sco_header
@ -86,7 +86,6 @@ def groups_view(
group_ids,
formsemestre_id=formsemestre_id,
etat=etat,
REQUEST=REQUEST,
select_all_when_unspecified=True,
)
# Formats spéciaux: download direct
@ -301,7 +300,6 @@ class DisplayedGroupsInfos(object):
etat=None,
select_all_when_unspecified=False,
moduleimpl_id=None, # used to find formsemestre when unspecified
REQUEST=None,
):
if isinstance(group_ids, int):
if group_ids:
@ -334,7 +332,7 @@ class DisplayedGroupsInfos(object):
for group_id in group_ids:
gq.append("group_ids=" + str(group_id))
self.groups_query_args = "&".join(gq)
self.base_url = REQUEST.URL0 + "?" + self.groups_query_args
self.base_url = request.base_url + "?" + self.groups_query_args
self.group_ids = group_ids
self.groups = []
groups_titles = []
@ -918,7 +916,7 @@ def form_choix_saisie_semaine(groups_infos, REQUEST=None):
del query_args["head_message"]
destination = "%s?%s" % (
REQUEST.URL,
six.moves.urllib.parse.urlencode(query_args, True),
urllib.parse.urlencode(query_args, True),
)
destination = destination.replace(
"%", "%%"

View File

@ -157,7 +157,6 @@ def sco_import_generate_excel_sample(
exclude_cols=[],
extra_cols=[],
group_ids=[],
REQUEST=None,
):
"""Generates an excel document based on format fmt
(format is the result of sco_import_format())
@ -188,7 +187,7 @@ def sco_import_generate_excel_sample(
titles += extra_cols
titlesStyles += [style] * len(extra_cols)
if group_ids:
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids, REQUEST=REQUEST)
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids)
members = groups_infos.members
log(
"sco_import_generate_excel_sample: group_ids=%s %d members"
@ -378,8 +377,12 @@ def scolars_import_excel_file(
# Excel date conversion:
if titleslist[i].lower() == "date_naissance":
if val:
# if re.match(r"^[0-9]*\.?[0-9]*$", str(val)):
val = sco_excel.xldate_as_datetime(val)
try:
val = sco_excel.xldate_as_datetime(val)
except ValueError:
raise ScoValueError(
f"date invalide ({val}) sur ligne {linenum}, colonne {titleslist[i]}"
)
# INE
if (
titleslist[i].lower() == "code_ine"

95
app/scodoc/sco_logos.py Normal file
View File

@ -0,0 +1,95 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2021 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""Gestion des images logos (nouveau ScoDoc 9)
Les logos sont `logo_header.<ext>` et `logo_footer.<ext>`
avec `ext` membre de LOGOS_IMAGES_ALLOWED_TYPES (= jpg, png)
SCODOC_LOGOS_DIR /opt/scodoc-data/config/logos
"""
import imghdr
import os
from flask import abort, current_app
from app.scodoc import sco_utils as scu
def get_logo_filename(logo_type: str, scodoc_dept: str) -> str:
"""return full filename for this logo, or "" if not found
an existing file with extension.
logo_type: "header" or "footer"
scodoc-dept: acronym
"""
# Search logos in dept specific dir (/opt/scodoc-data/config/logos/logos_<dept>),
# then in config dir /opt/scodoc-data/config/logos/
for image_dir in (
scu.SCODOC_LOGOS_DIR + "/logos_" + scodoc_dept,
scu.SCODOC_LOGOS_DIR, # global logos
):
for suffix in scu.LOGOS_IMAGES_ALLOWED_TYPES:
filename = os.path.join(image_dir, f"logo_{logo_type}.{suffix}")
if os.path.isfile(filename) and os.access(filename, os.R_OK):
return filename
return ""
def guess_image_type(stream) -> str:
"guess image type from header in stream"
header = stream.read(512)
stream.seek(0)
fmt = imghdr.what(None, header)
if not fmt:
return None
return fmt if fmt != "jpeg" else "jpg"
def _ensure_directory_exists(filename):
"create enclosing directory if necessary"
directory = os.path.split(filename)[0]
if not os.path.exists(directory):
current_app.logger.info(f"sco_logos creating directory %s", directory)
os.mkdir(directory)
def store_image(stream, basename):
img_type = guess_image_type(stream)
if img_type not in scu.LOGOS_IMAGES_ALLOWED_TYPES:
abort(400, "type d'image invalide")
filename = basename + "." + img_type
_ensure_directory_exists(filename)
with open(filename, "wb") as f:
f.write(stream.read())
current_app.logger.info(f"sco_logos.store_image %s", filename)
# erase other formats if they exists
for extension in set(scu.LOGOS_IMAGES_ALLOWED_TYPES) - set([img_type]):
try:
os.unlink(basename + "." + extension)
except IOError:
pass

View File

@ -221,7 +221,6 @@ _moduleimpl_inscriptionEditor = ndb.EditableTable(
def do_moduleimpl_inscription_create(args, formsemestre_id=None):
"create a moduleimpl_inscription"
cnx = ndb.GetDBConnexion()
log("do_moduleimpl_inscription_create: " + str(args))
r = _moduleimpl_inscriptionEditor.create(cnx, args)
sco_cache.invalidate_formsemestre(
formsemestre_id=formsemestre_id

View File

@ -33,6 +33,7 @@
En ScoDoc 9, ce n'est pas nécessaire car on est multiptocessus / monothread.
"""
import html
import io
import os
import queue
@ -85,7 +86,11 @@ def SU(s):
# car les "combining accents" ne sont pas traités par ReportLab mais peuvent
# nous être envoyés par certains navigateurs ou imports
# (on en a dans les bases de données)
return unicodedata.normalize("NFC", s)
s = unicodedata.normalize("NFC", s)
# Remplace les entité XML/HTML
# reportlab ne les supporte pas non plus.
s = html.unescape(s)
return s
def _splitPara(txt):

View File

@ -111,7 +111,7 @@ get_base_preferences(formsemestre_id)
"""
import flask
from flask import g
from flask import g, url_for
from app.models import Departement
from app.scodoc import sco_cache
@ -147,6 +147,52 @@ def get_preference(name, formsemestre_id=None):
return get_base_preferences().get(formsemestre_id, name)
def _convert_pref_type(p, pref_spec):
"""p est une ligne de la bd
{'id': , 'dept_id': , 'name': '', 'value': '', 'formsemestre_id': }
converti la valeur chane en le type désiré spécifié par pref_spec
"""
if "type" in pref_spec:
typ = pref_spec["type"]
if typ == "float":
# special case for float values (where NULL means 0)
if p["value"]:
p["value"] = float(p["value"])
else:
p["value"] = 0.0
else:
func = eval(typ)
p["value"] = func(p["value"])
if pref_spec.get("input_type", None) == "boolcheckbox":
# boolcheckbox: la valeur stockée en base est une chaine "0" ou "1"
# que l'on ressort en True|False
if p["value"]:
try:
p["value"] = bool(int(p["value"]))
except ValueError:
log(
f"""Warning: invalid value for boolean pref in db: '{p["value"]}'"""
)
p["value"] = False
else:
p["value"] = False # NULL (backward compat)
def _get_pref_default_value_from_config(name, pref_spec):
"""get default value store in application level config.
If not found, use defalut value hardcoded in pref_spec.
"""
# XXX va changer avec la nouvelle base
# search in scu.CONFIG
if hasattr(scu.CONFIG, name):
value = getattr(scu.CONFIG, name)
log("sco_preferences: found default value in config for %s=%s" % (name, value))
else:
# uses hardcoded default
value = pref_spec["initvalue"]
return value
PREF_CATEGORIES = (
# sur page "Paramètres"
("general", {}),
@ -469,21 +515,27 @@ class BasePreferences(object):
"abs_notification_mail_tmpl",
{
"initvalue": """
--- Ceci est un message de notification automatique issu de ScoDoc ---
--- Ceci est un message de notification automatique issu de ScoDoc ---
L'étudiant %(nomprenom)s
L'étudiant %(nomprenom)s
L'étudiant %(nomprenom)s
inscrit en %(inscription)s)
inscrit en %(inscription)s)
inscrit en %(inscription)s)
a cumulé %(nbabsjust)s absences justifiées
a cumulé %(nbabsjust)s absences justifiées
et %(nbabsnonjust)s absences NON justifiées.
a cumulé %(nbabsjust)s absences justifiées
et %(nbabsnonjust)s absences NON justifiées.
Le compte a pu changer depuis cet envoi, voir la fiche sur %(url_ficheetud)s.
Le compte a pu changer depuis cet envoi, voir la fiche sur %(url_ficheetud)s.
Votre dévoué serveur ScoDoc.
Votre dévoué serveur ScoDoc.
PS: Au dela de %(abs_notify_abs_threshold)s, un email automatique est adressé toutes les %(abs_notify_abs_increment)s absences. Ces valeurs sont modifiables dans les préférences de ScoDoc.
""",
PS: Au dela de %(abs_notify_abs_threshold)s, un email automatique est adressé toutes les %(abs_notify_abs_increment)s absences. Ces valeurs sont modifiables dans les préférences de ScoDoc.
""",
"title": """Message notification e-mail""",
"explanation": """Balises remplacées, voir la documentation""",
"input_type": "textarea",
@ -826,14 +878,18 @@ class BasePreferences(object):
"PV_INTRO",
{
"initvalue": """<bullet>-</bullet>
Vu l'arrêté du 3 août 2005 relatif au diplôme universitaire de technologie et notamment son article 4 et 6;
</para>
Vu l'arrêté du 3 août 2005 relatif au diplôme universitaire de technologie et notamment son article 4 et 6;
</para>
<para><bullet>-</bullet>
<para><bullet>-</bullet>
vu l'arrêté n° %(Decnum)s du Président de l'%(UnivName)s;
</para>
<para><bullet>-</bullet>
vu l'arrêté n° %(Decnum)s du Président de l'%(UnivName)s;
</para>
<para><bullet>-</bullet>
<para><bullet>-</bullet>
vu la délibération de la commission %(Type)s en date du %(Date)s présidée par le Chef du département;
""",
<para><bullet>-</bullet>
vu la délibération de la commission %(Type)s en date du %(Date)s présidée par le Chef du département;
""",
"title": """Paragraphe d'introduction sur le PV""",
"explanation": """Balises remplacées: %(Univname)s = nom de l'université, %(DecNum)s = numéro de l'arrêté, %(Date)s = date de la commission, %(Type)s = type de commission (passage ou délivrance), %(VDICode)s = code diplôme""",
"input_type": "textarea",
@ -940,8 +996,8 @@ class BasePreferences(object):
"PV_LETTER_PASSAGE_SIGNATURE",
{
"initvalue": """Pour le Directeur de l'IUT<br/>
et par délégation<br/>
Le Chef du département""",
et par délégation<br/>
Le Chef du département""",
"title": """Signature des lettres individuelles de passage d'un semestre à l'autre""",
"explanation": """%(DirectorName)s et %(DirectorTitle)s remplacés""",
"input_type": "textarea",
@ -965,43 +1021,45 @@ class BasePreferences(object):
"PV_LETTER_TEMPLATE",
{
"initvalue": """<para spaceBefore="1mm"> </para>
<para spaceBefore="20mm" leftindent="%(pv_htab1)s">%(INSTITUTION_CITY)s, le %(date_jury)s
</para>
<para spaceBefore="20mm" leftindent="%(pv_htab1)s">%(INSTITUTION_CITY)s, le %(date_jury)s
</para>
<para leftindent="%(pv_htab1)s" spaceBefore="10mm">
à <b>%(nomprenom)s</b>
</para>
<para leftindent="%(pv_htab1)s">%(domicile)s</para>
<para leftindent="%(pv_htab1)s">%(codepostaldomicile)s %(villedomicile)s</para>
<para leftindent="%(pv_htab1)s" spaceBefore="10mm">
à <b>%(nomprenom)s</b>
</para>
<para leftindent="%(pv_htab1)s">%(domicile)s</para>
<para leftindent="%(pv_htab1)s">%(codepostaldomicile)s %(villedomicile)s</para>
<para spaceBefore="25mm" fontSize="14" alignment="center">
<b>Jury de %(type_jury)s <br/> %(titre_formation)s</b>
</para>
<para spaceBefore="25mm" fontSize="14" alignment="center">
<b>Jury de %(type_jury)s <br/> %(titre_formation)s</b>
</para>
<para spaceBefore="10mm" fontSize="14" leftindent="0">
Le jury de %(type_jury_abbrv)s du département %(DeptName)s
<para spaceBefore="10mm" fontSize="14" leftindent="0">
Le jury de %(type_jury_abbrv)s du département %(DeptName)s
s'est réuni le %(date_jury)s.
s'est réuni le %(date_jury)s.
</para>
<para fontSize="14" leftindent="0">Les décisions vous concernant sont :
</para>
s'est réuni le %(date_jury)s.
</para>
<para fontSize="14" leftindent="0">Les décisions vous concernant sont :
</para>
<para leftindent="%(pv_htab2)s" spaceBefore="5mm" fontSize="14">%(prev_decision_sem_txt)s</para>
<para leftindent="%(pv_htab2)s" spaceBefore="5mm" fontSize="14">
<b>Décision %(decision_orig)s :</b> %(decision_sem_descr)s
</para>
<para leftindent="%(pv_htab2)s" spaceBefore="5mm" fontSize="14">%(prev_decision_sem_txt)s</para>
<para leftindent="%(pv_htab2)s" spaceBefore="5mm" fontSize="14">
<b>Décision %(decision_orig)s :</b> %(decision_sem_descr)s
</para>
<para leftindent="%(pv_htab2)s" spaceBefore="0mm" fontSize="14">
%(decision_ue_txt)s
</para>
<para leftindent="%(pv_htab2)s" spaceBefore="0mm" fontSize="14">
%(decision_ue_txt)s
</para>
<para leftindent="%(pv_htab2)s" spaceBefore="0mm" fontSize="14">
%(observation_txt)s
</para>
<para leftindent="%(pv_htab2)s" spaceBefore="0mm" fontSize="14">
%(observation_txt)s
</para>
<para spaceBefore="10mm" fontSize="14">%(autorisations_txt)s</para>
<para spaceBefore="10mm" fontSize="14">%(autorisations_txt)s</para>
<para spaceBefore="10mm" fontSize="14">%(diplome_txt)s</para>
""",
<para spaceBefore="10mm" fontSize="14">%(diplome_txt)s</para>
""",
"title": """Lettre individuelle""",
"explanation": """Balises remplacées et balisage XML, voir la documentation""",
"input_type": "textarea",
@ -1362,24 +1420,24 @@ class BasePreferences(object):
"bul_pdf_title",
{
"initvalue": """<para fontSize="14" align="center">
<b>%(UnivName)s</b>
</para>
<para fontSize="16" align="center" spaceBefore="2mm">
<b>%(InstituteName)s</b>
</para>
<para fontSize="16" align="center" spaceBefore="4mm">
<b>RELEVÉ DE NOTES</b>
</para>
<b>%(UnivName)s</b>
</para>
<para fontSize="16" align="center" spaceBefore="2mm">
<b>%(InstituteName)s</b>
</para>
<para fontSize="16" align="center" spaceBefore="4mm">
<b>RELEVÉ DE NOTES</b>
</para>
<para fontSize="15" spaceBefore="3mm">
%(nomprenom)s <b>%(demission)s</b>
</para>
<para fontSize="15" spaceBefore="3mm">
%(nomprenom)s <b>%(demission)s</b>
</para>
<para fontSize="14" spaceBefore="3mm">
Formation: %(titre_num)s</para>
<para fontSize="14" spaceBefore="2mm">
Année scolaire: %(anneescolaire)s
</para>""",
<para fontSize="14" spaceBefore="3mm">
Formation: %(titre_num)s</para>
<para fontSize="14" spaceBefore="2mm">
Année scolaire: %(anneescolaire)s
</para>""",
"title": "Bulletins PDF: paragraphe de titre",
"explanation": "(balises interprétées, voir documentation)",
"input_type": "textarea",
@ -1404,10 +1462,10 @@ class BasePreferences(object):
"bul_pdf_sig_left",
{
"initvalue": """<para>La direction des études
<br/>
%(responsable)s
</para>
""",
<br/>
%(responsable)s
</para>
""",
"title": "Bulletins PDF: signature gauche",
"explanation": "(balises interprétées, voir documentation)",
"input_type": "textarea",
@ -1420,10 +1478,10 @@ class BasePreferences(object):
"bul_pdf_sig_right",
{
"initvalue": """<para>Le chef de département
<br/>
%(ChiefDeptName)s
</para>
""",
<br/>
%(ChiefDeptName)s
</para>
""",
"title": "Bulletins PDF: signature droite",
"explanation": "(balises interprétées, voir documentation)",
"input_type": "textarea",
@ -1799,88 +1857,57 @@ class BasePreferences(object):
def load(self):
"""Load all preferences from db"""
log(f"loading preferences for dept_id={self.dept_id}")
try:
scu.GSL.acquire()
cnx = ndb.GetDBConnexion()
preflist = self._editor.list(cnx, {"dept_id": self.dept_id})
self.prefs = {None: {}} # { formsemestre_id (or None) : { name : value } }
self.default = {} # { name : default_value }
for p in preflist:
if not p["formsemestre_id"] in self.prefs:
self.prefs[p["formsemestre_id"]] = {}
# Ignore les noms de préférences non utilisés dans le code:
if p["name"] not in self.prefs_dict:
continue
# Convert types:
if (
p["name"] in self.prefs_dict
and "type" in self.prefs_dict[p["name"]]
):
typ = self.prefs_dict[p["name"]]["type"]
if typ == "float":
# special case for float values (where NULL means 0)
if p["value"]:
p["value"] = float(p["value"])
else:
p["value"] = 0.0
else:
func = eval(typ)
p["value"] = func(p["value"])
if (
p["name"] in self.prefs_dict
and self.prefs_dict[p["name"]].get("input_type", None)
== "boolcheckbox"
):
# boolcheckbox: la valeur stockée en base est une chaine "0" ou "1"
# que l'on ressort en True|False
if p["value"]:
try:
p["value"] = bool(int(p["value"]))
except ValueError:
log(
f"""Warning: invalid value for boolean pref in db: '{p["value"]}'"""
)
p["value"] = False
else:
p["value"] = False # NULL (backward compat)
self.prefs[p["formsemestre_id"]][p["name"]] = p["value"]
# add defaults for missing prefs
for pref in self.prefs_definition:
name = pref[0]
# search preferences in configuration file
if name and name[0] != "_" and name not in self.prefs[None]:
# search in scu.CONFIG
if hasattr(scu.CONFIG, name):
value = getattr(scu.CONFIG, name)
log(
"sco_preferences: found default value in config for %s=%s"
% (name, value)
)
else:
# uses hardcoded default
value = pref[1]["initvalue"]
cnx = ndb.GetDBConnexion()
preflist = self._editor.list(cnx, {"dept_id": self.dept_id})
self.prefs = {None: {}} # { formsemestre_id (or None) : { name : value } }
self.default = {} # { name : default_value }
for p in preflist:
if not p["formsemestre_id"] in self.prefs:
self.prefs[p["formsemestre_id"]] = {}
# Ignore les noms de préférences non utilisés dans le code:
if p["name"] not in self.prefs_dict:
continue
self.default[name] = value
self.prefs[None][name] = value
log("creating missing preference for %s=%s" % (name, value))
# add to db table
self._editor.create(
cnx, {"dept_id": self.dept_id, "name": name, "value": value}
)
finally:
scu.GSL.release()
# Convert types:
if p["name"] in self.prefs_dict:
_convert_pref_type(p, self.prefs_dict[p["name"]])
self.prefs[p["formsemestre_id"]][p["name"]] = p["value"]
# add defaults for missing prefs
for pref in self.prefs_definition:
name = pref[0]
# search preferences in configuration file
if name and name[0] != "_" and name not in self.prefs[None]:
value = _get_pref_default_value_from_config(name, pref[1])
self.default[name] = value
self.prefs[None][name] = value
log("creating missing preference for %s=%s" % (name, value))
# add to db table
self._editor.create(
cnx, {"dept_id": self.dept_id, "name": name, "value": value}
)
def get(self, formsemestre_id, name):
"""Returns preference value.
If no value defined for this semestre, returns global value.
If global_lookup, when no value defined for this semestre, returns global value.
"""
if formsemestre_id in self.prefs and name in self.prefs[formsemestre_id]:
return self.prefs[formsemestre_id][name]
elif name in self.prefs[None]:
return self.prefs[None][name]
else:
return self.default[name]
params = {
"dept_id": self.dept_id,
"name": name,
"formsemestre_id": formsemestre_id,
}
cnx = ndb.GetDBConnexion()
plist = self._editor.list(cnx, params)
if not plist:
del params["formsemestre_id"]
plist = self._editor.list(cnx, params)
if not plist:
return self.default[name]
p = plist[0]
_convert_pref_type(p, self.prefs_dict[name])
return p["value"]
def __contains__(self, item):
return item in self.prefs[None]
@ -1890,74 +1917,75 @@ class BasePreferences(object):
def is_global(self, formsemestre_id, name):
"True if name if not defined for semestre"
if (
not (formsemestre_id in self.prefs)
or not name in self.prefs[formsemestre_id]
):
return True
else:
return False
params = {
"dept_id": self.dept_id,
"name": name,
"formsemestre_id": formsemestre_id,
}
cnx = ndb.GetDBConnexion()
plist = self._editor.list(cnx, params)
return len(plist) == 0
def save(self, formsemestre_id=None, name=None):
"""Write one or all (if name is None) values to db"""
try:
scu.GSL.acquire()
modif = False
cnx = ndb.GetDBConnexion()
if name is None:
names = list(self.prefs[formsemestre_id].keys())
else:
names = [name]
for name in names:
value = self.get(formsemestre_id, name)
if self.prefs_dict[name].get("input_type", None) == "boolcheckbox":
# repasse les booleens en chaines "0":"1"
value = "1" if value else "0"
# existe deja ?
pdb = self._editor.list(
modif = False
cnx = ndb.GetDBConnexion()
if name is None:
names = list(self.prefs[formsemestre_id].keys())
else:
names = [name]
for name in names:
value = self.prefs[formsemestre_id][name]
if self.prefs_dict[name].get("input_type", None) == "boolcheckbox":
# repasse les booleens en chaines "0":"1"
value = "1" if value else "0"
# existe deja ?
pdb = self._editor.list(
cnx,
args={
"dept_id": self.dept_id,
"formsemestre_id": formsemestre_id,
"name": name,
},
)
if not pdb:
# crée préférence
log("create pref sem=%s %s=%s" % (formsemestre_id, name, value))
self._editor.create(
cnx,
args={
{
"dept_id": self.dept_id,
"formsemestre_id": formsemestre_id,
"name": name,
"value": value,
"formsemestre_id": formsemestre_id,
},
)
if not pdb:
# crée préférence
log("create pref sem=%s %s=%s" % (formsemestre_id, name, value))
self._editor.create(
modif = True
log("create pref sem=%s %s=%s" % (formsemestre_id, name, value))
else:
# edit existing value
existing_value = pdb[0]["value"] # old stored value
if (
(existing_value != value)
and (existing_value != str(value))
and (existing_value or str(value))
):
self._editor.edit(
cnx,
{
"dept_id": self.dept_id,
"pref_id": pdb[0]["pref_id"],
"formsemestre_id": formsemestre_id,
"name": name,
"value": value,
"formsemestre_id": formsemestre_id,
},
)
modif = True
log("create pref sem=%s %s=%s" % (formsemestre_id, name, value))
else:
# edit existing value
if pdb[0]["value"] != str(value) and (
pdb[0]["value"] or str(value)
):
self._editor.edit(
cnx,
{
"pref_id": pdb[0]["pref_id"],
"formsemestre_id": formsemestre_id,
"name": name,
"value": value,
},
)
modif = True
log("save pref sem=%s %s=%s" % (formsemestre_id, name, value))
log("save pref sem=%s %s=%s" % (formsemestre_id, name, value))
# les preferences peuvent affecter les PDF cachés et les notes calculées:
if modif:
sco_cache.invalidate_formsemestre()
finally:
scu.GSL.release()
# les preferences peuvent affecter les PDF cachés et les notes calculées:
if modif:
sco_cache.invalidate_formsemestre()
def set(self, formsemestre_id, name, value):
if not name or name[0] == "_" or name not in self.prefs_name:
@ -1972,29 +2000,29 @@ class BasePreferences(object):
def delete(self, formsemestre_id, name):
if not formsemestre_id:
raise ScoException()
try:
scu.GSL.acquire()
if formsemestre_id in self.prefs and name in self.prefs[formsemestre_id]:
del self.prefs[formsemestre_id][name]
cnx = ndb.GetDBConnexion()
pdb = self._editor.list(
cnx, args={"formsemestre_id": formsemestre_id, "name": name}
)
if pdb:
log("deleting pref sem=%s %s" % (formsemestre_id, name))
assert pdb[0]["dept_id"] == self.dept_id
self._editor.delete(cnx, pdb[0]["pref_id"])
sco_cache.invalidate_formsemestre() # > modif preferences
finally:
scu.GSL.release()
if formsemestre_id in self.prefs and name in self.prefs[formsemestre_id]:
del self.prefs[formsemestre_id][name]
cnx = ndb.GetDBConnexion()
pdb = self._editor.list(
cnx, args={"formsemestre_id": formsemestre_id, "name": name}
)
if pdb:
log("deleting pref sem=%s %s" % (formsemestre_id, name))
assert pdb[0]["dept_id"] == self.dept_id
self._editor.delete(cnx, pdb[0]["pref_id"])
sco_cache.invalidate_formsemestre() # > modif preferences
def edit(self, REQUEST):
"""HTML dialog: edit global preferences"""
from app.scodoc import html_sco_header
self.load()
H = [
html_sco_header.sco_header(page_title="Préférences"),
"<h2>Préférences globales pour %s</h2>" % scu.ScoURL(),
f"""<p><a href="{url_for("scolar.config_logos", scodoc_dept=g.scodoc_dept)
}">modification des logos du département (pour documents pdf)</a></p>""",
"""<p class="help">Ces paramètres s'appliquent par défaut à tous les semestres, sauf si ceux-ci définissent des valeurs spécifiques.</p>
<p class="msg">Attention: cliquez sur "Enregistrer les modifications" en bas de page pour appliquer vos changements !</p>
""",

View File

@ -622,7 +622,7 @@ def formsemestre_pvjury_pdf(formsemestre_id, group_ids=[], etudid=None, REQUEST=
group_ids = [sco_groups.get_default_group(formsemestre_id)]
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids, formsemestre_id=formsemestre_id, REQUEST=REQUEST
group_ids, formsemestre_id=formsemestre_id
)
etudids = [m["etudid"] for m in groups_infos.members]
@ -800,7 +800,7 @@ def formsemestre_lettres_individuelles(formsemestre_id, group_ids=[], REQUEST=No
# tous les inscrits du semestre
group_ids = [sco_groups.get_default_group(formsemestre_id)]
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids, formsemestre_id=formsemestre_id, REQUEST=REQUEST
group_ids, formsemestre_id=formsemestre_id
)
etudids = [m["etudid"] for m in groups_infos.members]

View File

@ -620,7 +620,6 @@ def saisie_notes_tableur(evaluation_id, group_ids=[], REQUEST=None):
formsemestre_id=formsemestre_id,
select_all_when_unspecified=True,
etat=None,
REQUEST=REQUEST,
)
H = [
@ -793,7 +792,6 @@ def feuille_saisie_notes(evaluation_id, group_ids=[], REQUEST=None):
formsemestre_id=formsemestre_id,
select_all_when_unspecified=True,
etat=None,
REQUEST=REQUEST,
)
groups = sco_groups.listgroups(groups_infos.group_ids)
gr_title_filename = sco_groups.listgroups_filename(groups)
@ -891,7 +889,6 @@ def saisie_notes(evaluation_id, group_ids=[], REQUEST=None):
formsemestre_id=formsemestre_id,
select_all_when_unspecified=True,
etat=None,
REQUEST=REQUEST,
)
if E["description"]:

View File

@ -158,11 +158,10 @@ class SemSet(dict):
ndb.SimpleQuery(
"""INSERT INTO notes_semset_formsemestre
(dept_id, id, semset_id)
VALUES (%(dept_id)s, %(formsemestre_id)s, %(semset_id)s)
(formsemestre_id, semset_id)
VALUES (%(formsemestre_id)s, %(semset_id)s)
""",
{
"dept_id": g.scodoc_dept_id,
"formsemestre_id": formsemestre_id,
"semset_id": self.semset_id,
},

View File

@ -78,7 +78,7 @@ def trombino(
etat = None # may be passed as ''
# Informations sur les groupes à afficher:
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids, formsemestre_id=formsemestre_id, etat=etat, REQUEST=REQUEST
group_ids, formsemestre_id=formsemestre_id, etat=etat
)
#
@ -247,7 +247,7 @@ def _trombino_zip(groups_infos):
# Copy photos from portal to ScoDoc
def trombino_copy_photos(group_ids=[], REQUEST=None, dialog_confirmed=False):
"Copy photos from portal to ScoDoc (overwriting local copy)"
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids, REQUEST=REQUEST)
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids)
back_url = "groups_view?%s&curtab=tab-photos" % groups_infos.groups_query_args
portal_url = sco_portal_apogee.get_portal_url()
@ -485,14 +485,13 @@ def photos_generate_excel_sample(group_ids=[], REQUEST=None):
"photo_filename",
],
extra_cols=["fichier_photo"],
REQUEST=REQUEST,
)
return sco_excel.send_excel_file(REQUEST, data, "ImportPhotos" + scu.XLSX_SUFFIX)
def photos_import_files_form(group_ids=[], REQUEST=None):
"""Formulaire pour importation photos"""
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids, REQUEST=REQUEST)
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids)
back_url = "groups_view?%s&curtab=tab-photos" % groups_infos.groups_query_args
H = [
@ -541,7 +540,7 @@ def photos_import_files_form(group_ids=[], REQUEST=None):
def photos_import_files(group_ids=[], xlsfile=None, zipfile=None, REQUEST=None):
"""Importation des photos"""
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids, REQUEST=REQUEST)
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids)
back_url = "groups_view?%s&curtab=tab-photos" % groups_infos.groups_query_args
filename_title = "fichier_photo"
page_title = "Téléchargement des photos des étudiants"

View File

@ -61,7 +61,7 @@ def pdf_trombino_tours(
"""Generation du trombinoscope en fichier PDF"""
# Informations sur les groupes à afficher:
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids, formsemestre_id=formsemestre_id, REQUEST=REQUEST
group_ids, formsemestre_id=formsemestre_id
)
DeptName = sco_preferences.get_preference("DeptName")
@ -296,7 +296,7 @@ def pdf_feuille_releve_absences(
# Informations sur les groupes à afficher:
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids, formsemestre_id=formsemestre_id, REQUEST=REQUEST
group_ids, formsemestre_id=formsemestre_id
)
DeptName = sco_preferences.get_preference("DeptName")

View File

@ -28,104 +28,19 @@
""" Verification version logiciel vs version "stable" sur serveur
N'effectue pas la mise à jour automatiquement, mais permet un affichage d'avertissement.
Désactivé temporairement pour ScoDoc 9.
"""
import datetime
import app.scodoc.sco_utils as scu
from app import log
# Appel renvoyant la subversion "stable"
# La notion de "stable" est juste là pour éviter d'afficher trop frequemment
# des avertissements de mise à jour: on veut pouvoir inciter à mettre à jour lors de
# correctifs majeurs.
GET_VER_URL = "http://scodoc.iutv.univ-paris13.fr/scodoc-installmgr/last_stable_version"
def get_last_stable_version():
"""request last stable version number from server
(returns string as given by server, empty if failure)
(do not wait server answer more than 3 seconds)
"""
global _LAST_UP_TO_DATE_REQUEST
ans = scu.query_portal(
GET_VER_URL, msg="ScoDoc version server", timeout=3
) # sco_utils
if ans:
ans = ans.strip()
_LAST_UP_TO_DATE_REQUEST = datetime.datetime.now()
log(
'get_last_stable_version: updated at %s, answer="%s"'
% (_LAST_UP_TO_DATE_REQUEST, ans)
)
return ans
_LAST_UP_TO_DATE_REQUEST = None # datetime of last request to server
_UP_TO_DATE = True # cached result (limit requests to 1 per day)
_UP_TO_DATE_MSG = ""
from flask import current_app
def is_up_to_date():
"""True if up_to_date
Returns status, message
"""
log("Warning: is_up_to_date not implemented for ScoDoc8")
current_app.logger.debug("Warning: is_up_to_date not implemented for ScoDoc9")
return True, "unimplemented"
# global _LAST_UP_TO_DATE_REQUEST, _UP_TO_DATE, _UP_TO_DATE_MSG
# if _LAST_UP_TO_DATE_REQUEST and (
# datetime.datetime.now() - _LAST_UP_TO_DATE_REQUEST
# ) < datetime.timedelta(1):
# # requete deja effectuee aujourd'hui:
# return _UP_TO_DATE, _UP_TO_DATE_MSG
# last_stable_ver = get_last_stable_version()
# cur_ver = scu.get_svn_version(scu.SCO_SRC_DIR) # in sco_utils
# cur_ver2 = cur_ver
# cur_ver_num = -1
# # Convert versions to integers:
# try:
# # cur_ver can be "1234" or "1234M' or '1234:1245M'...
# fs = cur_ver.split(":", 1)
# if len(fs) > 1:
# cur_ver2 = fs[-1]
# m = re.match(r"([0-9]*)", cur_ver2)
# if not m:
# raise ValueError(
# "invalid svn version"
# ) # should never occur, regexp always (maybe empty) match
# cur_ver_num = int(m.group(1))
# except:
# log('Warning: no numeric subversion ! (cur_ver="%s")' % cur_ver)
# return _UP_TO_DATE, _UP_TO_DATE_MSG # silently ignore misconfiguration ?
# try:
# last_stable_ver_num = int(last_stable_ver)
# except:
# log("Warning: last_stable_version returned by server is invalid !")
# return (
# _UP_TO_DATE,
# _UP_TO_DATE_MSG,
# ) # should ignore this error (maybe server is unreachable)
# #
# if cur_ver_num < last_stable_ver_num:
# _UP_TO_DATE = False
# _UP_TO_DATE_MSG = "Version %s disponible (version %s installée)" % (
# last_stable_ver,
# cur_ver_num,
# )
# log(
# "Warning: ScoDoc installation is not up-to-date, should upgrade\n%s"
# % _UP_TO_DATE_MSG
# )
# else:
# _UP_TO_DATE = True
# _UP_TO_DATE_MSG = ""
# log(
# "ScoDoc is up-to-date (cur_ver: %s, using %s=%s)"
# % (cur_ver, cur_ver2, cur_ver_num)
# )
# return _UP_TO_DATE, _UP_TO_DATE_MSG
def html_up_to_date_box():

View File

@ -232,6 +232,8 @@ if not os.path.exists(SCO_TMP_DIR):
os.mkdir(SCO_TMP_DIR, 0o755)
# ----- Les logos: /opt/scodoc-data/config/logos
SCODOC_LOGOS_DIR = os.path.join(SCODOC_CFG_DIR, "logos")
LOGOS_IMAGES_ALLOWED_TYPES = ("jpg", "jpeg", "png") # remind that PIL does not read pdf
# ----- Les outils distribués
SCO_TOOLS_DIR = os.path.join(Config.SCODOC_DIR, "tools")
@ -305,8 +307,6 @@ PDF_MIMETYPE = "application/pdf"
XML_MIMETYPE = "text/xml"
JSON_MIMETYPE = "application/json"
LOGOS_IMAGES_ALLOWED_TYPES = ("jpg", "png") # remind that PIL does not read pdf
# Admissions des étudiants
# Différents types de voies d'admission:
# (stocké en texte libre dans la base, mais saisie par menus pour harmoniser)
@ -672,14 +672,14 @@ def graph_from_edges(edges, graph_name="mygraph"):
ICONSIZES = {} # name : (width, height) cache image sizes
def icontag(name, file_format="png", **attrs):
def icontag(name, file_format="png", no_size=False, **attrs):
"""tag HTML pour un icone.
(dans les versions anterieures on utilisait Zope)
Les icones sont des fichiers PNG dans .../static/icons
Si la taille (width et height) n'est pas spécifiée, lit l'image
pour la mesurer (et cache le résultat).
"""
if ("width" not in attrs) or ("height" not in attrs):
if (not no_size) and (("width" not in attrs) or ("height" not in attrs)):
if name not in ICONSIZES:
img_file = os.path.join(
Config.SCODOC_DIR,

View File

@ -96,6 +96,10 @@ tr.bandeaugtr {
text-decoration: underline;
}
.navbar-default .navbar-nav>li.logout a {
color: rgb(255,0,0);
}
/* ----- page content ------ */
div.about-logo {
@ -253,6 +257,13 @@ div.logo-insidebar {
div.logo-logo {
text-align: center ;
}
div.logo-logo img {
margin-top: 20px;
width: 100px;
}
div.sidebar-bottom {
margin-top: 10px;
}
div.etud_info_div {
border: 2px solid gray;
@ -317,6 +328,9 @@ table.listesems th {
padding-top: 0.5em;
padding-left: 0.5em;
}
table.listesems td {
vertical-align: center;
}
table.listesems td.semicon {
padding-left: 1.5em;
@ -326,6 +340,11 @@ table.listesems tr.firstsem td {
padding-top: 0.8em;
}
td.datesem {
font-size: 80%;
white-space: nowrap;
}
h2.listesems {
padding-top: 10px;
padding-bottom: 0px;
@ -816,11 +835,22 @@ a.discretelink:hover {
div.sco_help {
margin-top: 12px;
margin-bottom: 3px;
font-style: italic;
color: navy;
background-color: rgb(200,200,220);
}
span.wtf-field ul.errors li {
color: red;
}
.configuration_logo div.img-container {
width: 256px;
}
.configuration_logo div.img-container img {
max-width: 100%;
}
p.indent {
padding-left: 2em;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -25,15 +25,21 @@
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
{% if current_user.is_administrator() %}
<ul class="nav navbar-nav">
<li><a href="{{ url_for('scodoc.configuration') }}">Configuration</a></li>
<li><a href="{{ url_for('scodoc.configuration') }}">configuration</a></li>
</ul>
{% endif %}
<ul class="nav navbar-nav navbar-right">
{% if current_user.is_anonymous %}
<li><a href="{{ url_for('auth.login') }}">Login</a></li>
<li><a href="{{ url_for('auth.login') }}">connexion</a></li>
{% else %}
<li>{{current_user.user_name}}</li>
<li><a href="{{ url_for('auth.logout') }}">Logout</a></li>
<li>{% if current_user.dept %}
<a href="{{ url_for('users.user_info_page', scodoc_dept=current_user.dept, user_name=current_user.user_name )
}}">{{current_user.user_name}} ({{current_user.dept}})</a>
{% else %}
<a href="">{{current_user.user_name}}</a>
{% endif %}
</li>
<li class="logout"><a href="{{ url_for('auth.logout') }}">déconnexion</a></li>
{% endif %}
</ul>
</div>

View File

@ -2,32 +2,52 @@
{% import 'bootstrap/wtf.html' as wtf %}
{% macro render_field(field) %}
<div>
<span class="wtf-field">{{ field.label }} :</span>
<span class="wtf-field">{{ field()|safe }}
<div>
<span class="wtf-field">{{ field.label }} :</span>
<span class="wtf-field">{{ field()|safe }}
{% if field.errors %}
<ul class=errors>
<ul class=errors>
{% for error in field.errors %}
<li>{{ error }}</li>
<li>{{ error }}</li>
{% endfor %}
</ul>
</ul>
{% endif %}
</span>
</div>
</span>
</div>
{% endmacro %}
{% block app_content %}
<h1>Configuration générale</h1>
<p class="help">Les paramètres donnés ici s'appliquent à tout ScoDoc (tous les départements).</p>
{% if scodoc_dept %}
<h1>Logos du département {{ scodoc_dept }}</h1>
{% else %}
<h1>Configuration générale {{ scodoc_dept }}</h1>
{% endif %}
<form class="sco-form" action="" method="post" novalidate>
<form class="sco-form" action="" method="post" enctype="multipart/form-data" novalidate>
{{ form.hidden_tag() }}
{% if not scodoc_dept %}
<div class="sco_help">Les paramètres donnés ici s'appliquent à tout ScoDoc (tous les départements):</div>
{{ render_field(form.bonus_sport_func_name)}}
{# <p>
{{ form.bonus_sport_func_name.label }}<br>
{{ form.bonus_sport_func_name() }}
</p> #}
{% endif %}
<div class="configuration_logo">
<h3>Logo en-tête</h3>
<p class="help">image placée en haut de certains documents documents PDF. Image actuelle:</p>
<div class="img-container"><img src="{{ url_for('scodoc.logo_header', scodoc_dept=scodoc_dept) }}"
alt="pas de logo chargé" /></div>
{{ render_field(form.logo_header) }}
<h3>Logo pied de page</h3>
<p class="help">image placée en pied de page de certains documents documents PDF. Image actuelle:</p>
<div class="img-container"><img src="{{ url_for('scodoc.logo_footer', scodoc_dept=g.scodoc_dept) }}"
alt="pas de logo chargé" /></div>
{{ render_field(form.logo_footer) }}
</div>
<!-- <div class="sco_help">Les paramètres ci-dessous peuvent être changés dans chaque département
(paramétrage).<br />On indique ici les valeurs initiales par défaut:
</div> -->
<div class="sco-submit">{{ form.submit() }}</div>
</form>
{% endblock %}
{% endblock %}

View File

@ -30,7 +30,7 @@
</p>
{% if current_user.is_authenticated %}
<form action="table_etud_in_accessible_depts" method="POST">
<form action="{{url_for('scodoc.table_etud_in_accessible_depts')}}" method="POST">
<b>Chercher étudiant:</b>
<input type="text" name="expnom" width="12" spellcheck="false" value="">
<input type="submit" value="Chercher">
@ -38,8 +38,9 @@
</form>
{% endif %}
<!--
<div style="margin-top: 1cm;">
<p><a href="/ScoDoc/static/mobile">Charger la version mobile (expérimentale)</a></p>
</div>
</div> -->
{% endblock %}

View File

@ -68,6 +68,7 @@ from app.decorators import (
permission_required,
admin_required,
login_required,
permission_required_compat_scodoc7,
)
from app.views import absences_bp as bp
@ -312,7 +313,7 @@ def SignaleAbsenceGrHebdo(
moduleimpl_id = None
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids, moduleimpl_id=moduleimpl_id, REQUEST=REQUEST
group_ids, moduleimpl_id=moduleimpl_id
)
if not groups_infos.members:
return (
@ -474,7 +475,7 @@ def SignaleAbsenceGrSemestre(
REQUEST=None,
):
"""Saisie des absences sur une journée sur un semestre (ou intervalle de dates) entier"""
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids, REQUEST=REQUEST)
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids)
if not groups_infos.members:
return (
html_sco_header.sco_header(page_title="Saisie des absences")
@ -847,7 +848,7 @@ def EtatAbsencesGr(
datedebut = ndb.DateDMYtoISO(debut)
datefin = ndb.DateDMYtoISO(fin)
# Informations sur les groupes à afficher:
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids, REQUEST=REQUEST)
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids)
formsemestre_id = groups_infos.formsemestre_id
sem = groups_infos.formsemestre
@ -971,13 +972,11 @@ ou entrez une date pour visualiser les absents un jour donné&nbsp;:
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def EtatAbsencesDate(
group_ids=[], date=None, REQUEST=None # list of groups to display
):
def EtatAbsencesDate(group_ids=[], date=None): # list of groups to display
# ported from dtml
"""Etat des absences pour un groupe à une date donnée"""
# Informations sur les groupes à afficher:
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids, REQUEST=REQUEST)
groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids)
H = [html_sco_header.sco_header(page_title="Etat des absences")]
if date:
dateiso = ndb.DateDMYtoISO(date)
@ -1236,9 +1235,11 @@ def listeBilletsEtud(etudid=False, REQUEST=None, format="html"):
return tab.make_page(REQUEST=REQUEST, format=format)
@bp.route("/XMLgetBilletsEtud")
@bp.route(
"/XMLgetBilletsEtud", methods=["GET", "POST"]
) # pour compat anciens clients PHP
@scodoc
@permission_required(Permission.ScoView)
@permission_required_compat_scodoc7(Permission.ScoView)
@scodoc7func
def XMLgetBilletsEtud(etudid=False, REQUEST=None):
"""Liste billets pour un etudiant"""
@ -1250,9 +1251,9 @@ def XMLgetBilletsEtud(etudid=False, REQUEST=None):
return r
@bp.route("/listeBillets")
@bp.route("/listeBillets", methods=["GET", "POST"]) # pour compat anciens clients PHP
@scodoc
@permission_required(Permission.ScoView)
@permission_required_compat_scodoc7(Permission.ScoView)
@scodoc7func
def listeBillets(REQUEST=None):
"""Page liste des billets non traités et formulaire recherche d'un billet"""
@ -1461,9 +1462,19 @@ def ProcessBilletAbsenceForm(billet_id, REQUEST=None):
return "\n".join(H) + html_sco_header.sco_footer()
@bp.route("/XMLgetAbsEtud")
# @bp.route("/essai_api7")
# @scodoc
# @permission_required_compat_scodoc7(Permission.ScoView)
# @scodoc7func
# def essai_api7(x="xxx"):
# "un essai"
# log("arfffffffffffffffffff")
# return "OK OK x=" + str(x)
@bp.route("/XMLgetAbsEtud", methods=["GET", "POST"]) # pour compat anciens clients PHP
@scodoc
@permission_required(Permission.ScoView)
@permission_required_compat_scodoc7(Permission.ScoView)
@scodoc7func
def XMLgetAbsEtud(beg_date="", end_date="", REQUEST=None):
"""returns list of absences in date interval"""

View File

@ -50,6 +50,7 @@ from app.decorators import (
scodoc,
scodoc7func,
permission_required,
permission_required_compat_scodoc7,
admin_required,
login_required,
)
@ -252,11 +253,36 @@ sco_publish(
Permission.ScoChangeFormation,
methods=["GET", "POST"],
)
sco_publish(
"/formsemestre_bulletinetud",
sco_bulletins.formsemestre_bulletinetud,
Permission.ScoView,
)
@bp.route(
"formsemestre_bulletinetud", methods=["GET", "POST"]
) # pour compat anciens clients PHP
@scodoc
@permission_required_compat_scodoc7(Permission.ScoView)
@scodoc7func
def formsemestre_bulletinetud(
etudid=None,
formsemestre_id=None,
format="html",
version="long",
xml_with_decisions=False,
force_publishing=False,
prefer_mail_perso=False,
REQUEST=None,
):
return sco_bulletins.formsemestre_bulletinetud(
etudid=etudid,
formsemestre_id=formsemestre_id,
format=format,
version=version,
xml_with_decisions=xml_with_decisions,
force_publishing=force_publishing,
prefer_mail_perso=prefer_mail_perso,
REQUEST=REQUEST,
)
sco_publish(
"/formsemestre_evaluations_cal",
sco_evaluations.formsemestre_evaluations_cal,
@ -573,9 +599,11 @@ sco_publish("/ue_move", sco_edit_formation.ue_move, Permission.ScoChangeFormatio
# --- Semestres de formation
@bp.route("/formsemestre_list")
@bp.route(
"/formsemestre_list", methods=["GET", "POST"]
) # pour compat anciens clients PHP
@scodoc
@permission_required(Permission.ScoView)
@permission_required_compat_scodoc7(Permission.ScoView)
@scodoc7func
def formsemestre_list(
format=None,
@ -599,9 +627,11 @@ def formsemestre_list(
return scu.sendResult(REQUEST, sems, name="formsemestre", format=format)
@bp.route("/XMLgetFormsemestres")
@bp.route(
"/XMLgetFormsemestres", methods=["GET", "POST"]
) # pour compat anciens clients PHP
@scodoc
@permission_required(Permission.ScoView)
@permission_required_compat_scodoc7(Permission.ScoView)
@scodoc7func
def XMLgetFormsemestres(etape_apo=None, formsemestre_id=None, REQUEST=None):
"""List all formsemestres matching etape, XML format

View File

@ -30,20 +30,35 @@ Module main: page d'accueil, avec liste des départements
Emmanuel Viennet, 2021
"""
from app.auth.models import User
import os
import flask
from flask import flash, url_for, redirect, render_template
from flask import abort, flash, url_for, redirect, render_template, send_file
from flask import request
from flask.app import Flask
from flask_login.utils import login_required
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed
from werkzeug.exceptions import BadRequest, NotFound
from wtforms import SelectField, SubmitField
from wtforms.fields import IntegerField
from wtforms.fields.simple import BooleanField, StringField, TextAreaField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo
# from wtforms.validators import DataRequired
from app.models import Departement, ScoDocSiteConfig
import app
from app.models import Departement, Identite
from app.models import FormSemestre, NotesFormsemestreInscription
from app.models import ScoDocSiteConfig
import sco_version
from app.scodoc import sco_logos
from app.scodoc import sco_find_etud
from app.decorators import admin_required
from app.scodoc import sco_utils as scu
from app.decorators import (
admin_required,
scodoc7func,
permission_required_compat_scodoc7,
)
from app.scodoc.sco_permissions import Permission
from app.views import scodoc_bp as bp
@ -72,13 +87,56 @@ def table_etud_in_accessible_depts():
return sco_find_etud.table_etud_in_accessible_depts(expnom=request.form["expnom"])
# Fonction d'API accessible sans aucun authentification
@bp.route("/ScoDoc/get_etud_dept")
def get_etud_dept():
"""Returns the dept acronym (eg "GEII") of an etud (identified by etudid,
code_nip ou code_ine in the request).
Ancienne API: ramène la chaine brute, texte sans JSON ou XML.
"""
if "etudid" in request.args:
# zero ou une réponse:
etuds = [Identite.query.get(request.args["etudid"])]
elif "code_nip" in request.args:
# il peut y avoir plusieurs réponses si l'étudiant est passé par plusieurs départements
etuds = Identite.query.filter_by(code_nip=request.args["code_nip"]).all()
elif "code_ine" in request.args:
etuds = Identite.query.filter_by(code_nip=request.args["code_ine"]).all()
else:
raise BadRequest(
"missing argument (expected one among: etudid, code_nip or code_ine)"
)
if not etuds:
raise NotFound("student not found")
elif len(etuds) == 1:
last_etud = etuds[0]
else:
# inscriptions dans plusieurs departements: cherche la plus recente
last_etud = None
last_date = None
for etud in etuds:
inscriptions = NotesFormsemestreInscription.query.filter_by(
etudid=etud.id
).all()
for ins in inscriptions:
date_fin = FormSemestre.query.get(ins.formsemestre_id).date_fin
if (last_date is None) or date_fin > last_date:
last_date = date_fin
last_etud = etud
if not last_etud:
# est présent dans plusieurs semestres mais inscrit dans aucun !
# le choix a peu d'importance...
last_etud = etuds[-1]
return Departement.query.get(last_etud.dept_id).acronym
# ---- CONFIGURATION
class ScoDocConfigurationForm(FlaskForm):
"Panneau de configuration général"
# très préliminaire ;-)
# On veut y mettre la fonction bonus et ensuite les logos
bonus_sport_func_name = SelectField(
label="Fonction de calcul des bonus sport&culture",
choices=[
@ -86,28 +144,104 @@ class ScoDocConfigurationForm(FlaskForm):
for x in ScoDocSiteConfig.get_bonus_sport_func_names()
],
)
logo_header = FileField(
label="Modifier l'image:",
description="logo placé en haut des documents PDF",
validators=[
FileAllowed(
scu.LOGOS_IMAGES_ALLOWED_TYPES,
f"n'accepte que les fichiers image <tt>{','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}</tt>",
)
],
)
logo_footer = FileField(
label="Modifier l'image:",
description="logo placé en pied des documents PDF",
validators=[
FileAllowed(
scu.LOGOS_IMAGES_ALLOWED_TYPES,
f"n'accepte que les fichiers image <tt>{','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}</tt>",
)
],
)
submit = SubmitField("Enregistrer")
# Notes pour variables config: (valeurs par défaut des paramètres de département)
# Chaines simples
# SCOLAR_FONT = "Helvetica"
# SCOLAR_FONT_SIZE = 10
# SCOLAR_FONT_SIZE_FOOT = 6
# INSTITUTION_NAME = "<b>Institut Universitaire de Technologie - Université Georges Perec</b>"
# INSTITUTION_ADDRESS = "Web <b>www.sor.bonne.top</b> - 11, rue Simon Crubelier - 75017 Paris"
# INSTITUTION_CITY = "Paris"
# Textareas:
# DEFAULT_PDF_FOOTER_TEMPLATE = "Edité par %(scodoc_name)s le %(day)s/%(month)s/%(year)s à %(hour)sh%(minute)s sur %(server_url)s"
# Booléens
# always_require_ine
# Logos:
# LOGO_FOOTER*, LOGO_HEADER*
@bp.route("/ScoDoc/configuration", methods=["GET", "POST"])
@admin_required
def configuration():
"Panneau de configuration général"
form = ScoDocConfigurationForm(
bonus_sport_func_name=ScoDocSiteConfig.get_bonus_sport_func_name()
bonus_sport_func_name=ScoDocSiteConfig.get_bonus_sport_func_name(),
)
if form.validate_on_submit():
ScoDocSiteConfig.set_bonus_sport_func(form.bonus_sport_func_name.data)
if form.logo_header.data:
sco_logos.store_image(
form.logo_header.data, os.path.join(scu.SCODOC_LOGOS_DIR, "logo_header")
)
if form.logo_footer.data:
sco_logos.store_image(
form.logo_footer.data, os.path.join(scu.SCODOC_LOGOS_DIR, "logo_footer")
)
app.clear_scodoc_cache()
flash(f"Configuration enregistrée")
return redirect(url_for("scodoc.index"))
return render_template(
"configuration.html",
title="Configuration ScoDoc",
form=form,
# bonus_sport_func_name=ScoDocSiteConfig.get_bonus_sport_func(),
scodoc_dept=None,
)
def _return_logo(logo_type="header", scodoc_dept=""):
# stockée dans /opt/scodoc-data/config/logos donc servie manuellement ici
filename = sco_logos.get_logo_filename(logo_type, scodoc_dept)
if filename:
extension = os.path.splitext(filename)[1]
return send_file(filename, mimetype=f"image/{extension}")
else:
return ""
@bp.route("/ScoDoc/logo_header")
@bp.route("/ScoDoc/<scodoc_dept>/logo_header")
def logo_header(scodoc_dept=""):
"Image logo header"
# "/opt/scodoc-data/config/logos/logo_header")
return _return_logo(logo_type="header", scodoc_dept=scodoc_dept)
@bp.route("/ScoDoc/logo_footer")
@bp.route("/ScoDoc/<scodoc_dept>/logo_footer")
def logo_footer(scodoc_dept=""):
"Image logo footer"
return _return_logo(logo_type="footer", scodoc_dept=scodoc_dept)
# essais
# @bp.route("/testlog")
# def testlog():

View File

@ -30,7 +30,7 @@ issu de ScoDoc7 / ZScolar.py
Emmanuel Viennet, 2021
"""
import os
import sys
import time
@ -40,15 +40,19 @@ from zipfile import ZipFile
import psycopg2
import flask
from flask import jsonify, url_for
from flask import jsonify, url_for, flash, redirect, render_template
from flask import current_app, g, request
from flask_login import current_user
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed
from wtforms import SubmitField
from config import Config
from app.decorators import (
scodoc,
scodoc7func,
permission_required,
permission_required_compat_scodoc7,
admin_required,
login_required,
)
@ -71,8 +75,8 @@ from app.scodoc.sco_exceptions import (
)
from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message
import sco_version
import app
from app.scodoc.gen_tables import GenTable
from app.scodoc import html_sco_header
from app.scodoc import html_sidebar
from app.scodoc import imageresize
@ -94,6 +98,7 @@ from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_groups
from app.scodoc import sco_groups_edit
from app.scodoc import sco_groups_view
from app.scodoc import sco_logos
from app.scodoc import sco_news
from app.scodoc import sco_page_etud
from app.scodoc import sco_parcours_dut
@ -201,6 +206,66 @@ def doc_preferences(REQUEST):
return sco_preferences.doc_preferences()
class DeptLogosConfigurationForm(FlaskForm):
"Panneau de configuration logos dept"
logo_header = FileField(
label="Modifier l'image:",
description="logo placé en haut des documents PDF",
validators=[
FileAllowed(
scu.LOGOS_IMAGES_ALLOWED_TYPES,
f"n'accepte que les fichiers image <tt>{','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}</tt>",
)
],
)
logo_footer = FileField(
label="Modifier l'image:",
description="logo placé en pied des documents PDF",
validators=[
FileAllowed(
scu.LOGOS_IMAGES_ALLOWED_TYPES,
f"n'accepte que les fichiers image <tt>{','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}</tt>",
)
],
)
submit = SubmitField("Enregistrer")
@bp.route("/config_logos", methods=["GET", "POST"])
@permission_required(Permission.ScoChangePreferences)
def config_logos(scodoc_dept):
"Panneau de configuration général"
form = DeptLogosConfigurationForm()
if form.validate_on_submit():
if form.logo_header.data:
sco_logos.store_image(
form.logo_header.data,
os.path.join(
scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_header"
),
)
if form.logo_footer.data:
sco_logos.store_image(
form.logo_footer.data,
os.path.join(
scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_footer"
),
)
app.clear_scodoc_cache()
flash(f"Logos enregistrés")
return redirect(url_for("scolar.index_html", scodoc_dept=scodoc_dept))
return render_template(
"configuration.html",
title="Configuration Logos du département",
form=form,
scodoc_dept=scodoc_dept,
)
# --------------------------------------------------------------------
#
# ETUDIANTS
@ -283,7 +348,10 @@ sco_publish(
)
sco_publish(
"/trombino_copy_photos", sco_trombino.trombino_copy_photos, Permission.ScoView
"/trombino_copy_photos",
sco_trombino.trombino_copy_photos,
Permission.ScoView,
methods=["GET", "POST"],
)
sco_publish("/groups_view", sco_groups_view.groups_view, Permission.ScoView)
@ -332,10 +400,18 @@ def search_etud_by_name():
# XMLgetEtudInfos était le nom dans l'ancienne API ScoDoc 6
@bp.route("/etud_info")
@bp.route("/XMLgetEtudInfos")
@bp.route("/etud_info", methods=["GET", "POST"]) # pour compat anciens clients PHP)
@bp.route(
"/XMLgetEtudInfos", methods=["GET", "POST"]
) # pour compat anciens clients PHP)
@bp.route(
"/Absences/XMLgetEtudInfos", methods=["GET", "POST"]
) # pour compat anciens clients PHP
@bp.route(
"/Notes/XMLgetEtudInfos", methods=["GET", "POST"]
) # pour compat anciens clients PHP
@scodoc
@permission_required(Permission.ScoView)
@permission_required_compat_scodoc7(Permission.ScoView)
@scodoc7func
def etud_info(etudid=None, format="xml", REQUEST=None):
"Donne les informations sur un etudiant"

View File

@ -121,7 +121,9 @@ def create_user_form(REQUEST, user_name=None, edit=0):
is_super_admin = True
# Les rôles standards créés à l'initialisation de ScoDoc:
standard_roles = [Role.get_named_role(r) for r in ("Ens", "Secr", "Admin")]
standard_roles = [
Role.get_named_role(r) for r in ("Ens", "Secr", "Admin", "RespPe")
]
# Rôles pouvant etre attribués aux utilisateurs via ce dialogue:
# si SuperAdmin, tous les rôles standards dans tous les départements
# sinon, les départements dans lesquels l'utilisateur a le droit

View File

@ -30,9 +30,9 @@ class Config:
SCODOC_DIR = os.environ.get("SCODOC_DIR", "/opt/scodoc")
SCODOC_VAR_DIR = os.environ.get("SCODOC_VAR_DIR", "/opt/scodoc-data")
SCODOC_LOG_FILE = os.path.join(SCODOC_VAR_DIR, "log", "scodoc.log")
# For legacy ScoDoc7 installs: postgresql user
SCODOC7_SQL_USER = os.environ.get("SCODOC7_SQL_USER", "www-data")
DEFAULT_SQL_PORT = os.environ.get("DEFAULT_SQL_PORT", "5432")
#
MAX_CONTENT_LENGTH = 10 * 1024 * 1024 # Flask uploads
# STATIC_URL_PATH = "/ScoDoc/static"
# static_folder = "stat"
# SERVER_NAME = os.environ.get("SERVER_NAME")

View File

@ -0,0 +1,36 @@
"""ScoDoc 9.0.13: essai cascade
Revision ID: a217bf588f4c
Revises: f73251d1d825
Create Date: 2021-09-10 21:44:34.947317
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a217bf588f4c'
down_revision = 'f73251d1d825'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('notes_semset_formsemestre', 'semset_id',
existing_type=sa.INTEGER(),
nullable=False)
op.drop_constraint('notes_semset_formsemestre_semset_id_fkey', 'notes_semset_formsemestre', type_='foreignkey')
op.create_foreign_key(None, 'notes_semset_formsemestre', 'notes_semset', ['semset_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'notes_semset_formsemestre', type_='foreignkey')
op.create_foreign_key('notes_semset_formsemestre_semset_id_fkey', 'notes_semset_formsemestre', 'notes_semset', ['semset_id'], ['id'])
op.alter_column('notes_semset_formsemestre', 'semset_id',
existing_type=sa.INTEGER(),
nullable=True)
# ### end Alembic commands ###

View File

@ -36,7 +36,7 @@ class ScoError(Exception):
def GET(s, path, errmsg=None):
"""Get and returns as JSON"""
r = s.get(BASEURL + "/" + path)
r = s.get(BASEURL + "/" + path, verify=CHECK_CERTIFICATE)
if r.status_code != 200:
raise ScoError(errmsg or "erreur !")
return r.json() # decode la reponse JSON
@ -44,7 +44,7 @@ def GET(s, path, errmsg=None):
def POST(s, path, data, errmsg=None):
"""Post"""
r = s.post(BASEURL + "/" + path, data=data)
r = s.post(BASEURL + "/" + path, data=data, verify=CHECK_CERTIFICATE)
if r.status_code != 200:
raise ScoError(errmsg or "erreur !")
return r.text
@ -52,6 +52,10 @@ def POST(s, path, data, errmsg=None):
# --- Ouverture session (login)
s = requests.Session()
s.post(
"https://deb11.viennet.net/api/auth/login",
data={"user_name": USER, "password": PASSWORD},
)
r = s.get(BASEURL, auth=(USER, PASSWORD), verify=CHECK_CERTIFICATE)
if r.status_code != 200:
raise ScoError("erreur de connection: vérifier adresse et identifiants")

View File

@ -18,6 +18,7 @@ Flask==2.0.1
Flask-Babel==2.0.0
Flask-Bootstrap==3.3.7.1
Flask-Caching==1.10.1
Flask-HTTPAuth==4.4.0
Flask-Login==0.5.0
Flask-Mail==0.9.1
Flask-Migrate==3.1.0

View File

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

View File

@ -188,24 +188,6 @@ def create_dept(dept): # create-dept
return 0
@app.cli.command()
@click.argument("filename")
@with_appcontext
def test_interactive(filename=None):
"Run interactive test"
import flask_login
from app import decorators
click.echo("Executing {}".format(filename))
with app.test_request_context(""):
u = User.query.first()
flask_login.login_user(u)
REQUEST = decorators.ZRequest()
exec(open(filename).read())
click.echo("Done.")
@app.cli.command()
@with_appcontext
def import_scodoc7_users(): # import-scodoc7-users

View File

@ -40,11 +40,11 @@ from app.scodoc.sco_exceptions import ScoValueError
random.seed(12345) # tests reproductibles
DEMO_DIR = Config.SCODOC_DIR + "/tools/demo/"
NOMS = [x.strip() for x in open(DEMO_DIR + "/noms.txt").readlines()]
PRENOMS_H = [x.strip() for x in open(DEMO_DIR + "/prenoms-h.txt").readlines()]
PRENOMS_F = [x.strip() for x in open(DEMO_DIR + "/prenoms-f.txt").readlines()]
PRENOMS_X = [x.strip() for x in open(DEMO_DIR + "/prenoms-x.txt").readlines()]
NOMS_DIR = Config.SCODOC_DIR + "/tools/fakeportal/nomsprenoms"
NOMS = [x.strip() for x in open(NOMS_DIR + "/noms.txt").readlines()]
PRENOMS_H = [x.strip() for x in open(NOMS_DIR + "/prenoms-h.txt").readlines()]
PRENOMS_F = [x.strip() for x in open(NOMS_DIR + "/prenoms-f.txt").readlines()]
PRENOMS_X = [x.strip() for x in open(NOMS_DIR + "/prenoms-x.txt").readlines()]
def id_generator(size=6, chars=string.ascii_uppercase + string.digits):

View File

@ -73,6 +73,8 @@ def test_preferences(test_client):
assert len(prefs2) == len(prefs)
prefs2.set(None, "abs_notification_mail_tmpl", "toto")
assert prefs2.get(None, "abs_notification_mail_tmpl") == "toto"
# Vérifie que les prefs sont bien sur un seul département:
app.set_sco_dept(current_dept.acronym)
assert prefs.get(None, "abs_notification_mail_tmpl") != "toto"
orm_val = (
ScoPreference.query.filter_by(dept_id=d.id, name="abs_notification_mail_tmpl")
@ -82,6 +84,7 @@ def test_preferences(test_client):
assert orm_val == "toto"
# --- Preferences d'un semestre
# rejoue ce test pour avoir un semestre créé
app.set_sco_dept("D2")
test_sco_basic.run_sco_basic()
sem = sco_formsemestre.do_formsemestre_list()[0]
formsemestre_id = sem["formsemestre_id"]

View File

@ -108,8 +108,7 @@ change_scodoc_file_ownership
# ------------ CREATION BASE DE DONNEES
echo
echo "Voulez-vous créer la base SQL SCODOC ?"
echo "répondre oui sauf si vous avez déjà une base existante"
echo "que vous souhaitez conserver (mais pour les migrations, répondre oui)."
echo "(répondre oui sauf si vous savez vraiment ce que vous faites)"
echo -n 'Créer la base de données SCODOC ? (y/n) [y] '
read -r ans
if [ "$(norm_ans "$ans")" != 'N' ]
@ -121,9 +120,10 @@ then
echo
echo "Création des tables et du compte admin"
echo
su -c "(cd /opt/scodoc; source venv/bin/activate; flask db upgrade; flask sco-db-init; flask user-password admin)" "$SCODOC_USER" || die "Erreur: sco-db-init"
msg="Saisir le mot de passe de l\'administrateur \(admin\):"
su -c "(cd /opt/scodoc; source venv/bin/activate; flask db upgrade; flask sco-db-init; echo; echo $msg; flask user-password admin)" "$SCODOC_USER" || die "Erreur: sco-db-init"
echo
echo "base initialisée et admin créé."
echo "Base initialisée et admin créé."
echo
fi
@ -134,6 +134,7 @@ systemctl start scodoc9
echo
echo "Service configuré et démarré."
echo "Vous pouvez vous connecter en web et vous identifier comme \"admin\"."
echo

2
tools/debian/postinst Normal file → Executable file
View File

@ -29,7 +29,7 @@ do
/usr/sbin/locale-gen --keep-existing
fi
done
echo "debian postinst: scodoc9 is $(systemctl is-active scodoc9)"
echo "debian postinst: scodoc9 systemd service is $(systemctl is-active scodoc9)"
# On a besoin d'un postgresql lancé pour la mise à jour
systemctl restart postgresql

19
tools/debian/postrm Executable file
View File

@ -0,0 +1,19 @@
#!/bin/bash
# Déinstallation de scodoc
# Ne touche pas aux données (/opt/scodoc-data)
# N'enlève complètement /opt/scodoc qui si --purge
systemctl stop scodoc9
systemctl disable scodoc9
if [ "$#" == 1 ] && [ "$1" == "purge" ]
then
/bin/rm -rf /opt/scodoc
/bin/rm -f scodoc9.service
/bin/rm -f /etc/systemd/system/scodoc-updater.service
/bin/rm -f /etc/systemd/system/scodoc-updater.timer
/bin/rm -f /etc/nginx/sites-enabled/scodoc9.nginx
fi
systemctl reload nginx

0
tools/debian/preinst Normal file → Executable file
View File

View File

@ -20,7 +20,7 @@ User=scodoc
Group=scodoc
WorkingDirectory=/opt/scodoc
#Environment=FLASK_ENV=production
ExecStart=/opt/scodoc/venv/bin/gunicorn -b localhost:8000 -w 4 scodoc:app
ExecStart=/opt/scodoc/venv/bin/gunicorn -b localhost:8000 -w 4 --timeout 600 scodoc:app
Restart=always
[Install]

View File

@ -100,7 +100,7 @@ class MyHttpRequestHandler(http.server.SimpleHTTPRequestHandler):
if "etapes" in self.path.lower():
self.path = str(Path(script_dir / "etapes.xml").relative_to(Path.cwd()))
elif "scodocEtudiant" in self.path:
elif "scodocEtudiant" in self.path: # API v2
# 2 forms: nip=xxx or etape=eee&annee=aaa
if "nip" in query_components:
nip = query_components["nip"][0]

View File

@ -59,7 +59,7 @@ def import_scodoc7_user_db(scodoc7_db="dbname=SCOUSERS"):
roles7 = []
for role_dept in roles7:
# Migre les rôles RespPeX, EnsX, AdminX, SecrX et ignore les autres
m = re.match(r"^(-?Ens|-?Secr|-?ResPe|-?Admin)(.*)$", role_dept)
m = re.match(r"^(-?Ens|-?Secr|-?RespPe|-?Admin)(.*)$", role_dept)
if not m:
msg = f"User {user_name}: role inconnu '{role_dept}' (ignoré)"
current_app.logger.warning(msg)
@ -75,7 +75,7 @@ def import_scodoc7_user_db(scodoc7_db="dbname=SCOUSERS"):
dept = m.group(2)
role = Role.query.filter_by(name=role_name).first()
if not role:
msg = f"User {user_name}: ignoring role '{role_dept}'"
msg = f"Role '{role_name}' introuvable. User {user_name}: ignoring role '{role_dept}'"
current_app.logger.warning(msg)
messages.append(msg)
else:

View File

@ -23,10 +23,6 @@ CONFIG = CFG()
CONFIG.always_require_ine = 0 # set to 1 if you want to require INE
# The base URL, use only if you are behind a proxy
# eg "https://scodoc.example.net/ScoDoc"
CONFIG.ABSOLUTE_URL = ""
#
# ------------- Documents PDF -------------
#
@ -78,6 +74,7 @@ CONFIG.DEFAULT_PDF_FOOTER_TEMPLATE = "Edité par %(scodoc_name)s le %(day)s/%(mo
#
# - règle "LMD": capitalisation uniquement des UE avec moy. > 10
# XXX à revoir pour le BUT: variable à intégrer aux parcours
CONFIG.CAPITALIZE_ALL_UES = (
True # si vrai, capitalise toutes les UE des semestres validés (règle "LMD").
)
@ -86,7 +83,7 @@ CONFIG.CAPITALIZE_ALL_UES = (
#
# -----------------------------------------------------
#
# -------------- Personnalisation des pages
# -------------- Personnalisation des pages (DEPRECATED)
#
# -----------------------------------------------------
# Nom (chemin complet) d'un fichier .html à inclure juste après le <body>