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

rdmorganiser / rdmo / 25669587073

11 May 2026 12:17PM UTC coverage: 94.913% (+0.01%) from 94.901%
25669587073

Pull #1606

github

web-flow
Merge 6f331701a into e77e2ca30
Pull Request #1606: RDMO 2.4.5 🦇

2154 of 2255 branches covered (95.52%)

22951 of 24181 relevant lines covered (94.91%)

3.8 hits per line

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

88.29
rdmo/projects/viewsets.py
1
from collections import defaultdict
4✔
2

3
from django.conf import settings
4✔
4
from django.contrib.sites.shortcuts import get_current_site
4✔
5
from django.core.exceptions import ObjectDoesNotExist
4✔
6
from django.db.models import OuterRef, Prefetch, Q, Subquery
4✔
7
from django.db.models.functions import Coalesce, Greatest
4✔
8
from django.http import Http404, HttpResponseRedirect
4✔
9
from django.utils.translation import gettext_lazy as _
4✔
10

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

22
from django_filters.rest_framework import DjangoFilterBackend
4✔
23
from rest_framework_extensions.mixins import NestedViewSetMixin
4✔
24

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

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

94

95
class ProjectPagination(PageNumberPagination):
4✔
96
    page_size = settings.PROJECT_TABLE_PAGE_SIZE
4✔
97

98

99
class ProjectViewSet(ModelViewSet):
4✔
100
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
101
    serializer_class = ProjectSerializer
4✔
102
    pagination_class = ProjectPagination
4✔
103

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

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

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

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

145
        return queryset
4✔
146

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

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

159
        # update instance
160
        for key, value in serializer.validated_data.items():
4✔
161
            setattr(instance, key, value)
4✔
162

163
        site = get_current_site(self.request)
4✔
164
        owners = [self.request.user]
4✔
165
        project_copy = copy_project(instance, site, owners)
4✔
166

167
        serializer = self.get_serializer(project_copy)
4✔
168
        headers = self.get_success_headers(serializer.data)
4✔
169
        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
4✔
170

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

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

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

192
        # compute navigation from the answer tree
193
        navigation = compute_navigation(project, section)
4✔
194

195
        return Response(navigation)
4✔
196

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

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

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

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

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

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

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

254
        return Response({'result': False})
4✔
255

256
    @resolve.mapping.post
4✔
257
    def resolve_post(self, request, pk=None):
4✔
258
        project = self.get_object()
4✔
259

260
        serializer = ProjectResolveSerializer(data=request.data, many=True)
4✔
261
        serializer.is_valid(raise_exception=True)
4✔
262
        validated_data = serializer.validated_data
4✔
263

264
        # gather element_ids
265
        element_ids = defaultdict(set)
4✔
266
        for params in validated_data:
4✔
267
            element_ids[params['element_type']].add(params['element_id'])
4✔
268

269
        elements = defaultdict(lambda: defaultdict(set))
4✔
270

271
        # gather conditions for pages
272
        if 'pages' in element_ids:
4✔
273
            queryset = Page.conditions.through.objects.filter(page_id__in=element_ids['pages'])
×
274
            for page_id, condition_id in queryset.values_list('page_id', 'condition_id'):
×
275
                elements['pages'][page_id].add(condition_id)
×
276

277
        # gather conditions for questionsets
278
        if 'questionsets' in element_ids:
4✔
279
            queryset = QuestionSet.conditions.through.objects.filter(questionset_id__in=element_ids['questionsets'])
4✔
280
            for questionset_id, condition_id in queryset.values_list('questionset_id', 'condition_id'):
4✔
281
                elements['questionsets'][questionset_id].add(condition_id)
4✔
282

283
        # gather conditions for questions
284
        if 'questions' in element_ids:
4✔
285
            queryset = Question.conditions.through.objects.filter(question_id__in=element_ids['questions'])
4✔
286
            for question_id, condition_id in queryset.values_list('question_id', 'condition_id'):
4✔
287
                elements['questions'][question_id].add(condition_id)
4✔
288

289
        # gather conditions for optionsets
290
        if 'optionsets' in element_ids:
4✔
291
            queryset = OptionSet.conditions.through.objects.filter(optionset_id__in=element_ids['optionsets'])
4✔
292
            for optionset_id, condition_id in queryset.values_list('optionset_id', 'condition_id'):
4✔
293
                elements['optionsets'][optionset_id].add(condition_id)
4✔
294

