Commit 2f3e6bb6 authored by Florent Chehab's avatar Florent Chehab

feat(Recommendation List): done | tweaks(a lot of stuff):

Recommendation list:
* Focus on performance with silent state update (and no hook)
* Whole logic and componets working
* Updated few stuff in the back
* Complete validation of the recommendation list json content (and tests for most of it)
* Compute universities from the JSON on save

Fixes #34
Fixse #127

Tweaks:
* Reworked downshift multiple
* Reworked selected and multislect field to use downshift multiple when there are too many options
* Fixed wrong used of do_before_save
* Drop use of redux for filter; switch to static handling of part of the state: much more natural

Fixes #125

Other:
New components: CopyToClipBoard, LinkToUser, onBlurContainer, SimplePopupMenu
Updated SaveButton
parent 8f668e77
Pipeline #42014 passed with stages
in 8 minutes and 11 seconds
# Generated by Django 2.1.7 on 2019-06-15 20:12
import backend_app.fields
import backend_app.validation.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("backend_app", "0010_recommendationlist")]
operations = [
migrations.AlterField(
model_name="recommendationlist",
name="content",
field=backend_app.fields.JSONField(
default=list,
validators=[
backend_app.validation.validators.RecommendationListJsonContentValidator(),
backend_app.validation.validators.RecommendationListUnivValidator(),
],
),
),
migrations.AlterField(
model_name="recommendationlist",
name="title",
field=models.CharField(max_length=100),
),
]
......@@ -9,20 +9,22 @@ from backend_app.models.abstract.base import (
BaseModelViewSet,
)
from backend_app.models.university import University
from backend_app.permissions.app_permissions import (
IsOwner,
IsFollower,
ReadOnly,
IsPublic,
from backend_app.permissions.app_permissions import IsOwner, ReadOnly, IsPublic
from backend_app.validation.validators import (
RecommendationListJsonContentValidator,
RecommendationListUnivValidator,
)
from base_app.models import User
json_content_validator = RecommendationListJsonContentValidator()
univ_content_validator = RecommendationListUnivValidator()
class RecommendationList(BaseModel):
moderation_level = 0
last_update = models.DateTimeField(auto_now=True)
title = models.CharField(max_length=200)
title = models.CharField(max_length=100)
owner = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="user_recommendation_list"
)
......@@ -32,34 +34,59 @@ class RecommendationList(BaseModel):
User, related_name="followed_recommendation_list"
)
description = models.CharField(max_length=300, default="")
content = JSONField(default=list)
content = JSONField(
default=list, validators=[json_content_validator, univ_content_validator]
)
universities = models.ManyToManyField(University)
def save(self, *args, **kwargs):
"""
Custom save function to ensure consistency.
Won't be active on first create
"""
# Will crash if pk is None
if self.pk is not None:
self.universities.clear()
univ_ids_in_list = RecommendationListUnivValidator.get_universities_ids_from_content(
self.content
)
for univ in University.objects.all():
if univ.pk in univ_ids_in_list:
self.universities.add(univ)
return super().save(*args, **kwargs)
class RecommendationListSerializer(BaseModelSerializer):
last_update = serializers.DateTimeField(read_only=True)
universities = serializers.PrimaryKeyRelatedField(read_only=True, many=True)
is_user_owner = serializers.SerializerMethodField()
is_user_follower = serializers.SerializerMethodField()
nb_followers = serializers.SerializerMethodField()
owner_pseudo = serializers.SerializerMethodField()
def get_is_user_owner(self, obj):
return self.get_user_from_request().pk == obj.owner.pk
def get_is_user_follower(self, obj):
if obj.pk is None:
return False
else:
return obj.followers.filter(pk=self.get_user_from_request().pk).exists()
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()
def get_owner_pseudo(self, obj):
return obj.owner.pseudo
self.override_validated_data({"owner": user})
def create(self, validated_data):
# make sure we cannot create list for others
validated_data["owner"] = self.get_user_from_request()
return super().create(validated_data)
class Meta:
model = RecommendationList
......@@ -71,8 +98,10 @@ class RecommendationListSerializer(BaseModelSerializer):
"description",
"content",
"is_user_owner",
"is_user_follower",
"universities",
"last_update",
"owner_pseudo",
)
......@@ -86,13 +115,15 @@ class RecommendationListSerializerShort(RecommendationListSerializer):
"nb_followers",
"is_user_owner",
"description",
"last_update",
"owner_pseudo",
)
class RecommendationListViewSet(BaseModelViewSet):
serializer_class = RecommendationListSerializer
end_point_route = "recommendationLists"
permission_classes = (IsOwner | (IsFollower & IsPublic & ReadOnly),)
permission_classes = (IsOwner | (IsPublic & ReadOnly),)
def get_serializer_class(self):
if "action" in self.__dict__ and self.action == "list":
......@@ -109,4 +140,6 @@ class RecommendationListViewSet(BaseModelViewSet):
is_public = Q(is_public=True)
query = query | is_public
return RecommendationList.objects.filter(query)
return RecommendationList.objects.filter(query).prefetch_related(
"owner", "universities"
)
......@@ -8,6 +8,7 @@ from backend_app.models.abstract.base import (
BaseModelSerializer,
BaseModelViewSet,
)
from backend_app.permissions.app_permissions import IsOwner, IsStaff, ReadOnly
from backend_app.utils import get_user_level, get_default_theme_settings
from backend_app.validation.validators import ThemeValidator
from base_app.models import User
......@@ -46,20 +47,9 @@ class UserDataSerializer(BaseModelSerializer):
return self._list_user_post_function(obj.owner, "POST")
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})
# we try to recover the correct instance
query = UserData.objects.filter(owner=user)
if len(query) == 1:
self.instance = query[0]
def create(self, validated_data):
validated_data["owner"] = self.get_user_from_request()
return super().create(validated_data)
class Meta:
model = UserData
......@@ -68,6 +58,7 @@ class UserDataSerializer(BaseModelSerializer):
class UserDataViewSet(BaseModelViewSet):
serializer_class = UserDataSerializer
permission_classes = (IsOwner | (IsStaff & ReadOnly),)
end_point_route = "userData"
def list(self, request, *args, **kwargs):
......
......@@ -11,6 +11,7 @@ from backend_app.validation.validators import (
TaggedItemValidator,
ThemeValidator,
ImageValidator,
RecommendationListJsonContentValidator,
)
from base_app.settings.dir_locations import BACKEND_ROOT_DIR
......@@ -84,6 +85,58 @@ class TestThemeValidator(object):
self.validator(data)
class TestRecommendationListContentValidator(object):
validator = RecommendationListJsonContentValidator()
@staticmethod
def expect_error(data):
with pytest.raises(ValidationError):
TestRecommendationListContentValidator.validator(data)
@staticmethod
def get_valid_text_block():
return dict(type="text-block", content="coucou")
@staticmethod
def get_valid_univ_block(university=1, appreciation=0):
return dict(
type="univ-block",
content=dict(university=university, appreciation=appreciation),
)
def test_ok(self):
data = []
self.validator(data)
def test_not_ok(self):
data = {}
self.expect_error(data)
def test_ok_text_block(self):
data = [self.get_valid_text_block()]
self.validator(data)
def test_ok_univ_block(self):
data = [self.get_valid_univ_block()]
self.validator(data)
def test_ok_both_block(self):
data = [self.get_valid_univ_block(), self.get_valid_text_block()]
self.validator(data)
def test_ok_null_appreciation_univ(self):
data = [self.get_valid_univ_block(appreciation=None)]
self.validator(data)
def test_ok_many(self):
data = [self.get_valid_text_block()] * 99
self.validator(data)
def test_not_ok_too_many(self):
data = [self.get_valid_text_block()] * 105
self.expect_error(data)
class TestPhotosValidator(object):
validator = PhotosValidator()
......
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Content field of recommendation lists",
"description": "Schema to validate the content field of the recommendation lists",
"type": "array",
"maxItems": 100,
"additionalItems": false,
"items": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"required": [
"type",
"content"
],
"properties": {
"type": {
"const": "univ-block"
},
"content": {
"type": "object",
"additionalProperties": false,
"required": [
"university",
"appreciation"
],
"properties": {
"university": {
"type": [
"integer",
"null"
],
"description": "pk of the university"
},
"appreciation": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 10
}
}
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"type",
"content"
],
"properties": {
"type": {
"const": "text-block"
},
"content": {
"type": "string",
"maxLength": 100000,
"description": "We put a max-length property for principle"
}
}
}
]
}
}
......@@ -22,6 +22,10 @@ with open(join(schema_dir, "theme.json"), "r") as f:
THEME_SCHEMA = json.load(f)
Draft7Validator.check_schema(THEME_SCHEMA)
with open(join(schema_dir, "recommendation_list_content.json"), "r") as f:
RECOMMENDATION_LIST_CONTENT_SCHEMA = json.load(f)
Draft7Validator.check_schema(RECOMMENDATION_LIST_CONTENT_SCHEMA)
with open(join(schema_dir, "tags_schemas_collection.json"), "r") as f:
TAGS_SCHEMA_COLLECTION = json.load(f)
for key, schema in TAGS_SCHEMA_COLLECTION.items():
......
......@@ -12,6 +12,7 @@ from backend_app.validation.schemas import (
PHOTOS_SCHEMA,
TAGS_SCHEMA_COLLECTION,
THEME_SCHEMA,
RECOMMENDATION_LIST_CONTENT_SCHEMA,
)
from backend_app.validation.utils import DEFINITIONS_RESOLVER, get_schema_for_tag
......@@ -49,7 +50,7 @@ class JsonValidator(object):
@deconstructible()
class UsefulLinksValidator(JsonValidator):
"""
Validator to be used on a JSON field that is suppose to store Useful links
Validator to be used on a JSON field that is supposed to store Useful links
"""
schema = USEFUL_LINKS_SCHEMA
......@@ -58,7 +59,7 @@ class UsefulLinksValidator(JsonValidator):
@deconstructible()
class PhotosValidator(JsonValidator):
"""
Validator to be used on a JSON field that is suppose to store photos
Validator to be used on a JSON field that is supposed to store photos
"""
schema = PHOTOS_SCHEMA
......@@ -67,12 +68,51 @@ class PhotosValidator(JsonValidator):
@deconstructible()
class ThemeValidator(JsonValidator):
"""
Validator to be used on a JSON field that is suppose to store the theme data
Validator to be used on a JSON field that is supposed to store the theme data
"""
schema = THEME_SCHEMA
@deconstructible()
class RecommendationListJsonContentValidator(JsonValidator):
"""
Validator to be used on a JSON field that is supposed to store recommendation list content
"""
schema = RECOMMENDATION_LIST_CONTENT_SCHEMA
@deconstructible()
class RecommendationListUnivValidator(object):
"""
Validator to check that all the universities exist in the json of recommendation list
"""
def __init__(self):
from backend_app.models.university import University
self.University = University
@staticmethod
def get_universities_ids_from_content(content):
univ_ids = set()
for block in content:
if block["type"] == "univ-block":
univ_ids.add(block["content"]["university"])
return univ_ids
def __call__(self, value):
univ_ids_in_content = self.get_universities_ids_from_content(value)
all_universities_ids = set(
map(lambda univ: univ.pk, self.University.objects.all())
)
for univ_id in univ_ids_in_content:
if univ_id not in all_universities_ids:
raise ValidationError("Unrecognized university id {}".format(univ_id))
@deconstructible()
class TaggedItemValidator(object):
def __init__(self):
......
......@@ -178,7 +178,7 @@ class RecommendationListChangeFollowerViewSet(ViewSet):
end_point_route = "recommendationListChangeFollower"
permission_classes = tuple()
def create(self, request, pk=None):
def update(self, request, pk=None):
if pk is None:
return Response(status=403)
......@@ -194,7 +194,7 @@ class RecommendationListChangeFollowerViewSet(ViewSet):
if pk is None:
return Response(status=403)
# can delete folower even if list not public
recommendation = self.get_list(pk)
recommendation = RecommendationList.objects.get(pk=pk)
recommendation.followers.remove(self.request.user)
recommendation.save()
return Response(status=200)
......
Troubleshooting
===============
## General theme / app bar color is messed up
?> :information_desk_person: Check that none of your imports from `material-ui` ends with `/index` (webstorm adds them sometimes and it seems to break some stuff along the way).
......@@ -30,6 +30,7 @@
* [Tests](Application/Frontend/tests.md)
* [Notifications](Application/Frontend/notifications.md)
* [Map](Application/Frontend/map.md)
* [Troubleshooting](Application/Frontend/troubleshooting.md)
- [**Deploy**](Application/deploy.md)
......
......@@ -60,6 +60,7 @@ const mapStateToProps = (state) => {
return {
countries: state.api.countriesAll,
currencies: state.api.currenciesAll,
universities: state.api.universitiesAll,
languages: state.api.languagesAll,
serverModerationStatus: state.api.serverModerationStatusAll,
};
......@@ -70,6 +71,7 @@ const mapDispatchToProps = (dispatch) => {
api: {
countries: () => dispatch(getActions("countries").readAll()),
currencies: () => dispatch(getActions("currencies").readAll()),
universities: () => dispatch(getActions("universities").readAll()),
languages: () => dispatch(getActions("languages").readAll()),
serverModerationStatus: () => dispatch(getActions("serverModerationStatus").readAll()), // not needed for server moderation status
},
......
import Tooltip from "@material-ui/core/Tooltip";
import React, {useState} from "react";
import clipboardCopy from "../../utils/clipboardCopy";
import PropTypes from "prop-types";
/**
* Render prop component that wraps element in a Tooltip that shows "Copied to clipboard!" when the
* copy function is invoked
*/
function CopyToClipboard(props) {
const [showToolTip, setShowTooltip] = useState(false);
return (
<Tooltip
open={showToolTip}
title={props.message}
leaveDelay={1500}
onClose={() => setShowTooltip(false)}
>
{props.render((text) => {
clipboardCopy(text);
setShowTooltip(true);
})}
</Tooltip>
);
}
CopyToClipboard.propTypes = {
render: PropTypes.func.isRequired,
message: PropTypes.string.isRequired,
};
CopyToClipboard.defaultProps = {
message: "Lien copié 😁",
};
export default React.memo(CopyToClipboard);
......@@ -9,16 +9,15 @@ import TextField from "@material-ui/core/TextField";
import Paper from "@material-ui/core/Paper";
import MenuItem from "@material-ui/core/MenuItem";
import Chip from "@material-ui/core/Chip";
import __difference from "lodash/difference";
import fuzzysort from "fuzzysort";
import isEqual from "lodash/isEqual";
function renderInput(inputProps) {
function renderInput(inputProps, autoFocus) {
const {InputProps, classes, ref, ...other} = inputProps;
return (
<TextField
autoFocus={autoFocus}
InputProps={{
inputRef: ref,
classes: {root: classes.inputRoot},
......@@ -36,7 +35,7 @@ function renderSuggestion({suggestion, index, itemProps, highlightedIndex, selec
return (
<MenuItem
{...itemProps}
key={suggestion.label}
key={suggestion.value}
selected={isHighlighted}
component="div"
style={{
......@@ -53,44 +52,56 @@ renderSuggestion.propTypes = {
index: PropTypes.number,
itemProps: PropTypes.object,
selectedItem: PropTypes.string,
suggestion: PropTypes.shape({label: PropTypes.string, id: PropTypes.number}).isRequired,
suggestion: PropTypes.shape({label: PropTypes.string, value: PropTypes.number}).isRequired,
};
class DownshiftMultiple extends React.Component {
static CACHE = {};
getFinalOptions() {
return this.props.options.map(({value, label}) => ({value, label, valueStr: value.toString()}));
}
// holds the mapping from value to label of all options
// options cannot be changed once the component in mounted
reverseOptions = new Map();
constructor(props) {
super(props);
this.state = Object.assign({}, this.props.config);
const {value, inputValue} = props;
this.state = {value: [...value], inputValue, options: this.getFinalOptions()};
this.reverseOptions = new Map();
}
componentDidMount() {
this.setState(this.props.config);
}
const {options} = this.props;
this.reverseOptions = new Map();
options.forEach(el => this.reverseOptions.set(el.value, el.label));
// eslint-disable-next-line no-unused-vars
componentDidUpdate(prevProps, prevState, snapshot) {
if (!isEqual(prevProps.config.selectedItems, this.props.config.selectedItems)) {
this.setState(this.props.config);
let proposal = {value: this.props.value, inputValue: this.props.inputValue};
const {cacheId} = this.props;
if (cacheId !== null && DownshiftMultiple.CACHE[cacheId]) {
Object.assign(proposal, DownshiftMultiple.CACHE[cacheId]);
}
if (this.state.selectedItems !== prevState.selectedItems) {
this.props.onChange(this.state.selectedItems);
}
this.setState({value: [...proposal.value], inputValue: proposal.inputValue, options: this.getFinalOptions()});
}
componentWillUnmount() {
if (this.props.onComponentUnmount) {
this.props.onComponentUnmount(this.state);
const {cacheId} = this.props;
if (cacheId !== null) {
const {value, inputValue} = this.state;
DownshiftMultiple.CACHE[cacheId] = {value, inputValue};
}
}
getSuggestions(value) {
const {options} = this.props;
const {selectedItems} = this.state;
let possible = __difference(options, selectedItems);
const filter = fuzzysort.go(value, possible, {limit: 5, key: "label"});
getSuggestions(inputValue) {
const {value, options} = this.state;
let possible = options.filter(el => !value.includes(el.value));
const filter = fuzzysort.go(inputValue, possible, {limit: 5, keys: ["label", "valueStr"]});
if (filter.length > 0) {
return filter.map(item => item.obj);
} else {
......@@ -99,10 +110,10 @@ class DownshiftMultiple extends React.Component {
}
handleKeyDown = event => {
const {inputValue, selectedItems} = this.state;
if (selectedItems.length && !inputValue.length && keycode(event) === "backspace") {
const {inputValue, value} = this.state;
if (value.length && !inputValue.length && keycode(event) === "backspace") {
this.setState({
selectedItems: selectedItems.slice(0, selectedItems.length - 1),
value: value.slice(0, value.length - 1),
});
}
};
......@@ -111,44 +122,50 @@ class DownshiftMultiple extends React.Component {
this.setState({inputValue: event.target.value});
};
handleChange = item => {
const {options} = this.props;
let {selectedItems} = this.state;
if (selectedItems.indexOf(item) === -1) {
for (let ind in options) {
let el = options[ind];
if (el.id == item) {
selectedItems = [...selectedItems, el];
break;
}
handleChange = val => {
let {value} = this.state;
if (value.indexOf(val) === -1) {
value = [...value, val];
if (!this.props.multiple) {
value = value.length === 0 ? [] : [value[value.length - 1]];
}
// Tell subscriber
this.onChange(value);
}
this.setState({
inputValue: "",
selectedItems,
value,
});
};
handleDelete = item => () => {
handleDelete = val => () => {
this.setState(state => {
const selectedItems = [...state.selectedItems];
selectedItems.splice(selectedItems.indexOf(item), 1);
return {selectedItems};
const value = [...state.value];
value.splice(value.indexOf(val), 1);
this.onChange(value);
return {value};
});