diff --git a/kobo/apps/audit_log/models.py b/kobo/apps/audit_log/models.py index 81c688147a..c13f22e9eb 100644 --- a/kobo/apps/audit_log/models.py +++ b/kobo/apps/audit_log/models.py @@ -19,6 +19,7 @@ get_client_ip, get_human_readable_client_user_agent, ) +from kobo.apps.subsequences.constants import Action from kpi.constants import ( ACCESS_LOG_LOGINAS_AUTH_TYPE, ACCESS_LOG_SUBMISSION_AUTH_TYPE, @@ -404,6 +405,8 @@ def create_from_request(cls, request: WSGIRequest): 'submissions-list': cls._create_from_submission_request, 'submission-detail': cls._create_from_submission_request, 'submission-supplement': cls._create_from_submission_extra_request, + 'advanced-features-list': cls._create_from_question_advanced_feature_request, # noqa + 'advanced-features-detail': cls._create_from_question_advanced_feature_request, # noqa } url_name = request.resolver_match.url_name method = url_name_to_action.get(url_name, None) @@ -787,6 +790,32 @@ def _create_from_permissions_request(cls, request): ) ProjectHistoryLog.objects.bulk_create(logs) + @classmethod + def _create_from_question_advanced_feature_request(cls, request): + initial_data = getattr(request, 'initial_data', None) + updated_data = getattr(request, 'updated_data', None) + source_data = updated_data if updated_data else initial_data + asset_uid = request.resolver_match.kwargs['parent_lookup_asset'] + if not source_data: + return + if source_data.get('action') != Action.QUAL: + return + owner = source_data.pop('asset.owner.username') + object_id = source_data.pop('object_id') + metadata = { + 'asset_uid': asset_uid, + 'log_subtype': PROJECT_HISTORY_LOG_PROJECT_SUBTYPE, + 'ip_address': get_client_ip(request), + 'source': get_human_readable_client_user_agent(request), + 'project_owner': owner, + } + action = AuditAction.UPDATE_QA + metadata['qa'] = {PROJECT_HISTORY_LOG_METADATA_FIELD_NEW: source_data['params']} + user = get_database_user(request.user) + ProjectHistoryLog.objects.create( + user=user, object_id=object_id, action=action, metadata=metadata + ) + @classmethod def _create_from_v1_export(cls, request): updated_data = getattr(request, 'updated_data', None) diff --git a/kobo/apps/audit_log/tests/test_project_history_logs.py b/kobo/apps/audit_log/tests/test_project_history_logs.py index 817c73a40b..e8fda79694 100644 --- a/kobo/apps/audit_log/tests/test_project_history_logs.py +++ b/kobo/apps/audit_log/tests/test_project_history_logs.py @@ -27,6 +27,8 @@ remove_uuid_prefix, ) from kobo.apps.openrosa.libs.utils.logger_tools import dict2xform +from kobo.apps.subsequences.constants import Action +from kobo.apps.subsequences.models import QuestionAdvancedFeature from kpi.constants import ( ASSET_TYPE_TEMPLATE, CLONE_ARG_NAME, @@ -589,6 +591,108 @@ def test_update_qa_creates_log(self): request_data['advanced_features']['_actionConfigs'], ) + def test_add_qa_creates_log(self): + request_data = { + 'action': 'qual', + 'question_xpath': 'Audio', + 'params': [ + { + 'labels': {'_default': 'wherefore?'}, + 'uuid': '12345', + 'type': 'qualText', + } + ], + } + metadata = self._base_project_history_log_test( + self.client.post, + reverse( + self._get_endpoint('advanced-features-list'), args=[self.asset.uid] + ), + request_data=request_data, + expected_action=AuditAction.UPDATE_QA, + expected_subtype=PROJECT_HISTORY_LOG_PROJECT_SUBTYPE, + ) + self.assertEqual(metadata['qa']['new'], request_data['params']) + + def test_failed_add_qa_does_not_create_log(self): + request_data = { + 'action': 'qual', + 'question_xpath': 'Audio', + 'params': [{'bad': 'params'}], + } + self.client.post( + reverse( + self._get_endpoint('advanced-features-list'), args=[self.asset.uid] + ), + data=request_data, + ) + self.assertEqual(ProjectHistoryLog.objects.count(), 0) + + def test_add_other_advanced_feature_does_not_create_log(self): + request_data = { + 'action': 'manual_transcription', + 'question_xpath': 'Audio', + 'params': [{'language': 'en'}], + } + self.client.post( + reverse( + self._get_endpoint('advanced-features-list'), args=[self.asset.uid] + ), + data=request_data, + ) + self.assertEqual(ProjectHistoryLog.objects.count(), 0) + + def test_modify_qa_creates_log(self): + question_qual_action = QuestionAdvancedFeature.objects.create( + asset=self.asset, + action=Action.QUAL, + question_xpath='q1', + params=[ + {'labels': {'_default': 'why?'}, 'uuid': '12345', 'type': 'qualText'} + ], + ) + request_data = { + 'params': [ + { + 'labels': {'_default': 'wherefore?'}, + 'uuid': '12345', + 'type': 'qualText', + } + ] + } + metadata = self._base_project_history_log_test( + self.client.patch, + reverse( + self._get_endpoint('advanced-features-detail'), + args=[self.asset.uid, question_qual_action.uid], + ), + request_data=request_data, + expected_action=AuditAction.UPDATE_QA, + expected_subtype=PROJECT_HISTORY_LOG_PROJECT_SUBTYPE, + ) + self.assertEqual(metadata['qa']['new'], request_data['params']) + + def test_failed_modify_qa_does_not_create_log(self): + question_qual_action = QuestionAdvancedFeature.objects.create( + asset=self.asset, + action=Action.QUAL, + question_xpath='q1', + params=[ + {'labels': {'_default': 'why?'}, 'uuid': '12345', 'type': 'qualText'} + ], + ) + request_data = {'params': [{'bad': 'params'}]} + self.client.patch( + reverse( + self._get_endpoint('advanced-features-detail'), + args=[self.asset.uid, question_qual_action.uid], + ), + request_data=request_data, + expected_action=AuditAction.UPDATE_QA, + expected_subtype=PROJECT_HISTORY_LOG_PROJECT_SUBTYPE, + ) + self.assertEqual(ProjectHistoryLog.objects.count(), 0) + @data(True, False) def test_register_service_creates_log(self, use_v2): request_data = { diff --git a/kobo/apps/subsequences/actions/base.py b/kobo/apps/subsequences/actions/base.py index 6ec6abd893..841e061199 100644 --- a/kobo/apps/subsequences/actions/base.py +++ b/kobo/apps/subsequences/actions/base.py @@ -458,6 +458,17 @@ def run_external_process( """ raise NotImplementedError + def update_params(self, incoming_params): + """ + Returns the result of updating current params with incoming ones from + a request. May be overridden, eg, to prevent deletion of existing lanugages + for transcriptions/translations + Defaults to replacing the existing params with the new ones. + Should raise an error if the incoming params are not well-formatted + """ + self.validate_params(incoming_params) + self.params = incoming_params + def _inject_data_schema(self, destination_schema: dict, skipped_keys: list): raise Exception('This method is going away') """ @@ -594,6 +605,13 @@ def languages(self) -> list[str]: languages.append(individual_params['language']) return languages + def update_params(self, incoming_params): + self.validate_params(incoming_params) + current_languages = self.languages + for language_obj in incoming_params: + if language_obj['language'] not in current_languages: + self.params.append(language_obj) + class BaseAutomaticNLPAction(BaseManualNLPAction): """ diff --git a/kobo/apps/subsequences/constants.py b/kobo/apps/subsequences/constants.py index 2445db6e01..00b9de3d6c 100644 --- a/kobo/apps/subsequences/constants.py +++ b/kobo/apps/subsequences/constants.py @@ -1,3 +1,5 @@ +from django.db import models + SUBMISSION_UUID_FIELD = 'meta/rootUuid' # FIXME: import from elsewhere SUPPLEMENT_KEY = '_supplementalDetails' # leave unchanged for backwards compatibility @@ -20,3 +22,11 @@ '20250820', None ] + + +class Action(models.TextChoices): + MANUAL_TRANSCRIPTION = 'manual_transcription' + MANUAL_TRANSLATION = 'manual_translation' + AUTOMATIC_GOOGLE_TRANSLATION = 'automatic_google_translation' + AUTOMATIC_GOOGLE_TRANSCRIPTION = 'automatic_google_transcription' + QUAL = 'qual' diff --git a/kobo/apps/subsequences/docs/api/v2/subsequences/create.md b/kobo/apps/subsequences/docs/api/v2/subsequences/create.md new file mode 100644 index 0000000000..bf56302976 --- /dev/null +++ b/kobo/apps/subsequences/docs/api/v2/subsequences/create.md @@ -0,0 +1,74 @@ +## Add an advanced action to an asset + +Enables a new type of advanced action on a question in the asset. +* `action`, `params`, and `question_xpath` are required +* `params` must match the expected param_schema of the `action` + +Accepted `action`s include: +* `manual_transcription` +* `automatic_google_transcription` +* `manual_translation` +* `automatic_google_translation` +* `qual` + +For all actions except `qual`, `params` must look like +> '[{"language": "es"}, {"language": "en"}, ...]' + +For `qual`, `params` must look like +``` + [ + { + 'type': 'qualInteger', + 'uuid': '1a2c8eb0-e2ec-4b3c-942a-c1a5410c081a', + 'labels': {'_default': 'How many characters appear in the story?'}, + }, + { + 'type': 'qualSelectMultiple', + 'uuid': '2e30bec7-4843-43c7-98bc-13114af230c5', + 'labels': {'_default': "What themes were present in the story?"}, + 'choices': [ + { + 'uuid': '2e24e6b4-bc3b-4e8e-b0cd-d8d3b9ca15b6', + 'labels': {'_default': 'Empathy'}, + }, + { + 'uuid': 'cb82919d-2948-4ccf-a488-359c5d5ee53a', + 'labels': {'_default': 'Competition'}, + }, + { + 'uuid': '8effe3b1-619e-4ada-be45-ebcea5af0aaf', + 'labels': {'_default': 'Apathy'}, + }, + ], + }, + { + 'type': 'qualSelectOne', + 'uuid': '1a8b748b-f470-4c40-bc09-ce2b1197f503', + 'labels': {'_default': 'Was this a first-hand account?'}, + 'choices': [ + { + 'uuid': '3c7aacdc-8971-482a-9528-68e64730fc99', + 'labels': {'_default': 'Yes'}, + }, + { + 'uuid': '7e31c6a5-5eac-464c-970c-62c383546a94', + 'labels': {'_default': 'No'}, + }, + ], + }, + { + 'type': 'qualTags', + 'uuid': 'e9b4e6d1-fdbb-4dc9-8b10-a9c3c388322f', + 'labels': {'_default': 'Tag any landmarks mentioned in the story'}, + }, + { + 'type': 'qualText', + 'uuid': '83acf2a7-8edc-4fd8-8b9f-f832ca3f18ad', + 'labels': {'_default': 'Add any further remarks'}, + }, + { + 'type': 'qualNote', + 'uuid': '5ef11d48-d7a3-432e-af83-8c2e9b1feb72', + 'labels': {'_default': 'Thanks for your diligence'}, + }, + ]``` diff --git a/kobo/apps/subsequences/docs/api/v2/subsequences/list.md b/kobo/apps/subsequences/docs/api/v2/subsequences/list.md new file mode 100644 index 0000000000..ddebff474a --- /dev/null +++ b/kobo/apps/subsequences/docs/api/v2/subsequences/list.md @@ -0,0 +1,3 @@ +## List all advanced features on an asset + +Lists all advanced features on all questions in an asset diff --git a/kobo/apps/subsequences/docs/api/v2/subsequences/retrieve.md b/kobo/apps/subsequences/docs/api/v2/subsequences/retrieve.md new file mode 100644 index 0000000000..369db0251c --- /dev/null +++ b/kobo/apps/subsequences/docs/api/v2/subsequences/retrieve.md @@ -0,0 +1,3 @@ +## Retrieve advanced feature configuration for a question on an asset + +Gets the params for one advanced action for one question in an asset diff --git a/kobo/apps/subsequences/docs/api/v2/subsequences/update.md b/kobo/apps/subsequences/docs/api/v2/subsequences/update.md new file mode 100644 index 0000000000..a514e4ce21 --- /dev/null +++ b/kobo/apps/subsequences/docs/api/v2/subsequences/update.md @@ -0,0 +1,67 @@ +## Update an advanced action on an asset + +Update the params of an advanced action on a question in the asset. +* `params` is required +* `params` must match the expected param_schema of the action being updated + +For all actions except `qual`, `params` must look like +> '[{"language": "es"}, {"language": "en"}, ...]' + +For `qual`, `params` must look like +``` + [ + { + 'type': 'qualInteger', + 'uuid': '1a2c8eb0-e2ec-4b3c-942a-c1a5410c081a', + 'labels': {'_default': 'How many characters appear in the story?'}, + }, + { + 'type': 'qualSelectMultiple', + 'uuid': '2e30bec7-4843-43c7-98bc-13114af230c5', + 'labels': {'_default': "What themes were present in the story?"}, + 'choices': [ + { + 'uuid': '2e24e6b4-bc3b-4e8e-b0cd-d8d3b9ca15b6', + 'labels': {'_default': 'Empathy'}, + }, + { + 'uuid': 'cb82919d-2948-4ccf-a488-359c5d5ee53a', + 'labels': {'_default': 'Competition'}, + }, + { + 'uuid': '8effe3b1-619e-4ada-be45-ebcea5af0aaf', + 'labels': {'_default': 'Apathy'}, + }, + ], + }, + { + 'type': 'qualSelectOne', + 'uuid': '1a8b748b-f470-4c40-bc09-ce2b1197f503', + 'labels': {'_default': 'Was this a first-hand account?'}, + 'choices': [ + { + 'uuid': '3c7aacdc-8971-482a-9528-68e64730fc99', + 'labels': {'_default': 'Yes'}, + }, + { + 'uuid': '7e31c6a5-5eac-464c-970c-62c383546a94', + 'labels': {'_default': 'No'}, + }, + ], + }, + { + 'type': 'qualTags', + 'uuid': 'e9b4e6d1-fdbb-4dc9-8b10-a9c3c388322f', + 'labels': {'_default': 'Tag any landmarks mentioned in the story'}, + }, + { + 'type': 'qualText', + 'uuid': '83acf2a7-8edc-4fd8-8b9f-f832ca3f18ad', + 'labels': {'_default': 'Add any further remarks'}, + }, + { + 'type': 'qualNote', + 'uuid': '5ef11d48-d7a3-432e-af83-8c2e9b1feb72', + 'labels': {'_default': 'Thanks for your diligence'}, + }, + ]``` diff --git a/kobo/apps/subsequences/migrations/0005_questionadvancedfeature.py b/kobo/apps/subsequences/migrations/0005_questionadvancedfeature.py new file mode 100644 index 0000000000..2931fd065d --- /dev/null +++ b/kobo/apps/subsequences/migrations/0005_questionadvancedfeature.py @@ -0,0 +1,64 @@ +# Generated by Django 4.2.24 on 2025-11-21 05:48 + +import django.db.models.deletion +from django.db import migrations, models + +import kpi.fields.kpi_uid +import kpi.fields.lazy_default_jsonb + + +class Migration(migrations.Migration): + + dependencies = [ + ('subsequences', '0004_increase_subsequences_submission_uuid'), + ] + + operations = [ + migrations.CreateModel( + name='QuestionAdvancedFeature', + fields=[ + ( + 'uid', + kpi.fields.kpi_uid.KpiUidField( + _null=False, primary_key=True, uid_prefix='qaa' + ), + ), + ( + 'action', + models.CharField( + choices=[ + ('manual_transcription', 'Manual Transcription'), + ('manual_translation', 'Manual Translation'), + ( + 'automatic_google_translation', + 'Automatic Google Translation', + ), + ( + 'automatic_google_transcription', + 'Automatic Google Transcription', + ), + ('qual', 'Qual'), + ], + db_index=True, + max_length=60, + ), + ), + ('question_xpath', models.CharField(max_length=2000)), + ( + 'params', + kpi.fields.lazy_default_jsonb.LazyDefaultJSONBField(default=dict), + ), + ( + 'asset', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='advanced_features_set', + to='kpi.asset', + ), + ), + ], + options={ + 'unique_together': {('asset_id', 'question_xpath', 'action')}, + }, + ), + ] diff --git a/kobo/apps/subsequences/models.py b/kobo/apps/subsequences/models.py index 149f965136..258bd70559 100644 --- a/kobo/apps/subsequences/models.py +++ b/kobo/apps/subsequences/models.py @@ -1,9 +1,10 @@ from django.db import models from kobo.apps.openrosa.apps.logger.xform_instance_parser import remove_uuid_prefix +from kpi.fields import KpiUidField, LazyDefaultJSONBField from kpi.models.abstract_models import AbstractTimeStampedModel from .actions import ACTION_IDS_TO_CLASSES -from .constants import SCHEMA_VERSIONS, SUBMISSION_UUID_FIELD +from .constants import SCHEMA_VERSIONS, SUBMISSION_UUID_FIELD, Action from .exceptions import InvalidAction, InvalidXPath from .schemas import validate_submission_supplement @@ -34,9 +35,7 @@ def __repr__(self): @staticmethod def revise_data(asset: 'kpi.Asset', submission: dict, incoming_data: dict) -> dict: - if not asset.advanced_features or not asset.advanced_features.get( - '_actionConfigs' - ): + if not asset.advanced_features_set.exists(): raise InvalidAction schema_version = incoming_data.get('_version') @@ -49,10 +48,6 @@ def revise_data(asset: 'kpi.Asset', submission: dict, incoming_data: dict) -> di # TODO: migrate from old per-submission schema raise NotImplementedError - if asset.advanced_features.get('_version') != schema_version: - # TODO: migrate from old per-asset schema - raise NotImplementedError - submission_uuid = remove_uuid_prefix(submission[SUBMISSION_UUID_FIELD]) # constant? supplemental_data = SubmissionExtras.objects.get_or_create( asset=asset, submission_uuid=submission_uuid @@ -67,24 +62,21 @@ def revise_data(asset: 'kpi.Asset', submission: dict, incoming_data: dict) -> di # FIXME: what's a better way? skip all leading underscore keys? # pop off the known special keys first? continue - try: - action_configs_for_this_question = asset.advanced_features[ - '_actionConfigs' - ][question_xpath] - except KeyError as e: - raise InvalidXPath from e + feature_configs_for_this_question = asset.advanced_features_set.filter( + question_xpath=question_xpath + ) + if not feature_configs_for_this_question.exists(): + raise InvalidXPath for action_id, action_data in data_for_this_question.items(): + if not ACTION_IDS_TO_CLASSES.get(action_id): + raise InvalidAction try: - action_class = ACTION_IDS_TO_CLASSES[action_id] - except KeyError as e: - raise InvalidAction from e - try: - action_params = action_configs_for_this_question[action_id] - except KeyError as e: + feature = feature_configs_for_this_question.get(action=action_id) + except QuestionAdvancedFeature.DoesNotExist as e: raise InvalidAction from e - action = action_class(question_xpath, action_params, asset) + action = feature.to_action() action.check_limits(asset.owner) question_supplemental_data = supplemental_data.setdefault( @@ -163,10 +155,6 @@ def retrieve_data( # TODO: migrate from old per-submission schema raise NotImplementedError - if asset.advanced_features.get('_version') != schema_version: - # TODO: migrate from old per-asset schema - raise NotImplementedError - retrieved_supplemental_data = {} data_for_output = {} @@ -174,42 +162,22 @@ def retrieve_data( processed_data_for_this_question = retrieved_supplemental_data.setdefault( question_xpath, {} ) - action_configs = asset.advanced_features['_actionConfigs'] - try: - action_configs_for_this_question = action_configs[question_xpath] - except KeyError: - # There's still supplemental data for this question at the - # submission level, but the question is no longer configured at the - # asset level. - # Allow this for now, but maybe forbid later and also forbid - # removing things from the asset-level action configuration? - # Actions could be disabled or hidden instead of being removed - - # FIXME: divergence between the asset-level configuration and - # submission-level supplemental data is going to cause schema - # validation failures! We defo need to forbid removal of actions - # and instead provide a way to mark them as deleted - continue + advanced_features_for_this_question = asset.advanced_features_set.filter( + question_xpath=question_xpath + ) for action_id, action_data in data_for_this_question.items(): - try: - action_class = ACTION_IDS_TO_CLASSES[action_id] - except KeyError: + if not ACTION_IDS_TO_CLASSES.get(action_id): # An action class present in the submission data no longer # exists in the application code # TODO: log an error continue try: - action_params = action_configs_for_this_question[action_id] - except KeyError: - # An action class present in the submission data is no longer - # configured at the asset level for this question - # Allow this for now, but maybe forbid later and also forbid - # removing things from the asset-level action configuration? - # Actions could be disabled or hidden instead of being removed - continue + feature = advanced_features_for_this_question.get(action=action_id) + except QuestionAdvancedFeature.DoesNotExist as e: + raise InvalidAction from e - action = action_class(question_xpath, action_params) + action = feature.to_action() retrieved_data = action.retrieve_data(action_data) processed_data_for_this_question[action_id] = retrieved_data @@ -239,3 +207,34 @@ def retrieve_data( return data_for_output return retrieved_supplemental_data + + +class QuestionAdvancedFeature(models.Model): + uid = KpiUidField(uid_prefix='qaf', primary_key=True) + asset = models.ForeignKey( + 'kpi.Asset', + related_name='advanced_features_set', + null=False, + blank=False, + on_delete=models.CASCADE, + ) + action = models.CharField( + max_length=60, + choices=Action.choices, + db_index=True, + null=False, + blank=False, + ) + question_xpath = models.CharField(null=False, blank=False, max_length=2000) + params = LazyDefaultJSONBField(default=dict) + + class Meta: + unique_together = ('asset_id', 'question_xpath', 'action') + + def to_action(self): + action_class = ACTION_IDS_TO_CLASSES[self.action] + return action_class( + source_question_xpath=self.question_xpath, + params=self.params, + asset=self.asset, + ) diff --git a/kobo/apps/subsequences/schemas.py b/kobo/apps/subsequences/schemas.py index 79da5a616e..00efaae078 100644 --- a/kobo/apps/subsequences/schemas.py +++ b/kobo/apps/subsequences/schemas.py @@ -1,9 +1,9 @@ from copy import deepcopy + import jsonschema -from .actions import ACTION_IDS_TO_CLASSES, ACTIONS +from .actions import ACTIONS from .constants import SCHEMA_VERSIONS -from .utils.versioning import migrate_advanced_features # not the full complexity of XPath, but a slash-delimited path of valid XML tag # names to convey group hierarchy @@ -42,26 +42,16 @@ def validate_submission_supplement(asset: 'kpi.models.Asset', supplement: dict): def get_submission_supplement_schema(asset: 'kpi.models.Asset') -> dict: - if migrated_schema := migrate_advanced_features(asset.advanced_features): - asset.advanced_features = migrated_schema - submission_supplement_schema = { 'additionalProperties': False, 'properties': {'_version': {'const': SCHEMA_VERSIONS[0]}}, 'type': 'object', } - for ( - question_xpath, - action_configs_for_this_question, - ) in asset.advanced_features['_actionConfigs'].items(): - for ( - action_id, - action_params, - ) in action_configs_for_this_question.items(): - action = ACTION_IDS_TO_CLASSES[action_id](question_xpath, action_params) - submission_supplement_schema['properties'].setdefault(question_xpath, {})[ - action_id - ] = action.result_schema + for action_config in asset.advanced_features_set.all(): + action = action_config.to_action() + submission_supplement_schema['properties'].setdefault( + action_config.question_xpath, {} + )[action_config.action] = action.result_schema return submission_supplement_schema diff --git a/kobo/apps/subsequences/serializers.py b/kobo/apps/subsequences/serializers.py new file mode 100644 index 0000000000..5f15e9003e --- /dev/null +++ b/kobo/apps/subsequences/serializers.py @@ -0,0 +1,34 @@ +import jsonschema.exceptions +from rest_framework import serializers + +from kobo.apps.subsequences.models import QuestionAdvancedFeature + + +class QuestionAdvancedFeatureUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = QuestionAdvancedFeature + fields = ['params', 'question_xpath', 'action', 'asset', 'uid'] + read_only_fields = ['question_xpath', 'action', 'asset', 'uid'] + + def validate(self, attrs): + data = super().validate(attrs) + action = self.instance.to_action() + try: + action.__class__.validate_params(attrs.get('params')) + except jsonschema.exceptions.ValidationError as ve: + raise serializers.ValidationError(ve) + return data + + def update(self, instance, validated_data): + action = instance.to_action() + action.update_params(validated_data['params']) + instance.params = action.params + instance.save(update_fields=['params']) + return instance + + +class QuestionAdvancedFeatureSerializer(serializers.ModelSerializer): + class Meta: + model = QuestionAdvancedFeature + fields = ['question_xpath', 'action', 'params', 'uid'] + read_only_fields = ['uid'] diff --git a/kobo/apps/subsequences/tasks.py b/kobo/apps/subsequences/tasks.py index 8086402cbd..6422fb2530 100644 --- a/kobo/apps/subsequences/tasks.py +++ b/kobo/apps/subsequences/tasks.py @@ -1,13 +1,14 @@ from celery.signals import task_failure - from django.apps import apps + +from kobo.apps.openrosa.apps.logger.xform_instance_parser import remove_uuid_prefix +from kobo.apps.subsequences.exceptions import SubsequenceTimeoutError from kobo.celery import celery_app from kpi.utils.django_orm_helper import UpdateJSONFieldAttributes -from kobo.apps.subsequences.exceptions import SubsequenceTimeoutError from .constants import SUBMISSION_UUID_FIELD -from kobo.apps.openrosa.apps.logger.xform_instance_parser import remove_uuid_prefix from .utils.versioning import set_version + # With retry_backoff=5 and retry_backoff_max=60, each retry waits: # min(5 * 2^(n-1), 60) seconds. # We also add an initial 10s delay before the first attempt. @@ -53,7 +54,6 @@ def poll_run_external_process( def poll_run_external_process_failure(sender=None, **kwargs): # Avoid circular import - from .actions import ACTION_IDS_TO_CLASSES Asset = apps.get_model('kpi', 'Asset') # noqa: N806 SubmissionSupplement = apps.get_model('subsequences', 'SubmissionSupplement') # noqa: N806 @@ -76,11 +76,10 @@ def poll_run_external_process_failure(sender=None, **kwargs): if 'is still in progress for submission' in error: error = 'Maximum retries exceeded.' - action_class = ACTION_IDS_TO_CLASSES[action_id] - action_configs = asset.advanced_features['_actionConfigs'] - action_configs_for_this_question = action_configs[question_xpath] - action_params = action_configs_for_this_question[action_id] - action = action_class(question_xpath, action_params, asset=asset) + feature = asset.advanced_features_set.get( + question_xpath=question_xpath, action=action_id + ) + action = feature.to_action() action.get_action_dependencies(supplemental_data[question_xpath]) action_supplemental_data = supplemental_data[question_xpath][action_id] diff --git a/kobo/apps/subsequences/tests/api/v2/base.py b/kobo/apps/subsequences/tests/api/v2/base.py index 42c0722762..5252dd820b 100644 --- a/kobo/apps/subsequences/tests/api/v2/base.py +++ b/kobo/apps/subsequences/tests/api/v2/base.py @@ -43,11 +43,3 @@ def setUp(self): self._get_endpoint('submission-supplement'), args=[self.asset.uid, self.submission_uuid], ) - - def set_asset_advanced_features(self, features): - self.asset.advanced_features = features - self.asset.save( - adjust_content=False, - create_version=False, - update_fields=['advanced_features'], - ) diff --git a/kobo/apps/subsequences/tests/api/v2/test_permissions.py b/kobo/apps/subsequences/tests/api/v2/test_permissions.py index 675f187b00..4b2bc04a10 100644 --- a/kobo/apps/subsequences/tests/api/v2/test_permissions.py +++ b/kobo/apps/subsequences/tests/api/v2/test_permissions.py @@ -4,14 +4,17 @@ from zoneinfo import ZoneInfo from ddt import data, ddt, unpack +from django.urls import reverse from freezegun import freeze_time from rest_framework import status from kobo.apps.kobo_auth.shortcuts import User +from kobo.apps.subsequences.models import QuestionAdvancedFeature from kobo.apps.subsequences.tests.api.v2.base import SubsequenceBaseTestCase from kpi.constants import ( PERM_CHANGE_SUBMISSIONS, PERM_PARTIAL_SUBMISSIONS, + PERM_VIEW_ASSET, PERM_VIEW_SUBMISSIONS, ) from kpi.utils.object_permission import get_anonymous_user @@ -137,17 +140,11 @@ def test_can_write(self, username, shared, status_code): self.client.force_login(user) # Activate advanced features for the project - self.set_asset_advanced_features( - { - '_version': '20250820', - '_actionConfigs': { - 'q1': { - 'manual_transcription': [ - {'language': 'es'}, - ] - } - }, - } + QuestionAdvancedAction.objects.create( + asset=self.asset, + question_xpath='q1', + action='manual_transcription', + params=[{'language': 'es'}], ) if shared: @@ -228,3 +225,157 @@ def test_cannot_read_data(self): self.client.force_login(anotheruser) response = self.client.get(self.supplement_details_url) assert response.status_code == status.HTTP_404_NOT_FOUND + + +@ddt +class AdvancedFeaturesPermissionTestCase(SubsequenceBaseTestCase): + + def setUp(self): + super().setUp() + self.advanced_features_url = reverse( + 'api_v2:advanced-features-list', args=(self.asset.uid,) + ) + with patch( + 'kobo.apps.subsequences.models.KpiUidField.generate_uid', + return_value='12345', + ): + QuestionAdvancedFeature.objects.create( + asset=self.asset, + action='manual_transcription', + question_xpath='q1', + params=[{'language': 'en'}], + ) + + @data( + # owner: Obviously, no need to share. + ( + 'someuser', + False, + status.HTTP_200_OK, + ), + # regular user with no permissions + ( + 'anotheruser', + False, + status.HTTP_404_NOT_FOUND, + ), + # regular user with view permission + ( + 'anotheruser', + True, + status.HTTP_200_OK, + ), + # admin user with no permissions + ( + 'adminuser', + False, + status.HTTP_200_OK, + ), + # admin user with view permissions + ( + 'adminuser', + True, + status.HTTP_200_OK, + ), + # anonymous user with no permissions + ( + 'anonymous', + False, + status.HTTP_404_NOT_FOUND, + ), + # anonymous user with view permissions + ( + 'anonymous', + True, + status.HTTP_200_OK, + ), + ) + @unpack + def test_can_read(self, username, shared, status_code): + user = get_anonymous_user() + self.client.logout() + if username != 'anonymous': + user = User.objects.get(username=username) + self.client.force_login(user) + + if shared: + self.asset.assign_perm(user, PERM_VIEW_ASSET) + + response = self.client.get(self.advanced_features_url) + assert response.status_code == status_code + if status_code == status.HTTP_200_OK: + assert response.data == [ + { + 'question_xpath': 'q1', + 'uid': '12345', + 'params': [{'language': 'en'}], + 'action': 'manual_transcription', + } + ] + + @data( + # owner: Obviously, no need to share. + ( + 'someuser', + False, + status.HTTP_201_CREATED, + ), + # regular user with no permissions + ( + 'anotheruser', + False, + status.HTTP_404_NOT_FOUND, + ), + # regular user with change submission permission + ( + 'anotheruser', + True, + status.HTTP_201_CREATED, + ), + # admin user with no permissions + ( + 'adminuser', + False, + status.HTTP_201_CREATED, + ), + # admin user with change submission permissions + ( + 'adminuser', + True, + status.HTTP_201_CREATED, + ), + # anonymous user with no permissions + ( + 'anonymous', + False, + status.HTTP_404_NOT_FOUND, + ), + ) + @unpack + def test_can_write(self, username, shared, status_code): + payload = { + 'action': 'manual_translation', + 'question_xpath': 'q1', + 'params': [{'language': 'es'}], + } + + user = get_anonymous_user() + self.client.logout() + if username != 'anonymous': + user = User.objects.get(username=username) + self.client.force_login(user) + + if shared: + self.asset.assign_perm(user, PERM_CHANGE_SUBMISSIONS) + + frozen_datetime_now = datetime(2024, 4, 8, 15, 27, 0, tzinfo=ZoneInfo('UTC')) + with freeze_time(frozen_datetime_now): + response = self.client.post( + self.advanced_features_url, data=payload, format='json' + ) + + assert response.status_code == status_code + if response.status_code == status.HTTP_201_CREATED: + assert QuestionAdvancedFeature.objects.filter( + asset=self.asset, action='manual_translation' + ).exists() diff --git a/kobo/apps/subsequences/tests/api/v2/test_validation.py b/kobo/apps/subsequences/tests/api/v2/test_validation.py index bf271051c4..0d902d25b2 100644 --- a/kobo/apps/subsequences/tests/api/v2/test_validation.py +++ b/kobo/apps/subsequences/tests/api/v2/test_validation.py @@ -2,13 +2,149 @@ from rest_framework import status -from kobo.apps.subsequences.models import SubmissionSupplement +from kobo.apps.subsequences.models import QuestionAdvancedFeature, SubmissionSupplement from kobo.apps.subsequences.tests.api.v2.base import SubsequenceBaseTestCase from kobo.apps.subsequences.tests.constants import QUESTION_SUPPLEMENT class SubmissionSupplementAPITestCase(SubsequenceBaseTestCase): + def _simulate_completed_transcripts(self): + QuestionAdvancedFeature.objects.create( + asset=self.asset, + question_xpath='q1', + action='automatic_google_transcription', + params=[{'language': 'en'}], + ) + QuestionAdvancedFeature.objects.create( + asset=self.asset, + question_xpath='q1', + action='manual_transcription', + params=[{'language': 'en'}], + ) + + # Simulate a completed transcription, first. + mock_submission_supplement = {'_version': '20250820', 'q1': QUESTION_SUPPLEMENT} + + SubmissionSupplement.objects.create( + submission_uuid=self.submission_uuid, + content=mock_submission_supplement, + asset=self.asset, + ) + + def test_valid_manual_transcription(self): + payload = { + '_version': '20250820', + 'q1': { + 'manual_transcription': { + 'language': 'en', + 'value': 'hello world', + } + }, + } + QuestionAdvancedFeature.objects.create( + asset=self.asset, + question_xpath='q1', + action='manual_transcription', + params=[{'language': 'en'}], + ) + response = self.client.patch( + self.supplement_details_url, data=payload, format='json' + ) + assert response.status_code == status.HTTP_200_OK + + def test_valid_manual_translation(self): + self._simulate_completed_transcripts() + QuestionAdvancedFeature.objects.create( + asset=self.asset, + question_xpath='q1', + action='manual_translation', + params=[{'language': 'es'}], + ) + payload = { + '_version': '20250820', + 'q1': { + 'manual_translation': { + 'language': 'es', + 'value': 'hola el mundo', + } + }, + } + + response = self.client.patch( + self.supplement_details_url, data=payload, format='json' + ) + assert response.status_code == status.HTTP_200_OK + + def test_valid_automatic_transcription(self): + # Set up the asset to allow automatic google transcription + QuestionAdvancedFeature.objects.create( + asset=self.asset, + question_xpath='q1', + action='automatic_google_transcription', + params=[{'language': 'en'}], + ) + + payload = { + '_version': '20250820', + 'q1': { + 'automatic_google_transcription': { + 'language': 'en', + } + }, + } + + # Mock GoogleTranscriptionService and simulate completed transcription + mock_service = MagicMock() + mock_service.process_data.return_value = { + 'status': 'complete', + 'value': 'hello world', + } + + with patch( + 'kobo.apps.subsequences.actions.automatic_google_transcription.GoogleTranscriptionService', # noqa + return_value=mock_service, + ): + response = self.client.patch( + self.supplement_details_url, data=payload, format='json' + ) + assert response.status_code == status.HTTP_200_OK + + def test_valid_automatic_translation(self): + self._simulate_completed_transcripts() + # Set up the asset to allow automatic google translation + QuestionAdvancedFeature.objects.create( + asset=self.asset, + question_xpath='q1', + action='automatic_google_translation', + params=[{'language': 'es'}], + ) + + payload = { + '_version': '20250820', + 'q1': { + 'automatic_google_translation': { + 'language': 'es', + } + }, + } + + # Mock GoogleTranslationService and simulate in progress translation + mock_service = MagicMock() + mock_service.process_data.return_value = { + 'status': 'complete', + 'value': 'hola el mundo', + } + + with patch( + 'kobo.apps.subsequences.actions.automatic_google_translation.GoogleTranslationService', # noqa + return_value=mock_service, + ): + response = self.client.patch( + self.supplement_details_url, data=payload, format='json' + ) + assert response.status_code == status.HTTP_200_OK + def test_cannot_patch_if_action_is_invalid(self): payload = { '_version': '20250820', @@ -28,17 +164,11 @@ def test_cannot_patch_if_action_is_invalid(self): assert 'Invalid action' in str(response.data) # Activate manual transcription (even if payload asks for translation) - self.set_asset_advanced_features( - { - '_version': '20250820', - '_actionConfigs': { - 'q1': { - 'manual_transcription': [ - {'language': 'es'}, - ] - } - }, - } + QuestionAdvancedFeature.objects.create( + asset=self.asset, + question_xpath='q1', + action='manual_transcription', + params=[{'language': 'es'}], ) response = self.client.patch( self.supplement_details_url, data=payload, format='json' @@ -47,17 +177,11 @@ def test_cannot_patch_if_action_is_invalid(self): assert 'Invalid action' in str(response.data) def test_cannot_patch_with_invalid_payload(self): - self.set_asset_advanced_features( - { - '_version': '20250820', - '_actionConfigs': { - 'q1': { - 'manual_transcription': [ - {'language': 'es'}, - ] - } - }, - } + QuestionAdvancedFeature.objects.create( + asset=self.asset, + question_xpath='q1', + action='manual_transcription', + params=[{'language': 'es'}], ) payload = { @@ -78,34 +202,18 @@ def test_cannot_patch_with_invalid_payload(self): assert 'Invalid action' in str(response.data) def test_cannot_set_value_with_automatic_actions(self): - # First, set up the asset to allow automatic actions - advanced_features = { - '_version': '20250820', - '_actionConfigs': { - 'q1': { - 'automatic_google_transcription': [ - {'language': 'en'}, - ], - 'automatic_google_translation': [ - {'language': 'fr'}, - ] - } - }, - } - self.set_asset_advanced_features(advanced_features) - - # Simulate a completed transcription, first. - mock_submission_supplement = { - '_version': '20250820', - 'q1': QUESTION_SUPPLEMENT - } - - SubmissionSupplement.objects.create( - submission_uuid=self.submission_uuid, - content=mock_submission_supplement, + self._simulate_completed_transcripts() + # Set up the asset to allow automatic actions + QuestionAdvancedFeature.objects.create( asset=self.asset, + question_xpath='q1', + action='automatic_google_translation', + params=[{'language': 'fr'}], ) - automatic_actions = advanced_features['_actionConfigs']['q1'].keys() + + automatic_actions = self.asset.advanced_features_set.filter( + question_xpath='q1' + ).values_list('action', flat=True) for automatic_action in automatic_actions: payload = { '_version': '20250820', @@ -125,17 +233,11 @@ def test_cannot_set_value_with_automatic_actions(self): def test_cannot_accept_incomplete_automatic_transcription(self): # Set up the asset to allow automatic google transcription - self.set_asset_advanced_features( - { - '_version': '20250820', - '_actionConfigs': { - 'q1': { - 'automatic_google_transcription': [ - {'language': 'es'}, - ] - } - }, - } + QuestionAdvancedFeature.objects.create( + asset=self.asset, + question_xpath='q1', + action='automatic_google_transcription', + params=[{'language': 'es'}], ) # Try to set 'accepted' status when translation is not complete @@ -164,32 +266,14 @@ def test_cannot_accept_incomplete_automatic_transcription(self): assert 'Invalid payload' in str(response.data) def test_cannot_accept_incomplete_automatic_translation(self): - # Set up the asset to allow automatic google actions - self.set_asset_advanced_features( - { - '_version': '20250820', - '_actionConfigs': { - 'q1': { - 'automatic_google_transcription': [ - {'language': 'en'}, - ], - 'automatic_google_translation': [ - {'language': 'fr'}, - ] - } - }, - } - ) + self._simulate_completed_transcripts() + # Set up the asset to allow automatic google translation - # Simulate a completed transcription, first. - mock_submission_supplement = { - '_version': '20250820', - 'q1': QUESTION_SUPPLEMENT - } - SubmissionSupplement.objects.create( - submission_uuid=self.submission_uuid, - content=mock_submission_supplement, + QuestionAdvancedFeature.objects.create( asset=self.asset, + question_xpath='q1', + action='automatic_google_translation', + params=[{'language': 'fr'}], ) # Try to set 'accepted' status when translation is not complete @@ -219,22 +303,18 @@ def test_cannot_accept_incomplete_automatic_translation(self): def test_cannot_request_translation_without_transcription(self): # Set up the asset to allow automatic google actions - self.set_asset_advanced_features( - { - '_version': '20250820', - '_actionConfigs': { - 'q1': { - 'automatic_google_transcription': [ - {'language': 'en'}, - ], - 'automatic_google_translation': [ - {'language': 'fr'}, - ] - } - }, - } + QuestionAdvancedFeature.objects.create( + asset=self.asset, + question_xpath='q1', + action='automatic_google_transcription', + params=[{'language': 'en'}], + ) + QuestionAdvancedFeature.objects.create( + asset=self.asset, + question_xpath='q1', + action='automatic_google_translation', + params=[{'language': 'fr'}], ) - # Try to ask for translation payload = { '_version': '20250820', diff --git a/kobo/apps/subsequences/tests/api/v2/test_views.py b/kobo/apps/subsequences/tests/api/v2/test_views.py new file mode 100644 index 0000000000..89d5cc5990 --- /dev/null +++ b/kobo/apps/subsequences/tests/api/v2/test_views.py @@ -0,0 +1,92 @@ +import json + +from django.test import Client +from django.urls import reverse +from rest_framework import status + +from kobo.apps.kobo_auth.shortcuts import User +from kobo.apps.subsequences.models import QuestionAdvancedFeature +from kpi.models import Asset +from kpi.tests.base_test_case import BaseTestCase + + +class QuestionAdvancedFeatureViewSetTestCase(BaseTestCase): + fixtures = ['test_data'] + + def setUp(self): + user = User.objects.get(username='someuser') + self.asset = Asset.objects.create( + owner=user, + content={'survey': [{'type': 'audio', 'label': 'q1', 'name': 'q1'}]}, + ) + self.action = QuestionAdvancedFeature.objects.create( + asset=self.asset, + question_xpath='q1', + action='manual_transcription', + params=[{'language': 'en'}], + ) + self.list_actions_url = reverse( + 'api_v2:advanced-features-list', + kwargs={'parent_lookup_asset': self.asset.uid}, + ) + self.action_detail_url = reverse( + 'api_v2:advanced-features-detail', + kwargs={'parent_lookup_asset': self.asset.uid, 'pk': self.action.uid}, + ) + self.client = Client(raise_request_exception=False) + self.client.force_login(user) + + def test_list_advanced_features(self): + res = self.client.get(self.list_actions_url) + assert res.status_code == status.HTTP_200_OK + assert res.json() == [ + { + 'action': 'manual_transcription', + 'question_xpath': 'q1', + 'params': [{'language': 'en'}], + 'uid': self.action.uid, + } + ] + + def test_update_feature(self): + res = self.client.patch( + self.action_detail_url, + content_type='application/json', + data=json.dumps({'params': [{'language': 'es'}]}), + ) + assert res.status_code == status.HTTP_200_OK + self.action.refresh_from_db() + assert self.action.params == [{'language': 'en'}, {'language': 'es'}] + + def test_cannot_update_feature_with_invalid_params(self): + res = self.client.patch( + self.action_detail_url, + content_type='application/json', + data=json.dumps({'params': [{'bad': 'stuff'}]}), + ) + assert res.status_code == status.HTTP_400_BAD_REQUEST + self.action.refresh_from_db() + assert self.action.params == [{'language': 'en'}] + + def test_create_feature(self): + res = self.client.post( + self.list_actions_url, + data={ + 'action': 'manual_translation', + 'params': json.dumps([{'language': 'de'}]), + 'question_xpath': 'q1', + }, + ) + assert res.status_code == status.HTTP_201_CREATED + new_action = QuestionAdvancedFeature.objects.get( + asset=self.asset, action='manual_translation' + ) + assert new_action.params == [{'language': 'de'}] + assert new_action.question_xpath == 'q1' + + def test_cannot_delete_features(self): + res = self.client.delete(self.action_detail_url) + assert res.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + assert QuestionAdvancedFeature.objects.filter( + asset=self.asset, action=self.action.action + ).exists() diff --git a/kobo/apps/subsequences/utils/supplement_data.py b/kobo/apps/subsequences/utils/supplement_data.py index b365e7b278..d765854322 100644 --- a/kobo/apps/subsequences/utils/supplement_data.py +++ b/kobo/apps/subsequences/utils/supplement_data.py @@ -1,10 +1,8 @@ from typing import Generator from kobo.apps.openrosa.apps.logger.xform_instance_parser import remove_uuid_prefix -from kobo.apps.subsequences.actions import ACTION_IDS_TO_CLASSES from kobo.apps.subsequences.constants import SUBMISSION_UUID_FIELD, SUPPLEMENT_KEY from kobo.apps.subsequences.models import SubmissionSupplement -from kobo.apps.subsequences.utils.versioning import migrate_advanced_features def get_supplemental_output_fields(asset: 'kpi.models.Asset') -> list[dict]: @@ -36,30 +34,20 @@ def get_supplemental_output_fields(asset: 'kpi.models.Asset') -> list[dict]: submission. We'll do that by looking at the acceptance dates and letting the most recent win """ - advanced_features = asset.advanced_features - - if migrated_schema := migrate_advanced_features(advanced_features): - asset.advanced_features = migrated_schema output_fields_by_name = {} - # FIXME: `_actionConfigs` is 👎 and should be dropped in favor of top-level configs, eh? # data already exists at the top level alongisde leading-underscore metadata like _version - for source_question_xpath, per_question_actions in advanced_features[ - '_actionConfigs' - ].items(): - for action_id, action_config in per_question_actions.items(): - action = ACTION_IDS_TO_CLASSES[action_id]( - source_question_xpath, action_config - ) - for field in action.get_output_fields(): - try: - existing = output_fields_by_name[field['name']] - except KeyError: - output_fields_by_name[field['name']] = field - else: - # It's normal for multiple actions to contribute the same - # field, but they'd better be exactly the same! - assert field == existing + for advanced_action in asset.advanced_features_set.all(): + action = advanced_action.to_action() + for field in action.get_output_fields(): + try: + existing = output_fields_by_name[field['name']] + except KeyError: + output_fields_by_name[field['name']] = field + else: + # It's normal for multiple actions to contribute the same + # field, but they'd better be exactly the same! + assert field == existing # since we want transcripts always to come before translations, à la # @@ -70,7 +58,7 @@ def get_supplemental_output_fields(asset: 'kpi.models.Asset') -> list[dict]: def stream_with_supplements( asset: 'kpi.models.Asset', submission_stream: Generator, for_output: bool = False ) -> Generator: - if not asset.advanced_features: + if not asset.advanced_features_set.exists(): yield from submission_stream return diff --git a/kobo/apps/subsequences/views.py b/kobo/apps/subsequences/views.py new file mode 100644 index 0000000000..ed050218ee --- /dev/null +++ b/kobo/apps/subsequences/views.py @@ -0,0 +1,126 @@ +from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view +from rest_framework import mixins +from rest_framework_extensions.mixins import NestedViewSetMixin + +from kobo.apps.audit_log.base_views import AuditLoggedViewSet +from kobo.apps.audit_log.models import AuditType +from kobo.apps.subsequences.models import QuestionAdvancedFeature +from kobo.apps.subsequences.serializers import ( + QuestionAdvancedFeatureSerializer, + QuestionAdvancedFeatureUpdateSerializer, +) +from kpi.permissions import AssetAdvancedFeaturesPermission +from kpi.schema_extensions.v2.subsequences.serializers import ( + AdvancedFeaturePatchRequest, + AdvancedFeaturePostRequest, + AdvancedFeatureResponse, +) +from kpi.utils.schema_extensions.markdown import read_md +from kpi.utils.schema_extensions.response import ( + open_api_200_ok_response, + open_api_201_created_response, +) +from kpi.utils.viewset_mixins import AssetNestedObjectViewsetMixin + + +@extend_schema( + tags=['Advanced Features'], + parameters=[ + OpenApiParameter( + name='parent_lookup_asset', + type=str, + location=OpenApiParameter.PATH, + required=True, + description='UID of the parent assets', + ), + ], +) +@extend_schema_view( + create=extend_schema( + description=read_md('subsequences', 'subsequences/create.md'), + request=AdvancedFeaturePostRequest, + responses=open_api_201_created_response( + AdvancedFeatureResponse, + require_auth=False, + raise_access_forbidden=False, + ), + ), + list=extend_schema( + description=read_md('subsequences', 'subsequences/list.md'), + responses=open_api_200_ok_response( + AdvancedFeatureResponse, + require_auth=False, + raise_access_forbidden=False, + validate_payload=False, + ), + ), + partial_update=extend_schema( + description=read_md('subsequences', 'subsequences/update.md'), + request=AdvancedFeaturePatchRequest, + responses=open_api_200_ok_response( + AdvancedFeatureResponse, + require_auth=False, + raise_access_forbidden=False, + ), + parameters=[ + OpenApiParameter( + name='uid', + type=str, + location=OpenApiParameter.PATH, + required=True, + description='UID of the action', + ), + ], + ), + retrieve=extend_schema( + description=read_md('subsequences', 'subsequences/retrieve.md'), + responses=open_api_200_ok_response( + AdvancedFeatureResponse, + require_auth=False, + raise_access_forbidden=False, + validate_payload=False, + ), + parameters=[ + OpenApiParameter( + name='uid', + type=str, + location=OpenApiParameter.PATH, + required=True, + description='UID of the action', + ), + ], + ), + update=extend_schema( + exclude=True, + ), +) +class QuestionAdvancedFeatureViewSet( + AuditLoggedViewSet, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.ListModelMixin, + AssetNestedObjectViewsetMixin, + NestedViewSetMixin, +): + log_type = AuditType.PROJECT_HISTORY + logged_fields = [ + 'asset.owner.username', + 'action', + 'params', + ('object_id', 'asset.id'), + ] + pagination_class = None + permission_classes = (AssetAdvancedFeaturesPermission,) + + def get_queryset(self): + return QuestionAdvancedFeature.objects.filter(asset=self.asset) + + def perform_create_override(self, serializer): + serializer.save(asset=self.asset) + + def get_serializer_class(self): + if self.action in ['update', 'partial_update']: + return QuestionAdvancedFeatureUpdateSerializer + else: + return QuestionAdvancedFeatureSerializer diff --git a/kpi/permissions.py b/kpi/permissions.py index f326c141e3..bee22d6c91 100644 --- a/kpi/permissions.py +++ b/kpi/permissions.py @@ -223,6 +223,20 @@ def has_permission(self, request, view): raise Http404 +class AssetAdvancedFeaturesPermission(AssetNestedObjectPermission): + """ + Owner, managers and editors can write. + - Reads need 'view_asset' permission + - Writes need 'change_submissions' permission + """ + + perms_map = deepcopy(AssetNestedObjectPermission.perms_map) + perms_map['POST'] = ['%(app_label)s.change_submissions'] + perms_map['PUT'] = perms_map['POST'] + perms_map['PATCH'] = perms_map['POST'] + perms_map['DELETE'] = perms_map['POST'] + + class AssetEditorPermission(AssetNestedObjectPermission): """ Owner, managers and editors can write. diff --git a/kpi/schema_extensions/imports.py b/kpi/schema_extensions/imports.py index 8d6bef6fdd..93118baf3f 100644 --- a/kpi/schema_extensions/imports.py +++ b/kpi/schema_extensions/imports.py @@ -18,6 +18,7 @@ import kpi.schema_extensions.v2.paired_data.extensions import kpi.schema_extensions.v2.permissions.extensions import kpi.schema_extensions.v2.service_usage.extensions +import kpi.schema_extensions.v2.subsequences.extensions import kpi.schema_extensions.v2.tags.extensions import kpi.schema_extensions.v2.tos.extensions import kpi.schema_extensions.v2.users.extensions diff --git a/kpi/schema_extensions/v2/subsequences/extensions.py b/kpi/schema_extensions/v2/subsequences/extensions.py new file mode 100644 index 0000000000..dccae429bd --- /dev/null +++ b/kpi/schema_extensions/v2/subsequences/extensions.py @@ -0,0 +1,11 @@ +from drf_spectacular.extensions import OpenApiSerializerFieldExtension +from drf_spectacular.plumbing import build_array_type + +from kpi.schema_extensions.v2.generic.schema import GENERIC_OBJECT_SCHEMA + + +class SubsequenceParamsFieldExtension(OpenApiSerializerFieldExtension): + target_class = 'kpi.schema_extensions.v2.subsequences.fields.AdvancedFeatureParamsField' # noqa + + def map_serializer_field(self, auto_schema, direction): + return build_array_type(schema=GENERIC_OBJECT_SCHEMA) diff --git a/kpi/schema_extensions/v2/subsequences/fields.py b/kpi/schema_extensions/v2/subsequences/fields.py new file mode 100644 index 0000000000..fbcbd0efe0 --- /dev/null +++ b/kpi/schema_extensions/v2/subsequences/fields.py @@ -0,0 +1,5 @@ +from rest_framework import serializers + + +class AdvancedFeatureParamsField(serializers.JSONField): + pass diff --git a/kpi/schema_extensions/v2/subsequences/serializers.py b/kpi/schema_extensions/v2/subsequences/serializers.py new file mode 100644 index 0000000000..19f7305bb6 --- /dev/null +++ b/kpi/schema_extensions/v2/subsequences/serializers.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from kpi.schema_extensions.v2.subsequences.fields import AdvancedFeatureParamsField +from kpi.utils.schema_extensions.serializers import inline_serializer_class + +AdvancedFeatureResponse = inline_serializer_class( + name='AdvancedFeatureResponse', + fields={ + 'question_xpath': serializers.CharField(), + 'action': serializers.CharField(), + 'params': AdvancedFeatureParamsField(), + 'asset': serializers.CharField(), + 'uid': serializers.CharField(), + }, +) + +AdvancedFeaturePatchRequest = inline_serializer_class( + name='AdvancedFeaturePatchRequest', fields={'params': AdvancedFeatureParamsField()} +) + +AdvancedFeaturePostRequest = inline_serializer_class( + name='AdvancedFeaturePostRequest', + fields={ + 'question_xpath': serializers.CharField(), + 'action': serializers.CharField(), + 'params': AdvancedFeatureParamsField(), + }, +) diff --git a/kpi/urls/router_api_v2.py b/kpi/urls/router_api_v2.py index 55e358815d..936acb1085 100644 --- a/kpi/urls/router_api_v2.py +++ b/kpi/urls/router_api_v2.py @@ -14,6 +14,7 @@ ) from kobo.apps.project_ownership.urls import router as project_ownership_router from kobo.apps.project_views.views import ProjectViewViewSet +from kobo.apps.subsequences.views import QuestionAdvancedFeatureViewSet from kpi.views.v2.asset import AssetViewSet from kpi.views.v2.asset_counts import AssetCountsViewSet from kpi.views.v2.asset_export_settings import AssetExportSettingsViewSet @@ -138,6 +139,13 @@ def get_urls(self, *args, **kwargs): parents_query_lookups=['asset'], ) +asset_routes.register( + r'advanced-features', + QuestionAdvancedFeatureViewSet, + basename='advanced-features', + parents_query_lookups=['asset'], +) + data_routes = asset_routes.register(r'data', DataViewSet, basename='submission',