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

rdmorganiser / rdmo / 17951596797

23 Sep 2025 03:46PM UTC coverage: 94.624% (-0.2%) from 94.796%
17951596797

Pull #1432

github

web-flow
Merge 98f3be8fc into 79917de8d
Pull Request #1432: RDMO 3.0.0 🌠

2110 of 2226 branches covered (94.79%)

22634 of 23920 relevant lines covered (94.62%)

3.78 hits per line

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

88.59
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 F, 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.core.permissions import HasModelPermission
4✔
25
from rdmo.core.utils import human2bytes, is_truthy, return_file_response
4✔
26
from rdmo.options.models import OptionSet
4✔
27
from rdmo.questions.models import Catalog, Page, Question, QuestionSet
4✔
28
from rdmo.tasks.models import Task
4✔
29
from rdmo.views.models import View
4✔
30

31
from .filters import (
4✔
32
    AttributeFilterBackend,
33
    OptionFilterBackend,
34
    ProjectDateFilterBackend,
35
    ProjectOrderingFilter,
36
    ProjectSearchFilterBackend,
37
    ProjectUserFilterBackend,
38
    SnapshotFilterBackend,
39
)
40
from .models import Continuation, Integration, Invite, Issue, Membership, Project, Snapshot, Value, Visibility
4✔
41
from .permissions import (
4✔
42
    HasProjectLeavePermission,
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_next_relevant_page,
54
    compute_progress,
55
    compute_sets,
56
    compute_show_page,
57
    resolve_conditions,
58
)
59
from .serializers.v1 import (
4✔
60
    IntegrationSerializer,
61
    InviteSerializer,
62
    IssueSerializer,
63
    MembershipSerializer,
64
    ProjectCopySerializer,
65
    ProjectIntegrationSerializer,
66
    ProjectInviteSerializer,
67
    ProjectInviteUpdateSerializer,
68
    ProjectIssueSerializer,
69
    ProjectMembershipHierarchySerializer,
70
    ProjectMembershipListSerializer,
71
    ProjectMembershipSerializer,
72
    ProjectMembershipUpdateSerializer,
73
    ProjectSerializer,
74
    ProjectSnapshotSerializer,
75
    ProjectValueSerializer,
76
    ProjectVisibilitySerializer,
77
    SnapshotSerializer,
78
    UserInviteSerializer,
79
    ValueSearchSerializer,
80
    ValueSerializer,
81
)
82
from .serializers.v1.overview import CatalogSerializer, ProjectOverviewSerializer
4✔
83
from .serializers.v1.page import PageSerializer
4✔
84
from .utils import (
4✔
85
    check_conditions,
86
    check_options,
87
    compute_set_prefix_from_set_value,
88
    copy_project,
89
    get_contact_message,
90
    get_upload_accept,
91
    send_contact_message,
92
    send_invite_email,
93
)
94

95

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

99

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

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

128
    def get_queryset(self):
4✔
129
        if hasattr(self, '_cached_queryset'):
4✔
130
            return self._cached_queryset
4✔
131

132
        # for the user action (below), we need to set filter_for_user to s to filter the
133
        # projects for the current user regardless of their permissions, e.g. for admins
134
        filter_for_user = (self.action == 'user')
4✔
135

136
        queryset = Project.objects.filter_user(self.request.user, filter_for_user).distinct().prefetch_related(
4✔
137
            'snapshots',
138
            'views',
139
            Prefetch('memberships', queryset=Membership.objects.prefetch_related(
140
                'user__socialaccount_set'
141
            ).select_related('user'), to_attr='memberships_list')
142
        ).select_related('catalog', 'visibility')
143

144
        # prepare subquery for last_changed
145
        last_changed_subquery = Subquery(
4✔
146
            Value.objects.filter(project=OuterRef('pk')).order_by('-updated').values('updated')[:1]
147
        )
148
        # the 'updated' field from a Project always returns a valid DateTime value
149
        # when Greatest returns null, then Coalesce will return the value for 'updated' as a fall-back
150
        # when Greatest returns a value, then Coalesce will return this value
151
        queryset = queryset.annotate(last_changed=Coalesce(Greatest(last_changed_subquery, 'updated'), 'updated'))
4✔
152

153
        # cache queryset and return
154
        self._cached_queryset = queryset
4✔
155
        return self._cached_queryset
4✔
156

157
    @action(detail=False, methods=['GET'], permission_classes=(HasModelPermission | HasProjectsPermission, ))
4✔
158
    def user(self, request, *args, **kwargs):
4✔
159
        return self.list(request, *args, **kwargs)
4✔
160

161
    @action(detail=True, methods=['POST'],
4✔
162
            permission_classes=(HasModelPermission | HasProjectPermission, ))
163
    def copy(self, request, pk=None):
4✔
164
        instance = self.get_object()