295
        # gather conditions
296
        if 'conditions' in element_ids:
4✔
297
            # construct a similar structure for conditions
298
            for condition_id in element_ids['conditions']:
4✔
299
                elements['conditions'][condition_id] = {condition_id}
4✔
300

301
        # query conditions
302
        condition_ids = set().union(*(
4✔
303
            conditions_set
304
            for element_dict in elements.values()
305
            for conditions_set in element_dict.values()
306
        ))
307
        conditions = Condition.objects.select_related('source', 'target_option').in_bulk(condition_ids)
4✔
308

309
        # get all values of the project
310
        values = project.values.filter(snapshot=None).select_related('attribute', 'option')
4✔
311

312
        # second pass: resolve conditions
313
        for params in validated_data:
4✔
314
            set_prefix = params['set_prefix']
4✔
315
            set_index = params['set_index']
4✔
316
            element_type = params['element_type']
4✔
317
            element_id = params['element_id']
4✔
318

319
            element_conditions = [conditions[condition_id] for condition_id in elements[element_type][element_id]]
4✔
320
            params['result'] = check_conditions(element_conditions, values, set_prefix, set_index)
4✔
321

322
        return Response(validated_data)
4✔
323

324
    @action(detail=True, permission_classes=(HasModelPermission | HasProjectPermission, ))
4✔
325
    def options(self, request, pk=None):
4✔
326
        project = self.get_object()
4✔
327
        try:
4✔
328
            try:
4✔
329
                optionset_id = request.GET.get('optionset')
4✔
330
                optionset = OptionSet.objects.get(pk=optionset_id)
4✔
331
            except (ValueError, OptionSet.DoesNotExist) as e:
×
332
                raise NotFound() from e
×
333

334
            # check if the optionset belongs to this catalog and if it has a provider
335
            project.catalog.prefetch_elements()
4✔
336
            if Question.objects.filter_by_catalog(project.catalog).filter(optionsets=optionset) and \
4✔
337
                    optionset.provider is not None:
338
                options = []
4✔
339
                for option in optionset.provider.get_options(project, search=request.GET.get('search'),
4✔
340
                                                             user=request.user, site=request.site):
341
                    if 'id' not in option:
4✔
342
                        raise RuntimeError(f"'id' is missing in options of '{optionset.provider.class_name}'")
×
343
                    elif 'text' not in option:
4✔
344
                        raise RuntimeError(f"'text' is missing in options of '{optionset.provider.class_name}'")
×
345
                    if 'text_and_help' not in option:
4✔
346
                        if 'help' in option:
4✔
347
                            option['text_and_help'] = '{text} [{help}]'.format(**option)
4✔
348
                        else:
349
                            option['text_and_help'] = '{text}'.format(**option)
4✔
350
                    options.append(option)
4✔
351

352
                return Response(options)
4✔
353

354
        except OptionSet.DoesNotExist:
×
355
            pass
×
356

357
        # if it didn't work return 404
358
        raise NotFound()
×
359

360
    @action(detail=True, permission_classes=(HasModelPermission | HasProjectPermission, ))
4✔
361
    def answers(self, request, pk=None):
4✔
362
        project = self.get_object()
×
363
        project.catalog.prefetch_elements()
×
364
        return Response(project.get_answer_tree(verbose=request.GET.getlist('verbose')))
×
365

366
    @action(detail=True, methods=['get', 'post'],
4✔
367
            permission_classes=(HasProjectProgressModelPermission | HasProjectProgressObjectPermission, ))
368
    def progress(self, request, pk=None):
4✔
369
        project = self.get_object()
4✔
370

371
        if request.method == 'POST' or project.progress_count is None or project.progress_total is None:
4✔
372
            project.catalog.prefetch_elements()
4✔
373

374
            # compute the progress, but store it only, if it has changed
375
            progress_count, progress_total = compute_progress(project)
4✔
376
            if progress_count != project.progress_count or progress_total != project.progress_total:
4✔
377
                project.progress_count, project.progress_total = progress_count, progress_total
4✔
378
                project.save()
4✔
379

380
        try:
4✔
381
            ratio = project.progress_count / project.progress_total
4✔
382
        except ZeroDivisionError:
×
383
            ratio = 0
×
384

385
        return Response({
4✔
386
            'count': project.progress_count,
387
            'total': project.progress_total,
388
            'ratio': ratio
389
        })
390

