essentialModule.py 13.3 KB
Newer Older
1
from django.contrib.contenttypes.fields import GenericRelation
Florent Chehab's avatar
Florent Chehab committed
2
from django.contrib.contenttypes.models import ContentType
3 4 5
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
Florent Chehab's avatar
Florent Chehab committed
6
from django.utils import timezone
7
from rest_framework import serializers
Florent Chehab's avatar
Florent Chehab committed
8

9
from backend_app.models.pendingModeration import PendingModeration
10
from backend_app.permissions.app_permissions import NoPost, NoDelete
11 12
from backend_app.permissions.moderation import is_moderation_required
from backend_app.permissions.utils import Request as FakeRequest
13
from backend_app.settings.defaults import (
14 15 16
    DEFAULT_OBJ_MODERATION_LV,
    OBJ_MODERATION_PERMISSIONS,
)
17 18
from backend_app.utils import get_user_level
from base_app.models import User
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
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):
    """
50
        All models in the app depend of this one.
51
        It contains the required attributes for managing optional data moderation.
Florent Chehab's avatar
Florent Chehab committed
52

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

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
    # 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 = {
103 104 105 106
    "moderated_by": None,
    "moderated_on": None,
    "updated_by": None,
    "updated_on": None,
Florent Chehab's avatar
Florent Chehab committed
107 108 109
}


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

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


120 121
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
122 123 124 125 126

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

127 128
    check_obj_permissions_for_edit = False

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

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

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

139 140 141
    # Add a content_type_id field to be able to find versions
    content_type_id = serializers.SerializerMethodField()

142 143 144 145 146 147
    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)

148 149 150 151 152 153
    def get_content_type_id(self, obj):
        """
        Serializer for content type
        """
        return ContentType.objects.get_for_model(self.Meta.model).id

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

167 168 169 170 171 172 173 174 175 176 177 178
        if user_can_edit and self.check_obj_permissions_for_edit:
            try:
                fake_edit_request = FakeRequest(self.get_user_from_request(), "PUT")
                for permission_class in self.context["permission_classes"]:
                    if not permission_class.has_object_permission(
                        fake_edit_request, None, obj
                    ):
                        user_can_edit = False
                        break
            except KeyError:
                pass

179
        obj_info["user_can_edit"] = user_can_edit
180
        obj_info["user_can_moderate"] = user_can_edit and not is_moderation_required(
181 182 183
            self.Meta.model, obj, self.get_user_from_request()
        )
        return obj_info
184 185

    class Meta:
186
        model = EssentialModule
187 188 189 190 191 192 193 194
        fields = BaseModelSerializer.Meta.fields + (
            "updated_by",
            "updated_on",
            "moderated_by",
            "moderated_on",
            "has_pending_moderation",
            "content_type_id",
        )
195

Florent Chehab's avatar
Florent Chehab committed
196
    def validate(self, attrs):
Florent Chehab's avatar
Florent Chehab committed
197
        """
198
        Validate `BaseModel` fields and enforce certain field at the backend level.
Florent Chehab's avatar
Florent Chehab committed
199

Florent Chehab's avatar
Florent Chehab committed
200
        Checks that the requested moderation level is not higher than the one of the user.
Florent Chehab's avatar
Florent Chehab committed
201
        """
Florent Chehab's avatar
Florent Chehab committed
202 203
        if "obj_moderation_level" in attrs:
            requested_obj_moder_lv = attrs["obj_moderation_level"]
204

205
            try:
206
                user_level = get_user_level(self.get_user_from_request())
207 208 209 210 211 212 213
            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:
Florent Chehab's avatar
Florent Chehab committed
214
                raise ValidationError(
215 216
                    "You can't request moderation for a higher rank than you."
                )
Florent Chehab's avatar
Florent Chehab committed
217

218
        return attrs
Florent Chehab's avatar
Florent Chehab committed
219

Florent Chehab's avatar
Florent Chehab committed
220 221 222
    def set_model_attrs_for_moderation_and_update(
        self, user, moderated_and_updated: bool
    ):
Florent Chehab's avatar
Florent Chehab committed
223
        """
Florent Chehab's avatar
Florent Chehab committed
224 225 226 227
        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
228
        """
229
        now = timezone.now()
230
        self.override_validated_data({"moderated_by": user, "moderated_on": now})
231 232

        if moderated_and_updated:
233
            self.override_validated_data({"updated_by": user, "updated_on": now})
234 235

    def clean_validated_data(self):
Florent Chehab's avatar
Florent Chehab committed
236 237 238
        """
        Clear fields related to update and moderation
        """
239
        self.override_validated_data(CLEANED_ESSENTIAL_MODULE_MODEL_DATA)
240

Florent Chehab's avatar
Florent Chehab committed
241
    def override_validated_data(self, new_data: dict):
Florent Chehab's avatar
Florent Chehab committed
242 243 244 245 246 247
        """
        Method used to force specific attributes when saving a model
        """
        for key in new_data:
            self.validated_data[key] = new_data[key]

248
    def do_before_save(self):
Florent Chehab's avatar
Florent Chehab committed
249
        """
