• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rdmorganiser / rdmo / 20428555173

22 Dec 2025 10:02AM UTC coverage: 94.693% (-0.1%) from 94.814%
20428555173

Pull #1436

github

web-flow
Merge 0ffb48a5d into 57a75b09e
Pull Request #1436: Draft: add `rdmo.config` app for `Plugin` model (plugin managament)

2191 of 2304 branches covered (95.1%)

23411 of 24723 relevant lines covered (94.69%)

3.79 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.62
rdmo/projects/viewsets.py
1
from django.conf import settings
4✔
2
from django.contrib.sites.shortcuts import get_current_site
4✔
3
from django.core.exceptions import ObjectDoesNotExist
4✔
4
from django.db.models import OuterRef, Prefetch, Q, Subquery
4✔
5
from django.db.models.functions import Coalesce, Greatest
4✔
6
from django.http import Http404, HttpResponseRedirect
4✔
7
from django.utils.translation import gettext_lazy as _
4✔
8

9
from rest_framework import serializers, status
4✔
10
from rest_framework.decorators import action
4✔
11
from rest_framework.exceptions import NotFound, ValidationError
4✔
12
from rest_framework.filters import SearchFilter
4✔
13
from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin
4✔
14
from rest_framework.pagination import PageNumberPagination
4✔
15
from rest_framework.permissions import IsAuthenticated
4✔
16
from rest_framework.response import Response
4✔
17
from rest_framework.reverse import reverse
4✔
18
from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelViewSet
4✔
19

20
from django_filters.rest_framework import DjangoFilterBackend
4✔
21
from rest_framework_extensions.mixins import NestedViewSetMixin
4✔
22

23
from rdmo.conditions.models import Condition
4✔
24
from rdmo.config.models import Plugin
4✔
25
from rdmo.core.permissions import HasModelPermission
4✔
26
from rdmo.core.utils import human2bytes, is_truthy, return_file_response
4✔
27
from rdmo.options.models import OptionSet
4✔
28
from rdmo.questions.models import Catalog, Page, Question, QuestionSet
4✔
29
from rdmo.tasks.models import Task
4✔
30
from rdmo.views.models import View
4✔
31

32
from .filters import (
4✔
33
    AttributeFilterBackend,
34
    OptionFilterBackend,
35
    ProjectDateFilterBackend,
36
    ProjectOrderingFilter,
37
    ProjectSearchFilterBackend,
38
    ProjectUserFilterBackend,
39
    SnapshotFilterBackend,
40
)
41
from .models import Continuation, Integration, Invite, Issue, Membership, Project, Snapshot, Value, Visibility
4✔
42
from .permissions import (
4✔
43
    HasProjectPagePermission,
44
    HasProjectPermission,
45
    HasProjectProgressModelPermission,
46
    HasProjectProgressObjectPermission,
47
    HasProjectsPermission,
48
    HasProjectVisibilityModelPermission,
49
    HasProjectVisibilityObjectPermission,
50
)
51
from .progress import (
4✔
52
    compute_navigation,
53
    compute_page,
54
    compute_progress,
55
)
56
from .serializers.v1 import (
4✔
57
    IntegrationSerializer,
58
    InviteSerializer,
59
    IssueSerializer,
60
    MembershipSerializer,
61
    ProjectCopySerializer,
62
    ProjectIntegrationSerializer,
63
    ProjectInviteSerializer,
64
    ProjectInviteUpdateSerializer,
65
    ProjectIssueSerializer,
66
    ProjectMembershipSerializer,
67
    ProjectMembershipUpdateSerializer,
68
    ProjectSerializer,
69
    ProjectSnapshotSerializer,
70
    ProjectValueSerializer,
71
    ProjectVisibilitySerializer,
72
    SnapshotSerializer,
73
    UserInviteSerializer,
74
    ValueSearchSerializer,
75
    ValueSerializer,
76
)
77
from .serializers.v1.overview import CatalogSerializer, ProjectOverviewSerializer
4✔
78
from .serializers.v1.page import PageSerializer
4✔
79
from .utils import (
4✔
80
    check_conditions,
81
    check_options,
82
    compute_set_prefix_from_set_value,
83
    copy_project,
84
    filter_tasks_or_views_for_project,
85
    get_contact_message,
86
    get_upload_accept,
87
    send_contact_message,
88
    send_invite_email,
89
)
90

91

92
class ProjectPagination(PageNumberPagination):
4✔
93
    page_size = settings.PROJECT_TABLE_PAGE_SIZE
4✔
94

95

96
class ProjectViewSet(ModelViewSet):
4✔
97
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
98
    serializer_class = ProjectSerializer
4✔
99
    pagination_class = ProjectPagination
4✔
100

101
    filter_backends = (
4✔
102
        DjangoFilterBackend,
103
        ProjectUserFilterBackend,
104
        ProjectDateFilterBackend,
105
        ProjectOrderingFilter,
106
        ProjectSearchFilterBackend,
107
    )
108
    filterset_fields = (
4✔
109
        'title',
110
        # user is part of ProjectUserFilterBackend
111
        'catalog',
112
        'catalog__uri'
113
    )
