diff --git a/backend/base_app/templates/stats.html b/backend/base_app/templates/stats.html index 7589427712488cbf9bd99dd14596133dce924807..4504513d3fb0f06028c11900e67a2517bd19007d 100644 --- a/backend/base_app/templates/stats.html +++ b/backend/base_app/templates/stats.html @@ -1,11 +1,12 @@ {% extends "base.html" %} + {% load render_bundle from webpack_loader %} {% block content %} - + {% render_bundle 'stats' %} {% endblock %} diff --git a/backend/base_app/views.py b/backend/base_app/views.py index 3293e7df452022c0c5cc0d103aff3ef5ed377ebf..50e6642c4060a53bf385a21586e9838b49f86d70 100644 --- a/backend/base_app/views.py +++ b/backend/base_app/views.py @@ -1,8 +1,10 @@ +from datetime import datetime, timedelta import logging import json from django.http import HttpResponse, HttpResponseRedirect, HttpResponseNotFound from django.shortcuts import render +from django.utils.timezone import make_aware from webpack_loader.utils import get_files from backend_app.utils import clean_route @@ -16,6 +18,7 @@ from _cron_tasks import ( clear_and_clean_sessions, update_daily_stats, ) +from stats_app.models import DailyConnections, DailyExchangeContributionsInfo logger = logging.getLogger("django") @@ -50,7 +53,57 @@ def stats(request): """ Render the view that displays stats """ - stats_data = [] + dataset = request.GET.get("dataset") + + now = make_aware(datetime.now()) + now_minus_365_days = now - timedelta(days=365) + + if dataset == "daily_connections": + daily_connections = DailyConnections.objects.filter( + date__gte=now_minus_365_days, + date__lt=now, + ) + raw_data = [ + { + "date": dc.date.strftime("%Y-%m-%d"), + "nb_connections": dc.nb_connections + } + for dc in daily_connections + ] + + cols = ["date", "nb_connections"] + + elif dataset == "daily_exchange_contributions": + daily_contributions = DailyExchangeContributionsInfo.objects.filter( + date__gte=now_minus_365_days, + date__lt=now, + ).prefetch_related("university") + + raw_data = [ + { + "date": dc.date.strftime("%Y-%m-%d"), + "university": f"{dc.university.pk} - {dc.university.name}", + "major": dc.major, + "minor": dc.minor, + "exchange_semester": dc.exchange_semester, + "nb_contributions": dc.nb_contributions, + } + for dc in daily_contributions + ] + + cols = [ + "date", + "university", + "major", + "minor", + "exchange_semester", + "nb_contributions", + ] + + else: + return HttpResponseRedirect("/stats/?dataset=daily_connections") + + stats_data = {c: [d[c] for d in raw_data] for c in cols} return render(request, "stats.html", dict(stats_data=json.dumps(stats_data))) diff --git a/frontend/src/components/pages/PageStats.jsx b/frontend/src/components/pages/PageStats.jsx index 8f4b2a831bb80c51d21c614ebbfe6dce8892c411..00f24246420d0d97a348ded842534760c04e02e5 100644 --- a/frontend/src/components/pages/PageStats.jsx +++ b/frontend/src/components/pages/PageStats.jsx @@ -1,51 +1,54 @@ import React from "react"; import { compose } from "recompose"; import Typography from "@material-ui/core/Typography"; -import { Line } from "react-chartjs-2"; -import alasql from "alasql"; +import { makeStyles } from "@material-ui/styles"; +import AppBar from "@material-ui/core/AppBar"; +import Tabs from "@material-ui/core/Tabs"; +import Tab from "@material-ui/core/Tab"; import { withPaddedPaper } from "./shared"; +import SqlInterface from "../stats/RequestSQLHandler"; +import { appBarHeight, siteMaxWidth } from "../../config/sharedStyles"; -const data = [ - { a: 1, b: 1, c: 1 }, - { a: 1, b: 2, c: 1 }, - { a: 1, b: 3, c: 1 }, - { a: 2, b: 1, c: 1 }, -]; -const res = alasql("SELECT a, COUNT(*) AS c FROM ? GROUP BY a", [data]); - -// eslint-disable-next-line no-console -console.log(res); +const useStyles = makeStyles((theme) => ({ + requestInterface: {}, + tabBar: { + minHeight: theme.spacing(5), + [`@media (min-width:${siteMaxWidth()}px)`]: { + top: appBarHeight(theme), + }, + }, +})); /** * Component corresponding to the stats page of the site */ function PageStats() { + const classes = useStyles(); + + // TODO faire une fonction handleChange qui change le dataset dans l'url et recharge la page + return ( <> - Vive les stats -
- More to come... - + + Exploration des statistiques d'utilisation du site REX-DRI + + + + + + + +
+ +
); } diff --git a/frontend/src/components/stats/Plot.jsx b/frontend/src/components/stats/Plot.jsx new file mode 100644 index 0000000000000000000000000000000000000000..60617630a407b26d4f972fc4d47bcc36c6e3019c --- /dev/null +++ b/frontend/src/components/stats/Plot.jsx @@ -0,0 +1,102 @@ +import React from "react"; +import { Line } from "react-chartjs-2"; +import PropTypes from "prop-types"; + +const colors = [ + "#1f77b4", + "#ff7f0e", + "#2ca02c", + "#d62728", + "#9467bd", + "#8c564b", + "#e377c2", + "#7f7f7f", + "#bcbd22", + "#17becf", +]; + +function PlotResult({ result }) { + if ( + !Object.keys(result[0]).includes("x") || + !Object.keys(result[0]).includes("y") + ) { + return ( +
+
+ Pour afficher les résultats sous forme graphique, vous devez préciser + 'x' et 'y' dans le SELECT (pensez à utiliser un ORDER BY sur X). Vous + pouvez également préciser des catégories avec 'cat'. +
+
+ ); + } + + if (!Object.keys(result[0]).includes("cat")) { + const mapping = new Map(); + result.forEach(({ x, y }) => { + mapping.set(x, y); + }); + + const X = [...mapping.keys()]; + X.sort(); + + return ( +
+ mapping.get(x)), + }, + ], + }} + /> +
+ ); + } + + const categories = [...new Set(result.map((el) => el.cat))]; + + const mapping = new Map(); + result.forEach(({ x, y, cat }) => { + if (!mapping.has(x)) { + mapping.set(x, new Map()); + } + + mapping.get(x).set(cat, y); + }); + + const X = [...mapping.keys()]; + X.sort(); + + const datasets = categories.map((cat, index) => ({ + label: cat, + fill: "disabled", + borderColor: colors[index % colors.length], + data: X.map((x) => { + const val = mapping.get(x).get(cat); + return typeof val !== "undefined" ? val : 0; + }), + })); + + return ( +
+ +
+ ); +} + +PlotResult.propTypes = { + result: PropTypes.array.isRequired, +}; + +export default PlotResult; diff --git a/frontend/src/components/stats/RequestSQLHandler.jsx b/frontend/src/components/stats/RequestSQLHandler.jsx new file mode 100644 index 0000000000000000000000000000000000000000..bf1ee40dad00e62ef6fd55d0fcb9d5da4c0951f5 --- /dev/null +++ b/frontend/src/components/stats/RequestSQLHandler.jsx @@ -0,0 +1,209 @@ +import React, { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import alasql from "alasql"; +import Typography from "@material-ui/core/Typography"; +import TextField from "@material-ui/core/TextField"; +import Button from "@material-ui/core/Button"; +import { makeStyles } from "@material-ui/styles"; +import TableFromData from "./Table"; +import PlotResult from "./Plot"; + +// eslint-disable-next-line no-undef +const DataFromBackend = __StatsData; +const cols = Object.keys(DataFromBackend); +const data = []; +// eslint-disable-next-line no-plusplus +for (let i = 0; i < DataFromBackend[cols[0]].length; i++) { + const elem = {}; + cols.forEach((col) => { + elem[col] = DataFromBackend[col][i]; + }); + data.push(elem); +} + +const useStyles = makeStyles({}); + +// eslint-disable-next-line no-shadow +function executeRequest(sqlRequest, data) { + return alasql.promise(sqlRequest, [data]); +} + +function SqlRequestResult({ result }) { + return ( +
+
+ + +
+ +
+
+ Pour partager votre recherche il vous suffit de copier l'url de la page ! +
+ ); +} + +SqlRequestResult.propTypes = { + result: PropTypes.array.isRequired, +}; + +const defaultRequest = `SELECT + date AS x, + nb_connections AS y +FROM ? +ORDER BY x ASC; +`; +// TODO à changer selon le dataset +// TODO si plusieurs requetes par défaut proposées pour un meme dataset : les proposer avec des boutons +/* + SELECT + date AS x, + major AS cat, + sum(nb_contributions) AS y + FROM ? + GROUP BY date, major + ORDER BY date ASC; +*/ + +/** + SELECT + date AS x, + CASE WHEN + major = 'IM' OR major = 'GM' OR major = 'GSM' THEN 'IM' + ELSE 'AUTRES' + END AS cat, + sum(nb_contributions) AS y + FROM ? + GROUP BY date, major + ORDER BY date ASC; + */ + +function getRequestFromUrl() { + const getParamsInUrl = new URL(document.location).searchParams; + let request = defaultRequest; + + try { + const requestInfoString = getParamsInUrl.has("request_info") + ? getParamsInUrl.get("request_info") + : ""; + request = JSON.parse(atob(requestInfoString)).request; + } catch (err) { + // eslint-disable-next-line no-console + console.log(err); + } + + return request; +} + +/** + * + */ +function setRequestInUrl(request) { + const requestInfo = { + request, + version: 1.0, + }; + + const requestInfoAsString = btoa(JSON.stringify(requestInfo)); + const getParamsInUrl = new URL(document.location).searchParams; + getParamsInUrl.set("request_info", requestInfoAsString); + + window.history.replaceState(null, null, `?${getParamsInUrl.toString()}`); +} + +// eslint-disable-next-line no-unused-vars +function getDatasetFromUrl() { + const getParamsInUrl = new URL(document.location).searchParams; + let datasetName = "daily_connections"; + + try { + datasetName = getParamsInUrl.has("dataset") + ? getParamsInUrl.get("dataset") + : "daily_connections"; + } catch (err) { + // eslint-disable-next-line no-console + console.log(err); + } + + return datasetName; +} + +// eslint-disable-next-line no-unused-vars +function setDatasetInUrl(datasetName) { + const getParamsInUrl = new URL(document.location).searchParams; + getParamsInUrl.set("dataset", datasetName); + + window.history.replaceState(null, null, `?${getParamsInUrl.toString()}`); +} + +const requestFromUrl = getRequestFromUrl(); + +function SqlInterface() { + const classes = useStyles(); + + const [sqlRequest, setSqlRequest] = useState(requestFromUrl); + + useEffect(() => { + setRequestInUrl(sqlRequest); + }, [sqlRequest]); + + const [requestError, setRequestError] = useState(""); + const [requestResult, setRequestResult] = useState([]); + + return ( +
+ setSqlRequest(event.target.value)} + /> + {requestError !== "" && ( + <> +
+ {requestError.toString()} +
+ + )} + + {requestResult.length !== 0 && ( + + )} +
+ ); +} + +export default SqlInterface; diff --git a/frontend/src/components/stats/Table.jsx b/frontend/src/components/stats/Table.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ed8d69b7c3e2259272facc76978b55de1ed32a03 --- /dev/null +++ b/frontend/src/components/stats/Table.jsx @@ -0,0 +1,95 @@ +import React from "react"; +import PropTypes from "prop-types"; +import Paper from "@material-ui/core/Paper"; +import Table from "@material-ui/core/Table"; +import TableBody from "@material-ui/core/TableBody"; +import TableCell from "@material-ui/core/TableCell"; +import TableContainer from "@material-ui/core/TableContainer"; +import TableHead from "@material-ui/core/TableHead"; +import TablePagination from "@material-ui/core/TablePagination"; +import TableRow from "@material-ui/core/TableRow"; +import { makeStyles } from "@material-ui/styles"; + +const useStyles = makeStyles({ + root: { + width: "100%", + }, + container: { + maxHeight: 440, + }, +}); + +function TableFromData({ data }) { + const classes = useStyles(); + const [page, setPage] = React.useState(0); + const [rowsPerPage, setRowsPerPage] = React.useState(10); + + if (data.length === 0) { + return <>; + } + + const columns = Object.keys(data[0]).map((id) => ({ id, label: id })); + + const handleChangePage = (event, newPage) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event) => { + setRowsPerPage(+event.target.value); + setPage(0); + }; + + return ( + + + + + + {columns.map((column) => ( + {column.label} + ))} + + + + {data + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map((row, i) => { + return ( + // eslint-disable-next-line react/no-array-index-key + + {columns.map((column) => { + const value = row[column.id]; + return ( + + {column.format && typeof value === "number" + ? column.format(value) + : value} + + ); + })} + + ); + })} + +
+
+ +
+ ); +} + +TableFromData.propTypes = { + data: PropTypes.array.isRequired, +}; + +export default TableFromData;