Commit 2e1e841b authored by Solene Aboud's avatar Solene Aboud Committed by Florent Chehab

feat(recommendation list): back & front almost done

Backend:
* Added recommendation list model
* Added smart serializers/viewsets for the model
* Added DRF permissions `IsFollower` and `IsPublic`
* Backend handling of following,

Frontend:
* Cleaned setup
* Textblock ready
* Better save button
* List and view recommendation

Both:
Connected for creation and save of recommendation

Almost done #34
parent 6cad83dd
......@@ -22,6 +22,7 @@ from backend_app.models.for_testing.moderation import ForTestingModeration
from backend_app.models.for_testing.versioning import ForTestingVersioning
from backend_app.models.offer import Offer
from backend_app.models.pendingModeration import PendingModeration
from backend_app.models.recommendationList import RecommendationList
from backend_app.models.specialty import Specialty
from backend_app.models.tag import Tag
from backend_app.models.university import University
......@@ -50,6 +51,7 @@ ALL_MODELS = [
PendingModeration,
Exchange,
ExchangeFeedback,
RecommendationList,
Specialty,
Tag,
University,
......
......@@ -8,6 +8,9 @@ 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,
)
def load_all():
......@@ -24,3 +27,4 @@ def load_all():
LoadTags(admin).load()
LoadLanguages().load()
LoadUniversityEx(admin).load()
LoadRecommendationLists(admin).load()
from backend_app.models.recommendationList import RecommendationList
from base_app.models import User
from .loadGeneric import LoadGeneric
class LoadRecommendationLists(LoadGeneric):
"""
Class to load the tags in the app.
"""
def __init__(self, admin: User):
self.admin = admin
def load(self):
recommendation_list_ex = RecommendationList(
title="Ma première liste",
owner=self.admin,
is_public=True,
description="Ceci est une super liste !",
content=[],
)
recommendation_list_ex.save()
# Generated by Django 2.1.7 on 2019-06-01 13:54
import backend_app.fields
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("backend_app", "0009_auto_20190531_2007"),
]
operations = [
migrations.CreateModel(
name="RecommendationList",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("last_update", models.DateTimeField(auto_now=True)),
("title", models.CharField(max_length=200)),
("is_public", models.BooleanField(default=False)),
("description", models.CharField(default="", max_length=300)),
("content", backend_app.fields.JSONField(default=list)),
(
"followers",
models.ManyToManyField(
related_name="followed_recommendation_list",
to=settings.AUTH_USER_MODEL,
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="user_recommendation_list",
to=settings.AUTH_USER_MODEL,
),
),
("universities", models.ManyToManyField(to="backend_app.University")),
],
options={"abstract": False},
)
]
from django.db import models
from django.db.models import Q
from rest_framework import serializers
from backend_app.fields import JSONField
from backend_app.models.abstract.base import (
BaseModel,
BaseModelSerializer,
BaseModelViewSet,
)
from backend_app.models.university import University
from backend_app.permissions.app_permissions import (
IsOwner,
IsFollower,
ReadOnly,
IsPublic,
)
from base_app.models import User
class RecommendationList(BaseModel):
moderation_level = 0
last_update = models.DateTimeField(auto_now=True)
title = models.CharField(max_length=200)
owner = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="user_recommendation_list"
)
is_public = models.BooleanField(default=False)
followers = models.ManyToManyField(
User, related_name="followed_recommendation_list"
)
description = models.CharField(max_length=300, default="")
content = JSONField(default=list)
universities = models.ManyToManyField(University)
class RecommendationListSerializer(BaseModelSerializer):
last_update = serializers.DateTimeField(read_only=True)
universities = serializers.PrimaryKeyRelatedField(read_only=True, many=True)
is_user_owner = serializers.SerializerMethodField()
nb_followers = serializers.SerializerMethodField()
def get_is_user_owner(self, obj):
return self.get_user_from_request().pk == obj.owner.pk
def get_nb_followers(self, obj):
if obj.is_public:
return obj.followers.count()
else:
return 0
def do_before_save(self):
"""
For safety: enforce (for sure) that we update the model corresponding to the user/owner.
"""
super().do_before_save()
user = self.get_user_from_request()
self.override_validated_data({"owner": user})
class Meta:
model = RecommendationList
fields = BaseModelSerializer.Meta.fields + (
"title",
"owner",
"is_public",
"nb_followers",
"description",
"content",
"is_user_owner",
"universities",
"last_update",
)
class RecommendationListSerializerShort(RecommendationListSerializer):
class Meta:
model = RecommendationList
fields = BaseModelSerializer.Meta.fields + (
"title",
"owner",
"is_public",
"nb_followers",
"is_user_owner",
"description",
)
class RecommendationListViewSet(BaseModelViewSet):
serializer_class = RecommendationListSerializer
end_point_route = "recommendationLists"
permission_classes = (IsOwner | (IsFollower & IsPublic & ReadOnly),)
def get_serializer_class(self):
if "action" in self.__dict__ and self.action == "list":
return RecommendationListSerializerShort
else:
return RecommendationListSerializer
def get_queryset(self): # this functions allows to get the user's lists
owned = Q(owner=self.request.user)
followed = Q(followers__in=[self.request.user], is_public=True)
query = owned | followed
if "action" in self.__dict__ and self.action != "list":
is_public = Q(is_public=True)
query = query | is_public
return RecommendationList.objects.filter(query)
......@@ -38,7 +38,7 @@ class IsOwner(BasePermission):
"""
Permission that checks that the requester is the owner of the object.
The object must have a owner field that corresponds to a user, or the object
The object must have an owner field that corresponds to a user, or the object
must be the user itself.
"""
......@@ -53,6 +53,28 @@ class IsOwner(BasePermission):
return True
class IsFollower(BasePermission):
"""
Permission that checks that the requester is a follower of the object (a list of universities).
The object must have a "followers" field that corresponds to a list of users.
"""
def has_object_permission(self, request, view, obj):
return obj.followers.filter(pk=request.user.pk).exists()
class IsPublic(BasePermission):
"""
Permission that checks that the object is public.
The object must have a "is_public" field.
"""
def has_object_permission(self, request, view, obj):
return obj.is_public
class NoDelete(BasePermission):
"""
Permission to prevent the use of the DELETE method.
......
......@@ -27,6 +27,10 @@ from backend_app.models.pendingModeration import (
PendingModerationViewSet,
PendingModerationObjViewSet,
)
from backend_app.models.recommendationList import (
RecommendationListViewSet,
RecommendationList,
)
from backend_app.models.specialty import SpecialtyViewSet
from backend_app.models.tag import TagViewSet
from backend_app.models.university import UniversityViewSet
......@@ -70,6 +74,7 @@ ALL_API_VIEWSETS = [
FileViewSet,
PictureViewSet,
ExchangeFeedbackViewSet,
RecommendationListViewSet,
SpecialtyViewSet,
TagViewSet,
UniversityViewSet,
......@@ -162,10 +167,44 @@ class BannedUserViewSet(ViewSet):
return Response(status=200)
class RecommendationListChangeFollowerViewSet(ViewSet):
"""
Viewset to be able to add or delete followers on
a recommendation list
"""
# Since RecommendationListChangeFollowerViewSet doesn't inherit from BaseModelViewSet
# We need to link here the correct permissions
end_point_route = "recommendationListChangeFollower"
permission_classes = tuple()
def create(self, request, pk=None):
if pk is None:
return Response(status=403)
recommendation = RecommendationList.objects.get(pk=pk)
if recommendation.is_public:
recommendation.followers.add(self.request.user)
recommendation.save()
return Response(status=200)
else:
return Response(status=403)
def delete(self, request, pk=None):
if pk is None:
return Response(status=403)
# can delete folower even if list not public
recommendation = self.get_list(pk)
recommendation.followers.remove(self.request.user)
recommendation.save()
return Response(status=200)
ALL_API_VIEW_VIEWSETS = [
AppModerationStatusViewSet,
LogFrontendErrorsViewSet,
BannedUserViewSet,
RecommendationListChangeFollowerViewSet,
]
ALL_VIEWSETS = ALL_API_VIEWSETS + ALL_API_VIEW_VIEWSETS
......
......@@ -18,7 +18,7 @@ export const mainMenuItems = [
export const mainMenuHome = item("Accueil", APP_ROUTES.base, HomeIcon);
export const secondaryMenuItems = [
item("Mes listes", APP_ROUTES.lists, AssignmentIcon),
item("Listes", APP_ROUTES.lists, AssignmentIcon),
];
export const infoMenuItems = [
......
import React from "react";
import React, {useEffect, useState} from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import {withStyles} from "@material-ui/core/styles";
import CircularProgress from "@material-ui/core/CircularProgress";
import green from "@material-ui/core/colors/green";
import Button from "@material-ui/core/Button";
import Fab from "@material-ui/core/Fab";
import CheckIcon from "@material-ui/icons/Check";
import SaveIcon from "@material-ui/icons/Save";
import {makeStyles} from "@material-ui/styles";
/**
* Component to render a nice save button
* Inspired by https://material-ui.com/demos/progress/
*
* @class SaveButton
* @extends {React.Component}
*/
class SaveButton extends React.Component {
state = {
loading: false,
};
handleButtonClick = () => {
if (!this.state.loading && !this.props.success) {
this.setState({loading: true,});
this.props.handleSaveRequested();
}
};
componentDidUpdate(prevProps) {
// we smartly remove the loading status
if (!prevProps.success && this.props.success) {
this.setState({loading: false});
}
}
render() {
const {loading} = this.state,
{classes, success, disabled, extraRootClass} = this.props,
buttonClassname = classNames({
[classes.buttonSuccess]: success,
});
return (
<div className={classNames(classes.root, extraRootClass)}>
<div className={classes.wrapper}>
<Fab color="primary"
className={buttonClassname}
onClick={() => this.handleButtonClick()}
disabled={disabled}>
{success ? <CheckIcon/> : <SaveIcon/>}
</Fab>
{loading && <CircularProgress size={68} className={classes.fabProgress}/>}
</div>
<div className={classes.wrapper}>
<Button variant="contained"
color="primary"
className={buttonClassname}
disabled={loading || disabled}
onClick={() => this.handleButtonClick()}
>
{this.props.label}
</Button>
{loading && <CircularProgress size={24} className={classes.buttonProgress}/>}
</div>
</div>
);
}
}
SaveButton.defaultProps = {
success: false,
disabled: true,
label: "Enregistrer",
extraRootClass: "",
};
SaveButton.propTypes = {
classes: PropTypes.object.isRequired,
handleSaveRequested: PropTypes.func.isRequired,
success: PropTypes.bool.isRequired,
disabled: PropTypes.bool.isRequired,
label: PropTypes.string.isRequired,
extraRootClass: PropTypes.string.isRequired,
};
const styles = theme => ({
const useStyles = makeStyles(theme => ({
root: {
display: "flex",
alignItems: "center",
......@@ -117,6 +41,85 @@ const styles = theme => ({
marginTop: -12,
marginLeft: -12,
},
});
}));
/**
* Component to render a nice save button
* Inspired by https://material-ui.com/demos/progress/
*/
function SaveButton(props) {
const classes = useStyles(),
[loading, setLoading] = useState(false),
[success, setSuccess] = useState(false);
function handleButtonClick() {
if (!loading && !success) {
setLoading(true);
props.handleSaveRequested();
}
}
// we smartly remove the loading status
useEffect(() => {
setSuccess(props.success);
if (props.success) {
setLoading(false);
const {autoResetSuccessTimeout} = props;
if (autoResetSuccessTimeout !== 0){
setTimeout(() => setSuccess(false), autoResetSuccessTimeout);
}
}
}, [props.success]);
const {disabled, extraRootClass} = props,
buttonClassname = classNames({
[classes.buttonSuccess]: success,
});
return (
<div className={classNames(classes.root, extraRootClass)}>
<div className={classes.wrapper}>
<Fab color="primary"
className={buttonClassname}
onClick={() => handleButtonClick()}
disabled={disabled}>
{success ? <CheckIcon/> : <SaveIcon/>}
</Fab>
{loading && <CircularProgress size={68} className={classes.fabProgress}/>}
</div>
<div className={classes.wrapper}>
<Button variant="contained"
color="primary"
className={buttonClassname}
disabled={loading || disabled}
onClick={() => handleButtonClick()}
>
{success ? props.successLabel : props.label}
</Button>
{loading && <CircularProgress size={24} className={classes.buttonProgress}/>}
</div>
</div>
);
}
SaveButton.defaultProps = {
success: false,
disabled: true,
label: "Enregistrer",
extraRootClass: "",
autoResetSuccessTimeout: 0,
};
SaveButton.propTypes = {
handleSaveRequested: PropTypes.func.isRequired,
success: PropTypes.bool.isRequired,
disabled: PropTypes.bool.isRequired,
label: PropTypes.string.isRequired,
successLabel: PropTypes.string.isRequired,
extraRootClass: PropTypes.string.isRequired,
autoResetSuccessTimeout: PropTypes.number.isRequired,
};
export default withStyles(styles)(SaveButton);
export default SaveButton;
import React from "react";
import PropTypes from "prop-types";
import compose from "recompose/compose";
import {connect} from "react-redux";
import withStyles from "@material-ui/core/styles/withStyles";
import Paper from "@material-ui/core/Paper";
import Typography from "@material-ui/core/Typography";
import AddIcon from "@material-ui/icons/Add";
import Fab from "@material-ui/core/Fab";
import Markdown from "../common/Markdown";
import CustomComponentForAPI from "../common/CustomComponentForAPI";
import getActions from "../../redux/api/getActions";
import Recommendation from "../recommendation/Recommendation";
import {withErrorBoundary} from "../common/ErrorBoundary";
import SelectList from "../recommendation/SelectListSubPage";
import ViewList from "../recommendation/ViewListSubPage";
import {makeStyles} from "@material-ui/styles";
import PropTypes from "prop-types";
const source = "Ici vous pourrez bientôt voir toutes vos listes d'universités";
const RECOMMENDATIONS = {
content: [
{
name: "List Test 1",
public: true,
user: "UserTest",
descriptive: { id: "1", type: "field", text: "\n **Ajouter** un descriptif " },
content: [
{
type: "univ",
id: "66",
name: "EPFL",
city: "Lausanne",
country: "Suisse",
comment: { id: "2", type: "field", text: "\n Ajouter un commentaire" }
},
{
type: "univ",
id: "33",
name: "Seoul National University Of Science And Technology Seoultech",
city: "Seoul",
country: "République de Corée",
comment: { id: "1", type: "comment", text: "\n Commentaire pour l'**université de Séoul**. Ça à l'air cool comme univ ! :) " }
}
]
},
{
name: "List Test 2",
public: false,
user: "me",
descriptive: { id: "1", type: "comment", text: "\n Ceci est ma listes d'universités pour mes départs en A19 " },
content: [
{
type: "univ",
id: "66",
name: "EPFL",
city: "Lausanne",
country: "Suisse",
comment: { id: "2", type: "comment", text: "\n Une super **bonne univ**, mais il faudra **bosser**.... :/ " }
},
{
type: "univ",
id: "33",
name: "Seoul National University Of Science And Technology Seoultech",
city: "Seoul",
country: "République de Corée",
comment: { id: "1", type: "fiel", text: "" }
}
]
}
]
};
const useStyles = makeStyles(theme => ({
paper: theme.myPaper,
}));
/**
* Component holding the page with the lists of universities
* Component to render the page associated with recommendation lists
*
* @class PageLists
* @extends {React.Component}
* @param props
* @returns {*}
* @constructor
*/
class PageLists extends CustomComponentForAPI {
customRender() {
const { theme } = this.props;
const { classes } = this.props;
return (
<Paper style={theme.myPaper}>
<Typography variant="h4" gutterBottom>
Mes Listes
</Typography>
<Markdown source={source} />
{RECOMMENDATIONS.content.map((el, idx) => <div key={idx}> <Recommendation list={ el } /> </div> )}
<Fab color="primary" className={classes.fab}>
<AddIcon/>
</Fab>
</Paper>
);
}
function PageLists(props) {
const classes = useStyles(),
listId = props.match.params.listId;
return (
<Paper className={classes.paper}>
{typeof listId === "undefined" ?
<SelectList/>
:
<ViewList listId={listId}/>
}
</Paper>
);
}
const mapStateToProps = (state) => {
return {
universities: state.api.universitiesAll,
};
};
const mapDispatchToProps = (dispatch) => {
return {
api: {
universities: () => dispatch(getActions("universities").readAll()),
},
};
};
PageLists.propTypes = {
theme: PropTypes.object.isRequired,
match: PropTypes.object.isRequired,
};
const styles = theme => ({
fab: {
margin: theme.spacing(1),
position: "fixed",
bottom: "1rem",
right: "1rem",
},
extendedIcon: {
marginRight: theme.spacing(1),
},
});
export default compose(
withStyles(styles, { withTheme: true }),
connect(mapStateToProps, mapDispatchToProps),
withErrorBoundary(),
)(PageLists);
import React from "react";
import PropTypes from "prop-types";
import withStyles from "@material-ui/core/styles/withStyles";
import {Paper} from "@material-ui/core";
import Button from "@material-ui/core/Button";
import AddIcon from "@material-ui/icons/Add";
import TextBlock from "../recommendation/TextBlock";
import UnivBlock from "../recommendation/UnivBlock";
import RecommendationEditor from "../recommendation/RecommendationEditor";
import CustomComponentForAPI from "../common/CustomComponentForAPI";
// import {compose} from "redux";
// import getActions from "../../redux/api/getActions";
// import {connect} from "react-redux";
// import editorStyle from "../university/editors/common/editorStyle.js";
// const RECOMMENDATION = {
// name: "List Test 1",
// public: true,
// user: "UserTest",
// descriptive: { id: "1", type: "field", text: "\n **Ajouter** un descriptif " },
// content: [