114
    ordering_fields = (
4✔
115
        'title',
116
        'progress',
117
        'role',
118
        'owner',
119
        'updated',
120
        'created',
121
        'last_changed'
122
    )
123

124
    filter_for_user = False  # flag for get_queryset to return only projects like for a regular user
4✔
125

126
    def get_queryset(self):
4✔
127
        queryset = Project.objects.filter_user(self.request.user, self.filter_for_user).distinct().prefetch_related(
4✔
128
            'snapshots',
129
            'views',
130
            Prefetch('memberships', queryset=Membership.objects.select_related('user'), to_attr='memberships_list')
131
        ).select_related('catalog', 'visibility')
132

133
        # prepare subquery for last_changed
134
        last_changed_subquery = Subquery(
4✔
135
            Value.objects.filter(project=OuterRef('pk')).order_by('-updated').values('updated')[:1]
136
        )
137
        # the 'updated' field from a Project always returns a valid DateTime value
138
        # when Greatest returns null, then Coalesce will return the value for 'updated' as a fall-back
139
        # when Greatest returns a value, then Coalesce will return this value
140
        queryset = queryset.annotate(last_changed=Coalesce(Greatest(last_changed_subquery, 'updated'), 'updated'))
4✔
141

142
        return queryset
4✔
143

144
    @action(detail=False, methods=['GET'], permission_classes=(HasModelPermission | HasProjectsPermission, ))
4✔
145
    def user(self, request, *args, **kwargs):
4✔
146
        self.filter_for_user = True
4✔
147
        return self.list(request, *args, **kwargs)
4✔
148

149
    @action(detail=True, methods=['POST'],
4✔
150
            permission_classes=(HasModelPermission | HasProjectPermission, ))
151
    def copy(self, request, pk=None):
4✔
152
        instance = self.get_object()
4✔
153
        serializer = ProjectCopySerializer(instance, data=request.data, context=self.get_serializer_context())
4✔
154
        serializer.is_valid(raise_exception=True)
4✔
155

156
        # update instance
157
        for key, value in serializer.validated_data.items():
4✔
158
            setattr(instance, key, value)
4✔
159

160
        site = get_current_site(self.request)
4✔
161
        owners = [self.request.user]
4✔
162
        project_copy = copy_project(instance, site, owners)
4✔
163

164
        serializer = self.get_serializer(project_copy)
4✔
165
        headers = self.get_success_headers(serializer.data)
4✔
166
        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
4✔
167

168
    @action(detail=True, permission_classes=(HasModelPermission | HasProjectPermission, ))
4✔
169
    def overview(self, request, pk=None):
4✔
170
        project = self.get_object()
4✔
171
        serializer = ProjectOverviewSerializer(project, context={'request': request})
4✔
172
        return Response(serializer.data)
4✔
173

174
    @action(detail=True, url_path=r'navigation(?:/(?P<section_id>\d+))?',
4✔
175
            permission_classes=(HasModelPermission | HasProjectPermission, ))
176
    def navigation(self, request, pk=None, section_id=None):
4✔
177
        project = self.get_object()
4✔
178
        project.catalog.prefetch_elements()
4✔
179

180
        # if a section is provided, check if it actually exists in the catalog
181
        if section_id is None:
4✔
182
            section = None
4✔
183
        else:
184
            try:
4✔
185
                section = project.catalog.sections.get(pk=section_id)
4✔
186
            except ObjectDoesNotExist as e:
×
187
                raise NotFound() from e
×
188

189
        # compute navigation from the answer tree
190
        navigation = compute_navigation(project, section)
4✔
191

192
        return Response(navigation)
4✔
193

194
    @action(detail=True, permission_classes=(HasModelPermission | HasProjectPermission, ))
4✔
195
    def resolve(self, request, pk=None):
4✔
196
        snapshot_id = request.GET.get('snapshot')
4✔
197
        set_prefix = request.GET.get('set_prefix')
4✔
198
        set_index = request.GET.get('set_index')
4✔
199

200
        values = self.get_object().values.filter(snapshot_id=snapshot_id).select_related('attribute', 'option')
4✔
201

202
        page_id = request.GET.get('page')
4✔
203
        if page_id:
4✔
204
            try:
×
205
                page = Page.objects.get(id=page_id)
×
206
                conditions = page.conditions.select_related('source', 'target_option')
×
207
                if check_conditions(conditions, values, set_prefix, set_index):
×
208
                    return Response({'result': True})
×
209
            except Page.DoesNotExist:
×
210
                pass
×
211

212
        questionset_id = request.GET.get('questionset')
4✔
213
        if questionset_id:
4✔
214
            try:
×
215
                questionset = QuestionSet.objects.get(id=questionset_id)
×
216
                conditions = questionset.conditions.select_related('source', 'target_option')
×
217
                if check_conditions(conditions, values, set_prefix, set_index):
×
218
                    return Response({'result': True})
×
219
            except QuestionSet.DoesNotExist:
×
220
                pass
×
221

222
        question_id = request.GET.get('question')
4✔
223
        if question_id:
4✔
224
            try:
×
225
                question = Question.objects.get(id=question_id)
×
226
                conditions = question.conditions.select_related('source', 'target_option')
×
227
                if check_conditions(conditions, values, set_prefix, set_index):
×
228
                    return Response({'result': True})
×
229
            except Question.DoesNotExist:
×
230
                pass
×
231

232
        optionset_id = request.GET.get('optionset')
4✔
233
        if optionset_id:
4✔
234
            try:
×
235
                optionset = OptionSet.objects.get(id=optionset_id)
×
236
                conditions = optionset.conditions.select_related('source', 'target_option')
×
237
                if check_conditions(conditions, values, set_prefix, set_index):
×
238
                    return Response({'result': True})
×
239
            except OptionSet.DoesNotExist:
×
240
                pass
×
241

242
        condition_id = request.GET.get('condition')
4✔
243
        if condition_id:
4✔
244
            try:
4✔
245
                condition = Condition.objects.select_related('source', 'target_option').get(id=condition_id)
4✔
246
                if check_conditions([condition], values, set_prefix, set_index):
4✔
247
                    return Response({'result': True})
4✔
248
            except Condition.DoesNotExist:
×
249
                pass
×
250

251
        return Response({'result': False})
4✔
252

253
    @action(detail=True, permission_classes=(HasModelPermission | HasProjectPermission, ))
4✔
254
    def options(self, request, pk=None):
4✔
255
        project = self.get_object()
4✔
256
        try:
4✔
257
            try:
4✔
258
                optionset_id = request.GET.get('optionset')
4✔
259
                optionset = OptionSet.objects.get(pk=optionset_id)
4✔
260
            except (ValueError, OptionSet.DoesNotExist) as e:
×
261
                raise NotFound() from e
×
262

263
            # check if the optionset belongs to this catalog and if it has a provider
264
            project.catalog.prefetch_elements()
4✔
265
            if (
4✔
266
                Question.objects.filter_by_catalog(project.catalog).filter(optionsets=optionset).exists()
267
                and optionset.has_plugins
268
            ):
269
                options = []
4✔
270
                for plugin in optionset.plugins.all():
4✔
271
                    provider = plugin.initialize_class()
4✔
272
                    if provider is None:
4✔
273
                        continue  # skip when plugin class initialization fails
×
274

275
                    for option in provider.get_options(project, search=request.GET.get('search'),
4✔
276
                                         user=request.user, site=request.site):
277
                        if 'id' not in option:
4✔
278
                            raise RuntimeError(f"'id' is missing in options of '{provider.class_name}'")
×
279
                        elif 'text' not in option:
4✔
280
                            raise RuntimeError(f"'text' is missing in options of '{provider.class_name}'")
×
281
                        if 'text_and_help' not in option:
4✔
282
                            if 'help' in option:
4✔
283
                                option['text_and_help'] = '{text} [{help}]'.format(**option)
4✔
284
                            else:
285
                                option['text_and_help'] = '{text}'.format(**option)
4✔
286
                        options.append(option)
4✔
287

288
                return Response(options)
4✔
289

290
        except OptionSet.DoesNotExist:
×
291
            pass
×
292

293
        # if it didn't work return 404
294
        raise NotFound()
×
295

296
    @action(detail=True, permission_classes=(HasModelPermission | HasProjectPermission, ))
4✔
297
    def answers(self, request, pk=None):
4✔
298
        project = self.get_object()
×
299
        project.catalog.prefetch_elements()
×
300
        return Response(project.get_answer_tree(verbose=request.GET.getlist('verbose')))
×
301

302
    @action(detail=True, methods=['get', 'post'],
4✔
303
            permission_classes=(HasProjectProgressModelPermission | HasProjectProgressObjectPermission, ))
304
    def progress(self, request, pk=None):
4✔
305
        project = self.get_object()
4✔
306

307
        if request.method == 'POST' or project.progress_count is None or project.progress_total is None:
4✔
308
            project.catalog.prefetch_elements()
4✔
309

310
            # compute the progress, but store it only, if it has changed
311
            progress_count, progress_total = compute_progress(project)
4✔
312
            if progress_count != project.progress_count or progress_total != project.progress_total:
4✔
313
                project.progress_count, project.progress_total = progress_count, progress_total
4✔
314
                project.save()
4✔
315

316
        try:
4✔
317
            ratio = project.progress_count / project.progress_total
4✔
318
        except ZeroDivisionError:
×
319
            ratio = 0
×
320

321
        return Response({
4✔
322
            'count': project.progress_count,
323
            'total': project.progress_total,
324
            'ratio': ratio
325
        })
326

327
    @action(detail=True, methods=['get', 'post', 'delete'],
4✔
328
            permission_classes=(HasProjectVisibilityModelPermission | HasProjectVisibilityObjectPermission, ))
329
    def visibility(self, request, pk=None):
4✔
330
        project = self.get_object()
4✔
331

332
        try:
4✔
333
            instance = project.visibility
4✔
334
        except Visibility.DoesNotExist:
4✔
335
            instance = None
4✔
336

337
        if request.method == 'POST':
4✔
338
            data = {'project': project.id}
