Commit ae65342b authored by Florent Chehab's avatar Florent Chehab

feat(SiteInformation): back & front + tweaks

* Update home page
* Display dynamic information on the home page (external data update and site informations)
* back and front for SiteInformation
* Footer with important information
* Notification on connect for import information
* Fixed markdown currency rounding

Closes #120
parent de3c1897
Pipeline #42350 passed with stages
in 3 minutes and 46 seconds
......@@ -33,8 +33,10 @@ from backend_app.models.universitySemestersDates import UniversitySemestersDates
from backend_app.models.universityTaggedItem import UniversityTaggedItem
from backend_app.models.userData import UserData
from backend_app.models.version import Version
from base_app.models import SiteInformation
ALL_MODELS = [
SiteInformation,
Campus,
CampusTaggedItem,
City,
......
......@@ -5,12 +5,15 @@ from backend_app.load_data.loading_scripts.loadCountries import LoadCountries
from backend_app.load_data.loading_scripts.loadCurrencies import LoadCurrencies
from backend_app.load_data.loading_scripts.loadGroups import LoadGroups
from backend_app.load_data.loading_scripts.loadLanguages import LoadLanguages
from backend_app.load_data.loading_scripts.loadTags import LoadTags
from backend_app.load_data.loading_scripts.loadUniversities import LoadUniversities
from backend_app.load_data.loading_scripts.loadUniversityEx import LoadUniversityEx
from backend_app.load_data.loading_scripts.loadRecommendationLists import (
LoadRecommendationLists,
)
from backend_app.load_data.loading_scripts.loadSiteInformation import (
LoadSiteInformation,
)
from backend_app.load_data.loading_scripts.loadTags import LoadTags
from backend_app.load_data.loading_scripts.loadUniversities import LoadUniversities
from backend_app.load_data.loading_scripts.loadUniversityEx import LoadUniversityEx
def load_all():
......@@ -28,3 +31,4 @@ def load_all():
LoadLanguages().load()
LoadUniversityEx(admin).load()
LoadRecommendationLists(admin).load()
LoadSiteInformation(admin).load()
import datetime
from base_app.models import SiteInformation
from base_app.models import User
from .loadGeneric import LoadGeneric
class LoadSiteInformation(LoadGeneric):
"""
Load currencies in the app
"""
def __init__(self, admin: User):
self.admin = admin
def load(self):
start = datetime.datetime.now()
end = start + datetime.timedelta(days=5)
info = SiteInformation(
start=start,
end=end,
variant="success",
message="Le site vient d'être créé !",
)
self.add_info_and_save(info, self.admin)
......@@ -52,7 +52,8 @@ from backend_app.serializers import (
CourseSerializer,
)
from backend_app.settings.defaults import OBJ_MODERATION_PERMISSIONS
from base_app.models import UserViewset, User
from base_app.models import UserViewset, User, SiteInformationViewSet
from external_data.models import ExternalDataUpdateInfo
class CourseViewSet(BaseModelViewSet):
......@@ -108,6 +109,7 @@ class ExchangeViewSet(BaseModelViewSet):
ALL_API_VIEWSETS = [
SiteInformationViewSet,
UserViewset,
CampusViewSet,
MainCampusViewSet,
......@@ -167,6 +169,33 @@ class AppModerationStatusViewSet(ViewSet):
)
class LatestUpdateExternalDataViewSet(ViewSet):
"""
Viewset to fetch the latest update dates of the external data
"""
# Since AppModerationStatusViewSet doesn't inherit from BaseModelViewSet
# We need to link here the correct permissions
permission_classes = (ReadOnly,)
end_point_route = "externalDataUpdateInfo"
def list(self, request):
objects = (
ExternalDataUpdateInfo.objects.all()
.order_by("source", "-timestamp")
.distinct("source")
)
return Response(
list(
map(
lambda obj: dict(timestamp=obj.timestamp, source=obj.source),
objects,
)
)
)
class LogFrontendErrorsViewSet(ViewSet):
"""
Viewset to handle the logging of errors coming from the frontend.
......@@ -289,6 +318,7 @@ class RecommendationListChangeFollowerViewSet(ViewSet):
ALL_API_VIEW_VIEWSETS = [
AppModerationStatusViewSet,
LatestUpdateExternalDataViewSet,
LogFrontendErrorsViewSet,
BannedUserViewSet,
RecommendationListChangeFollowerViewSet,
......
# Generated by Django 2.1.7 on 2019-06-23 15:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("base_app", "0003_auto_20190616_1754")]
operations = [
migrations.CreateModel(
name="SiteInformation",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("start", models.DateTimeField()),
("end", models.DateTimeField(null=True)),
("message", models.TextField(max_length=500)),
(
"variant",
models.CharField(
choices=[
("info", "info"),
("success", "success"),
("warning", "warning"),
("error", "error"),
],
default="info",
max_length=10,
),
),
],
options={"abstract": False},
)
]
from typing import List
from django.utils import timezone
from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from django.utils.functional import cached_property
from rest_framework.response import Response
from backend_app.models.abstract.base import BaseModelSerializer
from backend_app.models.abstract.base import BaseModelSerializer, BaseModel
from backend_app.models.abstract.base import BaseModelViewSet
from backend_app.permissions.app_permissions import IsOwner, ReadOnly
from backend_app.utils import get_user_level, OBJ_MODERATION_PERMISSIONS
......@@ -122,3 +124,51 @@ class UserViewset(BaseModelViewSet):
permission_classes = (IsOwner | ReadOnly,)
serializer_class = UserSerializer
end_point_route = "users"
SITE_INFORMATION_VARIANTS = (
("info", "info"),
("success", "success"),
("warning", "warning"),
("error", "error"),
)
class SiteInformation(BaseModel):
start = models.DateTimeField(null=False)
end = models.DateTimeField(null=True)
message = models.TextField(max_length=500)
variant = models.CharField(
max_length=10,
choices=SITE_INFORMATION_VARIANTS,
default="info",
null=False,
blank=False,
)
class SiteInformationSerializer(BaseModelSerializer):
class Meta:
model = SiteInformation
fields = BaseModelSerializer.Meta.fields + (
"start",
"end",
"message",
"variant",
)
class SiteInformationViewSet(BaseModelViewSet):
permission_classes = (ReadOnly,)
serializer_class = SiteInformationSerializer
end_point_route = "information"
def get_queryset(self):
get = self.request.GET
if "now" in get.keys():
now = timezone.now()
return SiteInformation.objects.filter(
Q(start__lte=now) & (Q(end__isnull=True) | Q(end__gte=now))
)
else:
return SiteInformation.objects.all()
{% load static %}
<!DOCTYPE html>
<html style="font-size:14">
<html style="font-size:13;">
<head>
<meta
......@@ -30,7 +30,7 @@
</head>
<body>
<div id="app" class="columns">
<div id="app">
<!-- React -->
</div>
</body>
......
......@@ -26,6 +26,8 @@ import {PageCgu, PageRgpd} from "../pages/PagesRgpdCgu";
import PageNotFound from "../pages/PageNotFound";
import PageEditPreviousExchanges from "../pages/PageEditExchangeFeedbacks";
import PageMyExchanges from "../pages/PageMyExchanges";
import NotifierImportantInformation from "./NotifierImportantInformation";
import FooterImportantInformation from "./FooterImportantInformation";
/**
* @class App
......@@ -35,26 +37,30 @@ import PageMyExchanges from "../pages/PageMyExchanges";
class App extends CustomComponentForAPI {
customRender() {
return (
<MainAppFrame>
<FullScreenDialog/>
<main>
<Switch>
<Route exact path={APP_ROUTES.base} component={PageHome}/>
<Route path={APP_ROUTES.search} component={PageSearch}/>
<Route path={APP_ROUTES.map} component={PageMap}/>
<Route path={APP_ROUTES.themeSettings} component={PageSettings}/>
<Route path={APP_ROUTES.listsWithParams} component={PageLists}/>
<Route path={APP_ROUTES.universityWithParams} component={PageUniversity}/>
<Route path={APP_ROUTES.myExchanges} component={PageMyExchanges}/>
<Route path={APP_ROUTES.editPreviousExchangeWithParams} component={PageEditPreviousExchanges}/>
<Route path={APP_ROUTES.userWithParams} component={PageUser}/>
<Route path={APP_ROUTES.aboutProject} component={PageAboutProject}/>
<Route path={APP_ROUTES.aboutRgpd} component={PageRgpd}/>
<Route path={APP_ROUTES.aboutCgu} component={PageCgu}/>
<Route component={PageNotFound}/>
</Switch>
</main>
</MainAppFrame>
<div style={{display: "flex", flexDirection: "column", justifyItems: "flex-end", minHeight: "100vh"}}>
<MainAppFrame>
<FullScreenDialog/>
<NotifierImportantInformation/>
<main>
<Switch>
<Route exact path={APP_ROUTES.base} component={PageHome}/>
<Route path={APP_ROUTES.search} component={PageSearch}/>
<Route path={APP_ROUTES.map} component={PageMap}/>
<Route path={APP_ROUTES.themeSettings} component={PageSettings}/>
<Route path={APP_ROUTES.listsWithParams} component={PageLists}/>
<Route path={APP_ROUTES.universityWithParams} component={PageUniversity}/>
<Route path={APP_ROUTES.myExchanges} component={PageMyExchanges}/>
<Route path={APP_ROUTES.editPreviousExchangeWithParams} component={PageEditPreviousExchanges}/>
<Route path={APP_ROUTES.userWithParams} component={PageUser}/>
<Route path={APP_ROUTES.aboutProject} component={PageAboutProject}/>
<Route path={APP_ROUTES.aboutRgpd} component={PageRgpd}/>
<Route path={APP_ROUTES.aboutCgu} component={PageCgu}/>
<Route component={PageNotFound}/>
</Switch>
</main>
</MainAppFrame>
<FooterImportantInformation/>
</div>
);
}
}
......
import React from "react";
import {connect} from "react-redux";
import CustomComponentForAPI from "../common/CustomComponentForAPI";
import {compose} from "recompose";
import getActions from "../../redux/api/getActions";
import List from "@material-ui/core/List";
import ListItem from "@material-ui/core/ListItem";
import ListItemText from "@material-ui/core/ListItemText";
import {toDateFr} from "../../utils/dateToFr";
import Typography from "@material-ui/core/Typography";
function getLabel(source) {
if (source === "fixer") return "Données concernant les taux de change (Fixer)";
return "Donnée externe non connue";
}
/**
* Class to render notifications for important stuff
*
* @class ExternalDataUpdateInfo
* @extends {CustomComponentForAPI}
* @extends React.Component
*/
class ExternalDataUpdateInfo extends CustomComponentForAPI {
customRender() {
const updateList = this.getLatestReadData("updates");
updateList.sort((a, b) => a.source.localeCompare(b.source));
if (updateList.length > 0) {
return (
<List aria-label="List of updates">
{
updateList.map(
el =>
<ListItem key={el.source}>
<ListItemText primary={getLabel(el.source)} secondary={toDateFr(el.timestamp)}/>
</ListItem>
)
}
</List>
);
} else {
return <Typography variant={"caption"} display={"block"}>Aucune mise-à-jour ne semble avoir été
réalisée.</Typography>;
}
}
}
ExternalDataUpdateInfo.propTypes = {};
ExternalDataUpdateInfo.defaultProps = {};
const mapStateToProps = (state) => {
return {
updates: state.api.externalDataUpdateInfoAll,
};
};
const mapDispatchToProps = (dispatch) => {
return {
api: {
updates: () => dispatch(getActions("externalDataUpdateInfo").readAll()),
},
};
};
export default compose(
connect(mapStateToProps, mapDispatchToProps),
)(ExternalDataUpdateInfo);
import React from "react";
import Divider from "@material-ui/core/Divider";
import {makeStyles} from "@material-ui/styles";
import PropTypes from "prop-types";
import InformationList from "./InformationList";
import {withRouter} from "react-router-dom";
import {APP_ROUTES} from "../../config/appRoutes";
const useStyles = makeStyles(theme => ({
divider: {
marginTop: theme.spacing(4),
marginBottom: theme.spacing(0),
paddingBottom: theme.spacing(2),
color: theme.palette.text.secondary
}
}));
function FooterImportantInformation(props) {
const classes = useStyles();
// Don't display it on the home page
if (props.location.pathname === APP_ROUTES.base) {
return <></>;
}
return (
<footer>
<Divider className={classes.divider}/>
<InformationList includeVariants={["warning", "error"]}/>
</footer>
);
}
FooterImportantInformation.propTypes = {
location: PropTypes.shape({
pathname: PropTypes.string.isRequired,
}).isRequired,
};
export default withRouter(FooterImportantInformation);
import React from "react";
import {connect} from "react-redux";
import CustomComponentForAPI from "../common/CustomComponentForAPI";
import {compose} from "recompose";
import {withErrorBoundary} from "../common/ErrorBoundary";
import ErrorIcon from "@material-ui/icons/Error";
import StartIcon from "@material-ui/icons/StarTwoTone";
import InfoIcon from "@material-ui/icons/Info";
import WarningIcon from "@material-ui/icons/Warning";
import getActions from "../../redux/api/getActions";
import {withSnackbar} from "notistack";
import {RequestParams} from "../../redux/api/RequestParams";
import List from "@material-ui/core/List";
import ListItem from "@material-ui/core/ListItem";
import ListItemIcon from "@material-ui/core/ListItemIcon";
import ListItemText from "@material-ui/core/ListItemText";
import PropTypes from "prop-types";
import {toDateFr} from "../../utils/dateToFr";
const INFORMATION_ICONS = {
success: <StartIcon/>,
info: <InfoIcon/>,
error: <ErrorIcon color={"error"}/>,
warning: <WarningIcon color={"error"}/>,
};
/**
* Class to render notifications for important stuff
*
* @class InformationList
* @extends {CustomComponentForAPI}
* @extends React.Component
*/
class InformationList extends CustomComponentForAPI {
customRender() {
const informationList = this.getLatestReadData("information")
.filter(({variant}) => this.props.includeVariants.includes(variant));
informationList.sort((a, b) => -a.start.localeCompare(b.start));
return (
<List aria-label="List of information">
{
informationList.map(
el =>
<ListItem key={el.id}>
<ListItemIcon>
{INFORMATION_ICONS[el.variant]}
</ListItemIcon>
<ListItemText primary={el.message} secondary={toDateFr(el.start)}/>
</ListItem>
)
}
</List>
);
}
}
InformationList.propTypes = {
includeVariants: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired,
};
InformationList.defaultProps = {
includeVariants: ["warning", "error", "info", "success"]
};
const mapStateToProps = (state) => {
return {
information: state.api.informationAll,
};
};
const mapDispatchToProps = (dispatch) => {
return {
api: {
information: () => dispatch(getActions("information").readAll(RequestParams.Builder.withQueryParam("now", "true").build())),
},
};
};
export default compose(
withSnackbar,
connect(mapStateToProps, mapDispatchToProps),
withErrorBoundary(),
)(InformationList);
import React from "react";
import {connect} from "react-redux";
import CustomComponentForAPI from "../common/CustomComponentForAPI";
import {compose} from "recompose";
import {withErrorBoundary} from "../common/ErrorBoundary";
import getActions from "../../redux/api/getActions";
import {withSnackbar} from "notistack";
import Notifier from "../common/Notifier";
import {RequestParams} from "../../redux/api/RequestParams";
/**
* Class to create notifications for important stuff on start up
*
* @class NotifierImportantInformation
* @extends {CustomComponentForAPI}
* @extends React.Component
*/
class NotifierImportantInformation extends CustomComponentForAPI {
customRender() {
const informationList = this.getLatestReadData("information"),
importantInformationList = informationList.filter(({variant}) => variant !== "info"),
hasImportantInformation = importantInformationList.length > 0;
if (hasImportantInformation) {
return (
<>
{importantInformationList.map(
el =>
<Notifier key={el.id}
message={el.message}
options={{variant: el.variant, autoHideDuration: 5000}}/>)}
</>
);
}
return <></>;
}
}
NotifierImportantInformation.propTypes = {};
const mapStateToProps = (state) => {
return {
information: state.api.informationAll,
};
};
const mapDispatchToProps = (dispatch) => {
return {
api: {
information: () => dispatch(getActions("information").readAll(RequestParams.Builder.withQueryParam("now", "true").build())),
},
};
};
export default compose(
withSnackbar,
connect(mapStateToProps, mapDispatchToProps),
withErrorBoundary(),
)(NotifierImportantInformation);
......@@ -17,14 +17,14 @@ function compileSource(source) {
const {amount, currency} = el;
if (currency === "EUR") {
compiled += `${amount}€`;
compiled += `${amount.toFixed(2)}€`;
} else {
const converted = convertAmountToEur(amount, currency);
compiled += `${amount}${currency} `;
compiled += `${amount.toFixed(2)} ${currency} `;
if (converted === null) {
compiled += `*(\`${currency}\` n'a pas été reconnue comme le code d'une monnaie ; nous n'avons pas pu procéder à une conversion automatique)*`;
} else {
compiled += `[*(≈ ${converted}€)*](https://www.xe.com/currencyconverter/convert/?Amount=${amount}&From=${currency}&To=EUR)`; // add money converted information in markdown format
compiled += `[*(≈ ${converted.toFixed(2)}€)*](https://www.xe.com/currencyconverter/convert/?Amount=${amount}&From=${currency}&To=EUR)`; // add money converted information in markdown format
}
}
}
......
......@@ -7,7 +7,7 @@ import {withPaddedPaper} from "./shared";
const source = `
# Contexte
# TODO
# Contribuer
......
......@@ -4,55 +4,35 @@ import {withErrorBoundary} from "../common/ErrorBoundary";
import Typography from "@material-ui/core/Typography";
import Markdown from "../common/markdown/Markdown";
import {withPaddedPaper} from "./shared";
import InformationList from "../app/InformationList";
import CustomLink from "../common/CustomLink";
import {APP_ROUTES} from "../../config/appRoutes";
import ExternalDataUpdateInfo from "../app/ExternalDataUpdateInfo";
const sourceIntro = `
**REX-DRI** c'est LA plateforme de capitalisation sur les départs à l'étranger à l'UTC.
const source = `
**Ce service est à l'heure actuelle au stade de version _alpha_ afin de montrer certaines fonctionnalités. Les données seront vraisemblablement remises à zéro lors du passage à la phase _beta_ (quand toutes les fonctionnalités seront en place).**
Vous y retrouvez:
* Les destinations disponibles,
* Les précédents départs effectués,
* Une carte des universités partenaires,
* Toutes les bonnes informations contribuées par les uns et les autres,
* Des listes mélant universités et commentaires à votre guise,
* etc.
Si vous trouvez des bugs ou si vous avez des suggestions, merci de les signaler [ici](https://gitlab.utc.fr/chehabfl/outgoing_rex/issues) ou par mail à l'adresse [florent.chehab@etu.utc.fr](mailto:florent.chehab@etu.utc.fr).
Pour rendre plus parlantes certaines fonctionnalités liées à la modération (grandement paramétrable) des informations, durant cette phase _alpha_ vous pouvez rejoindre les différents groupes d'accès tout seul :
- Pour rejoindre le groupe des modérateurs, cliquez [ici](/role_change/moderator/)
- Pour rejoindre le groupe d'accès « DRI », cliquez [ici](/role_change/dri/)
- Pour rejoindre le groupe _classique_, cliquez [ici](/role_change/normal/)
Les données actuellement présente sur la plateforme sont extraites d'un document récapitulant les destinations offertes aux GI il y a quelques semestre de cela ; et elles sont complétés par mes ajouts personnels (voir en particulier pour l'EPFL).
## Fonctionnalités manquantes
Voici les fonctionnalités qui seront rajoutées « prochainement » :
- Possibilité de filtrer les universités sur la page avec la carte ou celle avec la recherche. Les critères seront (ou devraient être) : destinations disponibles à tel semestre, destinations où sont partis des étudiants de telles branches/filières, destinations ouvertes à des étudiants de telles branches/filières, niveau de langue requis, etc.
- Possibilité de faire des listes commentées avec des universités et de les partager (ou non).
- Ajout des autres modules sur les pages des universités.
- Gestion des retours des étudiants sur leurs départs.
- Meilleure compatibilité avec les mobiles.
NB : les images de couverture sur les pages des universités seront aussi différentes ! (actuellement cet élément n'est pas _connecté_ au serveur)
--------
L'objectif est de mettre en place la phase _beta_ d'ici la prochaine session de candidature pour les départs à l'étranger.
--------
Les objectifs de ce service sont :
- Regrouper les informations sur les départs à l'étranger réalisés par les étudiants de l'UTC ;
- Les renders accessibles et commensurables.
Âge des données de l'UTC :
| **Feature** | **Support** |
| ------ | ----------- |
| Ancien départs | ✔ |
| Départs possibles | ✔ |
| Informatio sur les universités | ✔ |
## Autre fun feature
Les valeurs de la plateforme sont :
**bienveillance**, **partage**, **contribution** et **collaboration**. Aidez-nous à les diffuser ! 😉
`;
You can format money: \`:100CHF:\` => :100CHF:
You can format money: \`:100LSD:\` => :100LSD:
const sourceFocusMarkdown = `
De nombreux éléments de saisie supportent la syntaxe [markdown](https://www.markdownguide.org/basic-syntax/)
pour un rendu plus nuancé, faîtes-en bon usage 😌 (vive le **gras**, l'*italique*, etc.).
**Dès que vous parler d'💰, nous vous invitons à utiliser la syntaxe dédiée** \`:100.10USD:\` (\`:\` suivi du montant et
du code ISO de la monnaie, puis de nouveau \`:\`) dans votre markdown.
Cette dernière sera automatiquement reconnu et la valeur après application du taux de change du jour
sera automatiquement affichée en euro : :100.10USD: 🎉
`;
......@@ -65,7 +45,28 @@ function PageHome() {
<Typography variant="h3">
Bienvenue sur <em><b>REX-DRI</b></em> !
</Typography>
<Markdown source={source} headingOffset={2} />
<Markdown source={sourceIntro}/>
<Typography variant="h5">
Focus sur le markdown
</Typography>
<Markdown source={sourceFocusMarkdown}/>
<Typography variant="h4">
Amélioration continue
</Typography>
<Typography>
Un bug 🐇 ? Une fonctionnalité qui vous manque ? Envie de contribuer (bénévolement/PR) 😍 ? Plus
d'informations <CustomLink to={APP_ROUTES.aboutProject}>ici</CustomLink>.
</Typography>