4✔
165
        serializer = ProjectCopySerializer(instance, data=request.data, context=self.get_serializer_context())
4✔
166
        serializer.is_valid(raise_exception=True)
4✔
167

168
        # update instance
169
        for key, value in serializer.validated_data.items():
4✔
170
            setattr(instance, key, value)
4✔
171

172
        site = get_current_site(self.request)
4✔
173
        owners = [self.request.user]
4✔
174
        project_copy = copy_project(instance, site, owners)
4✔
175

176
        serializer = self.get_serializer(project_copy)
4✔
177
        headers = self.get_success_headers(serializer.data)
4✔
178
        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
4✔
179

180
    @action(detail=True, permission_classes=(HasModelPermission | HasProjectPermission, ))
4✔
181
    def overview(self, request, pk=None):
4✔
182
        project = self.get_object()
4✔
183
        serializer = ProjectOverviewSerializer(project, context={'request': request})
4✔
184
        return Response(serializer.data)
4✔
185

186
    @action(detail=True, url_path=r'navigation(?:/(?P<section_id>\d+))?',
4✔
187
            permission_classes=(HasModelPermission | HasProjectPermission, ))
188
    def navigation(self, request, pk=None, section_id=None):
4✔
189
        project = self.get_object()
4✔
190

191
        section = None
4✔
192
        if section_id is not None:
4✔
193
            try:
4✔
194
                section = project.catalog.sections.get(pk=section_id)
4✔
195
            except ObjectDoesNotExist as e:
×
196
                raise NotFound() from e
×
197

198
        project.catalog.prefetch_elements()
4✔
199

200
        navigation = compute_navigation(section, project)
4✔
201
        return Response(navigation)
4✔
202

203
    @action(detail=True, permission_classes=(HasModelPermission | HasProjectPermission, ))
4✔
204
    def resolve(self, request, pk=None):
4✔
205
        snapshot_id = request.GET.get('snapshot')
4✔
206
        set_prefix = request.GET.get('set_prefix')
4✔
207
        set_index = request.GET.get('set_index')
4✔
208

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

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

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

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

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

251
        condition_id = request.GET.get('condition')
4✔
252
        if condition_id:
4✔
253
            try:
4✔
254
                condition = Condition.objects.select_related('source', 'target_option').get(id=condition_id)
4✔
255
                if check_conditions([condition], values, set_prefix, set_index):
4✔
256
                    return Response({'result': True})
4✔
257
            except Condition.DoesNotExist:
×
258
                pass
×
259

260
        return Response({'result': False})
4✔
261

262
    @action(detail=True, permission_classes=(HasModelPermission | HasProjectPermission, ))
4✔
263
    def options(self, request, pk=None):
4✔
264
        project = self.get_object()
4✔
265
        try:
4✔
266
            try:
4✔
267
                optionset_id = request.GET.get('optionset')
4✔
268
                optionset = OptionSet.objects.get(pk=optionset_id)
4✔
269
            except (ValueError, OptionSet.DoesNotExist) as e:
×
270
                raise NotFound() from e
×
271

272
            # check if the optionset belongs to this catalog and if it has a provider
273
            project.catalog.prefetch_elements()
4✔
274
            if Question.objects.filter_by_catalog(project.catalog).filter(optionsets=optionset) and \
4✔
275
                    optionset.provider is not None:
276
                options = []
4✔
277
                for option in optionset.provider.get_options(project, search=request.GET.get('search'),
4✔
278
                                                             user=request.user, site=request.site):
279
                    if 'id' not in option:
4✔
280
                        raise RuntimeError(f"'id' is missing in options of '{optionset.provider.class_name}'")
×
281
                    elif 'text' not in option:
4✔
282
                        raise RuntimeError(f"'text' is missing in options of '{optionset.provider.class_name}'")
×
283
                    if 'text_and_help' not in option:
4✔
284
                        if 'help' in option:
4✔
285
                            option['text_and_help'] = '{text} [{help}]'.format(**option)
4✔
286
                        else:
287
                            option['text_and_help'] = '{text}'.format(**option)
4✔
288
                    options.append(option)
4✔
289

290
                return Response(options)
4✔
291

292
        except OptionSet.DoesNotExist:
×
293
            pass
×
294

295
        # if it didn't work return 404
296
        raise NotFound()
×
297

298
    @action(detail=True, methods=['get', 'post'],
4✔
299
            permission_classes=(HasProjectProgressModelPermission | HasProjectProgressObjectPermission, ))
300
    def progress(self, request, pk=None):
4✔
301
        project = self.get_object()
4✔
302

303
        if request.method == 'POST' or project.progress_count is None or project.progress_total is None:
4✔
304
            # compute the progress, but store it only, if it has changed
305
            project.catalog.prefetch_elements()
4✔
306
            progress_count, progress_total = compute_progress(project)