4✔
339

340
            if settings.MULTISITE:
4✔
341
                if request.user.has_perm('projects.change_visibility'):
4✔
342
                    data['sites'] = request.data.getlist('sites', [])
4✔
343
                else:
344
                    data['sites'] = list({
4✔
345
                        *[site.id for site in instance.sites.all()],
346
                        get_current_site(self.request).id
347
                    })
348

349
            if settings.GROUPS:
4✔
350
                data['groups'] = request.data.getlist('groups', [])
4✔
351

352
            serializer = ProjectVisibilitySerializer(instance, data=data)
4✔
353
            serializer.is_valid(raise_exception=True)
4✔
354
            serializer.save()
4✔
355
            return Response(serializer.data)
4✔
356

357
        elif request.method == 'DELETE':
4✔
358
            if instance is not None:
4✔
359
                if settings.MULTISITE and not request.user.has_perm('projects.delete_visibility'):
4✔
360
                    instance.remove_site(get_current_site(self.request))
4✔
361
                else:
362
                    instance.delete()
4✔
363

364
                return Response(status=status.HTTP_204_NO_CONTENT)
4✔
365
        else:
366
            if instance is not None:
4✔
367
                serializer = ProjectVisibilitySerializer(instance)
4✔
368
                return Response(serializer.data)
4✔
369

370
        # if nothing worked, raise 404
371
        raise Http404
4✔
372

373
    @action(detail=True, methods=['get', 'post'],
4✔
374
            permission_classes=(HasModelPermission | HasProjectPermission, ))
375
    def contact(self, request, pk):
4✔
376
        if settings.PROJECT_CONTACT:
4✔
377
            project = self.get_object()
4✔
378
            if request.method == 'POST':
4✔
379
                subject = request.data.get('subject')
4✔
380
                message = request.data.get('message')
4✔
381

382
                if subject and message:
4✔
383
                    send_contact_message(request, subject, message)
4✔
384
                    return Response(status=status.HTTP_204_NO_CONTENT)
4✔
385
                else:
386
                    raise ValidationError({
4✔
387
                        'subject': [_('This field may not be blank.')] if not subject else [],
388
                        'message': [_('This field may not be blank.')] if not message else []
389
                    })
390
            else:
391
                project.catalog.prefetch_elements()
4✔
392
                return Response(get_contact_message(request, project))
4✔
393
        else:
394
            raise Http404
×
395

396
    @action(detail=False, url_path='upload-accept', permission_classes=(IsAuthenticated, ))
4✔
397
    def upload_accept(self, request):
4✔
398
        return Response(get_upload_accept())
4✔
399

400
    @action(detail=False, permission_classes=(IsAuthenticated, ))
4✔
401
    def imports(self, request):
4✔
402
        plugins = Plugin.objects.for_context(plugin_type="project_import", user=request.user)
4✔
403
        return Response([{
4✔
404
            "key": plugin.url_name,  # url_name or uri_path?
405
            "label": plugin.title,
406
            "class_name": plugin.python_path,
407
            "href": reverse('project_create_import', args=[plugin.url_name]),
408
        } for plugin in plugins])
409

410
    def perform_create(self, serializer):
4✔
411
        project = serializer.save(site=get_current_site(self.request))
4✔
412

413
        # add current user as owner
414
        membership = Membership(project=project, user=self.request.user, role='owner')
4✔
415
        membership.save()
4✔
416

417

418
        # add all tasks to project
419
        if self.request.data.get('tasks') is None:
4✔
420
            if not settings.PROJECT_TASKS_SYNC:
4✔
421
                for task in filter_tasks_or_views_for_project(Task, project).filter_availability(self.request.user):
4✔
422
                    project.tasks.add(task)
4✔
423

424
        if self.request.data.get('views') is None:
4✔
425
            # add all views to project
426
            if not settings.PROJECT_VIEWS_SYNC:
4✔
427
                for view in filter_tasks_or_views_for_project(View, project).filter_availability(self.request.user):
4✔
428
                    project.views.add(view)
4✔
429

430

431
class ProjectNestedViewSetMixin(NestedViewSetMixin):
4✔
432

433
    def initial(self, request, *args, **kwargs):
4✔
434
        self.project = self.get_project_from_parent_viewset()
4✔
435
        super().initial(request, *args, **kwargs)
4✔
436

437
    def get_project_from_parent_viewset(self):
4✔
438
        try:
4✔
439
            return Project.objects.filter_user(self.request.user).get(pk=self.get_parents_query_dict().get('project'))
4✔
440
        except Project.DoesNotExist as e:
4✔
441
            raise Http404 from e
4✔
442

443
    def perform_create(self, serializer):
4✔
444
        # this call provides the nested serializers with the project
445
        serializer.save(project=self.project)
4✔
446

447

448
class ProjectMembershipViewSet(ProjectNestedViewSetMixin, ModelViewSet):
4✔
449
    permission_classes = (HasModelPermission | HasProjectPermission, )
4✔
450

451
    filter_backends = (DjangoFilterBackend, )