391
    @action(detail=True, methods=['get', 'post', 'delete'],
4✔
392
            permission_classes=(HasProjectVisibilityModelPermission | HasProjectVisibilityObjectPermission, ))
393
    def visibility(self, request, pk=None):
4✔
394
        project = self.get_object()
4✔
395

396
        try:
4✔
397
            instance = project.visibility
4✔
398
        except Visibility.DoesNotExist:
4✔
399
            instance = None
4✔
400

401
        if request.method == 'POST':
4✔
402
            data = {'project': project.id}
4✔
403

404
            if settings.MULTISITE:
4✔
405
                if request.user.has_perm('projects.change_visibility'):
4✔
406
                    data['sites'] = request.data.getlist('sites', [])
4✔
407
                else:
408
                    data['sites'] = list({
4✔
409
                        *[site.id for site in instance.sites.all()],
410
                        get_current_site(self.request).id
411
                    })
412

413
            if settings.GROUPS:
4✔
414
                data['groups'] = request.data.getlist('groups', [])
4✔
415

416
            serializer = ProjectVisibilitySerializer(instance, data=data)
4✔
417
            serializer.is_valid(raise_exception=True)
4✔
418
            serializer.save()
4✔
419
            return Response(serializer.data)
4✔
420

421
        elif request.method == 'DELETE':
4✔
422
            if instance is not None:
4✔
423
                if settings.MULTISITE and not request.user.has_perm('projects.delete_visibility'):
4✔
424
                    instance.remove_site(get_current_site(self.request))
4✔
425
                else:
426
                    instance.delete()
4✔
427

428
                return Response(status=status.HTTP_204_NO_CONTENT)
4✔
429
        else:
430
            if instance is not None:
4✔
431
                serializer = ProjectVisibilitySerializer(instance)
4✔
432
                return Response(serializer.data)
4✔
433

434
        # if nothing worked, raise 404
435
        raise Http404
4✔
436

437
    @action(detail=True, methods=['get', 'post'],
4✔
438
            permission_classes=(HasModelPermission | HasProjectPermission, ))
439
    def contact(self, request, pk):
4✔
440
        if settings.PROJECT_CONTACT:
4✔
441
            project = self.get_object()
4✔
442
            if request.method == 'POST':
4✔
443
                subject = request.data.get('subject')
4✔
444
                message = request.data.get('message')
4✔
445

446
                if subject and message:
4✔
447
                    send_contact_message(request, subject, message)
4✔
448
                    return Response(status=status.HTTP_204_NO_CONTENT)
4✔
449
                else:
450
                    raise ValidationError({
4✔
451
                        'subject': [_('This field may not be blank.')] if not subject else [],
452
                        'message': [_('This field may not be blank.')] if not message else []
453
                    })
454
            else:
455
                project.catalog.prefetch_elements()
4✔
456
                return Response(get_contact_message(request, project))
4✔
457
        else:
458
            raise Http404
×
459

460
    @action(detail=False, url_path='upload-accept', permission_classes=(IsAuthenticated, ))
4✔
461
    def upload_accept(self, request):
4✔
462
        return Response(get_upload_accept())
4✔
463

464
    @action(detail=False, permission_classes=(IsAuthenticated, ))
4✔
465
    def imports(self, request):
4✔
466
        return Response([{
4✔
467
            'key': key,
468
            'label': label,
469
            'class_name': class_name,
470
            'href': reverse('project_create_import', args=[key])
471
        } for key, label, class_name in settings.PROJECT_IMPORTS if key in settings.PROJECT_IMPORTS_LIST] )
472

473
    def perform_create(self, serializer):
4✔
474
        project = serializer.save(site=get_current_site(self.request))
4✔
475

476
        # add current user as owner
477
        membership = Membership(project=project, user=self.request.user, role='owner')
4✔
478
        membership.save()
4✔
479

480
        # add all tasks to project
481
        if self.request.data.get('tasks') is None:
4✔
482
            if not settings.PROJECT_TASKS_SYNC:
4✔
483
                for task in filter_tasks_or_views_for_project(Task, project):
4✔
484
                    project.tasks.add(task)
4✔
485

486
        if self.request.data.get('views') is None:
4✔
487
            # add all views to project
488
            if not settings.PROJECT_VIEWS_SYNC:
4✔
489
                for view in filter_tasks_or_views_for_project(View, project):
4✔
490
                    project.views.add(view)
4✔
491

492