4✔
307
            if progress_count != project.progress_count or progress_total != project.progress_total:
4✔
308
                project.progress_count, project.progress_total = progress_count, progress_total
4✔
309
                project.save()
4✔
310
        try:
4✔
311
            ratio = project.progress_count / project.progress_total
4✔
312
        except ZeroDivisionError:
×
313
            ratio = 0
×
314

315
        return Response({
4✔
316
            'count': project.progress_count,
317
            'total': project.progress_total,
318
            'ratio': ratio
319
        })
320

321
    @action(detail=True, methods=['get', 'post', 'delete'],
4✔
322
            permission_classes=(HasProjectVisibilityModelPermission | HasProjectVisibilityObjectPermission, ))
323
    def visibility(self, request, pk=None):
4✔
324
        project = self.get_object()
4✔
325

326
        try:
4✔
327
            instance = project.visibility
4✔
328
        except Visibility.DoesNotExist:
4✔
329
            instance = None
4✔
330

331
        if request.method == 'POST':
4✔
332
            data = {'project': project.id}
4✔
333

334
            if settings.MULTISITE:
4✔
335
                if request.user.has_perm('projects.change_visibility'):
4✔
336
                    data['sites'] = request.data.getlist('sites', [])
4✔
337
                else:
338
                    data['sites'] = list({
4✔
339
                        *[site.id for site in instance.sites.all()],
340
                        get_current_site(self.request).id
341
                    })
342

343
            if settings.GROUPS:
4✔
344
                data['groups'] = request.data.getlist('groups', [])
4✔
345

346
            serializer = ProjectVisibilitySerializer(instance, data=data)
4✔
347
            serializer.is_valid(raise_exception=True)
4✔
348
            serializer.save()
4✔
349
            return Response(serializer.data)
4✔
350

351
        elif request.method == 'DELETE':
4✔
352
            if instance is not None:
4✔
353
                if settings.MULTISITE and not request.user.has_perm('projects.delete_visibility'):
4✔
354
                    instance.remove_site(get_current_site(self.request))
4✔
355
                else:
356
                    instance.delete()
4✔
357

358
                return Response(status=status.HTTP_204_NO_CONTENT)
4✔
359
        else:
360
            if instance is not None:
4✔
361
                serializer = ProjectVisibilitySerializer(instance)
4✔
362
                return Response(serializer.data)
4✔
363

364
        # if nothing worked, raise 404
365
        raise Http404
4✔
366

367
    @action(detail=True, methods=['get', 'post'],
4✔
368
            permission_classes=(HasModelPermission | HasProjectPermission, ))
369
    def contact(self, request, pk):
4✔
370
        if settings.PROJECT_CONTACT:
4✔
371
            project = self.get_object()
4✔
372
            if request.method == 'POST':
4✔
373
                subject = request.data.get('subject')
4✔
374
                message = request.data.get('message')
4✔
375

376
                if subject and message:
4✔
377
                    send_contact_message(request, subject, message)
4✔
378
                    return Response(status=status.HTTP_204_NO_CONTENT)
4✔
379
                else:
380
                    raise ValidationError({
4✔
381
                        'subject': [_('This field may not be blank.')] if not subject else [],
382
                        'message': [_('This field may not be blank.')] if not message else []
383
                    })
384
            else:
385
                project.catalog.prefetch_elements()
4✔
386
                return Response(get_contact_message(request, project))
4✔
387
        else:
388
            raise Http404
×
389

390
    @action(detail=False, url_path='upload-accept', permission_classes=(IsAuthenticated, ))
4✔
391
    def upload_accept(self, request):
4✔
392
        return Response(get_upload_accept())
4✔
393

394
    @action(detail=False, permission_classes=(IsAuthenticated, ))
4✔
395
    def imports(self, request):
4✔
396
        return Response([{
4✔
397
            'key': key,
398
            'label': label,
399
            'class_name': class_name,
400
            'href': reverse('project_create_import', args=[key])
401
        } for key, label, class_name in settings.PROJECT_IMPORTS if key in settings.PROJECT_IMPORTS_LIST] )
402

403
    def perform_create(self, serializer):
4✔
404
        project = serializer.save(site=get_current_site(self.request))
4✔
405

406
        # add current user as owner
407
        membership = Membership(project=project, user=self.request.user, role='owner')
4✔
408
        membership.save()
4✔
409

410

411
        # add all tasks to project
412
        if self.request.data.get('tasks') is None:
4✔
413
            if not settings.PROJECT_TASKS_SYNC:
4✔
414
                tasks = Task.objects.filter_for_project(project).filter_availability(self.request.user)
4✔
415
                for task in tasks:
4✔
416
                    project.tasks.add(task)
4✔
417

418
        if self.request.data.get('views') is None:
4✔
419
            # add all views to project
