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 ...@@ -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.for_testing.versioning import ForTestingVersioning
from backend_app.models.offer import Offer from backend_app.models.offer import Offer
from backend_app.models.pendingModeration import PendingModeration 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.specialty import Specialty
from backend_app.models.tag import Tag from backend_app.models.tag import Tag
from backend_app.models.university import University from backend_app.models.university import University
...@@ -50,6 +51,7 @@ ALL_MODELS = [ ...@@ -50,6 +51,7 @@ ALL_MODELS = [
PendingModeration, PendingModeration,
Exchange, Exchange,
ExchangeFeedback, ExchangeFeedback,
RecommendationList,
Specialty, Specialty,
Tag, Tag,
University, University,
......
...@@ -8,6 +8,9 @@ from backend_app.load_data.loading_scripts.loadLanguages import LoadLanguages ...@@ -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.loadTags import LoadTags
from backend_app.load_data.loading_scripts.loadUniversities import LoadUniversities 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.loadUniversityEx import LoadUniversityEx
from backend_app.load_data.loading_scripts.loadRecommendationLists import (
LoadRecommendationLists,
)
def load_all(): def load_all():
...@@ -24,3 +27,4 @@ def load_all(): ...@@ -24,3 +27,4 @@ def load_all():
LoadTags(admin).load() LoadTags(admin).load()
LoadLanguages().load() LoadLanguages().load()
LoadUniversityEx(admin).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): ...@@ -38,7 +38,7 @@ class IsOwner(BasePermission):
""" """
Permission that checks that the requester is the owner of the object. 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. must be the user itself.
""" """
...@@ -53,6 +53,28 @@ class IsOwner(BasePermission): ...@@ -53,6 +53,28 @@ class IsOwner(BasePermission):
return True 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): class NoDelete(BasePermission):
""" """
Permission to prevent the use of the DELETE method. Permission to prevent the use of the DELETE method.
......
...@@ -27,6 +27,10 @@ from backend_app.models.pendingModeration import ( ...@@ -27,6 +27,10 @@ from backend_app.models.pendingModeration import (
PendingModerationViewSet, PendingModerationViewSet,
PendingModerationObjViewSet, PendingModerationObjViewSet,
) )
from backend_app.models.recommendationList import (
RecommendationListViewSet,
RecommendationList,
)
from backend_app.models.specialty import SpecialtyViewSet from backend_app.models.specialty import SpecialtyViewSet
from backend_app.models.tag import TagViewSet from backend_app.models.tag import TagViewSet
from backend_app.models.university import UniversityViewSet from backend_app.models.university import UniversityViewSet
...@@ -70,6 +74,7 @@ ALL_API_VIEWSETS = [ ...@@ -70,6 +74,7 @@ ALL_API_VIEWSETS = [
FileViewSet, FileViewSet,
PictureViewSet, PictureViewSet,
ExchangeFeedbackViewSet, ExchangeFeedbackViewSet,
RecommendationListViewSet,
SpecialtyViewSet, SpecialtyViewSet,
TagViewSet, TagViewSet,
UniversityViewSet, UniversityViewSet,
...@@ -162,10 +167,44 @@ class BannedUserViewSet(ViewSet): ...@@ -162,10 +167,44 @@ class BannedUserViewSet(ViewSet):
return Response(status=200) 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 = [ ALL_API_VIEW_VIEWSETS = [
AppModerationStatusViewSet, AppModerationStatusViewSet,
LogFrontendErrorsViewSet, LogFrontendErrorsViewSet,
BannedUserViewSet, BannedUserViewSet,
RecommendationListChangeFollowerViewSet,
] ]
ALL_VIEWSETS = ALL_API_VIEWSETS + ALL_API_VIEW_VIEWSETS ALL_VIEWSETS = ALL_API_VIEWSETS + ALL_API_VIEW_VIEWSETS
......
...@@ -18,7 +18,7 @@ export const mainMenuItems = [ ...@@ -18,7 +18,7 @@ export const mainMenuItems = [
export const mainMenuHome = item("Accueil", APP_ROUTES.base, HomeIcon); export const mainMenuHome = item("Accueil", APP_ROUTES.base, HomeIcon);
export const secondaryMenuItems = [ export const secondaryMenuItems = [
item("Mes listes", APP_ROUTES.lists, AssignmentIcon), item("Listes", APP_ROUTES.lists, AssignmentIcon),
]; ];
export const infoMenuItems = [ export const infoMenuItems = [
......
import React from "react"; import React, {useEffect, useState} from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import classNames from "classnames"; import classNames from "classnames";
import {withStyles} from "@material-ui/core/styles";
import CircularProgress from "@material-ui/core/CircularProgress"; import CircularProgress from "@material-ui/core/CircularProgress";
import green from "@material-ui/core/colors/green"; import green from "@material-ui/core/colors/green";
import Button from "@material-ui/core/Button"; import Button from "@material-ui/core/Button";
import Fab from "@material-ui/core/Fab"; import Fab from "@material-ui/core/Fab";
import CheckIcon from "@material-ui/icons/Check"; import CheckIcon from "@material-ui/icons/Check";
import SaveIcon from "@material-ui/icons/Save"; import SaveIcon from "@material-ui/icons/Save";
import {makeStyles} from "@material-ui/styles";
const useStyles = makeStyles(theme => ({
root: {
display: "flex",
alignItems: "center",
width: "fit-content",
},
wrapper: {
margin: theme.spacing(1),
position: "relative",
},
buttonSuccess: {
backgroundColor: green[500],
"&:hover": {
backgroundColor: green[700],
},
},
fabProgress: {
color: green[500],
position: "absolute",
top: -6,
left: -6,
zIndex: 1,
},
buttonProgress: {
color: green[500],
position: "absolute",
top: "50%",
left: "50%",
marginTop: -12,
marginLeft: -12,
},
}));
/** /**
* Component to render a nice save button * Component to render a nice save button
* Inspired by https://material-ui.com/demos/progress/ * Inspired by https://material-ui.com/demos/progress/
*
* @class SaveButton
* @extends {React.Component}
*/ */
class SaveButton extends React.Component { function SaveButton(props) {
state = { const classes = useStyles(),
loading: false, [loading, setLoading] = useState(false),
}; [success, setSuccess] = useState(false);
handleButtonClick = () => { function handleButtonClick() {
if (!this.state.loading && !this.props.success) { if (!loading && !success) {
this.setState({loading: true,}); setLoading(true);
this.props.handleSaveRequested(); props.handleSaveRequested();
}
} }
};
componentDidUpdate(prevProps) {
// we smartly remove the loading status // we smartly remove the loading status
if (!prevProps.success && this.props.success) { useEffect(() => {
this.setState({loading: false}); setSuccess(props.success);
if (props.success) {
setLoading(false);
const {autoResetSuccessTimeout} = props;
if (autoResetSuccessTimeout !== 0){
setTimeout(() => setSuccess(false), autoResetSuccessTimeout);
} }
} }
}, [props.success]);
render() { const {disabled, extraRootClass} = props,
const {loading} = this.state,
{classes, success, disabled, extraRootClass} = this.props,
buttonClassname = classNames({ buttonClassname = classNames({
[classes.buttonSuccess]: success, [classes.buttonSuccess]: success,
}); });
...@@ -47,7 +82,7 @@ class SaveButton extends React.Component { ...@@ -47,7 +82,7 @@ class SaveButton extends React.Component {
<div className={classes.wrapper}> <div className={classes.wrapper}>
<Fab color="primary" <Fab color="primary"
className={buttonClassname} className={buttonClassname}
onClick={() => this.handleButtonClick()} onClick={() => handleButtonClick()}
disabled={disabled}> disabled={disabled}>
{success ? <CheckIcon/> : <SaveIcon/>} {success ? <CheckIcon/> : <SaveIcon/>}
</Fab> </Fab>
...@@ -58,15 +93,14 @@ class SaveButton extends React.Component { ...@@ -58,15 +93,14 @@ class SaveButton extends React.Component {
color="primary" color="primary"
className={buttonClassname} className={buttonClassname}
disabled={loading || disabled} disabled={loading || disabled}
onClick={() => this.handleButtonClick()} onClick={() => handleButtonClick()}
> >
{this.props.label} {success ? props.successLabel : props.label}
</Button> </Button>
{loading && <CircularProgress size={24} className={classes.buttonProgress}/>} {loading && <CircularProgress size={24} className={classes.buttonProgress}/>}
</div> </div>
</div> </div>
); );
}
} }
SaveButton.defaultProps = { SaveButton.defaultProps = {
...@@ -74,49 +108,18 @@ SaveButton.defaultProps = { ...@@ -74,49 +108,18 @@ SaveButton.defaultProps = {
disabled: true, disabled: true,
label: "Enregistrer", label: "Enregistrer",
extraRootClass: "", extraRootClass: "",
autoResetSuccessTimeout: 0,
}; };
SaveButton.propTypes = { SaveButton.propTypes = {
classes: PropTypes.object.isRequired,
handleSaveRequested: PropTypes.func.isRequired, handleSaveRequested: PropTypes.func.isRequired,
success: PropTypes.bool.isRequired, success: PropTypes.bool.isRequired,
disabled: PropTypes.bool.isRequired, disabled: PropTypes.bool.isRequired,
label: PropTypes.string.isRequired, label: PropTypes.string.isRequired,
successLabel: PropTypes.string.isRequired,
extraRootClass: PropTypes.string.isRequired, extraRootClass: PropTypes.string.isRequired,
autoResetSuccessTimeout: PropTypes.number.isRequired,
}; };
const styles = theme => ({ export default SaveButton;
root: {
display: "flex",
alignItems: "center",
width: "fit-content",
},
wrapper: {
margin: theme.spacing(1),
position: "relative",
},
buttonSuccess: {
backgroundColor: green[500],
"&:hover": {
backgroundColor: green[700],
},
},
fabProgress: {
color: green[500],
position: "absolute",
top: -6,
left: -6,
zIndex: 1,
},
buttonProgress: {
color: green[500],
position: "absolute",
top: "50%",
left: "50%",
marginTop: -12,
marginLeft: -12,
},
});
export default withStyles(styles)(SaveButton);
import React from "react"; import React from "react";
import PropTypes from "prop-types";
import compose from "recompose/compose"; 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 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 {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 useStyles = makeStyles(theme => ({
paper: theme.myPaper,
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: "" }
}
]
}
]
};
/** /**
* Component holding the page with the lists of universities * Component to render the page associated with recommendation lists
* *
* @class PageLists * @param props
* @extends {React.Component} * @returns {*}
* @constructor
*/ */
function PageLists(props) {
class PageLists extends CustomComponentForAPI { const classes = useStyles(),
customRender() { listId = props.match.params.listId;
const { theme } = this.props;
const { classes } = this.props;
return ( return (
<Paper style={theme.myPaper}> <Paper className={classes.paper}>
<Typography variant="h4" gutterBottom>