503 lines
15 KiB
Python
503 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
################
|
|
### UniSquat ###
|
|
################
|
|
|
|
# Une application pour afficher les salles libres dans les différents
|
|
# départements de l'Université de Strasbourg.
|
|
|
|
### Fichier de l'interface Web Flask ###
|
|
|
|
# Modules :
|
|
import datetime as dti
|
|
import pytz
|
|
import json
|
|
import os
|
|
import traceback
|
|
|
|
from flask import Flask
|
|
from flask import render_template
|
|
from flask import url_for
|
|
from flask import request
|
|
from flask.helpers import make_response
|
|
|
|
# Fichiers locaux :
|
|
import date_tools
|
|
import rooms_get as ro
|
|
|
|
# Constantes :
|
|
# Le maximum de départements qu'il est possible de sélectionner :
|
|
MAX_DEPT = 5
|
|
|
|
# Le nombre de jours pendant lesquels les logs sont conservés :
|
|
MAX_LOG_DAYS = 30
|
|
|
|
# Le nombre maximal de départements les plus cherchés à afficher :
|
|
MAX_LOG_DEPT = 5
|
|
|
|
# Le nombre maximal de salles favorites à afficher :
|
|
MAX_LOG_FAVS = 10
|
|
|
|
# Nombre d'utilisations à partir duquel un message d'avertissement est affiché :
|
|
PING_WARN = 500
|
|
|
|
# Contient des stats sur les salles les plus mises en favoris,
|
|
# et sur les départements les plus recherchés :
|
|
LOG_FILE = "log.json"
|
|
|
|
# Contexte constant pour les templates Jinja :
|
|
GLOBAL_CONTEXT = {}
|
|
|
|
# Le lien vers le code source :
|
|
GLOBAL_CONTEXT["SOURCE"] = "https://forge.chapril.org/Wantoo/UniSquat_Python"
|
|
|
|
# Le lien vers le site de l'organisation :
|
|
GLOBAL_CONTEXT["CREDITSLINK"] = "https://forge.chapril.org/Wantoo"
|
|
|
|
# Le nom de l'organisation :
|
|
GLOBAL_CONTEXT["CREDITSNAME"] = "Wantoo"
|
|
|
|
# Mode bavard :
|
|
GLOBAL_CONTEXT["DEBUG"] = False
|
|
|
|
# Le domaine sur lequel est hébergée l'instance officielle :
|
|
GLOBAL_CONTEXT["DOMAIN"] = "https://unisquat.alwaysdata.net"
|
|
|
|
# Timezone du serveur :
|
|
TIMEZONE = pytz.timezone("Europe/Paris")
|
|
|
|
# Globales :
|
|
app = Flask(__name__)
|
|
|
|
# Stocke les différentes requêtes faites sur /free_rooms/
|
|
# sous la forme {"timestamp":timestamp,"depts":[]} :
|
|
logs = []
|
|
if os.path.isfile(LOG_FILE):
|
|
with open(LOG_FILE,"r") as f:
|
|
logs = json.loads(f.read())
|
|
|
|
# Fonctions :
|
|
def save_logs(logs):
|
|
"""
|
|
Sauvegarde les logs dans un fichier.
|
|
|
|
Parameters
|
|
----------
|
|
logs : list
|
|
Liste des logs.
|
|
|
|
Returns
|
|
-------
|
|
None.
|
|
|
|
"""
|
|
with open(LOG_FILE,"w") as f:
|
|
f.write(json.dumps(logs))
|
|
|
|
@app.route("/")
|
|
def home() :
|
|
"""
|
|
Page d'accueil du site Web.
|
|
|
|
Parameters
|
|
----------
|
|
None
|
|
|
|
Returns
|
|
-------
|
|
flask.render_template
|
|
|
|
"""
|
|
return render_template("index.html", **GLOBAL_CONTEXT)
|
|
|
|
@app.route("/app")
|
|
def select_dept() :
|
|
"""
|
|
Permet de sélectionner un ou plusieurs départements dans lesquels
|
|
chercher des salles libres.
|
|
|
|
Parameters
|
|
----------
|
|
None
|
|
|
|
Returns
|
|
-------
|
|
flask.render_template
|
|
|
|
"""
|
|
dept_filen = "data/dept_list.txt"
|
|
|
|
dept_list = ro.get_depts(dept_filen)
|
|
|
|
context = {"dept_list":dept_list}
|
|
|
|
url_for("static", filename="style.css")
|
|
return render_template("dept-select.html", **context, **GLOBAL_CONTEXT)
|
|
|
|
@app.route("/stats")
|
|
def stats():
|
|
"""
|
|
Statistiques d'utilisation de l'instance :
|
|
Salles marquées comme favorites
|
|
Départements les plus cherchés
|
|
|
|
Parameters
|
|
----------
|
|
None
|
|
|
|
Returns
|
|
-------
|
|
flask.render_template
|
|
|
|
"""
|
|
pings = 0
|
|
counts = {}
|
|
counts_favs = {}
|
|
|
|
for log in logs :
|
|
if log["type"] == "deptcount" :
|
|
for dept in log["depts"] :
|
|
pings += 1
|
|
if dept in counts.keys() :
|
|
counts[dept] += 1
|
|
else :
|
|
counts[dept] = 1
|
|
|
|
elif log["type"] == "favs" :
|
|
for fav in log["favs"] :
|
|
fav_map = fav[0] + ";" + fav[1]
|
|
if fav_map in counts_favs.keys() :
|
|
counts_favs[fav_map][2] += 1
|
|
else:
|
|
fav.append(1)
|
|
counts_favs[fav_map] = fav
|
|
|
|
sort = [[x, counts[x]] for x in counts.keys()]
|
|
|
|
# Tri selon la valeur du deuxieme élément de la liste :
|
|
sort.sort(key = lambda x: x[1], reverse = True)
|
|
|
|
sort_favs = [counts_favs[x] for x in counts_favs.keys()]
|
|
sort_favs.sort(key = lambda x: x[2], reverse = True)
|
|
|
|
context = {
|
|
"MAX_LOG_DAYS":MAX_LOG_DAYS,
|
|
"PING_WARN":PING_WARN,
|
|
"depts":sort[:MAX_LOG_DEPT],
|
|
"favs":sort_favs[:MAX_LOG_FAVS],
|
|
"nbping":pings
|
|
}
|
|
|
|
return render_template("stats.html", **context, **GLOBAL_CONTEXT)
|
|
|
|
@app.route("/app/free-rooms", methods=["POST", "GET"])
|
|
def free_rooms(api = False, rq = None) :
|
|
"""
|
|
Affiche les salles libres dans les départements sélectionnés.
|
|
|
|
Parameters
|
|
----------
|
|
api : bool
|
|
Indique si la page est accédée par l'API.
|
|
|
|
rq : requests.request
|
|
Requête.
|
|
|
|
Returns
|
|
-------
|
|
flask.render_template
|
|
|
|
"""
|
|
if not api :
|
|
rq = request
|
|
|
|
if GLOBAL_CONTEXT["DEBUG"]:
|
|
print(f"dept:\n\t{rq.args.getlist('dept')}")
|
|
print(f"favs:\n\t{rq.args.getlist('favs')}")
|
|
print(f"date:\n\t{rq.args.get('date')}\t{rq.args.get('time')}")
|
|
|
|
# Récupération des ID des départements depuis le formulaire :
|
|
dident_list = list(rq.args.getlist("dept"))
|
|
if len(dident_list) > MAX_DEPT :
|
|
return render_template("error.html",
|
|
error="Trop de départements sélectionnés ! Vous pouvez en sélectionner "+str(MAX_DEPT)+" au maximum.")
|
|
if len(dident_list) == 0 :
|
|
return render_template("error.html",
|
|
error="Il faut choisir au moins un département !")
|
|
|
|
# Récupération de l'éventuelle date personnalisée :
|
|
date_uf = str(rq.args.get("date"))
|
|
date_uf_sav = date_uf
|
|
|
|
date_uf = date_tools.d_t_format(date_uf, False)
|
|
|
|
if date_uf != [""] and not (date_tools.check_date(date_uf)) :
|
|
return render_template("error.html", error="Date incorrecte !")
|
|
|
|
time_uf = str(rq.args.get("time"))
|
|
time_uf_sav = time_uf
|
|
|
|
time_uf = date_tools.d_t_format(time_uf, True)
|
|
|
|
if time_uf != [""] and not (date_tools.check_time(time_uf)) :
|
|
return render_template("error.html", error="Heure incorrecte !")
|
|
|
|
date = dti.datetime.now(TIMEZONE)
|
|
|
|
date_str = "" # Date affichée sur la page (si personnalisée)
|
|
|
|
if date_uf != [""] :
|
|
date = date.replace(year = int(date_uf[0]), month = int(date_uf[1]), day = int(date_uf[2]))
|
|
date_str += date_uf[2] + "/" + date_uf[1] + "/" + date_uf[0]
|
|
|
|
if time_uf != [""] :
|
|
date = date.replace(hour = int(time_uf[0]), minute = int(time_uf[1]))
|
|
date_str += ", à " + time_uf[0] + ":" + time_uf[1]
|
|
|
|
# Récupération des IDs des salles favorites :
|
|
favs_ids = list(rq.args.getlist("favs"))
|
|
if favs_ids == [None] :
|
|
favs_ids = []
|
|
|
|
# Récupération de la liste des départements existants :
|
|
dept_filen = "data/dept_list.txt"
|
|
dept_list = ro.get_depts(dept_filen)
|
|
|
|
# Vérification qu'il n'y a pas de mauvais départements sélectionnés :
|
|
for d in dident_list :
|
|
try :
|
|
int(d)
|
|
except:
|
|
return render_template("error.html",
|
|
error="Identifiant de département invalide !",
|
|
**GLOBAL_CONTEXT)
|
|
if int(d) < 0 or int(d) >= len(dept_list) :
|
|
return render_template("error.html",
|
|
error="Identifiant de département invalide !",
|
|
**GLOBAL_CONTEXT)
|
|
dident_list.sort()
|
|
|
|
# Récupération des départements choisis à partir des données du formulaire :
|
|
i = 0
|
|
depts = list()
|
|
depts_str = "" # Noms des départements pour l'affichage
|
|
for d in dept_list :
|
|
if i < len(dident_list) and d.ident == int(dident_list[i]) :
|
|
depts.append(d)
|
|
depts_str += d.name
|
|
if (i + 1) < len(dident_list) :
|
|
depts_str += ", "
|
|
i += 1
|
|
|
|
ignore_list = ["salle non définie", "salle en Distanciel"]
|
|
|
|
try :
|
|
free_rooms = ro.getrooms(date, depts, ignore_list)
|
|
except ValueError as err :
|
|
errdetails = str(''.join(traceback.format_exception(None, err, err.__traceback__)))
|
|
if GLOBAL_CONTEXT["DEBUG"] :
|
|
print(errdetails)
|
|
return render_template("error.html",
|
|
error="Désolé, une erreur est survenue. Unisquat ne peut pas continuer.")
|
|
|
|
# Création d'un dictionnaire avec les infos des salles :
|
|
frooms_disp = dict() # Mise en forme des infos pour la page Web
|
|
|
|
i = 0
|
|
for r in free_rooms :
|
|
remain_time_str = ""
|
|
if r.is_free :
|
|
remain_time_str = date_tools.remain_time(date, r.end)
|
|
else :
|
|
remain_time_str = date_tools.remain_time(date, r.start)
|
|
|
|
frooms_disp[r.name] = {"start":date_tools.hour_disp(r.start),
|
|
"end":date_tools.hour_disp(r.end),
|
|
"rtime":remain_time_str}
|
|
|
|
|
|
# Ajout des arguments favoris, et départements à l'URL :
|
|
change_date_str = "?"
|
|
if favs_ids != [] :
|
|
i = 0
|
|
for f in favs_ids :
|
|
change_date_str += "favs=" + str(f)
|
|
if i < len(favs_ids) - 1:
|
|
change_date_str += "&"
|
|
i+=1
|
|
change_date_str += "&"
|
|
for v in dident_list:
|
|
i = 0
|
|
change_date_str += "dept="+str(v)
|
|
if i<len(dident_list)-1:
|
|
change_date_str += "&"
|
|
i+=1
|
|
|
|
|
|
# Génération du lien pour enlever les favoris séléctionnés :
|
|
nofavslink = "/app/free-rooms?"
|
|
for dept in dident_list:
|
|
nofavslink+="dept="+str(dept)+"&"
|
|
# Ajout des éventuels date et heure :
|
|
if date_uf_sav != "None" :
|
|
nofavslink += "date=" + date_uf_sav + "&"
|
|
if time_uf_sav != "None" :
|
|
nofavslink += "time=" + time_uf_sav
|
|
# Suppression de l'éventuel '&' en trop :
|
|
if nofavslink[-1] == "&":
|
|
nofavslink = nofavslink[:-1]
|
|
|
|
# Tri des salles selon leurs catégories :
|
|
favs_free_rooms = []
|
|
favs_soon_rooms = []
|
|
soon_rooms = []
|
|
final_rooms = []
|
|
|
|
for r in free_rooms:
|
|
[[soon_rooms,final_rooms],[favs_soon_rooms,favs_free_rooms]][r.id in favs_ids][r.is_free].append(r)
|
|
|
|
# Tri des salles bientôt disponibles en fonction du temps d'attente
|
|
sortfunc = lambda x: x.start-date.astimezone(pytz.timezone('Europe/Paris'))
|
|
soon_rooms.sort(key=sortfunc)
|
|
favs_soon_rooms.sort(key=sortfunc)
|
|
|
|
context = {"favs_free_rooms":favs_free_rooms, "favs_soon_rooms":favs_soon_rooms,
|
|
"free_rooms":final_rooms, "soon_rooms":soon_rooms, "frooms_disp":frooms_disp,
|
|
"depts_str":depts_str, "dident_list":dident_list, "date_str":date_str,
|
|
"date_uf_sav":date_uf_sav, "time_uf_sav":time_uf_sav, "change_date_str":change_date_str,
|
|
"favs":len(favs_ids)>0,"nofavslink":nofavslink}
|
|
|
|
# Création d'un log de la date et des départements demandés (pour les stats du site) :
|
|
ctimestamp = dti.datetime.now(TIMEZONE).timestamp()
|
|
log = {}
|
|
log["timestamp"] = ctimestamp
|
|
log["depts"] = [ x.name for x in depts ] # Liste les noms de départements
|
|
log["type"] = "deptcount" # Type du log
|
|
logs.append(log)
|
|
# Création d'un log de la date et des salles favorites
|
|
favs = favs_soon_rooms+favs_free_rooms
|
|
if len(favs)>0:
|
|
log = {}
|
|
log["timestamp"] = ctimestamp
|
|
log["favs"] = [ [x.name,x.dept_name] for x in favs] # Liste les noms des salles favorites
|
|
log["type"] = "favs" # Type du log
|
|
logs.append(log)
|
|
|
|
# Suppression des logs vieux de MAX_LOG_DAYS :
|
|
while (ctimestamp - logs[0]["timestamp"]) / (60*60*24) > MAX_LOG_DAYS :
|
|
del(logs[0])
|
|
|
|
# Sauvegarde les logs dans un fichier cache
|
|
save_logs(logs)
|
|
|
|
if api :
|
|
serial_context = context
|
|
for i in range(len(serial_context["free_rooms"])) :
|
|
fr_start = serial_context["free_rooms"][i].start
|
|
serial_context["free_rooms"][i].start = [fr_start.year, fr_start.month, fr_start.day, fr_start.hour, fr_start.minute, fr_start.second]
|
|
fr_end = serial_context["free_rooms"][i].end
|
|
serial_context["free_rooms"][i].end = [fr_end.year, fr_end.month, fr_end.day, fr_end.hour, fr_end.minute, fr_end.second]
|
|
serial_context["free_rooms"][i] = vars(serial_context["free_rooms"][i])
|
|
for i in range(len(serial_context["soon_rooms"])) :
|
|
fr_start = serial_context["soon_rooms"][i].start
|
|
serial_context["soon_rooms"][i].start = [fr_start.year, fr_start.month, fr_start.day, fr_start.hour, fr_start.minute, fr_start.second]
|
|
fr_end = serial_context["soon_rooms"][i].end
|
|
serial_context["soon_rooms"][i].end = [fr_end.year, fr_end.month, fr_end.day, fr_end.hour, fr_end.minute, fr_end.second]
|
|
serial_context["soon_rooms"][i] = vars(serial_context["soon_rooms"][i])
|
|
print(serial_context)
|
|
response = make_response(json.dumps(context))
|
|
response.headers["Content-Type"] = "application/json"
|
|
return response
|
|
else :
|
|
url_for("static", filename="style.css")
|
|
# Vérifie si le mode billboard est demandé :
|
|
billboard = str(rq.args.get("billboard"))
|
|
if billboard in ("1", "true", "True") :
|
|
return render_template("billboard.html", **context, **GLOBAL_CONTEXT)
|
|
else:
|
|
return render_template("free-rooms.html", **context, **GLOBAL_CONTEXT)
|
|
|
|
@app.route("/app/date-select", methods=["POST", "GET"])
|
|
def date_select() :
|
|
"""
|
|
Permet de sélectionner une date à laquelle
|
|
chercher des salles libres.
|
|
|
|
Parameters
|
|
----------
|
|
None.
|
|
|
|
Returns
|
|
-------
|
|
flask.render_template
|
|
|
|
"""
|
|
dident_list = list(request.args.getlist("dept"))
|
|
favs_ids = list(request.args.getlist("favs"))
|
|
|
|
if GLOBAL_CONTEXT["DEBUG"]:
|
|
print(f"dept:{dident_list}")
|
|
print(f"favs:{favs_ids}")
|
|
|
|
context = {"dident_list":dident_list, "favs_ids":favs_ids}
|
|
|
|
return render_template("date-select.html", **context, **GLOBAL_CONTEXT)
|
|
|
|
@app.route("/api/depts_list")
|
|
def api_depts_get() :
|
|
"""
|
|
Renvoie la liste des départements (pour l'API).
|
|
|
|
Returns
|
|
-------
|
|
None.
|
|
|
|
"""
|
|
# Récupération de la liste des départements existants :
|
|
dept_filen = "data/dept_list.txt"
|
|
dept_list = ro.get_depts(dept_filen)
|
|
serial_dlist = [vars(d) for d in dept_list]
|
|
|
|
response = make_response(json.dumps(serial_dlist))
|
|
response.headers["Content-Type"] = "application/json"
|
|
return response
|
|
|
|
@app.route("/api/free_rooms")
|
|
def api_frooms() :
|
|
"""
|
|
Redirige vers le JSON des salles (pour l'API).
|
|
|
|
Returns
|
|
-------
|
|
None.
|
|
|
|
"""
|
|
return free_rooms(True, request)
|
|
|
|
@app.route("/sitemap.xml")
|
|
def sitemap():
|
|
sitemap_xml = render_template("sitemap.xml", **GLOBAL_CONTEXT)
|
|
response = make_response(sitemap_xml)
|
|
response.headers["Content-Type"] = "application/xml"
|
|
return response
|
|
|
|
@app.errorhandler(404)
|
|
def error(e):
|
|
"""
|
|
Affiche la page d'erreur.
|
|
|
|
Parameters
|
|
----------
|
|
None
|
|
|
|
Returns
|
|
-------
|
|
flask.render_template
|
|
|
|
"""
|
|
template = render_template("error.html", error="Page non trouvée !", **GLOBAL_CONTEXT)
|
|
response = make_response(template, 404)
|
|
return response
|