essentialModule.py 12.8 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
11
from backend_app.permissions.moderation import is_moderation_required
from backend_app.permissions.utils import Request as FakeRequest
12
from backend_app.settings.defaults import (
13
14
15
    DEFAULT_OBJ_MODERATION_LV,
    OBJ_MODERATION_PERMISSIONS,
)
16
17
from backend_app.utils import get_user_level
from base_app.models import User
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
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
51

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

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


Florent Chehab's avatar
Florent Chehab committed
109
110
111
112
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
113
    for key in new_data:
Florent Chehab's avatar
Florent Chehab committed
114
115
        if key in old_data:
            old_data[key] = new_data[key]
Florent Chehab's avatar
Florent Chehab committed
116
117
118
    return old_data


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

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

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

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

134
    has_pending_moderation = serializers.BooleanField(read_only=True)
135

136
137
138
    # Add a content_type_id field to be able to find versions
    content_type_id = serializers.SerializerMethodField()

139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
    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)

157
158
159
160
161
162
    def get_content_type_id(self, obj):
        """
        Serializer for content type
        """
        return ContentType.objects.get_for_model(self.Meta.model).id

163
    def get_obj_info(self, obj) -> dict:
Florent Chehab's avatar
Florent Chehab committed
164
        """
165
        Serializer for the `obj_info` *dynamic* field. Redefined.
Florent Chehab's avatar
Florent Chehab committed
166
        """
167
168
169
        try:
            user_can_edit = self.context["user_can_edit"]
        except KeyError:
170
            # In case some viewsets don't inherit from BaseModelViewSet and therefore
171
172
173
174
175
176
            # 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,
177
            "user_can_moderate": not is_moderation_required(
178
179
180
                self.Meta.model, obj, self.get_user_from_request()
            ),
        }
181
182

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

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

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

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

215
        return attrs
Florent Chehab's avatar
Florent Chehab committed
216

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

        if moderated_and_updated:
230
            self.override_validated_data({"updated_by": user, "updated_on": now})
231
232

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

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

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

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

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

261
        self.clean_validated_data()
262
        self.do_before_save()
Florent Chehab's avatar
Florent Chehab committed
263
        ct = ContentType.objects.get_for_model(self.Meta.model)
264

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

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

280
281
282
            data_to_save = override_data(
                data_to_save, CLEANED_ESSENTIAL_MODULE_MODEL_DATA
            )
Florent Chehab's avatar
Florent Chehab committed
283

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

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

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

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

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

Florent Chehab's avatar
Florent Chehab committed
329
330
                except PendingModeration.DoesNotExist:
                    pass
331

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

            # Performance optimization to know if has pending moderation
            instance.has_pending_moderation = False
            instance.save()
            return instance
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375


#
#
#
#
#
#
#      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.
        """
376
377
378
379
380
381

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

382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
        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")