Commit ae65342b authored by Florent Chehab's avatar Florent Chehab
Browse files

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 ...@@ -33,8 +33,10 @@ from backend_app.models.universitySemestersDates import UniversitySemestersDates
from backend_app.models.universityTaggedItem import UniversityTaggedItem from backend_app.models.universityTaggedItem import UniversityTaggedItem
from backend_app.models.userData import UserData from backend_app.models.userData import UserData
from backend_app.models.version import Version from backend_app.models.version import Version
from base_app.models import SiteInformation
ALL_MODELS = [ ALL_MODELS = [
SiteInformation,
Campus, Campus,
CampusTaggedItem, CampusTaggedItem,
City, City,
......
...@@ -5,12 +5,15 @@ from backend_app.load_data.loading_scripts.loadCountries import LoadCountries ...@@ -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.loadCurrencies import LoadCurrencies
from backend_app.load_data.loading_scripts.loadGroups import LoadGroups 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.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 ( from backend_app.load_data.loading_scripts.loadRecommendationLists import (
LoadRecommendationLists, 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(): def load_all():
...@@ -28,3 +31,4 @@ def load_all(): ...@@ -28,3 +31,4 @@ def load_all():
LoadLanguages().load() LoadLanguages().load()
LoadUniversityEx(admin).load() LoadUniversityEx(admin).load()
LoadRecommendationLists(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 ( ...@@ -52,7 +52,8 @@ from backend_app.serializers import (
CourseSerializer, CourseSerializer,
) )
from backend_app.settings.defaults import OBJ_MODERATION_PERMISSIONS 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): class CourseViewSet(BaseModelViewSet):
...@@ -108,6 +109,7 @@ class ExchangeViewSet(BaseModelViewSet): ...@@ -108,6 +109,7 @@ class ExchangeViewSet(BaseModelViewSet):
ALL_API_VIEWSETS = [ ALL_API_VIEWSETS = [
SiteInformationViewSet,
UserViewset, UserViewset,
CampusViewSet, CampusViewSet,
MainCampusViewSet, MainCampusViewSet,
...@@ -167,6 +169,33 @@ class AppModerationStatusViewSet(ViewSet): ...@@ -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): class LogFrontendErrorsViewSet(ViewSet):
""" """
Viewset to handle the logging of errors coming from the frontend. Viewset to handle the logging of errors coming from the frontend.
...@@ -289,6 +318,7 @@ class RecommendationListChangeFollowerViewSet(ViewSet): ...@@ -289,6 +318,7 @@ class RecommendationListChangeFollowerViewSet(ViewSet):
ALL_API_VIEW_VIEWSETS = [ ALL_API_VIEW_VIEWSETS = [
AppModerationStatusViewSet, AppModerationStatusViewSet,
LatestUpdateExternalDataViewSet,
LogFrontendErrorsViewSet, LogFrontendErrorsViewSet,
BannedUserViewSet, BannedUserViewSet,
RecommendationListChangeFollowerViewSet, 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 typing import List
from django.utils import timezone
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Q
from django.utils.functional import cached_property from django.utils.functional import cached_property
from rest_framework.response import Response 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.models.abstract.base import BaseModelViewSet
from backend_app.permissions.app_permissions import IsOwner, ReadOnly from backend_app.permissions.app_permissions import IsOwner, ReadOnly
from backend_app.utils import get_user_level, OBJ_MODERATION_PERMISSIONS from backend_app.utils import get_user_level, OBJ_MODERATION_PERMISSIONS
...@@ -122,3 +124,51 @@ class UserViewset(BaseModelViewSet): ...@@ -122,3 +124,51 @@ class UserViewset(BaseModelViewSet):
permission_classes = (IsOwner | ReadOnly,) permission_classes = (IsOwner | ReadOnly,)
serializer_class = UserSerializer serializer_class = UserSerializer
end_point_route = "users" 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 %} {% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html style="font-size:14"> <html style="font-size:13;">
<head> <head>
<meta <meta
...@@ -30,7 +30,7 @@ ...@@ -30,7 +30,7 @@
</head> </head>
<body> <body>
<div id="app" class="columns"> <div id="app">
<!-- React --> <!-- React -->
</div> </div>
</body> </body>
......
...@@ -26,6 +26,8 @@ import {PageCgu, PageRgpd} from "../pages/PagesRgpdCgu"; ...@@ -26,6 +26,8 @@ import {PageCgu, PageRgpd} from "../pages/PagesRgpdCgu";
import PageNotFound from "../pages/PageNotFound"; import PageNotFound from "../pages/PageNotFound";
import PageEditPreviousExchanges from "../pages/PageEditExchangeFeedbacks"; import PageEditPreviousExchanges from "../pages/PageEditExchangeFeedbacks";
import PageMyExchanges from "../pages/PageMyExchanges"; import PageMyExchanges from "../pages/PageMyExchanges";
import NotifierImportantInformation from "./NotifierImportantInformation";
import FooterImportantInformation from "./FooterImportantInformation";
/** /**
* @class App * @class App
...@@ -35,26 +37,30 @@ import PageMyExchanges from "../pages/PageMyExchanges"; ...@@ -35,26 +37,30 @@ import PageMyExchanges from "../pages/PageMyExchanges";
class App extends CustomComponentForAPI { class App extends CustomComponentForAPI {
customRender() { customRender() {
return ( return (
<MainAppFrame> <div style={{display: "flex", flexDirection: "column", justifyItems: "flex-end", minHeight: "100vh"}}>
<FullScreenDialog/> <MainAppFrame>
<main> <FullScreenDialog/>
<Switch> <NotifierImportantInformation/>
<Route exact path={APP_ROUTES.base} component={PageHome}/> <main>
<Route path={APP_ROUTES.search} component={PageSearch}/> <Switch>
<Route path={APP_ROUTES.map} component={PageMap}/> <Route exact path={APP_ROUTES.base} component={PageHome}/>
<Route path={APP_ROUTES.themeSettings} component={PageSettings}/> <Route path={APP_ROUTES.search} component={PageSearch}/>
<Route path={APP_ROUTES.listsWithParams} component={PageLists}/> <Route path={APP_ROUTES.map} component={PageMap}/>
<Route path={APP_ROUTES.universityWithParams} component={PageUniversity}/> <Route path={APP_ROUTES.themeSettings} component={PageSettings}/>
<Route path={APP_ROUTES.myExchanges} component={PageMyExchanges}/> <Route path={APP_ROUTES.listsWithParams} component={PageLists}/>
<Route path={APP_ROUTES.editPreviousExchangeWithParams} component={PageEditPreviousExchanges}/> <Route path={APP_ROUTES.universityWithParams} component={PageUniversity}/>
<Route path={APP_ROUTES.userWithParams} component={PageUser}/> <Route path={APP_ROUTES.myExchanges} component={PageMyExchanges}/>
<Route path={APP_ROUTES.aboutProject} component={PageAboutProject}/> <Route path={APP_ROUTES.editPreviousExchangeWithParams} component={PageEditPreviousExchanges}/>
<Route path={APP_ROUTES.aboutRgpd} component={PageRgpd}/> <Route path={APP_ROUTES.userWithParams} component={PageUser}/>
<Route path={APP_ROUTES.aboutCgu} component={PageCgu}/> <Route path={APP_ROUTES.aboutProject} component={PageAboutProject}/>
<Route component={PageNotFound}/> <Route path={APP_ROUTES.aboutRgpd} component={PageRgpd}/>
</Switch> <Route path={APP_ROUTES.aboutCgu} component={PageCgu}/>
</main> <Route component={PageNotFound}/>
</MainAppFrame> </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
*