493
class ProjectNestedViewSetMixin(NestedViewSetMixin):
4✔
494

495
    def initial(self, request, *args, **kwargs):
4✔
496
        self.project = self.get_project_from_parent_viewset()
4✔
497
        super().initial(request, *args, **kwargs)
4✔
498

499
    def get_project_from_parent_viewset(self):
4✔
500
        try:
4✔
501
            return Project.objects.filter_user(self.request.user).get(pk=self.get_parents_query_dict().get('project'))
4✔
502
        except Project.DoesNotExist as e:
4✔
503
            raise Http404 from e
4✔
504

505
    def perform_create(self, serializer):
4✔
506
        # this call provides the nested serializers with the project
507
        serializer.save(project=self.project)
4✔
508

509

510
class ProjectMembershipViewSet(ProjectNestedViewSetMixin, ModelViewSet):
4✔
511
    permission_classes = (HasModelPermission | HasProjectPermission, )
4✔
512

513
    filter_backends = (DjangoFilterBackend, )
4✔
514
    filterset_fields = (
4✔
515
        'user',
516
        'user__username',
517
        'role'
518
    )
519

520
    def get_queryset(self):
4✔
521
        return Membership.objects.filter(project=self.project)
4✔
522

523
    def get_serializer_class(self):
4✔
524
        if self.action == 'update':
4✔
525
            return ProjectMembershipUpdateSerializer
4✔
526
        else:
527
            return ProjectMembershipSerializer
4✔
528

529

530
class ProjectIntegrationViewSet(ProjectNestedViewSetMixin, ModelViewSet):
4✔
531
    permission_classes = (HasModelPermission | HasProjectPermission, )
4✔
532
    serializer_class = ProjectIntegrationSerializer
4✔
533

534
    filter_backends = (DjangoFilterBackend, )
4✔
535
    filterset_fields = (
4✔
536
        'provider_key',
537
    )
538

539
    def get_queryset(self):
4✔
540
        return Integration.objects.filter(project=self.project)
4✔
541

542

543
class ProjectInviteViewSet(ProjectNestedViewSetMixin, ModelViewSet):
4✔
544
    permission_classes = (HasModelPermission | HasProjectPermission, )
4✔
545

546
    filter_backends = (DjangoFilterBackend, )
4✔
547
    filterset_fields = (
4✔
548
        'user',
549
        'user__username',
550
        'email',
551
        'role'
552
    )
553

554
    def get_queryset(self):
4✔
555
        return Invite.objects.filter(project=self.project)
4✔
556

557
    def get_serializer_class(self):
4✔
558
        if self.action == 'update':
4✔
559
            return ProjectInviteUpdateSerializer
4✔
560
        else:
561
            return ProjectInviteSerializer
4✔
562

563
    def perform_create(self, serializer):
4✔
564
        super().perform_create(serializer)
4✔
565
        if settings.PROJECT_SEND_INVITE:
4✔
566
            send_invite_email(self.request, serializer.instance)
4✔
567

568

569
class ProjectIssueViewSet(ProjectNestedViewSetMixin, ListModelMixin, RetrieveModelMixin,
4✔
570
                          UpdateModelMixin, GenericViewSet):
571
    permission_classes = (HasModelPermission | HasProjectPermission, )
4✔
572
    serializer_class = ProjectIssueSerializer
4✔
573

574
    filter_backends = (DjangoFilterBackend, )
4✔
575
    filterset_fields = (
4✔
576
        'task',
577
        'task__uri',
578
        'status'
579
    )
580

581
    def get_queryset(self):
4✔
582
        return Issue.objects.filter(project=self.project).prefetch_related('resources')
4✔
583

584

585
class ProjectSnapshotViewSet(ProjectNestedViewSetMixin, CreateModelMixin, RetrieveModelMixin,
4✔
586
                             UpdateModelMixin, ListModelMixin, GenericViewSet):
587
    permission_classes = (HasModelPermission | HasProjectPermission, )
4✔
588
    serializer_class = ProjectSnapshotSerializer
4✔
589

590
    def get_queryset(self):
4✔
591
        return self.project.snapshots.all()
4✔
592

593

594
class ProjectValueViewSet(ProjectNestedViewSetMixin, ModelViewSet):
4✔
595
    permission_classes = (HasModelPermission | HasProjectPermission, )
4✔
596
    serializer_class = ProjectValueSerializer
4✔
597

598
    filter_backends = (AttributeFilterBackend, DjangoFilterBackend)
