diff --git a/rdmo/core/assets/js/components/Modal.js b/rdmo/core/assets/js/components/Modal.js index 22c07abeab..2ef42a543b 100644 --- a/rdmo/core/assets/js/components/Modal.js +++ b/rdmo/core/assets/js/components/Modal.js @@ -8,9 +8,13 @@ const Modal = ({ title, show, modalProps, submitLabel, submitProps, onClose, onS

{title}

- - { children } - + { + children && ( + + { children } + + ) + } diff --git a/rdmo/projects/assets/js/interview/components/main/question/QuestionCopyValues.js b/rdmo/projects/assets/js/interview/components/main/question/QuestionCopyValues.js index 699c22a767..181098590b 100644 --- a/rdmo/projects/assets/js/interview/components/main/question/QuestionCopyValues.js +++ b/rdmo/projects/assets/js/interview/components/main/question/QuestionCopyValues.js @@ -5,17 +5,13 @@ import { isEmpty } from 'lodash' import { isEmptyValue } from '../../../utils/value' const QuestionCopyValues = ({ question, sets, values, siblings, currentSet, copyValue }) => { - const handleCopyValues = () => { - values.forEach((value) => copyValue(value)) - } - const button = question.widget_type == 'checkbox' ? ( - ) : ( - ) diff --git a/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSet.js b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSet.js index a0a30396ed..c9131f6a0b 100644 --- a/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSet.js +++ b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSet.js @@ -8,12 +8,13 @@ import Question from '../question/Question' import QuestionSetAddSet from './QuestionSetAddSet' import QuestionSetAddSetHelp from './QuestionSetAddSetHelp' +import QuestionSetCopySet from './QuestionSetCopySet' import QuestionSetHelp from './QuestionSetHelp' import QuestionSetHelpTemplate from './QuestionSetHelpTemplate' import QuestionSetRemoveSet from './QuestionSetRemoveSet' const QuestionSet = ({ templates, questionset, sets, values, disabled, isManager, - parentSet, createSet, updateSet, deleteSet, + parentSet, createSet, updateSet, deleteSet, copySet, createValue, updateValue, deleteValue, copyValue }) => { const setPrefix = getChildPrefix(parentSet) @@ -35,7 +36,8 @@ const QuestionSet = ({ templates, questionset, sets, values, disabled, isManager currentSets.map((set, setIndex) => (
- + +
{ @@ -55,6 +57,7 @@ const QuestionSet = ({ templates, questionset, sets, values, disabled, isManager createSet={createSet} updateSet={updateSet} deleteSet={deleteSet} + copySet={copySet} createValue={createValue} updateValue={updateValue} deleteValue={deleteValue} @@ -115,6 +118,7 @@ QuestionSet.propTypes = { createSet: PropTypes.func.isRequired, updateSet: PropTypes.func.isRequired, deleteSet: PropTypes.func.isRequired, + copySet: PropTypes.func.isRequired, createValue: PropTypes.func.isRequired, updateValue: PropTypes.func.isRequired, deleteValue: PropTypes.func.isRequired, diff --git a/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetCopyModal.js b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetCopyModal.js new file mode 100644 index 0000000000..151059eb60 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetCopyModal.js @@ -0,0 +1,21 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import Modal from 'rdmo/core/assets/js/components/Modal' + +const QuestionSetCopyModal = ({ title, show, onClose, onSubmit }) => { + return ( + + + ) +} + +QuestionSetCopyModal.propTypes = { + title: PropTypes.string.isRequired, + show: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, +} + +export default QuestionSetCopyModal diff --git a/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetCopySet.js b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetCopySet.js new file mode 100644 index 0000000000..cc2e5492d5 --- /dev/null +++ b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetCopySet.js @@ -0,0 +1,43 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { capitalize, last } from 'lodash' + +import useModal from 'rdmo/core/assets/js/hooks/useModal' + +import QuestionSetCopyModal from './QuestionSetCopyModal' + +const QuestionCopySet = ({ questionset, sets, currentSet, copySet }) => { + const modal = useModal() + + const handleCopySet = () => { + copySet(currentSet, null, { + set_prefix: currentSet.set_prefix, + set_index: last(sets) ? last(sets).set_index + 1 : 0, + }) + modal.close() + } + + return questionset.is_collection && ( + <> + + + + + ) +} + +QuestionCopySet.propTypes = { + questionset: PropTypes.object.isRequired, + sets: PropTypes.array.isRequired, + currentSet: PropTypes.object.isRequired, + copySet: PropTypes.func.isRequired +} + +export default QuestionCopySet diff --git a/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetRemoveSet.js b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetRemoveSet.js index a7364a8909..06a0dcae61 100644 --- a/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetRemoveSet.js +++ b/rdmo/projects/assets/js/interview/components/main/questionset/QuestionSetRemoveSet.js @@ -6,12 +6,12 @@ import useModal from 'rdmo/core/assets/js/hooks/useModal' import QuestionSetDeleteModal from './QuestionSetDeleteModal' -const QuestionAddSet = ({ questionset, set, deleteSet }) => { +const QuestionRemoveSet = ({ questionset, currentSet, deleteSet }) => { const {show: showDeleteModal, open: openDeleteModal, close: closeDeleteModal} = useModal() const handleDeleteSet = () => { - deleteSet(set) + deleteSet(currentSet) closeDeleteModal() } @@ -31,10 +31,10 @@ const QuestionAddSet = ({ questionset, set, deleteSet }) => { ) } -QuestionAddSet.propTypes = { +QuestionRemoveSet.propTypes = { questionset: PropTypes.object.isRequired, - set: PropTypes.object.isRequired, + currentSet: PropTypes.object.isRequired, deleteSet: PropTypes.func.isRequired } -export default QuestionAddSet +export default QuestionRemoveSet diff --git a/rdmo/projects/assets/js/interview/components/main/widget/SelectInput.js b/rdmo/projects/assets/js/interview/components/main/widget/SelectInput.js index a0d694957e..b53f1f6baf 100644 --- a/rdmo/projects/assets/js/interview/components/main/widget/SelectInput.js +++ b/rdmo/projects/assets/js/interview/components/main/widget/SelectInput.js @@ -22,14 +22,10 @@ import OptionText from './common/OptionText' const SelectInput = ({ question, value, options, disabled, creatable, updateValue, buttons }) => { const [inputValue, setInputValue] = useState('') - // const [isOpen, setIsOpen] = useState(false) const handleChange = (option) => { if (isNil(option)) { - // close the select input when the value is reset - // setIsOpen(false) setInputValue('') - updateValue(value, {}) } else if (option.__isNew__ === true) { updateValue(value, { @@ -85,6 +81,7 @@ const SelectInput = ({ question, value, options, disabled, creatable, updateValu const isAsync = question.optionsets.some((optionset) => optionset.has_search) const selectProps = { + key: value.id, classNamePrefix: 'react-select', className: classnames, backspaceRemovesValue: false, diff --git a/rdmo/projects/assets/js/interview/containers/Main.js b/rdmo/projects/assets/js/interview/containers/Main.js index a8f6189fe2..be0b98f316 100644 --- a/rdmo/projects/assets/js/interview/containers/Main.js +++ b/rdmo/projects/assets/js/interview/containers/Main.js @@ -54,6 +54,7 @@ const Main = ({ config, settings, templates, user, project, interview, configAct updateSet={interviewActions.updateSet} deleteSet={interviewActions.deleteSet} copyValue={interviewActions.copyValue} + copySet={interviewActions.copySet} /> ) } diff --git a/rdmo/projects/assets/js/interview/reducers/interviewReducer.js b/rdmo/projects/assets/js/interview/reducers/interviewReducer.js index 39ac156bc2..d62bcd60cf 100644 --- a/rdmo/projects/assets/js/interview/reducers/interviewReducer.js +++ b/rdmo/projects/assets/js/interview/reducers/interviewReducer.js @@ -25,7 +25,10 @@ import { CREATE_SET, DELETE_SET_INIT, DELETE_SET_SUCCESS, - DELETE_SET_ERROR + DELETE_SET_ERROR, + COPY_SET_INIT, + COPY_SET_SUCCESS, + COPY_SET_ERROR } from '../actions/actionTypes' const initialState = { @@ -76,6 +79,8 @@ export default function interviewReducer(state = initialState, action) { values: state.values.filter((value) => !action.values.includes(value)), sets: state.sets.filter((set) => !action.sets.includes(set)) } + case COPY_SET_SUCCESS: + return { ...state, values: action.values, sets: action.sets } case FETCH_PAGE_INIT: case FETCH_NAVIGATION_INIT: case FETCH_OPTIONS_INIT: @@ -97,6 +102,7 @@ export default function interviewReducer(state = initialState, action) { )) } case DELETE_SET_INIT: + case COPY_SET_INIT: return { ...state, errors: [] } case FETCH_PAGE_ERROR: case FETCH_NAVIGATION_ERROR: @@ -117,6 +123,8 @@ export default function interviewReducer(state = initialState, action) { case DELETE_VALUE_ERROR: case DELETE_SET_ERROR: return { ...state, errors: [...state.errors, { actionType: action.type, ...action.error }] } + case COPY_SET_ERROR: + return { ...state, errors: [...state.errors, { actionType: action.type, ...action.error }] } default: return state } diff --git a/rdmo/projects/assets/js/interview/utils/set.js b/rdmo/projects/assets/js/interview/utils/set.js index 282fa90b21..9a576875f9 100644 --- a/rdmo/projects/assets/js/interview/utils/set.js +++ b/rdmo/projects/assets/js/interview/utils/set.js @@ -1,4 +1,4 @@ -import { isEmpty, isNil, toNumber, toString, last } from 'lodash' +import { isEmpty, isNil, toNumber, toString, last, sortBy } from 'lodash' import SetFactory from '../factories/SetFactory' @@ -27,7 +27,7 @@ const getDescendants = (items, set) => { } const gatherSets = (values) => { - return values.reduce((sets, value) => { + const sets = values.reduce((sets, value) => { if (sets.find((set) => ( (set.set_prefix === value.set_prefix) && (set.set_index === value.set_index) @@ -40,6 +40,8 @@ const gatherSets = (values) => { })] } }, []) + + return sortBy(sets, ['set_prefix', 'set_index']) } const initSets = (sets, element, setPrefix) => { diff --git a/rdmo/projects/assets/js/projects/components/main/Projects.js b/rdmo/projects/assets/js/projects/components/main/Projects.js index 6f15f8e7b8..fb9b672e5a 100644 --- a/rdmo/projects/assets/js/projects/components/main/Projects.js +++ b/rdmo/projects/assets/js/projects/components/main/Projects.js @@ -116,20 +116,36 @@ const Projects = ({ config, configActions, currentUserObject, projectsActions, p if (myProjects) { visibleColumns.splice(2, 0, 'role') - columnWidths = ['35%', '20%', '20%', '20%', '5%'] + columnWidths = ['40%', '18%', '18%', '18%', '6%'] } else { visibleColumns.splice(2, 0, 'created') visibleColumns.splice(2, 0, 'owner') - columnWidths = ['35%', '10%', '20%', '20%', '20%', '5%'] + columnWidths = ['30%', '10%', '18%', '18%', '18%', '6%'] } const cellFormatters = { title: (content, row) => renderTitle(content, row), role: (_content, row) => { const { rolesString } = getUserRoles(row, currentUserId) - return rolesString + return <> + { + rolesString &&

{rolesString}

+ } + { + row.visibility &&

{row.visibility}

+ } + }, - owner: (_content, row) => row.owners.map(owner => `${owner.first_name} ${owner.last_name}`).join('; '), + owner: (_content, row) => ( + <> +

+ {row.owners.map(owner => `${owner.first_name} ${owner.last_name}`).join('; ')} +

+ { + row.visibility &&

{row.visibility}

+ } + + ), progress: (_content, row) => getProgressString(row), created: content => useFormattedDateTime(content, language), last_changed: content => useFormattedDateTime(content, language), diff --git a/rdmo/projects/assets/scss/interview.scss b/rdmo/projects/assets/scss/interview.scss index 20eb128e67..4fa5b2a1c4 100644 --- a/rdmo/projects/assets/scss/interview.scss +++ b/rdmo/projects/assets/scss/interview.scss @@ -100,22 +100,20 @@ .interview-block-options { position: absolute; - top: 0; - right: 0; + top: 6px; + right: 8px; z-index: 5; + display: flex; + gap: 4px; + + .btn-copy-set, .btn-remove-set { opacity: 0.8; line-height: 20px; font-size: 14px; - position: absolute; - z-index: 5; - top: 0; - right: 0; - - padding-left: 8px; - padding-right: 8px; + padding: 0; &:hover { opacity: 1; @@ -372,6 +370,10 @@ display: inline; } } + + .react-select { + max-width: 100%; + } } .badge-optional { diff --git a/rdmo/projects/managers.py b/rdmo/projects/managers.py index a6d49e4ff6..20764ae3b6 100644 --- a/rdmo/projects/managers.py +++ b/rdmo/projects/managers.py @@ -155,6 +155,29 @@ def exclude_empty(self): def distinct_list(self): return self.order_by('attribute').values_list('attribute', 'set_prefix', 'set_index').distinct() + def filter_set(self, set_value): + # get the catalog and prefetch most elements of the catalog + catalog = set_value.project.catalog + catalog.prefetch_elements() + + # Get all attributes from matching elements and their descendants + attributes = { + descendant.attribute + for element in (catalog.pages + catalog.questions) + if element.attribute == set_value.attribute + for descendant in element.descendants + } + + # construct the set_prefix for descendants for this set + descendants_set_prefix = \ + f'{set_value.set_prefix}|{set_value.set_index}' if set_value.set_prefix else str(set_value.set_index) + + # collect all values for this set and all descendants + return self.filter(attribute__in=attributes).filter( + Q(set_prefix=set_value.set_prefix, set_index=set_value.set_index) | + Q(set_prefix__startswith=descendants_set_prefix) + ) + class ProjectManager(CurrentSiteManagerMixin, TreeManager): diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index 8e61748193..26ea38402d 100644 --- a/rdmo/projects/serializers/v1/__init__.py +++ b/rdmo/projects/serializers/v1/__init__.py @@ -5,6 +5,7 @@ from rest_framework import serializers +from rdmo.domain.models import Attribute from rdmo.questions.models import Catalog from rdmo.services.validators import ProviderValidator @@ -64,6 +65,8 @@ def get_queryset(self): last_changed = serializers.DateTimeField(read_only=True) + visibility = serializers.CharField(source='visibility.get_help_display', read_only=True) + class Meta: model = Project fields = ( @@ -84,7 +87,8 @@ class Meta: 'site', 'views', 'progress_total', - 'progress_count' + 'progress_count', + 'visibility' ) read_only_fields = ( 'snapshots', @@ -404,6 +408,8 @@ class Meta: class ValueSerializer(serializers.ModelSerializer): + attribute = serializers.PrimaryKeyRelatedField(queryset=Attribute.objects.all(), required=True) + class Meta: model = Value fields = ( diff --git a/rdmo/projects/templates/projects/project_form_visibility.html b/rdmo/projects/templates/projects/project_form_visibility.html index 2356b821ee..4c5a736c5c 100644 --- a/rdmo/projects/templates/projects/project_form_visibility.html +++ b/rdmo/projects/templates/projects/project_form_visibility.html @@ -19,12 +19,12 @@

{% endblocktrans %}

- {% if object.visibility and 'sites' in form.fields or 'groups' in form.fields %} + {% if not object.visibility %} + {% bootstrap_form submit=_('Make visible') %} + {% elif object.visibility and 'sites' in form.fields or 'groups' in form.fields %} {% bootstrap_form submit=_('Update visibility') delete=_('Remove visibility') %} - {% elif object.visibility %} - {% bootstrap_form delete=_('Remove visibility') %} {% else %} - {% bootstrap_form submit=_('Make visible') %} + {% bootstrap_form delete=_('Remove visibility') %} {% endif %} {% endblock %} diff --git a/rdmo/projects/tests/test_utils.py b/rdmo/projects/tests/test_utils.py index db2bfe2c51..a3a182c33f 100644 --- a/rdmo/projects/tests/test_utils.py +++ b/rdmo/projects/tests/test_utils.py @@ -7,8 +7,8 @@ from rdmo.core.tests.utils import compute_checksum from ..filters import ProjectFilter -from ..models import Project -from ..utils import copy_project, set_context_querystring_with_filter_and_page +from ..models import Project, Value +from ..utils import compute_set_prefix_from_set_value, copy_project, set_context_querystring_with_filter_and_page GET_queries = [ 'page=2&title=project', @@ -17,6 +17,18 @@ '' ] +SET_VALUES = [ + ({'set_prefix': '' , 'set_index': 1}, {'set_prefix': '0'}, '1'), + ({'set_prefix': '' , 'set_index': 1}, {'set_prefix': '0|0'}, '1|0'), + ({'set_prefix': '' , 'set_index': 1}, {'set_prefix': '0|0|0'}, '1|0|0'), + ({'set_prefix': '' , 'set_index': 2}, {'set_prefix': '0'}, '2'), + ({'set_prefix': '' , 'set_index': 2}, {'set_prefix': '0|0'}, '2|0'), + ({'set_prefix': '' , 'set_index': 2}, {'set_prefix': '0|0|0'}, '2|0|0'), + ({'set_prefix': '0' , 'set_index': 1}, {'set_prefix': '0|0'}, '0|1'), + ({'set_prefix': '0' , 'set_index': 1}, {'set_prefix': '0|0|0'}, '0|1|0'), + ({'set_prefix': '0|0', 'set_index': 1}, {'set_prefix': '0|0|0'}, '0|0|1'), +] + @pytest.mark.parametrize('GET_query', GET_queries) def test_set_context_querystring_with_filter_and_page(GET_query): querydict = QueryDict(GET_query) @@ -128,3 +140,8 @@ def test_copy_project(db, files): compute_checksum(value.file.open('rb').read()) else: assert not value.file + + +@pytest.mark.parametrize('set_value, value, result', SET_VALUES) +def test_compute_set_prefix_from_set_value(set_value, value, result): + assert compute_set_prefix_from_set_value(Value(**set_value), Value(**value)) == result diff --git a/rdmo/projects/tests/test_viewset_project_value.py b/rdmo/projects/tests/test_viewset_project_value.py index aaa1fb2693..3359527fb9 100644 --- a/rdmo/projects/tests/test_viewset_project_value.py +++ b/rdmo/projects/tests/test_viewset_project_value.py @@ -1,3 +1,4 @@ +import json from pathlib import Path import pytest @@ -30,7 +31,7 @@ 'site': [1, 2, 3, 4, 5, 12] } -add_value_permission_map = change_value_permission_map = delete_value_permission_map = { +add_value_permission_map = change_value_permission_map = delete_value_permission_map = copy_value_permission_map = { 'owner': [1, 2, 3, 4, 5, 12], 'manager': [1, 3, 5], 'author': [1, 3, 5], @@ -76,6 +77,7 @@ ('phone', '+49 (0) 1337 12345678') ) + @pytest.mark.parametrize('username,password', users) @pytest.mark.parametrize('project_id', projects) def test_list(db, client, username, password, project_id): @@ -248,10 +250,50 @@ def test_delete(db, client, username, password, value_id): assert Value.objects.filter(pk=value_id).exists() +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('value_id, set_values_count', set_values) +def test_copy_set(db, client, username, password, value_id, set_values_count): + client.login(username=username, password=password) + set_value = Value.objects.get(id=value_id) + values_count = Value.objects.count() + + url = reverse(urlnames['set'], args=[set_value.project_id, value_id]) + data = { + 'attribute': set_value.attribute.id, + 'set_prefix': set_value.set_prefix, + 'set_index': 2, + 'text': 'new' + } + response = client.post(url, data=json.dumps(data), content_type="application/json") + + if set_value.project_id in copy_value_permission_map.get(username, []): + assert response.status_code == 201 + assert len(response.json()) == set_values_count + 1 + assert Value.objects.get( + project=set_value.project_id, + snapshot=None, + **data + ) + assert Value.objects.count() == values_count + set_values_count + 1 # one is for set/id + for value_data in response.json(): + if value_data['set_prefix'] == data['set_prefix']: + assert value_data['set_index'] == data['set_index'] + else: + set_prefix_split = value_data['set_prefix'].split('|') + assert set_prefix_split[0] == str(data['set_index']) + + elif set_value.project_id in view_value_permission_map.get(username, []): + assert response.status_code == 403 + assert Value.objects.count() == values_count + else: + assert response.status_code == 404 + assert Value.objects.count() == values_count + + @pytest.mark.parametrize('username,password', users) @pytest.mark.parametrize('project_id', projects) -@pytest.mark.parametrize('value_id,set_values_count', set_values) -def test_set(db, client, username, password, project_id, value_id, set_values_count): +@pytest.mark.parametrize('value_id, set_values_count', set_values) +def test_delete_set(db, client, username, password, project_id, value_id, set_values_count): client.login(username=username, password=password) value_exists = Value.objects.filter(project_id=project_id, snapshot=None, id=value_id).exists() values_count = Value.objects.count() diff --git a/rdmo/projects/utils.py b/rdmo/projects/utils.py index ab619b54b4..06153b855e 100644 --- a/rdmo/projects/utils.py +++ b/rdmo/projects/utils.py @@ -261,3 +261,11 @@ def get_upload_accept(): else: return None return ','.join(accept) + + +def compute_set_prefix_from_set_value(set_value, value): + set_prefix_length = len(set_value.set_prefix.split('|')) if set_value.set_prefix else 0 + return '|'.join([ + str(set_value.set_index) if (index == set_prefix_length) else value + for index, value in enumerate(value.set_prefix.split('|')) + ]) diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index a0221ee894..9eccecfa28 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -1,14 +1,14 @@ from django.conf import settings from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import ObjectDoesNotExist -from django.db.models import OuterRef, Prefetch, Q, Subquery +from django.db.models import OuterRef, Prefetch, Subquery from django.db.models.functions import Coalesce, Greatest from django.http import Http404, HttpResponseRedirect from django.utils.translation import gettext_lazy as _ from rest_framework import serializers, status from rest_framework.decorators import action -from rest_framework.exceptions import NotFound +from rest_framework.exceptions import MethodNotAllowed, NotFound from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin from rest_framework.pagination import PageNumberPagination from rest_framework.permissions import IsAuthenticated @@ -75,7 +75,13 @@ ) from .serializers.v1.overview import CatalogSerializer, ProjectOverviewSerializer from .serializers.v1.page import PageSerializer -from .utils import check_conditions, copy_project, get_upload_accept, send_invite_email +from .utils import ( + check_conditions, + compute_set_prefix_from_set_value, + copy_project, + get_upload_accept, + send_invite_email, +) class ProjectPagination(PageNumberPagination): @@ -115,7 +121,7 @@ def get_queryset(self): 'snapshots', 'views', Prefetch('memberships', queryset=Membership.objects.select_related('user'), to_attr='memberships_list') - ).select_related('catalog') + ).select_related('catalog', 'visibility') # prepare subquery for last_changed last_changed_subquery = Subquery( @@ -497,34 +503,60 @@ def get_queryset(self): # this is needed for the swagger ui return Value.objects.none() - @action(detail=True, methods=['DELETE'], + @action(detail=True, methods=['POST', 'DELETE'], url_path='set', permission_classes=(HasModelPermission | HasProjectPermission, )) def set(self, request, parent_lookup_project, pk=None): + if request.method == 'POST': + return self.copy_set(request, parent_lookup_project, pk) + elif request.method == 'DELETE': + return self.delete_set(request, parent_lookup_project, pk) + else: + raise MethodNotAllowed + + def copy_set(self, request, parent_lookup_project, pk=None): + # copy all values for questions in questionset collections with the attribute + # for this value and the same set_prefix and set_index + currentValue = self.get_object() + + # collect all values for this set and all descendants + currentValues = self.get_queryset().filter_set(currentValue) + + # de-serialize the posted new set value and save it, use the ValueSerializer + # instead of ProjectValueSerializer, since the latter does not include project + set_value_serializer = ValueSerializer(data={ + 'project': parent_lookup_project, + **request.data + }) + set_value_serializer.is_valid(raise_exception=True) + set_value = set_value_serializer.save() + set_value_data = set_value_serializer.data + + # create new values for the new set + values = [] + for value in currentValues: + value.id = None + if value.set_prefix == set_value.set_prefix: + value.set_index = set_value.set_index + else: + value.set_prefix = compute_set_prefix_from_set_value(set_value, value) + values.append(value) + + # bulk create the new values + values = Value.objects.bulk_create(values) + values_data = [ValueSerializer(instance=value).data for value in values] + + # return all new values + headers = self.get_success_headers(set_value_serializer.data) + return Response([set_value_data, *values_data], status=status.HTTP_201_CREATED, headers=headers) + + def delete_set(self, request, parent_lookup_project, pk=None): # delete all values for questions in questionset collections with the attribute # for this value and the same set_prefix and set_index - value = self.get_object() - value.delete() - - # prefetch most elements of the catalog - self.project.catalog.prefetch_elements() - - # collect the attributes of all questions of all pages or questionsets - # of this catalog, which have the attribute of this value - attributes = set() - elements = self.project.catalog.pages + self.project.catalog.questions - for element in elements: - if element.attribute == value.attribute: - attributes.update([descendant.attribute for descendant in element.descendants]) - - # construct the set_prefix for descendants for this set - descendants_set_prefix = f'{value.set_prefix}|{value.set_index}' if value.set_prefix else str(value.set_index) - - # delete all values for this set and all descendants - values = self.get_queryset().filter(attribute__in=attributes) \ - .filter( - Q(set_prefix=value.set_prefix, set_index=value.set_index) | - Q(set_prefix__startswith=descendants_set_prefix) - ) + set_value = self.get_object() + set_value.delete() + + # collect all values for this set and all descendants and delete them + values = self.get_queryset().filter_set(set_value) values.delete() return Response(status=204)