250
        Action to perform before saving a model
Florent Chehab's avatar
Florent Chehab committed
251
        """
Florent Chehab's avatar
Florent Chehab committed
252 253
        pass

Florent Chehab's avatar
Florent Chehab committed
254
    def save(self, *args, **kwargs):
Florent Chehab's avatar
Florent Chehab committed
255 256
        """
        Function that handles all the moderation in a smart way.
257 258
        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
259
        """
260 261 262 263

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

264
        self.clean_validated_data()
265
        self.do_before_save()
Florent Chehab's avatar
Florent Chehab committed
266
        ct = ContentType.objects.get_for_model(self.Meta.model)
267

268
        if is_moderation_required(self.Meta.model, self.instance, user, user_level):
269
            if self.instance is None:  # we need to create the main model
Florent Chehab's avatar
Florent Chehab committed
270
                # Store the user for squashing data in versions models
271
                self.validated_data.updated_by = user
Florent Chehab's avatar
Florent Chehab committed
272
                self.instance = super().save(*args, **kwargs)
273

Florent Chehab's avatar
Florent Chehab committed
274 275 276
            data_to_save = dict()
            for key in self.validated_data:
                try:
Florent Chehab's avatar
Florent Chehab committed
277 278
                    # retrieve the submitted data and save the clean json
                    # to make sure it will be savable
Florent Chehab's avatar
Florent Chehab committed
279 280 281 282
                    data_to_save[key] = self.initial_data[key]
                except KeyError:
                    pass

283 284 285
            data_to_save = override_data(
                data_to_save, CLEANED_ESSENTIAL_MODULE_MODEL_DATA
            )
Florent Chehab's avatar
Florent Chehab committed
286

Florent Chehab's avatar
Florent Chehab committed
287
            # Save instance into pending moderation state
Florent Chehab's avatar
Florent Chehab committed
288 289 290 291
            PendingModeration.objects.update_or_create(
                content_type=ct,
                object_id=self.instance.pk,
                defaults={
292
                    "updated_on": timezone.now(),
293
                    "updated_by": user,
294 295 296
                    "new_object": data_to_save,
                },
            )
297 298 299 300

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

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

305
            moderated_and_updated = True
306
            if self.instance is None:
Florent Chehab's avatar
Florent Chehab committed
307 308 309
                self.set_model_attrs_for_moderation_and_update(
                    user, moderated_and_updated
                )
310
                instance = super().save(*args, **kwargs)
311
            else:
Florent Chehab's avatar
Florent Chehab committed
312 313
                try:
                    pending_instance = PendingModeration.objects.get(
314
                        content_type=ct, object_id=self.instance.pk
Florent Chehab's avatar
Florent Chehab committed
315
                    )
Florent Chehab's avatar
Florent Chehab committed
316
                    self.clean_validated_data()  # Make quez that it is done...
Florent Chehab's avatar
Florent Chehab committed
317 318 319 320 321 322 323 324
                    # 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
325

Florent Chehab's avatar
Florent Chehab committed
326
                    if pending_instance.new_object == self.initial_data:
327
                        moderated_and_updated = False
328 329
                        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
330
                    pending_instance.delete()
331

Florent Chehab's avatar
Florent Chehab committed
332 333
                except PendingModeration.DoesNotExist:
                    pass
334

Florent Chehab's avatar
Florent Chehab committed
335 336 337
                self.set_model_attrs_for_moderation_and_update(
                    user, moderated_and_updated
                )
338 339 340 341 342 343
                instance = super().save(*args, **kwargs)

            # Performance optimization to know if has pending moderation
            instance.has_pending_moderation = False
            instance.save()
            return instance
344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367


#
#
#
#
#
#
#      ViewSet
#
#
#
#
#
#
#
#


class EssentialModuleViewSet(BaseModelViewSet):
    """
    Custom default viewset
    """

368
    permission_classes = (NoPost & NoDelete,)
369 370 371 372 373 374 375 376 377 378 379
    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.
        """
380 381 382 383 384 385

        # 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()

386 387 388 389 390 391 392 393 394 395
        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.
396 397 398
            #
            # Set check_obj_permissions_for_edit=True in your serializer
            # if you want a better check at the object level
399 400 401 402 403 404
            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
405
        default_context["permission_classes"] = self.get_permissions()
406 407 408 409 410 411 412 413
        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")