420
            if not settings.PROJECT_VIEWS_SYNC:
4✔
421
                views = View.objects.filter_for_project(project).filter_availability(self.request.user)
4✔
422
                for view in views:
4✔
423
                    project.views.add(view)
4✔
424

425

426
class ProjectNestedViewSetMixin(NestedViewSetMixin):
4✔
427

428
    def initial(self, request, *args, **kwargs):
4✔
429
        self.project = self.get_project_from_parent_viewset()
4✔
430
        super().initial(request, *args, **kwargs)
4✔
431

432
    def get_project_from_parent_viewset(self):
4✔
433
        try:
4✔
434
            return Project.objects.filter_user(self.request.user).get(pk=self.get_parents_query_dict().get('project'))
4✔
435
        except Project.DoesNotExist as e:
4✔
436
            raise Http404 from e
4✔
437

438
    def perform_create(self, serializer):
4✔
439
        # this call provides the nested serializers with the project
440
        serializer.save(project=self.project)
4✔
441

442

443
class ProjectMembershipViewSet(ProjectNestedViewSetMixin, ModelViewSet):
4✔
444
    permission_classes = (HasModelPermission | HasProjectPermission, )
4✔
445

446
    filter_backends = (DjangoFilterBackend, )
4✔
447
    filterset_fields = (
4✔
448
        'user',
449
        'user__username',
450
        'role'
451
    )
452

453
    def get_queryset(self):
4✔
454
        return Membership.objects.filter(project=self.project)
4✔
455

456
    def get_serializer_class(self):
4✔
457
        if self.action == 'list':
4✔
458
            return ProjectMembershipListSerializer
4✔
459
        elif self.action == 'update':
4✔
460
            return ProjectMembershipUpdateSerializer
4✔
461
        else:
462
            return ProjectMembershipSerializer
4✔
463

464
    @action(detail=False, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ))
4✔
465
    def hierarchy(self, request, parent_lookup_project=None):
4✔
466
        # get the ancestors of this project
467
        ancestors = self.project.get_ancestors()
4✔
468

469
        # add a subquery to find the highest occurrence of a user in the project hierarchy
470
        highest_project = Membership.objects.filter(
4✔
471
            project__in=ancestors, user_id=OuterRef('user_id')
472
        ).order_by('-project__level')
473

474
        # query memberships for all ancestors, but only the highest level
475
        memberships = Membership.objects.filter(project__in=ancestors) \
4✔
476
                                        .annotate(highest=Subquery(highest_project.values('project__level')[:1])) \
477
                                        .filter(highest=F('project__level')) \
478
                                        .select_related('project', 'user')
479

480
        if settings.SOCIALACCOUNT:
4✔
481
            # prefetch the users social account, if that relation exists
482
            memberships = memberships.prefetch_related('user__socialaccount_set')
×
483

484
        serializer = ProjectMembershipHierarchySerializer(memberships, many=True, context={
4✔
485
            'request': request, 'view': self
486
        })
487
        return Response(serializer.data)
4✔
488

489
    @action(detail=False, methods=['delete'], permission_classes=(HasProjectLeavePermission, ))
4✔
490
    def leave(self, request, parent_lookup_project=None):
4✔
491
        membership = Membership.objects.filter(project=self.project).get(user=request.user)
4✔
492
        if membership.is_last_owner:
4✔
493
            raise ValidationError(_('The last owner is not allowed to leave the project.'))
4✔
494
        else:
495
            membership.delete()
4✔
496
            return Response(status=status.HTTP_204_NO_CONTENT)
4✔
497

498

499
class ProjectIntegrationViewSet(ProjectNestedViewSetMixin, ModelViewSet):
4✔
500
    permission_classes = (HasModelPermission | HasProjectPermission, )
4✔
501
    serializer_class = ProjectIntegrationSerializer
4✔
502

503
    filter_backends = (DjangoFilterBackend, )
4✔
504
    filterset_fields = (
4✔
505
        'provider_key',
506
    )
507

508
    def get_queryset(self):
4✔
509
        return Integration.objects.filter(project=self.project)
4✔
510

511

512
class ProjectInviteViewSet(ProjectNestedViewSetMixin, ModelViewSet):
4✔
513
    permission_classes = (HasModelPermission | HasProjectPermission, )
4✔
514

515
    filter_backends = (DjangoFilterBackend, )
4✔
516
    filterset_fields = (
4✔
517
        'user',
518
        'user__username',
519
        'email',
520
        'role'
521
    )
522

523
    def get_queryset(self):
4✔
524
        return Invite.objects.filter(project=self.project)
4✔
525

526
    def get_serializer_class(self):
4✔
527
        if self.action == "update":
4✔
528
            return ProjectInviteUpdateSerializer
4✔
529
        else:
530
            return ProjectInviteSerializer