4✔
452
    filterset_fields = (
4✔
453
        'user',
454
        'user__username',
455
        'role'
456
    )
457

458
    def get_queryset(self):
4✔
459
        return Membership.objects.filter(project=self.project)
4✔
460

461
    def get_serializer_class(self):
4✔
462
        if self.action == 'update':
4✔
463
            return ProjectMembershipUpdateSerializer
4✔
464
        else:
465
            return ProjectMembershipSerializer
4✔
466

467

468
class ProjectIntegrationViewSet(ProjectNestedViewSetMixin, ModelViewSet):
4✔
469
    permission_classes = (HasModelPermission | HasProjectPermission, )
4✔
470
    serializer_class = ProjectIntegrationSerializer
4✔
471

472
    filter_backends = (DjangoFilterBackend, )
4✔
473
    filterset_fields = (
4✔
474
        'provider_key',
475
    )
476

477
    def get_queryset(self):
4✔
478
        return Integration.objects.filter(project=self.project)
4✔
479

480

481
class ProjectInviteViewSet(ProjectNestedViewSetMixin, ModelViewSet):
4✔
482
    permission_classes = (HasModelPermission | HasProjectPermission, )
4✔
483

484
    filter_backends = (DjangoFilterBackend, )
4✔
485
    filterset_fields = (
4✔
486
        'user',
487
        'user__username',
488
        'email',
489
        'role'
490
    )
491

492
    def get_queryset(self):
4✔
493
        return Invite.objects.filter(project=self.project)
4✔
494

495
    def get_serializer_class(self):
4✔
496
        if self.action == 'update':
4✔
497
            return ProjectInviteUpdateSerializer
4✔
498
        else:
499
            return ProjectInviteSerializer
4✔
500

501
    def perform_create(self, serializer):
4✔
502
        super().perform_create(serializer)
4✔
503
        if settings.PROJECT_SEND_INVITE:
4✔
504
            send_invite_email(self.request, serializer.instance)
4✔
505

506

507
class ProjectIssueViewSet(ProjectNestedViewSetMixin, ListModelMixin, RetrieveModelMixin,
4✔
508
                          UpdateModelMixin, GenericViewSet):
509
    permission_classes = (HasModelPermission | HasProjectPermission, )
4✔
510
    serializer_class = ProjectIssueSerializer
4✔
511

512
    filter_backends = (DjangoFilterBackend, )
4✔
513
    filterset_fields = (
4✔
514
        'task',
515
        'task__uri',
516
        'status'
517
    )
518

519
    def get_queryset(self):
4✔
520
        return Issue.objects.filter(project=self.project).prefetch_related('resources')
4✔
521

522

523
class ProjectSnapshotViewSet(ProjectNestedViewSetMixin, CreateModelMixin, RetrieveModelMixin,
4✔
524
                             UpdateModelMixin, ListModelMixin, GenericViewSet):
525
    permission_classes = (HasModelPermission | HasProjectPermission, )
4✔
526
    serializer_class = ProjectSnapshotSerializer
4✔
527

528
    def get_queryset(self):
4✔
529
        return self.project.snapshots.all()
4✔
530

531

532
class ProjectValueViewSet(ProjectNestedViewSetMixin, ModelViewSet):
4✔
533
    permission_classes = (HasModelPermission | HasProjectPermission, )
4✔
534
    serializer_class = ProjectValueSerializer
4✔
535

536
    filter_backends = (AttributeFilterBackend, DjangoFilterBackend)
4✔
537
    filterset_fields = (
4✔
538
        # attribute is part of AttributeFilterBackend
539
        'attribute__uri',
540
        'option',
541
        'option__uri',
542
    )
543

544
    def get_queryset(self):
4✔
545
        return self.project.values.filter(snapshot=None).select_related('attribute', 'option')
4✔
546

547
    @action(detail=False, methods=['POST'], url_path='set',
4✔
548
            permission_classes=(HasModelPermission | HasProjectPermission, ))
549
    def copy_set(self, request, parent_lookup_project, pk=None):
4✔
550
        # copy all values for questions in questionset collections with the attribute
551
        # for this value and the same set_prefix and set_index
552

553
        # obtain the id of the set value for the set we want to copy
554
        try:
4✔
555
            copy_value_id = int(request.data.pop('copy_set_value'))
4✔
556
        except KeyError as e:
4✔
557
            raise ValidationError({
4✔
558
                'copy_set_value': [_('This field may not be blank.')]
559
            }) from e
560
        except ValueError as e:
4✔
561
            raise NotFound from e
4✔
562

563
        # look for this value in the database, using the users permissions, and
564
        # collect all values for this set and all descendants
565
        try:
4✔
566
            copy_value = Value.objects.filter_user(self.request.user).get(id=copy_value_id)
4✔
567
            copy_values = Value.objects.filter_user(self.request.user).filter_set(copy_value)
4✔
568
        except Value.DoesNotExist as e:
4✔
569
            raise NotFound from e
4✔
570

571
        # init list of values to return
572
        response_values = []
4✔
573

574
        set_value_id = request.data.get('id')
4✔
575
        if set_value_id:
4✔
576
            # if an id is given in the post request, this is an import
