Commit 4c12f242 authored by Florent Chehab's avatar Florent Chehab

feat(backend): complete refactoring of the backend

* Added new BaseModel /serializer/viewsets
* Change names of abstract models/serializers/viewsets
* Folder structure changed in tha backend
* Added some backend test
* Corrected bug related to moderation on non versionned modeles
* Corrected bug related to no post permissions
* Updated doc accordingly

Fixes #91
parent 710a5417
Pipeline #37109 passed with stages
in 4 minutes and 54 seconds
......@@ -11,3 +11,4 @@ htmlcov
.pytest_cache
database.db
database.db-journal
.idea
......@@ -4,15 +4,18 @@ from reversion_compare.admin import CompareVersionAdmin
from backend_app.config.models import get_models
from backend_app.checks import check_classic_models, check_versionned_models
VERSIONNED_MODELS = get_models(versionned=True, requires_testing=False)
CLASSIC_MODELS = get_models(versionned=False, requires_testing=False)
# We need to register testing models, otherwise we won't be able to test properly,
# Since no migrations would privide those models.
# So don't put requires_testing=True
VERSIONED_MODELS = get_models(versionned=True) # , requires_testing=False)
CLASSIC_MODELS = get_models(versionned=False) # , requires_testing=False)
#######
# Perform some dynamic checks
#######
check_classic_models(CLASSIC_MODELS)
check_versionned_models(VERSIONNED_MODELS)
check_versionned_models(VERSIONED_MODELS)
#######
# Register the models
......@@ -22,7 +25,6 @@ for Model in CLASSIC_MODELS:
# Register the model in the admin in a standard way
admin.site.register(Model)
for Model in VERSIONNED_MODELS:
for Model in VERSIONED_MODELS:
# Register the model in the admin with versioning
admin.site.register(Model, CompareVersionAdmin)
......@@ -5,6 +5,5 @@ class BackendAppConfig(AppConfig):
name = "backend_app"
def ready(self):
import backend_app.signals.__create_user_modules_post_create # noqa:F401
import backend_app.signals.__squash_revision_by_user # noqa:F401
import backend_app.signals.__create_univ_modules_post_save # noqa:F401
import backend_app.signals.squash_revisions # noqa:F401
import backend_app.signals.auto_creation # noqa:F401
import importlib
from typing import List, Optional, Union
from backend_app.models.abstract.versionedEssentialModule import (
VersionedEssentialModule,
)
from django.conf import settings
from backend_app.models.abstract.my_model.myModelVersionned import MyModelVersionned
from .utils import load_viewsets_config
......@@ -51,9 +52,9 @@ def get_models(
Model = Viewset.serializer_class.Meta.model
if versionned is not None:
if versionned and not issubclass(Model, MyModelVersionned):
if versionned and not issubclass(Model, VersionedEssentialModule):
continue
if not versionned and issubclass(Model, MyModelVersionned):
if not versionned and issubclass(Model, VersionedEssentialModule):
continue
out.append(Model)
......
......@@ -49,7 +49,7 @@ CampusViewSet:
UserDataViewSet:
import_location: user
import_location: userData
api_end_point: userData
api_name: user-data-detail
viewset_permission: IsOwner
......@@ -66,33 +66,33 @@ CurrencyViewSet:
viewset_permission: IsStaff | ReadOnly
DepartmentViewSet:
import_location: other_core
import_location: department
api_end_point: departments
viewset_permission: IsStaff | ReadOnly
SpecialtyViewSet:
import_location: other_core
import_location: specialty
api_end_point: specialties
viewset_permission: IsStaff | ReadOnly
OfferViewSet:
import_location: other_core
import_location: offer
api_end_point: offers
viewset_permission: IsStaff | ReadOnly
CountryTaggedItemViewSet:
import_location: country
import_location: countryTaggedItem
api_end_point: countryTaggedItems
api_attr: (?P<country_id>[a-zA-Z]+)
CountryScholarshipViewSet:
import_location: country
import_location: countryScholarship
api_end_point: countryScholarships
api_attr: (?P<country_id>[a-zA-Z]+)
CountryDriViewSet:
import_location: country
import_location: countryDri
api_end_point: countryDri
api_attr: (?P<country_id>[a-zA-Z]+)
viewset_permission: IsStaff | IsDri | NoPost
......@@ -100,34 +100,34 @@ CountryDriViewSet:
CityTaggedItemViewSet:
import_location: city
import_location: cityTaggedItem
api_end_point: cityTaggedItems
api_attr: (?P<city_id>[0-9]+)
UniversityTaggedItemViewSet:
import_location: university
import_location: universityTaggedItem
api_end_point: universityTaggedItems
api_attr: (?P<univ_id>[0-9]+)
UniversityScholarshipViewSet:
import_location: university
import_location: universityScholarship
api_end_point: universityScholarships
api_attr: (?P<univ_id>[0-9]+)
UniversityInfoViewSet:
import_location: university
import_location: universityInfo
viewset_permission: IsStaff | NoPost
api_end_point: universitiesInfo
UniversitySemestersDatesViewSet:
import_location: university
import_location: universitySemestersDates
api_end_point: universitiesSemestersDates
viewset_permission: IsStaff | NoPost
UniversityDriViewSet:
import_location: university
import_location: universityDri
api_end_point: universityDri
api_attr: (?P<univ_id>[0-9]+)
viewset_permission: IsStaff | IsDri | NoPost
......@@ -135,7 +135,7 @@ UniversityDriViewSet:
CampusTaggedItemViewSet:
import_location: campus
import_location: campusTaggedItem
api_end_point: campusTaggedItems
api_attr: (?P<campus_id>[0-9]+)
......@@ -147,50 +147,50 @@ MainCampusViewSet:
RecommendationViewSet:
import_location: user
import_location: recommendation
api_end_point: userRecommendations
RecommendationListViewSet:
import_location: user
import_location: recommendationList
api_end_point: userRecommendationLists
PreviousDepartureViewSet:
import_location: user
import_location: previousDeparture
api_end_point: universitiesPreviousDepartures
viewset_permission: ReadOnly
PreviousDepartureFeedbackViewSet:
import_location: user
import_location: previousDepartureFeedback
api_end_point: universitiesPreviousDepartureFeedback
PendingModerationViewSet:
import_location: abstract.my_model
import_location: pendingModeration
api_end_point: pendingModeration
viewset_permission: IsStaff
VersionViewSet:
import_location: abstract.my_model
import_location: version
api_end_point: versions
api_attr: (?P<content_type_id>[0-9]+)/(?P<object_pk>[0-9A-Za-z]+)
api_name: versionsList
viewset_permission: IsStaff | ReadOnly
PendingModerationObjViewSet:
import_location: abstract.my_model
import_location: pendingModeration
api_end_point: pendingModerationObj
api_attr: (?P<content_type_id>[0-9]+)/(?P<object_pk>[0-9A-Za-z]+)
api_name: pendingModerationObj
viewset_permission: ReadOnly
ForTestingModerationViewSet:
import_location: abstract.my_model
import_location: for_testing.moderation
api_end_point: test/moderation
requires_testing: true
ForTestingVersioningViewSet:
import_location: abstract.my_model
import_location: for_testing.versioning
api_end_point: test/versioning
requires_testing: true
from .mySerializerWithJSON import MySerializerWithJSON
__all__ = ["MySerializerWithJSON"]
......@@ -2,7 +2,7 @@ from base_app.models import User
from django.utils import timezone
import reversion
from backend_app.models.abstract.my_model import MyModel
from backend_app.models.abstract.essentialModule import EssentialModule
class LoadGeneric(object):
......@@ -10,7 +10,7 @@ class LoadGeneric(object):
"""
@classmethod
def add_info_and_save(cls, obj: MyModel, admin: User):
def add_info_and_save(cls, obj: EssentialModule, admin: User):
with reversion.create_revision():
obj.moderated_by = admin
obj.updated_by = admin
......
......@@ -2,16 +2,15 @@ from datetime import datetime
from base_app.models import User
from backend_app.models.country import Country, CountryScholarship
from backend_app.models.country import Country
from backend_app.models.countryScholarship import CountryScholarship
from backend_app.models.currency import Currency
from backend_app.models.tag import Tag
from backend_app.models.university import (
University,
UniversityDri,
UniversityInfo,
UniversitySemestersDates,
UniversityTaggedItem,
)
from backend_app.models.university import University
from backend_app.models.universityDri import UniversityDri
from backend_app.models.universityInfo import UniversityInfo
from backend_app.models.universitySemestersDates import UniversitySemestersDates
from backend_app.models.universityTaggedItem import UniversityTaggedItem
from .loadGeneric import LoadGeneric
......
# Generated by Django 2.1.7 on 2019-03-16 11:20
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("backend_app", "0001_new-initial")]
operations = [
migrations.RenameField(
model_name="countryscholarship",
old_name="type",
new_name="short_description",
),
migrations.RenameField(
model_name="universityscholarship",
old_name="type",
new_name="short_description",
),
]
SEMESTER_OPTIONS = (("a", "autumn"), ("p", "spring"))
__all__ = ["SEMESTER_OPTION"]
__all__ = ["SEMESTER_OPTIONS"]
from django.db import models
from backend_app.custom.mySerializerWithJSON import MySerializerWithJSON
from backend_app.permissions.viewsets import get_viewset_permissions
from rest_framework import serializers, viewsets
class BaseModel(models.Model):
"""
All models in the app inherits from this one.
As of now, this model doesn't have any special fields.
It is basically here to have a coherent naming convention. In fact some
high level behaviors have been implemented in the corresponding Serializer and
viewset.
"""
class Meta:
abstract = True
class BaseModelSerializer(MySerializerWithJSON):
"""
Serializer to go along the BaseModel model. This serializer make sure some
relevant data is always returned.
"""
obj_info = serializers.SerializerMethodField()
# 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()
def get_obj_info(self, obj) -> dict:
"""
Serializer for the `obj_info` *dynamic* field.
`obj` is required in the function definition, but it's not used.
For all object return by the backend api, we add a custom `obj_info`
field. The default value are chown below.
This methods is overrided in EssentialModuleSerializer for
a smarter behavior.
"""
return {"user_can_edit": False, "user_can_moderate": False}
def get_id(self, obj: BaseModel):
"""
Serializer for the id field.
"""
return obj.pk
class Meta:
model = BaseModel
class BaseModelViewSet(viewsets.ModelViewSet):
"""
Custom default viewset
"""
serializer_class = BaseModelSerializer
_permission_classes_cached = None
@property
def permission_classes(self):
"""
We retrieve permissions classes for the viewsets
From the config file and cache it.
"""
if self._permission_classes_cached is None:
self._permission_classes_cached = get_viewset_permissions(
self.__class__.__name__
)
return self._permission_classes_cached
from .basicModule import BasicModule, BasicModuleSerializer, BasicModuleViewSet
__all__ = ["BasicModule", "BasicModuleSerializer", "BasicModuleViewSet"]
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from rest_framework import serializers
from backend_app.custom import MySerializerWithJSON
from backend_app.permissions 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.utils import get_user_level
from rest_framework import serializers
from rest_framework.validators import ValidationError
from backend_app.config.other import DEFAULT_OBJ_MODERATION_LV
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.config.other import (
DEFAULT_OBJ_MODERATION_LV,
OBJ_MODERATION_PERMISSIONS,
)
from backend_app.models.pendingModeration import PendingModeration
from .base import BaseModel, BaseModelSerializer, BaseModelViewSet
POSSIBLE_OBJ_MODER_LV = [
OBJ_MODERATION_PERMISSIONS[key] for key in OBJ_MODERATION_PERMISSIONS
]
def validate_obj_model_lv(value):
if value not in POSSIBLE_OBJ_MODER_LV:
raise ValidationError("obj_moderation_level not recognized")
#
#
#
#
#
#
# Module
#
#
#
#
#
#
#
#
class EssentialModule(BaseModel):
"""
All models in the app deppend of this one.
It contains the required attributes for managing optional data moderation.
from .myModel import MyModel
from .pendingModeration import PendingModeration
All the logic behind moderation is done in EssentialModuleSerializer
"""
CLEANED_MY_MODEL_DATA = {
# store the update author
updated_by = models.ForeignKey(
User, null=True, on_delete=models.SET_NULL, related_name="+"
)
# store the update date (model can be updated without moderation)
updated_on = models.DateTimeField(null=True)
# store the moderator
moderated_by = models.ForeignKey(
User, null=True, on_delete=models.SET_NULL, related_name="+"
)
# store the moderation date
moderated_on = models.DateTimeField(null=True)
# Store the object moderation level by default
obj_moderation_level = models.SmallIntegerField(
default=DEFAULT_OBJ_MODERATION_LV,
validators=[MinValueValidator(0), validate_obj_model_lv],
)
# Add the link to pending moderation
pending_moderation = GenericRelation(PendingModeration)
# A bit of optimization: we store if there is something pending moderation
has_pending_moderation = models.BooleanField(default=False)
class Meta:
abstract = True
#
#
#
#
#
#
# Serializer
#
#
#
#
#
#
#
#
CLEANED_ESSENTIAL_MODULE_MODEL_DATA = {
"moderated_by": None,
"moderated_on": None,
"updated_by": None,
......@@ -29,8 +118,8 @@ def override_data(old_data: dict, new_data: dict) -> dict:
return old_data
class MyModelSerializer(MySerializerWithJSON):
"""Serializer to go along the MyModel Model. This serializer handles backend data moderation checks and tricks.
class EssentialModuleSerializer(BaseModelSerializer):
"""Serializer to go along the EssentialModule Model. This serializer handles backend data moderation checks and tricks.
Raises:
ValidationError -- If you are trying to moderate something you don't have rights to
......@@ -45,21 +134,28 @@ class MyModelSerializer(MySerializerWithJSON):
moderated_on = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True)
has_pending_moderation = serializers.BooleanField(read_only=True)
obj_info = serializers.SerializerMethodField()
# 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_content_type_id(self, obj):
"""
Serializer for content type
"""
return ContentType.objects.get_for_model(self.Meta.model).id
def get_obj_info(self, obj) -> dict:
"""
Serializer for the `obj_info` *dynamic* field.
`obj` is required in the function definition, but it's not used.
"""
try:
user_can_edit = self.context["user_can_edit"]
except KeyError:
# Some viewsets don't inherit from MyModelViewset and therefore
# In case some viewsets don't inherit from BaseModelViewSet and therefore
# don't have the method to produce context["user_can_edit"]
# Anyway, those Viewsets should be readonly, so we can return false.
user_can_edit = False
......@@ -71,14 +167,8 @@ class MyModelSerializer(MySerializerWithJSON):
),
}
def get_id(self, obj: MyModel):
"""
Serializer for the id field.
"""
return obj.pk
class Meta:
model = MyModel
model = EssentialModule
def get_user_from_request(self):
"""
......@@ -88,11 +178,10 @@ class MyModelSerializer(MySerializerWithJSON):
def validate(self, attrs):
"""
Validate `MyModel` fields and enforce certain field at the backend level.
Validate `BaseModel` fields and enforce certain field at the backend level.
Checks that the requested moderation level is not higher than the one of the user.
"""
if "obj_moderation_level" in attrs:
requested_obj_moder_lv = attrs["obj_moderation_level"]
......@@ -130,7 +219,7 @@ class MyModelSerializer(MySerializerWithJSON):
"""
Clear fields related to update and moderation
"""
self.override_validated_data(CLEANED_MY_MODEL_DATA)
self.override_validated_data(CLEANED_ESSENTIAL_MODULE_MODEL_DATA)
def override_validated_data(self, new_data: dict):
"""
......@@ -174,7 +263,9 @@ class MyModelSerializer(MySerializerWithJSON):
except KeyError:
pass
data_to_save = override_data(data_to_save, CLEANED_MY_MODEL_DATA)
data_to_save = override_data(
data_to_save, CLEANED_ESSENTIAL_MODULE_MODEL_DATA
)
# Save instance into pending moderation state
PendingModeration.objects.update_or_create(
......@@ -233,3 +324,62 @@ class MyModelSerializer(MySerializerWithJSON):
instance.has_pending_moderation = False
instance.save()
return instance
#
#
#
#
#
#
# ViewSet
#
#
#
#
#
#
#
#
class EssentialModuleViewSet(BaseModelViewSet):
"""
Custom default viewset
"""
serializer_class = EssentialModuleSerializer
def get_serializer_context(self):
"""
Override default function.
Extra context is provided to the serializer class to
know if a user can edit an element or not.
This allows to not do this query for all elements and improves
performances. You can look at the comment below for more information.
"""
fake_edit_request = FakeRequest(self.request.user, "PUT")
user_can_edit = True
for permission_class in self.get_permissions():
# Theoretically speaking we would need to use has_object_permission
# But for performance purpose, we will consider edition right at the model
# level. Which is consistent with our design.
# Beware, that this might provide inconsistent data to the frontend
# especially if permission_classes impact at the object level such as
# IsOwner.
if not permission_class.has_permission(fake_edit_request, None):
user_can_edit = False
break
default_context = super().get_serializer_context()
default_context["user_can_edit"] = user_can_edit
return default_context
def get_queryset(self):
"""
Extended default rest framework behavior
to prefetch some table and enhance performances
"""
return self.queryset.prefetch_related("moderated_by", "updated_by")
</
from django.db import models
from backend_app.fields import JSONField
from backend_app.models.abstract.my_model import (
MyModelVersionned,
MyModelVersionnedSerializer,
MyModelVersionnedViewSet,
from backend_app.models.abstract.versionedEssentialModule import (
VersionedEssentialModule,
VersionedEssentialModuleSerializer,
VersionedEssentialModuleViewSet,
)
from backend_app.validators.tag import validate_content_against_config
from backend_app.validators.tag.tags_config import USEFULL_LINKS_CONFIG
......@@ -12,7 +12,7 @@ from backend_app.validators.tag.tags_config import USEFULL_LINKS_CONFIG
IMPORTANCE_LEVEL = (("-", "normal"), ("+", "important"), ("++", "IMPORTANT"))
class BasicModule(MyModelVersionned):
class Module(VersionedEssentialModule):
"""
Abstract module that provides defaults fields:
Title, comment, useful_links and importance_level
......@@ -33,7 +33,7 @@ class BasicModule(MyModelVersionned):
abstract = True