4✔
531

532
    def get_serializer_context(self):
4✔
533
        context = super().get_serializer_context()
4✔
534
        context['project'] = self.project
4✔
535
        return context
4✔
536

537
    def perform_create(self, serializer):
4✔
538
        super().perform_create(serializer)
4✔
539
        if settings.PROJECT_SEND_INVITE:
4✔
540
            send_invite_email(self.request, serializer.instance)
4✔
541

542

543
class ProjectIssueViewSet(ProjectNestedViewSetMixin, ListModelMixin, RetrieveModelMixin,
4✔
544
                          UpdateModelMixin, GenericViewSet):
545
    permission_classes = (HasModelPermission | HasProjectPermission, )
4✔
546
    serializer_class = ProjectIssueSerializer
4✔
547

548
    filter_backends = (DjangoFilterBackend, )
4✔
549
    filterset_fields = (
4✔
550
        'task',
551
        'task__uri',
552
        'status'
553
    )
554

555
    def get_queryset(self):
4✔
556
        return Issue.objects.filter(project=self.project).prefetch_related('resources')
4✔
557

558

559
class ProjectSnapshotViewSet(ProjectNestedViewSetMixin, CreateModelMixin, RetrieveModelMixin,
4✔
560
                             UpdateModelMixin, ListModelMixin, GenericViewSet):
561
    permission_classes = (HasModelPermission | HasProjectPermission, )
4✔
562
    serializer_class = ProjectSnapshotSerializer
4✔
563

564
    def get_queryset(self):
4✔
565
        return self.project.snapshots.all()
4✔
566

567

568
class ProjectValueViewSet(ProjectNestedViewSetMixin, ModelViewSet):
4✔
569
    permission_classes = (HasModelPermission | HasProjectPermission, )
4✔
570
    serializer_class = ProjectValueSerializer
4✔
571

572
    filter_backends = (AttributeFilterBackend, DjangoFilterBackend)
4✔
573
    filterset_fields = (
4✔
574
        # attribute is part of AttributeFilterBackend
575
        'attribute__uri',
576
        'option',
577
        'option__uri',
578
    )
579

580
    def get_queryset(self):
4✔
581
        return self.project.values.filter(snapshot=None).select_related('attribute', 'option')
4✔
582

583
    @action(detail=False, methods=['POST'], url_path='set',
4✔
584
            permission_classes=(HasModelPermission | HasProjectPermission, ))
585
    def copy_set(self, request, parent_lookup_project, pk=None):
4✔
586
        # copy all values for questions in questionset collections with the attribute
587
        # for this value and the same set_prefix and set_index
588

589
        # obtain the id of the set value for the set we want to copy
590
        try:
4✔
591
            copy_value_id = int(request.data.pop('copy_set_value'))
4✔
592
        except KeyError as e:
4✔
593
            raise ValidationError({
4✔
594
                'copy_set_value': [_('This field may not be blank.')]
595
            }) from e
596
        except ValueError as e:
4✔
597
            raise NotFound from e
4✔
598

599
        # look for this value in the database, using the users permissions, and
600
        # collect all values for this set and all descendants
601
        try:
4✔
602
            copy_value = Value.objects.filter_user(self.request.user).get(id=copy_value_id)
4✔
603
            copy_values = Value.objects.filter_user(self.request.user).filter_set(copy_value)
4✔
604
        except Value.DoesNotExist as e:
4✔
605
            raise NotFound from e
4✔
606

607
        # init list of values to return
608
        response_values = []
4✔
609

610
        set_value_id = request.data.get('id')
4✔
611
        if set_value_id:
4✔
612
            # if an id is given in the post request, this is an import
613
            try:
4✔
614
                # look for the set value for the set we want to import into
615
                set_value = Value.objects.filter_user(self.request.user).get(id=set_value_id)
4✔
616

617
                # collect all non-empty values for this set and all descendants and convert
618
                # them to a list to compare them later to the new values
619
                set_values = Value.objects.filter_user(self.request.user).filter_set(set_value)
4✔
620
                set_values_list = set_values.exclude_empty().values_list('attribute', 'set_prefix', 'set_index')
4✔
621
                set_empty_values_list = set_values.filter_empty().values_list(
4✔
622
                    'attribute', 'set_prefix', 'set_index', 'collection_index'
623
                )
624
            except Value.DoesNotExist as e:
×
625
                raise NotFound from e
×
626
        else:
627
            # otherwise, we want to create a new set and need to create a new set value
628
            # de-serialize the posted new set value and save it, use the ValueSerializer
629
            # instead of ProjectValueSerializer, since the latter does not include project
630
            set_value_serializer = ValueSerializer(data={
4✔
631
                'project': parent_lookup_project,
632
                **request.data
633
            })
634
            set_value_serializer.is_valid(raise_exception=True)