577
            try:
4✔
578
                # look for the set value for the set we want to import into
579
                set_value = Value.objects.filter_user(self.request.user).get(id=set_value_id)
4✔
580

581
                # collect all non-empty values for this set and all descendants and convert
582
                # them to a list to compare them later to the new values
583
                set_values = Value.objects.filter_user(self.request.user).filter_set(set_value)
4✔
584
                set_values_list = set_values.exclude_empty().values_list('attribute', 'set_prefix', 'set_index')
4✔
585
                set_empty_values_list = set_values.filter_empty().values_list(
4✔
586
                    'attribute', 'set_prefix', 'set_index', 'collection_index'
587
                )
588
            except Value.DoesNotExist as e:
×
589
                raise NotFound from e
×
590
        else:
591
            # otherwise, we want to create a new set and need to create a new set value
592
            # de-serialize the posted new set value and save it, use the ValueSerializer
593
            # instead of ProjectValueSerializer, since the latter does not include project
594
            set_value_serializer = ValueSerializer(data={
4✔
595
                'project': parent_lookup_project,
596
                **request.data
597
            })
598
            set_value_serializer.is_valid(raise_exception=True)
4✔
599
            set_value = set_value_serializer.save()
4✔
600

601
            set_values = Value.objects.none()
4✔
602
            set_values_list = set_empty_values_list = []
4✔
603

604
            # add the new set value to response_values
605
            response_values.append(set_value_serializer.data)
4✔
606

607
        # create new values for the new set
608
        new_values = []
4✔
609
        updated_values = []
4✔
610
        for value in copy_values:
4✔
611
            value.id = None
4✔
612
            value.project = set_value.project
4✔
613
            value.snapshot = None
4✔
614
            if value.set_prefix == set_value.set_prefix:
4✔
615
                value.set_index = set_value.set_index
4✔
616
            else:
617
                value.set_prefix = compute_set_prefix_from_set_value(set_value, value)
4✔
618

619
            # skip this value if value.option does not match the optionsets of it's question
620
            if not check_options(self.project, value):
4✔
621
                continue
4✔
622

623
            # check if the value already exists, we do not consider collection_index
624
            # since we do not want to import e.g. into partially filled checkboxes
625
            if (value.attribute_id, value.set_prefix, value.set_index) in set_values_list:
4✔
626
                # do not overwrite existing values
627
                pass
×
628
            elif (value.attribute_id, value.set_prefix,
4✔
629
                  value.set_index, value.collection_index) in set_empty_values_list:
630
                # update empty values
631
                updated_value = set_values.get(attribute_id=value.attribute_id, set_prefix=value.set_prefix,
×
632
                                               set_index=value.set_index, collection_index=value.collection_index)
633
                updated_value.text = value.text
×
634
                updated_value.option = value.option
×
635
                updated_value.external_id = value.external_id
×
636
                updated_value.save()
×
637

638
                updated_values.append(updated_value)
×
639
            else:
640
                new_values.append(value)
4✔
641

642
        # bulk create the new values
643
        created_values = Value.objects.bulk_create(new_values)
4✔
644
        response_values += [ValueSerializer(instance=value).data for value in created_values]
4✔
645
        response_values += [ValueSerializer(instance=value).data for value in updated_values]
4✔
646

647
        # return all new values
648
        return Response(response_values, status=status.HTTP_201_CREATED)
4✔
649

650
    @action(detail=True, methods=['DELETE'], url_path='set',
4✔
651
            permission_classes=(HasModelPermission | HasProjectPermission, ))
652
    def delete_set(self, request, parent_lookup_project, pk=None):
4✔
653
        # delete all values for questions in questionset collections with the attribute
654
        # for this value and the same set_prefix and set_index
655
        set_value = self.get_object()
4✔
656
        set_value.delete()
4✔
657

658
        # collect all values for this set and all descendants and delete them
659
        values = self.get_queryset().filter_set(set_value)
4✔
660
        values.delete()
4✔
661

662
        return Response(status=status.HTTP_204_NO_CONTENT)
4✔
663

664
    @action(detail=True, methods=['GET', 'POST'],
4✔
665
            permission_classes=(HasModelPermission | HasProjectPermission, ))
666
    def file(self, request, parent_lookup_project, pk=None):
4✔
667
        value = self.get_object()
4✔
668

669
        if request.method == 'POST':
4✔
670
            value.file = request.FILES.get('file')
4✔
671

672
            # check if the project is reached
673
            if value.file and value.file.size + value.project.file_size > human2bytes(settings.PROJECT_FILE_QUOTA):
4✔
674
                raise serializers.ValidationError({
×
675
                    'value': [_('You reached the file quota for this project.')]
676
                })
677

678
            value.save()
4✔
679
            serializer = self.get_serializer(value)
4✔
680
            return Response(serializer.data)
4✔
681

682
        else:
683
            if value.file:
4✔
684
                return return_file_response(value.file.name, value.file_type)
4✔
685

686
        # if it didn't work return 404
687
        raise NotFound()
4✔
688

689