4✔
599
    filterset_fields = (
4✔
600
        # attribute is part of AttributeFilterBackend
601
        'attribute__uri',
602
        'option',
603
        'option__uri',
604
    )
605

606
    def perform_create(self, serializer):
4✔
607
        super().perform_create(serializer)
4✔
608
        value_created.send(sender=Value, instance=serializer.instance)
4✔
609

610
    def perform_update(self, serializer):
4✔
611
        super().perform_update(serializer)
4✔
612
        value_updated.send(sender=Value, instance=serializer.instance)
4✔
613

614
    def perform_destroy(self, instance):
4✔
615
        super().perform_destroy(instance)
4✔
616
        value_deleted.send(sender=Value, instance=instance)
4✔
617

618
    def get_queryset(self):
4✔
619
        return self.project.values.filter(snapshot=None).select_related('attribute', 'option')
4✔
620

621
    @action(detail=False, methods=['POST'], url_path='set',
4✔
622
            permission_classes=(HasModelPermission | HasProjectPermission, ))
623
    def copy_set(self, request, parent_lookup_project, pk=None):
4✔
624
        # copy all values for questions in questionset collections with the attribute
625
        # for this value and the same set_prefix and set_index
626

627
        # obtain the id of the set value for the set we want to copy
628
        try:
4✔
629
            copy_value_id = int(request.data.pop('copy_set_value'))
4✔
630
        except KeyError as e:
4✔
631
            raise ValidationError({
4✔
632
                'copy_set_value': [_('This field may not be blank.')]
633
            }) from e
634
        except ValueError as e:
4✔
635
            raise NotFound from e
4✔
636

637
        # look for this value in the database, using the users permissions, and
638
        # collect all values for this set and all descendants
639
        try:
4✔
640
            copy_value = Value.objects.filter_user(self.request.user).get(id=copy_value_id)
4✔
641
            copy_values = Value.objects.filter_user(self.request.user).filter_set(copy_value)
4✔
642
        except Value.DoesNotExist as e:
4✔
643
            raise NotFound from e
4✔
644

645
        # init list of values to return
646
        response_values = []
4✔
647

648
        set_value_id = request.data.get('id')
4✔
649
        if set_value_id:
4✔
650
            # if an id is given in the post request, this is an import
651
            try:
4✔
652
                # look for the set value for the set we want to import into
653
                set_value = Value.objects.filter_user(self.request.user).get(id=set_value_id)
4✔
654

655
                # collect all non-empty values for this set and all descendants and convert
656
                # them to a list to compare them later to the new values
657
                set_values = Value.objects.filter_user(self.request.user).filter_set(set_value)
4✔
658
                set_values_list = set_values.exclude_empty().values_list('attribute', 'set_prefix', 'set_index')
4✔
659
                set_empty_values_list = set_values.filter_empty().values_list(
4✔
660
                    'attribute', 'set_prefix', 'set_index', 'collection_index'
661
                )
662
            except Value.DoesNotExist as e:
×
663
                raise NotFound from e
×
664
        else:
665
            # otherwise, we want to create a new set and need to create a new set value
666
            # de-serialize the posted new set value and save it, use the ValueSerializer
667
            # instead of ProjectValueSerializer, since the latter does not include project
668
            set_value_serializer = ValueSerializer(data={
4✔
669
                'project': parent_lookup_project,
670
                **request.data
671
            })
672
            set_value_serializer.is_valid(raise_exception=True)
4✔
673
            set_value = set_value_serializer.save()
4✔
674

675
            set_values = Value.objects.none()
4✔
676
            set_values_list = set_empty_values_list = []
4✔
677

678
            # add the new set value to response_values
679
            response_values.append(set_value_serializer.data)
4✔
680

681
        # create new values for the new set
682
        new_values = []
4✔
683
        updated_values = []
4✔
684
        for value in copy_values:
4✔
685
            value.id = None
4✔
686
            value.project = set_value.project
4✔
687
            value.snapshot = None
4✔
688
            if value.set_prefix == set_value.set_prefix:
4✔
689
                value.set_index = set_value.set_index
4✔
690
            else:
691
                value.set_prefix = compute_set_prefix_from_set_value(set_value, value)
4✔
692

693
            # skip this value if value.option does not match the optionsets of it's question
694
            if not check_options(self.project, value):
4✔
695
                continue
4✔
696

697
            # check if the value already exists, we do not consider collection_index