4✔
635
            set_value = set_value_serializer.save()
4✔
636

637
            set_values = Value.objects.none()
4✔
638
            set_values_list = set_empty_values_list = []
4✔
639

640
            # add the new set value to response_values
641
            response_values.append(set_value_serializer.data)
4✔
642

643
        # create new values for the new set
644
        new_values = []
4✔
645
        updated_values = []
4✔
646
        for value in copy_values:
4✔
647
            value.id = None
4✔
648
            value.project = set_value.project
4✔
649
            value.snapshot = None
4✔
650
            if value.set_prefix == set_value.set_prefix:
4✔
651
                value.set_index = set_value.set_index
4✔
652
            else:
653
                value.set_prefix = compute_set_prefix_from_set_value(set_value, value)
4✔
654

655
            # skip this value if value.option does not match the optionsets of it's question
656
            if not check_options(self.project, value):
4✔
657
                continue
4✔
658

659
            # check if the value already exists, we do not consider collection_index
660
            # since we do not want to import e.g. into partially filled checkboxes
661
            if (value.attribute_id, value.set_prefix, value.set_index) in set_values_list:
4✔
662
                # do not overwrite existing values
663
                pass
×
664
            elif (value.attribute_id, value.set_prefix,
4✔
665
                  value.set_index, value.collection_index) in set_empty_values_list:
666
                # update empty values
667
                updated_value = set_values.get(attribute_id=value.attribute_id, set_prefix=value.set_prefix,
×
668
                                               set_index=value.set_index, collection_index=value.collection_index)
669
                updated_value.text = value.text
×
670
                updated_value.option = value.option
×
671
                updated_value.external_id = value.external_id
×
672
                updated_value.save()
×
673

674
                updated_values.append(updated_value)
×
675
            else:
676
                new_values.append(value)
4✔
677

678
        # bulk create the new values
679
        created_values = Value.objects.bulk_create(new_values)
4✔
680
        response_values += [ValueSerializer(instance=value).data for value in created_values]
4✔
681
        response_values += [ValueSerializer(instance=value).data for value in updated_values]
4✔
682

683
        # return all new values
684
        return Response(response_values, status=status.HTTP_201_CREATED)
4✔
685

686
    @action(detail=True, methods=['DELETE'], url_path='set',
4✔
687
            permission_classes=(HasModelPermission | HasProjectPermission, ))
688
    def delete_set(self, request, parent_lookup_project, pk=None):
4✔
689
        # delete all values for questions in questionset collections with the attribute
690
        # for this value and the same set_prefix and set_index
691
        set_value = self.get_object()
4✔
692
        set_value.delete()
4✔
693

694
        # collect all values for this set and all descendants and delete them
695
        values = self.get_queryset().filter_set(set_value)
4✔
696
        values.delete()
4✔
697

698
        return Response(status=status.HTTP_204_NO_CONTENT)
4✔
699

700
    @action(detail=True, methods=['GET', 'POST'],
4✔
701
            permission_classes=(HasModelPermission | HasProjectPermission, ))
702
    def file(self, request, parent_lookup_project, pk=None):
4✔
703
        value = self.get_object()
4✔
704

705
        if request.method == 'POST':
4✔
706
            value.file = request.FILES.get('file')
4✔
707

708
            # check if the project is reached
709
            if value.file and value.file.size + value.project.file_size > human2bytes(settings.PROJECT_FILE_QUOTA):
4✔
710
                raise serializers.ValidationError({
×
711
                    'value': [_('You reached the file quota for this project.')]
712
                })
713

714
            value.save()
4✔
715
            serializer = self.get_serializer(value)
4✔
716
            return Response(serializer.data)
4✔
717

718
        else:
719
            if value.file:
4✔
720
                return return_file_response(value.file.name, value.file_type)
4✔
721

722
        # if it didn't work return 404
723
        raise NotFound()
4✔
724

725

726
class ProjectPageViewSet(ProjectNestedViewSetMixin, RetrieveModelMixin, GenericViewSet):
4✔
727
    permission_classes = (HasModelPermission | HasProjectPagePermission, )
4✔
728
    serializer_class = PageSerializer
4✔
729

730
    def get_queryset(self):
4✔
731
        self.project.catalog.prefetch_elements()
4✔
732
        page = Page.objects.filter_by_catalog(self.project.catalog).prefetch_related(
4✔
733
            *Page.prefetch_lookups,
734
            'page_questions__question__optionsets__optionset_options__option',
735
            'page_questionsets__questionset__questionset_questions__question__optionsets__optionset_options__option',
736
        )
737
        return page
4✔
738

739
    def get_serializer_context(self):
4✔
740
        context = super().get_serializer_context()
4✔
741
        context['catalog'] = self.project.catalog
4✔
742
        return context
4✔
743

744
    def dispatch(self, *args, **kwargs):
