diff --git a/.eslintrc.js b/.eslintrc.js index 1419f87dfe..5d67f9ab04 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -44,5 +44,5 @@ module.exports = { 'react': { 'version': 'detect' } - } + }, } diff --git a/rdmo/core/static/core/js/core.js b/rdmo/core/static/core/js/core.js index 39f154fc2c..ee790f74fa 100644 --- a/rdmo/core/static/core/js/core.js +++ b/rdmo/core/static/core/js/core.js @@ -16,6 +16,13 @@ angular.module('core', ['ngResource']) method: 'PUT', params: {} }; + $resourceProvider.defaults.actions.postAction = { + method: 'POST', + params: { + id: '@id', + detail_action: '@detail_action' + } + }; }]) .filter('capitalize', function() { diff --git a/rdmo/projects/managers.py b/rdmo/projects/managers.py index 8ee22b0da2..72e7c21a0b 100644 --- a/rdmo/projects/managers.py +++ b/rdmo/projects/managers.py @@ -1,5 +1,6 @@ from django.conf import settings from django.db import models +from django.db.models import Q from mptt.models import TreeManager from mptt.querysets import TreeQuerySet @@ -141,6 +142,12 @@ def filter_user(self, user): else: return self.none() + def exclude_empty(self): + return self.exclude((Q(text='') | Q(text=None)) & Q(option=None) & (Q(file='') | Q(file=None))) + + def distinct_list(self): + return self.order_by('attribute').values_list('attribute', 'set_prefix', 'set_index').distinct() + class ProjectManager(CurrentSiteManagerMixin, TreeManager): diff --git a/rdmo/projects/migrations/0059_project_progress.py b/rdmo/projects/migrations/0059_project_progress.py new file mode 100644 index 0000000000..abce63ea50 --- /dev/null +++ b/rdmo/projects/migrations/0059_project_progress.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.19 on 2023-08-24 09:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0058_meta'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='progress_count', + field=models.IntegerField(help_text='The number of values for the progress bar.', null=True, verbose_name='Progress count'), + ), + migrations.AddField( + model_name='project', + name='progress_total', + field=models.IntegerField(help_text='The total number of expected values for the progress bar.', null=True, verbose_name='Progress total'), + ), + ] diff --git a/rdmo/projects/models/project.py b/rdmo/projects/models/project.py index eae2128fe2..d13b33a60a 100644 --- a/rdmo/projects/models/project.py +++ b/rdmo/projects/models/project.py @@ -2,7 +2,6 @@ from django.contrib.sites.models import Site from django.core.exceptions import ValidationError from django.db import models -from django.db.models import Exists, OuterRef from django.db.models.signals import pre_delete from django.dispatch import receiver from django.urls import reverse @@ -12,8 +11,7 @@ from mptt.models import MPTTModel, TreeForeignKey from rdmo.core.models import Model -from rdmo.domain.models import Attribute -from rdmo.questions.models import Catalog, Question +from rdmo.questions.models import Catalog from rdmo.tasks.models import Task from rdmo.views.models import View @@ -65,6 +63,16 @@ class Project(MPTTModel, Model): verbose_name=_('Views'), help_text=_('The views that will be used for this project.') ) + progress_total = models.IntegerField( + null=True, + verbose_name=_('Progress total'), + help_text=_('The total number of expected values for the progress bar.') + ) + progress_count = models.IntegerField( + null=True, + verbose_name=_('Progress count'), + help_text=_('The number of values for the progress bar.') + ) class Meta: ordering = ('tree_id', 'level', 'title') @@ -86,36 +94,6 @@ def clean(self): 'parent': [_('A project may not be moved to be a child of itself or one of its descendants.')] }) - @property - def progress(self): - # create a queryset for the attributes of the catalog for this project - # the subquery is used to query only attributes which have a question in the catalog, which is not optional - questions = Question.objects.filter_by_catalog(self.catalog) \ - .filter(attribute_id=OuterRef('pk')).exclude(is_optional=True) - attributes = Attribute.objects.annotate(active=Exists(questions)).filter(active=True).distinct() - - # query the total number of attributes from the qs above - total = attributes.count() - - # query all current values with attributes from the qs above, but where the text, option, or file field is set, - # and count only one value per attribute - values = self.values.filter(snapshot=None) \ - .filter(attribute__in=attributes) \ - .exclude((models.Q(text='') | models.Q(text=None)) & models.Q(option=None) & - (models.Q(file='') | models.Q(file=None))) \ - .distinct().values('attribute').count() - - try: - ratio = values / total - except ZeroDivisionError: - ratio = 0 - - return { - 'total': total, - 'values': values, - 'ratio': ratio - } - @property def catalog_uri(self): if self.catalog is not None: diff --git a/rdmo/projects/permissions.py b/rdmo/projects/permissions.py index c549b02e49..cb924a9c57 100644 --- a/rdmo/projects/permissions.py +++ b/rdmo/projects/permissions.py @@ -62,3 +62,14 @@ class HasProjectPagePermission(HasProjectPermission): def get_required_object_permissions(self, method, model_cls): return ('projects.view_page_object', ) + + +class HasProjectProgressPermission(HasProjectPermission): + + def get_required_object_permissions(self, method, model_cls): + if method == 'GET': + return ('projects.view_project_object', ) + elif method == 'POST': + return ('projects.change_project_progress_object', ) + else: + raise RuntimeError('Unsupported method for HasProjectProgressPermission') diff --git a/rdmo/projects/progress.py b/rdmo/projects/progress.py new file mode 100644 index 0000000000..ef082fa507 --- /dev/null +++ b/rdmo/projects/progress.py @@ -0,0 +1,148 @@ +from collections import defaultdict + +from django.db.models import Exists, OuterRef, Q + +from rdmo.conditions.models import Condition +from rdmo.questions.models import Page, Question, QuestionSet + + +def resolve_conditions(project, values): + # get all conditions for this catalog + pages_conditions_subquery = Page.objects.filter_by_catalog(project.catalog) \ + .filter(conditions=OuterRef('pk')) + questionsets_conditions_subquery = QuestionSet.objects.filter_by_catalog(project.catalog) \ + .filter(conditions=OuterRef('pk')) + questions_conditions_subquery = Question.objects.filter_by_catalog(project.catalog) \ + .filter(conditions=OuterRef('pk')) + + catalog_conditions = Condition.objects.annotate(has_page=Exists(pages_conditions_subquery)) \ + .annotate(has_questionset=Exists(questionsets_conditions_subquery)) \ + .annotate(has_question=Exists(questions_conditions_subquery)) \ + .filter(Q(has_page=True) | Q(has_questionset=True) | Q(has_question=True)) \ + .distinct().select_related('source', 'target_option') + + # evaluate conditions + conditions = set() + for condition in catalog_conditions: + if condition.resolve(values): + conditions.add(condition.id) + + # return all true conditions for this project + return conditions + + +def compute_navigation(section, project, snapshot=None): + # get all values for this project and snapshot + values = project.values.filter(snapshot=snapshot).select_related('attribute', 'option') + + # get true conditions + conditions = resolve_conditions(project, values) + + # compute sets from values (including empty values) + sets = defaultdict(lambda: defaultdict(list)) + for attribute, set_prefix, set_index in values.distinct_list(): + sets[attribute][set_prefix].append(set_index) + + # query distinct, non empty set values + values_list = values.exclude_empty().distinct_list() + + navigation = [] + for catalog_section in project.catalog.elements: + navigation_section = { + 'id': catalog_section.id, + 'uri': catalog_section.uri, + 'title': catalog_section.title, + 'first': catalog_section.elements[0].id if section.elements else None + } + if catalog_section.id == section.id: + navigation_section['pages'] = [] + for page in catalog_section.elements: + pages_conditions = {page.id for page in page.conditions.all()} + show = bool(not pages_conditions or pages_conditions.intersection(conditions)) + + # count the total number of questions, taking sets and conditions into account + counts = count_questions(page, sets, conditions) + + # filter the values_list for the attributes, and compute the total sum of counts + count = len(tuple(filter(lambda value: value[0] in counts.keys(), values_list))) + total = sum(counts.values()) + + navigation_section['pages'].append({ + 'id': page.id, + 'uri': page.uri, + 'title': page.title, + 'show': show, + 'count': count, + 'total': total + }) + + navigation.append(navigation_section) + + return navigation + + +def compute_progress(project, snapshot=None): + # get all values for this project and snapshot + values = project.values.filter(snapshot=snapshot).select_related('attribute', 'option') + + # get true conditions + conditions = resolve_conditions(project, values) + + # compute sets from values (including empty values) + sets = defaultdict(lambda: defaultdict(list)) + for attribute, set_prefix, set_index in values.distinct_list(): + sets[attribute][set_prefix].append(set_index) + + # query distinct, non empty set values + values_list = values.exclude_empty().distinct_list() + + + # count the total number of questions, taking sets and conditions into account + counts = count_questions(project.catalog, sets, conditions) + + # filter the values_list for the attributes, and compute the total sum of counts + count = len(tuple(filter(lambda value: value[0] in counts.keys(), values_list))) + total = sum(counts.values()) + + return count, total + + +def count_questions(element, sets, conditions): + counts = defaultdict(int) + + # obtain the maximum number of set-distinct values the questions in this element + # this number is how often each question is displayed and we will use this number + # to determine how often a question needs to be counted + if isinstance(element, (Page, QuestionSet)) and element.is_collection: + set_count = 0 + + if element.attribute is not None: + child_count = sum(len(set_indexes) for set_indexes in sets[element.attribute.id].values()) + set_count = max(set_count, child_count) + + for child in element.elements: + if isinstance(child, Question): + child_count = sum(len(set_indexes) for set_indexes in sets[child.attribute.id].values()) + set_count = max(set_count, child_count) + else: + set_count = 1 + + # loop over all children of this element + for child in element.elements: + # look for the elements conditions + if isinstance(child, (Page, QuestionSet, Question)): + child_conditions = {condition.id for condition in child.conditions.all()} + else: + child_conditions = [] + + if not child_conditions or child_conditions.intersection(conditions): + if isinstance(child, Question): + # for questions add the set_count to the counts dict + # use the max function, since the same attribute could apear twice in the tree + if child.attribute is not None: + counts[child.attribute.id] = max(counts[child.attribute.id], set_count) + else: + # for everthing else, call this function recursively + counts.update(count_questions(child, sets, conditions)) + + return counts diff --git a/rdmo/projects/rules.py b/rdmo/projects/rules.py index 93a2fec87e..f11061e064 100644 --- a/rdmo/projects/rules.py +++ b/rdmo/projects/rules.py @@ -57,6 +57,7 @@ def is_site_manager_for_current_site(user, request): rules.add_perm('projects.view_project_object', is_project_member | is_site_manager) rules.add_perm('projects.change_project_object', is_project_manager | is_project_owner | is_site_manager) +rules.add_perm('projects.change_project_progress_object', is_project_author | is_project_manager | is_project_owner | is_site_manager) # noqa: E501 rules.add_perm('projects.delete_project_object', is_project_owner | is_site_manager) rules.add_perm('projects.leave_project_object', is_current_project_member) rules.add_perm('projects.export_project_object', is_project_owner | is_project_manager | is_site_manager) diff --git a/rdmo/projects/serializers/v1/overview.py b/rdmo/projects/serializers/v1/overview.py index a0428b9836..cc5cfdf3e2 100644 --- a/rdmo/projects/serializers/v1/overview.py +++ b/rdmo/projects/serializers/v1/overview.py @@ -1,51 +1,18 @@ from rest_framework import serializers from rdmo.projects.models import Project -from rdmo.questions.models import Catalog, Page, Section - - -class PageSerializer(serializers.ModelSerializer): - - class Meta: - model = Page - fields = ( - 'id', - 'title', - 'has_conditions' - ) - - -class SectionSerializer(serializers.ModelSerializer): - - pages = serializers.SerializerMethodField() - - class Meta: - model = Section - fields = ( - 'id', - 'title', - 'pages' - ) - - def get_pages(self, obj): - return PageSerializer(obj.elements, many=True, read_only=True).data +from rdmo.questions.models import Catalog class CatalogSerializer(serializers.ModelSerializer): - sections = serializers.SerializerMethodField() - class Meta: model = Catalog fields = ( 'id', - 'title', - 'sections' + 'title' ) - def get_sections(self, obj): - return SectionSerializer(obj.elements, many=True, read_only=True).data - class ProjectOverviewSerializer(serializers.ModelSerializer): diff --git a/rdmo/projects/serializers/v1/page.py b/rdmo/projects/serializers/v1/page.py index 49fe49fd03..de51adeaa9 100644 --- a/rdmo/projects/serializers/v1/page.py +++ b/rdmo/projects/serializers/v1/page.py @@ -181,6 +181,7 @@ def get_section(self, obj): return { 'id': section.id, 'title': section.title, + 'first': section.elements[0].id if section.elements else None } if section else {} def get_prev_page(self, obj): diff --git a/rdmo/projects/static/projects/css/project_questions.scss b/rdmo/projects/static/projects/css/project_questions.scss index 9f1a92f117..f4141d99b9 100644 --- a/rdmo/projects/static/projects/css/project_questions.scss +++ b/rdmo/projects/static/projects/css/project_questions.scss @@ -302,3 +302,9 @@ .project-progress { margin-bottom: 10px; } +.project-progress-count { + position: absolute; + left: 0; + right: 0; + text-align: center; +} diff --git a/rdmo/projects/static/projects/js/project_questions/services.js b/rdmo/projects/static/projects/js/project_questions/services.js index 92b09fc744..35aa912260 100644 --- a/rdmo/projects/static/projects/js/project_questions/services.js +++ b/rdmo/projects/static/projects/js/project_questions/services.js @@ -9,7 +9,7 @@ angular.module('project_questions') /* configure resources */ var resources = { - projects: $resource(baseurl + 'api/v1/projects/projects/:id/:detail_action/'), + projects: $resource(baseurl + 'api/v1/projects/projects/:id/:detail_action/:detail_id/'), values: $resource(baseurl + 'api/v1/projects/projects/:project/values/:id/:detail_action/'), pages: $resource(baseurl + 'api/v1/projects/projects/:project/pages/:list_action/:id/'), settings: $resource(baseurl + 'api/v1/core/settings/') @@ -147,13 +147,14 @@ angular.module('project_questions') } return service.fetchPage(page_id) + .then(service.fetchNavigation) .then(service.fetchOptions) .then(service.fetchValues) .then(service.fetchConditions) .then(function () { // copy future objects angular.forEach([ - 'page', 'progress', 'attributes', 'questionsets', 'questions', 'valuesets', 'values' + 'page', 'progress', 'attributes', 'questionsets', 'questions', 'valuesets', 'values', 'navigation' ], function (key) { service[key] = angular.copy(future[key]); }); @@ -276,6 +277,16 @@ angular.module('project_questions') }); }; + service.fetchNavigation = function() { + future.navigation = resources.projects.query({ + id: service.project.id, + detail_id: future.page.section.id, + detail_action: 'navigation' + }); + + return future.navigation.$promise + }; + service.initPage = function(page) { // store attributes in a separate array if (page.attribute !== null) future.attributes.push(page.attribute); @@ -891,16 +902,7 @@ angular.module('project_questions') } else if (angular.isDefined(page)) { service.initView(page.id); } else if (angular.isDefined(section)) { - if (angular.isDefined(section.pages)) { - service.initView(section.pages[0].id); - } else { - // jump to first page of the section in breadcrumb - // let section_from_service = service.project.catalog.sections.find(x => x.id === section.id) - var section_from_service = $filter('filter')(service.project.catalog.sections, { - id: section.id - })[0] - service.initView(section_from_service.pages[0].id); - } + service.initView(section.first); } else { service.initView(null); } @@ -909,16 +911,7 @@ angular.module('project_questions') if (angular.isDefined(page)) { service.initView(page.id); } else if (angular.isDefined(section)) { - if (angular.isDefined(section.pages)) { - service.initView(section.pages[0].id); - } else { - // jump to first page of the section in breadcrumb - // let section_from_service = service.project.catalog.sections.find(x => x.id === section.id) - var section_from_service = $filter('filter')(service.project.catalog.sections, { - id: section.id - })[0] - service.initView(section_from_service.pages[0].id); - } + service.initView(section.first); } else { service.initView(null); } @@ -955,14 +948,14 @@ angular.module('project_questions') } } else { // update progress - resources.projects.get({ - id: service.project.id, - detail_action: 'progress' - }, function(response) { - if (service.progress.values != response.values) { + if (service.project.read_only !== true) { + resources.projects.postAction({ + id: service.project.id, + detail_action: 'progress' + }, function(response) { service.progress = response - } - }); + }); + } // check if we need to refresh the site angular.forEach([service.page].concat(service.questionsets), function(questionset) { diff --git a/rdmo/projects/templates/projects/project_detail_header_hierarchy.html b/rdmo/projects/templates/projects/project_detail_header_hierarchy.html index 33d6f62a3f..c7004fc525 100644 --- a/rdmo/projects/templates/projects/project_detail_header_hierarchy.html +++ b/rdmo/projects/templates/projects/project_detail_header_hierarchy.html @@ -19,13 +19,13 @@ {% if can_view_parent_project %} {% if node.id == project.id %} - {{ node.title }} + {{ node.title }} {% project_progress_text node %} {% else %} - {{ node.title }} + {{ node.title }} {% project_progress_text node %} {% endif %} {% else %} - {{ node.title }} + {{ node.title }} {% project_progress_text node %} {% endif %} {% endfor %} diff --git a/rdmo/projects/templates/projects/project_questions_navigation.html b/rdmo/projects/templates/projects/project_questions_navigation.html index f5945d8db2..ba5f062f12 100644 --- a/rdmo/projects/templates/projects/project_questions_navigation.html +++ b/rdmo/projects/templates/projects/project_questions_navigation.html @@ -3,22 +3,27 @@ {% include 'projects/project_questions_navigation_help.html' %}
{% blocktrans trimmed %} -Entries with might be skipped based on your input. +Grey entries will be conditionally skipped based on your input. {% endblocktrans %}
diff --git a/rdmo/projects/templates/projects/project_questions_progress.html b/rdmo/projects/templates/projects/project_questions_progress.html index 9b66e61bb9..b66e5d4a62 100644 --- a/rdmo/projects/templates/projects/project_questions_progress.html +++ b/rdmo/projects/templates/projects/project_questions_progress.html @@ -1,10 +1,15 @@ {% load i18n %} +{% trans 'Name' %} | +{% trans 'Name' %} | +{% trans 'Progress' %} | {% trans 'Created' %} | {% trans 'Last changed' %} | @@ -76,6 +77,9 @@ | + {% project_progress project %} + | {{ project.created }} | diff --git a/rdmo/projects/templatetags/projects_tags.py b/rdmo/projects/templatetags/projects_tags.py index 390a79e4f8..7b64ab9b44 100644 --- a/rdmo/projects/templatetags/projects_tags.py +++ b/rdmo/projects/templatetags/projects_tags.py @@ -1,6 +1,7 @@ from django import template from django.template.defaultfilters import stringfilter from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ from ..models import Membership @@ -11,13 +12,45 @@ def projects_indent(level): string = '' if level > 0: - for _ in range(level - 1): + for i in range(level - 1): string += ' ' string += '• ' return mark_safe('' + string + '') +@register.simple_tag() +def project_progress(project): + if project.progress_count is None or project.progress_total is None: + return '' + + return _('%(count)s of %(total)s') % { + 'count': project.progress_count, + 'total': project.progress_total + } + +@register.simple_tag() +def project_progress_ratio(project): + if project.progress_count is None or project.progress_total is None: + return '' + + try: + ratio = project.progress_count / project.progress_total + except ZeroDivisionError: + ratio = 0 + + return f'{ratio:.0%}' + + +@register.simple_tag() +def project_progress_text(project): + progress = project_progress(project) + if progress: + return _('(%(progress)s progress)') % {'progress': progress} + else: + return '' + + @register.filter() @stringfilter def projects_role(role): diff --git a/rdmo/projects/tests/test_navigation.py b/rdmo/projects/tests/test_navigation.py new file mode 100644 index 0000000000..7f7a9c90b3 --- /dev/null +++ b/rdmo/projects/tests/test_navigation.py @@ -0,0 +1,67 @@ +import pytest + +from rdmo.projects.models import Project +from rdmo.projects.progress import compute_navigation + +sections = ( + 'http://example.com/terms/questions/catalog/individual', + 'http://example.com/terms/questions/catalog/collections', + 'http://example.com/terms/questions/catalog/set', + 'http://example.com/terms/questions/catalog/conditions', + 'http://example.com/terms/questions/catalog/blocks' +) + +# (count, total, show) for each page or section (as default fallback) +result_map = { + 'http://example.com/terms/questions/catalog/individual': (1, 1, True), + 'http://example.com/terms/questions/catalog/individual/autocomplete': (0, 1, True), + 'http://example.com/terms/questions/catalog/collections': (1, 1, True), + 'http://example.com/terms/questions/catalog/collections/autocomplete': (0, 1, True), + 'http://example.com/terms/questions/catalog/set/individual-single': (8, 9, True), + 'http://example.com/terms/questions/catalog/set/individual-collection': (9, 10, True), + 'http://example.com/terms/questions/catalog/set/collection-single': (14, 18, True), + 'http://example.com/terms/questions/catalog/set/collection-collection': (16, 20, True), + 'http://example.com/terms/questions/catalog/conditions/input': (2, 2, True), + 'http://example.com/terms/questions/catalog/conditions/text_contains': (1, 1, True), + 'http://example.com/terms/questions/catalog/conditions/text_empty': (1, 1, False), + 'http://example.com/terms/questions/catalog/conditions/text_equal': (1, 1, True), + 'http://example.com/terms/questions/catalog/conditions/text_greater_than': (1, 1, False), + 'http://example.com/terms/questions/catalog/conditions/text_greater_than_equal': (1, 1, False), + 'http://example.com/terms/questions/catalog/conditions/text_lesser_than': (1, 1, False), + 'http://example.com/terms/questions/catalog/conditions/text_lesser_than_equal': (1, 1, False), + 'http://example.com/terms/questions/catalog/conditions/text_not_empty': (1, 1, True), + 'http://example.com/terms/questions/catalog/conditions/text_not_equal': (1, 1, False), + 'http://example.com/terms/questions/catalog/conditions/option_empty': (1, 1, False), + 'http://example.com/terms/questions/catalog/conditions/option_equal': (1, 1, True), + 'http://example.com/terms/questions/catalog/conditions/option_not_empty': (1, 1, True), + 'http://example.com/terms/questions/catalog/conditions/option_not_equal': (1, 1, False), + 'http://example.com/terms/questions/catalog/conditions/set': (0, 2, True), + 'http://example.com/terms/questions/catalog/conditions/set_set': (0, 2, True), + 'http://example.com/terms/questions/catalog/conditions/optionset': (0, 2, True), + 'http://example.com/terms/questions/catalog/conditions/text_set': (0, 2, True), + 'http://example.com/terms/questions/catalog/blocks/set': (9, 18, True), +} + + +@pytest.mark.parametrize('section_uri', sections) +def test_compute_navigation(db, section_uri): + project = Project.objects.get(id=1) + project.catalog.prefetch_elements() + + section = project.catalog.sections.get(uri=section_uri) + + navigation = compute_navigation(section, project) + assert [item['id'] for item in navigation] == [element.id for element in project.catalog.elements] + + for section in navigation: + if 'pages' in section: + for page in section['pages']: + if page['uri'] in result_map: + count, total, show = result_map[page['uri']] + elif section['uri'] in result_map: + count, total, show = result_map[section['uri']] + else: + raise AssertionError('{uri} not in result_map'.format(**page)) + assert page['count'] == count, page['uri'] + assert page['total'] == total, page['uri'] + assert page['show'] == show, page['uri'] diff --git a/rdmo/projects/tests/test_progress.py b/rdmo/projects/tests/test_progress.py new file mode 100644 index 0000000000..58782ca807 --- /dev/null +++ b/rdmo/projects/tests/test_progress.py @@ -0,0 +1,21 @@ +import pytest + +from rdmo.projects.models import Project +from rdmo.projects.progress import compute_progress + +projects = [1, 11] + +results_map = { + 1: (58, 87), + 11: (0, 29) +} + + +@pytest.mark.parametrize('project_id', projects) +def test_compute_progress(db, project_id): + project = Project.objects.get(id=project_id) + project.catalog.prefetch_elements() + + progress = compute_progress(project) + + assert progress == results_map[project_id] diff --git a/rdmo/projects/tests/test_viewset_project.py b/rdmo/projects/tests/test_viewset_project.py index c5d08d0c40..3e523882f4 100644 --- a/rdmo/projects/tests/test_viewset_project.py +++ b/rdmo/projects/tests/test_viewset_project.py @@ -41,18 +41,17 @@ 'list': 'v1-projects:project-list', 'detail': 'v1-projects:project-detail', 'overview': 'v1-projects:project-overview', + 'navigation': 'v1-projects:project-navigation', 'resolve': 'v1-projects:project-resolve', - 'progress': 'v1-projects:project-progress' } projects = [1, 2, 3, 4, 5] conditions = [1] -project_values = 37 -project_total = 54 catalog_id = 1 catalog_id_not_available = 2 +section_id = 1 @pytest.mark.parametrize('username,password', users) def test_list(db, client, username, password): @@ -241,15 +240,15 @@ def test_overview(db, client, username, password, project_id, condition_id): @pytest.mark.parametrize('username,password', users) @pytest.mark.parametrize('project_id', projects) @pytest.mark.parametrize('condition_id', conditions) -def test_resolve(db, client, username, password, project_id, condition_id): +def test_navigation(db, client, username, password, project_id, condition_id): client.login(username=username, password=password) - url = reverse(urlnames['resolve'], args=[project_id]) + f'?condition={condition_id}' + url = reverse(urlnames['navigation'], args=[project_id, section_id]) response = client.get(url) if project_id in view_project_permission_map.get(username, []): assert response.status_code == 200 - assert isinstance(response.json(), dict) + assert isinstance(response.json(), list) else: if password: assert response.status_code == 404 @@ -259,22 +258,16 @@ def test_resolve(db, client, username, password, project_id, condition_id): @pytest.mark.parametrize('username,password', users) @pytest.mark.parametrize('project_id', projects) -def test_progress(db, client, username, password, project_id): +@pytest.mark.parametrize('condition_id', conditions) +def test_resolve(db, client, username, password, project_id, condition_id): client.login(username=username, password=password) - url = reverse(urlnames['progress'], args=[project_id]) + url = reverse(urlnames['resolve'], args=[project_id]) + f'?condition={condition_id}' response = client.get(url) if project_id in view_project_permission_map.get(username, []): assert response.status_code == 200 assert isinstance(response.json(), dict) - - if project_id == 1: - assert response.json().get('values') == project_values - else: - assert response.json().get('values') == 1 - - assert response.json().get('total') == project_total else: if password: assert response.status_code == 404 diff --git a/rdmo/projects/tests/test_viewset_project_progress.py b/rdmo/projects/tests/test_viewset_project_progress.py new file mode 100644 index 0000000000..6c39a52798 --- /dev/null +++ b/rdmo/projects/tests/test_viewset_project_progress.py @@ -0,0 +1,93 @@ +import pytest + +from django.urls import reverse + +from ..models import Project + +users = ( + ('owner', 'owner'), + ('manager', 'manager'), + ('author', 'author'), + ('guest', 'guest'), + ('api', 'api'), + ('user', 'user'), + ('site', 'site'), + ('anonymous', None), +) + +view_progress_permission_map = { + 'owner': [1, 2, 3, 4, 5, 10], + 'manager': [1, 3, 5, 7], + 'author': [1, 3, 5, 8], + 'guest': [1, 3, 5, 9], + 'api': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + 'site': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] +} + +change_progress_permission_map = { + 'owner': [1, 2, 3, 4, 5, 10], + 'manager': [1, 3, 5, 7], + 'author': [1, 3, 5, 8], + 'api': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + 'site': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] +} + +urlnames = { + 'progress': 'v1-projects:project-progress' +} + +projects = [1, 2, 3, 4, 5] + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('project_id', projects) +def test_progress_get(db, client, username, password, project_id): + client.login(username=username, password=password) + + url = reverse(urlnames['progress'], args=[project_id]) + response = client.get(url) + + if project_id in view_progress_permission_map.get(username, []): + assert response.status_code == 200 + assert isinstance(response.json(), dict) + + project = Project.objects.get(id=project_id) + assert response.json()['count'] == project.progress_count + assert response.json()['total'] == project.progress_total + + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('project_id', projects) +def test_progress_post(db, client, username, password, project_id): + client.login(username=username, password=password) + + if project_id in change_progress_permission_map.get(username, []): + # set project count and value to a different value + project = Project.objects.get(id=project_id) + project.progress_count = 0 + project.progress_total = 0 + project.save() + + url = reverse(urlnames['progress'], args=[project_id]) + response = client.post(url) + + if project_id in change_progress_permission_map.get(username, []): + assert response.status_code == 200 + assert isinstance(response.json(), dict) + + project.refresh_from_db() + assert response.json()['count'] > 0 + assert response.json()['total'] > 0 + + else: + if project_id in view_progress_permission_map.get(username, []): + assert response.status_code == 403 + elif password: + assert response.status_code == 404 + else: + assert response.status_code == 401 diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 257c510dc1..0d3ab9e29e 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -1,6 +1,6 @@ from django.conf import settings from django.contrib.sites.shortcuts import get_current_site -from django.db.models import prefetch_related_objects +from django.core.exceptions import ObjectDoesNotExist from django.http import Http404, HttpResponseRedirect from django.utils.translation import gettext_lazy as _ @@ -8,7 +8,6 @@ from rest_framework.decorators import action from rest_framework.exceptions import NotFound from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin -from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelViewSet @@ -26,7 +25,13 @@ from .filters import SnapshotFilterBackend, ValueFilterBackend from .models import Continuation, Integration, Invite, Issue, Membership, Project, Snapshot, Value -from .permissions import HasProjectPagePermission, HasProjectPermission, HasProjectsPermission +from .permissions import ( + HasProjectPagePermission, + HasProjectPermission, + HasProjectProgressPermission, + HasProjectsPermission, +) +from .progress import compute_navigation, compute_progress from .serializers.v1 import ( IntegrationSerializer, InviteSerializer, @@ -65,17 +70,27 @@ class ProjectViewSet(ModelViewSet): def get_queryset(self): return Project.objects.filter_user(self.request.user).select_related('catalog') - @action(detail=True, permission_classes=(IsAuthenticated, )) + @action(detail=True, permission_classes=(HasModelPermission | HasProjectPermission, )) def overview(self, request, pk=None): project = self.get_object() - - # prefetch only the pages (and their conditions) - prefetch_related_objects([project.catalog], - 'catalog_sections__section__section_pages__page__conditions') - serializer = ProjectOverviewSerializer(project, context={'request': request}) return Response(serializer.data) + @action(detail=True, url_path=r'navigation/(?P
---|