889 lines
26 KiB
Python
889 lines
26 KiB
Python
# -*- 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
|
|
#
|
|
##############################################################################
|
|
|
|
|
|
""" Common definitions
|
|
"""
|
|
import os
|
|
import sys
|
|
import copy
|
|
import re
|
|
import bisect
|
|
import types
|
|
import numbers
|
|
import thread
|
|
import urllib2
|
|
import xml.sax.saxutils
|
|
import time
|
|
import datetime
|
|
import json
|
|
|
|
# XML generation package (apt-get install jaxml)
|
|
import jaxml
|
|
|
|
try:
|
|
import six
|
|
|
|
STRING_TYPES = six.string_types
|
|
except ImportError:
|
|
# fallback for very old ScoDoc instances
|
|
STRING_TYPES = types.StringType
|
|
|
|
from PIL import Image as PILImage
|
|
|
|
from VERSION import SCOVERSION
|
|
import VERSION
|
|
|
|
from SuppressAccents import suppression_diacritics
|
|
from notes_log import log
|
|
from sco_codes_parcours import NOTES_TOLERANCE, CODES_EXPL
|
|
|
|
# ----- CALCUL ET PRESENTATION DES NOTES
|
|
NOTES_PRECISION = 1e-4 # evite eventuelles erreurs d'arrondis
|
|
NOTES_MIN = 0.0 # valeur minimale admise pour une note (sauf malus, dans [-20, 20])
|
|
NOTES_MAX = 1000.0
|
|
NOTES_NEUTRALISE = -1000.0 # notes non prises en comptes dans moyennes
|
|
NOTES_SUPPRESS = -1001.0 # note a supprimer
|
|
NOTES_ATTENTE = -1002.0 # note "en attente" (se calcule comme une note neutralisee)
|
|
|
|
|
|
# Types de modules
|
|
MODULE_STANDARD = 0
|
|
MODULE_MALUS = 1
|
|
|
|
MALUS_MAX = 20.0
|
|
MALUS_MIN = -20.0
|
|
|
|
APO_MISSING_CODE_STR = "----" # shown in HTML pages in place of missing code Apogée
|
|
EDIT_NB_ETAPES = 6 # Nombre max de codes étapes / semestre presentés dans l'UI
|
|
|
|
IT_SITUATION_MISSING_STR = (
|
|
"____" # shown on ficheEtud (devenir) in place of empty situation
|
|
)
|
|
|
|
RANG_ATTENTE_STR = "(attente)" # rang affiché sur bulletins quand notes en attente
|
|
|
|
# borne supérieure de chaque mention
|
|
NOTES_MENTIONS_TH = (
|
|
NOTES_TOLERANCE,
|
|
7.0,
|
|
10.0,
|
|
12.0,
|
|
14.0,
|
|
16.0,
|
|
18.0,
|
|
20.0 + NOTES_TOLERANCE,
|
|
)
|
|
NOTES_MENTIONS_LABS = (
|
|
"Nul",
|
|
"Faible",
|
|
"Insuffisant",
|
|
"Passable",
|
|
"Assez bien",
|
|
"Bien",
|
|
"Très bien",
|
|
"Excellent",
|
|
)
|
|
|
|
EVALUATION_NORMALE = 0
|
|
EVALUATION_RATTRAPAGE = 1
|
|
|
|
|
|
def fmt_note(val, note_max=None, keep_numeric=False):
|
|
"""conversion note en str pour affichage dans tables HTML ou PDF.
|
|
Si keep_numeric, laisse les valeur numeriques telles quelles (pour export Excel)
|
|
"""
|
|
if val is None:
|
|
return "ABS"
|
|
if val == NOTES_NEUTRALISE:
|
|
return "EXC" # excuse, note neutralise
|
|
if val == NOTES_ATTENTE:
|
|
return "ATT" # attente, note neutralisee
|
|
if type(val) == types.FloatType or type(val) == types.IntType:
|
|
if note_max != None and note_max > 0:
|
|
val = val * 20.0 / note_max
|
|
if keep_numeric:
|
|
return val
|
|
else:
|
|
s = "%2.2f" % round(float(val), 2) # 2 chiffres apres la virgule
|
|
s = "0" * (5 - len(s)) + s # padding: 0 à gauche pour longueur 5: "12.34"
|
|
return s
|
|
else:
|
|
return val.replace("NA0", "-") # notes sans le NA0
|
|
|
|
|
|
def fmt_coef(val):
|
|
"""Conversion valeur coefficient (float) en chaine"""
|
|
if val < 0.01:
|
|
return "%g" % val # unusually small value
|
|
return "%g" % round(val, 2)
|
|
|
|
|
|
def fmt_abs(val):
|
|
"""Conversion absences en chaine. val est une list [nb_abs_total, nb_abs_justifiees
|
|
=> NbAbs / Nb_justifiees
|
|
"""
|
|
return "%s / %s" % (val[0], val[1])
|
|
|
|
|
|
def isnumber(x):
|
|
"True if x is a number (int, float, etc.)"
|
|
return isinstance(x, numbers.Number)
|
|
|
|
|
|
def join_words(*words):
|
|
words = [str(w).strip() for w in words if w is not None]
|
|
return " ".join([w for w in words if w])
|
|
|
|
|
|
def get_mention(moy):
|
|
"""Texte "mention" en fonction de la moyenne générale"""
|
|
try:
|
|
moy = float(moy)
|
|
except:
|
|
return ""
|
|
return NOTES_MENTIONS_LABS[bisect.bisect_right(NOTES_MENTIONS_TH, moy)]
|
|
|
|
|
|
# ----- Global lock for critical sections (except notes_tables caches)
|
|
GSL = thread.allocate_lock() # Global ScoDoc Lock
|
|
|
|
if "INSTANCE_HOME" in os.environ:
|
|
# ----- Repertoire "var" (local)
|
|
SCODOC_VAR_DIR = os.path.join(os.environ["INSTANCE_HOME"], "var", "scodoc")
|
|
# ----- Version information
|
|
SCODOC_VERSION_DIR = os.path.join(SCODOC_VAR_DIR, "config", "version")
|
|
# ----- Repertoire tmp
|
|
SCO_TMPDIR = os.path.join(SCODOC_VAR_DIR, "tmp")
|
|
if not os.path.exists(SCO_TMPDIR):
|
|
os.mkdir(SCO_TMPDIR, 0o755)
|
|
# ----- Les logos: /opt/scodoc/var/scodoc/config/logos
|
|
SCODOC_LOGOS_DIR = os.path.join(SCODOC_VAR_DIR, "config", "logos")
|
|
|
|
# ----- Repertoire "config" (devrait s'appeler "tools"...)
|
|
SCO_CONFIG_DIR = os.path.join(
|
|
os.environ["INSTANCE_HOME"], "Products", "ScoDoc", "config"
|
|
)
|
|
|
|
|
|
# ----- Lecture du fichier de configuration
|
|
SCO_SRCDIR = os.path.split(VERSION.__file__)[0]
|
|
if SCO_SRCDIR:
|
|
SCO_SRCDIR += "/"
|
|
else:
|
|
SCO_SRCDIR = "/opt/scodoc/Products/ScoDoc/" # debug mode
|
|
CONFIG = None
|
|
try:
|
|
_config_filename = SCO_SRCDIR + "config/scodoc_config.py"
|
|
_config_text = open(_config_filename).read()
|
|
except:
|
|
sys.stderr.write("sco_utils: cannot open configuration file %s" % _config_filename)
|
|
raise
|
|
|
|
try:
|
|
exec(_config_text)
|
|
except:
|
|
sys.stderr.write("sco_utils: error in configuration file %s" % _config_filename)
|
|
raise
|
|
|
|
if hasattr(CONFIG, "CODES_EXPL"):
|
|
CODES_EXPL.update(
|
|
CONFIG.CODES_EXPL
|
|
) # permet de customiser les explications de codes
|
|
|
|
|
|
if CONFIG.CUSTOM_HTML_HEADER:
|
|
CUSTOM_HTML_HEADER = open(CONFIG.CUSTOM_HTML_HEADER).read()
|
|
else:
|
|
CUSTOM_HTML_HEADER = ""
|
|
|
|
if CONFIG.CUSTOM_HTML_HEADER_CNX:
|
|
CUSTOM_HTML_HEADER_CNX = open(CONFIG.CUSTOM_HTML_HEADER_CNX).read()
|
|
else:
|
|
CUSTOM_HTML_HEADER_CNX = ""
|
|
|
|
if CONFIG.CUSTOM_HTML_FOOTER:
|
|
CUSTOM_HTML_FOOTER = open(CONFIG.CUSTOM_HTML_FOOTER).read()
|
|
else:
|
|
CUSTOM_HTML_FOOTER = ""
|
|
|
|
if CONFIG.CUSTOM_HTML_FOOTER_CNX:
|
|
CUSTOM_HTML_FOOTER_CNX = open(CONFIG.CUSTOM_HTML_FOOTER_CNX).read()
|
|
else:
|
|
CUSTOM_HTML_FOOTER_CNX = ""
|
|
|
|
SCO_ENCODING = "utf-8" # used by Excel, XML, PDF, ...
|
|
# Attention: encodage lié au codage Zope et aussi à celui de postgresql
|
|
# et aussi a celui des fichiers sources Python (comme celui-ci).
|
|
|
|
# def to_utf8(s):
|
|
# return unicode(s, SCO_ENCODING).encode('utf-8')
|
|
|
|
|
|
SCO_DEFAULT_SQL_USER = "www-data" # should match Zope process UID
|
|
SCO_DEFAULT_SQL_PORT = (
|
|
"5432" # warning: 5433 for postgresql-8.1 on Debian if 7.4 also installed !
|
|
)
|
|
SCO_DEFAULT_SQL_USERS_CNX = "dbname=SCOUSERS port=%s" % SCO_DEFAULT_SQL_PORT
|
|
|
|
# Valeurs utilisées pour affichage seulement, pas de requetes ni de mails envoyés:
|
|
SCO_WEBSITE = "https://scodoc.org"
|
|
SCO_USER_MANUAL = "https://scodoc.org/GuideUtilisateur"
|
|
SCO_ANNONCES_WEBSITE = "https://listes.univ-paris13.fr/mailman/listinfo/scodoc-annonces"
|
|
SCO_DEVEL_LIST = "scodoc-devel@listes.univ-paris13.fr"
|
|
SCO_USERS_LIST = "notes@listes.univ-paris13.fr"
|
|
|
|
# Mails avec exceptions (erreurs) anormales envoyés à cette adresse:
|
|
# mettre '' pour désactiver completement l'envois de mails d'erreurs.
|
|
# (ces mails sont précieux pour corriger les erreurs, ne les désactiver que si
|
|
# vous avez de bonnes raisons de le faire: vous pouvez me contacter avant)
|
|
SCO_EXC_MAIL = "scodoc-exception@viennet.net"
|
|
|
|
# L'adresse du mainteneur (non utilisée automatiquement par ScoDoc: ne pas changer)
|
|
SCO_DEV_MAIL = "emmanuel.viennet@gmail.com" # SVP ne pas changer
|
|
|
|
# Adresse pour l'envoi des dumps (pour assistance technnique):
|
|
# ne pas changer (ou vous perdez le support)
|
|
SCO_DUMP_UP_URL = "https://scodoc.iutv.univ-paris13.fr/scodoc-installmgr/upload-dump"
|
|
|
|
CSV_FIELDSEP = ";"
|
|
CSV_LINESEP = "\n"
|
|
CSV_MIMETYPE = "text/comma-separated-values"
|
|
XLS_MIMETYPE = "application/vnd.ms-excel"
|
|
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
|
|
|
|
|
|
class DictDefault(dict): # obsolete, use collections.defaultdict
|
|
"""A dictionnary with default value for all keys
|
|
Each time a non existent key is requested, it is added to the dict.
|
|
(used in python 2.4, can't use new __missing__ method)
|
|
"""
|
|
|
|
defaultvalue = 0
|
|
|
|
def __init__(self, defaultvalue=0, kv_dict={}):
|
|
dict.__init__(self)
|
|
self.defaultvalue = defaultvalue
|
|
self.update(kv_dict)
|
|
|
|
def __getitem__(self, k):
|
|
if self.has_key(k):
|
|
return self.get(k)
|
|
value = copy.copy(self.defaultvalue)
|
|
self[k] = value
|
|
return value
|
|
|
|
|
|
class WrapDict:
|
|
"""Wrap a dict so that getitem returns '' when values are None"""
|
|
|
|
def __init__(self, adict, NoneValue=""):
|
|
self.dict = adict
|
|
self.NoneValue = NoneValue
|
|
|
|
def __getitem__(self, key):
|
|
value = self.dict[key]
|
|
if value is None:
|
|
return self.NoneValue
|
|
else:
|
|
return value
|
|
|
|
|
|
def group_by_key(d, key):
|
|
g = DictDefault(defaultvalue=[])
|
|
for e in d:
|
|
g[e[key]].append(e)
|
|
return g
|
|
|
|
|
|
# Admissions des étudiants
|
|
# Différents types de voies d'admission:
|
|
# (stocké en texte libre dans la base, mais saisie par menus pour harmoniser)
|
|
TYPE_ADMISSION_DEFAULT = "Inconnue"
|
|
TYPES_ADMISSION = (TYPE_ADMISSION_DEFAULT, "APB", "APB-PC", "CEF", "Direct")
|
|
|
|
# ---- Simple python utilities
|
|
|
|
|
|
def simplesqlquote(s, maxlen=50):
|
|
"""simple SQL quoting to avoid most SQL injection attacks.
|
|
Note: we use this function in the (rare) cases where we have to
|
|
construct SQL code manually"""
|
|
s = s[:maxlen]
|
|
s.replace("'", r"\'")
|
|
s.replace(";", r"\;")
|
|
for bad in ("select", "drop", ";", "--", "insert", "delete", "xp_"):
|
|
s = s.replace(bad, "")
|
|
return s
|
|
|
|
|
|
def unescape_html(s):
|
|
"""un-escape html entities"""
|
|
s = s.strip().replace("&", "&")
|
|
s = s.replace("<", "<")
|
|
s = s.replace(">", ">")
|
|
return s
|
|
|
|
|
|
# test if obj is iterable (but not a string)
|
|
isiterable = lambda obj: getattr(obj, "__iter__", False)
|
|
|
|
|
|
def unescape_html_dict(d):
|
|
"""un-escape all dict values, recursively"""
|
|
try:
|
|
indices = d.keys()
|
|
except:
|
|
indices = range(len(d))
|
|
for k in indices:
|
|
v = d[k]
|
|
if type(v) == types.StringType:
|
|
d[k] = unescape_html(v)
|
|
elif isiterable(v):
|
|
unescape_html_dict(v)
|
|
|
|
|
|
def quote_xml_attr(data):
|
|
"""Escape &, <, >, quotes and double quotes"""
|
|
return xml.sax.saxutils.escape(str(data), {"'": "'", '"': """})
|
|
|
|
|
|
def dict_quote_xml_attr(d, fromhtml=False):
|
|
"""Quote XML entities in dict values.
|
|
Non recursive (but probbaly should be...).
|
|
Returns a new dict.
|
|
"""
|
|
if fromhtml:
|
|
# passe d'un code HTML a un code XML
|
|
return dict([(k, quote_xml_attr(unescape_html(v))) for (k, v) in d.items()])
|
|
else:
|
|
# passe d'une chaine non quotée a du XML
|
|
return dict([(k, quote_xml_attr(v)) for (k, v) in d.items()])
|
|
|
|
|
|
def simple_dictlist2xml(dictlist, doc=None, tagname=None, quote=False):
|
|
"""Represent a dict as XML data.
|
|
All keys with string or numeric values are attributes (numbers converted to strings).
|
|
All list values converted to list of childs (recursively).
|
|
*** all other values are ignored ! ***
|
|
Values (xml entities) are not quoted, except if requested by quote argument.
|
|
|
|
Exemple:
|
|
simple_dictlist2xml([ { 'id' : 1, 'ues' : [{'note':10},{}] } ], tagname='infos')
|
|
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<infos id="1">
|
|
<ues note="10" />
|
|
<ues />
|
|
</infos>
|
|
|
|
"""
|
|
if not tagname:
|
|
raise ValueError("invalid empty tagname !")
|
|
if not doc:
|
|
doc = jaxml.XML_document(encoding=SCO_ENCODING)
|
|
scalar_types = [types.StringType, types.UnicodeType, types.IntType, types.FloatType]
|
|
for d in dictlist:
|
|
doc._push()
|
|
if (
|
|
type(d) == types.InstanceType or type(d) in scalar_types
|
|
): # pour ApoEtapeVDI et listes de chaines
|
|
getattr(doc, tagname)(code=str(d))
|
|
else:
|
|
if quote:
|
|
d_scalar = dict(
|
|
[
|
|
(k, quote_xml_attr(v))
|
|
for (k, v) in d.items()
|
|
if type(v) in scalar_types
|
|
]
|
|
)
|
|
else:
|
|
d_scalar = dict(
|
|
[(k, v) for (k, v) in d.items() if type(v) in scalar_types]
|
|
)
|
|
getattr(doc, tagname)(**d_scalar)
|
|
d_list = dict([(k, v) for (k, v) in d.items() if type(v) == types.ListType])
|
|
if d_list:
|
|
for (k, v) in d_list.items():
|
|
simple_dictlist2xml(v, doc=doc, tagname=k, quote=quote)
|
|
doc._pop()
|
|
return doc
|
|
|
|
|
|
# Expressions used to check noms/prenoms
|
|
FORBIDDEN_CHARS_EXP = re.compile(r"[*\|~\(\)\\]")
|
|
ALPHANUM_EXP = re.compile(r"^[\w-]+$", re.UNICODE)
|
|
|
|
|
|
def is_valid_code_nip(s):
|
|
"""True si s peut être un code NIP: au moins 6 chiffres décimaux"""
|
|
if not s:
|
|
return False
|
|
return re.match(r"^[0-9]{6,32}$", s)
|
|
|
|
|
|
def strnone(s):
|
|
"convert s to string, '' if s is false"
|
|
if s:
|
|
return str(s)
|
|
else:
|
|
return ""
|
|
|
|
|
|
def stripquotes(s):
|
|
"strip s from spaces and quotes"
|
|
s = s.strip()
|
|
if s and ((s[0] == '"' and s[-1] == '"') or (s[0] == "'" and s[-1] == "'")):
|
|
s = s[1:-1]
|
|
return s
|
|
|
|
|
|
def suppress_accents(s):
|
|
"s is an ordinary string, encoding given by SCO_ENCODING"
|
|
return str(suppression_diacritics(unicode(s, SCO_ENCODING)))
|
|
|
|
|
|
def sanitize_string(s):
|
|
"""s is an ordinary string, encoding given by SCO_ENCODING"
|
|
suppress accents and chars interpreted in XML
|
|
Irreversible (not a quote)
|
|
|
|
For ids and some filenames
|
|
"""
|
|
return (
|
|
suppress_accents(s.translate(None, "'`\"<>!&\\ "))
|
|
.replace(" ", "_")
|
|
.replace("\t", "_")
|
|
)
|
|
|
|
|
|
def make_filename(name):
|
|
"""Try to convert name to a reasonnable filename"""
|
|
return suppress_accents(name).replace(" ", "_")
|
|
|
|
|
|
VALID_CARS = (
|
|
"-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.!" # no / !
|
|
)
|
|
VALID_CARS_SET = set(VALID_CARS)
|
|
VALID_EXP = re.compile("^[" + VALID_CARS + "]+$")
|
|
|
|
|
|
def sanitize_filename(filename):
|
|
"""Keep only valid chars
|
|
used for archives filenames
|
|
"""
|
|
sane = "".join([c for c in filename if c in VALID_CARS_SET])
|
|
if len(sane) < 2:
|
|
sane = time.strftime("%Y-%m-%d-%H%M%S") + "-" + sane
|
|
return sane
|
|
|
|
|
|
def is_valid_filename(filename):
|
|
"""True if filename is safe"""
|
|
return VALID_EXP.match(filename)
|
|
|
|
|
|
def sendCSVFile(REQUEST, data, filename):
|
|
"""publication fichier.
|
|
(on ne doit rien avoir émis avant, car ici sont générés les entetes)
|
|
"""
|
|
filename = (
|
|
unescape_html(suppress_accents(filename)).replace("&", "").replace(" ", "_")
|
|
)
|
|
REQUEST.RESPONSE.setHeader("content-type", CSV_MIMETYPE)
|
|
REQUEST.RESPONSE.setHeader(
|
|
"content-disposition", 'attachment; filename="%s"' % filename
|
|
)
|
|
return data
|
|
|
|
|
|
def sendPDFFile(REQUEST, data, filename):
|
|
filename = (
|
|
unescape_html(suppress_accents(filename)).replace("&", "").replace(" ", "_")
|
|
)
|
|
if REQUEST:
|
|
REQUEST.RESPONSE.setHeader("content-type", PDF_MIMETYPE)
|
|
REQUEST.RESPONSE.setHeader(
|
|
"content-disposition", 'attachment; filename="%s"' % filename
|
|
)
|
|
return data
|
|
|
|
|
|
class ScoDocJSONEncoder(json.JSONEncoder):
|
|
def default(self, o): # pylint: disable=E0202
|
|
import sco_formsemestre
|
|
|
|
# horrible hack pour encoder les dates mx
|
|
if str(type(o)) == "<type 'mx.DateTime.DateTime'>":
|
|
return o.strftime("%Y-%m-%dT%H:%M:%S")
|
|
if isinstance(o, (datetime.date, datetime.datetime)):
|
|
return o.isoformat()
|
|
elif isinstance(o, sco_formsemestre.ApoEtapeVDI):
|
|
return str(o)
|
|
else:
|
|
log("not mx: %s" % type(o))
|
|
return json.JSONEncoder.default(self, o)
|
|
|
|
|
|
def sendJSON(REQUEST, data):
|
|
js = json.dumps(data, encoding=SCO_ENCODING, indent=1, cls=ScoDocJSONEncoder)
|
|
if REQUEST:
|
|
REQUEST.RESPONSE.setHeader("content-type", JSON_MIMETYPE)
|
|
return js
|
|
|
|
|
|
def sendXML(REQUEST, data, tagname=None, force_outer_xml_tag=True):
|
|
if type(data) != types.ListType:
|
|
data = [data] # always list-of-dicts
|
|
if force_outer_xml_tag:
|
|
root_tagname = tagname + "_list"
|
|
doc = jaxml.XML_document(encoding=SCO_ENCODING)
|
|
getattr(doc, root_tagname)()
|
|
doc._push()
|
|
else:
|
|
doc = None
|
|
doc = simple_dictlist2xml(data, doc=doc, tagname=tagname)
|
|
if force_outer_xml_tag:
|
|
doc._pop()
|
|
if REQUEST:
|
|
REQUEST.RESPONSE.setHeader("content-type", XML_MIMETYPE)
|
|
return repr(doc)
|
|
|
|
|
|
def sendResult(REQUEST, data, name=None, format=None, force_outer_xml_tag=True):
|
|
if format is None:
|
|
return data
|
|
elif format == "xml": # name is outer tagname
|
|
return sendXML(
|
|
REQUEST, data, tagname=name, force_outer_xml_tag=force_outer_xml_tag
|
|
)
|
|
elif format == "json":
|
|
return sendJSON(REQUEST, data)
|
|
else:
|
|
raise ValueError("invalid format: %s" % format)
|
|
|
|
|
|
# Get SVN version
|
|
def get_svn_version(path):
|
|
if os.path.exists("/usr/bin/svnversion"):
|
|
try:
|
|
return os.popen("svnversion " + path).read().strip()
|
|
except:
|
|
return "non disponible (erreur de lecture)"
|
|
else:
|
|
return "non disponible"
|
|
|
|
|
|
# Simple string manipulations
|
|
# on utf-8 encoded python strings
|
|
# (yes, we should only use unicode strings, but... we use only strings)
|
|
def strupper(s):
|
|
return s.decode(SCO_ENCODING).upper().encode(SCO_ENCODING)
|
|
|
|
|
|
def strlower(s):
|
|
return s.decode(SCO_ENCODING).lower().encode(SCO_ENCODING)
|
|
|
|
|
|
def strcapitalize(s):
|
|
return s.decode(SCO_ENCODING).capitalize().encode(SCO_ENCODING)
|
|
|
|
|
|
def abbrev_prenom(prenom):
|
|
"Donne l'abreviation d'un prenom"
|
|
# un peu lent, mais espère traiter tous les cas
|
|
# Jean -> J.
|
|
# Charles -> Ch.
|
|
# Jean-Christophe -> J.-C.
|
|
# Marie Odile -> M. O.
|
|
prenom = prenom.decode(SCO_ENCODING).replace(".", " ").strip()
|
|
if not prenom:
|
|
return ""
|
|
d = prenom[:3].upper()
|
|
if d == "CHA":
|
|
abrv = "Ch." # 'Charles' donne 'Ch.'
|
|
i = 3
|
|
else:
|
|
abrv = prenom[0].upper() + "."
|
|
i = 1
|
|
n = len(prenom)
|
|
while i < n:
|
|
c = prenom[i]
|
|
if c == " " or c == "-" and i < n - 1:
|
|
sep = c
|
|
i += 1
|
|
# gobbe tous les separateurs
|
|
while i < n and (prenom[i] == " " or prenom[i] == "-"):
|
|
if prenom[i] == "-":
|
|
sep = "-"
|
|
i += 1
|
|
if i < n:
|
|
abrv += sep + prenom[i].upper() + "."
|
|
i += 1
|
|
return abrv.encode(SCO_ENCODING)
|
|
|
|
|
|
#
|
|
def timedate_human_repr():
|
|
"representation du temps courant pour utilisateur: a localiser"
|
|
return time.strftime("%d/%m/%Y à %Hh%M")
|
|
|
|
|
|
def annee_scolaire_repr(year, month):
|
|
"""representation de l'annee scolaire : '2009 - 2010'
|
|
à partir d'une date.
|
|
"""
|
|
if month > 7: # apres le 1er aout
|
|
return "%s - %s" % (year, year + 1)
|
|
else:
|
|
return "%s - %s" % (year - 1, year)
|
|
|
|
|
|
def annee_scolaire_debut(year, month):
|
|
"""Annee scolaire de debut (septembre): heuristique pour l'hémisphère nord..."""
|
|
if int(month) > 7:
|
|
return int(year)
|
|
else:
|
|
return int(year) - 1
|
|
|
|
|
|
def sem_decale_str(sem):
|
|
"""'D' si semestre decalé, ou ''"""
|
|
# considère "décalé" les semestre impairs commençant entre janvier et juin
|
|
# et les pairs entre juillet et decembre
|
|
if sem["semestre_id"] <= 0:
|
|
return ""
|
|
if (sem["semestre_id"] % 2 and sem["mois_debut_ord"] <= 6) or (
|
|
not sem["semestre_id"] % 2 and sem["mois_debut_ord"] > 6
|
|
):
|
|
return "D"
|
|
else:
|
|
return ""
|
|
|
|
|
|
# Graphes (optionnel pour ne pas accroitre les dependances de ScoDoc)
|
|
try:
|
|
import pydot
|
|
|
|
WITH_PYDOT = True
|
|
except:
|
|
WITH_PYDOT = False
|
|
|
|
if WITH_PYDOT:
|
|
# check API (incompatible change after pydot version 0.9.10: scodoc install may use old or new version)
|
|
junk_graph = pydot.Dot("junk")
|
|
junk_graph.add_node(pydot.Node("a"))
|
|
n = junk_graph.get_node("a")
|
|
if type(n) == type([]):
|
|
|
|
def pydot_get_node(g, name):
|
|
r = g.get_node(name)
|
|
if not r:
|
|
return r
|
|
else:
|
|
return r[0]
|
|
|
|
else:
|
|
|
|
def pydot_get_node(g, name):
|
|
return g.get_node(name)
|
|
|
|
|
|
from sgmllib import SGMLParser
|
|
|
|
|
|
class html2txt_parser(SGMLParser):
|
|
"""html2txt()"""
|
|
|
|
def reset(self):
|
|
"""reset() --> initialize the parser"""
|
|
SGMLParser.reset(self)
|
|
self.pieces = []
|
|
|
|
def handle_data(self, text):
|
|
"""handle_data(text) --> appends the pieces to self.pieces
|
|
handles all normal data not between brackets "<>"
|
|
"""
|
|
self.pieces.append(text)
|
|
|
|
def handle_entityref(self, ref):
|
|
"""called for each entity reference, e.g. for "©", ref will be
|
|
"copy"
|
|
Reconstruct the original entity reference.
|
|
"""
|
|
if ref == "amp":
|
|
self.pieces.append("&")
|
|
|
|
def output(self):
|
|
"""Return processed HTML as a single string"""
|
|
return " ".join(self.pieces)
|
|
|
|
|
|
def scodoc_html2txt(html):
|
|
parser = html2txt_parser()
|
|
parser.reset()
|
|
parser.feed(html)
|
|
parser.close()
|
|
return parser.output()
|
|
|
|
|
|
def is_valid_mail(email):
|
|
"""True if well-formed email address"""
|
|
return re.match(r"^.+@.+\..{2,3}$", email)
|
|
|
|
|
|
ICONSIZES = {} # name : (width, height) cache image sizes
|
|
|
|
|
|
def icontag(name, file_format="png", **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 name not in ICONSIZES:
|
|
img_file = SCO_SRCDIR + "/static/icons/%s.%s" % (name, file_format)
|
|
im = PILImage.open(img_file)
|
|
width, height = im.size[0], im.size[1]
|
|
ICONSIZES[name] = (width, height) # cache
|
|
else:
|
|
width, height = ICONSIZES[name]
|
|
attrs["width"] = width
|
|
attrs["height"] = height
|
|
if "border" not in attrs:
|
|
attrs["border"] = 0
|
|
if "alt" not in attrs:
|
|
attrs["alt"] = "logo %s" % name
|
|
s = " ".join(['%s="%s"' % (k, attrs[k]) for k in attrs])
|
|
return '<img class="%s" %s src="/ScoDoc/static/icons/%s.%s" />' % (
|
|
name,
|
|
s,
|
|
name,
|
|
file_format,
|
|
)
|
|
|
|
|
|
ICON_PDF = icontag("pdficon16x20_img", title="Version PDF")
|
|
ICON_XLS = icontag("xlsicon_img", title="Version tableur")
|
|
|
|
|
|
def sort_dates(L, reverse=False):
|
|
"""Return sorted list of dates, allowing None items (they are put at the beginning)"""
|
|
mindate = datetime.datetime(datetime.MINYEAR, 1, 1)
|
|
try:
|
|
return sorted(L, key=lambda x: x or mindate, reverse=reverse)
|
|
except:
|
|
# Helps debugging
|
|
log("sort_dates( %s )" % L)
|
|
raise
|
|
|
|
|
|
def query_portal(req, msg="Portail Apogee", timeout=3):
|
|
"""Retreives external data using HTTP request
|
|
(used to connect to Apogee portal, or ScoDoc server)
|
|
returns a string, "" on error
|
|
"""
|
|
log("query_portal: %s" % req)
|
|
|
|
try:
|
|
f = urllib2.urlopen(req, timeout=timeout) # seconds / request
|
|
except:
|
|
log("query_portal: can't connect to %s" % msg)
|
|
return ""
|
|
try:
|
|
data = f.read()
|
|
except:
|
|
log("query_portal: error reading from %s" % msg)
|
|
data = ""
|
|
|
|
return data
|
|
|
|
|
|
def AnneeScolaire(REQUEST=None):
|
|
"annee de debut de l'annee scolaire courante"
|
|
if REQUEST and REQUEST.form.has_key("sco_year"):
|
|
year = REQUEST.form["sco_year"]
|
|
try:
|
|
year = int(year)
|
|
if year > 1900 and year < 2999:
|
|
return year
|
|
except:
|
|
pass
|
|
t = time.localtime()
|
|
year, month = t[0], t[1]
|
|
if month < 8: # le "pivot" est le 1er aout
|
|
year = year - 1
|
|
return year
|
|
|
|
|
|
def log_unknown_etud(context, REQUEST=None, format="html"):
|
|
"""Log request: cas ou getEtudInfo n'a pas ramene de resultat"""
|
|
etudid = REQUEST.form.get("etudid", "?")
|
|
code_nip = REQUEST.form.get("code_nip", "?")
|
|
code_ine = REQUEST.form.get("code_ine", "?")
|
|
log(
|
|
"unknown student: etudid=%s code_nip=%s code_ine=%s"
|
|
% (etudid, code_nip, code_ine)
|
|
)
|
|
return context.ScoErrorResponse("unknown student", format=format, REQUEST=REQUEST)
|
|
|
|
|
|
# XXX
|
|
# OK mais ne fonctione pas avec le publisher du vieux Zope car la signature de la méthode change
|
|
# def zope_method_str_or_json(func):
|
|
# """Decorateur: pour les méthodes publiées qui ramène soit du texte (HTML) soit du JSON
|
|
# sauf quand elles sont appellées depuis python.
|
|
# La présence de l'argument REQUEST indique la publication.
|
|
# """
|
|
# def wrapper(*args, **kwargs):
|
|
# r = func(*args, **kwargs)
|
|
# REQUEST = kwargs.get('REQUEST', None)
|
|
# # Published on the web but not string => convert to JSON
|
|
# if REQUEST and not isinstance(r, six.string_types):
|
|
# return sendJSON(REQUEST, r)
|
|
# return r
|
|
# wrapper.__doc__ = func.__doc__
|
|
# return wrapper
|
|
|
|
|
|
def return_text_if_published(val, REQUEST):
|
|
"""Pour les méthodes publiées qui ramènent soit du texte (HTML) soit du JSON
|
|
sauf quand elles sont appellées depuis python.
|
|
La présence de l'argument REQUEST indique la publication.
|
|
"""
|
|
if REQUEST and not isinstance(val, STRING_TYPES):
|
|
return sendJSON(REQUEST, val)
|
|
return val
|