Commit 6ce40dc9 authored by Florent Chehab's avatar Florent Chehab

feat(user page) 馃嵕 & fixes 馃帄:

* Added a user page to show user information
* Updated backend to handle the new data
* Added backend test related to this
* User can now be anonymous
* NB: only users with level == authenticated user can be anonymous on the site
* Backend serializers updated to return the pseudonyme of the user
* Added a boolean field form element

Fixes #64 #65

Other fixes:

* Added basic test for userData
* Fixed bug in ReadOnly permission
* Fixed typos/bugs in new CrudActions
* Enhance fields declaration in abstract serializers
* Changed jsx linting rules
* Moved generic editors files
* Updated network errors handling
parent da3d378b
Pipeline #37942 passed with stages
in 4 minutes and 39 seconds
# Generated by Django 2.1.7 on 2019-04-05 19:31
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("backend_app", "0001_initial")]
operations = [
migrations.RemoveField(model_name="userdata", name="contact_info"),
migrations.RemoveField(model_name="userdata", name="contact_info_is_public"),
]
......@@ -56,8 +56,15 @@ class BaseModelSerializer(MySerializerWithJSON):
"""
return obj.pk
def get_user_from_request(self):
"""
Function to retrieve the user from the request
"""
return self.context["request"].user
class Meta:
model = BaseModel
fields = ("obj_info", "id")
class BaseModelViewSet(viewsets.ModelViewSet):
......
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.utils import timezone
from rest_framework import serializers
from backend_app.models.pendingModeration import PendingModeration
from backend_app.permissions.moderation import is_moderation_required
from backend_app.permissions.utils import Request as FakeRequest
from backend_app.utils import get_user_level
from base_app.models import User
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from backend_app.settings.defaults import (
DEFAULT_OBJ_MODERATION_LV,
OBJ_MODERATION_PERMISSIONS,
)
from backend_app.models.pendingModeration import PendingModeration
from backend_app.utils import get_user_level
from base_app.models import User
from .base import BaseModel, BaseModelSerializer, BaseModelViewSet
POSSIBLE_OBJ_MODER_LV = [
......@@ -127,21 +125,35 @@ class EssentialModuleSerializer(BaseModelSerializer):
######
# Basic fields serializers
updated_by = serializers.CharField(read_only=True)
updated_by = serializers.SerializerMethodField()
updated_on = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True)
moderated_by = serializers.CharField(read_only=True)
moderated_by = serializers.SerializerMethodField()
moderated_on = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True)
has_pending_moderation = serializers.BooleanField(read_only=True)
# For easier handling on the client side, we force an id field
# this is useful when a model has a dedicated primary key
id = serializers.SerializerMethodField()
# Add a content_type_id field to be able to find versions
content_type_id = serializers.SerializerMethodField()
def get_user_related_field(self, user):
"""
Generic function to make sure we return only the pseudo and the id".
:param user: user associated with object
:return: dict
"""
if user is None:
# In testing env or if data is not perfectly initialised
return dict(user_id=None, user_pseudo=None)
else:
return dict(user_id=user.pk, user_pseudo=user.pseudo)
def get_updated_by(self, obj):
return self.get_user_related_field(obj.updated_by)
def get_moderated_by(self, obj):
return self.get_user_related_field(obj.moderated_by)
def get_content_type_id(self, obj):
"""
Serializer for content type
......@@ -150,7 +162,7 @@ class EssentialModuleSerializer(BaseModelSerializer):
def get_obj_info(self, obj) -> dict:
"""
Serializer for the `obj_info` *dynamic* field.
Serializer for the `obj_info` *dynamic* field. Redefined.
"""
try:
user_can_edit = self.context["user_can_edit"]
......@@ -169,12 +181,14 @@ class EssentialModuleSerializer(BaseModelSerializer):
class Meta:
model = EssentialModule
def get_user_from_request(self):
"""
Function to retrieve the user from the request
"""
return self.context["request"].user
fields = BaseModelSerializer.Meta.fields + (
"updated_by",
"updated_on",
"moderated_by",
"moderated_on",
"has_pending_moderation",
"content_type_id",
)
def validate(self, attrs):
"""
......
......@@ -52,7 +52,12 @@ class ModuleSerializer(VersionedEssentialModuleSerializer):
class Meta:
model = Module
fields = "__all__"
fields = VersionedEssentialModuleSerializer.Meta.fields + (
"title",
"comment",
"useful_links",
"importance_level",
)
class ModuleViewSet(VersionedEssentialModuleViewSet):
......
import reversion
from django.db import models
from rest_framework import serializers
from reversion.models import Version
import reversion
from backend_app.models.abstract.essentialModule import (
EssentialModule,
EssentialModuleSerializer,
EssentialModuleViewSet,
)
from backend_app.signals.squash_revisions import new_revision_saved
from rest_framework import serializers
from reversion.models import Version
class VersionedEssentialModule(EssentialModule):
......@@ -52,6 +52,10 @@ class VersionedEssentialModuleSerializer(EssentialModuleSerializer):
new_revision_saved.send(sender=self.__class__, obj=self.instance)
return res
class Meta:
model = VersionedEssentialModule
fields = EssentialModuleSerializer.Meta.fields + ("nb_version",)
class VersionedEssentialModuleViewSet(EssentialModuleViewSet):
"""
......
......@@ -15,8 +15,6 @@ class UserData(BaseModel):
moderation_level = 0
owner = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True)
contact_info = JSONField(default=dict)
contact_info_is_public = models.BooleanField(default=False)
config = JSONField(default=dict)
other_data = JSONField(default=dict)
......
......@@ -33,11 +33,16 @@ 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.
The object must have a owner field that corresponds to a user, or the object
must be the user itself.
"""
def has_object_permission(self, request, view, obj):
try:
return request.user == obj.owner
except AttributeError:
# For the user model
return request.user == obj
class NoDelete(BasePermission):
......@@ -66,5 +71,13 @@ class ReadOnly(permissions.BasePermission):
Permission to make a viewset read-only.
"""
def has_object_permission(self, request, view, obj):
"""
We absolutely need this one since it is used with "OR".
If we don't put it, the IsOwner Or ReadOnly would pass the the has_permission on IsOwner
and then the has_object_permission on Read_only.
"""
return request.method in permissions.SAFE_METHODS
def has_permission(self, request, view):
return request.method in permissions.SAFE_METHODS
import json
from backend_app.models.userData import UserData, UserDataViewSet
from backend_app.tests.utils import WithUserTestCase
class UserDataTestCase(WithUserTestCase):
"""
Some basic tests on the userData viewset.
"""
@classmethod
def setUpMoreTestData(cls):
cls.api_user_data = "/api/{}/".format(UserDataViewSet.end_point_route)
def test_only_one_is_returned(self):
# make sure there is data in the db
self.assertNotEqual((UserData.objects.all()), 0)
# get the data from the apo
response = self.staff_client.get(self.api_user_data)
content = json.loads(response.content)
# make sure only one is returned ant that it is the one corresponding to the requester
self.assertEqual(len(content), 1)
self.assertEqual(content[0]["id"], self.staff_user.pk)
def test_get_working(self):
"""
Will also indirectly check that backend_app_permissions_request works
:return:
"""
def test_for_user(user_to_get, client, user_client):
response = client.get(self.api_user_data + "{}/".format(user_to_get.pk))
if user_to_get.pk != user_client.pk:
self.assertEqual(response.status_code, 404)
else:
content = json.loads(response.content)
self.assertEqual(content["id"], user_client.pk)
clients = [
self.authenticated_client,
self.moderator_client,
self.dri_client,
self.staff_client,
]
users = [
self.authenticated_user,
self.moderator_user,
self.dri_user,
self.staff_user,
]
for (client, client_user) in zip(clients, users):
for user in users:
test_for_user(user, client, client_user)
import re
from backend_app.settings.defaults import OBJ_MODERATION_PERMISSIONS
from base_app.models import User
def get_user_level(user: User) -> int:
def get_user_level(user) -> int:
"""
Returns the user level as int.
"""
......@@ -18,7 +17,7 @@ def get_user_level(user: User) -> int:
return OBJ_MODERATION_PERMISSIONS["authenticated_user"]
def is_member(group_name: str, user: User) -> bool:
def is_member(group_name: str, user) -> bool:
"""
Function to know if a user is part of a specific group.
"""
......
......@@ -3,7 +3,6 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from backend_app.checks import check_viewsets
from backend_app.settings.defaults import OBJ_MODERATION_PERMISSIONS
from backend_app.models.campus import CampusViewSet, MainCampusViewSet
from backend_app.models.campusTaggedItem import CampusTaggedItemViewSet
from backend_app.models.city import CityViewSet
......@@ -38,8 +37,11 @@ from backend_app.models.universityTaggedItem import UniversityTaggedItemViewSet
from backend_app.models.userData import UserDataViewSet
from backend_app.models.version import VersionViewSet
from backend_app.permissions.app_permissions import ReadOnly
from backend_app.settings.defaults import OBJ_MODERATION_PERMISSIONS
from base_app.models import UserViewset
ALL_API_VIEWSETS = [
UserViewset,
CampusViewSet,
MainCampusViewSet,
CampusTaggedItemViewSet,
......
# Generated by Django 2.1.7 on 2019-04-02 19:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("base_app", "0001_initial")]
operations = [
migrations.AddField(
model_name="user",
name="allow_sharing_personal_info",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="pseudo",
field=models.CharField(default="Anonymous42", max_length=12),
),
migrations.AddField(
model_name="user",
name="secondary_email",
field=models.EmailField(blank=True, max_length=254, null=True),
),
]
from typing import List
from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.functional import cached_property
from typing import List
from rest_framework import serializers
from rest_framework.response import Response
from backend_app.models.abstract.base import BaseModelSerializer
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
def validate(user, allow_sharing_personal_info):
"""
Custom validation to ensure that moderators, DRI and staff can't be "anonymous" on the plateform
"""
if (
get_user_level(user) >= OBJ_MODERATION_PERMISSIONS["moderator"]
and not allow_sharing_personal_info
):
raise ValidationError(
{
"allow_sharing_personal_info": "Users that are moderators, members of DRI or staff, must allow sharing of their 'identity', sorry."
}
)
class User(AbstractUser):
......@@ -10,3 +35,88 @@ class User(AbstractUser):
for group in self.groups.all():
out.append(group.name)
return out
allow_sharing_personal_info = models.BooleanField(default=True, null=False)
secondary_email = models.EmailField(null=True, blank=True)
pseudo = models.CharField(
blank=False, null=False, max_length=12, default="Anonymous42"
)
def save(self, *args, **kwargs):
"""
Custom save function to ensure consistency.
"""
# if the object is not created yet, we can't check to what groups it belongs
if self.pk is not None:
validate(self, self.allow_sharing_personal_info)
return super().save(*args, **kwargs)
class UserSerializer(BaseModelSerializer):
# Read only fields
username = serializers.CharField(read_only=True)
first_name = serializers.CharField(read_only=True)
last_name = serializers.CharField(read_only=True)
email = serializers.EmailField(read_only=True)
# fields that the user can modify
allow_sharing_personal_info = serializers.BooleanField()
secondary_email = serializers.EmailField()
pseudo = serializers.CharField()
def validate(self, attrs):
"""
Also validate at the serializer level to prevent error 500
"""
data = super().validate(attrs)
aspi = data["allow_sharing_personal_info"]
validate(self.get_user_from_request(), aspi)
return data
class Meta:
model = User
fields = BaseModelSerializer.Meta.fields + (
"username",
"first_name",
"last_name",
"email",
"allow_sharing_personal_info",
"secondary_email",
"pseudo",
"is_staff",
)
class UserViewset(BaseModelViewSet):
def list(self, request, *args, **kwargs):
# Prevent the querying of all objects.
return Response(list())
def retrieve(self, request, *args, **kwargs):
# Custom behavior to return only the correct set of attributes depending
# On allow_sharing_personal_info
instance = self.get_object()
serializer = self.get_serializer(instance)
if instance == self.request.user or self.request.user.is_staff:
out = serializer.data
else:
# serializer.data is a property we can't set it again, so we need a little trick for the output
# First we copy all the values
out = dict(serializer.data)
# Then we "correct" them
for key in [
"username",
"first_name",
"last_name",
"email",
"secondary_email",
]:
out[key] = None
return Response(out)
queryset = User.objects.all()
permission_classes = (IsOwner | ReadOnly,)
serializer_class = UserSerializer
end_point_route = "users"
import json
import pytest
from django.core.exceptions import ValidationError
from backend_app.tests.utils import WithUserTestCase
from base_app.models import UserViewset, User
class UserTestCase(WithUserTestCase):
"""
Tests for the custom user class
"""
@classmethod
def setUpMoreTestData(cls):
cls.api_users = "/api/{}/".format(UserViewset.end_point_route)
def test_authentificated_can_be_anonymous(self):
self.authenticated_user.allow_sharing_personal_info = True
self.authenticated_user.save()
self.authenticated_user.allow_sharing_personal_info = False
self.authenticated_user.save()
def test_moderators_cant_be_anonymous(self):
self.moderator_user.allow_sharing_personal_info = True
self.moderator_user.save()
with pytest.raises(ValidationError):
self.moderator_user.allow_sharing_personal_info = False
self.moderator_user.save()
def test_moderators_cant_be_anonymous_request(self):
response = self.moderator_client.put(
self.api_users + "{}/".format(self.moderator_user.pk),
dict(allow_sharing_personal_info=False),
)
self.assertEqual(response.status_code, 400)
def test_get_all_returns_empty_list(self):
# check that we have indeed users in the db
self.assertNotEqual(len(User.objects.all()), 0)
# Check what is the response of the endpoint
response = self.staff_client.get(self.api_users)
content = json.loads(response.content)
self.assertEqual(len(content), 0)
def test_data_cleaned(self):
self.authenticated_user.allow_sharing_personal_info = False
self.authenticated_user.save()
response = self.authenticated_client_2.get(
self.api_users + "{}/".format(self.authenticated_user.pk)
)
content = json.loads(response.content)
for key in ["username", "first_name", "last_name", "email", "secondary_email"]:
self.assertIsNone(content[key])
......@@ -27,7 +27,7 @@ module.exports = {
"indent": [
"error",
2,
{ "SwitchCase": 1 }
{ "SwitchCase": 1, "ignoredNodes": [ "JSXAttribute", "JSXSpreadAttribute", ] }
],
"linebreak-style": [
"error",
......@@ -42,6 +42,8 @@ module.exports = {
"always"
],
"react/no-unescaped-entities": "off", // that one doesn't improve code readability
// "react/jsx-indent-props": [2, "first"],
"react/jsx-indent": [2, 2],
"react/prop-types": "error",
"react/no-deprecated": "error",
"no-warning-comments": [
......
......@@ -45,7 +45,8 @@
"redux": "^4.0.1",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0",
"typeface-roboto": "0.0.54"
"typeface-roboto": "0.0.54",
"recompose": "latest"
},
"devDependencies": {
"@babel/core": "^7.3.3",
......
......@@ -34,6 +34,7 @@ import PageHome from "../pages/PageHome";
import PageUniversity from "../pages/PageUniversity";
import PageSearch from "../pages/PageSearch";
import PageSettings from "../pages/PageSettings";
import PageUser from "../pages/PageUser";
const DRAWER_WIDTH = 240;
......@@ -124,9 +125,8 @@ class App extends CustomComponentForAPI {
path="/app/university/"
render={() => (<Redirect to="/app/university/undefined" />)}
/>
</div>
<div >
<Route path="/app/university/:id" component={PageUniversity} />
<Route path="/app/user/:id" component={PageUser} />
</div>
</main>
......
......@@ -65,12 +65,15 @@ export const secondaryListItems = (
<ListItemText primary="Mes listes" />
</ListItem>
{/* eslint-disable-next-line no-undef */}
<NavLink to={`/app/user/${__AppUserId}`} style={{ textDecoration: "none" }}>
<ListItem button>
<ListItemIcon>
<PersonIcon />
</ListItemIcon>
<ListItemText primary="Informations" />
</ListItem>
</NavLink>
</>
);
......
import PropTypes from "prop-types";
import React from "react";
import Grid from "@material-ui/core/Grid";
/**
* Component to display several components on the same line with a Grid element
* @param {object} props The components in props.children will be displayed on a same line
* @constructor
*/
function SameLine(props) {
return (
<Grid container
direction="row"
justify="flex-start"
alignItems="flex-start">
{props.children.map((child, key) =>
(
<Grid item key={key}>
{child}
</Grid>
)
)}
</Grid>
);
}
SameLine.propTypes = {
children: PropTypes.arrayOf(PropTypes.element).isRequired,
};
export default SameLine;
\ No newline at end of file
import React 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";
/**
* 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.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} = this.props,
buttonClassname = classNames({
[classes.buttonSuccess]: success,
});
return (
<div className={classes.root}>
<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()}
>
Enregistrer
</Button>
{loading && <CircularProgress size={24} className={classes.buttonProgress}/>}
</div>
</div>
);
}
}
SaveButton.defaultProps = {
success: false,
disabled: true,
};