4✔
745
        response = super().dispatch(*args, **kwargs)
4✔
746
        if response.status_code == 200 and kwargs.get('pk'):
4✔
747
            try:
4✔
748
                continuation = Continuation.objects.get(project=self.project, user=self.request.user)
4✔
749
            except Continuation.DoesNotExist:
4✔
750
                continuation = Continuation(project=self.project, user=self.request.user)
4✔
751

752
            continuation.page_id = kwargs.get('pk')
4✔
753
            continuation.save()
4✔
754

755
        return response
4✔
756

757
    def retrieve(self, request, *args, **kwargs):
4✔
758
        page = self.get_object()
4✔
759
        catalog = self.project.catalog
4✔
760
        values = self.project.values.filter(snapshot=None).select_related('attribute', 'option')
4✔
761

762
        sets = compute_sets(values)
4✔
763
        resolved_conditions = resolve_conditions(catalog, values, sets)
4✔
764

765
        # check if the current page meets conditions
766
        if compute_show_page(page, resolved_conditions):
4✔
767
            serializer = self.get_serializer(page)
4✔
768
            return Response(serializer.data)
4✔
769
        else:
770
            # determine the direction of navigation (previous or next)
771
            direction = 'prev' if is_truthy(request.GET.get('back')) else 'next'
4✔
772

773
            # find the next relevant page with from pages and resolved conditions
774
            next_relevant_page = compute_next_relevant_page(page, direction, catalog, resolved_conditions)
4✔
775

776
            if next_relevant_page is not None:
4✔
777
                url = reverse('v1-projects:project-page-detail', args=[self.project.id, next_relevant_page.id])
4✔
778
                return HttpResponseRedirect(url, status=303)
4✔
779

780
            # end of catalog, if no next relevant page is found
781
            return Response({
×
782
                'detail': 'No Page matches the given query.',
783
                'done': True
784
            }, status=status.HTTP_404_NOT_FOUND)
785

786

787
    @action(detail=False, url_path='continue', permission_classes=(HasModelPermission | HasProjectPagePermission, ))
4✔
788
    def get_continue(self, request, pk=None, parent_lookup_project=None):
4✔
789
        if not self.project.catalog.pages:
4✔
790
            return Response({
4✔
791
                'detail': 'No Page matches the given query.',
792
                'done': True
793
            }, status=status.HTTP_404_NOT_FOUND)
794

795
        try:
4✔
796
            continuation = Continuation.objects.get(project=self.project, user=self.request.user)
4✔
797

798
            try:
4✔
799
                page = Page.objects.filter_by_catalog(self.project.catalog).get(id=continuation.page_id)
4✔
800
            except Page.DoesNotExist:
×
801
                page = self.project.catalog.pages[0]
×
802

803
        except Continuation.DoesNotExist:
4✔
804
            page = self.project.catalog.pages[0]
4✔
805

806
        serializer = self.get_serializer(page)
4✔
807
        return Response(serializer.data)
4✔
808

809

810
class MembershipViewSet(ReadOnlyModelViewSet):
4✔
811
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
812
    serializer_class = MembershipSerializer
4✔
813

814
    filter_backends = (DjangoFilterBackend,)
4✔
815
    filterset_fields = (
4✔
816
        'user',
817
        'user__username',
818
        'role'
819
    )
820

821
    def get_queryset(self):
4✔
822
        return Membership.objects.filter_user(self.request.user)
4✔
823

824

825
class IntegrationViewSet(ReadOnlyModelViewSet):
4✔
826
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
827
    serializer_class = IntegrationSerializer
4✔
828

829
    filter_backends = (DjangoFilterBackend, )
4✔
830
    filterset_fields = (
4✔
831
        'project',
832
        'provider_key'
833
    )
834

835
    def get_queryset(self):
4✔
836
        return Integration.objects.filter_user(self.request.user)
4✔
837

838

839
class InviteViewSet(ReadOnlyModelViewSet):
4✔
840
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
841
    serializer_class = InviteSerializer
4✔
842

843
    filter_backends = (DjangoFilterBackend, )
4✔
844
    filterset_fields = (
4✔
845
        'user',
846
        'user__username',
847
        'email',
848
        'role'
849
    )
850

851
    def get_queryset(self):
4✔
852
        return Invite.objects.filter_user(self.request.user)
4✔
853

854
    def get_detail_permission_object(self, obj):
4✔
855
        return obj.project
×
856

857
    @action(detail=False, permission_classes=(IsAuthenticated, ))
4✔
858
    def user(self, request):
4✔
859
        invites = Invite.objects.filter(user=self.request.user)
4✔
860
        serializer = UserInviteSerializer(invites, many=True)
4✔
861
        return Response(serializer.data)
4✔
862

863