690
class ProjectPageViewSet(ProjectNestedViewSetMixin, RetrieveModelMixin, GenericViewSet):
4✔
691
    permission_classes = (HasModelPermission | HasProjectPagePermission, )
4✔
692
    serializer_class = PageSerializer
4✔
693

694
    def get_queryset(self):
4✔
695
        self.project.catalog.prefetch_elements()
4✔
696
        page = Page.objects.filter_by_catalog(self.project.catalog).prefetch_related(
4✔
697
            *Page.prefetch_lookups,
698
            'page_questions__question__optionsets__optionset_options__option',
699
            'page_questionsets__questionset__questionset_questions__question__optionsets__optionset_options__option',
700
        )
701
        return page
4✔
702

703
    def get_serializer_context(self):
4✔
704
        context = super().get_serializer_context()
4✔
705
        context['catalog'] = self.project.catalog
4✔
706
        return context
4✔
707

708
    def dispatch(self, *args, **kwargs):
4✔
709
        response = super().dispatch(*args, **kwargs)
4✔
710
        if response.status_code == 200 and kwargs.get('pk'):
4✔
711
            try:
4✔
712
                continuation = Continuation.objects.get(project=self.project, user=self.request.user)
4✔
713
            except Continuation.DoesNotExist:
4✔
714
                continuation = Continuation(project=self.project, user=self.request.user)
4✔
715

716
            continuation.page_id = kwargs.get('pk')
4✔
717
            continuation.save()
4✔
718

719
        return response
4✔
720

721
    def retrieve(self, request, *args, **kwargs):
4✔
722
        page = self.get_object()
4✔
723
        direction = 'prev' if is_truthy(request.GET.get('back')) else 'next'
4✔
724
        computed_page_id = compute_page(self.project, page, direction)
4✔
725

726
        if computed_page_id == page.id:
4✔
727
            serializer = self.get_serializer(page)
4✔
728
            return Response(serializer.data)
4✔
729
        elif computed_page_id is not None:
4✔
730
            url = reverse('v1-projects:project-page-detail', args=[self.project.id, computed_page_id])
4✔
731
            return HttpResponseRedirect(url, status=303)
4✔
732
        else:
733
            # if no page was found, we are probably at the end of the catalog
734
            return Response({
×
735
                'detail': 'No Page matches the given query.',
736
                'done': True
737
            }, status=status.HTTP_404_NOT_FOUND)
738

739

740
    @action(detail=False, url_path='continue', permission_classes=(HasModelPermission | HasProjectPagePermission, ))
4✔
741
    def get_continue(self, request, pk=None, parent_lookup_project=None):
4✔
742
        if not self.project.catalog.pages:
4✔
743
            return Response({
4✔
744
                'detail': 'No Page matches the given query.',
745
                'done': True
746
            }, status=status.HTTP_404_NOT_FOUND)
747

748
        try:
4✔
749
            continuation = Continuation.objects.get(project=self.project, user=self.request.user)
4✔
750

751
            try:
4✔
752
                page = Page.objects.filter_by_catalog(self.project.catalog).get(id=continuation.page_id)
4✔
753
            except Page.DoesNotExist:
×
754
                page = self.project.catalog.pages[0]
×
755

756
        except Continuation.DoesNotExist:
4✔
757
            page = self.project.catalog.pages[0]
4✔
758

759
        serializer = self.get_serializer(page)
4✔
760
        return Response(serializer.data)
4✔
761

762

763
class MembershipViewSet(ReadOnlyModelViewSet):
4✔
764
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
765
    serializer_class = MembershipSerializer
4✔
766

767
    filter_backends = (DjangoFilterBackend,)
4✔
768
    filterset_fields = (
4✔
769
        'user',
770
        'user__username',
771
        'role'
772
    )
773

774
    def get_queryset(self):
4✔
775
        return Membership.objects.filter_user(self.request.user)
4✔
776

777

778
class IntegrationViewSet(ReadOnlyModelViewSet):
4✔
779
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
780
    serializer_class = IntegrationSerializer
4✔
781

782
    filter_backends = (DjangoFilterBackend, )
4✔
783
    filterset_fields = (
4✔
784
        'project',
785
        'provider_key'
786
    )
787

788
    def get_queryset(self):
4✔
789
        return Integration.objects.filter_user(self.request.user)
4✔
790

791

792
class InviteViewSet(ReadOnlyModelViewSet):
4✔
793
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
794
    serializer_class = InviteSerializer
4✔
795

796
    filter_backends = (DjangoFilterBackend, )
4✔
797
    filterset_fields = (
4✔
798
        'user',
799
        'user__username',
800
        'email',
801
        'role'
802
    )
803

804
    def get_queryset(self):
4✔
805
        return Invite.objects.filter_user(self.request.user)
4✔
806

807
    def get_detail_permission_object(self, obj):
4✔
808
        return obj.project
×
809

810
    @action(detail=False, permission_classes=(IsAuthenticated, ))
4✔
811
    def user(self, request):
4✔
812
        invites = Invite.objects.filter(user=self.request.user)
4✔
813
        serializer = UserInviteSerializer(invites, many=True)
4✔
814
        return Response(serializer.data)