698
            # since we do not want to import e.g. into partially filled checkboxes
699
            if (value.attribute_id, value.set_prefix, value.set_index) in set_values_list:
4✔
700
                # do not overwrite existing values
701
                pass
×
702
            elif (value.attribute_id, value.set_prefix,
4✔
703
                  value.set_index, value.collection_index) in set_empty_values_list:
704
                # update empty values
705
                updated_value = set_values.get(attribute_id=value.attribute_id, set_prefix=value.set_prefix,
×
706
                                               set_index=value.set_index, collection_index=value.collection_index)
707
                updated_value.text = value.text
×
708
                updated_value.option = value.option
×
709
                updated_value.external_id = value.external_id
×
710
                updated_value.save()
×
711

712
                updated_values.append(updated_value)
×
713
            else:
714
                new_values.append(value)
4✔
715

716
        # bulk create the new values
717
        created_values = Value.objects.bulk_create(new_values)
4✔
718
        response_values += [ValueSerializer(instance=value).data for value in created_values]
4✔
719
        response_values += [ValueSerializer(instance=value).data for value in updated_values]
4✔
720

721
        # return all new values
722
        return Response(response_values, status=status.HTTP_201_CREATED)
4✔
723

724
    @action(detail=True, methods=['DELETE'], url_path='set',
4✔
725
            permission_classes=(HasModelPermission | HasProjectPermission, ))
726
    def delete_set(self, request, parent_lookup_project, pk=None):
4✔
727
        # delete all values for questions in questionset collections with the attribute
728
        # for this value and the same set_prefix and set_index
729
        set_value = self.get_object()
4✔
730
        set_value.delete()
4✔
731

732
        # collect all values for this set and all descendants and delete them
733
        values = self.get_queryset().filter_set(set_value)
4✔
734
        values.delete()
4✔
735

736
        return Response(status=status.HTTP_204_NO_CONTENT)
4✔
737

738
    @action(detail=True, methods=['GET', 'POST'],
4✔
739
            permission_classes=(HasModelPermission | HasProjectPermission, ))
740
    def file(self, request, parent_lookup_project, pk=None):
4✔
741
        value = self.get_object()
4✔
742

743
        if request.method == 'POST':
4✔
744
            value.file = request.FILES.get('file')
4✔
745

746
            # check if the project is reached
747
            if value.file and value.file.size + value.project.file_size > human2bytes(settings.PROJECT_FILE_QUOTA):
4✔
748
                raise serializers.ValidationError({
×
749
                    'value': [_('You reached the file quota for this project.')]
750
                })
751

752
            value.save()
4✔
753
            serializer = self.get_serializer(value)
4✔
754
            return Response(serializer.data)
4✔
755

756
        else:
757
            if value.file:
4✔
758
                return return_file_response(value.file.name, value.file_type)
4✔
759

760
        # if it didn't work return 404
761
        raise NotFound()
4✔
762

763

764
class ProjectPageViewSet(ProjectNestedViewSetMixin, RetrieveModelMixin, GenericViewSet):
4✔
765
    permission_classes = (HasModelPermission | HasProjectPagePermission, )
4✔
766
    serializer_class = PageSerializer
4✔
767

768
    def get_queryset(self):
4✔
769
        self.project.catalog.prefetch_elements()
4✔
770
        page = Page.objects.filter_by_catalog(self.project.catalog).prefetch_related(
4✔
771
            *Page.prefetch_lookups,
772
            'page_questions__question__optionsets__optionset_options__option',
773
            'page_questionsets__questionset__questionset_questions__question__optionsets__optionset_options__option',
774
        )
775
        return page
4✔
776

777
    def get_serializer_context(self):
4✔
778
        context = super().get_serializer_context()
4✔
779
        context['catalog'] = self.project.catalog
4✔
780
        return context
4✔
781

782
    def dispatch(self, *args, **kwargs):
4✔
783
        response = super().dispatch(*args, **kwargs)
4✔
784
        if response.status_code == 200 and kwargs.get('pk'):
4✔
785
            try:
4✔
786
                continuation = Continuation.objects.get(project=self.project, user=self.request.user)
4✔
787
            except Continuation.DoesNotExist:
4✔
788
                continuation = Continuation(project=self.project, user=self.request.user)
4✔
789

790
            continuation.page_id = kwargs.get('pk')
4✔
791
            continuation.save()
4✔
792

793
        return response
4✔
794