864
class IssueViewSet(ReadOnlyModelViewSet):
4✔
865
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
866
    serializer_class = IssueSerializer
4✔
867

868
    filter_backends = (DjangoFilterBackend, )
4✔
869
    filterset_fields = (
4✔
870
        'task',
871
        'task__uri',
872
        'status'
873
    )
874

875
    def get_queryset(self):
4✔
876
        return Issue.objects.filter_user(self.request.user).prefetch_related('resources')
4✔
877

878

879
class SnapshotViewSet(ReadOnlyModelViewSet):
4✔
880
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
881
    serializer_class = SnapshotSerializer
4✔
882

883
    filter_backends = (DjangoFilterBackend,)
4✔
884
    filterset_fields = (
4✔
885
        'title',
886
        'project'
887
    )
888

889
    def get_queryset(self):
4✔
890
        return Snapshot.objects.filter_user(self.request.user)
4✔
891

892

893
class ValueViewSet(ReadOnlyModelViewSet):
4✔
894
    permission_classes = (HasModelPermission | HasProjectsPermission, )
4✔
895
    serializer_class = ValueSerializer
4✔
896

897
    filter_backends = (
4✔
898
        AttributeFilterBackend,
899
        SnapshotFilterBackend,
900
        OptionFilterBackend,
901
        DjangoFilterBackend,
902
        SearchFilter
903
    )
904
    filterset_fields = (
4✔
905
        'project',
906
        # snapshot is part of SnapshotFilterBackend
907
        # attribute is part of AttributeFilterBackend
908
        'attribute__uri',
909
        'attribute__path',
910
        # option is part of OptionFilterBackend
911
        'option__uri',
912
        'option__uri_path',
913
    )
914

915
    search_fields = ['text', 'project__title', 'snapshot__title']
4✔
916

917
    def get_queryset(self):
4✔
918
        return Value.objects.filter_user(self.request.user).select_related('attribute', 'option')
4✔
919

920
    @action(detail=False, permission_classes=(HasModelPermission | HasProjectsPermission, ))
4✔
921
    def search(self, request):
4✔
922
        queryset = self.filter_queryset(self.get_queryset()).exclude_empty().select_related('project', 'snapshot')
4✔
923

924
        # add a subquery to get the label for the set the value is part in
925
        try:
4✔
926
            set_attribute = int(request.GET.get('set_attribute'))
4✔
927
            set_label_subquery = Subquery(
×
928
                Value.objects.filter(attribute=set_attribute, set_prefix='', set_index=OuterRef('set_index'))
929
                             .values('text')[:1]
930
            )
931
            queryset = queryset.annotate(set_label=set_label_subquery)
×
932
        except (ValueError, TypeError):
4✔
933
            pass
4✔
934

935
        if is_truthy(request.GET.get('collection')):
4✔
936
            # if collection is set (for checkboxes), we first select each distinct set and create a Q object with it
937
            # by doing so we can select an undetermined number of values which belong to an exact number of sets
938
            # given by settings.PROJECT_VALUES_SEARCH_LIMIT
939
            #
940
            # DISTINCT ON is not available on sqlite so we just apply the limit like for all other questions, this
941
            # will display the last set of checked values incomplete
942
            fields = ('project_id', 'snapshot_id', 'attribute_id', 'set_prefix', 'set_index')
4✔
943
            values_list = (
4✔
944
                queryset
945
                    .values(*fields)
946
                    .order_by(*fields)
947
                    .distinct()
948
                    [:settings.PROJECT_VALUES_SEARCH_LIMIT]
949
            )
950

951
            q = Q()
4✔
952
            for values_dict in values_list:
4✔
953
                q |= Q(**values_dict)
×
954

955
            queryset = queryset.filter(q).order_by(*Value._meta.ordering)
4✔
956
        else:
957
            queryset = queryset.order_by(*Value._meta.ordering)[:settings.PROJECT_VALUES_SEARCH_LIMIT]
4✔
958

959
        serializer = ValueSearchSerializer(queryset, many=True)
4✔
960
        return Response(serializer.data)
4✔
961

962
    @action(detail=True, permission_classes=(HasModelPermission | HasProjectsPermission, ))
4✔
963
    def file(self, request, pk=None):
4✔
964
        value = self.get_object()
4✔
965

966
        if value.file:
4✔
967
            return return_file_response(value.file.name, value.file_type)
4✔
968

969
        # if it didn't work return 404
970
        raise NotFound()
4✔
971

972

973
class CatalogViewSet(ListModelMixin, GenericViewSet):
4✔
974
    permission_classes = (IsAuthenticated, )
4✔
975

976
    serializer_class = CatalogSerializer
4✔
977

978
    def get_queryset(self):
4✔
979
        return Catalog.objects.filter_current_site() \
4✔
980
                              .filter_group(self.request.user) \
981
                              .order_by('-available', 'order')
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