essentialModule.py 12.3 KB
Newer Older
Florent Chehab's avatar
Florent Chehab committed
1 2
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
3
from rest_framework import serializers
Florent Chehab's avatar
Florent Chehab committed
4

5 6
from backend_app.permissions.moderation import is_moderation_required
from backend_app.permissions.utils import Request as FakeRequest
Florent Chehab's avatar
Florent Chehab committed
7
from backend_app.utils import get_user_level
8 9 10 11 12 13
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

14
from backend_app.settings.defaults import (
15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
    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.
Florent Chehab's avatar
Florent Chehab committed
53

54 55
        All the logic behind moderation is done in EssentialModuleSerializer
    """
56

57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103
    # 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 = {
104 105 106 107
    "moderated_by": None,
    "moderated_on": None,
    "updated_by": None,
    "updated_on": None,
108 109 110
}


Florent Chehab's avatar
Florent Chehab committed
111 112 113 114
def override_data(old_data: dict, new_data: dict) -> dict:
    """Update the data in old_data with the one in new_data
    """

115
    for key in new_data:
Florent Chehab's avatar
Florent Chehab committed
116 117
        if key in old_data:
            old_data[key] = new_data[key]
118 119 120
    return old_data


121 122
class EssentialModuleSerializer(BaseModelSerializer):
    """Serializer to go along the EssentialModule Model. This serializer handles backend data moderation checks and tricks.
Florent Chehab's avatar
Florent Chehab committed
123 124 125 126 127 128 129 130

    Raises:
        ValidationError -- If you are trying to moderate something you don't have rights to
    """

    ######
    # Basic fields serializers
    updated_by = serializers.CharField(read_only=True)
131
    updated_on = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True)
Florent Chehab's avatar
Florent Chehab committed
132

133
    moderated_by = serializers.CharField(read_only=True)
Florent Chehab's avatar
Florent Chehab committed
134 135
    moderated_on = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True)

136
    has_pending_moderation = serializers.BooleanField(read_only=True)
137

138
    # For easier handling on the client side, we force an id field
139
    # this is useful when a model has a dedicated primary key
140 141
    id = serializers.SerializerMethodField()

142 143 144 145 146 147 148 149 150
    # 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

151
    def get_obj_info(self, obj) -> dict:
Florent Chehab's avatar
Florent Chehab committed
152
        """
153
        Serializer for the `obj_info` *dynamic* field.
Florent Chehab's avatar
Florent Chehab committed
154
        """
155 156 157
        try:
            user_can_edit = self.context["user_can_edit"]
        except KeyError:
158
            # In case some viewsets don't inherit from BaseModelViewSet and therefore
159 160 161 162 163 164 165 166 167 168
            # 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

        return {
            "user_can_edit": user_can_edit,
            "user_can_moderate": is_moderation_required(
                self.Meta.model, obj, self.get_user_from_request()
            ),
        }
169 170

    class Meta:
171
        model = EssentialModule
172

173 174 175 176 177 178
    def get_user_from_request(self):
        """
        Function to retrieve the user from the request
        """
        return self.context["request"].user

179
    def validate(self, attrs):
Florent Chehab's avatar
Florent Chehab committed
180
        """
181
        Validate `BaseModel` fields and enforce certain field at the backend level.
Florent Chehab's avatar
Florent Chehab committed
182

Florent Chehab's avatar
Florent Chehab committed
183
        Checks that the requested moderation level is not higher than the one of the user.
Florent Chehab's avatar
Florent Chehab committed
184
        """
185 186
        if "obj_moderation_level" in attrs:
            requested_obj_moder_lv = attrs["obj_moderation_level"]
187

188
            try:
189
                user_level = get_user_level(self.get_user_from_request())
190 191 192 193 194 195 196
            except KeyError:
                # if for some reason we don't have the user in the request
                # we set the level to the default one
                # this can occur during testing.
                user_level = DEFAULT_OBJ_MODERATION_LV

            if requested_obj_moder_lv > user_level:
197
                raise ValidationError(
198 199
                    "You can't request moderation for a higher rank than you."
                )
200

201
        return attrs
202

Florent Chehab's avatar
Florent Chehab committed
203 204 205
    def set_model_attrs_for_moderation_and_update(
        self, user, moderated_and_updated: bool
    ):
Florent Chehab's avatar
Florent Chehab committed
206
        """
Florent Chehab's avatar
Florent Chehab committed
207 208 209 210
        Overrides model attributes regarding moderation and update.
        The moderated field is set to the request user. The moderated_on field is reset to now.

        If there was an updated, the updated_by field and updated_on field are also reset.
Florent Chehab's avatar
Florent Chehab committed
211
        """
212
        now = timezone.now()
213
        self.override_validated_data({"moderated_by": user, "moderated_on": now})
214 215

        if moderated_and_updated:
216
            self.override_validated_data({"updated_by": user, "updated_on": now})
217 218

    def clean_validated_data(self):
Florent Chehab's avatar
Florent Chehab committed
219 220 221
        """
        Clear fields related to update and moderation
        """
222
        self.override_validated_data(CLEANED_ESSENTIAL_MODULE_MODEL_DATA)
223

Florent Chehab's avatar
Florent Chehab committed
224
    def override_validated_data(self, new_data: dict):
Florent Chehab's avatar
Florent Chehab committed
225 226 227 228 229 230
        """
        Method used to force specific attributes when saving a model
        """
        for key in new_data:
            self.validated_data[key] = new_data[key]

231
    def do_before_save(self):
Florent Chehab's avatar
Florent Chehab committed
232
        """
233
        Action to perform before saving a model
Florent Chehab's avatar
Florent Chehab committed
234
        """
Florent Chehab's avatar
Florent Chehab committed
235 236
        pass

Florent Chehab's avatar
Florent Chehab committed
237
    def save(self, *args, **kwargs):
Florent Chehab's avatar
Florent Chehab committed
238 239
        """
        Function that handles all the moderation in a smart way.
240 241
        Nothing has to be done to tell that we won't the data to be moderated,
        it is detected automatically.
Florent Chehab's avatar
Florent Chehab committed
242
        """
243 244 245 246

        user = self.context["request"].user
        user_level = get_user_level(user)

247
        self.clean_validated_data()
248
        self.do_before_save()
Florent Chehab's avatar
Florent Chehab committed
249
        ct = ContentType.objects.get_for_model(self.Meta.model)
250

251
        if is_moderation_required(self.Meta.model, self.instance, user, user_level):
252
            if self.instance is None:  # we need to create the main model
Florent Chehab's avatar
Florent Chehab committed
253
                # Store the user for squashing data in versions models
254
                self.validated_data.updated_by = user
Florent Chehab's avatar
Florent Chehab committed
255
                self.instance = super().save(*args, **kwargs)
256

257 258 259
            data_to_save = dict()
            for key in self.validated_data:
                try:
Florent Chehab's avatar
Florent Chehab committed
260 261
                    # retrieve the submitted data and save the clean json
                    # to make sure it will be savable
262 263 264 265
                    data_to_save[key] = self.initial_data[key]
                except KeyError:
                    pass

266 267 268
            data_to_save = override_data(
                data_to_save, CLEANED_ESSENTIAL_MODULE_MODEL_DATA
            )
Florent Chehab's avatar
Florent Chehab committed
269

Florent Chehab's avatar
Florent Chehab committed
270
            # Save instance into pending moderation state
Florent Chehab's avatar
Florent Chehab committed
271 272 273 274
            PendingModeration.objects.update_or_create(
                content_type=ct,
                object_id=self.instance.pk,
                defaults={
275
                    "updated_on": timezone.now(),
276
                    "updated_by": user,
277 278 279
                    "new_object": data_to_save,
                },
            )
280 281 282 283

            # Performance optimization, we store the fact that there is an object pending moderation
            self.instance.has_pending_moderation = True
            self.instance.save()
284 285
            return self.instance

Florent Chehab's avatar
Florent Chehab committed
286
        else:  # Moderation is not needed, we need to check whether it's a moderation or an update with no moderation
287

288
            moderated_and_updated = True
289
            if self.instance is None:
Florent Chehab's avatar
Florent Chehab committed
290 291 292
                self.set_model_attrs_for_moderation_and_update(
                    user, moderated_and_updated
                )
293
                instance = super().save(*args, **kwargs)
294
            else:
Florent Chehab's avatar
Florent Chehab committed
295 296
                try:
                    pending_instance = PendingModeration.objects.get(
297
                        content_type=ct, object_id=self.instance.pk
Florent Chehab's avatar
Florent Chehab committed
298
                    )
Florent Chehab's avatar
Florent Chehab committed
299
                    self.clean_validated_data()  # Make quez that it is done...
Florent Chehab's avatar
Florent Chehab committed
300 301 302 303 304 305 306 307
                    # We have to compare the serialized data
                    # So we make sure to compare the same elements
                    key_to_remove = []
                    for key in self.initial_data:
                        if key not in self.validated_data:
                            key_to_remove.append(key)
                    for key in key_to_remove:
                        self.initial_data.pop(key, None)
Florent Chehab's avatar
Florent Chehab committed
308

Florent Chehab's avatar
Florent Chehab committed
309
                    if pending_instance.new_object == self.initial_data:
310
                        moderated_and_updated = False
311 312
                        self.validated_data["updated_by"] = pending_instance.updated_by
                        self.validated_data["updated_on"] = pending_instance.updated_on
Florent Chehab's avatar
Florent Chehab committed
313
                    pending_instance.delete()
314

Florent Chehab's avatar
Florent Chehab committed
315 316
                except PendingModeration.DoesNotExist:
                    pass
317

Florent Chehab's avatar
Florent Chehab committed
318 319 320
                self.set_model_attrs_for_moderation_and_update(
                    user, moderated_and_updated
                )
321 322 323 324 325 326
                instance = super().save(*args, **kwargs)

            # Performance optimization to know if has pending moderation
            instance.has_pending_moderation = False
            instance.save()
            return instance
327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361


#
#
#
#
#
#
#      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.
        """
362 363 364 365 366 367

        # When generating the API documentation (url: /api-doc) the request would be None
        # and we don't need to do anything special
        if self.request is None:
            return super().get_serializer_context()

368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391
        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")