UniSquat_Python/app.py

507 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:
try:
logs = json.loads(f.read())
# On supprime le fichier s'il est invalide :
except json.decoder.JSONDecodeError:
os.remove(LOG_FILE)
# 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