795
    def retrieve(self, request, *args, **kwargs):
4✔
796
        page = self.get_object()
4✔
797
        direction = 'prev' if is_truthy(request.GET.get('back')) else 'next'
4✔
798
        computed_page_id = compute_page(self.project, page, direction)
4✔
799

800
        if computed_page_id == page.id:
4✔
801
            serializer = self.get_serializer(page)
4✔
802
            return Response(serializer.data)
4✔
803
        elif computed_page_id is not None:
4✔
804
            url = reverse('v1-projects:project-page-detail', args=[self.project.id, computed_page_id])
4✔
805
            return HttpResponseRedirect(url, status=303)
4✔
806
        else:
807
            # if no page was found, we are probably at the end of the catalog
808
            return Response({
×
809
                'detail': 'No Page matches the given query.',
810
                'done': True
811
            }, status=status.HTTP_404_NOT_FOUND)
812

813

814
    @action(detail=False, url_path='continue', permission_classes=(HasModelPermission | HasProjectPagePermission, ))
4✔
815
    def get_continue(self, request, pk=None, parent_lookup_project=None):
4✔
816
        if not self.project.catalog.pages:
4✔
817
            return Response({
4✔
818
                'detail': 'No Page matches the given query.',
819
                'done': True
820
            }, status=status.HTTP_404_NOT_FOUND)
821

822
        try:
4✔
823
            continuation = Continuation.objects.get(project=self.project, user=self.request.user)
4✔
824

825
            try:
4✔
826
                page = Page.objects.filter_by_catalog(self.project.catalog).get(id=continuation.page_id)
4✔
827
            except Page.DoesNotExist:
×
828
                page = self.project.catalog.pages[0]
×
829

830
        except Continuation.DoesNotExist:
4✔
831
            page = self.project.catalog.pages[0]
4✔
832

833
        serializer = self.get_serializer(page)
4✔
834
        return Response(serializer.data)
4✔
835

836

837
class MembershipViewSet(ReadOnlyModelViewSet):
4✔
838
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
839
    serializer_class = MembershipSerializer
4✔
840

841
    filter_backends = (DjangoFilterBackend,)
4✔
842
    filterset_fields = (
4✔
843
        'user',
844
        'user__username',
845
        'role'
846
    )
847

848
    def get_queryset(self):
4✔
849
        return Membership.objects.filter_user(self.request.user)
4✔
850

851

852
class IntegrationViewSet(ReadOnlyModelViewSet):
4✔
853
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
854
    serializer_class = IntegrationSerializer
4✔
855

856
    filter_backends = (DjangoFilterBackend, )
4✔
857
    filterset_fields = (
4✔
858
        'project',
859
        'provider_key'
860
    )
861

862
    def get_queryset(self):
4✔
863
        return Integration.objects.filter_user(self.request.user)
4✔
864

865

866
class InviteViewSet(ReadOnlyModelViewSet):
4✔
867
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
868
    serializer_class = InviteSerializer
4✔
869

870
    filter_backends = (DjangoFilterBackend, )
4✔
871
    filterset_fields = (
4✔
872
        'user',
873
        'user__username',
874
        'email',
875
        'role'
876
    )
877

878
    def get_queryset(self):
4✔
879
        return Invite.objects.filter_user(self.request.user)
4✔
880

881
    def get_detail_permission_object(self, obj):
4✔
882
        return obj.project
×
883

884
    @action(detail=False, permission_classes=(IsAuthenticated, ))
4✔
885
    def user(self, request):
4✔
886
        invites = Invite.objects.filter(user=self.request.user)
4✔
887
        serializer = UserInviteSerializer(invites, many=True)
4✔
888
        return Response(serializer.data)
4✔
889

890
class IssueViewSet(ReadOnlyModelViewSet):
4✔
891
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
892
    serializer_class = IssueSerializer
4✔
893

894
    filter_backends = (DjangoFilterBackend, )
4✔
895
    filterset_fields = (
4✔
896
        'task',
897
        'task__uri',
898
        'status'
899
    )
900

901
    def get_queryset(self):
4✔
902
        return Issue.objects.filter_user(self.request.user).prefetch_related('resources')
4✔
903

904

905
class SnapshotViewSet(ReadOnlyModelViewSet):
4✔
906
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
907
    serializer_class = SnapshotSerializer
4✔
908

909
    filter_backends = (DjangoFilterBackend,)
4✔
910
    filterset_fields = (
4✔
911
        'title',
912
        'project'
913
    )
914

915
    def get_queryset(self):
4✔
916
        return Snapshot.objects.filter_user(self.request.user)
4✔
917

918

919
class ValueViewSet(ReadOnlyModelViewSet):
4✔
920
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
921
    serializer_class = ValueSerializer
4✔
922

923
    filter_backends = (
4✔
924
        AttributeFilterBackend,
925
        SnapshotFilterBackend,
926
        OptionFilterBackend,
927
        DjangoFilterBackend,
928
        SearchFilter
929
    )
930
    filterset_fields = (
4✔
931
        'project',
932
        # snapshot is part of SnapshotFilterBackend
933
        # attribute is part of AttributeFilterBackend
934
        'attribute__uri',
935
        'attribute__path',
936
        # option is part of OptionFilterBackend
937
        'option__uri',
938
        'option__uri_path',
939
    )
940

941
    search_fields = ['text', 'project__title', 'snapshot__title']
4✔
942

943
    def get_queryset(self):
4✔
944
        return Value.objects.filter_user(self.request.user).select_related('attribute', 'option')
4✔
945

946
    @action(detail=False, permission_classes=(HasModelPermission | HasProjectsPermission, ))
4✔
947
    def search(self, request):
4✔
948
        queryset = self.filter_queryset(self.get_queryset()).exclude_empty().select_related('project', 'snapshot')
4✔
949

950
        # add a subquery to get the label for the set the value is part in
951
        try:
4✔
952
            set_attribute = int(request.GET.get('set_attribute'))
4✔
953
            set_label_subquery = Subquery(
×
954
                Value.objects.filter(attribute=set_attribute, set_prefix='', set_index=OuterRef('set_index'))
955
                             .values('text')[:1]
956
            )
957
            queryset = queryset.annotate(set_label=set_label_subquery)
×
958
        except (ValueError, TypeError):
4✔
959
            pass
4✔
960

961
        if is_truthy(request.GET.get('collection')):
4✔
962
            # if collection is set (for checkboxes), we first select each distinct set and create a Q object with it
963
            # by doing so we can select an undetermined number of values which belong to an exact number of sets
964
            # given by settings.PROJECT_VALUES_SEARCH_LIMIT
965
            #
966
            # DISTINCT ON is not available on sqlite so we just apply the limit like for all other questions, this
967
            # will display the last set of checked values incomplete
968
            fields = ('project_id', 'snapshot_id', 'attribute_id', 'set_prefix', 'set_index')
4✔
969
            values_list = (
4✔
970
                queryset
971
                    .values(*fields)
972
                    .order_by(*fields)
973
                    .distinct()
974
                    [:settings.PROJECT_VALUES_SEARCH_LIMIT]
975
            )
976

977
            q = Q()
4✔
978
            for values_dict in values_list:
4✔
979
                q |= Q(**values_dict)
×
980

981
            queryset = queryset.filter(q).order_by(*Value._meta.ordering)
4✔
982
        else:
983
            queryset = queryset.order_by(*Value._meta.ordering)[:settings.PROJECT_VALUES_SEARCH_LIMIT]
4✔
984

985
        serializer = ValueSearchSerializer(queryset, many=True)
4✔
986
        return Response(serializer.data)
4✔
987

988
    @action(detail=True, permission_classes=(HasModelPermission | HasProjectsPermission, ))
4✔
989
    def file(self, request, pk=None):
4✔
990
        value = self.get_object()
4✔
991

992
        if value.file:
4✔
993
            return return_file_response(value.file.name, value.file_type)
4✔
994

995
        # if it didn't work return 404
996
        raise NotFound()
4✔
997

998

999
class CatalogViewSet(ListModelMixin, GenericViewSet):
4✔
1000
    permission_classes = (IsAuthenticated, )
4✔
1001

1002
    serializer_class = CatalogSerializer
4✔
1003

1004
    def get_queryset(self):
4✔
1005
        queryset = (
4✔
1006
            Catalog.objects.filter_current_site().filter_group(self.request.user)
1007
        )
1008
        availability_subquery = Subquery(
4✔
1009
            queryset.filter_availability(self.request.user).values('pk')
1010
        )
1011
        return (
4✔
1012
            queryset.filter(Q(pk__in=availability_subquery) | Q(projects__user=self.request.user))
1013
            .order_by('-available', 'order', 'id').distinct()
1014
        )
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