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): ...@@ -56,8 +56,15 @@ class BaseModelSerializer(MySerializerWithJSON):
""" """
return obj.pk return obj.pk
def get_user_from_request(self):
"""
Function to retrieve the user from the request
"""
return self.context["request"].user
class Meta: class Meta:
model = BaseModel model = BaseModel
fields = ("obj_info", "id")
class BaseModelViewSet(viewsets.ModelViewSet): class BaseModelViewSet(viewsets.ModelViewSet):
......
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType 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 django.utils import timezone
from rest_framework import serializers 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.moderation import is_moderation_required
from backend_app.permissions.utils import Request as FakeRequest 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 ( from backend_app.settings.defaults import (
DEFAULT_OBJ_MODERATION_LV, DEFAULT_OBJ_MODERATION_LV,
OBJ_MODERATION_PERMISSIONS, OBJ_MODERATION_PERMISSIONS,
) )
from backend_app.utils import get_user_level
from backend_app.models.pendingModeration import PendingModeration from base_app.models import User
from .base import BaseModel, BaseModelSerializer, BaseModelViewSet from .base import BaseModel, BaseModelSerializer, BaseModelViewSet
POSSIBLE_OBJ_MODER_LV = [ POSSIBLE_OBJ_MODER_LV = [
...@@ -127,21 +125,35 @@ class EssentialModuleSerializer(BaseModelSerializer): ...@@ -127,21 +125,35 @@ class EssentialModuleSerializer(BaseModelSerializer):
###### ######
# Basic fields serializers # 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) 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) moderated_on = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True)
has_pending_moderation = serializers.BooleanField(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 # Add a content_type_id field to be able to find versions
content_type_id = serializers.SerializerMethodField() 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): def get_content_type_id(self, obj):
""" """
Serializer for content type Serializer for content type
...@@ -150,7 +162,7 @@ class EssentialModuleSerializer(BaseModelSerializer): ...@@ -150,7 +162,7 @@ class EssentialModuleSerializer(BaseModelSerializer):
def get_obj_info(self, obj) -> dict: def get_obj_info(self, obj) -> dict:
""" """
Serializer for the `obj_info` *dynamic* field. Serializer for the `obj_info` *dynamic* field. Redefined.
""" """
try: try:
user_can_edit = self.context["user_can_edit"] user_can_edit = self.context["user_can_edit"]
...@@ -169,12 +181,14 @@ class EssentialModuleSerializer(BaseModelSerializer): ...@@ -169,12 +181,14 @@ class EssentialModuleSerializer(BaseModelSerializer):
class Meta: class Meta:
model = EssentialModule model = EssentialModule
fields = BaseModelSerializer.Meta.fields + (
def get_user_from_request(self): "updated_by",
""" "updated_on",
Function to retrieve the user from the request "moderated_by",
""" "moderated_on",
return self.context["request"].user "has_pending_moderation",
"content_type_id",
)
def validate(self, attrs): def validate(self, attrs):
""" """
......
...@@ -52,7 +52,12 @@ class ModuleSerializer(VersionedEssentialModuleSerializer): ...@@ -52,7 +52,12 @@ class ModuleSerializer(VersionedEssentialModuleSerializer):
class Meta: class Meta:
model = Module model = Module
fields = "__all__" fields = VersionedEssentialModuleSerializer.Meta.fields + (
"title",
"comment",
"useful_links",
"importance_level",
)
class ModuleViewSet(VersionedEssentialModuleViewSet): class ModuleViewSet(VersionedEssentialModuleViewSet):
......
import reversion
from django.db import models from django.db import models
from rest_framework import serializers
from reversion.models import Version
import reversion
from backend_app.models.abstract.essentialModule import ( from backend_app.models.abstract.essentialModule import (
EssentialModule, EssentialModule,
EssentialModuleSerializer, EssentialModuleSerializer,
EssentialModuleViewSet, EssentialModuleViewSet,
) )
from backend_app.signals.squash_revisions import new_revision_saved from backend_app.signals.squash_revisions import new_revision_saved
from rest_framework import serializers
from reversion.models import Version
class VersionedEssentialModule(EssentialModule): class VersionedEssentialModule(EssentialModule):
...@@ -52,6 +52,10 @@ class VersionedEssentialModuleSerializer(EssentialModuleSerializer): ...@@ -52,6 +52,10 @@ class VersionedEssentialModuleSerializer(EssentialModuleSerializer):
new_revision_saved.send(sender=self.__class__, obj=self.instance) new_revision_saved.send(sender=self.__class__, obj=self.instance)
return res return res
class Meta:
model = VersionedEssentialModule
fields = EssentialModuleSerializer.Meta.fields + ("nb_version",)
class VersionedEssentialModuleViewSet(EssentialModuleViewSet): class VersionedEssentialModuleViewSet(EssentialModuleViewSet):
""" """
......
...@@ -15,8 +15,6 @@ class UserData(BaseModel): ...@@ -15,8 +15,6 @@ class UserData(BaseModel):
moderation_level = 0 moderation_level = 0
owner = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True) 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) config = JSONField(default=dict)
other_data = JSONField(default=dict) other_data = JSONField(default=dict)
......
...@@ -33,11 +33,16 @@ class IsOwner(BasePermission): ...@@ -33,11 +33,16 @@ 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. 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): def has_object_permission(self, request, view, obj):
return request.user == obj.owner try:
return request.user == obj.owner
except AttributeError:
# For the user model
return request.user == obj
class NoDelete(BasePermission): class NoDelete(BasePermission):
...@@ -66,5 +71,13 @@ class ReadOnly(permissions.BasePermission): ...@@ -66,5 +71,13 @@ class ReadOnly(permissions.BasePermission):
Permission to make a viewset read-only. 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): def has_permission(self, request, view):
return request.method in permissions.SAFE_METHODS 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 import re
from backend_app.settings.defaults import OBJ_MODERATION_PERMISSIONS 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. Returns the user level as int.
""" """
...@@ -18,7 +17,7 @@ def get_user_level(user: User) -> int: ...@@ -18,7 +17,7 @@ def get_user_level(user: User) -> int:
return OBJ_MODERATION_PERMISSIONS["authenticated_user"] 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. Function to know if a user is part of a specific group.
""" """
......
...@@ -3,7 +3,6 @@ from rest_framework.response import Response ...@@ -3,7 +3,6 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from backend_app.checks import check_viewsets 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.campus import CampusViewSet, MainCampusViewSet
from backend_app.models.campusTaggedItem import CampusTaggedItemViewSet from backend_app.models.campusTaggedItem import CampusTaggedItemViewSet
from backend_app.models.city import CityViewSet from backend_app.models.city import CityViewSet
...@@ -38,8 +37,11 @@ from backend_app.models.universityTaggedItem import UniversityTaggedItemViewSet ...@@ -38,8 +37,11 @@ from backend_app.models.universityTaggedItem import UniversityTaggedItemViewSet
from backend_app.models.userData import UserDataViewSet from backend_app.models.userData import UserDataViewSet
from backend_app.models.version import VersionViewSet from backend_app.models.version import VersionViewSet
from backend_app.permissions.app_permissions import ReadOnly 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 = [ ALL_API_VIEWSETS = [
UserViewset,
CampusViewSet, CampusViewSet,
MainCampusViewSet, MainCampusViewSet,
CampusTaggedItemViewSet, 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.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.functional import cached_property 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): class User(AbstractUser):
...@@ -10,3 +35,88 @@ class User(AbstractUser): ...@@ -10,3 +35,88 @@ class User(AbstractUser):
for group in self.groups.all(): for group in self.groups.all():
out.append(group.name) out.append(group.name)
return out 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 = { ...@@ -27,7 +27,7 @@ module.exports = {
"indent": [ "indent": [
"error", "error",
2, 2,
{ "SwitchCase": 1 } { "SwitchCase": 1, "ignoredNodes": [ "JSXAttribute", "JSXSpreadAttribute", ] }
], ],
"linebreak-style": [ "linebreak-style": [
"error", "error",
...@@ -42,6 +42,8 @@ module.exports = { ...@@ -42,6 +42,8 @@ module.exports = {
"always" "always"
], ],
"react/no-unescaped-entities": "off", // that one doesn't improve code readability "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/prop-types": "error",
"react/no-deprecated": "error", "react/no-deprecated": "error",
"no-warning-comments": [ "no-warning-comments": [
......
...@@ -45,7 +45,8 @@ ...@@ -45,7 +45,8 @@
"redux": "^4.0.1", "redux": "^4.0.1",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0", "redux-thunk": "^2.3.0",
"typeface-roboto": "0.0.54" "typeface-roboto": "0.0.54",
"recompose": "latest"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.3.3", "@babel/core": "^7.3.3",
......
...@@ -34,6 +34,7 @@ import PageHome from "../pages/PageHome"; ...@@ -34,6 +34,7 @@ import PageHome from "../pages/PageHome";
import PageUniversity from "../pages/PageUniversity"; import PageUniversity from "../pages/PageUniversity";
import PageSearch from "../pages/PageSearch"; import PageSearch from "../pages/PageSearch";
import PageSettings from "../pages/PageSettings"; import PageSettings from "../pages/PageSettings";
import PageUser from "../pages/PageUser";
const DRAWER_WIDTH = 240; const DRAWER_WIDTH = 240;
...@@ -124,9 +125,8 @@ class App extends CustomComponentForAPI { ...@@ -124,9 +125,8 @@ class App extends CustomComponentForAPI {
path="/app/university/" path="/app/university/"
render={() => (<Redirect to="/app/university/undefined" />)} render={() => (<Redirect to="/app/university/undefined" />)}
/> />
</div>
<div >
<Route path="/app/university/:id" component={PageUniversity} /> <Route path="/app/university/:id" component={PageUniversity} />
<Route path="/app/user/:id" component={PageUser} />
</div> </div>
</main> </main>
......
...@@ -65,12 +65,15 @@ export const secondaryListItems = ( ...@@ -65,12 +65,15 @@ export const secondaryListItems = (
<ListItemText primary="Mes listes" /> <ListItemText primary="Mes listes" />
</ListItem> </ListItem>
<ListItem button>