forked from dubacq/scodoc-cohortes
1892 lines
63 KiB
Python
Executable File
1892 lines
63 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
# Standard libs
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import os
|
|
import pdb # used for debugging
|
|
import inspect # used for debugging
|
|
import random
|
|
import sys
|
|
import re
|
|
|
|
# Third-party libs
|
|
import requests
|
|
from requests.auth import HTTPBasicAuth
|
|
import drawsvg
|
|
|
|
try:
|
|
from dotenv import load_dotenv
|
|
except ModuleNotFoundError:
|
|
print("\nError: dotenv not installed !", file=sys.stderr)
|
|
print("You may install it using:\npip install python-dotenv\n", file=sys.stderr)
|
|
|
|
|
|
def die(msg: str, status=3):
|
|
print(msg, file=sys.stderr)
|
|
sys.exit(status)
|
|
|
|
|
|
def debug(*args):
|
|
if not hasattr(debug, "counter"):
|
|
debug.counter = 1 # Initialize the counter if it doesn't exist
|
|
else:
|
|
debug.counter += 1
|
|
caller_frame = inspect.currentframe().f_back
|
|
where = str(caller_frame.f_lineno) + "@" + caller_frame.f_code.co_name
|
|
if len(args) > 1:
|
|
print(f"[DEBUG {debug.counter}:{where}] " + str(args[0]), args[1:])
|
|
elif len(args) > 0:
|
|
print(f"[DEBUG {debug.counter}:{where}] " + str(args[0]))
|
|
else:
|
|
print(f"[DEBUG {debug.counter}:{where}] (no reason given)")
|
|
|
|
|
|
def warning(*args):
|
|
if len(args) > 0:
|
|
print("[WARNING] " + str(args[0]), args[1:])
|
|
else:
|
|
print("[WARNING] (no reason given)")
|
|
|
|
|
|
def info(*args):
|
|
if len(args) > 0:
|
|
print("[INFO] " + str(args[0]), args[1:])
|
|
else:
|
|
print("[INFO] (no reason given)")
|
|
|
|
|
|
load_dotenv(".env")
|
|
|
|
SCODOC_SERVER = os.environ.get("SCODOC_SERVER") or "http://localhost:5000"
|
|
SCODOC_USER = os.environ.get("SCODOC_USER") or die(
|
|
"SCODOC_USER must be set in .env or the environment"
|
|
)
|
|
SCODOC_PASSWORD = os.environ.get("SCODOC_PASSWORD") or die(
|
|
"SCODOC_PASSWORD must be set in .env or the environment"
|
|
)
|
|
|
|
API_URL = f"{SCODOC_SERVER}/ScoDoc/api"
|
|
|
|
# TODO : refactor globals
|
|
DEBUG = True # Not used
|
|
BLOCKING = True # Die if csv is incorrect
|
|
|
|
|
|
def blockordie(reason: str = "", status: int = 2):
|
|
if reason:
|
|
print(reason)
|
|
else:
|
|
print("Blocking, no reason given")
|
|
if BLOCKING:
|
|
sys.exit(status)
|
|
|
|
|
|
class Filter:
|
|
# Filter on students to be considered
|
|
# 1 consider only technological baccalaureates, statistics are always asked
|
|
# 2 consider only women, because gender statistics are frequently asked
|
|
# 4 consider only incoming students (primo-entrants) in first year of the cohort
|
|
# 8 consider only people having a first year, not parallel entries
|
|
TECHNO = 1
|
|
WOMAN = 2
|
|
PRIMO = 4
|
|
MAIN = 8
|
|
|
|
|
|
class OptionSet:
|
|
def __init__(self, values={}):
|
|
# Initialise un dictionnaire interne pour stocker les options
|
|
if type(values) == type({}):
|
|
self._options = values
|
|
else:
|
|
self._options = {}
|
|
self._orderkey = None
|
|
self._main_filter = None
|
|
self._secondary_filter = None
|
|
self._depts = []
|
|
|
|
def __getitem__(self, key):
|
|
# Récupère la valeur correspondant à la clé
|
|
return self._options[key]
|
|
|
|
def __setitem__(self, key, value):
|
|
# Assigne la valeur à la clé donnée
|
|
self._options[key] = value
|
|
|
|
def __delitem__(self, key):
|
|
# Supprime la clé spécifiée
|
|
if key in self._options:
|
|
del self._options[key]
|
|
|
|
def __contains__(self, key):
|
|
# Permet l'utilisation de 'in' pour vérifier l'existence d'une clé
|
|
return key in self._options
|
|
|
|
def __getattr__(self, name):
|
|
try:
|
|
return self._options[name]
|
|
except KeyError:
|
|
raise AttributeError(f"'Options' object has no attribute '{name}'")
|
|
|
|
def __setattr__(self, name, value):
|
|
if name[0] == "_":
|
|
super().__setattr__(name, value)
|
|
else:
|
|
self._options[name] = value
|
|
|
|
def __delattr__(self, name):
|
|
# Appelé quand un attribut est supprimé
|
|
if name in self._options:
|
|
del self._options[name]
|
|
else:
|
|
raise AttributeError(f"'Options' object has no attribute '{name}'")
|
|
|
|
def __repr__(self):
|
|
return f"Options({self._options})"
|
|
|
|
def asDict(self):
|
|
return self._options
|
|
|
|
def asCLI(self, excludeDefault=True, onlyOrders=False, depts=True):
|
|
cli = []
|
|
if not onlyOrders:
|
|
for opt in self.__class__.choiceoptions:
|
|
if self[opt[0]] == 0 and excludeDefault:
|
|
continue
|
|
cli.append("--" + opt[1][self[opt[0]]])
|
|
for opt in self.__class__.stringoptions:
|
|
if excludeDefault and self[opt[0]] == opt[1]:
|
|
continue
|
|
cli.append("--" + opt[0])
|
|
cli.append(self[opt[0]])
|
|
for opt in self.__class__.posint_options:
|
|
if excludeDefault and self[opt[0]] == opt[1]:
|
|
continue
|
|
cli.append("--" + opt[0])
|
|
cli.append(self[opt[0]])
|
|
for opt in self.__class__.booleanoptions:
|
|
if excludeDefault and self[opt[0]] == opt[1]:
|
|
continue
|
|
if self[opt[0]]:
|
|
cli.append("--" + opt[0])
|
|
else:
|
|
cli.append("--no-" + opt[0])
|
|
if "override" in self._options:
|
|
for FIELD, FIELDdict in self._options["override"].items():
|
|
for FIELDVALUE, FIELDVALUEdict in FIELDdict.items():
|
|
for key, val in FIELDVALUEdict.items():
|
|
cli.extend(["--override", FIELD, FIELDVALUE, key, val])
|
|
if "orders" in self._options:
|
|
orders = self._options["orders"]
|
|
cli.append("--orders")
|
|
for i, column in enumerate(orders):
|
|
if i > 0:
|
|
cli.append("/")
|
|
for row in column:
|
|
cli.append(row)
|
|
cli.append(".")
|
|
if depts:
|
|
cli.extend(self._depts)
|
|
return cli
|
|
|
|
def orderkey(self, filters=False):
|
|
if filters:
|
|
d = self._depts.copy()
|
|
d.append(str(self.filter()))
|
|
d.append(str(self.filter(main=False)))
|
|
return "_".join(d)
|
|
if self._orderkey is not None:
|
|
return self._orderkey
|
|
self._orderkey = "_".join(self._depts)
|
|
return self._orderkey
|
|
|
|
def depts(self, xset=None):
|
|
if xset:
|
|
self._depts = xset
|
|
return self._depts
|
|
|
|
def filter(self, main: bool = True):
|
|
r = 0
|
|
stem = "base"
|
|
if not main:
|
|
if self._secondary_filter is not None:
|
|
return self._secondary_filter
|
|
stem = "secondary"
|
|
else:
|
|
if self._main_filter is not None:
|
|
return self._main_filter
|
|
for suffix, f in {"techno": Filter.TECHNO, "women": Filter.WOMAN}.items():
|
|
option = f"{stem}_{suffix}"
|
|
if option in self._options and self._options[option]:
|
|
r |= f
|
|
if main:
|
|
self._main_filter = r
|
|
return r
|
|
self._secondary_filter = r | self.filter()
|
|
return self._secondary_filter
|
|
|
|
stringoptions = [
|
|
[
|
|
"department_separator",
|
|
" ",
|
|
"Separator before department in semester designation/display/origin designation",
|
|
],
|
|
[
|
|
"diplome_separator",
|
|
"",
|
|
"Separator before diploma in semester designation/display/origin designation",
|
|
],
|
|
[
|
|
"modalite_separator",
|
|
" ",
|
|
"Separator before modality in semester designation/display/origin designation",
|
|
],
|
|
[
|
|
"parcours_separator",
|
|
"/",
|
|
"Separator before parcours in semester designation/display/origin designation",
|
|
],
|
|
[
|
|
"rank_separator",
|
|
"",
|
|
"Separator before rank (~year of progress) in cursus designation/display/origin designation",
|
|
],
|
|
[
|
|
"year_separator",
|
|
" ",
|
|
"Separator before year in semester designation/display/origin designation",
|
|
],
|
|
[
|
|
"nick",
|
|
"{diplome}{rank}{multidepartment}{modalite}{parcours}{year}",
|
|
"Yearly cursus designation (should be unique for each distinguisable cursus choice)",
|
|
],
|
|
[
|
|
"displayname",
|
|
"{diplome}{rank}{multidepartment}{modaliteshort}{parcours}",
|
|
"Yearly cursus origin (used only for captionning the flow)",
|
|
],
|
|
[
|
|
"extnick",
|
|
"{ext}{rank}{multidepartment}{diplomenobut}{modaliteshort}",
|
|
"Origin designation (should be unique for each distinguisable origin of students)",
|
|
],
|
|
]
|
|
|
|
choiceoptions = [["algo", ["optimize", "reuse", "restart"]]]
|
|
|
|
booleanoptions = [
|
|
["base_techno", False, "Base population includes only techno students"],
|
|
["base_women", False, "Base population includes only women students"],
|
|
[
|
|
"secondary_techno",
|
|
True,
|
|
"Secondary (focused) population includes only techno students",
|
|
],
|
|
[
|
|
"secondary_women",
|
|
False,
|
|
"Secondary (focused) population includes only women students",
|
|
],
|
|
]
|
|
|
|
posint_options = [
|
|
["spacing", 14, 0, 30, "Spacing between groups in the same column"],
|
|
["thickness", 6, 0, 30, "Width of the group bars in columns"],
|
|
["hmargin", 20, 0, 50, "Global margin around the graph"],
|
|
["fontsize_name", 10, 0, 30, "Font size of the group name"],
|
|
["fontsize_count", 14, 0, 30, "Font size of the population marks"],
|
|
["width", 1300, 800, None, "Width of the graphics (not counting captions)"],
|
|
["statwidth", 300, 0, None, "Width of the side caption"],
|
|
["height", 0, 0, None, "Height of the graphics (0 = automaticd)"],
|
|
["loops", 300, 0, 1000, "Number of loops of the optimization algorithm"],
|
|
["baseyear", 2021, 2000, None, "Base year (start of the cohort)"],
|
|
]
|
|
|
|
shortcuts = {"baseyear": ["--base", "-b"], "loops": ["-l"]}
|
|
|
|
|
|
def range_limited_int_type(arg, MIN_VAL, MAX_VAL):
|
|
"""Type function for argparse - an integer within some predefined bounds"""
|
|
try:
|
|
f = int(arg)
|
|
except ValueError:
|
|
raise argparse.ArgumentTypeError("Must be an integer point number")
|
|
if MIN_VAL is not None and f < MIN_VAL:
|
|
raise argparse.ArgumentTypeError(
|
|
"Argument must be larger or equal to " + str(MIN_VAL)
|
|
)
|
|
if MAX_VAL is not None and f > MAX_VAL:
|
|
raise argparse.ArgumentTypeError(
|
|
"Argument must be smaller or equal to " + str(MAX_VAL)
|
|
)
|
|
return f
|
|
|
|
|
|
def format_for_shell(strings):
|
|
# Regex pour détecter les caractères spéciaux
|
|
special_chars = re.compile(r"[^+/.a-zA-Z0-9_-]")
|
|
|
|
formatted_strings = []
|
|
for ss in strings:
|
|
s = str(ss)
|
|
if special_chars.search(s): # Si la chaîne contient des caractères spéciaux
|
|
formatted_s = "'" + s.replace("'", "'\"'\"'") + "'"
|
|
elif len(s) == 0:
|
|
formatted_s = "''"
|
|
else:
|
|
formatted_s = s
|
|
formatted_strings.append(formatted_s)
|
|
|
|
# Concatène les chaînes pour qu'elles soient prêtes à copier-coller dans le shell
|
|
return " ".join(formatted_strings)
|
|
|
|
|
|
def cli_check():
|
|
"""Read args from the command line
|
|
then read config from {orderkey}.json
|
|
"""
|
|
parser = argparse.ArgumentParser(
|
|
usage="""
|
|
%(prog)s [--options] DEPARTEMENTS...
|
|
|
|
OR
|
|
|
|
%(prog)s FILE.json
|
|
""",
|
|
description="Create a sankey diagram for the evolution of students through some departments.",
|
|
)
|
|
parser.add_argument(
|
|
"--orders",
|
|
nargs="+",
|
|
help="Start of orders list with subgroups separated by / ended by .",
|
|
)
|
|
|
|
parser.add_argument("depts", nargs="*", help="List of departments")
|
|
|
|
# STRING OPTIONS
|
|
for opt in OptionSet.stringoptions:
|
|
xopt = ["--" + opt[0]]
|
|
if opt[0] in OptionSet.shortcuts:
|
|
xopt.extend(OptionSet.shortcuts[opt[0]])
|
|
parser.add_argument(*xopt, type=str, default=opt[1], help=opt[2])
|
|
|
|
# POSITIVE INTEGERS OPTIONS
|
|
for opt in OptionSet.posint_options:
|
|
xopt = ["--" + opt[0]]
|
|
if opt[0] in OptionSet.shortcuts:
|
|
xopt.extend(OptionSet.shortcuts[opt[0]])
|
|
optrange = ""
|
|
if opt[3] == None and opt[2] != None:
|
|
optrange = f" larger or equal to {opt[2]}"
|
|
elif opt[2] == None and opt[3] != None:
|
|
optrange = f" smaller than {opt[3]}"
|
|
elif opt[2] != None and opt[3] != None:
|
|
optrange = f" (between {opt[2]} and {opt[3]})"
|
|
|
|
def rangefactory(y, z):
|
|
return lambda x: range_limited_int_type(x, y, z)
|
|
|
|
parser.add_argument(
|
|
*xopt,
|
|
type=rangefactory(opt[2], opt[3]),
|
|
default=opt[1],
|
|
help=opt[4] + optrange,
|
|
)
|
|
|
|
# BOOLEAN OPTIONS
|
|
for opt in OptionSet.booleanoptions:
|
|
g = parser.add_mutually_exclusive_group()
|
|
xopt = ["--" + opt[0]]
|
|
if opt[0] in OptionSet.shortcuts:
|
|
xopt.extend(OptionSet.shortcuts[opt[0]])
|
|
g.add_argument(*xopt, action="store_true", default=opt[1], help=opt[2])
|
|
xopt = ["--no-" + opt[0]]
|
|
if "no-" + opt[0] in OptionSet.shortcuts:
|
|
xopt.extend(OptionSet.shortcuts["no-" + opt[0]])
|
|
g.add_argument(
|
|
*xopt,
|
|
action="store_true",
|
|
# help=opt[2].replace("includes only", "doesn't care about"),
|
|
)
|
|
|
|
# OTHER OPTIONS
|
|
|
|
parser.add_argument(
|
|
"--override",
|
|
nargs=4,
|
|
metavar=(
|
|
"FIELD",
|
|
"FIELD_VALUE",
|
|
"REPLACEMENT_FIELD",
|
|
"REPLACEMENT_FIELD_VALUE",
|
|
),
|
|
help="Override a specific field with a fixed value in some specific semester(s) selected by FIELD=FIELD_VALUE",
|
|
)
|
|
|
|
optimize_group = parser.add_mutually_exclusive_group()
|
|
optimize_group.add_argument("--reuse", "-r", action="store_true", help="Reuse mode")
|
|
optimize_group.add_argument(
|
|
"--optimize",
|
|
"-o",
|
|
action="store_true",
|
|
help="Use algorithm to enhance graph (using last result)",
|
|
)
|
|
|
|
optimize_group.add_argument(
|
|
"--restart",
|
|
"-R",
|
|
action="store_true",
|
|
help="Use algorithm to enhance graph (starting from random)",
|
|
)
|
|
if len(sys.argv) > 1 and sys.argv[1].endswith(".json"):
|
|
try:
|
|
json_file = sys.argv[1]
|
|
with open(json_file, "r") as f:
|
|
fakeclisource = json.load(f)
|
|
except FileNotFoundError:
|
|
die(f"Error: File '{json_file}' not found.", 1)
|
|
except json.JSONDecodeError:
|
|
die(f"Error: File '{json_file}' is not valid JSON.", 1)
|
|
if (
|
|
type(fakeclisource) == dict
|
|
and "cli" in fakeclisource
|
|
and type(fakeclisource["cli"]) == list
|
|
):
|
|
fakecli = [str(x) for x in fakeclisource["cli"]]
|
|
elif type(fakeclisource) == list:
|
|
fakecli = [str(x) for x in fakeclisource]
|
|
|
|
else:
|
|
die(f"Error: File '{json_file}' has no CLI arguments.")
|
|
args = parser.parse_args(args=fakecli)
|
|
else:
|
|
args = parser.parse_args()
|
|
if len(args.depts) == 0 and (
|
|
args.orders is None or args.orders[-1] == "." or "." not in args.orders
|
|
):
|
|
parser.print_help()
|
|
sys.exit(0)
|
|
return args
|
|
|
|
|
|
def options_from_args(args):
|
|
Options = OptionSet()
|
|
# Gestion de --orders pour construire le tableau 2D
|
|
orders = []
|
|
if args.depts:
|
|
depts = args.depts
|
|
else:
|
|
depts = []
|
|
if args.orders:
|
|
current_order = []
|
|
l = args.orders.copy()
|
|
idx = 0
|
|
while len(l) > idx:
|
|
item = l[idx]
|
|
idx += 1
|
|
if item == "/":
|
|
# Nouvelle ligne à chaque "--next"
|
|
orders.append(current_order)
|
|
current_order = []
|
|
elif item == ".":
|
|
# Fin de la liste d'ordres
|
|
orders.append(current_order)
|
|
break
|
|
else:
|
|
# Ajouter l'élément au sous-groupe en cours
|
|
current_order.append(item)
|
|
depts.extend(l[idx:])
|
|
Options["orders"] = orders
|
|
dargs = vars(args)
|
|
for opt in OptionSet.posint_options:
|
|
if opt[0] in dargs:
|
|
Options[opt[0]] = dargs[opt[0]]
|
|
for opt in OptionSet.stringoptions:
|
|
if opt[0] in dargs:
|
|
Options[opt[0]] = dargs[opt[0]]
|
|
for opt in OptionSet.booleanoptions:
|
|
if opt[0] in dargs:
|
|
if "no_" + opt[0] in dargs and dargs["no_" + opt[0]]:
|
|
Options[opt[0]] = not dargs["no_" + opt[0]]
|
|
else:
|
|
Options[opt[0]] = dargs[opt[0]]
|
|
if not (args.reuse or args.restart or args.optimize):
|
|
Options.algo = 0
|
|
else:
|
|
Options.algo = 0 if args.optimize else (1 if args.reuse else 2)
|
|
Options.depts(depts)
|
|
return Options
|
|
|
|
|
|
def merge_options(Options, jsondict):
|
|
if "override" in jsondict:
|
|
Options["override"] = jsondict["override"]
|
|
if "orders" in jsondict:
|
|
Options["orders"] = jsondict["orders"]
|
|
|
|
|
|
def api_url(dept: str | None = None):
|
|
"""L'URL de l'API départementale."""
|
|
# peut être modifié ici pour n'utiliser que l'API globale
|
|
return (
|
|
f"{SCODOC_SERVER}/ScoDoc/{dept}/api" if dept else f"{SCODOC_SERVER}/ScoDoc/api"
|
|
)
|
|
|
|
|
|
def read_conf(key):
|
|
if os.path.exists(f"{key}.json"):
|
|
with open(f"{key}.json", "r") as f:
|
|
return json.load(f)
|
|
return {}
|
|
|
|
|
|
def write_conf(key, obj):
|
|
with open(f"{key}.json", "w") as f:
|
|
return json.dump(obj, f)
|
|
return {}
|
|
|
|
|
|
Options = options_from_args(cli_check())
|
|
orderkey = Options.orderkey()
|
|
depts = Options.depts()
|
|
|
|
defaults = {}
|
|
|
|
|
|
student = {}
|
|
CACHE_FILE = "cache.json"
|
|
|
|
|
|
def load_cache(cache_file):
|
|
if os.path.exists(cache_file):
|
|
with open(cache_file, "r") as f:
|
|
return json.load(f)
|
|
return {}
|
|
|
|
|
|
def save_cache(cache, file=None):
|
|
if file == None:
|
|
file = CACHE_FILE
|
|
with open(CACHE_FILE, "w") as f:
|
|
json.dump(cache, f)
|
|
|
|
|
|
cache = load_cache(CACHE_FILE)
|
|
|
|
|
|
# Read color theme
|
|
# There are default color values, so may be it should just join the conf.json file
|
|
def read_theme():
|
|
if os.path.exists("theme.csv"):
|
|
with open("theme.csv", newline="") as csvfile:
|
|
csvreader = csv.reader(csvfile, delimiter=",", quotechar='"')
|
|
for row in csvreader:
|
|
if len(row) == 0:
|
|
continue
|
|
elif len(row[0]) == 0:
|
|
blockordie("Wrong line in theme : " + str(row))
|
|
elif row[0][0] == "#":
|
|
continue
|
|
else:
|
|
colors[row[0]] = row[1]
|
|
|
|
|
|
colors = {
|
|
"+DUT": "#0040C0",
|
|
"QUIT": "#00FF00",
|
|
"SUCCESS": "#0000FF",
|
|
"NORMAL": "#C0C0C0",
|
|
"FAIL": "#FF4040",
|
|
"OLD": "#FF8000",
|
|
"NEW": "#FFFF00",
|
|
"TRANSPARENT": "#FFFFFF.0",
|
|
"RED": "#000000",
|
|
}
|
|
read_theme()
|
|
|
|
# Read redirects
|
|
# Only one file, since various combinations including same departments should
|
|
# use the same redirections ("no jury yet but almost sure it will be ...")
|
|
|
|
|
|
def read_redirects():
|
|
if os.path.exists("redirect.csv"):
|
|
with open("redirect.csv", newline="") as csvfile:
|
|
csvreader = csv.reader(csvfile, delimiter=",", quotechar='"')
|
|
for row in csvreader:
|
|
if len(row) == 0:
|
|
continue
|
|
elif len(row[0]) == 0:
|
|
blockordie("Wrong line in redirect : " + str(row))
|
|
elif row[0][0] == "#":
|
|
continue
|
|
else:
|
|
redirects[int(row[0])] = row[1]
|
|
|
|
|
|
redirects = {}
|
|
read_redirects()
|
|
|
|
|
|
# Gestion globale d'un jeton pour l'API
|
|
token = None
|
|
|
|
|
|
def get_json(url: str, params=None):
|
|
debug(f"Requesting {url}")
|
|
global token
|
|
if token == None:
|
|
url_token = f"{API_URL}/tokens"
|
|
response = requests.post(
|
|
url_token, auth=HTTPBasicAuth(SCODOC_USER, SCODOC_PASSWORD)
|
|
)
|
|
if response.status_code == 200:
|
|
token = response.json().get("token")
|
|
else:
|
|
blockordie(
|
|
f"Erreur de récupération de token: {response.status_code} - {response.text}",
|
|
status=1,
|
|
)
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
response = requests.get(url, headers=headers, params=params)
|
|
if response.status_code == 200:
|
|
# Afficher la réponse JSON
|
|
return response.json()
|
|
else:
|
|
blockordie(
|
|
f"Erreur avec {url}: {response.status_code} - {response.text}", status=1
|
|
)
|
|
|
|
|
|
formsem_dept = {}
|
|
formsem_department = {}
|
|
|
|
|
|
def get_formsem_from_dept(dept):
|
|
if "formsems" in cache and dept in cache["formsems"]:
|
|
return cache["formsems"][dept]
|
|
if "formsems" not in cache:
|
|
cache["formsems"] = {}
|
|
if "sem" not in cache:
|
|
cache["sem"] = {}
|
|
query_url = f"{api_url(dept)}/formsemestres/query"
|
|
formsemestres = get_json(query_url)
|
|
result = []
|
|
for sem in formsemestres:
|
|
semid = str(sem["formsemestre_id"])
|
|
formsem_dept[semid] = dept
|
|
cache["sem"][semid] = sem
|
|
result.append(semid)
|
|
cache["formsems"][dept] = result
|
|
save_cache(cache)
|
|
return result
|
|
|
|
|
|
def get_formations_from_dept(dept):
|
|
if "formations" in cache and dept in cache["formations"]:
|
|
return cache["formations"][dept]
|
|
if "formations" not in cache:
|
|
cache["formations"] = {}
|
|
query_url = f"{api_url(dept)}/formations"
|
|
formations = get_json(query_url)
|
|
result = []
|
|
for f in formations:
|
|
if f["type_parcours"] == 700:
|
|
result.append(f["formation_id"])
|
|
cache["formations"][dept] = result
|
|
save_cache(cache)
|
|
return result
|
|
|
|
|
|
def get_etuds_from_formsem(dept, semid):
|
|
if type(semid) == type(0):
|
|
semid = str(semid)
|
|
if "etudlist" in cache and semid in cache["etudlist"]:
|
|
return cache["etudlist"][semid]
|
|
if "etudlist" not in cache:
|
|
cache["etudlist"] = {}
|
|
query_url = f"{api_url(dept)}/formsemestre/{semid}/etudiants/long"
|
|
result = get_json(query_url)
|
|
cache["etudlist"][semid] = result
|
|
save_cache(cache)
|
|
return result
|
|
|
|
|
|
def get_jury_from_formsem(dept: str, semid):
|
|
if type(semid) == type(0):
|
|
semid = str(semid)
|
|
if "semjury" in cache and semid in cache["semjury"]:
|
|
return cache["semjury"][semid]
|
|
if "semjury" not in cache:
|
|
cache["semjury"] = {}
|
|
|
|
# query_url = f"{server}{dept}/Scolarite/Notes/formsemestre_recapcomplet?formsemestre_id={semid}&mode_jury=1&tabformat=json"
|
|
query_url = f"{api_url(dept)}/formsemestre/{semid}/decisions_jury"
|
|
result = get_json(query_url)
|
|
cache["semjury"][semid] = result
|
|
save_cache(cache)
|
|
return result
|
|
|
|
|
|
def get_override(sem, xkey, default=None):
|
|
if "overrides" not in Options:
|
|
return default
|
|
overrides = Options.override
|
|
for j in ["titre_num", "titre", "session_id"]:
|
|
if (
|
|
j in sem
|
|
and j in overrides
|
|
and sem[j] in overrides[j]
|
|
and xkey in overrides[j][sem[j]]
|
|
):
|
|
return overrides[j][sem[j]][xkey]
|
|
return default
|
|
|
|
|
|
def nick_replace(
|
|
department, diplome, rank, modalite, parcours, nick, year=Options.baseyear
|
|
):
|
|
if type(rank) != int:
|
|
rank = 0
|
|
if len(department) > 0:
|
|
nick = nick.replace("{department}", Options.department_separator + department)
|
|
else:
|
|
nick = nick.replace("{department}", "")
|
|
if len(department) > 0 and len(depts) > 1:
|
|
nick = nick.replace(
|
|
"{multidepartment}", Options.department_separator + department
|
|
)
|
|
else:
|
|
nick = nick.replace("{multidepartment}", "")
|
|
if len(diplome) > 0:
|
|
nick = nick.replace("{diplome}", Options.diplome_separator + diplome)
|
|
else:
|
|
nick = nick.replace("{diplome}", "")
|
|
if len(diplome) > 0 and diplome != "BUT":
|
|
nick = nick.replace("{diplomenobut}", Options.diplome_separator + diplome)
|
|
else:
|
|
nick = nick.replace("{diplomenobut}", "")
|
|
if rank > 0:
|
|
nick = nick.replace("{rank}", Options.rank_separator + str(rank))
|
|
else:
|
|
nick = nick.replace("{rank}", "")
|
|
nick = nick.replace(
|
|
"{year}", Options.year_separator + str(Options.baseyear + rank - 1)
|
|
)
|
|
if diplome != "BUT":
|
|
nick = nick.replace(
|
|
"{yearnobut}",
|
|
Options.year_separator + str(Options.baseyear + rank - 1),
|
|
)
|
|
else:
|
|
nick = nick.replace("{yearnobut}", "")
|
|
if len(modalite) > 0:
|
|
nick = nick.replace("{modalite}", Options.modalite_separator + modalite)
|
|
else:
|
|
nick = nick.replace("{modalite}", "")
|
|
if len(modalite) > 0 and modalite != "FI":
|
|
nick = nick.replace("{modaliteshort}", modalite[-1])
|
|
else:
|
|
nick = nick.replace("{modaliteshort}", "")
|
|
if len(parcours) > 0:
|
|
nick = nick.replace("{parcours}", Options.parcours_separator + parcours)
|
|
else:
|
|
nick = nick.replace("{parcours}", "")
|
|
extname = "Ecand "
|
|
if diplome == "BUT":
|
|
extname = "EXT"
|
|
nick = nick.replace("{ext}", extname)
|
|
return nick
|
|
|
|
|
|
def analyse_student(semobj, etud, univ_year=None):
|
|
"""Returns the final (department,diplome,rank,modalite,parcours,nickname,displayname) tuple from etudid in semid, taking into accounts overrides."""
|
|
session_id = semobj["session_id"].split("-")
|
|
year = str(semobj["annee_scolaire"])
|
|
department = session_id[0]
|
|
diplome = session_id[1]
|
|
modalite = session_id[2]
|
|
if univ_year == None:
|
|
if semobj["semestre_id"] < 0:
|
|
rank = 1
|
|
else:
|
|
rank = (semobj["semestre_id"] + 1) // 2
|
|
else:
|
|
rank = univ_year
|
|
parcours = None
|
|
groups = []
|
|
if "groups" in etud:
|
|
for x in etud["groups"]:
|
|
if x["partition_name"] == "Parcours":
|
|
parcours = x["group_name"]
|
|
groups.append(x["group_name"])
|
|
if parcours == None:
|
|
parcours = ""
|
|
parcours = get_override(semobj, "parcours", parcours)
|
|
department = get_override(semobj, "department", department)
|
|
rank = get_override(semobj, "rank", rank)
|
|
diplome = get_override(semobj, "diplome", diplome)
|
|
modalite = get_override(semobj, "modalite", modalite)
|
|
formsem_department[str(semobj["id"])] = department
|
|
if len(modalite) > 0 and modalite[0] == "G":
|
|
goal = modalite.split(":")[1:]
|
|
modalite = None
|
|
for g in goal:
|
|
gg = g.split("=")
|
|
if gg[0] in groups:
|
|
modalite = gg[1]
|
|
nick = Options.nick
|
|
nick = nick_replace(department, diplome, rank, modalite, parcours, nick, year)
|
|
displayname = Options.displayname
|
|
displayname = nick_replace(
|
|
department, diplome, rank, modalite, parcours, displayname, year
|
|
)
|
|
return department, diplome, rank, modalite, parcours, nick, displayname
|
|
|
|
|
|
def get_nick(semobj, etud):
|
|
department, diplome, rank, modalite, parcours, nick, displayname = analyse_student(
|
|
semobj, etud
|
|
)
|
|
return nick, displayname
|
|
|
|
|
|
def get_dept_from_sem(semid):
|
|
return formsem_department[str(semid)]
|
|
|
|
|
|
oldsems = set()
|
|
oldsemsdept = {}
|
|
futuresems = set()
|
|
futuresemsdept = {}
|
|
bacs = set()
|
|
cohort_nip = set()
|
|
|
|
|
|
def analyse_depts():
|
|
for dept in depts:
|
|
formsems = get_formsem_from_dept(dept)
|
|
for semid in formsems:
|
|
# Check if this is a part of the cohort
|
|
# or a future/old semester
|
|
sem = cache["sem"][str(semid)]
|
|
if sem["semestre_id"] < 0:
|
|
year = 1
|
|
else:
|
|
year = (sem["semestre_id"] + 1) // 2
|
|
offset = sem["annee_scolaire"] - Options.baseyear - year + 1
|
|
if offset < 0 and offset > -4:
|
|
oldsems.add(str(semid))
|
|
oldsemsdept[semid] = dept
|
|
if offset > 0 and offset < 4:
|
|
futuresems.add(str(semid))
|
|
futuresemsdept[semid] = dept
|
|
if offset != 0:
|
|
continue
|
|
if sem["formation"]["type_parcours"] != 700:
|
|
continue
|
|
if sem["modalite"] == "EXT":
|
|
continue
|
|
# This is a BUT semester, part of the cohort
|
|
# 0,1 : preceding year ; 2-7 : cohort ; 8+ : future
|
|
if sem["semestre_id"] < 0:
|
|
bucket = 1
|
|
else:
|
|
bucket = str(int(sem["semestre_id"] - 1))
|
|
# Ici, le semestre est donc un semestre intéressant
|
|
# On prélève tous les étudiants, et on remplit leur cursus
|
|
etuds = get_etuds_from_formsem(dept, semid)
|
|
jurys = get_jury_from_formsem(dept, semid)
|
|
key = sem["titre_num"]
|
|
for etud in etuds:
|
|
etudid = etud["id"]
|
|
if etudid in student:
|
|
studentsummary = student[etudid]
|
|
else:
|
|
studentsummary = {}
|
|
studentsummary["cursus"] = {} # Cursus is semid
|
|
studentsummary["etudid"] = {} # useful when merging students
|
|
studentsummary["pseudodept"] = {} # pseudo-dept for interdept
|
|
studentsummary["diplome"] = {} # diplome name
|
|
studentsummary["rank"] = {} # rank
|
|
studentsummary["modalite"] = {} # modalite
|
|
studentsummary["parcours"] = {} # parcours
|
|
studentsummary["nickname"] = {} # nick
|
|
studentsummary["displayname"] = {} # display name
|
|
studentsummary["dept"] = dept # useful when merging students
|
|
studentsummary["bac"] = "" # usually
|
|
department, diplome, rank, modalite, parcours, nick, displayname = (
|
|
analyse_student(sem, etud, year)
|
|
)
|
|
if "bac" in etud["admission"]:
|
|
studentsummary["bac"] = etud["admission"]["bac"]
|
|
else:
|
|
studentsummary["bac"] = "INCONNU"
|
|
if "civilite" in etud:
|
|
studentsummary["civilite"] = etud["civilite"]
|
|
else:
|
|
studentsummary["civilite"] = "?"
|
|
bacs.add(studentsummary["bac"])
|
|
if bucket in studentsummary["cursus"]:
|
|
semestreerreur = int(bucket) + 1
|
|
warning(
|
|
f"// Élève {etudid} dans deux semestres à la fois : S{semestreerreur}, semestres {studentsummary['cursus'][bucket]} et {semid}"
|
|
)
|
|
if "dept" in studentsummary and studentsummary["dept"] != dept:
|
|
warning(
|
|
f"// Élève ayant changé de département {dept},{studentsummary['dept']}"
|
|
)
|
|
# department, diplome, rank, modalite, parcours, nick = analyse_student(
|
|
studentsummary["cursus"][bucket] = semid
|
|
studentsummary["etudid"][bucket] = etudid
|
|
studentsummary["pseudodept"][bucket] = department
|
|
studentsummary["diplome"][bucket] = diplome
|
|
studentsummary["rank"][bucket] = rank
|
|
studentsummary["modalite"][bucket] = modalite
|
|
studentsummary["parcours"][bucket] = parcours
|
|
studentsummary["nickname"][bucket] = nick
|
|
studentsummary["displayname"][bucket] = displayname
|
|
studentsummary["debug"] = etud["sort_key"] # TODO: REMOVE
|
|
studentsummary["unid"] = etud["code_nip"]
|
|
cohort_nip.add(etud["code_nip"])
|
|
student[etudid] = studentsummary
|
|
|
|
|
|
analyse_depts()
|
|
|
|
|
|
def allseeingodin():
|
|
"""This function changes the student lists by peeking in the past and the future to know which students come from another cohort or go into a later cohort."""
|
|
displaynames = {}
|
|
oldstudents = {}
|
|
oldstudentslevel = {}
|
|
futurestudents = {}
|
|
futurestudentslevel = {}
|
|
|
|
# We look for the latest "old semester" in which every (old) student went
|
|
for semid in oldsems:
|
|
sem = cache["sem"][semid]
|
|
semlevel = sem["semestre_id"]
|
|
# For a while, some people registered former semesters (in other places) with "EXT" modalite for a fake semester
|
|
if sem["modalite"] == "EXT": # Ignore EXT modalite
|
|
continue
|
|
semlevel = abs(semlevel)
|
|
dept = oldsemsdept[semid]
|
|
etuds = get_etuds_from_formsem(dept, semid)
|
|
for etud in etuds:
|
|
nip = etud["code_nip"]
|
|
if nip not in cohort_nip:
|
|
continue
|
|
if nip not in oldstudentslevel or semlevel > oldstudentslevel[nip]:
|
|
oldstudentslevel[nip] = semlevel
|
|
nick_t, disp_t = get_nick(sem, etud)
|
|
oldstudents[nip] = [semid, nick_t]
|
|
displaynames[nick_t] = disp_t
|
|
for semid in futuresems:
|
|
sem = cache["sem"][semid]
|
|
if sem["formation"]["type_parcours"] != 700:
|
|
# We are only interested in BUT continuations (for now)
|
|
continue
|
|
semlevel = sem["semestre_id"]
|
|
semlevel = abs(semlevel)
|
|
dept = futuresemsdept[semid]
|
|
etuds = get_etuds_from_formsem(dept, semid)
|
|
for etud in etuds:
|
|
nip = etud["code_nip"]
|
|
if nip not in cohort_nip:
|
|
continue
|
|
if nip not in futurestudentslevel or semlevel > futurestudentslevel[nip]:
|
|
futurestudentslevel[nip] = semlevel
|
|
futurestudents[nip], tmp = get_nick(sem, etud)
|
|
|
|
unification = {}
|
|
|
|
duplicates = {}
|
|
|
|
for etudid in student.keys():
|
|
unid = student[etudid]["unid"]
|
|
if unid in unification:
|
|
if unid not in duplicates:
|
|
duplicates[unid] = [unification[unid]]
|
|
duplicates[unid].append(etudid)
|
|
unification[unid] = etudid
|
|
if unid in oldstudents:
|
|
student[etudid]["old"] = oldstudents[unid][1]
|
|
student[etudid]["oldsem"] = oldstudents[unid][0]
|
|
if unid in futurestudents:
|
|
student[etudid]["future"] = futurestudents[unid]
|
|
for unid in duplicates:
|
|
lastsem = -1
|
|
best = []
|
|
for suppidx in duplicates[unid][1:]:
|
|
supp = student[suppidx]
|
|
if str(lastsem) in supp["cursus"]:
|
|
best.append(suppidx)
|
|
for sem in range(5, lastsem, -1):
|
|
if str(sem) in supp["cursus"]:
|
|
lastsem = sem
|
|
best = [suppidx]
|
|
break
|
|
if len(best) > 1:
|
|
print(f"// Error: cannot chose last semester for NIP {unid}: ")
|
|
print(repr(best))
|
|
for x in best:
|
|
print(cache["sem"][str(x)])
|
|
sys.exit(6)
|
|
bestid = best[0]
|
|
base = student[bestid]
|
|
for suppidx in duplicates[unid]:
|
|
if suppidx == bestid:
|
|
continue
|
|
supp = student[suppidx]
|
|
for skey in (
|
|
"cursus",
|
|
"etudid",
|
|
"pseudodept",
|
|
"diplome",
|
|
"rank",
|
|
"modalite",
|
|
"parcours",
|
|
"nickname",
|
|
"displayname",
|
|
"old",
|
|
"oldsem",
|
|
):
|
|
if skey in supp:
|
|
for bucket in supp[skey]:
|
|
if bucket not in base[skey]:
|
|
base[skey][bucket] = supp[skey][bucket]
|
|
del student[suppidx]
|
|
foundfirst = False
|
|
# Ensure all cursus are continuous
|
|
for etudid in student:
|
|
etud = student[etudid]
|
|
foundfirst = False
|
|
foundlast = False
|
|
fillblanks = None
|
|
there = -1
|
|
for i in range(6):
|
|
if str(i) in etud["cursus"]:
|
|
if foundfirst and foundlast:
|
|
fillblanks = [there, i]
|
|
else:
|
|
foundfirst = True
|
|
here = i
|
|
else:
|
|
if not foundfirst:
|
|
continue
|
|
foundlast = True
|
|
there = i
|
|
if fillblanks is not None:
|
|
for i in range(fillblanks[0] - 1, fillblanks[1]):
|
|
bucket = str(i)
|
|
if bucket not in etud["cursus"]:
|
|
etud["etudid"][bucket] = etudid
|
|
etud["cursus"][bucket] = -1
|
|
etud["pseudodept"][bucket] = "OUT"
|
|
etud["diplome"][bucket] = "OUT"
|
|
etud["rank"][bucket] = (i // 2) + 1
|
|
etud["modalite"][bucket] = "FI"
|
|
etud["parcours"][bucket] = ""
|
|
etud["nickname"][bucket] = "OUT" + str(etud["rank"][bucket])
|
|
etud["displayname"][bucket] = "Césure"
|
|
displaynames[etud["nickname"][bucket]] = etud["displayname"][bucket]
|
|
return displaynames
|
|
|
|
|
|
displaynames = allseeingodin()
|
|
|
|
strange_cases = []
|
|
next = {}
|
|
nextnick = {}
|
|
|
|
|
|
def prepare_display(displaynames):
|
|
for etudid in student:
|
|
for semlevel in range(5):
|
|
if str(semlevel) in student[etudid]["nickname"]:
|
|
a = student[etudid]["nickname"][str(semlevel)]
|
|
b = student[etudid]["displayname"][str(semlevel)]
|
|
if a in displaynames:
|
|
if b != displaynames[a]:
|
|
die("{a} will be displayed as {b} or {displaynames[a]} !", 6)
|
|
else:
|
|
displaynames[a] = b
|
|
return displaynames
|
|
|
|
|
|
displaynames = prepare_display(displaynames)
|
|
|
|
|
|
for etudid in student.keys():
|
|
etud = student[etudid]
|
|
cursus_array = [None] * 6
|
|
nickname_array = [None] * 6
|
|
etudid_array = [None] * 6
|
|
for i in range(6):
|
|
if str(i) in etud["cursus"]:
|
|
cursus_array[i] = etud["cursus"][str(i)]
|
|
nickname_array[i] = etud["nickname"][str(i)]
|
|
etudid_array[i] = etud["etudid"][str(i)]
|
|
# On va réduire aux semestres pairs, on cherche donc la continuation la plus habituelle pour
|
|
# les élèves qui s'arrêtent sur un semestre impair
|
|
for i in range(0, 5, 2):
|
|
currs = str(cursus_array[i])
|
|
nexts = str(cursus_array[i + 1])
|
|
currn = str(nickname_array[i])
|
|
nextn = str(nickname_array[i + 1])
|
|
if nexts is not None:
|
|
if currs not in next:
|
|
next[currs] = {}
|
|
if nexts not in next[currs]:
|
|
next[currs][nexts] = 1
|
|
else:
|
|
next[currs][nexts] += 1
|
|
if nextn is not None:
|
|
if currn not in nextnick:
|
|
nextnick[currn] = {}
|
|
if nextn not in nextnick[currn]:
|
|
nextnick[currn][nextn] = 1
|
|
else:
|
|
nextnick[currn][nextn] += 1
|
|
etud["cursus_array"] = cursus_array
|
|
etud["nickname_array"] = nickname_array
|
|
etud["etudid_array"] = etudid_array
|
|
|
|
nextbest = {}
|
|
nextnickbest = {}
|
|
for key in next:
|
|
imax = 0
|
|
best = None
|
|
for key2 in next[key]:
|
|
if next[key][key2] > imax:
|
|
imax = next[key][key2]
|
|
best = key2
|
|
nextbest[key] = best
|
|
for key in nextnick:
|
|
imax = 0
|
|
best = None
|
|
for key2 in nextnick[key]:
|
|
if nextnick[key][key2] > imax:
|
|
imax = nextnick[key][key2]
|
|
best = key2
|
|
nextnickbest[key] = best
|
|
|
|
evennicknames = {}
|
|
for etudid in student.keys():
|
|
etud = student[etudid]
|
|
for i in range(1, 6, 2):
|
|
if etud["nickname_array"][i] not in evennicknames:
|
|
evennicknames[etud["nickname_array"][i]] = 1
|
|
else:
|
|
evennicknames[etud["nickname_array"][i]] += 1
|
|
|
|
|
|
for etudid in student.keys():
|
|
etud = student[etudid]
|
|
cursus_short = [None] * 5
|
|
nickname_short = [None] * 5
|
|
etudid_short = [None] * 5
|
|
semend = None
|
|
semstart = None
|
|
for year in range(1, 4):
|
|
sem1 = 2 * year - 2
|
|
sem2 = 2 * year - 1
|
|
finalsem = etud["cursus_array"][sem2]
|
|
nick = etud["nickname_array"][sem2]
|
|
etid = etud["etudid_array"][sem2]
|
|
if finalsem == None:
|
|
finalsem = etud["cursus_array"][sem1]
|
|
nick = etud["nickname_array"][sem1]
|
|
etid = etud["etudid_array"][sem1]
|
|
if finalsem != None:
|
|
# Abandon au premier semestre de cette année
|
|
# print(f"Pour {etudid}, année {year}, abandon au S1")
|
|
if nick not in evennicknames:
|
|
# print( f"Pour {etudid}, année {year}, changement {nick} en {nextnickbest[nick]}" )
|
|
nick = nextnickbest[nick]
|
|
if finalsem != None:
|
|
cursus_short[year] = finalsem
|
|
nickname_short[year] = nick
|
|
etudid_short[year] = etid
|
|
if etud["cursus_array"][sem1] == None:
|
|
# print(f"Pour {etudid}, année {year}, saute-mouton du S1")
|
|
pass
|
|
etud["short"] = cursus_short
|
|
etud["nickshort"] = nickname_short
|
|
etud["etudidshort"] = etudid_short
|
|
|
|
|
|
for etudid in student.keys():
|
|
etud = student[etudid]
|
|
lastyear = 4
|
|
lastsem = None
|
|
while lastsem == None:
|
|
lastyear -= 1
|
|
lastsem = etud["short"][lastyear]
|
|
ddd = get_dept_from_sem(lastsem)
|
|
if ddd not in depts:
|
|
depts.append(ddd)
|
|
|
|
badred = {}
|
|
goodred = {}
|
|
failure = {}
|
|
diploma = {}
|
|
reor2 = {}
|
|
reor1 = {}
|
|
unknown = {}
|
|
entries = {}
|
|
redirs = {}
|
|
|
|
finals = {
|
|
"FAIL": "✘",
|
|
"RED": "↩",
|
|
"QUIT": "↴",
|
|
"+DUT": "➡",
|
|
"DIPLOME": "✔",
|
|
"?": "?",
|
|
}
|
|
|
|
for d in depts:
|
|
badred[d] = 0
|
|
goodred[d] = 0
|
|
failure[d] = 0
|
|
diploma[d] = 0
|
|
reor2[d] = 0
|
|
reor1[d] = 0
|
|
unknown[d] = 0
|
|
entries[d] = 0
|
|
redirs[d] = 0
|
|
|
|
strangecases = []
|
|
for etudid in student.keys():
|
|
etud = student[etudid]
|
|
lastyear = 4
|
|
lastsem = None
|
|
while lastsem == None:
|
|
lastyear -= 1
|
|
lastsem = etud["short"][lastyear]
|
|
ddd = get_dept_from_sem(lastsem)
|
|
jury = get_jury_from_formsem(None, lastsem)
|
|
etudid_real = etud["etudidshort"][lastyear]
|
|
if etudid_real != etudid:
|
|
print(f"// Warning {etudid} {etudid_real}")
|
|
resjury = None
|
|
for x in jury:
|
|
if x["etudid"] == etudid_real:
|
|
resjury = x
|
|
break
|
|
if resjury == None:
|
|
print(f"// No jury for {etudid} year {lastyear}")
|
|
continue
|
|
resultyear = None
|
|
if resjury["etat"] == "D":
|
|
resultyear = "DEM"
|
|
if resjury["etat"] == "DEF":
|
|
resultyear = "DEF"
|
|
if (
|
|
"annee" in resjury
|
|
and "code" in resjury["annee"]
|
|
and resjury["annee"]["code"] is not None
|
|
):
|
|
resultyear = resjury["annee"]["code"]
|
|
finaloutput = None
|
|
checkred = False
|
|
if etudid in redirects:
|
|
resultyear = redirects[etudid]
|
|
redirs[ddd] += 1
|
|
strangecases.append(
|
|
f"REDI{lastyear} {SCODOC_SERVER}/{ddd}/Scolarite/fiche_etud?etudid={etudid}"
|
|
)
|
|
if resultyear == None:
|
|
finaloutput = "?"
|
|
unknown[ddd] += 1
|
|
strangecases.append(
|
|
f"????{lastyear} {SCODOC_SERVER}/{ddd}/Scolarite/fiche_etud?etudid={etudid}"
|
|
)
|
|
elif resultyear in ("RAT", "ATJ"):
|
|
finaloutput = "?"
|
|
unknown[ddd] += 1
|
|
strangecases.append(
|
|
f"ATTE{lastyear} {SCODOC_SERVER}/{ddd}/Scolarite/fiche_etud?etudid={etudid}"
|
|
)
|
|
elif resultyear in ("RED", "ABL", "ADSUP"):
|
|
finaloutput = "RED"
|
|
checkred = True
|
|
elif lastyear == 3 and resultyear in ("ADM", "ADJ"):
|
|
finaloutput = "DIPLOME"
|
|
diploma[ddd] += 1
|
|
elif lastyear == 2 and resultyear in ("ADM", "ADJ"):
|
|
finaloutput = "+DUT"
|
|
reor2[ddd] += 1
|
|
elif resultyear in ("PAS1NCI", "PASD"):
|
|
finaloutput = "QUIT"
|
|
reor1[ddd] += 1
|
|
elif lastyear < 2 and resultyear in ("ADM", "ADJ"):
|
|
finaloutput = "QUIT"
|
|
reor1[ddd] += 1
|
|
elif resultyear in ("NAR", "DEM", "DEF", "ABAN"):
|
|
finaloutput = "FAIL"
|
|
failure[ddd] += 1
|
|
elif resjury["annee"]["annee_scolaire"] != Options.baseyear + lastyear - 1:
|
|
finaloutput = "RED"
|
|
checkred = True
|
|
if checkred:
|
|
if "future" not in etud:
|
|
# print(f"// Mauvais redoublement : {etudid}")
|
|
badred[ddd] += 1
|
|
finaloutput = "FAIL"
|
|
else:
|
|
goodred[ddd] += 1
|
|
output = finaloutput + " " + etud["nickshort"][lastyear]
|
|
etud["nickshort"][lastyear + 1] = output
|
|
displaynames[output] = (
|
|
finals[finaloutput] + displaynames[etud["nickshort"][lastyear]]
|
|
)
|
|
(firstsem, firstyear) = (
|
|
(etud["short"][1], 1)
|
|
if etud["short"][1] != None
|
|
else (
|
|
(etud["short"][2], 2) if etud["short"][2] != None else (etud["short"][3], 3)
|
|
)
|
|
)
|
|
firstdept = cache["sem"][firstsem]["departement"]["acronym"]
|
|
if "old" in etud:
|
|
yearold = cache["sem"][etud["oldsem"]]["annee_scolaire"]
|
|
etud["nickshort"][firstyear - 1] = etud["old"]
|
|
# yy = yearold
|
|
# delta = firstyear + Options.baseyear - yy - 2
|
|
# for i in range(delta, firstyear - 1):
|
|
# etud["nickshort"][i] = etud["nickshort"][firstyear - 1] + "*" * (
|
|
# firstyear - 1 - i
|
|
# )
|
|
else:
|
|
if (
|
|
str(firstyear * 2 - 2) in etud["cursus"]
|
|
and etud["cursus"][str(firstyear * 2 - 2)] is not None
|
|
):
|
|
startsem = str(firstyear * 2 - 2)
|
|
else:
|
|
startsem = str(firstyear * 2 - 1)
|
|
department = etud["pseudodept"][startsem]
|
|
diplome = etud["diplome"][startsem]
|
|
rank = etud["rank"][startsem]
|
|
modalite = etud["modalite"][startsem]
|
|
parcours = etud["parcours"][startsem]
|
|
nick = "EXT" + Options.nick
|
|
nick = nick_replace(department, diplome, rank, modalite, parcours, nick)
|
|
displayname = Options.extnick
|
|
displayname = nick_replace(
|
|
department, diplome, rank, modalite, parcours, displayname
|
|
)
|
|
displaynames[nick] = displayname
|
|
etud["nickshort"][firstyear - 1] = nick
|
|
# to get a better ordering in sankeymatic, false nodes ere required
|
|
# This is no more the case when building our own graphics
|
|
# for i in range(0, firstyear - 1):
|
|
# etud["nickshort"][i] = nick + "*" * (firstyear - 1 - i)
|
|
entries[ddd] += 1
|
|
|
|
|
|
def bags_from_students(student, filter=0):
|
|
bags = []
|
|
for etudid in student.keys():
|
|
# Filter
|
|
# 0 No filter
|
|
# 1 Keep only technological baccalaureate
|
|
# 2 Keep only women
|
|
# ... to be completed
|
|
if filter & Filter.TECHNO:
|
|
if student[etudid]["bac"][:2] != "ST":
|
|
continue
|
|
parc = student[etudid]["nickshort"]
|
|
for i in range(len(parc) - 1):
|
|
while len(bags) <= i:
|
|
bags.append({})
|
|
nstart = parc[i]
|
|
nend = parc[i + 1]
|
|
if nstart != None and nend != None:
|
|
if nstart not in bags[i]:
|
|
bags[i][nstart] = {}
|
|
if nend not in bags[i][nstart]:
|
|
bags[i][nstart][nend] = 1
|
|
else:
|
|
bags[i][nstart][nend] += 1
|
|
return bags
|
|
|
|
|
|
def node_color(x):
|
|
color = colors["NORMAL"]
|
|
if x[0:4] == "FAIL":
|
|
color = f"{colors['FAIL']} <<"
|
|
elif x[0:4] == "+DUT":
|
|
color = f"{colors['+DUT']} <<"
|
|
elif x[0:4] == "QUIT":
|
|
color = f"{colors['QUIT']} <<"
|
|
elif x[0:3] == "RED":
|
|
color = f"{colors['RED']} <<"
|
|
elif x[0:4] == "DIPL":
|
|
color = f"{colors['SUCCESS']} <<"
|
|
elif x[0:3] == "EXT":
|
|
color = f"{colors['NEW']} >>"
|
|
elif x[0:3] == "BUT":
|
|
color = f"{colors['NORMAL']}"
|
|
elif x[0:3] == "DUT":
|
|
color = f"{colors['OLD']} >>"
|
|
if x[-1] == "*":
|
|
color = f"{colors['TRANSPARENT']} >>"
|
|
return color
|
|
|
|
|
|
def textwidth(text, font="Arial", fontsize=14):
|
|
try:
|
|
import cairo
|
|
except:
|
|
return len(text) * fontsize
|
|
surface = cairo.SVGSurface("undefined.svg", 1280, 200)
|
|
cr = cairo.Context(surface)
|
|
cr.select_font_face(font, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
|
|
cr.set_font_size(fontsize)
|
|
xbearing, ybearing, width, height, xadvance, yadvance = cr.text_extents(text)
|
|
return width
|
|
|
|
|
|
def crossweight(node_position, node_layer, edges):
|
|
w = 0
|
|
for e in edges:
|
|
for ee in edges:
|
|
if node_layer[e[0]] != node_layer[ee[0]]:
|
|
continue
|
|
if node_layer[e[1]] != node_layer[ee[1]]:
|
|
continue
|
|
if (node_position[e[0]] - node_position[ee[0]]) * (
|
|
node_position[e[1]] - node_position[ee[1]]
|
|
) < 0:
|
|
w += e[2] * ee[2]
|
|
return w
|
|
|
|
|
|
def genetic_optimize(node_position, node_layer, edges, loops=1):
|
|
debug(f"Begin genetic optimization with {loops} loops")
|
|
oldcandidates = []
|
|
l_indices = list(range(5))
|
|
lays = []
|
|
randomness_l = []
|
|
for index in range(5):
|
|
lays.append([x for x in node_layer.keys() if node_layer[x] == index])
|
|
if len(lays[index]) > 1:
|
|
for i in lays[index]:
|
|
randomness_l.append(index)
|
|
w = crossweight(node_position, node_layer, edges)
|
|
|
|
for i in range(20):
|
|
oldcandidates.append([node_position.copy(), w])
|
|
w = crossweight(node_position, node_layer, edges)
|
|
for i in range(10):
|
|
n = node_position.copy()
|
|
l_idx = random.choice(randomness_l)
|
|
q = lays[l_idx].copy()
|
|
k = 0
|
|
while len(q) > 0:
|
|
nn = random.choice(q)
|
|
q.remove(nn)
|
|
n[nn] = k
|
|
k += 1
|
|
oldcandidates.append([n, w])
|
|
candidates = oldcandidates
|
|
for i in range(loops):
|
|
oldcandidates = candidates
|
|
oldcandidates.sort(key=lambda x: x[1])
|
|
candidates = oldcandidates[:30]
|
|
while len(candidates) < 60:
|
|
# mutate some random candidate
|
|
candidate = random.choice(candidates)[0]
|
|
new_position = candidate.copy() # Copier la position pour la muter
|
|
l_idx = random.choice(randomness_l)
|
|
swapa = random.choice(lays[l_idx])
|
|
swapb = random.choice(lays[l_idx])
|
|
while swapa == swapb:
|
|
swapb = random.choice(lays[l_idx])
|
|
tmp = new_position[swapa]
|
|
new_position[swapa] = new_position[swapb]
|
|
new_position[swapb] = tmp
|
|
w = crossweight(new_position, node_layer, edges)
|
|
candidates.append([new_position, w])
|
|
while len(candidates) < 90:
|
|
# mutate some random candidate
|
|
candidate = random.choice(candidates)[0]
|
|
new_position = candidate.copy() # Copier la position pour la muter
|
|
l_idx = random.choice(randomness_l)
|
|
startidx = random.randrange(len(lays[l_idx]) - 1)
|
|
stopidx = random.randrange(startidx + 1, len(lays[l_idx]))
|
|
for n in lays[l_idx]:
|
|
if new_position[n] >= startidx and new_position[n] < stopidx:
|
|
new_position[n] += 1
|
|
elif new_position[n] == stopidx:
|
|
new_position[n] = startidx
|
|
w = crossweight(new_position, node_layer, edges)
|
|
candidates.append([new_position, w])
|
|
while len(candidates) < 100:
|
|
# mutate some random candidate
|
|
candidate = random.choice(candidates)[0]
|
|
candidate2 = random.choice(candidates)[0]
|
|
new_position = candidate.copy() # Copier la position pour la muter
|
|
l_idx = random.choice(randomness_l)
|
|
for n in lays[l_idx]:
|
|
new_position[n] = candidate2[n]
|
|
w = crossweight(new_position, node_layer, edges)
|
|
candidates.append([new_position, w])
|
|
candidates.sort(key=lambda x: x[1])
|
|
orders = []
|
|
best = candidates[0][0]
|
|
for i in range(5):
|
|
b = lays[i].copy()
|
|
b.sort(key=lambda x: best[x])
|
|
orders.append(b)
|
|
return orders
|
|
|
|
|
|
def ordernodes(layers, orders, edges):
|
|
node_position = {}
|
|
node_layer = {}
|
|
newls = [[], [], [], [], []]
|
|
if orders != {}:
|
|
for i in range(len(newls)):
|
|
ls = newls[i]
|
|
for node in orders[i]:
|
|
if node in layers[i]:
|
|
ls.append(node)
|
|
for node in layers[i]:
|
|
if node not in ls:
|
|
ls.append(node)
|
|
for layer, layernodes in enumerate(newls):
|
|
for j, n in enumerate(layernodes):
|
|
node_position[n] = j
|
|
node_layer[n] = layer
|
|
else:
|
|
for layer, layernodes in enumerate(layers):
|
|
for j, n in enumerate(layernodes):
|
|
node_position[n] = j
|
|
node_layer[n] = layer
|
|
debug("Solution has weight " + str(crossweight(node_position, node_layer, edges)))
|
|
return node_position, node_layer, newls
|
|
|
|
|
|
def get_layer_structure(newls, node_structure, spacing, hmargin, height):
|
|
layer_structure = []
|
|
density = []
|
|
for i in range(5):
|
|
ls = {}
|
|
ls["olayer"] = newls[i]
|
|
ls["num"] = len(newls[i])
|
|
ls["inout"] = 0
|
|
for j in ls["olayer"]:
|
|
lhi = 0
|
|
lho = 0
|
|
slhi = 0
|
|
slho = 0
|
|
k = node_structure[j]
|
|
for prev_node in k["prev"]:
|
|
lhi += prev_node[1]
|
|
slhi += prev_node[2]
|
|
for next_node in k["next"]:
|
|
lho += next_node[1]
|
|
slho += next_node[2]
|
|
k["size"] = max(lhi, lho)
|
|
k["ssize"] = max(slhi, slho)
|
|
k["in"] = lhi
|
|
k["out"] = lho
|
|
if lhi != lho and lhi * lho != 0:
|
|
warning(f"BUG1: {j} {k} {lhi} {lho}")
|
|
ls["inout"] += k["size"]
|
|
layer_structure.append(ls)
|
|
if height == 0:
|
|
minheight = 0
|
|
for i in range(5):
|
|
ls = layer_structure[i]
|
|
new_minheight = spacing * (ls["num"] - 1) + 2 * hmargin + 2 * ls["inout"]
|
|
if new_minheight > minheight:
|
|
minheight = new_minheight
|
|
height = (1 + (minheight // 150)) * 150
|
|
for i in range(5):
|
|
ls = layer_structure[i]
|
|
ls["density"] = ls["inout"] / (
|
|
spacing + height - spacing * ls["num"] - 2 * hmargin
|
|
)
|
|
density.append(ls["density"])
|
|
realdensity = max(density)
|
|
for i in range(5):
|
|
ls = layer_structure[i]
|
|
supp_spacing = (
|
|
spacing
|
|
+ height
|
|
- 2 * hmargin
|
|
- spacing * ls["num"]
|
|
- ls["inout"] / realdensity
|
|
) / (ls["num"] + 1)
|
|
cs = hmargin - spacing
|
|
for j in ls["olayer"]:
|
|
ns = node_structure[j]
|
|
ns["top"] = supp_spacing + spacing + cs
|
|
h = ns["size"] / realdensity
|
|
sh = ns["ssize"] / realdensity
|
|
ns["middle"] = ns["top"] + sh
|
|
cs = ns["bottom"] = ns["top"] + h
|
|
return realdensity, height, layer_structure
|
|
|
|
|
|
def nodestructure_from_bags(bags, sbags=None):
|
|
node_structure = {}
|
|
layers = [[], [], [], [], []]
|
|
edges = []
|
|
union_sbag = {}
|
|
if sbags is not None:
|
|
for layer, layernodes in enumerate(sbags):
|
|
for startnode in layernodes:
|
|
for endnode in layernodes[startnode]:
|
|
if startnode not in union_sbag:
|
|
union_sbag[startnode] = {}
|
|
union_sbag[startnode][endnode] = layernodes[startnode][endnode]
|
|
for layer, layernodes in enumerate(bags):
|
|
for startnode in layernodes:
|
|
# if startnode[-1] == "*":
|
|
# continue
|
|
for endnode in layernodes[startnode]:
|
|
# if endnode[-1] == "*":
|
|
# continue
|
|
weight = layernodes[startnode][endnode]
|
|
if startnode in union_sbag and endnode in union_sbag[startnode]:
|
|
sweight = union_sbag[startnode][endnode]
|
|
elif sbags is None:
|
|
sweight = -1
|
|
else:
|
|
sweight = 0
|
|
if endnode not in node_structure:
|
|
node_structure[endnode] = {
|
|
"prev": [[startnode, weight, sweight]],
|
|
"next": [],
|
|
"layer": layer + 1,
|
|
}
|
|
layers[layer + 1].append(endnode)
|
|
else:
|
|
node_structure[endnode]["prev"].append([startnode, weight, sweight])
|
|
if startnode not in node_structure:
|
|
node_structure[startnode] = {
|
|
"prev": [],
|
|
"next": [[endnode, weight, sweight]],
|
|
"layer": layer,
|
|
}
|
|
layers[layer].append(startnode)
|
|
else:
|
|
node_structure[startnode]["next"].append([endnode, weight, sweight])
|
|
edges.append([startnode, endnode, weight])
|
|
return node_structure, layers, edges
|
|
|
|
|
|
def compute_svg(height, padding, realdensity, node_structure):
|
|
unit_ratio = 96 / 72
|
|
thickness = Options.thickness
|
|
fontsize_name = Options.fontsize_name
|
|
fontsize_count = Options.fontsize_count
|
|
width = Options.width
|
|
columns = []
|
|
l = 0
|
|
for i in range(5):
|
|
l += width / 6
|
|
columns.append(l)
|
|
d = drawsvg.Drawing(width, height, origin=(0, 0), id_prefix=orderkey)
|
|
g1 = drawsvg.Group() # rectangles representing the student-sets
|
|
g2 = drawsvg.Group() # curves representing the transitions
|
|
g3 = drawsvg.Group() # texts (captions of each rectangle)
|
|
g4 = drawsvg.Group() # white rectangles below texts (for readability)
|
|
font_offset = max(fontsize_count, fontsize_name)
|
|
for n in node_structure:
|
|
ns = node_structure[n]
|
|
col = node_color(n).split(" ")[0].split(".")[0]
|
|
ns["color"] = col
|
|
xpos = width / 6 * (ns["layer"] + 1)
|
|
r = drawsvg.Rectangle(
|
|
xpos - thickness,
|
|
ns["top"],
|
|
2 * thickness,
|
|
ns["bottom"] - ns["top"],
|
|
fill=col,
|
|
opacity=0.5,
|
|
stroke_width=0.1,
|
|
stroke="#808080",
|
|
)
|
|
g1.append(r)
|
|
r = drawsvg.Rectangle(
|
|
xpos - thickness,
|
|
ns["top"],
|
|
2 * thickness,
|
|
ns["middle"] - ns["top"],
|
|
fill=col,
|
|
stroke_width=0.2,
|
|
stroke="black",
|
|
)
|
|
g1.append(r)
|
|
nw = textwidth(displaynames[n], "Arial", fontsize_name) * unit_ratio
|
|
cw = textwidth(str(ns["size"]), "Arial", fontsize_count) * unit_ratio
|
|
gw = nw + cw + padding
|
|
ggw = gw + 2 * padding
|
|
nxpos = xpos - gw / 2 + cw + padding + nw / 2
|
|
ypos = (ns["top"] + ns["bottom"]) / 2
|
|
cxpos = cw / 2 - gw / 2 + xpos
|
|
rxpos = xpos
|
|
if ns["in"] == 0:
|
|
nxpos -= gw / 2 + padding + thickness
|
|
cxpos -= gw / 2 + padding + thickness
|
|
rxpos -= gw / 2 + padding + thickness
|
|
if ns["out"] == 0:
|
|
nxpos += gw / 2 + padding + thickness
|
|
cxpos += gw / 2 + padding + thickness
|
|
rxpos += gw / 2 + padding + thickness
|
|
t = drawsvg.Text(
|
|
displaynames[n],
|
|
str(fontsize_name) + "pt",
|
|
nxpos,
|
|
ypos + fontsize_name / 2,
|
|
fill="black",
|
|
text_anchor="middle",
|
|
font_family="Arial",
|
|
)
|
|
tt = drawsvg.Text(
|
|
str(ns["size"]),
|
|
str(fontsize_count) + "pt",
|
|
cxpos,
|
|
ypos + fontsize_count / 2,
|
|
fill="black",
|
|
text_anchor="middle",
|
|
font_family="Arial",
|
|
)
|
|
g3.append(t)
|
|
g3.append(tt)
|
|
g4.append(
|
|
drawsvg.Rectangle(
|
|
rxpos - gw / 2 - padding,
|
|
ypos - font_offset / 2 - padding,
|
|
ggw,
|
|
font_offset + 2 * padding,
|
|
stroke="black",
|
|
stroke_width=0,
|
|
fill="white",
|
|
fill_opacity=".5",
|
|
rx=padding,
|
|
ry=padding,
|
|
)
|
|
)
|
|
for n in node_structure:
|
|
ns = node_structure[n]
|
|
ns["prev"].sort(key=lambda x: node_structure[x[0]]["top"])
|
|
ns["next"].sort(key=lambda x: node_structure[x[0]]["top"])
|
|
start = ns["top"]
|
|
for link in ns["prev"]:
|
|
ysize = link[1]
|
|
sysize = link[2]
|
|
link.append(start)
|
|
link.append(start + sysize / realdensity)
|
|
start += ysize / realdensity
|
|
link.append(start)
|
|
for n in node_structure:
|
|
ns = node_structure[n]
|
|
start = ns["top"]
|
|
for link in ns["next"]:
|
|
ysize = link[1]
|
|
sysize = link[2]
|
|
link.append(start)
|
|
link.append(start + sysize / realdensity)
|
|
start += ysize / realdensity
|
|
link.append(start)
|
|
targets = node_structure[link[0]]
|
|
target = None
|
|
for t in targets["prev"]:
|
|
if t[0] == n:
|
|
target = t
|
|
if target == None:
|
|
print(f"BUG: {n},{ns},{t}")
|
|
sys.exit(5)
|
|
# At this point, link has values target_name, size, secondary_size, top, middle, bottom
|
|
posxa = columns[ns["layer"]] + thickness
|
|
posxb = columns[targets["layer"]] - thickness
|
|
posxc = (3 * posxa + posxb) / 4
|
|
posxd = (posxa + 3 * posxb) / 4
|
|
grad = drawsvg.LinearGradient(posxa, 0, posxb, 0)
|
|
grad.add_stop(0, ns["color"], opacity=0.5)
|
|
grad.add_stop(1, targets["color"], opacity=0.5)
|
|
posyat = link[3]
|
|
posyam = link[4]
|
|
posyab = link[5]
|
|
posybt = target[3]
|
|
posybm = target[4]
|
|
posybb = target[5]
|
|
p = drawsvg.Path(fill=grad, stroke="#000", stroke_width=0, opacity=0.8)
|
|
p.M(posxa, posyab)
|
|
p.C(posxc, posyab, posxd, posybb, posxb, posybb)
|
|
p.L(posxb, posybt)
|
|
p.C(posxd, posybt, posxc, posyat, posxa, posyat)
|
|
p.Z()
|
|
p.append_title(
|
|
f"{displaynames[n]}=>{displaynames[link[0]]}: {link[2]}/{link[1]}"
|
|
)
|
|
g2.append(p)
|
|
p = drawsvg.Path(fill=grad, stroke="#000", stroke_width=0)
|
|
p.M(posxa, posyam)
|
|
p.C(posxc, posyam, posxd, posybm, posxb, posybm)
|
|
p.L(posxb, posybt)
|
|
p.C(posxd, posybt, posxc, posyat, posxa, posyat)
|
|
p.Z()
|
|
p.append_title(
|
|
f"{displaynames[n]}=>{displaynames[link[0]]}: {link[2]}/{link[1]}"
|
|
)
|
|
g2.append(p)
|
|
d.append(g2)
|
|
d.append(g1)
|
|
d.append(g4)
|
|
d.append(g3)
|
|
return d
|
|
|
|
|
|
def printsvg():
|
|
padding = 4
|
|
spacing = Options.spacing
|
|
height = Options.height
|
|
hmargin = Options.hmargin
|
|
bags = bags_from_students(student, Options.filter())
|
|
sbags = bags_from_students(student, Options.filter(main=False))
|
|
node_structure, layers, edges = nodestructure_from_bags(bags, sbags)
|
|
filename = "best-" + orderkey
|
|
if Options.algo == 2:
|
|
try:
|
|
os.remove(filename)
|
|
except OSError:
|
|
pass
|
|
if Options.algo < 2:
|
|
lastorders = read_conf(filename)
|
|
else:
|
|
lastorders = {}
|
|
node_position, node_layer, newls = ordernodes(layers, lastorders, edges)
|
|
if Options.algo != 1:
|
|
orders = genetic_optimize(node_position, node_layer, edges, loops=Options.loops)
|
|
else:
|
|
orders = newls
|
|
write_conf("best-" + orderkey, orders)
|
|
info(format_for_shell(OptionSet({"orders": orders}).asCLI(onlyOrders=True)))
|
|
Options.orders = orders
|
|
write_conf(Options.orderkey(filters=True), Options.asCLI())
|
|
info(format_for_shell(OptionSet({"orders": orders}).asCLI(onlyOrders=True)))
|
|
node_position, node_layer, newls = ordernodes(layers, orders, edges)
|
|
realdensity, height, layer_structure = get_layer_structure(
|
|
newls, node_structure, spacing, hmargin, height
|
|
)
|
|
d = compute_svg(height, padding, realdensity, node_structure)
|
|
d.save_svg(orderkey + ".svg")
|
|
|
|
|
|
printsvg()
|
|
for x in strangecases:
|
|
print(f"// {x}")
|