4✔
815

816
class IssueViewSet(ReadOnlyModelViewSet):
4✔
817
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
818
    serializer_class = IssueSerializer
4✔
819

820
    filter_backends = (DjangoFilterBackend, )
4✔
821
    filterset_fields = (
4✔
822
        'task',
823
        'task__uri',
824
        'status'
825
    )
826

827
    def get_queryset(self):
4✔
828
        return Issue.objects.filter_user(self.request.user).prefetch_related('resources')
4✔
829

830

831
class SnapshotViewSet(ReadOnlyModelViewSet):
4✔
832
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
833
    serializer_class = SnapshotSerializer
4✔
834

835
    filter_backends = (DjangoFilterBackend,)
4✔
836
    filterset_fields = (
4✔
837
        'title',
838
        'project'
839
    )
840

841
    def get_queryset(self):
4✔
842
        return Snapshot.objects.filter_user(self.request.user)
4✔
843

844

845
class ValueViewSet(ReadOnlyModelViewSet):
4✔
846
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
847
    serializer_class = ValueSerializer
4✔
848

849
    filter_backends = (
4✔
850
        AttributeFilterBackend,
851
        SnapshotFilterBackend,
852
        OptionFilterBackend,
853
        DjangoFilterBackend,
854
        SearchFilter
855
    )
856
    filterset_fields = (
4✔
857
        'project',
858
        # snapshot is part of SnapshotFilterBackend
859
        # attribute is part of AttributeFilterBackend
860
        'attribute__uri',
861
        'attribute__path',
862
        # option is part of OptionFilterBackend
863
        'option__uri',
864
        'option__uri_path',
865
    )
866

867
    search_fields = ['text', 'project__title', 'snapshot__title']
4✔
868

869
    def get_queryset(self):
4✔
870
        return Value.objects.filter_user(self.request.user).select_related('attribute', 'option')
4✔
871

872
    @action(detail=False, permission_classes=(HasModelPermission | HasProjectsPermission, ))
4✔
873
    def search(self, request):
4✔
874
        queryset = self.filter_queryset(self.get_queryset()).exclude_empty().select_related('project', 'snapshot')
4✔
875

876
        # add a subquery to get the label for the set the value is part in
877
        try:
4✔
878
            set_attribute = int(request.GET.get('set_attribute'))
4✔
879
            set_label_subquery = Subquery(
×
880
                Value.objects.filter(attribute=set_attribute, set_prefix='', set_index=OuterRef('set_index'))
881
                             .values('text')[:1]
882
            )
883
            queryset = queryset.annotate(set_label=set_label_subquery)
×
884
        except (ValueError, TypeError):
4✔
885
            pass
4✔
886

887
        if is_truthy(request.GET.get('collection')):
4✔
888
            # if collection is set (for checkboxes), we first select each distinct set and create a Q object with it
889
            # by doing so we can select an undetermined number of values which belong to an exact number of sets
890
            # given by settings.PROJECT_VALUES_SEARCH_LIMIT
891
            #
892
            # DISTINCT ON is not available on sqlite so we just apply the limit like for all other questions, this
893
            # will display the last set of checked values incomplete
894
            fields = ('project_id', 'snapshot_id', 'attribute_id', 'set_prefix', 'set_index')
4✔
895
            values_list = (
4✔
896
                queryset
897
                    .values(*fields)
898
                    .order_by(*fields)
899
                    .distinct()
900
                    [:settings.PROJECT_VALUES_SEARCH_LIMIT]
901
            )
902

903
            q = Q()
4✔
904
            for values_dict in values_list:
4✔
905
                q |= Q(**values_dict)
×
906

907
            queryset = queryset.filter(q).order_by(*Value._meta.ordering)
4✔
908
        else:
909
            queryset = queryset.order_by(*Value._meta.ordering)[:settings.PROJECT_VALUES_SEARCH_LIMIT]
4✔
910

911
        serializer = ValueSearchSerializer(queryset, many=True)
4✔
912
        return Response(serializer.data)
4✔
913

914
    @action(detail=True, permission_classes=(HasModelPermission | HasProjectsPermission, ))
4✔
915
    def file(self, request, pk=None):
4✔
916
        value = self.get_object()
4✔
917

918
        if value.file:
4✔
919
            return return_file_response(value.file.name, value.file_type)
4✔
920

921
        # if it didn't work return 404
922
        raise NotFound()
4✔
923

924

925
class CatalogViewSet(ListModelMixin, GenericViewSet):
4✔
926
    permission_classes = (IsAuthenticated, )
4✔
927

928
    serializer_class = CatalogSerializer
4✔
929

930
    def get_queryset(self):
4✔
931
        queryset = (
4✔
932
            Catalog.objects.filter_current_site().filter_group(self.request.user)
933
        )
934
        availability_subquery = Subquery(
4✔
935
            queryset.filter_availability(self.request.user).values('pk')
936
        )
937
        return (
4✔
938
            queryset.filter(Q(pk__in=availability_subquery) | Q(projects__user=self.request.user))
939
            .order_by('-available', 'order', 'id').distinct()
940
        )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc