diff --git a/backend/README.md b/backend/README.md index 266b5b4d4..d373c6763 100644 --- a/backend/README.md +++ b/backend/README.md @@ -7,7 +7,7 @@ BACKEND_URL=http://localhost:8001 FRONTEND_URL=http://localhost FORMS_URL=http://localhost/forms ENVIRONMENT=Development -ENABLE_LOGGING=yes +ENABLE_LOGGING=no DJANGO_DEBUG=yes DJANGO_SETTINGS_MODULE=src.settings DJANGO_SECRET_KEY=django_secret_django_secret_django_secret diff --git a/backend/src/accounts/locale/de/LC_MESSAGES/django.po b/backend/src/accounts/locale/de/LC_MESSAGES/django.po index 9fb728ee2..0413c5321 100644 --- a/backend/src/accounts/locale/de/LC_MESSAGES/django.po +++ b/backend/src/accounts/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/accounts/locale/django.pot b/backend/src/accounts/locale/django.pot index 4657a4b74..921217f3c 100644 --- a/backend/src/accounts/locale/django.pot +++ b/backend/src/accounts/locale/django.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/accounts/locale/es/LC_MESSAGES/django.po b/backend/src/accounts/locale/es/LC_MESSAGES/django.po index e640ce431..447b7de73 100644 --- a/backend/src/accounts/locale/es/LC_MESSAGES/django.po +++ b/backend/src/accounts/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/accounts/locale/fr/LC_MESSAGES/django.po b/backend/src/accounts/locale/fr/LC_MESSAGES/django.po index 556491adf..73f62c3e6 100644 --- a/backend/src/accounts/locale/fr/LC_MESSAGES/django.po +++ b/backend/src/accounts/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/accounts/locale/ru/LC_MESSAGES/django.po b/backend/src/accounts/locale/ru/LC_MESSAGES/django.po index 35bd71ce7..35eeee381 100644 --- a/backend/src/accounts/locale/ru/LC_MESSAGES/django.po +++ b/backend/src/accounts/locale/ru/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/accounts/migrations/0144_auto_20260609_1910.py b/backend/src/accounts/migrations/0144_auto_20260609_1910.py new file mode 100644 index 000000000..8ea04d53a --- /dev/null +++ b/backend/src/accounts/migrations/0144_auto_20260609_1910.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2 on 2026-06-09 19:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0143_user_manager'), + ] + + operations = [ + migrations.AlterField( + model_name='notification', + name='type', + field=models.CharField(choices=[('system', 'system'), ('comment', 'new comment'), ('mention', 'mention'), ('urgent', 'urgent'), ('not_urgent', 'not urgent'), ('overdue_task', 'overdue task'), ('reminder_task', 'reminder task'), ('snooze_workflow', 'snooze workflow'), ('resume_workflow', 'resume workflow'), ('due_date_changed', 'due date changed'), ('reaction', 'reaction'), ('complete_task', 'complete task'), ('complete_workflow', 'complete workflow')], max_length=24), + ), + ] diff --git a/backend/src/analysis/locale/de/LC_MESSAGES/django.po b/backend/src/analysis/locale/de/LC_MESSAGES/django.po index 9b63a7091..35bf8d285 100644 --- a/backend/src/analysis/locale/de/LC_MESSAGES/django.po +++ b/backend/src/analysis/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/analysis/locale/django.pot b/backend/src/analysis/locale/django.pot index eeb19e806..1bdd920fa 100644 --- a/backend/src/analysis/locale/django.pot +++ b/backend/src/analysis/locale/django.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/analysis/locale/es/LC_MESSAGES/django.po b/backend/src/analysis/locale/es/LC_MESSAGES/django.po index 5ebe07253..7adce183c 100644 --- a/backend/src/analysis/locale/es/LC_MESSAGES/django.po +++ b/backend/src/analysis/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/analysis/locale/fr/LC_MESSAGES/django.po b/backend/src/analysis/locale/fr/LC_MESSAGES/django.po index 2aabe93bf..dc7c6db57 100644 --- a/backend/src/analysis/locale/fr/LC_MESSAGES/django.po +++ b/backend/src/analysis/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/analysis/locale/ru/LC_MESSAGES/django.po b/backend/src/analysis/locale/ru/LC_MESSAGES/django.po index 757ec541a..709bc4714 100644 --- a/backend/src/analysis/locale/ru/LC_MESSAGES/django.po +++ b/backend/src/analysis/locale/ru/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/authentication/locale/de/LC_MESSAGES/django.po b/backend/src/authentication/locale/de/LC_MESSAGES/django.po index 682e52fd2..f5f9f4b29 100644 --- a/backend/src/authentication/locale/de/LC_MESSAGES/django.po +++ b/backend/src/authentication/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/authentication/locale/django.pot b/backend/src/authentication/locale/django.pot index 9481e9e33..7cdad1d8f 100644 --- a/backend/src/authentication/locale/django.pot +++ b/backend/src/authentication/locale/django.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/authentication/locale/es/LC_MESSAGES/django.po b/backend/src/authentication/locale/es/LC_MESSAGES/django.po index c5c58cc66..2ba8fb14d 100644 --- a/backend/src/authentication/locale/es/LC_MESSAGES/django.po +++ b/backend/src/authentication/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/authentication/locale/fr/LC_MESSAGES/django.po b/backend/src/authentication/locale/fr/LC_MESSAGES/django.po index 201874267..10d5532e1 100644 --- a/backend/src/authentication/locale/fr/LC_MESSAGES/django.po +++ b/backend/src/authentication/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/authentication/locale/ru/LC_MESSAGES/django.po b/backend/src/authentication/locale/ru/LC_MESSAGES/django.po index ac9ec56d8..8b60b665a 100644 --- a/backend/src/authentication/locale/ru/LC_MESSAGES/django.po +++ b/backend/src/authentication/locale/ru/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/datasets/locale/de/LC_MESSAGES/django.po b/backend/src/datasets/locale/de/LC_MESSAGES/django.po index aeca49712..b60de04a7 100644 --- a/backend/src/datasets/locale/de/LC_MESSAGES/django.po +++ b/backend/src/datasets/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/datasets/locale/django.pot b/backend/src/datasets/locale/django.pot index 165a6a29e..678d4f8d4 100644 --- a/backend/src/datasets/locale/django.pot +++ b/backend/src/datasets/locale/django.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/datasets/locale/es/LC_MESSAGES/django.po b/backend/src/datasets/locale/es/LC_MESSAGES/django.po index 5cfb6c4ca..b45093fcc 100644 --- a/backend/src/datasets/locale/es/LC_MESSAGES/django.po +++ b/backend/src/datasets/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/datasets/locale/fr/LC_MESSAGES/django.po b/backend/src/datasets/locale/fr/LC_MESSAGES/django.po index 59ad970de..6b9f97f2b 100644 --- a/backend/src/datasets/locale/fr/LC_MESSAGES/django.po +++ b/backend/src/datasets/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/datasets/locale/ru/LC_MESSAGES/django.po b/backend/src/datasets/locale/ru/LC_MESSAGES/django.po index 0f8ab8686..51a6f015f 100644 --- a/backend/src/datasets/locale/ru/LC_MESSAGES/django.po +++ b/backend/src/datasets/locale/ru/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/datasets/services/dataset.py b/backend/src/datasets/services/dataset.py index 155134819..42ab54ea6 100644 --- a/backend/src/datasets/services/dataset.py +++ b/backend/src/datasets/services/dataset.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional +from typing import Dict, List, Optional from django.contrib.auth import get_user_model from django.db import IntegrityError @@ -37,7 +37,7 @@ def _create_instance( def _create_related( self, - items: Optional[List[Dict[str, Any]]] = None, + items: Optional[List[Dict]] = None, **kwargs, ): if items: @@ -82,7 +82,7 @@ def delete(self) -> None: def create_items( self, - items_data: List[Dict[str, Any]], + items_data: List[Dict], ): service = DataSetItemService( user=self.user, @@ -97,7 +97,7 @@ def create_items( def update_items( self, - items_data: List[Dict[str, Any]], + items_data: List[Dict], ): """ All dataset items will be updated """ diff --git a/backend/src/datasets/services/dataset_item.py b/backend/src/datasets/services/dataset_item.py index d7be50054..a0c595457 100644 --- a/backend/src/datasets/services/dataset_item.py +++ b/backend/src/datasets/services/dataset_item.py @@ -34,9 +34,6 @@ def _create_instance( ) from ex return self.instance - def _create_related(self, **kwargs): - pass - def partial_update( self, force_save: bool = True, @@ -54,6 +51,3 @@ def partial_update( message=MSG_DS_0002(value=self.instance.value), ) from ex return self.instance - - def delete(self) -> None: - self.instance.delete() diff --git a/backend/src/generics/base/service.py b/backend/src/generics/base/service.py index 1e2b1bf64..9df725b1a 100644 --- a/backend/src/generics/base/service.py +++ b/backend/src/generics/base/service.py @@ -3,7 +3,6 @@ from django.contrib.auth import get_user_model from django.db import transaction -from django.db.models import Model from src.authentication.enums import AuthTokenType @@ -18,19 +17,19 @@ def __init__( instance=None, is_superuser: bool = False, auth_type: AuthTokenType.LITERALS = AuthTokenType.USER, + account=None, ): if user: self.user = user self.account = user.account else: self.user = None - self.account = None + self.account = account self.is_superuser = is_superuser self.auth_type = auth_type self.instance = instance self.update_fields = set() - @abstractmethod def _create_related( self, **kwargs, @@ -54,7 +53,7 @@ def _create_actions( def create( self, **kwargs, - ) -> Model: + ): with transaction.atomic(): self._create_instance(**kwargs) self._create_related(**kwargs) @@ -70,7 +69,7 @@ def partial_update( self, force_save=False, **update_kwargs, - ) -> Model: + ): self.update_fields.update(update_kwargs.keys()) for field_name, value in update_kwargs.items(): @@ -78,3 +77,6 @@ def partial_update( if force_save: self.save() return self.instance + + def delete(self) -> None: + self.instance.delete() diff --git a/backend/src/generics/fields.py b/backend/src/generics/fields.py index 8cfdeba98..cf4698ef4 100644 --- a/backend/src/generics/fields.py +++ b/backend/src/generics/fields.py @@ -11,6 +11,7 @@ UserDateFormat, ) from src.accounts.models import Account +from src.processes.models.templates.template import Template from src.generics.messages import ( MSG_GE_0002, MSG_GE_0007, @@ -18,7 +19,7 @@ ) -class AccountPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField): +class AccountQstMixin: def _get_account(self) -> Account: account = self.context.get('account') @@ -28,14 +29,31 @@ def _get_account(self) -> Account: account = request.user.account if not account: raise Exception( - 'Account not provided for AccountPrimaryKeyRelatedField', + 'Account not provided for AccountQstMixin', ) return account + +class TemplateQstMixin: + + def _get_template(self) -> Template: + template = self.context.get('template') + if not template: + raise Exception( + 'Template not provided for TemplateQstMixin', + ) + return template + + +class AccountPrimaryKeyRelatedField( + AccountQstMixin, + serializers.PrimaryKeyRelatedField, +): + def get_queryset(self): queryset = super().get_queryset() account = self._get_account() - if account is None or queryset is None: + if queryset is None: raise Exception(MSG_GE_0002) return queryset.filter(account=account) @@ -53,6 +71,30 @@ def to_internal_value(self, data): return super().to_internal_value(data) +class RelatedApiNameField( + AccountQstMixin, + TemplateQstMixin, + serializers.SlugRelatedField, +): + + def __init__(self, **kwargs): + super().__init__(slug_field='api_name', **kwargs) + + def get_queryset(self): + queryset = super().get_queryset() + account = self._get_account() + template = self._get_template() + if queryset is None: + raise Exception(MSG_GE_0002) + return queryset.filter(account=account, template=template) + + def to_internal_value(self, data): + + """Convert api_name -> to object before saving """ + + return super().to_internal_value(data) + + class AnyField(serializers.Field): def to_internal_value(self, data): @@ -65,15 +107,28 @@ def to_representation(self, value): class RelatedListField(serializers.ListField): def to_representation(self, objects): - """ - List of objects -> List of objects ids. - """ + + """ List of objects -> List of objects ids """ + return [ self.child.to_representation(item.id) for item in objects.all() ] +class RelatedApiNameListField(serializers.ListField): + + child = serializers.CharField() + + def to_representation(self, data): + + """ List of objects -> List of objects api_name's """ + + if hasattr(data, 'all'): + return [field.api_name for field in data.all()] + return [field.api_name for field in data if hasattr(field, 'api_name')] + + class CommaSeparatedListField(serializers.ListField): def get_value(self, dictionary): diff --git a/backend/src/generics/locale/de/LC_MESSAGES/django.po b/backend/src/generics/locale/de/LC_MESSAGES/django.po index 45c4e10d0..5a19815f6 100644 --- a/backend/src/generics/locale/de/LC_MESSAGES/django.po +++ b/backend/src/generics/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/generics/locale/django.pot b/backend/src/generics/locale/django.pot index 21d3d25b6..8193dada3 100644 --- a/backend/src/generics/locale/django.pot +++ b/backend/src/generics/locale/django.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/generics/locale/es/LC_MESSAGES/django.po b/backend/src/generics/locale/es/LC_MESSAGES/django.po index f33c9415e..f652bf3e6 100644 --- a/backend/src/generics/locale/es/LC_MESSAGES/django.po +++ b/backend/src/generics/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/generics/locale/fr/LC_MESSAGES/django.po b/backend/src/generics/locale/fr/LC_MESSAGES/django.po index 5d9b9561e..8e4bdcd67 100644 --- a/backend/src/generics/locale/fr/LC_MESSAGES/django.po +++ b/backend/src/generics/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/generics/locale/ru/LC_MESSAGES/django.po b/backend/src/generics/locale/ru/LC_MESSAGES/django.po index 110122ee2..ede626116 100644 --- a/backend/src/generics/locale/ru/LC_MESSAGES/django.po +++ b/backend/src/generics/locale/ru/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/generics/messages.py b/backend/src/generics/messages.py index c5df9e56d..94ad656a9 100644 --- a/backend/src/generics/messages.py +++ b/backend/src/generics/messages.py @@ -7,7 +7,7 @@ choice=choice, ) # Translators: AccountPrimaryKeyRelatedField incorrect init -MSG_GE_0002 = _('Account or queryset not provided') +MSG_GE_0002 = _('Queryset not provided') MSG_GE_0003 = _('Value should be a list of integers.') MSG_GE_0004 = _( 'The "raise_validation_error" method should be used only ' diff --git a/backend/src/logs/service.py b/backend/src/logs/service.py index f87b66860..4bb012064 100644 --- a/backend/src/logs/service.py +++ b/backend/src/logs/service.py @@ -56,9 +56,6 @@ def _create_instance( contractor=contractor, ) - def _create_related(self, **kwargs): - pass - def api_request( self, user: UserModel, diff --git a/backend/src/notifications/locale/de/LC_MESSAGES/django.po b/backend/src/notifications/locale/de/LC_MESSAGES/django.po index a0bc29df4..45aa68234 100644 --- a/backend/src/notifications/locale/de/LC_MESSAGES/django.po +++ b/backend/src/notifications/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/notifications/locale/django.pot b/backend/src/notifications/locale/django.pot index ed2b87766..0e1206c75 100644 --- a/backend/src/notifications/locale/django.pot +++ b/backend/src/notifications/locale/django.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/notifications/locale/es/LC_MESSAGES/django.po b/backend/src/notifications/locale/es/LC_MESSAGES/django.po index 11337f83e..eca05325b 100644 --- a/backend/src/notifications/locale/es/LC_MESSAGES/django.po +++ b/backend/src/notifications/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/notifications/locale/fr/LC_MESSAGES/django.po b/backend/src/notifications/locale/fr/LC_MESSAGES/django.po index 75169984f..568ddc69b 100644 --- a/backend/src/notifications/locale/fr/LC_MESSAGES/django.po +++ b/backend/src/notifications/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/notifications/locale/ru/LC_MESSAGES/django.po b/backend/src/notifications/locale/ru/LC_MESSAGES/django.po index 2030a0fe3..68ab2152a 100644 --- a/backend/src/notifications/locale/ru/LC_MESSAGES/django.po +++ b/backend/src/notifications/locale/ru/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/payment/locale/de/LC_MESSAGES/django.po b/backend/src/payment/locale/de/LC_MESSAGES/django.po index 1ccc0a8cb..4cec99bd7 100644 --- a/backend/src/payment/locale/de/LC_MESSAGES/django.po +++ b/backend/src/payment/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/payment/locale/django.pot b/backend/src/payment/locale/django.pot index 5b69697c7..4807fa176 100644 --- a/backend/src/payment/locale/django.pot +++ b/backend/src/payment/locale/django.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/payment/locale/es/LC_MESSAGES/django.po b/backend/src/payment/locale/es/LC_MESSAGES/django.po index 17b2e0dd8..36bc09ed7 100644 --- a/backend/src/payment/locale/es/LC_MESSAGES/django.po +++ b/backend/src/payment/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/payment/locale/fr/LC_MESSAGES/django.po b/backend/src/payment/locale/fr/LC_MESSAGES/django.po index f7084c777..c7a525fbd 100644 --- a/backend/src/payment/locale/fr/LC_MESSAGES/django.po +++ b/backend/src/payment/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/payment/locale/ru/LC_MESSAGES/django.po b/backend/src/payment/locale/ru/LC_MESSAGES/django.po index 14d5f75eb..9898dbcce 100644 --- a/backend/src/payment/locale/ru/LC_MESSAGES/django.po +++ b/backend/src/payment/locale/ru/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/processes/enums.py b/backend/src/processes/enums.py index 1fb287b03..0121e45bd 100644 --- a/backend/src/processes/enums.py +++ b/backend/src/processes/enums.py @@ -729,4 +729,42 @@ class SystemVariable: WORKFLOW_NAME_VARS = {DATE, TEMPLATE_NAME, WORKFLOW_ID, WORKFLOW_STARTER} - TASK_VARS = {WORKFLOW_STARTER} + # Allowed in task name / description without matching a FieldTemplate + # (same as workflow name template — date, template-name, + # workflow-id, starter). + TASK_VARS = WORKFLOW_NAME_VARS + + +class LabelPosition: + + TOP = 'top' + LEFT = 'left' + + CHOICES = ( + (TOP, 'Top'), + (LEFT, 'Left'), + ) + LITERALS = Literal[TOP, LEFT] + + +class FieldSetLayout: + + HORIZONTAL = 'horizontal' + VERTICAL = 'vertical' + + CHOICES = ( + (HORIZONTAL, 'Horizontal'), + (VERTICAL, 'Vertical'), + ) + LITERALS = Literal[HORIZONTAL, VERTICAL] + + +class FieldSetRuleType: + + SUM_EQUAL = 'sum_equal' + + CHOICES = ( + (SUM_EQUAL, 'The sum is equal'), + ) + + LITERALS = Literal[SUM_EQUAL] diff --git a/backend/src/processes/filters.py b/backend/src/processes/filters.py index c03dce6bb..f7695d3d8 100644 --- a/backend/src/processes/filters.py +++ b/backend/src/processes/filters.py @@ -14,8 +14,8 @@ TsQuerySearchFilter, ) from src.processes.enums import TaskStatus, WorkflowStatus +from src.processes.models.templates.fieldset import FieldsetTemplate from src.processes.models.templates.system_template import SystemTemplate -from src.processes.models.templates.template import Template from src.processes.models.workflows.event import WorkflowEvent @@ -28,22 +28,6 @@ def filter(self, qs, value): return super().filter(qs, ordering) -class TemplateFilter(FilterSet): - ordering = TemplateOrderingFilter( - fields=( - ('name', 'name'), - ('date_created', 'date'), - ), - ) - - class Meta: - model = Template - fields = ( - 'ordering', - 'is_active', - ) - - class WorkflowDurationFilter(FilterSet): date_from = IsoDateTimeFilter(method='filter_date_from') date_to = IsoDateTimeFilter(method='filter_date_to') @@ -150,3 +134,20 @@ class Meta: search = TsQuerySearchFilter( field_name='search_content', ) + + +class FieldSetFilter(FilterSet): + + ordering = DefaultOrderingFilter( + fields=( + ('name', 'name'), + ('date_created', 'date'), + ), + default=('-date_created',), + ) + + class Meta: + model = FieldsetTemplate + fields = ( + 'ordering', + ) diff --git a/backend/src/processes/locale/de/LC_MESSAGES/django.po b/backend/src/processes/locale/de/LC_MESSAGES/django.po index eb72e42a7..ddcad0cd6 100644 --- a/backend/src/processes/locale/de/LC_MESSAGES/django.po +++ b/backend/src/processes/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,6 +17,21 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +msgid "Cannot delete a fieldset template that is used in templates." +msgstr "" + +#, python-brace-format +msgid "The sum of fields in this fieldset exceeds the allowed maximum: \"{value}\"." +msgstr "" + +msgid "Rule \"sum_equal\" requires all fieldset fields to be of type \"number\"." +msgstr "" + +#, fuzzy +#| msgid "The value must be a number." +msgid "Rule \"sum_equal\" value must be a number." +msgstr "Der Wert muss eine Zahl sein." + msgid "You can't pass \"snooze\" for the first task." msgstr "Sie können \"snooze\" nicht für die erste Aufgabe übergeben." diff --git a/backend/src/processes/locale/django.pot b/backend/src/processes/locale/django.pot index b9d0b1505..aa9169865 100644 --- a/backend/src/processes/locale/django.pot +++ b/backend/src/processes/locale/django.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,6 +17,19 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +msgid "Cannot delete a fieldset template that is used in templates." +msgstr "" + +#, python-brace-format +msgid "The sum of fields in this fieldset exceeds the allowed maximum: \"{value}\"." +msgstr "" + +msgid "Rule \"sum_equal\" requires all fieldset fields to be of type \"number\"." +msgstr "" + +msgid "Rule \"sum_equal\" value must be a number." +msgstr "" + msgid "You can't pass \"snooze\" for the first task." msgstr "" diff --git a/backend/src/processes/locale/es/LC_MESSAGES/django.po b/backend/src/processes/locale/es/LC_MESSAGES/django.po index 726c47fcd..f53992a8c 100644 --- a/backend/src/processes/locale/es/LC_MESSAGES/django.po +++ b/backend/src/processes/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,6 +17,21 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +msgid "Cannot delete a fieldset template that is used in templates." +msgstr "" + +#, python-brace-format +msgid "The sum of fields in this fieldset exceeds the allowed maximum: \"{value}\"." +msgstr "" + +msgid "Rule \"sum_equal\" requires all fieldset fields to be of type \"number\"." +msgstr "" + +#, fuzzy +#| msgid "The value must be a number." +msgid "Rule \"sum_equal\" value must be a number." +msgstr "El valor debe ser un número." + msgid "You can't pass \"snooze\" for the first task." msgstr "No puedes pasar \"snooze\" para la primera tarea." diff --git a/backend/src/processes/locale/fr/LC_MESSAGES/django.po b/backend/src/processes/locale/fr/LC_MESSAGES/django.po index 7e4886d43..ec0e9249f 100644 --- a/backend/src/processes/locale/fr/LC_MESSAGES/django.po +++ b/backend/src/processes/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,6 +17,21 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +msgid "Cannot delete a fieldset template that is used in templates." +msgstr "" + +#, python-brace-format +msgid "The sum of fields in this fieldset exceeds the allowed maximum: \"{value}\"." +msgstr "" + +msgid "Rule \"sum_equal\" requires all fieldset fields to be of type \"number\"." +msgstr "" + +#, fuzzy +#| msgid "The value must be a number." +msgid "Rule \"sum_equal\" value must be a number." +msgstr "La valeur doit être un nombre." + msgid "You can't pass \"snooze\" for the first task." msgstr "Vous ne pouvez pas passer \"snooze\" pour la première tâche." diff --git a/backend/src/processes/locale/ru/LC_MESSAGES/django.po b/backend/src/processes/locale/ru/LC_MESSAGES/django.po index 82f5b7df0..21e752c90 100644 --- a/backend/src/processes/locale/ru/LC_MESSAGES/django.po +++ b/backend/src/processes/locale/ru/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,6 +17,21 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +msgid "Cannot delete a fieldset template that is used in templates." +msgstr "" + +#, python-brace-format +msgid "The sum of fields in this fieldset exceeds the allowed maximum: \"{value}\"." +msgstr "" + +msgid "Rule \"sum_equal\" requires all fieldset fields to be of type \"number\"." +msgstr "" + +#, fuzzy +#| msgid "The value must be a number." +msgid "Rule \"sum_equal\" value must be a number." +msgstr "Значение должно быть числом." + msgid "You can't pass \"snooze\" for the first task." msgstr "Нельзя передать «snooze» для первой задачи." diff --git a/backend/src/processes/management/commands/migrate_fieldsets.py b/backend/src/processes/management/commands/migrate_fieldsets.py new file mode 100644 index 000000000..0e5d1617c --- /dev/null +++ b/backend/src/processes/management/commands/migrate_fieldsets.py @@ -0,0 +1,184 @@ +# ruff: noqa: T201 +import copy + +from django.core.management.base import BaseCommand +from django.db import transaction +from src.processes.models.templates.fieldset import FieldsetTemplate +from src.processes.models.templates.template import TemplateDraft +from src.processes.services.fieldsets.fieldset import FieldSetTemplateService + + +class Command(BaseCommand): + + def update_draft_fieldset( + self, + template_draft: TemplateDraft, + fieldset_data: dict, + fieldset_api_name: str, + ): + draft = template_draft.draft + if not isinstance(draft, dict): + return + + print('--- update template draft') + updated = False + # Update matching fieldsets in kickoff + kickoff = draft.get('kickoff') + new_kickoff_fieldsets = [] + if isinstance(kickoff, dict): + kickoff_fieldsets = kickoff.get('fieldsets') or [] + for i in range(len(kickoff_fieldsets)): + api_name = kickoff_fieldsets[i].get('api_name') + if api_name == fieldset_api_name: + if not updated: + new_kickoff_fieldsets.append(fieldset_data) + print('---|--- new kickoff fieldset') + updated = True + else: + print('---|--- duplicate kickoff fieldset - removed') + else: + new_kickoff_fieldsets.append(kickoff_fieldsets[i]) + kickoff['fieldsets'] = new_kickoff_fieldsets + + # Update matching fieldsets in tasks + for task in draft.get('tasks') or []: + new_task_fieldsets = [] + task_fieldsets = task.get('fieldsets') or [] + for i in range(len(task_fieldsets)): + api_name = task_fieldsets[i].get('api_name') + if api_name == fieldset_api_name: + if not updated: + task_fieldsets.append(fieldset_data) + print( + f'---|--- new task fieldset: ' + f'task: "{task["name"]}"', + ) + updated = True + else: + print('---|--- duplicate kickoff fieldset - removed') + del task_fieldsets[i] + else: + new_task_fieldsets.append(task_fieldsets[i]) + task['fieldsets'] = new_kickoff_fieldsets + template_draft.save(update_fields=('draft',)) + + def handle(self, *args, **options): + old_fieldsets = ( + FieldsetTemplate.objects + .filter(is_shared=True) + .exclude(template_id=None) + .order_by('account_id') + ) + with transaction.atomic(): + for old_shared_fieldset in old_fieldsets: + print( + f'\nProcessed old shared fieldset: ' + f'{old_shared_fieldset.name} ({old_shared_fieldset.id})', + ) + template = old_shared_fieldset.template + if template.is_active: + template_draft = None + print('--- template is active') + else: + template_draft = template.draft + print('--- template not active') + + # Ensure the original is marked as shared + old_shared_fieldset.template_id = None + + # Build the serialized representation of the shared fieldset + old_shared_fieldset_data = FieldSetTemplateService.to_json( + old_shared_fieldset, + ) + template_fieldset_data = copy.deepcopy( + old_shared_fieldset_data, + ) + + old_shared_fieldset_data.pop('id') + old_shared_fieldset_data.pop('api_name') + old_shared_fieldset_data.pop('order') + # Recreate shared fieldset with new api_names + new_shared_fieldset_data = ( + FieldSetTemplateService._replace_api_names( + old_shared_fieldset_data, + ) + ) + old_shared_fieldset.fields.all().delete() + old_shared_fieldset.rules.all().delete() + user = old_shared_fieldset.account.get_owner() + + shared_service = FieldSetTemplateService(user=user) + new_shared_fieldset = shared_service.create_shared_fieldset( + **new_shared_fieldset_data, + ) + template_fieldset_data['shared_fieldset_id'] = ( + new_shared_fieldset.id + ) + template_fieldset_data.pop('order', None) + print(f'--- new shared fieldset id: {new_shared_fieldset.id}') + + # update drafts + if template_draft: + self.update_draft_fieldset( + template_draft=template_draft, + fieldset_data=template_fieldset_data, + fieldset_api_name=old_shared_fieldset.api_name, + ) + + updated = False + # update kickoff fieldsets + kickoff_links = ( + old_shared_fieldset.kickoffs + .through.objects.filter( + fieldset=old_shared_fieldset, + is_deleted=False, + ) + ) + for link in kickoff_links: + if not updated: + service = FieldSetTemplateService(user=user) + new_template_fieldset = service.create( + **template_fieldset_data, + is_shared=False, + order=link.order, + kickoff_id=link.kickoff_id, + template_id=link.kickoff.template_id, + ) + updated = True + print( + f'--- new kickoff fieldset: ' + f'{new_template_fieldset.id}. ' + f'template: {link.kickoff.template.name} ' + f'({link.kickoff.template_id})', + ) + else: + print('--- duplicate kickoff fieldset - removed') + link.delete() + + # update tasks fieldsets + task_links = old_shared_fieldset.tasks.through.objects.filter( + fieldset=old_shared_fieldset, + is_deleted=False, + ) + + for link in task_links: + if not updated: + service = FieldSetTemplateService(user=user) + new_template_fieldset = service.create( + **template_fieldset_data, + is_shared=False, + order=link.order, + task_id=link.task_id, + template_id=link.task.template_id, + ) + updated = True + print( + f'--- new task fieldset: ' + f'{new_template_fieldset.id}. ' + f'task: "{link.task.name}" ' + f'({link.task.id})', + ) + else: + print('--- duplicate task fieldset - removed') + link.delete() + old_shared_fieldset.delete() diff --git a/backend/src/processes/management/commands/migrate_presets.py b/backend/src/processes/management/commands/migrate_presets.py deleted file mode 100644 index 61fce766b..000000000 --- a/backend/src/processes/management/commands/migrate_presets.py +++ /dev/null @@ -1,42 +0,0 @@ -from django.core.management.base import BaseCommand -from django.db import transaction - -from src.processes.models.templates.preset import TemplatePresetField - - -class Command(BaseCommand): - - help = 'Set more unique api_names for system columns.' - - def handle(self, *args, **options): - with transaction.atomic(): - ( - TemplatePresetField.objects - .filter(api_name='workflow') - .update(api_name='system-column-workflow') - ) - ( - TemplatePresetField.objects - .filter(api_name='starter') - .update(api_name='system-column-starter') - ) - ( - TemplatePresetField.objects - .filter(api_name='progress') - .update(api_name='system-column-progress') - ) - ( - TemplatePresetField.objects - .filter(api_name='step') - .update(api_name='system-column-step') - ) - ( - TemplatePresetField.objects - .filter(api_name='performer') - .update(api_name='system-column-performer') - ) - ( - TemplatePresetField.objects - .filter(api_name='template') - .update(api_name='system-column-template') - ) diff --git a/backend/src/processes/messages/fieldset.py b/backend/src/processes/messages/fieldset.py new file mode 100644 index 000000000..04280a8b2 --- /dev/null +++ b/backend/src/processes/messages/fieldset.py @@ -0,0 +1,40 @@ +from django.utils.text import format_lazy +from django.utils.translation import ugettext_lazy as _ + +MSG_FS_0001 = _( + 'Cannot delete a fieldset template that is used in templates.', +) +MSG_FS_0002 = lambda value: format_lazy( + _('The sum of the fields in this field set must equal "{value}".'), + value=value, +) +MSG_FS_0003 = _( + 'Rule "Sum equal" requires all fields to be of type "number".', +) +MSG_FS_0004 = _('Rule "Sum equal" value must be a number.') +MSG_FS_0005 = lambda rule, field: format_lazy( + _('field "{field}" not found in rule "{rule}".'), + field=field, + rule=rule, +) +MSG_FS_0006 = _( + 'The task with the specified "api_name" was not found in the template', +) +MSG_FS_0007 = _( + 'Either "task" or "kickoff" must be provided to create a fieldset.', +) +MSG_FS_0008 = _( + 'Shared fieldset template with the specified id was not found.', +) +MSG_FS_0009 = _( + 'Cannot change a fieldset template that is used in templates.', +) +MSG_FS_0010 = _('shared_fieldset_id is required for a template fieldset.') +MSG_FS_0011 = _('template_id is required for a template fieldset.') +MSG_FS_0012 = lambda values: format_lazy( + _( + 'The sum of the fields in this field set must equal one of ' + 'the following values: "{values}".', + ), + values=values, +) diff --git a/backend/src/processes/migrations/0254_add_fieldsets.py b/backend/src/processes/migrations/0254_add_fieldsets.py new file mode 100644 index 000000000..e865e2436 --- /dev/null +++ b/backend/src/processes/migrations/0254_add_fieldsets.py @@ -0,0 +1,158 @@ +from django.db import migrations, models +import django.db.models.deletion +import src.generics.mixins.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('processes', '0253_add_completed_or_skipped_predicate'), + ] + + operations = [ + migrations.CreateModel( + name='FieldSet', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('api_name', models.CharField(max_length=200)), + ('label_position', models.CharField(choices=[('top', 'Top'), ('left', 'Left')], default='top', max_length=20)), + ('name', models.TextField(max_length=1000)), + ('description', models.TextField(blank=True, default='')), + ('layout', models.CharField(choices=[('horizontal', 'Horizontal'), ('vertical', 'Vertical')], default='vertical', max_length=200)), + ('order', models.IntegerField(default=0)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.Account')), + ('kickoff', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.KickoffValue')), + ('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Task')), + ('workflow', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Workflow')), + ], + options={ + 'ordering': ['-id'], + }, + bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model), + ), + migrations.CreateModel( + name='FieldSetRule', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('api_name', models.CharField(max_length=200)), + ('type', models.CharField(choices=[('sum_equal', 'The sum is equal')], max_length=50)), + ('value', models.TextField(blank=True, null=True)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.Account')), + ('fieldset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rules', to='processes.FieldSet')), + ], + options={ + 'ordering': ['-id'], + }, + bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model), + ), + migrations.CreateModel( + name='FieldsetTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('api_name', models.CharField(max_length=200)), + ('label_position', models.CharField(choices=[('top', 'Top'), ('left', 'Left')], default='top', max_length=20)), + ('name', models.TextField(max_length=1000)), + ('description', models.TextField(blank=True, default='')), + ('layout', models.CharField(choices=[('horizontal', 'Horizontal'), ('vertical', 'Vertical')], default='vertical', max_length=200)), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.Account')), + ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Template')), + ], + options={ + 'ordering': ['-id'], + }, + bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model), + ), + migrations.CreateModel( + name='FieldsetTemplateRule', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('api_name', models.CharField(max_length=200)), + ('type', models.CharField(choices=[('sum_equal', 'The sum is equal')], max_length=50)), + ('value', models.TextField(blank=True, null=True)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.Account')), + ('fieldset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rules', to='processes.FieldsetTemplate')), + ], + options={ + 'ordering': ['-id'], + }, + bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model), + ), + migrations.CreateModel( + name='FieldsetTemplateTaskTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('order', models.IntegerField(default=0)), + ('fieldset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='processes.FieldsetTemplate')), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='processes.TaskTemplate')), + ], + options={ + 'db_table': 'processes_fieldsettemplate_tasktemplate', + 'ordering': ['order'], + }, + bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model), + ), + migrations.CreateModel( + name='FieldsetTemplateKickoff', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, + serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('order', models.IntegerField(default=0)), + ('fieldset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='processes.FieldsetTemplate')), + ('kickoff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='processes.Kickoff')), + ], + options={ + 'db_table': 'processes_fieldsettemplate_kickoff', + 'ordering': ['order'], + }, + bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model), + ), + migrations.AddField( + model_name='fieldsettemplate', + name='kickoffs', + field=models.ManyToManyField(blank=True, related_name='fieldsets', through='processes.FieldsetTemplateKickoff', to='processes.Kickoff'), + ), + migrations.AddConstraint( + model_name='fieldsettemplate', + constraint=models.UniqueConstraint( + condition=models.Q(is_deleted=False), + fields=('template', 'api_name'), + name='fieldsettemplate_api_name_template_unique'), + ), + migrations.AddField( + model_name='fieldsettemplate', + name='tasks', + field=models.ManyToManyField(blank=True, related_name='fieldsets', through='processes.FieldsetTemplateTaskTemplate', to='processes.TaskTemplate'), + ), + migrations.AlterField( + model_name='fieldtemplate', + name='template', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='processes.Template'), + ), + migrations.AddField( + model_name='fieldtemplate', + name='fieldset', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='processes.FieldsetTemplate'), + ), + migrations.AddField( + model_name='fieldtemplate', + name='rules', + field=models.ManyToManyField(blank=True, related_name='fields', to='processes.FieldsetTemplateRule'), + ), + migrations.AddField( + model_name='taskfield', + name='fieldset', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='processes.FieldSet'), + ), + migrations.AddField( + model_name='taskfield', + name='rules', + field=models.ManyToManyField(blank=True, related_name='fields', to='processes.FieldSetRule'), + ), + ] diff --git a/backend/src/processes/migrations/0255_add_shared_fieldsets.py b/backend/src/processes/migrations/0255_add_shared_fieldsets.py new file mode 100644 index 000000000..becdeef08 --- /dev/null +++ b/backend/src/processes/migrations/0255_add_shared_fieldsets.py @@ -0,0 +1,89 @@ +# Generated by Django 2.2 on 2026-07-01 12:59 + +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('processes', '0254_add_fieldsets'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='fieldsettemplate', + name='fieldsettemplate_api_name_template_unique', + ), + migrations.RunSQL(""" + ALTER TABLE processes_fieldsettemplate + DROP CONSTRAINT IF EXISTS fieldsettemplate_template_api_name_unique; + """), + migrations.RunSQL(""" + DROP INDEX IF EXISTS fieldsettemplate_template_api_name_unique; + """), + migrations.AddField( + model_name='fieldset', + name='title', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='fieldsettemplate', + name='is_shared', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='fieldsettemplate', + name='kickoff', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Kickoff'), + ), + migrations.AddField( + model_name='fieldsettemplate', + name='order', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='fieldsettemplate', + name='shared_fieldset', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_fieldsets', to='processes.FieldsetTemplate'), + ), + migrations.AddField( + model_name='fieldsettemplate', + name='task', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.TaskTemplate'), + ), + migrations.AddField( + model_name='fieldsettemplate', + name='title', + field=models.TextField(blank=True, default=''), + ), + migrations.AlterField( + model_name='fieldsettemplate', + name='kickoffs', + field=models.ManyToManyField(blank=True, related_name='old_fieldsets', through='processes.FieldsetTemplateKickoff', to='processes.Kickoff'), + ), + migrations.AlterField( + model_name='fieldsettemplate', + name='tasks', + field=models.ManyToManyField(blank=True, related_name='old_fieldsets', through='processes.FieldsetTemplateTaskTemplate', to='processes.TaskTemplate'), + ), + migrations.AlterField( + model_name='fieldsettemplate', + name='template', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Template'), + ), + migrations.AlterField( + model_name='fieldtemplateselection', + name='template', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='selections', to='processes.Template'), + ), + migrations.AddConstraint( + model_name='fieldsettemplate', + constraint=models.UniqueConstraint(condition=models.Q(is_deleted=False), fields=('api_name', 'template', 'is_shared'), name='fieldsettemplate_template_api_name_is_shared_unique'), + ), + migrations.AddConstraint( + model_name='fieldsettemplaterule', + constraint=models.UniqueConstraint(condition=models.Q(is_deleted=False), fields=('api_name', 'fieldset'), name='fieldsettemplate_api_name_template_unique'), + ), + ] diff --git a/backend/src/processes/models/mixins.py b/backend/src/processes/models/mixins.py index 3d091ae93..f993b8220 100644 --- a/backend/src/processes/models/mixins.py +++ b/backend/src/processes/models/mixins.py @@ -7,13 +7,16 @@ from django.db import models from django.db.models.query import QuerySet -from src.accounts.models import UserGroup, AccountBaseMixin +from src.accounts.models import UserGroup from src.processes.enums import ( ConditionAction, FieldType, + LabelPosition, + FieldSetLayout, PerformerType, PredicateOperator, PredicateType, + FieldSetRuleType, ) from src.datasets.models import Dataset @@ -181,7 +184,7 @@ class Meta: ) -class FieldMixin(AccountBaseMixin): +class FieldMixin(models.Model): class Meta: abstract = True @@ -326,3 +329,43 @@ class Meta: abstract = True api_name = models.CharField(max_length=200) + + +class FieldMetaMixin(models.Model): + + class Meta: + abstract = True + + label_position = models.CharField( + max_length=20, + choices=LabelPosition.CHOICES, + default=LabelPosition.TOP, + ) + + +class BaseFieldSetMixin(FieldMetaMixin): + + class Meta: + abstract = True + + name = models.TextField(max_length=1000) + title = models.TextField(blank=True, default='') + order = models.IntegerField(default=0) + description = models.TextField(blank=True, default='') + layout = models.CharField( + max_length=200, + choices=FieldSetLayout.CHOICES, + default=FieldSetLayout.VERTICAL, + ) + + +class BaseFieldSetRuleMixin(models.Model): + + class Meta: + abstract = True + + type = models.CharField( + max_length=50, + choices=FieldSetRuleType.CHOICES, + ) + value = models.TextField(blank=True, null=True) diff --git a/backend/src/processes/models/templates/fields.py b/backend/src/processes/models/templates/fields.py index bed5f0ae7..3c36b8f03 100644 --- a/backend/src/processes/models/templates/fields.py +++ b/backend/src/processes/models/templates/fields.py @@ -1,6 +1,7 @@ from django.db import models from django.db.models import Q, UniqueConstraint +from src.accounts.models import AccountBaseMixin from src.generics.managers import BaseSoftDeleteManager from src.processes.models.base import BaseApiNameModel from src.processes.models.mixins import FieldMixin @@ -15,6 +16,7 @@ class FieldTemplate( BaseApiNameModel, + AccountBaseMixin, FieldMixin, ): @@ -33,6 +35,8 @@ class Meta: template = models.ForeignKey( Template, on_delete=models.CASCADE, + null=True, + blank=True, related_name='fields', ) kickoff = models.ForeignKey( @@ -47,6 +51,18 @@ class Meta: null=True, related_name='fields', ) + fieldset = models.ForeignKey( + 'processes.FieldsetTemplate', + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='fields', + ) + rules = models.ManyToManyField( + 'processes.FieldsetTemplateRule', + blank=True, + related_name='fields', + ) date_created = models.DateTimeField(auto_now_add=True) default = models.TextField(blank=True) @@ -83,6 +99,8 @@ class Meta: template = models.ForeignKey( Template, on_delete=models.CASCADE, + null=True, + blank=True, related_name='selections', ) field_template = models.ForeignKey( diff --git a/backend/src/processes/models/templates/fieldset.py b/backend/src/processes/models/templates/fieldset.py new file mode 100644 index 000000000..8149881db --- /dev/null +++ b/backend/src/processes/models/templates/fieldset.py @@ -0,0 +1,176 @@ +from django.db import models +from django.db.models import Q, UniqueConstraint + +from src.accounts.models import AccountBaseMixin +from src.generics.managers import BaseSoftDeleteManager +from src.generics.models import SoftDeleteModel +from src.processes.models.base import BaseApiNameModel +from src.processes.models.mixins import ( + BaseFieldSetMixin, + BaseFieldSetRuleMixin, +) +from src.processes.models.templates.kickoff import Kickoff +from src.processes.models.templates.template import Template +from src.processes.models.templates.task import TaskTemplate +from src.processes.querysets import ( + FieldsetTemplateQuerySet, + FieldsetTemplateRuleQuerySet, +) + + +class FieldsetTemplate( + BaseApiNameModel, + BaseFieldSetMixin, + AccountBaseMixin, +): + + class Meta: + ordering = ['-id'] + constraints = [ + UniqueConstraint( + fields=['api_name', 'template', 'is_shared'], + condition=Q(is_deleted=False), + name='fieldsettemplate_template_api_name_is_shared_unique', + ), + ] + + api_name_prefix = 'fieldset' + + date_created = models.DateTimeField( + auto_now_add=True, + ) + is_shared = models.BooleanField(default=True) + shared_fieldset = models.ForeignKey( + 'FieldsetTemplate', + on_delete=models.SET_NULL, + related_name='child_fieldsets', + null=True, + blank=True, + ) + template = models.ForeignKey( + Template, + on_delete=models.CASCADE, + related_name='fieldsets', + null=True, + blank=True, + ) + task = models.ForeignKey( + TaskTemplate, + on_delete=models.CASCADE, + related_name='fieldsets', + null=True, + blank=True, + ) + kickoff = models.ForeignKey( + Kickoff, + on_delete=models.CASCADE, + related_name='fieldsets', + null=True, + blank=True, + ) + + # TODO Deprecated + tasks = models.ManyToManyField( + TaskTemplate, + through='FieldsetTemplateTaskTemplate', + related_name='old_fieldsets', + blank=True, + ) + # TODO Deprecated + kickoffs = models.ManyToManyField( + Kickoff, + through='FieldsetTemplateKickoff', + related_name='old_fieldsets', + blank=True, + ) + + objects = BaseSoftDeleteManager.from_queryset( + FieldsetTemplateQuerySet, + )() + + def __str__(self): + return self.name + + +class FieldsetTemplateRule( + BaseApiNameModel, + BaseFieldSetRuleMixin, + AccountBaseMixin, +): + + class Meta: + ordering = ['-id'] + constraints = [ + UniqueConstraint( + fields=['api_name', 'fieldset'], + condition=Q(is_deleted=False), + name='fieldsettemplate_api_name_template_unique', + ), + ] + + api_name_prefix = 'fieldsetrule' + + fieldset = models.ForeignKey( + FieldsetTemplate, + on_delete=models.CASCADE, + related_name='rules', + ) + + objects = BaseSoftDeleteManager.from_queryset( + FieldsetTemplateRuleQuerySet, + )() + + def __str__(self): + return self.name + + +class FieldsetTemplateTaskTemplate(SoftDeleteModel): + + # TODO Deprecated + + class Meta: + ordering = ['order'] + db_table = 'processes_fieldsettemplate_tasktemplate' + + fieldset = models.ForeignKey( + 'FieldsetTemplate', + on_delete=models.CASCADE, + ) + task = models.ForeignKey( + TaskTemplate, + on_delete=models.CASCADE, + ) + order = models.IntegerField(default=0) + + def __str__(self): + return ( + f'Fieldset: {self.fieldset_id} - ' + f'Task: {self.task_template_id} - ' + f'Order: {self.order}' + ) + + +class FieldsetTemplateKickoff(SoftDeleteModel): + + # TODO Deprecated + + class Meta: + ordering = ['order'] + db_table = 'processes_fieldsettemplate_kickoff' + + fieldset = models.ForeignKey( + 'FieldsetTemplate', + on_delete=models.CASCADE, + ) + kickoff = models.ForeignKey( + Kickoff, + on_delete=models.CASCADE, + ) + order = models.IntegerField(default=0) + + def __str__(self): + return ( + f'Fieldset: {self.fieldset_id} - ' + f'Kickoff: {self.kickoff_id} - ' + f'Order: {self.order}' + ) diff --git a/backend/src/processes/models/templates/template.py b/backend/src/processes/models/templates/template.py index 2acc7ad53..12bba56d1 100644 --- a/backend/src/processes/models/templates/template.py +++ b/backend/src/processes/models/templates/template.py @@ -8,6 +8,7 @@ ObjectDoesNotExist, ) from django.db import models, transaction +from django.db.models import Q from src.accounts.models import ( AccountBaseMixin, @@ -146,16 +147,17 @@ def get_kickoff_output_fields( """ Return the output fields from kickoff """ - try: - result = self.kickoff.get().fields.all() - if fields_filter_kwargs: - result = result.filter(**fields_filter_kwargs) - - except ObjectDoesNotExist: - from src.processes.models.templates\ - .fields import FieldTemplate - result = FieldTemplate.objects.none() - return result + from src.processes.models.templates.fields import FieldTemplate + kickoff = self.kickoff.get() + qst = FieldTemplate.objects.filter( + Q( + Q(kickoff_id=kickoff.id) | + Q(fieldset__kickoff__id=kickoff.id), + ), + ) + if fields_filter_kwargs: + qst = qst.filter(**fields_filter_kwargs) + return qst def get_tasks_output_fields( self, @@ -164,26 +166,39 @@ def get_tasks_output_fields( fields_filter_kwargs: Optional[Dict] = None, ) -> FieldTemplateQuerySet: - """ Return the output fields from tasks """ + """Return output FieldTemplates for this template's tasks, + with optional task/field-level filtering and exclusion. - from src.processes.models.templates \ - .fields import FieldTemplate + Fields are collected from two sources: + - directly attached to tasks (task FK) + - indirectly via fieldsets linked to tasks through M2M + """ - if tasks_filter_kwargs is None: - tasks_filter_kwargs = { - 'task__template_id': self.id, - 'task__account_id': self.account_id, - } - else: - tasks_filter_kwargs['task__template_id'] = self.id - tasks_filter_kwargs['task__account_id'] = self.account_id - qst = FieldTemplate.objects.filter(**tasks_filter_kwargs) + from src.processes.models.templates.fields import FieldTemplate + + tasks_filter_kwargs = tasks_filter_kwargs or {} + tasks_exclude_kwargs = tasks_exclude_kwargs or {} + fields_filter_kwargs = fields_filter_kwargs or {} + + tasks_filter = {'task__template_id': self.id, **tasks_filter_kwargs} + fieldset_filter = {'fieldset__task__template_id': self.id} + for key, value in tasks_filter_kwargs.items(): + fieldset_filter[f'fieldset__{key}'] = value + tasks_q = Q(**tasks_filter) + fieldset_q = Q(**fieldset_filter) + + if tasks_exclude_kwargs: + fieldset_exclude_kwargs = {} + for key, value in tasks_exclude_kwargs.items(): + fieldset_exclude_kwargs[f'fieldset__{key}'] = value + tasks_q = Q(tasks_q, ~Q(**tasks_exclude_kwargs)) + fieldset_q = Q(fieldset_q, ~Q(**fieldset_exclude_kwargs)) + + qst = FieldTemplate.objects.filter(tasks_q | fieldset_q) if fields_filter_kwargs: qst = qst.filter(**fields_filter_kwargs) - if tasks_exclude_kwargs: - qst = qst.exclude(**tasks_exclude_kwargs) return qst def get_tasks(self, performer_id: int): diff --git a/backend/src/processes/models/workflows/fields.py b/backend/src/processes/models/workflows/fields.py index dca7e13ba..45ee46fdc 100644 --- a/backend/src/processes/models/workflows/fields.py +++ b/backend/src/processes/models/workflows/fields.py @@ -1,6 +1,7 @@ from django.contrib.auth import get_user_model from django.db import models +from src.accounts.models import AccountBaseMixin from src.generics.managers import BaseSoftDeleteManager from src.generics.models import SoftDeleteModel from src.processes.models.mixins import ( @@ -9,6 +10,7 @@ ) from src.processes.models.workflows.kickoff import KickoffValue from src.processes.models.workflows.task import Task +from src.processes.models.workflows.fieldset import FieldSet from src.processes.models.workflows.workflow import Workflow from src.processes.querysets import ( FieldSelectionQuerySet, @@ -20,6 +22,7 @@ class TaskField( SoftDeleteModel, + AccountBaseMixin, FieldMixin, ApiNameMixin, ): @@ -55,6 +58,18 @@ class Meta: related_name='output', null=True, ) + fieldset = models.ForeignKey( + FieldSet, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='fields', + ) + rules = models.ManyToManyField( + 'processes.FieldsetRule', + blank=True, + related_name='fields', + ) workflow = models.ForeignKey( Workflow, on_delete=models.CASCADE, diff --git a/backend/src/processes/models/workflows/fieldset.py b/backend/src/processes/models/workflows/fieldset.py new file mode 100644 index 000000000..86e08842e --- /dev/null +++ b/backend/src/processes/models/workflows/fieldset.py @@ -0,0 +1,63 @@ +from django.db import models + +from src.accounts.models import AccountBaseMixin +from src.generics.managers import BaseSoftDeleteManager +from src.processes.models.base import BaseApiNameModel +from src.processes.models.mixins import ( + BaseFieldSetMixin, + BaseFieldSetRuleMixin, +) +from src.processes.models.workflows.kickoff import KickoffValue +from src.processes.models.workflows.task import Task +from src.processes.models.workflows.workflow import Workflow +from src.processes.querysets import FieldSetQuerySet, FieldSetRuleQuerySet + + +class FieldSet( + BaseApiNameModel, + BaseFieldSetMixin, + AccountBaseMixin, +): + + class Meta: + ordering = ['-id'] + + workflow = models.ForeignKey( + Workflow, + on_delete=models.CASCADE, + related_name='fieldsets', + ) + kickoff = models.ForeignKey( + KickoffValue, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='fieldsets', + ) + task = models.ForeignKey( + Task, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='fieldsets', + ) + + objects = BaseSoftDeleteManager.from_queryset(FieldSetQuerySet)() + + +class FieldSetRule( + BaseApiNameModel, + BaseFieldSetRuleMixin, + AccountBaseMixin, +): + + class Meta: + ordering = ['-id'] + + fieldset = models.ForeignKey( + FieldSet, + on_delete=models.CASCADE, + related_name='rules', + ) + + objects = BaseSoftDeleteManager.from_queryset(FieldSetRuleQuerySet)() diff --git a/backend/src/processes/models/workflows/workflow.py b/backend/src/processes/models/workflows/workflow.py index b961c774f..80eaf046b 100644 --- a/backend/src/processes/models/workflows/workflow.py +++ b/backend/src/processes/models/workflows/workflow.py @@ -2,8 +2,8 @@ from typing import Dict, Optional from django.contrib.auth import get_user_model -from django.core.exceptions import ObjectDoesNotExist from django.db import models +from django.db.models import Q from django.utils import timezone from src.accounts.models import AccountBaseMixin @@ -126,15 +126,17 @@ def get_kickoff_output_fields( """ Return the output fields from kickoff """ - try: - result = self.kickoff.get().output.all() - if fields_filter_kwargs: - result = result.filter(**fields_filter_kwargs) - except ObjectDoesNotExist: - from src.processes.models.workflows \ - .fields import TaskField - result = TaskField.objects.none() - return result + from src.processes.models.workflows.fields import TaskField + kickoff = self.kickoff.get() + qst = TaskField.objects.filter( + Q( + Q(kickoff_id=kickoff.id) | + Q(fieldset__kickoff_id=kickoff.id), + ), + ) + if fields_filter_kwargs: + qst = qst.filter(**fields_filter_kwargs) + return qst def get_tasks_output_fields( self, @@ -145,24 +147,33 @@ def get_tasks_output_fields( """ Return the output fields from tasks """ - from src.processes.models.workflows \ - .fields import TaskField + from src.processes.models.workflows.fields import TaskField + + tasks_filter_kwargs = tasks_filter_kwargs or {} + tasks_exclude_kwargs = tasks_exclude_kwargs or {} + fields_filter_kwargs = fields_filter_kwargs or {} - if tasks_filter_kwargs is None: - tasks_filter_kwargs = { - 'task__workflow_id': self.id, - 'task__account_id': self.account_id, + tasks_filter = {'task__workflow_id': self.id, **tasks_filter_kwargs} + fieldset_filter = { + f'fieldset__{key}': value + for key, value in tasks_filter.items() + } + tasks_q = Q(**tasks_filter) + fieldset_q = Q(**fieldset_filter) + + if tasks_exclude_kwargs: + fieldset_exclude_kwargs = { + f'fieldset__{key}': value + for key, value in tasks_exclude_kwargs.items() } - else: - tasks_filter_kwargs['task__workflow_id'] = self.id - tasks_filter_kwargs['task__account_id'] = self.account_id - qst = TaskField.objects.filter(**tasks_filter_kwargs) + tasks_q = Q(tasks_q, ~Q(**tasks_exclude_kwargs)) + fieldset_q = Q(fieldset_q, ~Q(**fieldset_exclude_kwargs)) + + qst = TaskField.objects.filter(tasks_q | fieldset_q) if fields_filter_kwargs: qst = qst.filter(**fields_filter_kwargs) - if tasks_exclude_kwargs: - qst = qst.exclude(**tasks_exclude_kwargs) return qst @property diff --git a/backend/src/processes/querysets.py b/backend/src/processes/querysets.py index f3aaa3013..19ac77abe 100644 --- a/backend/src/processes/querysets.py +++ b/backend/src/processes/querysets.py @@ -293,6 +293,7 @@ def raw_list_query( lookup='kickoff', queryset=( Kickoff.objects.all().prefetch_related( + 'fieldsets', Prefetch( lookup='fields', queryset=( @@ -364,6 +365,7 @@ def raw_list_by_owners_query( lookup='kickoff', queryset=( Kickoff.objects.all().prefetch_related( + 'fieldsets', Prefetch( lookup='fields', queryset=( @@ -443,9 +445,11 @@ def raw_export_query( 'tasks__conditions__rules', 'tasks__conditions__rules__predicates', 'tasks__raw_performers', + 'tasks__fieldsets', 'kickoff', 'kickoff__fields', 'kickoff__fields__selections', + 'kickoff__fieldsets', ) ) @@ -686,7 +690,12 @@ def raw_list_query( queryset=( TaskField.objects .filter(api_name__in=fields) - .order_by('kickoff_id', 'task__number', '-order') + .order_by( + 'kickoff_id', + 'task__number', + 'fieldset_id', + '-order', + ) ), ), ) @@ -1275,3 +1284,24 @@ def by_user(self, user, template_id): class SearchContentQuerySet(AccountBaseQuerySet): pass + + +class FieldsetTemplateQuerySet(AccountBaseQuerySet): + + def shared(self): + return self.filter(is_shared=True) + + +class FieldsetTemplateRuleQuerySet(AccountBaseQuerySet): + + pass + + +class FieldSetQuerySet(AccountBaseQuerySet): + + pass + + +class FieldSetRuleQuerySet(AccountBaseQuerySet): + + pass diff --git a/backend/src/processes/serializers/templates/field.py b/backend/src/processes/serializers/templates/field.py index 7b92a8321..48ea10dff 100644 --- a/backend/src/processes/serializers/templates/field.py +++ b/backend/src/processes/serializers/templates/field.py @@ -73,16 +73,16 @@ class Meta: model = FieldTemplate api_primary_field = 'api_name' fields = ( - 'type', 'name', 'description', + 'type', 'is_required', 'is_hidden', - 'selections', - 'order', 'api_name', + 'selections', 'default', 'dataset', + 'order', ) create_or_update_fields = { 'type', @@ -215,31 +215,19 @@ def update( return instance -class FieldTemplateShortViewSerializer(ModelSerializer): - class Meta: - model = FieldTemplate - fields = ( - 'name', - 'type', - 'order', - 'description', - 'is_hidden', - 'api_name', - ) - - class FieldTemplateListSerializer(ModelSerializer): class Meta: model = FieldTemplate fields = ( 'name', + 'description', 'type', 'is_required', 'is_hidden', - 'description', 'api_name', 'selections', + 'default', 'dataset', 'order', ) diff --git a/backend/src/processes/serializers/templates/fieldset.py b/backend/src/processes/serializers/templates/fieldset.py new file mode 100644 index 000000000..b50a57c81 --- /dev/null +++ b/backend/src/processes/serializers/templates/fieldset.py @@ -0,0 +1,119 @@ +from django.core.validators import MinValueValidator +from rest_framework.fields import CharField, IntegerField +from rest_framework.serializers import ModelSerializer +from src.generics.fields import ( + RelatedApiNameListField, AccountPrimaryKeyRelatedField, +) +from src.generics.mixins.serializers import CustomValidationErrorMixin +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateRule, +) +from src.processes.serializers.templates.field import ( + FieldTemplateSerializer, +) + + +class FieldsetTemplateRuleSerializer( + CustomValidationErrorMixin, + ModelSerializer, +): + + class Meta: + model = FieldsetTemplateRule + fields = ( + 'type', + 'value', + 'api_name', + 'fields', + ) + + api_name = CharField(required=False, max_length=200) + fields = RelatedApiNameListField( + required=False, + allow_empty=True, + default=list, + ) + + +class FieldsetTemplateSerializer( + ModelSerializer, + CustomValidationErrorMixin, +): + + class Meta: + model = FieldsetTemplate + fields = ( + 'title', + 'order', + 'description', + 'api_name', + 'shared_fieldset_id', + 'name', + 'label_position', + 'layout', + 'rules', + 'fields', + ) + + read_only_fields = ( + 'name', + 'label_position', + 'layout', + 'rules', + 'fields', + ) + + shared_fieldset_id = AccountPrimaryKeyRelatedField( + queryset=FieldsetTemplate.objects.all(), + required=True, + ) + api_name = CharField(required=False, max_length=200) + rules = FieldsetTemplateRuleSerializer( + many=True, + required=False, + default=list, + ) + fields = FieldTemplateSerializer( + many=True, + required=False, + default=list, + ) + order = IntegerField( + required=False, + default=0, + validators=[MinValueValidator(0)], + ) + + +class SharedFieldsetTemplateSerializer( + CustomValidationErrorMixin, + ModelSerializer, +): + + class Meta: + model = FieldsetTemplate + fields = ( + 'id', + 'title', + 'order', + 'description', + 'api_name', + 'name', + 'label_position', + 'layout', + 'rules', + 'fields', + ) + + rules = FieldsetTemplateRuleSerializer( + many=True, + required=False, + default=list, + ) + fields = FieldTemplateSerializer( + many=True, + required=False, + default=list, + ) + api_name = CharField(required=False, max_length=200) diff --git a/backend/src/processes/serializers/templates/kickoff.py b/backend/src/processes/serializers/templates/kickoff.py index 668e10b0f..36ffa3115 100644 --- a/backend/src/processes/serializers/templates/kickoff.py +++ b/backend/src/processes/serializers/templates/kickoff.py @@ -3,7 +3,6 @@ from rest_framework.serializers import ( ModelSerializer, ) - from src.generics.mixins.serializers import ( AdditionalValidationMixin, CustomValidationErrorMixin, @@ -12,11 +11,14 @@ from src.processes.serializers.templates.field import ( FieldTemplateListSerializer, FieldTemplateSerializer, - FieldTemplateShortViewSerializer, +) +from src.processes.serializers.templates.fieldset import ( + FieldsetTemplateSerializer, ) from src.processes.serializers.templates.mixins import ( CreateOrUpdateInstanceMixin, CreateOrUpdateRelatedMixin, + FieldsetMixin, ) @@ -25,6 +27,7 @@ class KickoffSerializer( CreateOrUpdateRelatedMixin, CustomValidationErrorMixin, AdditionalValidationMixin, + FieldsetMixin, ModelSerializer, ): @@ -32,34 +35,50 @@ class Meta: model = Kickoff fields = ( 'fields', + 'fieldsets', ) create_or_update_fields = { 'account', 'template', } - fields = FieldTemplateSerializer(many=True, required=False) + fields = FieldTemplateSerializer(many=True, required=False, default=list) + fieldsets = FieldsetTemplateSerializer( + many=True, + required=False, + allow_empty=True, + ) - def to_representation(self, data: Dict[str, Any]): - data = super().to_representation(data) - if data.get('fields') is None: - data['fields'] = [] - return data + def to_representation(self, instance): + # TODO Delete when the Template <-> Kickoff relation becomes o2o + from django.db import models # noqa : PLC0415 + if isinstance(instance, models.Manager): + instance = instance.first() + if instance is None: + return {'fields': [], 'fieldsets': []} + return super().to_representation(instance) def create(self, validated_data: Dict[str, Any]): self.additional_validate(validated_data) + template = self.context['template'] instance = self.create_or_update_instance( validated_data={ - 'template': self.context['template'], + 'template': template, 'account': self.context.get('account'), **validated_data, }, ) + self.create_or_update_fieldsets( + fieldsets_data=validated_data.pop('fieldsets', []), + template=template, + kickoff=instance, + user=self.context['user'], + ) self.create_or_update_related( data=validated_data.get('fields'), ancestors_data={ 'kickoff': instance, - 'template': self.context['template'], + 'template': template, }, slz_cls=FieldTemplateSerializer, slz_context={ @@ -75,19 +94,26 @@ def update( validated_data: Dict[str, Any], ): self.additional_validate(validated_data) + template = self.context['template'] instance = self.create_or_update_instance( instance=instance, validated_data={ - 'template': self.context['template'], + 'template': template, 'account': self.context.get('account'), **validated_data, }, ) + self.create_or_update_fieldsets( + fieldsets_data=validated_data.pop('fieldsets', []), + template=template, + kickoff=instance, + user=self.context['user'], + ) self.create_or_update_related( data=validated_data.get('fields'), ancestors_data={ 'kickoff': instance.id, - 'template': self.context['template'], + 'template': template, }, slz_cls=FieldTemplateSerializer, slz_context={ @@ -98,26 +124,21 @@ def update( return instance -class KickoffOnlyFieldsSerializer(ModelSerializer): - class Meta: - model = Kickoff - fields = ( - 'fields', - ) - - fields = FieldTemplateShortViewSerializer( - many=True, - required=False, - read_only=True, - ) - - class KickoffListSerializer(ModelSerializer): class Meta: model = Kickoff fields = ( 'fields', + 'fieldsets', ) fields = FieldTemplateListSerializer(many=True) + fieldsets = FieldsetTemplateSerializer(many=True) + + def to_representation(self, instance): + # TODO Delete when the Template <-> Kickoff relation becomes o2o + from django.db import models # noqa : PLC0415 + if isinstance(instance, models.Manager): + instance = instance.first() + return super().to_representation(instance) diff --git a/backend/src/processes/serializers/templates/mixins.py b/backend/src/processes/serializers/templates/mixins.py index 95e81d6a4..494abe626 100644 --- a/backend/src/processes/serializers/templates/mixins.py +++ b/backend/src/processes/serializers/templates/mixins.py @@ -3,9 +3,17 @@ from django.contrib.auth import get_user_model from django.db import IntegrityError, transaction +from django.core.exceptions import ObjectDoesNotExist from rest_framework.serializers import Serializer from src.processes.messages.template import MSG_PT_0041 +from src.processes.models.templates.kickoff import Kickoff +from src.processes.models.templates.task import TaskTemplate +from src.processes.models.templates.template import Template +from src.processes.models.templates.fieldset import FieldsetTemplate +from src.processes.services.fieldsets.fieldset import ( + FieldSetTemplateService, +) from src.utils.validation import raise_validation_error UserModel = get_user_model() @@ -240,3 +248,93 @@ def additional_validate_api_name( new_api_name=value, ), ) + + +class FieldsetMixin: + + @staticmethod + def create_or_update_fieldsets( + fieldsets_data: List[Dict], + template: Template, + user: UserModel, + task: Optional[TaskTemplate] = None, + kickoff: Optional[Kickoff] = None, + ): + instance = task or kickoff + existing_fieldsets = {f.api_name: f for f in instance.fieldsets.all()} + fieldsets_api_names = set() + for fieldset_data in fieldsets_data: + fieldset_api_name = fieldset_data.get('api_name') + if fieldset_api_name and fieldset_api_name in existing_fieldsets: + fieldset = existing_fieldsets[fieldset_api_name] + update_kwargs = {} + if fieldset.order != fieldset_data['order']: + update_kwargs['order'] = fieldset_data['order'] + if fieldset.title != fieldset_data['title']: + update_kwargs['title'] = fieldset_data['title'] + if fieldset.description != fieldset_data['description']: + update_kwargs['description'] = fieldset_data['description'] + + if update_kwargs: + service = FieldSetTemplateService( + instance=fieldset, + user=user, + ) + service.partial_update_instance( + order=fieldset_data['order'], + title=fieldset_data.get('title'), + description=fieldset_data.get('description'), + ) + fieldsets_api_names.add(fieldset.api_name) + else: + shared_fieldset = fieldset_data['shared_fieldset_id'] + service = FieldSetTemplateService(user=user) + fieldset = service.create_from_shared( + shared_fieldset_data=FieldSetTemplateService.to_json( + shared_fieldset, + ), + shared_fieldset_id=shared_fieldset.id, + template_id=template.id, + task_id=task.id if task else None, + kickoff_id=kickoff.id if kickoff else None, + order=fieldset_data['order'], + api_name=fieldset_data.get('api_name'), + title=fieldset_data.get('title'), + description=fieldset_data.get('description'), + ) + fieldsets_api_names.add(fieldset.api_name) + instance.fieldsets.exclude(api_name__in=fieldsets_api_names).delete() + + def get_draft_fieldsets(self, fieldsets_data: Any): + result = [] + if isinstance(fieldsets_data, list): + for fieldset_data in fieldsets_data: + if fieldset_data.get('fields'): + result.append(fieldset_data) + # Fieldset already done + continue + try: + shared_fieldset_id = int(fieldset_data.get( + 'shared_fieldset_id', + )) + shared_fieldset = FieldsetTemplate.objects.get( + id=shared_fieldset_id, + is_shared=True, + ) + except (TypeError, ValueError, ObjectDoesNotExist): + # Remove invalid or not existent fieldset + continue + order = fieldset_data.get('order', 0) + service = FieldSetTemplateService() + new_fieldset_data = service.get_new_fieldset_data( + shared_fieldset_data=FieldSetTemplateService.to_json( + shared_fieldset, + ), + api_name=fieldset_data.get('api_name'), + title=fieldset_data.get('title'), + description=fieldset_data.get('description'), + ) + new_fieldset_data['order'] = order + new_fieldset_data['shared_fieldset_id'] = shared_fieldset_id + result.append(new_fieldset_data) + return result diff --git a/backend/src/processes/serializers/templates/public/kickoff.py b/backend/src/processes/serializers/templates/public/kickoff.py index d7fd10d6f..9a529bf7e 100644 --- a/backend/src/processes/serializers/templates/public/kickoff.py +++ b/backend/src/processes/serializers/templates/public/kickoff.py @@ -4,11 +4,12 @@ CharField, ModelSerializer, ) - from src.processes.models.templates.kickoff import Kickoff from src.processes.serializers.templates.field import ( PublicFieldTemplateSerializer, ) +from src.processes.serializers.templates.fieldset import \ + FieldsetTemplateSerializer class PublicKickoffSerializer(ModelSerializer): @@ -18,10 +19,12 @@ class Meta: fields = ( 'description', 'fields', + 'fieldsets', ) description = CharField(allow_blank=True, default='') fields = PublicFieldTemplateSerializer(many=True, required=False) + fieldsets = FieldsetTemplateSerializer(many=True, required=False) def to_representation(self, data: Dict[str, Any]): data = super().to_representation(data) @@ -29,4 +32,6 @@ def to_representation(self, data: Dict[str, Any]): data['description'] = '' if data.get('fields') is None: data['fields'] = [] + if data.get('fieldsets') is None: + data['fieldsets'] = [] return data diff --git a/backend/src/processes/serializers/templates/public/template.py b/backend/src/processes/serializers/templates/public/template.py index 732a06a82..78b1dece0 100644 --- a/backend/src/processes/serializers/templates/public/template.py +++ b/backend/src/processes/serializers/templates/public/template.py @@ -4,6 +4,7 @@ from rest_framework.serializers import ( CharField, ModelSerializer, + SerializerMethodField, ) from src.processes.models.templates.template import Template @@ -25,19 +26,21 @@ class Meta: ) description = CharField(allow_blank=True, default='') - kickoff = PublicKickoffSerializer(required=False) + kickoff = SerializerMethodField() + + def get_kickoff(self, instance: Template): + # PublicTemplateSerializer cannot return a single Kickoff + # object because the Template related with Kickoff by + # foreign key instead of one to one relation. + # Getting the object manually: + kickoff_slz = PublicKickoffSerializer( + instance=instance.kickoff_instance, + ) + return kickoff_slz.data def to_representation(self, data: Dict[str, Any]): data = super().to_representation(data) if data.get('description') is None: data['description'] = '' - - # PublicTemplateSerializer cannot return a single Kickoff object - # because the Template related with Kickoff by foreign key - # instead of one to one relation. Getting the object manually: - kickoff_slz = PublicKickoffSerializer( - instance=self.instance.kickoff_instance, - ) - data['kickoff'] = kickoff_slz.data return data diff --git a/backend/src/processes/serializers/templates/task.py b/backend/src/processes/serializers/templates/task.py index 2e74cca8a..f54014632 100644 --- a/backend/src/processes/serializers/templates/task.py +++ b/backend/src/processes/serializers/templates/task.py @@ -32,12 +32,15 @@ ) from src.processes.serializers.templates.field import ( FieldTemplateSerializer, - FieldTemplateShortViewSerializer, +) +from src.processes.serializers.templates.fieldset import ( + FieldsetTemplateSerializer, ) from src.processes.serializers.templates.mixins import ( CreateOrUpdateInstanceMixin, CreateOrUpdateRelatedMixin, CustomValidationApiNameMixin, + FieldsetMixin, ) from src.processes.serializers.templates.raw_due_date import ( RawDueDateTemplateSerializer, @@ -59,6 +62,7 @@ class TaskTemplateSerializer( CustomValidationErrorMixin, AdditionalValidationMixin, CustomValidationApiNameMixin, + FieldsetMixin, ModelSerializer, ): @@ -74,6 +78,7 @@ class Meta: 'skip_for_starter', 'delay', 'fields', + 'fieldsets', 'conditions', 'api_name', 'raw_performers', @@ -101,6 +106,11 @@ class Meta: number = IntegerField() api_name = CharField(max_length=200, required=False) fields = FieldTemplateSerializer(many=True, required=False) + fieldsets = FieldsetTemplateSerializer( + many=True, + required=False, + allow_empty=True, + ) checklists = ChecklistTemplateSerializer(many=True, required=False) conditions = ConditionTemplateSerializer(many=True, required=False) raw_performers = RawPerformerSerializer( @@ -416,15 +426,12 @@ def create(self, validated_data: Dict[str, Any]): }, ) template = self.context['template'] - if template.is_active and validated_data.get('raw_due_date'): - AnalyticService.templates_task_due_date_created( - user=self.context['user'], - template=template, - task=instance, - is_superuser=self.context['is_superuser'], - auth_type=self.context['auth_type'], - ) - + self.create_or_update_fieldsets( + fieldsets_data=validated_data.pop('fieldsets', []), + template=template, + task=instance, + user=self.context['user'], + ) self.create_or_update_related( data=validated_data.get('fields'), ancestors_data={ @@ -486,8 +493,9 @@ def create(self, validated_data: Dict[str, Any]): }, ) + raw_due_date_data = validated_data.get('raw_due_date') self.create_or_update_related_one( - data=validated_data.get('raw_due_date'), + data=raw_due_date_data, ancestors_data={ 'task': instance, 'template': self.context['template'], @@ -498,6 +506,14 @@ def create(self, validated_data: Dict[str, Any]): 'task': instance, }, ) + if template.is_active and raw_due_date_data: + AnalyticService.templates_task_due_date_created( + user=self.context['user'], + template=template, + task=instance, + is_superuser=self.context['is_superuser'], + auth_type=self.context['auth_type'], + ) return instance def update( @@ -515,6 +531,7 @@ def update( and not hasattr(self.instance, 'raw_due_date') and validated_data.get('raw_due_date') ) + validated_data.pop('fieldsettemplatetasktemplate_set', None) instance = self.create_or_update_instance( instance=instance, validated_data={ @@ -525,6 +542,12 @@ def update( **validated_data, }, ) + self.create_or_update_fieldsets( + fieldsets_data=validated_data.pop('fieldsets', []), + template=template, + task=instance, + user=self.context['user'], + ) if raw_due_date_created: AnalyticService.templates_task_due_date_created( user=self.context['user'], @@ -613,30 +636,11 @@ class TemplateStepNameSerializer(ModelSerializer): class Meta: model = TaskTemplate fields = ( - 'id', # Deprecated - 'name', - 'number', - 'api_name', - ) - - -class TemplateTaskOnlyFieldsSerializer(ModelSerializer): - class Meta: - model = TaskTemplate - fields = ( - 'id', # Deprecated 'name', 'number', 'api_name', - 'fields', ) - fields = FieldTemplateShortViewSerializer( - many=True, - required=False, - read_only=True, - ) - class TaskTemplatePrivilegesSerializer(ModelSerializer): class Meta: diff --git a/backend/src/processes/serializers/templates/template.py b/backend/src/processes/serializers/templates/template.py index befe0f3c0..1ec4bb3b4 100644 --- a/backend/src/processes/serializers/templates/template.py +++ b/backend/src/processes/serializers/templates/template.py @@ -37,7 +37,8 @@ SystemVariable, TemplateOrdering, TemplateType, - WorkflowApiStatus, TaskStatus, + WorkflowApiStatus, + TaskStatus, ) from src.processes.messages import template as messages from src.processes.models.templates.kickoff import Kickoff @@ -48,12 +49,11 @@ ) from src.processes.serializers.templates.kickoff import ( KickoffListSerializer, - KickoffOnlyFieldsSerializer, KickoffSerializer, ) from src.processes.serializers.templates.mixins import ( CreateOrUpdateInstanceMixin, - CreateOrUpdateRelatedMixin, + CreateOrUpdateRelatedMixin, FieldsetMixin, ) from src.processes.serializers.templates.owner import ( TemplateOwnerSerializer, @@ -62,7 +62,6 @@ ShortTaskSerializer, TaskTemplatePrivilegesSerializer, TaskTemplateSerializer, - TemplateTaskOnlyFieldsSerializer, ) from src.processes.services.templates.integrations import ( TemplateIntegrationsService, @@ -90,6 +89,7 @@ class TemplateSerializer( AdditionalValidationMixin, CreateOrUpdateInstanceMixin, CreateOrUpdateRelatedMixin, + FieldsetMixin, ModelSerializer, ): """ @@ -157,6 +157,23 @@ class Meta: public_success_url = CharField(allow_null=True, required=False) date_updated_tsp = TimeStampField(read_only=True, source='date_updated') + def _get_formatted_fields_data(self, fields_data: list): + result = [] + for field in fields_data: + try: + api_name = field.get('api_name') + name = field.get('name') + is_required = field.get('is_required', False) + if api_name and name: + result.append({ + 'name': name, + 'api_name': api_name, + 'is_required': is_required, + }) + except (TypeError, AttributeError): + continue + return result + def _get_raw_fields_from_kickoff(self, data: Dict[str, Any]) -> List[dict]: """ Return format: @@ -170,26 +187,16 @@ def _get_raw_fields_from_kickoff(self, data: Dict[str, Any]) -> List[dict]: """ result = [] try: - fields = data['kickoff']['fields'] + kickoff_data = data['kickoff'] except KeyError: - pass - else: - try: - for field in fields: - try: - api_name = field.get('api_name') - name = field.get('name') - is_required = field.get('is_required', False) - if api_name and name: - result.append({ - 'name': name, - 'api_name': api_name, - 'is_required': is_required, - }) - except TypeError: - continue - except TypeError: - pass + return result + + fields_data = kickoff_data.get('fields') or [] + result = self._get_formatted_fields_data(fields_data) + fieldsets_data = kickoff_data.get('fieldsets') or [] + for fieldset in fieldsets_data: + fields_data = fieldset.get('fields') or [] + result.extend(self._get_formatted_fields_data(fields_data)) return result def _get_template_performers_ids(self, data: Dict[str, Any]) -> Set[int]: @@ -454,8 +461,14 @@ def _get_normalized_kickoff_draft( ) -> dict: if isinstance(data, dict): data['fields'] = data.get('fields', []) + data['fieldsets'] = self.get_draft_fieldsets( + data.get('fieldsets'), + ) else: - data = {'fields': []} + data = { + 'fields': [], + 'fieldsets': [], + } return data def save_as_draft(self) -> Template: @@ -482,6 +495,10 @@ def save_as_draft(self) -> Template: else: task['parents'] = [] task['ancestors'] = [] + task['fieldsets'] = self.get_draft_fieldsets( + task.get('fieldsets'), + ) + if not self.instance: self.instance = Template.objects.create( account=user.account, @@ -623,6 +640,7 @@ def create(self, validated_data: Dict[str, Any]) -> Template: parents_by_tasks = get_tasks_parents(validated_data['tasks']) tasks_api_names = set(parents_by_tasks.keys()) ancestors_by_tasks = get_tasks_ancestors(parents_by_tasks) + self.create_or_update_related( data=validated_data['tasks'], ancestors_data={ @@ -847,43 +865,6 @@ def get_is_editable(self, instance: Template) -> bool: ).exists() -class TemplateOnlyFieldsSerializer(ModelSerializer): - class Meta: - model = Template - fields = ( - 'id', - 'kickoff', - 'tasks', - ) - - kickoff = KickoffOnlyFieldsSerializer(required=False, read_only=True) - tasks = TemplateTaskOnlyFieldsSerializer( - many=True, - required=False, - read_only=True, - ) - - def to_representation(self, data: Dict[str, Any]): - - data = super().to_representation(data) - if data.get('tasks') is None: - data['tasks'] = [] - - # TemplateSerializer cannot return a single Kickoff object - # because the Template related with Kickoff by foreign key - # instead of one to one relation. Getting the object manually: - kickoff_slz = KickoffOnlyFieldsSerializer( - instance=self.instance.kickoff_instance, - ) - data['kickoff'] = kickoff_slz.data - return data - - def get_response_data(self) -> Dict[str, Any]: - if self.instance.is_active: - return self.data - return self.instance.get_draft() - - class TemplateTitlesByWorkflowsSerializer( CustomValidationErrorMixin, Serializer, @@ -1040,3 +1021,22 @@ class TemplateTitlesSerializer(Serializer): id = IntegerField(read_only=True) name = CharField(read_only=True) count = IntegerField(read_only=True) + + +class FieldsetTemplateFilterSerializer( + CustomValidationErrorMixin, + AdditionalValidationMixin, + Serializer, +): + + ordering = ChoiceField( + required=False, + choices=( + ('name', 'name'), + ('-name', '-name'), + ('date', 'date'), + ('-date', '-date'), + ), + ) + limit = IntegerField(min_value=0, required=False) + offset = IntegerField(min_value=0, required=False) diff --git a/backend/src/processes/serializers/templates/template_fields.py b/backend/src/processes/serializers/templates/template_fields.py new file mode 100644 index 000000000..1d6d36de1 --- /dev/null +++ b/backend/src/processes/serializers/templates/template_fields.py @@ -0,0 +1,137 @@ +from typing import Any, Dict + +from rest_framework.serializers import ( + ModelSerializer, +) +from src.processes.models.templates.fields import FieldTemplate +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, +) +from src.processes.models.templates.kickoff import Kickoff +from src.processes.models.templates.template import Template +from src.processes.models.templates.task import TaskTemplate + + +# All serializers for the GET /templates/id/fields API + + +class FieldTemplateOnlyFieldsSerializer(ModelSerializer): + + class Meta: + model = FieldTemplate + fields = ( + 'name', + 'type', + 'order', + 'description', + 'is_hidden', + 'api_name', + ) + + +class FieldsetTemplateOnlyFieldsSerializer(ModelSerializer): + + class Meta: + model = FieldsetTemplate + fields = ( + 'order', + 'name', + 'title', + 'description', + 'fields', + 'api_name', + 'label_position', + 'layout', + ) + + fields = FieldTemplateOnlyFieldsSerializer(many=True) + + +class KickoffOnlyFieldsSerializer(ModelSerializer): + + class Meta: + model = Kickoff + fields = ( + 'fields', + 'fieldsets', + ) + + fields = FieldTemplateOnlyFieldsSerializer( + many=True, + default=[], + read_only=True, + ) + fieldsets = FieldsetTemplateOnlyFieldsSerializer( + many=True, + default=[], + read_only=True, + ) + + def to_representation(self, instance): + # TODO Delete when the Template <-> Kickoff relation becomes o2o + from django.db import models # noqa : PLC0415 + if isinstance(instance, models.Manager): + instance = instance.first() + return super().to_representation(instance) + + +class TemplateTaskOnlyFieldsSerializer(ModelSerializer): + + class Meta: + model = TaskTemplate + fields = ( + 'name', + 'number', + 'api_name', + 'fields', + 'fieldsets', + ) + + fields = FieldTemplateOnlyFieldsSerializer( + many=True, + default=[], + read_only=True, + ) + fieldsets = FieldsetTemplateOnlyFieldsSerializer( + many=True, + default=[], + read_only=True, + ) + + +class TemplateOnlyFieldsSerializer(ModelSerializer): + + class Meta: + model = Template + fields = ( + 'id', + 'kickoff', + 'tasks', + ) + + kickoff = KickoffOnlyFieldsSerializer(required=False, read_only=True) + tasks = TemplateTaskOnlyFieldsSerializer( + many=True, + required=False, + read_only=True, + ) + + def to_representation(self, data: Dict[str, Any]): + + data = super().to_representation(data) + if data.get('tasks') is None: + data['tasks'] = [] + + # TemplateSerializer cannot return a single Kickoff object + # because the Template related with Kickoff by foreign key + # instead of one to one relation. Getting the object manually: + kickoff_slz = KickoffOnlyFieldsSerializer( + instance=self.instance.kickoff_instance, + ) + data['kickoff'] = kickoff_slz.data + return data + + def get_response_data(self) -> Dict[str, Any]: + if self.instance.is_active: + return self.data + return self.instance.get_draft() diff --git a/backend/src/processes/serializers/workflows/events.py b/backend/src/processes/serializers/workflows/events.py index 6ef99cd2f..fe568358e 100644 --- a/backend/src/processes/serializers/workflows/events.py +++ b/backend/src/processes/serializers/workflows/events.py @@ -11,6 +11,7 @@ from src.processes.serializers.workflows.field import ( TaskFieldEventSerializer, ) +from src.processes.serializers.workflows.fieldset import FieldSetSerializer from src.processes.serializers.workflows.task_performer import ( TaskUserGroupPerformerSerializer, ) @@ -68,6 +69,7 @@ class Meta: 'performers', 'due_date_tsp', 'output', + 'fieldsets', 'sub_workflow', ) @@ -76,6 +78,7 @@ class Meta: source='exclude_directly_deleted_taskperformer_set', ) output = serializers.SerializerMethodField() + fieldsets = serializers.SerializerMethodField() due_date_tsp = TimeStampField(source='due_date') sub_workflow = serializers.SerializerMethodField() @@ -87,6 +90,17 @@ def get_output(self, instance): ).data return None + def get_fieldsets(self, instance): + if ( + self.context['event_type'] == WorkflowEventType.TASK_COMPLETE + and instance.fieldsets.exists() + ): + return FieldSetSerializer( + instance=instance.fieldsets.all(), + many=True, + ).data + return None + def get_sub_workflow(self, instance): if self.context['event_type'] == WorkflowEventType.SUB_WORKFLOW_RUN: return SubWorkflowEventSerializer( diff --git a/backend/src/processes/serializers/workflows/field.py b/backend/src/processes/serializers/workflows/field.py index ccc87865d..5a841ed18 100644 --- a/backend/src/processes/serializers/workflows/field.py +++ b/backend/src/processes/serializers/workflows/field.py @@ -83,6 +83,7 @@ class Meta: 'clear_value', 'user_id', 'group_id', + 'fieldset_id', ) diff --git a/backend/src/processes/serializers/workflows/fieldset.py b/backend/src/processes/serializers/workflows/fieldset.py new file mode 100644 index 000000000..e8a4de857 --- /dev/null +++ b/backend/src/processes/serializers/workflows/fieldset.py @@ -0,0 +1,23 @@ +from rest_framework import serializers + +from src.processes.models.workflows.fieldset import FieldSet +from src.processes.serializers.workflows.field import TaskFieldSerializer + + +class FieldSetSerializer(serializers.ModelSerializer): + + class Meta: + model = FieldSet + fields = ( + 'id', + 'api_name', + 'name', + 'title', + 'description', + 'order', + 'label_position', + 'layout', + 'fields', + ) + + fields = TaskFieldSerializer(many=True) diff --git a/backend/src/processes/serializers/workflows/kickoff_value.py b/backend/src/processes/serializers/workflows/kickoff_value.py index b9a1ffe69..6eea83236 100644 --- a/backend/src/processes/serializers/workflows/kickoff_value.py +++ b/backend/src/processes/serializers/workflows/kickoff_value.py @@ -1,31 +1,44 @@ from typing import Any, Dict - +from django.db.models import Q from rest_framework import serializers from src.generics.serializers import CustomValidationErrorMixin +from src.processes.models.templates.fieldset import FieldsetTemplate from src.processes.models.templates.kickoff import Kickoff +from src.processes.models.workflows.fieldset import FieldSet +from src.processes.models.workflows.fields import TaskField from src.processes.models.workflows.kickoff import KickoffValue from src.processes.models.workflows.workflow import Workflow from src.processes.serializers.workflows.field import ( TaskFieldSerializer, ) +from src.processes.serializers.workflows.fieldset import ( + FieldSetSerializer, +) +from src.processes.services.exceptions import FieldsetServiceException from src.processes.services.tasks.exceptions import ( TaskFieldException, ) from src.processes.services.tasks.task import ( TaskFieldService, ) +from src.processes.services.workflows.fieldsets.fieldset import ( + FieldSetService, +) + from src.services.markdown import MarkdownService class KickoffValueInfoSerializer(serializers.ModelSerializer): output = TaskFieldSerializer(many=True) + fieldsets = FieldSetSerializer(many=True) class Meta: model = KickoffValue fields = ( 'id', 'output', + 'fieldsets', ) @@ -69,20 +82,44 @@ def create(self, validated_data: Dict[str, Any]): account_id=validated_data['account_id'], clear_description=clear_description, ) - for field_template in kickoff.fields.all(): - service = TaskFieldService(user=self.context['user']) - try: + workflow = validated_data['workflow'] + fieldset_templates = ( + FieldsetTemplate.objects + .filter(kickoff=kickoff) + .prefetch_related('rules', 'fields') + .order_by('order') + ) + try: + for fieldset_template in fieldset_templates: + service = FieldSetService(user=self.context['user']) + service.create( + instance_template=fieldset_template, + account_id=workflow.account_id, + workflow=workflow, + kickoff=instance, + fields_data=fields_data, + ) + try: + service.validate_rules() + except FieldsetServiceException as ex: + self.raise_validation_error(message=ex.message) + for field_template in kickoff.fields.filter(fieldset__isnull=True): + service = TaskFieldService(user=self.context['user']) service.create( instance_template=field_template, - workflow_id=validated_data['workflow'].id, + workflow_id=workflow.id, kickoff_id=instance.id, value=fields_data.get(field_template.api_name), ) - except TaskFieldException as ex: - self.raise_validation_error( - message=ex.message, - api_name=field_template.api_name, - ) + except TaskFieldException as ex: + self.raise_validation_error( + message=ex.message, + api_name=ex.api_name, + ) + except FieldsetServiceException as ex: + self.raise_validation_error( + message=ex.message, + ) return instance def update( @@ -90,24 +127,43 @@ def update( instance: KickoffValue, validated_data: Dict[str, Any], ): - fields_data: dict = validated_data.pop('fields_data', {}) + fields_values: dict = validated_data.pop('fields_data', {}) instance = super().update(instance, validated_data) - if fields_data: - for task_field in self.instance.output.filter( - api_name__in=fields_data.keys(), - ): + if fields_values: + fields = ( + TaskField.objects + .filter( + Q(fieldset__kickoff=instance) | Q(kickoff=instance), + api_name__in=fields_values, + ) + ) + fieldsets_ids = set() + for field in fields: + if field.fieldset_id: + fieldsets_ids.add(field.fieldset_id) service = TaskFieldService( user=self.context['user'], - instance=task_field, + instance=field, ) try: service.partial_update( - value=fields_data[task_field.api_name], + value=fields_values[field.api_name], force_save=True, ) except TaskFieldException as ex: self.raise_validation_error( message=ex.message, - api_name=task_field.api_name, + api_name=field.api_name, ) + if fieldsets_ids: + fieldsets = FieldSet.objects.filter(id__in=fieldsets_ids) + try: + for fieldset in fieldsets: + service = FieldSetService( + user=self.context['user'], + instance=fieldset, + ) + service.validate_rules() + except FieldsetServiceException as ex: + self.raise_validation_error(message=ex.message) return instance diff --git a/backend/src/processes/serializers/workflows/task.py b/backend/src/processes/serializers/workflows/task.py index 7aa6333d4..1e5299aad 100644 --- a/backend/src/processes/serializers/workflows/task.py +++ b/backend/src/processes/serializers/workflows/task.py @@ -26,6 +26,9 @@ from src.processes.serializers.workflows.field import ( TaskFieldSerializer, ) +from src.processes.serializers.workflows.fieldset import ( + FieldSetSerializer, +) from src.processes.serializers.workflows.task_performer import ( get_performers_for_task, ) @@ -113,6 +116,7 @@ class Meta: 'status', 'revert_tasks', 'is_read_only_viewer', + 'fieldsets', ) date_started_tsp = TimeStampField(source='date_started') @@ -131,6 +135,7 @@ class Meta: sub_workflows = serializers.SerializerMethodField() revert_tasks = TaskShortSerializer(many=True, source='get_revert_tasks') is_read_only_viewer = serializers.SerializerMethodField() + fieldsets = FieldSetSerializer(many=True) def get_performers(self, instance) -> List[Dict[str, Any]]: return get_performers_for_task(instance) diff --git a/backend/src/processes/serializers/workflows/workflow.py b/backend/src/processes/serializers/workflows/workflow.py index 218d96724..5a4ba5948 100644 --- a/backend/src/processes/serializers/workflows/workflow.py +++ b/backend/src/processes/serializers/workflows/workflow.py @@ -399,23 +399,32 @@ class Meta: is_read_only_viewer = serializers.SerializerMethodField() def get_kickoff(self, instance: Workflow): + field_prefetches = [ + Prefetch( + lookup='selections', + queryset=FieldSelection.objects.order_by('id'), + to_attr='selections_values', + ), + Prefetch( + 'dataset__items', + queryset=DatasetItem.objects.order_by('order'), + to_attr='dataset_values', + ), + ] kickoff = ( KickoffValue.objects .filter(workflow=self.instance) .prefetch_related( Prefetch( lookup='output', - queryset=TaskField.objects.all().prefetch_related( - Prefetch( - lookup='selections', - queryset=FieldSelection.objects.order_by('id'), - to_attr='selections_values', - ), - Prefetch( - 'dataset__items', - queryset=DatasetItem.objects.order_by('order'), - to_attr='dataset_values', - ), + queryset=TaskField.objects.filter( + fieldset__isnull=True, + ).prefetch_related(*field_prefetches), + ), + Prefetch( + lookup='fieldsets__fields', + queryset=TaskField.objects.prefetch_related( + *field_prefetches, ), ), ).first() diff --git a/backend/src/processes/services/events.py b/backend/src/processes/services/events.py index 1eabf8cb0..013d845d1 100644 --- a/backend/src/processes/services/events.py +++ b/backend/src/processes/services/events.py @@ -604,9 +604,6 @@ def task_delegation_event( class CommentService(BaseModelService): - def _create_related(self, **kwargs): - pass - def _create_instance(self, **kwargs): pass diff --git a/backend/src/processes/services/exceptions.py b/backend/src/processes/services/exceptions.py index e8d0000a8..b495b9980 100644 --- a/backend/src/processes/services/exceptions.py +++ b/backend/src/processes/services/exceptions.py @@ -1,4 +1,5 @@ from src.generics.exceptions import BaseServiceException +from src.processes.messages import fieldset as fs_messages from src.processes.messages import template as pt_messages from src.processes.messages import workflow as pw_messages @@ -196,3 +197,72 @@ class TemplatePresetServiceException(BaseServiceException): class CommentedNotTask(CommentServiceException): default_message = pw_messages.MSG_PW_0077 + + +class FieldsetTemplateRuleServiceException(BaseServiceException): + + pass + + +class FieldsetTemplateRuleSumMaxFieldsNotNumber( + FieldsetTemplateRuleServiceException, +): + + default_message = fs_messages.MSG_FS_0003 + + +class FieldsetTemplateRuleSumMaxInvalidValue( + FieldsetTemplateRuleServiceException, +): + + default_message = fs_messages.MSG_FS_0004 + + +class FieldsetTemplateServiceException(BaseServiceException): + + pass + + +class FieldsetTemplateSharedIdMissing(FieldsetTemplateServiceException): + + default_message = fs_messages.MSG_FS_0010 + + +class FieldsetTemplateTemplateIdMissing(FieldsetTemplateServiceException): + + default_message = fs_messages.MSG_FS_0011 + + +class FieldsetTemplateInUseException(FieldsetTemplateServiceException): + + default_message = fs_messages.MSG_FS_0001 + + +class FieldsetTemplateInUseException2(FieldsetTemplateServiceException): + + default_message = fs_messages.MSG_FS_0009 + + +class FieldTemplateServiceException(BaseServiceException): + + pass + + +class FieldTemplateSelectionsRequired(FieldTemplateServiceException): + + default_message = pt_messages.MSG_PT_0005 + + +class FieldTemplateUserMustBeRequired(FieldTemplateServiceException): + + default_message = pt_messages.MSG_PT_0006 + + +class FieldsetServiceException(BaseServiceException): + + pass + + +class SharedFieldsetNotFoundException(FieldsetServiceException): + + default_message = fs_messages.MSG_FS_0008 diff --git a/backend/src/processes/services/fieldsets/__init__.py b/backend/src/processes/services/fieldsets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/processes/services/fieldsets/fieldset.py b/backend/src/processes/services/fieldsets/fieldset.py new file mode 100644 index 000000000..ec0306e05 --- /dev/null +++ b/backend/src/processes/services/fieldsets/fieldset.py @@ -0,0 +1,361 @@ +# ruff: noqa: PLC0415 +from copy import deepcopy +from typing import Dict, List, Optional +from django.contrib.auth import get_user_model +from django.db import transaction + +from src.generics.base.service import BaseModelService +from src.processes.enums import LabelPosition, FieldSetLayout +from src.processes.models.templates.fields import FieldTemplate +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateRule, +) +from src.processes.services.exceptions import ( + FieldsetTemplateInUseException, + FieldsetTemplateInUseException2, + FieldsetTemplateSharedIdMissing, + FieldsetTemplateTemplateIdMissing, +) +from src.processes.services.templates.field_template import ( + FieldTemplateService, +) +from src.processes.services.fieldsets.fieldset_rule import ( + FieldsetTemplateRuleService, +) +from src.processes.utils.common import create_api_name + + +UserModel = get_user_model() + + +class FieldSetTemplateService(BaseModelService): + + def _create_instance( + self, + name: str, + is_shared: bool, + template_id: Optional[int] = None, + order: int = 0, + title: str = '', + description: str = '', + kickoff_id: Optional[int] = None, + task_id: Optional[int] = None, + shared_fieldset_id: Optional[int] = None, + api_name: Optional[str] = None, + label_position: LabelPosition.LITERALS = LabelPosition.TOP, + layout: FieldSetLayout.LITERALS = FieldSetLayout.VERTICAL, + **kwargs, + ): + + if not is_shared: + if not template_id: + raise FieldsetTemplateTemplateIdMissing + if not shared_fieldset_id: + raise FieldsetTemplateSharedIdMissing + + create_kwargs = { + 'template_id': template_id, + 'account': self.account, + 'order': order, + 'name': name, + 'title': title, + 'description': description, + 'label_position': label_position, + 'layout': layout, + 'kickoff_id': kickoff_id, + 'task_id': task_id, + 'shared_fieldset_id': shared_fieldset_id, + 'is_shared': is_shared, + } + if api_name: + create_kwargs['api_name'] = api_name + self.instance = FieldsetTemplate.objects.create(**create_kwargs) + return self.instance + + def create_shared_fieldset( + self, + name: str, + title: str = '', + description: str = '', + api_name: Optional[str] = None, + label_position: LabelPosition.LITERALS = LabelPosition.TOP, + layout: FieldSetLayout.LITERALS = FieldSetLayout.VERTICAL, + **kwargs, + ): + + """ Creates a shared FieldSetTemplate + that is not linked to a template. """ + + return super().create( + name=name, + title=title, + description=description, + api_name=api_name, + label_position=label_position, + layout=layout, + is_shared=True, + **kwargs, + ) + + def create_from_shared( + self, + shared_fieldset_data: dict, + shared_fieldset_id: int, + template_id: int, + order: int = 0, + kickoff_id: Optional[int] = None, + task_id: Optional[int] = None, + api_name: Optional[str] = None, + title: Optional[str] = None, + description: Optional[str] = None, + ) -> FieldsetTemplate: + + fieldset_data = self.get_new_fieldset_data( + shared_fieldset_data=shared_fieldset_data, + api_name=api_name, + title=title, + description=description, + ) + + return self.create( + **fieldset_data, + is_shared=False, + shared_fieldset_id=shared_fieldset_id, + order=order, + kickoff_id=kickoff_id, + task_id=task_id, + template_id=template_id, + ) + + def _create_related( + self, + rules: Optional[List[Dict]] = None, + fields: Optional[List[Dict]] = None, + **kwargs, + ): + if fields: + self._create_fields(fields_data=fields) + if rules: + self.create_rules(rules_data=rules) + + def _create_fields( + self, + fields_data: List[Dict], + ): + for field_data in fields_data: + service = FieldTemplateService( + user=self.user, + is_superuser=self.is_superuser, + auth_type=self.auth_type, + account=self.account, + ) + service.create( + fieldset_id=self.instance.id, + template_id=self.instance.template_id, + **field_data, + ) + + def _update_fields( + self, + fields_data: List[Dict], + ): + """ All fieldset fields will be updated """ + + existing_fields = { + field.api_name: field + for field in self.instance.fields.all() + } + fields_api_names = set() + for field_data in fields_data: + field_api_name = field_data.pop('api_name', None) + if field_api_name and field_api_name in existing_fields: + service = FieldTemplateService( + user=self.user, + is_superuser=self.is_superuser, + auth_type=self.auth_type, + instance=existing_fields[field_api_name], + ) + service.partial_update(force_save=True, **field_data) + fields_api_names.add(field_api_name) + else: + service = FieldTemplateService( + user=self.user, + is_superuser=self.is_superuser, + auth_type=self.auth_type, + ) + field = service.create( + fieldset_id=self.instance.id, + template_id=self.instance.template_id, + **field_data, + ) + fields_api_names.add(field.api_name) + + self.instance.fields.exclude(api_name__in=fields_api_names).delete() + + def _validate_rules(self): + for rule in self.instance.rules.all(): + service = FieldsetTemplateRuleService( + user=self.user, + is_superuser=self.is_superuser, + auth_type=self.auth_type, + instance=rule, + ) + service._validate() + + def partial_update( + self, + **update_kwargs, + ) -> FieldsetTemplate: + + if self.instance.is_shared and self.instance.child_fieldsets.exists(): + raise FieldsetTemplateInUseException2 + + rules_data = update_kwargs.pop('rules', None) + fields_data = update_kwargs.pop('fields', None) + with transaction.atomic(): + if update_kwargs: + self.instance = super().partial_update( + force_save=True, + **update_kwargs, + ) + + if fields_data is not None: + self._update_fields(fields_data=fields_data) + if rules_data is not None: + self.update_rules(rules_data=rules_data) + self._validate_rules() + return self.instance + + def partial_update_instance( + self, + **update_kwargs, + ) -> FieldsetTemplate: + + with transaction.atomic(): + if update_kwargs: + self.instance = super().partial_update( + force_save=True, + **update_kwargs, + ) + return self.instance + + def delete(self) -> None: + if self.instance.is_shared and self.instance.child_fieldsets.exists(): + raise FieldsetTemplateInUseException + if self.instance.kickoff_id or self.instance.task_id: + raise FieldsetTemplateInUseException + self.instance.delete() + + @staticmethod + def _replace_api_names(shared_fieldset_data: dict) -> dict: + + fieldset_data = deepcopy(shared_fieldset_data) + fieldset_data['api_name'] = create_api_name( + FieldsetTemplate.api_name_prefix, + ) + fields_map: Dict[str, str] = {} + updated_fields_data = [] + for field_data in fieldset_data.get('fields', []): + new_api_name = create_api_name( + FieldTemplate.api_name_prefix, + ) + fields_map[field_data['api_name']] = new_api_name + field_data['api_name'] = new_api_name + updated_fields_data.append(field_data) + fieldset_data['fields'] = updated_fields_data + + updated_rules_data = [] + for rule_data in fieldset_data.get('rules', []): + rule_data['api_name'] = create_api_name( + FieldsetTemplateRule.api_name_prefix, + ) + rule_data['fields'] = [ + fields_map[old_api_name] + for old_api_name in rule_data.get('fields', []) + ] + updated_rules_data.append(rule_data) + fieldset_data['rules'] = updated_rules_data + return fieldset_data + + def get_new_fieldset_data( + self, + shared_fieldset_data: dict, + api_name: Optional[str] = None, + title: Optional[str] = None, + description: Optional[str] = None, + ) -> dict: + + fieldset_data = self._replace_api_names(shared_fieldset_data) + if api_name: + fieldset_data['api_name'] = api_name + if title: + fieldset_data['title'] = title + if description: + fieldset_data['description'] = description + fieldset_data.pop('order', None) + return fieldset_data + + def create_rules( + self, + rules_data: List[Dict], + ): + service = FieldsetTemplateRuleService( + user=self.user, + is_superuser=self.is_superuser, + auth_type=self.auth_type, + ) + for rule_data in rules_data: + service.create( + fieldset_id=self.instance.id, + **rule_data, + ) + + def update_rules( + self, + rules_data: List[Dict], + ): + """ All dataset items will be updated """ + + existing_rules = { + rule.api_name: rule for rule in self.instance.rules.all() + } + rule_api_names = set() + for rule_data in rules_data: + rule_api_name = rule_data.pop('api_name', None) + if rule_api_name and rule_api_name in existing_rules: + service = FieldsetTemplateRuleService( + user=self.user, + is_superuser=self.is_superuser, + auth_type=self.auth_type, + instance=existing_rules[rule_api_name], + ) + service.partial_update(**rule_data) + rule_api_names.add(rule_api_name) + else: + service = FieldsetTemplateRuleService( + user=self.user, + is_superuser=self.is_superuser, + auth_type=self.auth_type, + ) + rule = service.create( + fieldset_id=self.instance.id, + **rule_data, + ) + rule_api_names.add(rule.api_name) + + self.instance.rules.exclude(api_name__in=rule_api_names).delete() + + @staticmethod + def to_json(fieldset: FieldsetTemplate) -> dict: + if fieldset.is_shared: + from src.processes.serializers.templates.fieldset import ( + SharedFieldsetTemplateSerializer, + ) + slz_cls = SharedFieldsetTemplateSerializer + else: + from src.processes.serializers.templates.fieldset import ( + FieldsetTemplateSerializer, + ) + slz_cls = FieldsetTemplateSerializer + return dict(slz_cls(fieldset).data) diff --git a/backend/src/processes/services/fieldsets/fieldset_rule.py b/backend/src/processes/services/fieldsets/fieldset_rule.py new file mode 100644 index 000000000..3c5975af0 --- /dev/null +++ b/backend/src/processes/services/fieldsets/fieldset_rule.py @@ -0,0 +1,123 @@ +from typing import List, Optional +from django.contrib.auth import get_user_model +from django.db import transaction +from src.generics.base.service import BaseModelService +from src.processes.enums import FieldSetRuleType, FieldType +from src.processes.models.templates.fields import FieldTemplate +from src.processes.models.templates.fieldset import ( + FieldsetTemplateRule, +) +from src.processes.services.exceptions import ( + FieldsetTemplateRuleSumMaxFieldsNotNumber, + FieldsetTemplateRuleSumMaxInvalidValue, + FieldsetTemplateRuleServiceException, +) +from src.processes.messages.fieldset import MSG_FS_0005 + + +UserModel = get_user_model() + + +class FieldsetTemplateRuleService(BaseModelService): + + def _validate_sum_equal(self, **kwargs) -> float: + + value = self.instance.value + if not value: + raise FieldsetTemplateRuleSumMaxInvalidValue + try: + result = float(value) + except (ValueError, TypeError) as ex: + raise FieldsetTemplateRuleSumMaxInvalidValue from ex + + if self.instance.fields.exclude(type=FieldType.NUMBER).exists(): + raise FieldsetTemplateRuleSumMaxFieldsNotNumber + return result + + def _validate(self, **kwargs): + + """ Call after objects save """ + + validator = getattr(self, f'_validate_{self.instance.type}', None) + validator(**kwargs) + + def _create_instance( + self, + type: FieldSetRuleType.LITERALS, # noqa: A002 + value: Optional[str] = None, + fieldset_id: Optional[int] = None, + api_name: Optional[str] = None, + **kwargs, + ): + create_kwargs = { + 'account': self.account, + 'type': type, + 'value': value, + 'fieldset_id': fieldset_id, + } + if api_name: + create_kwargs['api_name'] = api_name + self.instance = FieldsetTemplateRule.objects.create(**create_kwargs) + return self.instance + + def _create_related( + self, + **kwargs, + ): + fields = kwargs.pop('fields', None) + if fields is not None: + self._set_fields(fields) + + def _get_valid_fields( + self, + fields_api_names: List[str], + **kwargs, + ) -> List[FieldTemplate]: + + rule_type = kwargs.get('type') or self.instance.type + available_fields = list( + FieldTemplate.objects + .filter( + fieldset_id=self.instance.fieldset_id, + api_name__in=fields_api_names, + ), + ) + fields_api_names = set(fields_api_names) + available_api_names = {field.api_name for field in available_fields} + failed_api_names = fields_api_names - available_api_names + if failed_api_names: + raise FieldsetTemplateRuleServiceException( + message=MSG_FS_0005( + rule=rule_type, + field=failed_api_names.pop(), + ), + ) + return available_fields + + def _set_fields(self, fields_api_names: List[str], **kwargs): + if fields_api_names: + fields = self._get_valid_fields(fields_api_names, **kwargs) + self.instance.fields.set(fields) + else: + self.instance.fields.clear() + + def create( + self, + **kwargs, + ) -> FieldsetTemplateRule: + + with transaction.atomic(): + self._create_instance(**kwargs) + self._create_related(**kwargs) + self._create_actions(**kwargs) + self._validate(**kwargs) + return self.instance + + def partial_update(self, **update_kwargs) -> FieldsetTemplateRule: + fields = update_kwargs.pop('fields', None) + with transaction.atomic(): + result = super().partial_update(**update_kwargs, force_save=True) + if fields is not None: + self._set_fields(fields) + self._validate(**update_kwargs) + return result diff --git a/backend/src/processes/services/tasks/field.py b/backend/src/processes/services/tasks/field.py index 0f787ecac..2d5a95221 100644 --- a/backend/src/processes/services/tasks/field.py +++ b/backend/src/processes/services/tasks/field.py @@ -15,6 +15,7 @@ FieldTemplate, ) from src.processes.models.workflows.attachment import FileAttachment +from src.processes.models.workflows.fieldset import FieldSetRule from src.processes.models.workflows.fields import TaskField from src.processes.services.base import BaseWorkflowService from src.processes.services.tasks.exceptions import TaskFieldException @@ -287,6 +288,7 @@ def _create_instance( self.instance = TaskField( kickoff_id=kwargs.get('kickoff_id'), task_id=kwargs.get('task_id'), + fieldset_id=kwargs.get('fieldset_id'), type=instance_template.type, is_required=instance_template.is_required, is_hidden=instance_template.is_hidden, @@ -320,6 +322,8 @@ def _create_related( self._link_new_attachments(raw_value) elif self.instance.type in FieldType.TYPES_WITH_SELECTIONS: self._create_selections(instance_template) + if instance_template.rules.all().exists(): + self._link_rules(instance_template, **kwargs) def _link_new_attachments( self, @@ -354,6 +358,22 @@ def _create_selections( field_id=self.instance.id, ) + def _link_rules( + self, + instance_template: FieldTemplate, + **kwargs, + ): + + rule_api_names = set( + instance_template.rules.values_list('api_name', flat=True), + ) + rules = FieldSetRule.objects.filter( + account=self.account, + fieldset_id=kwargs['fieldset_id'], + api_name__in=rule_api_names, + ) + self.instance.rules.set(rules) + def _remove_unused_attachments( self, value: Optional[str], diff --git a/backend/src/processes/services/tasks/task.py b/backend/src/processes/services/tasks/task.py index 74f09b732..a68c8260f 100644 --- a/backend/src/processes/services/tasks/task.py +++ b/backend/src/processes/services/tasks/task.py @@ -13,6 +13,7 @@ from src.processes.models.templates.checklist import ( ChecklistTemplateSelection, ) +from src.processes.models.templates.fieldset import FieldsetTemplate from src.processes.models.templates.task import TaskTemplate from src.processes.models.workflows.conditions import ( Condition, @@ -42,6 +43,9 @@ from src.processes.services.tasks.mixins import ( ConditionMixin, ) +from src.processes.services.workflows.fieldsets.fieldset import ( + FieldSetService, +) from src.processes.utils.common import ( insert_fields_values_to_text, ) @@ -101,6 +105,7 @@ def _create_related( redefined_performer=kwargs.get('redefined_performer'), ) self.create_fields_from_template(instance_template) + self.create_fieldsets_from_template(instance_template) self.create_conditions_from_template(instance_template) self.create_checklists_from_template(instance_template) self.create_raw_due_date_from_template(instance_template) @@ -200,8 +205,14 @@ def create_conditions_from_template( self.create_rules(conditions, conditions_tree) def create_fields_from_template(self, instance_template: TaskTemplate): - - for field_template in instance_template.fields.all(): + active_fieldset_ids = ( + FieldsetTemplate.objects + .filter(task=instance_template) + .values_list('id', flat=True) + ) + for field_template in instance_template.fields.exclude( + fieldset__in=active_fieldset_ids, + ): service = TaskFieldService(user=self.user) service.create( instance_template=field_template, @@ -210,6 +221,27 @@ def create_fields_from_template(self, instance_template: TaskTemplate): skip_value=True, ) + def create_fieldsets_from_template( + self, + instance_template: TaskTemplate, + ): + fieldsets = ( + FieldsetTemplate.objects + .filter(task=instance_template) + .prefetch_related('rules', 'fields') + .order_by('order') + ) + for fieldset in fieldsets: + service = FieldSetService(user=self.user) + service.create( + instance_template=fieldset, + account_id=self.instance.workflow.account_id, + workflow=self.instance.workflow, + task=self.instance, + order=fieldset.order, + skip_value=True, + ) + def create_checklists_from_template(self, instance_template: TaskTemplate): for checklist_template in instance_template.checklists.all(): checklist_service = ChecklistService( diff --git a/backend/src/processes/services/tasks/task_version.py b/backend/src/processes/services/tasks/task_version.py index 81b20b850..038f1fe32 100644 --- a/backend/src/processes/services/tasks/task_version.py +++ b/backend/src/processes/services/tasks/task_version.py @@ -17,6 +17,10 @@ Predicate, Rule, ) +from src.processes.models.workflows.fieldset import ( + FieldSet, + FieldSetRule, +) from src.processes.models.workflows.fields import ( FieldSelection, TaskField, @@ -54,6 +58,27 @@ class TaskUpdateVersionService( # TODO Very bad code. Needs to be refactored + def _update_field_selections( + self, + field: TaskField, + field_data: Dict, + ) -> None: + + if field_data.get('selections'): + selection_ids = set() + for selection_data in field_data['selections']: + selection, __ = ( + FieldSelection.objects.update_or_create( + field=field, + api_name=selection_data['api_name'], + defaults={ + 'value': selection_data['value'], + }, + ) + ) + selection_ids.add(selection.id) + field.selections.exclude(id__in=selection_ids).delete() + def _update_fields( self, data: Optional[List[Dict]] = None, @@ -64,22 +89,9 @@ def _update_fields( field_ids = [] if data: for field_data in data: - field, _ = self._update_field(field_data) + field, _ = self._update_field(field_data, fieldset=None) field_ids.append(field.id) - if field_data.get('selections'): - selection_ids = set() - for selection_data in field_data['selections']: - selection, __ = ( - FieldSelection.objects.update_or_create( - field=field, - api_name=selection_data['api_name'], - defaults={ - 'value': selection_data['value'], - }, - ) - ) - selection_ids.add(selection.id) - field.selections.exclude(id__in=selection_ids).delete() + self._update_field_selections(field, field_data) self.instance.output.exclude(id__in=field_ids).delete() def _update_delay(self, new_duration: Optional[str] = None): @@ -153,26 +165,115 @@ def _update_conditions( conditions = Condition.objects.bulk_create(conditions) self.create_rules(conditions, conditions_tree) - def _update_field(self, template: Dict): + def _update_field( + self, + field_data: Dict, + fieldset: Optional[FieldSet] = None, + ): # TODO Move to TaskFieldService return TaskField.objects.update_or_create( task=self.instance, - api_name=template['api_name'], + api_name=field_data['api_name'], + fieldset=fieldset, defaults={ - 'name': template['name'], - 'description': template['description'], - 'type': template['type'], - 'is_required': template['is_required'], - 'is_hidden': template['is_hidden'], - 'order': template['order'], + 'name': field_data['name'], + 'description': field_data['description'], + 'type': field_data['type'], + 'is_required': field_data['is_required'], + 'is_hidden': field_data['is_hidden'], + 'order': field_data['order'], 'workflow': self.instance.workflow, 'account': self.instance.account, - 'dataset_id': template['dataset_id'], + 'dataset_id': field_data['dataset_id'], }, ) + def _update_fieldset_rules( + self, + fieldset: FieldSet, + rules_data: Optional[List[Dict]], + ) -> None: + + rule_ids = [] + rules_data = rules_data or [] + for rule_data in rules_data: + rule, _ = FieldSetRule.objects.update_or_create( + fieldset=fieldset, + api_name=rule_data['api_name'], + defaults={ + 'account_id': fieldset.account_id, + 'type': rule_data['type'], + 'value': rule_data.get('value'), + }, + ) + rule_ids.append(rule.id) + fieldset.rules.exclude(id__in=rule_ids).delete() + + def _update_field_rules( + self, + field: TaskField, + field_data: Dict, + fieldset: FieldSet, + ) -> None: + + rules = field_data.get('rules', []) + if rules: + rules_api_names = [e['api_name'] for e in rules] + rules = FieldSetRule.objects.filter( + fieldset=fieldset, + api_name__in=rules_api_names, + ) + field.rules.set(rules) + else: + field.rules.clear() + + def _update_fieldset_fields( + self, + fieldset: FieldSet, + fields_data: Optional[List[Dict]], + ) -> None: + + field_ids = [] + fields_data = fields_data or [] + for field_data in fields_data: + field, _ = self._update_field(field_data, fieldset=fieldset) + field_ids.append(field.id) + self._update_field_selections(field, field_data) + self._update_field_rules(field, field_data, fieldset) + fieldset.fields.exclude(id__in=field_ids).delete() + + def _update_fieldsets(self, data: Optional[List]) -> None: + + fieldset_api_names = set() + for fieldset_data in data or []: + fieldset, _ = FieldSet.objects.update_or_create( + workflow=self.instance.workflow, + task=self.instance, + api_name=fieldset_data['api_name'], + defaults={ + 'account_id': self.instance.account_id, + 'name': fieldset_data['name'], + 'description': fieldset_data['description'], + 'order': fieldset_data['order'], + 'label_position': fieldset_data['label_position'], + 'layout': fieldset_data['layout'], + }, + ) + self._update_fieldset_rules( + fieldset=fieldset, + rules_data=fieldset_data.get('rules'), + ) + self._update_fieldset_fields( + fieldset=fieldset, + fields_data=fieldset_data.get('fields'), + ) + fieldset_api_names.add(fieldset.api_name) + FieldSet.objects.filter( + task=self.instance, + ).exclude(api_name__in=fieldset_api_names).delete() + def _update_checklists( self, version: int, @@ -406,6 +507,7 @@ def update_from_version( 'is_urgent': bool, 'require_completion_by_all': bool, 'fields': list, + 'fieldsets': list, 'checklists': list, 'conditions': list, 'raw_due_date: dict, @@ -424,6 +526,7 @@ def update_from_version( fields_values=tasks_fields_values, ) self._update_fields(data=data.get('fields')) + self._update_fieldsets(data=data.get('fieldsets')) self._update_conditions(data=data.get('conditions')) self._update_checklists( data=data.get('checklists'), diff --git a/backend/src/processes/services/templates/field_template.py b/backend/src/processes/services/templates/field_template.py new file mode 100644 index 000000000..133c9bef7 --- /dev/null +++ b/backend/src/processes/services/templates/field_template.py @@ -0,0 +1,103 @@ +from typing import Optional + +from django.db.models import Model + +from src.generics.base.service import BaseModelService +from src.processes.enums import FieldType +from src.processes.models.templates.fields import FieldTemplate +from src.processes.services.exceptions import ( + FieldTemplateSelectionsRequired, + FieldTemplateUserMustBeRequired, +) +from src.processes.services.templates.field_template_selection import ( + FieldTemplateSelectionService, +) + + +class FieldTemplateService(BaseModelService): + + def _validate(self, **kwargs): + field_type = kwargs.get('type') + + if ( + field_type in FieldType.TYPES_WITH_SELECTIONS + and not (kwargs.get('selections') or kwargs.get('dataset')) + ): + raise FieldTemplateSelectionsRequired + + if field_type == FieldType.USER and kwargs.get('is_required') is False: + raise FieldTemplateUserMustBeRequired + + def create(self, **kwargs) -> Model: + self._validate(**kwargs) + return super().create(**kwargs) + + def partial_update(self, **update_kwargs) -> Model: + self._validate(**update_kwargs) + selections_data = update_kwargs.pop('selections', None) + result = super().partial_update(**update_kwargs) + if selections_data is not None: + self.instance.selections.all().delete() + self.create_selections(selections_data=selections_data) + return result + + def _create_instance( + self, + name: str, + type: str, # noqa: A002 + order: int = 0, + description: str = '', + is_required: bool = False, + is_hidden: bool = False, + default: str = '', + template_id: Optional[int] = None, + kickoff_id: Optional[int] = None, + task_id: Optional[int] = None, + fieldset_id: Optional[int] = None, + dataset=None, + dataset_id: Optional[int] = None, + api_name: Optional[str] = None, + **kwargs, + ): + if dataset is not None and dataset_id is None: + dataset_id = dataset.pk if hasattr(dataset, 'pk') else dataset + params = { + 'account': self.account, + 'name': name, + 'type': type, + 'order': order, + 'description': description, + 'is_required': is_required, + 'is_hidden': is_hidden, + 'default': default, + 'template_id': template_id, + 'kickoff_id': kickoff_id, + 'task_id': task_id, + 'fieldset_id': fieldset_id, + 'dataset_id': dataset_id, + } + if api_name: + params['api_name'] = api_name + self.instance = FieldTemplate.objects.create(**params) + return self.instance + + def _create_related( + self, + selections: Optional[list] = None, + **kwargs, + ): + if selections: + self.create_selections(selections_data=selections) + + def create_selections(self, selections_data: list): + service = FieldTemplateSelectionService( + user=self.user, + is_superuser=self.is_superuser, + auth_type=self.auth_type, + ) + for selection_data in selections_data: + service.create( + field_template_id=self.instance.id, + template_id=self.instance.template_id, + **selection_data, + ) diff --git a/backend/src/processes/services/templates/field_template_selection.py b/backend/src/processes/services/templates/field_template_selection.py new file mode 100644 index 000000000..f2f12866b --- /dev/null +++ b/backend/src/processes/services/templates/field_template_selection.py @@ -0,0 +1,25 @@ +from typing import Optional + +from src.generics.base.service import BaseModelService +from src.processes.models.templates.fields import FieldTemplateSelection + + +class FieldTemplateSelectionService(BaseModelService): + + def _create_instance( + self, + value: str, + field_template_id: int, + template_id: Optional[int] = None, + api_name: Optional[str] = None, + **kwargs, + ): + params = { + 'value': value, + 'field_template_id': field_template_id, + 'template_id': template_id, + } + if api_name: + params['api_name'] = api_name + self.instance = FieldTemplateSelection.objects.create(**params) + return self.instance diff --git a/backend/src/processes/services/templates/preset.py b/backend/src/processes/services/templates/preset.py index 2f28a6b18..3c0ecf7eb 100644 --- a/backend/src/processes/services/templates/preset.py +++ b/backend/src/processes/services/templates/preset.py @@ -65,9 +65,6 @@ def set_default(self) -> TemplatePreset: self.partial_update(is_default=True, force_save=True) return self.instance - def delete(self) -> None: - self.instance.delete() - def _reset_default_presets(self) -> None: queryset = ( TemplatePreset.objects diff --git a/backend/src/processes/services/templates/template.py b/backend/src/processes/services/templates/template.py index 7c8f0d594..f165ec6d2 100644 --- a/backend/src/processes/services/templates/template.py +++ b/backend/src/processes/services/templates/template.py @@ -68,6 +68,7 @@ def fill_template_data(self, initial_data: dict) -> dict: 'kickoff': { 'description': initial_kickoff_data.get('description', ''), 'fields': initial_kickoff_data.get('fields', []), + 'fieldsets': initial_kickoff_data.get('fieldsets', []), }, 'tasks': deepcopy(initial_tasks_data), } diff --git a/backend/src/processes/services/versioning/schemas.py b/backend/src/processes/services/versioning/schemas.py index 91440da70..f78e18eb2 100644 --- a/backend/src/processes/services/versioning/schemas.py +++ b/backend/src/processes/services/versioning/schemas.py @@ -9,6 +9,10 @@ PredicateTemplate, RuleTemplate, ) +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateRule, +) from src.processes.models.templates.fields import ( FieldTemplate, FieldTemplateSelection, @@ -32,6 +36,17 @@ class Meta: ) +class FieldsetTemplateRuleSchemaV1(serializers.ModelSerializer): + + class Meta: + model = FieldsetTemplateRule + fields = ( + 'api_name', + 'type', + 'value', + ) + + class FieldSchemaV1(serializers.ModelSerializer): class Meta: @@ -48,6 +63,7 @@ class Meta: 'default', 'selections', 'dataset_id', + 'rules', ) selections = SelectionSchemaV1( @@ -56,6 +72,35 @@ class Meta: allow_empty=True, required=False, ) + rules = FieldsetTemplateRuleSchemaV1( + many=True, + allow_null=True, + allow_empty=True, + ) + + +class FieldSetSchemaV1(serializers.ModelSerializer): + + class Meta: + model = FieldsetTemplate + fields = ( + 'name', + 'title', + 'description', + 'order', + 'api_name', + 'label_position', + 'layout', + 'fields', + 'rules', + ) + + fields = FieldSchemaV1(many=True, allow_null=True, allow_empty=True) + rules = FieldsetTemplateRuleSchemaV1( + many=True, + allow_null=True, + allow_empty=True, + ) class KickoffSchemaV1(serializers.ModelSerializer): @@ -64,9 +109,16 @@ class Meta: model = Kickoff fields = ( 'fields', + 'fieldsets', ) fields = FieldSchemaV1(many=True, allow_null=True, allow_empty=True) + fieldsets = FieldSetSchemaV1( + many=True, + allow_null=True, + allow_empty=True, + required=False, + ) class TemplateOwnerSchemaV1(serializers.ModelSerializer): @@ -194,6 +246,7 @@ class Meta: 'require_completion_by_all', 'skip_for_starter', 'fields', + 'fieldsets', 'delay', 'conditions', 'raw_performers', @@ -204,6 +257,12 @@ class Meta: ) fields = FieldSchemaV1(many=True, allow_null=True, allow_empty=True) + fieldsets = FieldSetSchemaV1( + many=True, + allow_null=True, + allow_empty=True, + required=False, + ) conditions = ConditionSchemaV1( many=True, allow_null=True, diff --git a/backend/src/processes/services/workflow_action.py b/backend/src/processes/services/workflow_action.py index 4778291c9..0a10ed131 100644 --- a/backend/src/processes/services/workflow_action.py +++ b/backend/src/processes/services/workflow_action.py @@ -27,11 +27,13 @@ WorkflowStatus, ) from src.processes.messages import workflow as messages +from src.processes.models.workflows.fieldset import FieldSet from src.processes.models.workflows.task import ( Delay, Task, TaskPerformer, ) +from src.processes.models.workflows.fields import TaskField from src.processes.models.workflows.workflow import Workflow from src.processes.services import exceptions from src.processes.services.condition_check.service import ( @@ -44,6 +46,7 @@ TaskFieldService, ) from src.processes.services.tasks.task import TaskService +from src.processes.services.workflows.fieldsets.fieldset import FieldSetService from src.processes.tasks.webhooks import ( send_task_completed_webhook, send_task_returned_webhook, @@ -855,15 +858,38 @@ def complete_task_for_user( fields_values = fields_values or {} with transaction.atomic(): - for task_field in task.output.all(): + fields = ( + TaskField.objects + .filter( + Q(fieldset__task=task) | Q(task=task), + api_name__in=fields_values, + ) + ) + fieldsets_ids = set() + for field in fields: + if field.fieldset_id: + fieldsets_ids.add(field.fieldset_id) service = TaskFieldService( user=self.user, - instance=task_field, + instance=field, + is_superuser=self.is_superuser, + auth_type=self.auth_type, ) service.partial_update( - value=fields_values.get(task_field.api_name), + value=fields_values[field.api_name], force_save=True, ) + if fieldsets_ids: + fieldsets = FieldSet.objects.filter(id__in=fieldsets_ids) + for fieldset in fieldsets: + service = FieldSetService( + user=self.user, + instance=fieldset, + is_superuser=self.is_superuser, + auth_type=self.auth_type, + ) + service.validate_rules() + if task_performer: if self._task_can_be_completed(task): self.complete_task(task=task, by_user=True) diff --git a/backend/src/processes/services/workflows/fieldsets/__init__.py b/backend/src/processes/services/workflows/fieldsets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/processes/services/workflows/fieldsets/fieldset.py b/backend/src/processes/services/workflows/fieldsets/fieldset.py new file mode 100644 index 000000000..39943f1c5 --- /dev/null +++ b/backend/src/processes/services/workflows/fieldsets/fieldset.py @@ -0,0 +1,111 @@ +from itertools import groupby +from typing import List, Optional, Dict +from django.contrib.auth import get_user_model + +from src.generics.base.service import BaseModelService +from src.processes.messages.fieldset import MSG_FS_0007, MSG_FS_0012 +from src.processes.models.templates.fieldset import FieldsetTemplate +from src.processes.models.workflows.fieldset import FieldSet +from src.processes.services.exceptions import FieldsetServiceException +from src.processes.services.tasks.field import TaskFieldService +from src.processes.services.workflows.fieldsets.fieldset_rule import ( + FieldSetRuleService, +) + +UserModel = get_user_model() + + +class FieldSetService(BaseModelService): + + def _create_instance( + self, + instance_template: FieldsetTemplate, + **kwargs, + ): + task = kwargs.get('task') + kickoff = kwargs.get('kickoff') + if not (task or kickoff): + raise FieldsetServiceException( + message=MSG_FS_0007, + ) + + self.instance = FieldSet.objects.create( + account=self.account, + workflow=kwargs['workflow'], + kickoff=kickoff, + task=task, + api_name=instance_template.api_name, + name=instance_template.name, + title=instance_template.title, + description=instance_template.description, + order=instance_template.order, + label_position=instance_template.label_position, + layout=instance_template.layout, + ) + + def _create_fields( + self, + instance_template: FieldsetTemplate, + fields_data: Optional[List[Dict]] = None, + skip_value: bool = False, + **kwargs, + ): + fields_data = fields_data or {} + for field_template in instance_template.fields.all(): + field_service = TaskFieldService( + user=self.user, + is_superuser=self.is_superuser, + auth_type=self.auth_type, + ) + field_service.create( + instance_template=field_template, + workflow_id=self.instance.workflow_id, + fieldset_id=self.instance.id, + skip_value=skip_value, + value=fields_data.get(field_template.api_name, ''), + ) + + def _create_rules(self, instance_template, **kwargs): + for rule_template in instance_template.rules.filter(is_deleted=False): + service = FieldSetRuleService(user=self.user) + service.create( + instance_template=rule_template, + fieldset=self.instance, + skip_validation=kwargs.get('skip_value'), + ) + + def _create_related(self, instance_template, **kwargs): + self._create_rules(instance_template, **kwargs) + self._create_fields(instance_template, **kwargs) + + def validate_rules(self) -> bool: + rules = list(self.instance.rules.order_by('type').all()) + for _, group in groupby(rules, key=lambda r: r.type): + group_rules = list(group) + if len(group_rules) == 1: + # Single rule of this type — standard validation + service = FieldSetRuleService( + user=self.user, + instance=group_rules[0], + ) + service.validate() + else: + # Multiple rules of the same type — OR logic: + # validation passes if at least one rule succeeds + ex_counter = 0 + for rule in group_rules: + try: + service = FieldSetRuleService( + user=self.user, + instance=rule, + ) + service.validate() + except FieldsetServiceException: + ex_counter += 1 + if len(group_rules) == ex_counter: + values = ', '.join( + str(rule.value) for rule in group_rules + ) + raise FieldsetServiceException( + message=MSG_FS_0012(values), + ) diff --git a/backend/src/processes/services/workflows/fieldsets/fieldset_rule.py b/backend/src/processes/services/workflows/fieldsets/fieldset_rule.py new file mode 100644 index 000000000..4540da22f --- /dev/null +++ b/backend/src/processes/services/workflows/fieldsets/fieldset_rule.py @@ -0,0 +1,62 @@ +from django.contrib.auth import get_user_model +from django.db import transaction +from src.processes.messages.fieldset import MSG_FS_0002 +from src.processes.models.workflows.fieldset import FieldSetRule +from src.processes.services.base import BaseModelService +from src.processes.services.exceptions import FieldsetServiceException + + +UserModel = get_user_model() + + +class FieldSetRuleService(BaseModelService): + + NULL_VALUES = (None, '', []) + + def _validate_sum_equal(self, **kwargs): + + total = 0 + values_exists = False + for field in self.instance.fields.all(): + if field.value in self.NULL_VALUES: + if field.is_required: + values_exists = True + else: + total += float(field.value) + values_exists = True + if values_exists and total != float(self.instance.value): + raise FieldsetServiceException( + message=MSG_FS_0002(self.instance.value), + ) + return True + + def validate(self, **kwargs): + + """ Call after objects save """ + + validator = getattr(self, f'_validate_{self.instance.type}') + validator(**kwargs) + + def _create_instance(self, instance_template, **kwargs): + self.instance = FieldSetRule.objects.create( + account=self.account, + fieldset=kwargs['fieldset'], + api_name=instance_template.api_name, + type=instance_template.type, + value=instance_template.value, + ) + + def create(self, **kwargs) -> FieldSetRule: + with transaction.atomic(): + self._create_instance(**kwargs) + self._create_related(**kwargs) + self._create_actions(**kwargs) + if kwargs.get('skip_validation') is False: + self.validate(**kwargs) + return self.instance + + def partial_update(self, **update_kwargs) -> FieldSetRule: + with transaction.atomic(): + result = super().partial_update(**update_kwargs) + self.validate(**update_kwargs) + return result diff --git a/backend/src/processes/services/workflows/kickoff_version.py b/backend/src/processes/services/workflows/kickoff_version.py index 9fa54b4ff..6bb093882 100644 --- a/backend/src/processes/services/workflows/kickoff_version.py +++ b/backend/src/processes/services/workflows/kickoff_version.py @@ -1,5 +1,9 @@ -from typing import Dict, List +from typing import Dict, List, Optional +from src.processes.models.workflows.fieldset import ( + FieldSet, + FieldSetRule, +) from src.processes.models.workflows.fields import ( FieldSelection, TaskField, @@ -11,13 +15,19 @@ class KickoffUpdateVersionService(BaseUpdateVersionService): - def _update_field(self, template: dict): + def _update_field( + self, + template: dict, + *, + fieldset: Optional[FieldSet] = None, + ): # TODO Move to TaskFieldService return TaskField.objects.update_or_create( kickoff=self.instance, api_name=template['api_name'], + fieldset=fieldset, defaults={ 'name': template['name'], 'description': template['description'], @@ -31,6 +41,25 @@ def _update_field(self, template: dict): }, ) + def _update_field_selections( + self, + field: TaskField, + field_data: Dict, + ) -> None: + + if field_data.get('selections'): + selection_ids = set() + for selection_data in field_data['selections']: + selection, __ = FieldSelection.objects.update_or_create( + field=field, + api_name=selection_data['api_name'], + defaults={ + 'value': selection_data['value'], + }, + ) + selection_ids.add(selection.id) + field.selections.exclude(id__in=selection_ids).delete() + def _update_fields( self, data: List[Dict], @@ -40,21 +69,101 @@ def _update_fields( field_ids = [] for field_data in data: - field, _ = self._update_field(field_data) + field, _ = self._update_field(field_data, fieldset=None) field_ids.append(field.id) - if field_data.get('selections'): - selection_ids = set() - for selection_data in field_data['selections']: - selection, __ = FieldSelection.objects.update_or_create( - field=field, - api_name=selection_data['api_name'], - defaults={ - 'value': selection_data['value'], - }, - ) - selection_ids.add(selection.id) - field.selections.exclude(id__in=selection_ids).delete() - self.instance.output.exclude(id__in=field_ids).delete() + self._update_field_selections(field, field_data) + self.instance.output.filter( + fieldset__isnull=True, + ).exclude(id__in=field_ids).delete() + + def _update_fieldset_rules( + self, + fieldset: FieldSet, + rules_data: Optional[List[Dict]] = None, + ) -> None: + + rule_ids = [] + rules_data = rules_data or [] + for rule_data in rules_data: + rule, _ = FieldSetRule.objects.update_or_create( + fieldset=fieldset, + api_name=rule_data['api_name'], + defaults={ + 'account_id': fieldset.account_id, + 'type': rule_data['type'], + 'value': rule_data.get('value'), + }, + ) + rule_ids.append(rule.id) + fieldset.rules.exclude(id__in=rule_ids).delete() + + def _update_field_rules( + self, + field: TaskField, + field_data: Dict, + fieldset: FieldSet, + ) -> None: + + rules = field_data.get('rules', []) + if rules: + rules_api_names = [e['api_name'] for e in rules] + rules = FieldSetRule.objects.filter( + fieldset=fieldset, + api_name__in=rules_api_names, + ) + field.rules.set(rules) + else: + field.rules.clear() + + def _update_fieldset_fields( + self, + fieldset: FieldSet, + fields_data: Optional[List[Dict]], + ) -> None: + + field_ids = [] + fields_data = fields_data or [] + for field_data in fields_data: + field, _ = self._update_field(field_data, fieldset=fieldset) + field_ids.append(field.id) + self._update_field_selections(field, field_data) + self._update_field_rules(field, field_data, fieldset) + TaskField.objects.filter( + kickoff=self.instance, + fieldset=fieldset, + ).exclude(id__in=field_ids).delete() + + def _update_fieldsets(self, data: Optional[List]) -> None: + + fs_api_names = set() + for fs_data in data or []: + order = fs_data['kickoff_links'][0]['order'] + fieldset, _ = FieldSet.objects.update_or_create( + workflow=self.instance.workflow, + kickoff=self.instance, + api_name=fs_data['api_name'], + defaults={ + 'account_id': self.instance.account_id, + 'name': fs_data['name'], + 'description': fs_data['description'], + 'order': order, + 'label_position': fs_data['label_position'], + 'layout': fs_data['layout'], + }, + ) + self._update_fieldset_rules( + fieldset=fieldset, + rules_data=fs_data.get('rules'), + ) + self._update_fieldset_fields( + fieldset=fieldset, + fields_data=fs_data.get('fields'), + ) + fs_api_names.add(fs_data['api_name']) + FieldSet.objects.filter( + kickoff=self.instance, + is_deleted=False, + ).exclude(api_name__in=fs_api_names).delete() def update_from_version( self, @@ -64,9 +173,12 @@ def update_from_version( """ data = { 'description': str, - 'fields': list + 'fields': list, + 'fieldsets': list, } """ if data.get('fields'): self._update_fields(data=data['fields']) + if data.get('fieldsets') is not None: + self._update_fieldsets(data=data['fieldsets']) diff --git a/backend/src/processes/tests/fixtures.py b/backend/src/processes/tests/fixtures.py index fbd35696b..9fbaa85b4 100644 --- a/backend/src/processes/tests/fixtures.py +++ b/backend/src/processes/tests/fixtures.py @@ -25,7 +25,9 @@ from src.payment.enums import BillingPeriod from src.processes.enums import ( ConditionAction, + FieldSetLayout, FieldType, + LabelPosition, OwnerRole, OwnerType, PerformerType, @@ -34,7 +36,7 @@ TaskStatus, TemplateType, WorkflowEventType, - WorkflowStatus, + WorkflowStatus, FieldSetRuleType, ) from src.processes.models.templates.checklist import ( ChecklistTemplate, @@ -45,6 +47,11 @@ PredicateTemplate, RuleTemplate, ) +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateRule, +) +from src.processes.models.templates.fields import FieldTemplate from src.processes.models.templates.kickoff import Kickoff from src.processes.models.templates.owner import TemplateOwner from src.processes.models.templates.preset import ( @@ -64,6 +71,7 @@ from src.processes.models.workflows.fields import ( TaskField, ) +from src.processes.models.workflows.fieldset import FieldSet, FieldSetRule from src.processes.models.workflows.kickoff import KickoffValue from src.processes.models.workflows.task import Task from src.processes.models.workflows.workflow import Workflow @@ -819,3 +827,169 @@ def create_test_dataset( order=i, ) return dataset + + +def create_test_shared_fieldset( + account: Account, + name: str = 'Test Fieldset', + title: str = '', + description: str = '', + order: int = 0, + label_position: LabelPosition.LITERALS = LabelPosition.TOP, + layout: FieldSetLayout.LITERALS = FieldSetLayout.VERTICAL, + rule_type: Optional[FieldSetRuleType.LITERALS] = None, + rule_value: Optional[str] = None, + api_name: Optional[str] = None, +) -> FieldsetTemplate: + + """Creating fieldset templates.""" + + fieldset = FieldsetTemplate.objects.create( + account=account, + name=name, + title=title, + description=description, + order=order, + label_position=label_position, + layout=layout, + api_name=api_name, + is_shared=True, + ) + if rule_type: + FieldsetTemplateRule.objects.create( + fieldset=fieldset, + account=account, + api_name=f'{fieldset.api_name}-rule-1', + type=rule_type, + value=rule_value, + ) + if rule_type == FieldSetRuleType.SUM_EQUAL: + field_type = FieldType.NUMBER + else: + field_type = FieldType.STRING + + FieldTemplate.objects.create( + name='Fieldset field', + type=field_type, + fieldset=fieldset, + order=1, + api_name=f'{fieldset.api_name}-field-1', + account=account, + ) + return fieldset + + +def create_test_fieldset_template( + account: Account, + template: Optional[Template] = None, + kickoff: Optional[Kickoff] = None, + task: Optional[TaskTemplate] = None, + title: Optional[str] = None, + description: Optional[str] = None, + order: int = 0, + api_name: Optional[str] = None, + shared_fieldset: Optional[FieldsetTemplate] = None, + rule_type: Optional[FieldSetRuleType.LITERALS] = None, + rule_value: Optional[str] = None, +) -> FieldsetTemplate: + + """Creating fieldset templates.""" + + if shared_fieldset is None: + shared_fieldset = create_test_shared_fieldset( + account=account, + rule_type=rule_type, + rule_value=rule_value, + ) + + fieldset = FieldsetTemplate.objects.create( + account=account, + template=template, + name=shared_fieldset.name, + title=title or shared_fieldset.title, + description=description or shared_fieldset.description, + order=order, + label_position=shared_fieldset.label_position, + layout=shared_fieldset.layout, + api_name=api_name, + task=task, + kickoff=kickoff, + is_shared=False, + shared_fieldset=shared_fieldset, + ) + for shared_rule in shared_fieldset.rules.all(): + FieldsetTemplateRule.objects.create( + fieldset=fieldset, + account=account, + api_name=f'{fieldset.api_name}-rule-1', + type=shared_rule.type, + value=shared_rule.value, + ) + + for shared_field in shared_fieldset.fields.all(): + FieldTemplate.objects.create( + name=shared_field.name, + type=shared_field.type, + fieldset=fieldset, + template=template, + order=shared_field.order, + api_name=f'{fieldset.api_name}-field-1', + account=account, + ) + return fieldset + + +def create_test_fieldset( + workflow: Workflow, + task: Optional[Task] = None, + kickoff: Optional[KickoffValue] = None, + name: str = 'Test Fieldset', + description: str = '', + order: int = 0, + label_position: LabelPosition.LITERALS = LabelPosition.TOP, + layout: FieldSetLayout.LITERALS = FieldSetLayout.VERTICAL, + rule_type: Optional[FieldSetRuleType.LITERALS] = None, + rule_value: Optional[str] = None, + api_name: Optional[str] = None, +) -> FieldSet: + + """Creating a workflow FieldSet with one TaskField.""" + + fieldset = FieldSet.objects.create( + account=workflow.account, + workflow=workflow, + kickoff=kickoff, + task=task, + name=name, + description=description, + order=order, + label_position=label_position, + layout=layout, + api_name=api_name, + ) + if rule_type: + FieldSetRule.objects.create( + fieldset=fieldset, + account=workflow.account, + api_name=f'{fieldset.api_name}-rule-1', + type=rule_type, + value=rule_value, + ) + if rule_type == FieldSetRuleType.SUM_EQUAL: + field_type = FieldType.NUMBER + field_value = '10' + else: + field_type = FieldType.STRING + field_value = 'Some value' + TaskField.objects.create( + account=workflow.account, + workflow=workflow, + fieldset=fieldset, + task=task, + name='Fieldset field', + type=field_type, + order=1, + api_name=f'{fieldset.api_name}-field-1', + value=field_value, + ) + return fieldset diff --git a/backend/src/processes/tests/test_models/test_workflow.py b/backend/src/processes/tests/test_models/test_workflow.py index c7ca2d804..5189d09cd 100644 --- a/backend/src/processes/tests/test_models/test_workflow.py +++ b/backend/src/processes/tests/test_models/test_workflow.py @@ -1,106 +1,488 @@ import pytest from django.contrib.auth import get_user_model - +from src.processes.enums import FieldType from src.processes.models.workflows.task import Task from src.processes.models.workflows.workflow import Workflow +from src.processes.models.workflows.fields import TaskField +from src.processes.models.workflows.fieldset import FieldSet from src.processes.tests.fixtures import ( - create_test_user, + create_test_account, + create_test_owner, create_test_workflow, ) +pytestmark = pytest.mark.django_db UserModel = get_user_model() pytestmark = pytest.mark.django_db -class TestWorkflow: - @pytest.fixture - def workflow_sql(self): - return """ - SELECT - id, - is_deleted, - template_id - FROM processes_workflow - WHERE id = %(workflow_id)s - """ - - @pytest.fixture - def task_sql(self): - return """ - SELECT - id, - is_deleted - FROM processes_task - WHERE workflow_id = %(workflow_id)s - """ - - def test_delete( - self, +@pytest.fixture +def workflow_sql(): + return """ + SELECT + id, + is_deleted, + template_id + FROM processes_workflow + WHERE id = %(workflow_id)s + """ + + +@pytest.fixture +def task_sql(): + return """ + SELECT + id, + is_deleted + FROM processes_task + WHERE workflow_id = %(workflow_id)s + """ + + +def test_delete(workflow_sql, task_sql): + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user) + + # act + workflow.delete() + + # assert + assert Workflow.objects.raw( workflow_sql, + {'workflow_id': workflow.id}, + )[0].is_deleted is True + task_list = Task.objects.raw( task_sql, - ): - # arrange - user = create_test_user() - workflow = create_test_workflow(user) - - # act - workflow.delete() - - # assert - assert Workflow.objects.raw( - workflow_sql, - {'workflow_id': workflow.id}, - )[0].is_deleted is True - task_list = Task.objects.raw( - task_sql, - {'workflow_id': workflow.id}, - ) - assert task_list[0].is_deleted is True - assert task_list[1].is_deleted is True - assert task_list[2].is_deleted is True - - def test_get_kickoff_fields_values__ok(self, mocker): - # arrange - user = create_test_user() - workflow = create_test_workflow(user=user) - field_mock = mocker.Mock( - api_name='field-template', - markdown_value='test', - ) - kickoff_output_fields_mock = mocker.patch( - 'src.processes.models.workflows.workflow.Workflow.' - 'get_kickoff_output_fields', - return_value=[field_mock], - ) - - # act - workflow.get_kickoff_fields_values() - - # assert - kickoff_output_fields_mock.assert_called_once() - - def test_get_fields_markdown_values__workflow_starter__ok(self): - # arrange - user = create_test_user() - workflow = create_test_workflow(user=user) - - # act - fields_values = workflow.get_fields_markdown_values() - - # assert - assert 'workflow-starter' in fields_values - assert fields_values['workflow-starter'] == user.name - - def test_get_kickoff_fields_markdown_values__workflow_starter__ok(self): - # arrange - user = create_test_user() - workflow = create_test_workflow(user=user) - - # act - fields_values = workflow.get_kickoff_fields_markdown_values() - - # assert - assert 'workflow-starter' in fields_values - assert fields_values['workflow-starter'] == user.name + {'workflow_id': workflow.id}, + ) + assert task_list[0].is_deleted is True + assert task_list[1].is_deleted is True + assert task_list[2].is_deleted is True + + +def test_get_kickoff_fields_values__ok(mocker): + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user) + field_mock = mocker.Mock( + api_name='field-template', + markdown_value='test', + ) + kickoff_output_fields_mock = mocker.patch( + 'src.processes.models.workflows.workflow.Workflow.' + 'get_kickoff_output_fields', + return_value=[field_mock], + ) + + # act + workflow.get_kickoff_fields_values() + + # assert + kickoff_output_fields_mock.assert_called_once() + + +def test_get_fields_markdown_values__workflow_starter__ok(): + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user) + + # act + fields_values = workflow.get_fields_markdown_values() + + # assert + assert 'workflow-starter' in fields_values + assert fields_values['workflow-starter'] == user.name + + +def test_get_kickoff_fields_markdown_values__workflow_starter__ok(): + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user) + + # act + fields_values = workflow.get_kickoff_fields_markdown_values() + + # assert + assert 'workflow-starter' in fields_values + assert fields_values['workflow-starter'] == user.name + + +def test_get_kickoff_output_fields__field_and_fieldset__ok(): + + """Call with default params returns kickoff and fieldset fields.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user) + kickoff = workflow.kickoff.get() + field_1 = TaskField.objects.create( + name='Field 1', + type=FieldType.STRING, + api_name='field-1', + kickoff=kickoff, + workflow=workflow, + account=account, + ) + fieldset_1 = FieldSet.objects.create( + name='Fieldset 1', + api_name='fieldset-1', + workflow=workflow, + kickoff=kickoff, + account=account, + ) + field_2 = TaskField.objects.create( + name='Field 2', + type=FieldType.STRING, + api_name='field-2', + fieldset=fieldset_1, + workflow=workflow, + account=account, + ) + + # act + result = workflow.get_kickoff_output_fields() + + # assert + assert result.count() == 2 + assert field_1 in result + assert field_2 in result + + +def test_get_kickoff_output_fields__only_field__ok(): + + """Returns only direct kickoff fields when no fieldsets exist.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user) + kickoff = workflow.kickoff.get() + field_1 = TaskField.objects.create( + name='Field 1', + type=FieldType.STRING, + api_name='field-1', + kickoff=kickoff, + workflow=workflow, + account=account, + ) + task = workflow.tasks.get(number=1) + fieldset_1 = FieldSet.objects.create( + name='Fieldset 1', + api_name='fieldset-1', + workflow=workflow, + task=task, + account=account, + ) + TaskField.objects.create( + name='Field 2', + type=FieldType.STRING, + api_name='field-2', + fieldset=fieldset_1, + workflow=workflow, + account=account, + ) + + # act + result = workflow.get_kickoff_output_fields() + + # assert + assert result.count() == 1 + assert result[0] == field_1 + + +def test_get_kickoff_output_fields__only_fieldsets__ok(): + + """Returns only fieldset fields when no direct kickoff fields exist.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user) + kickoff = workflow.kickoff.get() + fieldset_1 = FieldSet.objects.create( + name='Fieldset 1', + api_name='fieldset-1', + workflow=workflow, + kickoff=kickoff, + account=account, + ) + field_1 = TaskField.objects.create( + name='Field 1', + type=FieldType.STRING, + api_name='field-1', + fieldset=fieldset_1, + workflow=workflow, + account=account, + ) + task = workflow.tasks.get(number=1) + TaskField.objects.create( + name='Field 1', + type=FieldType.STRING, + api_name='field-1', + task=task, + workflow=workflow, + account=account, + ) + + # act + result = workflow.get_kickoff_output_fields() + + # assert + assert result.count() == 1 + assert result[0] == field_1 + + +def test_get_kickoff_output_fields__fields_filter__ok(): + + """Call with fields_filter_kwargs applies additional filter.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user) + kickoff = workflow.kickoff.get() + field_1 = TaskField.objects.create( + name='Field 1', + type=FieldType.STRING, + api_name='field-1', + kickoff=kickoff, + workflow=workflow, + account=account, + ) + TaskField.objects.create( + name='Field 2', + type=FieldType.TEXT, + api_name='field-2', + kickoff=kickoff, + workflow=workflow, + account=account, + ) + + # act + result = workflow.get_kickoff_output_fields( + fields_filter_kwargs={'type': FieldType.STRING}, + ) + + # assert + assert result.count() == 1 + assert result[0] == field_1 + + +def test_get_tasks_output_fields__field_and_fieldset__ok(): + + """Call with default params returns task and fieldset fields.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user) + task_1 = workflow.tasks.get(number=1) + field_1 = TaskField.objects.create( + name='Field 1', + type=FieldType.STRING, + api_name='field-1', + task=task_1, + workflow=workflow, + account=account, + ) + fieldset_1 = FieldSet.objects.create( + name='Fieldset 1', + api_name='fieldset-1', + workflow=workflow, + task=task_1, + account=account, + ) + field_2 = TaskField.objects.create( + name='Field 2', + type=FieldType.STRING, + api_name='field-2', + fieldset=fieldset_1, + workflow=workflow, + account=account, + ) + + # act + result = workflow.get_tasks_output_fields() + + # assert + assert result.count() == 2 + assert field_1 in result + assert field_2 in result + + +def test_get_tasks_output_fields__only_fields__ok(): + + """Returns only direct task fields when no fieldsets exist.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user) + task_1 = workflow.tasks.get(number=1) + field_1 = TaskField.objects.create( + name='Field 1', + type=FieldType.STRING, + api_name='field-1', + task=task_1, + workflow=workflow, + account=account, + ) + fieldset_1 = FieldSet.objects.create( + name='Fieldset 1', + api_name='fieldset-1', + workflow=workflow, + kickoff=workflow.kickoff_instance, + account=account, + ) + TaskField.objects.create( + name='Field 2', + type=FieldType.STRING, + api_name='field-2', + fieldset=fieldset_1, + workflow=workflow, + account=account, + ) + + # act + result = workflow.get_tasks_output_fields() + + # assert + assert result.count() == 1 + assert result[0] == field_1 + + +def test_get_tasks_output_fields__only_fieldsets__ok(): + + """Returns only fieldset fields when no direct task fields exist.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user) + task_1 = workflow.tasks.get(number=1) + fieldset_1 = FieldSet.objects.create( + name='Fieldset 1', + api_name='fieldset-1', + workflow=workflow, + task=task_1, + account=account, + ) + field_1 = TaskField.objects.create( + name='Field 1', + type=FieldType.STRING, + api_name='field-1', + fieldset=fieldset_1, + workflow=workflow, + account=account, + ) + TaskField.objects.create( + name='Field 1', + type=FieldType.STRING, + api_name='field-1', + kickoff=workflow.kickoff_instance, + workflow=workflow, + account=account, + ) + + # act + result = workflow.get_tasks_output_fields() + + # assert + assert result.count() == 1 + assert result[0] == field_1 + + +def test_get_tasks_output_fields__exclude_kwargs__ok(): + + """Call with tasks_exclude_kwargs excludes matching tasks and fieldsets.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user) + task_1 = workflow.tasks.get(number=1) + task_2 = workflow.tasks.get(number=2) + field_1 = TaskField.objects.create( + name='Field 1', + type=FieldType.STRING, + api_name='field-1', + task=task_1, + workflow=workflow, + account=account, + ) + TaskField.objects.create( + name='Field 2', + type=FieldType.STRING, + api_name='field-2', + task=task_2, + workflow=workflow, + account=account, + ) + fieldset_1 = FieldSet.objects.create( + name='Fieldset 1', + api_name='fieldset-1', + workflow=workflow, + task=task_2, + account=account, + ) + TaskField.objects.create( + name='Field 3', + type=FieldType.STRING, + api_name='field-3', + fieldset=fieldset_1, + workflow=workflow, + account=account, + ) + + # act + result = workflow.get_tasks_output_fields( + tasks_exclude_kwargs={'task__number': 2}, + ) + + # assert + assert result.count() == 1 + assert result[0] == field_1 + + +def test_get_tasks_output_fields__fields_filter__ok(): + + """Call with fields_filter_kwargs applies additional filter.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user) + task_1 = workflow.tasks.get(number=1) + field_1 = TaskField.objects.create( + name='Field 1', + type=FieldType.STRING, + api_name='field-1', + task=task_1, + workflow=workflow, + account=account, + ) + TaskField.objects.create( + name='Field 2', + type=FieldType.TEXT, + api_name='field-2', + task=task_1, + workflow=workflow, + account=account, + ) + + # act + result = workflow.get_tasks_output_fields( + fields_filter_kwargs={'type': FieldType.STRING}, + ) + + # assert + assert result.count() == 1 + assert result[0] == field_1 diff --git a/backend/src/processes/tests/test_services/test_fieldsets/__init__.py b/backend/src/processes/tests/test_services/test_fieldsets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/processes/tests/test_services/test_fieldsets/test_fieldset_template_rule_service.py b/backend/src/processes/tests/test_services/test_fieldsets/test_fieldset_template_rule_service.py new file mode 100644 index 000000000..7f60472f4 --- /dev/null +++ b/backend/src/processes/tests/test_services/test_fieldsets/test_fieldset_template_rule_service.py @@ -0,0 +1,894 @@ +import pytest +from src.authentication.enums import AuthTokenType +from src.processes.enums import ( + FieldSetRuleType, + FieldType, +) +from src.processes.messages import fieldset as fs_messages +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateRule, +) +from src.processes.models.templates.fields import FieldTemplate +from src.processes.services.exceptions import ( + FieldsetTemplateRuleServiceException, + FieldsetTemplateRuleSumMaxFieldsNotNumber, + FieldsetTemplateRuleSumMaxInvalidValue, +) +from src.processes.services.fieldsets.fieldset_rule import ( + FieldsetTemplateRuleService, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_owner, + create_test_template, +) + +pytestmark = pytest.mark.django_db + + +def test__create_instance__default_params__ok(): + + """ + Call with default parameters + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + # act + result = service._create_instance( + type=FieldSetRuleType.SUM_EQUAL, + fieldset_id=fieldset.id, + ) + + # assert + assert service.instance is not None + assert result is service.instance + assert service.instance.type == FieldSetRuleType.SUM_EQUAL + assert service.instance.value is None + assert service.instance.fieldset_id == fieldset.id + assert service.instance.account_id == account.id + assert service.instance.api_name + + +def test__create_instance__all_params__ok(): + + """ + Call with all parameters + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + value = '100' + api_name = 'rule-1' + rule_type = FieldSetRuleType.SUM_EQUAL + + # act + service._create_instance( + type=rule_type, + value=value, + api_name=api_name, + fieldset_id=fieldset.id, + ) + + # assert + assert service.instance is not None + assert service.instance.type == rule_type + assert service.instance.value == value + assert service.instance.fieldset_id == fieldset.id + assert service.instance.account_id == account.id + assert service.instance.api_name == api_name + + +def test__validate_sum_equal__valid__ok(): + + """ + Value from kwargs, valid number, all NUMBER fields → ok + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + field = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Number field', + type=FieldType.NUMBER, + api_name='num', + order=1, + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='100.5', + ) + rule.fields.add(field) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + result = service._validate_sum_equal() + + # assert + assert result == 100.5 + + +def test__validate_sum_equal__empty_value__raise_exception(): + + """ + Value is empty → raises SumMaxInvalidValue + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value=None, + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + with pytest.raises(FieldsetTemplateRuleSumMaxInvalidValue) as ex: + service._validate_sum_equal() + + # assert + assert ex.value.message == fs_messages.MSG_FS_0004 + + +def test__validate_sum_equal__non_numeric__raise_exception(): + + """ + Value is non-numeric → raises SumMaxInvalidValue + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='abc', + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + with pytest.raises(FieldsetTemplateRuleSumMaxInvalidValue) as ex: + service._validate_sum_equal() + + # assert + assert ex.value.message == fs_messages.MSG_FS_0004 + + +@pytest.mark.parametrize( + 'field_type', + ( + FieldType.STRING, + FieldType.TEXT, + FieldType.RADIO, + FieldType.CHECKBOX, + FieldType.DATE, + FieldType.URL, + FieldType.DROPDOWN, + FieldType.FILE, + FieldType.USER, + ), +) +def test__validate_sum_equal__non_number_type__raise_exception(field_type): + + """ + Non-NUMBER field exists → raises SumMaxFieldsNotNumber + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + field = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='String field', + type=field_type, + api_name='str_field', + order=1, + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ) + rule.fields.add(field) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + with pytest.raises(FieldsetTemplateRuleSumMaxFieldsNotNumber) as ex: + service._validate_sum_equal() + + # assert + assert ex.value.message == fs_messages.MSG_FS_0003 + + +def test__validate__call_method_by_type__ok(mocker): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + validate_sum_equal_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset_rule.' + 'FieldsetTemplateRuleService._validate_sum_equal', + ) + kwargs = {'type': FieldSetRuleType.SUM_EQUAL} + + # act + service._validate(**kwargs) + + # assert + validate_sum_equal_mock.assert_called_once_with(**kwargs) + + +def test_get_valid_fields__all_found__ok(): + + """ + All fields found → returns list + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + field_1_api_name = 'field_1' + field1 = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Field 1', + type=FieldType.STRING, + api_name=field_1_api_name, + order=1, + ) + field_2_api_name = 'field_2' + field2 = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + api_name=field_2_api_name, + order=2, + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + service.instance = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + ) + + # act + result = service._get_valid_fields([field_1_api_name, field_2_api_name]) + + # assert + assert len(result) == 2 + assert field1 in result + assert field2 in result + + +def test_get_valid_fields__type_from_kwargs__ok(): + + """ + rule_type from kwargs + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + field_api_name = 'field_1' + field = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Number field', + type=FieldType.NUMBER, + api_name=field_api_name, + order=1, + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + service.instance = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + ) + + # act + result = service._get_valid_fields( + fields_api_names=[field_api_name], + type=FieldSetRuleType.SUM_EQUAL, + ) + + # assert + assert result == [field] + + +def test_get_valid_fields__type_from_instance__ok(): + + """ + rule_type from instance + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + field_api_name = 'field_1' + field = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Number field', + type=FieldType.NUMBER, + api_name=field_api_name, + order=1, + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + service.instance = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + ) + + # act + result = service._get_valid_fields( + fields_api_names=[field_api_name], + ) + + # assert + assert result == [field] + + +def test_get_valid_fields__one_failed__raise_exception(): + + """ + failed_api_names has 1 element → raises exception + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Number field', + type=FieldType.NUMBER, + api_name='num', + order=1, + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + service.instance = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + ) + + # act + with pytest.raises(FieldsetTemplateRuleServiceException) as ex: + service._get_valid_fields(['missing']) + + # assert + assert ex.value.message == fs_messages.MSG_FS_0005( + rule=FieldSetRuleType.SUM_EQUAL, + field='missing', + ) + + +def test_get_valid_fields__two_failed__raise_exception(): + + """ + failed_api_names has 2 elements → raises exception + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + service.instance = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + ) + + # act + with pytest.raises(FieldsetTemplateRuleServiceException) as ex: + service._get_valid_fields(['missing1', 'missing2']) + + # assert + assert ex.value.message in { + fs_messages.MSG_FS_0005( + rule=FieldSetRuleType.SUM_EQUAL, + field='missing1', + ), fs_messages.MSG_FS_0005( + rule=FieldSetRuleType.SUM_EQUAL, + field='missing2', + ), + } + + +def test_set_fields__fields_provided__set_fields(mocker): + + """ + Non-empty list → fields are set + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + field_api_name = 'num' + field = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Number field', + type=FieldType.NUMBER, + api_name=field_api_name, + order=1, + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + ) + service.instance = rule + get_valid_fields_mock = mocker.patch( + 'src.processes.services.fieldsets' + '.fieldset_rule.FieldsetTemplateRuleService' + '._get_valid_fields', + return_value=[field], + ) + + # act + service._set_fields([field_api_name]) + + # assert + get_valid_fields_mock.assert_called_once_with(['num']) + assert list(rule.fields.all()) == [field] + + +def test_set_fields__fields_not_provided__clear_fields(mocker): + + """ + Empty list → fields are cleared + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + field = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Number field', + type=FieldType.NUMBER, + api_name='num', + order=1, + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + ) + rule.fields.add(field) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + service.instance = rule + get_valid_fields_mock = mocker.patch( + 'src.processes.services.fieldsets' + '.fieldset_rule.FieldsetTemplateRuleService' + '._get_valid_fields', + ) + + # act + service._set_fields([]) + + # assert + get_valid_fields_mock.assert_not_called() + assert rule.fields.count() == 0 + + +def test_create_related__fields_provided__ok(mocker): + + """ + fields present in kwargs + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + set_fields_mock = mocker.patch( + 'src.processes.services.fieldsets' + '.fieldset_rule.FieldsetTemplateRuleService' + '._set_fields', + ) + fields = ['num'] + + # act + service._create_related(fields=fields) + + # assert + set_fields_mock.assert_called_once_with(fields) + + +def test_create_related__fields_provided_empty_list__ok(mocker): + + """ + fields not in kwargs → no-op + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + set_fields_mock = mocker.patch( + 'src.processes.services.fieldsets' + '.fieldset_rule.FieldsetTemplateRuleService' + '._set_fields', + ) + + # act + service._create_related(fields=[]) + + # assert + set_fields_mock.assert_called_once_with([]) + + +def test_create_related__fields_not_provided__skip(mocker): + + """ + fields not in kwargs → no-op + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + set_fields_mock = mocker.patch( + 'src.processes.services.fieldsets' + '.fieldset_rule.FieldsetTemplateRuleService' + '._set_fields', + ) + + # act + service._create_related() + + # assert + set_fields_mock.assert_not_called() + + +def test_create__valid_data__ok(mocker): + + """ + Call with valid data → returns instance + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + service.instance = rule + create_instance_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset_rule.' + 'FieldsetTemplateRuleService._create_instance', + ) + create_related_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset_rule.' + 'FieldsetTemplateRuleService._create_related', + ) + create_actions_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset_rule.' + 'FieldsetTemplateRuleService._create_actions', + ) + validate_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset_rule.' + 'FieldsetTemplateRuleService._validate', + ) + + kwargs = { + 'fields': ['num'], + 'value': '100', + 'type': FieldSetRuleType.SUM_EQUAL, + 'fieldset_id': fieldset.id, + } + + # act + result = service.create(**kwargs) + + # assert + assert result == rule + create_instance_mock.assert_called_once_with(**kwargs) + create_related_mock.assert_called_once_with(**kwargs) + create_actions_mock.assert_called_once_with(**kwargs) + validate_mock.assert_called_once_with(**kwargs) + + +def test_partial_update__with_fields__ok(mocker): + + """ + fields in update_kwargs → branch taken + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + set_fields_mock = mocker.patch( + 'src.processes.services.fieldsets' + '.fieldset_rule.FieldsetTemplateRuleService' + '._set_fields', + ) + validate_mock = mocker.patch( + 'src.processes.services.fieldsets' + '.fieldset_rule.FieldsetTemplateRuleService' + '._validate', + ) + value = '200' + fields = ['num'] + + # act + result = service.partial_update(value=value, fields=fields) + + # assert + set_fields_mock.assert_called_once_with(fields) + validate_mock.assert_called_once_with(value=value) + assert result == rule + rule.refresh_from_db() + assert rule.value == value + + +def test_partial_update__without_fields__ok(mocker): + + """ + fields not in update_kwargs → branch skipped + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + set_fields_mock = mocker.patch( + 'src.processes.services.fieldsets' + '.fieldset_rule.FieldsetTemplateRuleService' + '._set_fields', + ) + validate_mock = mocker.patch( + 'src.processes.services.fieldsets' + '.fieldset_rule.FieldsetTemplateRuleService' + '._validate', + ) + super_partial_mock = mocker.patch( + 'src.processes.services.fieldsets' + '.fieldset_rule.BaseModelService' + '.partial_update', + return_value=rule, + ) + value = '200' + + # act + result = service.partial_update(value=value) + + # assert + super_partial_mock.assert_called_once_with(value=value, force_save=True) + set_fields_mock.assert_not_called() + validate_mock.assert_called_once_with(value=value) + assert result == rule diff --git a/backend/src/processes/tests/test_services/test_fieldsets/test_fieldset_template_service.py b/backend/src/processes/tests/test_services/test_fieldsets/test_fieldset_template_service.py new file mode 100644 index 000000000..bcb4dee7a --- /dev/null +++ b/backend/src/processes/tests/test_services/test_fieldsets/test_fieldset_template_service.py @@ -0,0 +1,2104 @@ +import pytest +from src.authentication.enums import AuthTokenType +from src.processes.enums import ( + FieldSetLayout, + FieldSetRuleType, + FieldType, + LabelPosition, +) +from src.processes.messages import fieldset as fs_messages +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateRule, +) +from src.processes.models.templates.fields import FieldTemplate +from src.processes.services.exceptions import ( + FieldsetTemplateInUseException, + FieldsetTemplateSharedIdMissing, + FieldsetTemplateTemplateIdMissing, +) +from src.processes.services.templates.field_template import ( + FieldTemplateService, +) +from src.processes.services.fieldsets.fieldset import ( + FieldSetTemplateService, +) +from src.processes.services.fieldsets.fieldset_rule import ( + FieldsetTemplateRuleService, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_owner, + create_test_template, + create_test_fieldset_template, + create_test_shared_fieldset, +) + +pytestmark = pytest.mark.django_db + + +def test__create_instance__default_params__ok(): + + """ + Call with default parameters + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + shared_fieldset = FieldsetTemplate.objects.create( + account=account, + name='Shared FS', + is_shared=True, + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + name = 'Test fieldset' + + # act + service._create_instance( + name=name, + template_id=template.id, + is_shared=False, + shared_fieldset_id=shared_fieldset.id, + ) + + # assert + assert service.instance is not None + assert service.instance.name == name + assert service.instance.api_name + assert service.instance.template_id == template.id + assert service.instance.shared_fieldset_id == shared_fieldset.id + assert service.instance.account_id == account.id + assert service.instance.description == '' + assert service.instance.label_position == LabelPosition.TOP + assert service.instance.layout == FieldSetLayout.VERTICAL + + +def test__create_instance__all_params__ok(): + + """ + Call with all parameters + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + shared_fieldset = FieldsetTemplate.objects.create( + account=account, + name='Shared FS', + is_shared=True, + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + name = 'Test fieldset' + description = 'Test description' + label_position = LabelPosition.LEFT + layout = FieldSetLayout.HORIZONTAL + api_name = 'fs-1' + + # act + service._create_instance( + name=name, + template_id=template.id, + is_shared=False, + shared_fieldset_id=shared_fieldset.id, + description=description, + label_position=label_position, + layout=layout, + api_name=api_name, + ) + + # assert + assert service.instance.name == name + assert service.instance.template_id == template.id + assert service.instance.shared_fieldset_id == shared_fieldset.id + assert service.instance.description == description + assert service.instance.label_position == label_position + assert service.instance.layout == layout + assert service.instance.api_name == api_name + + +def test__create_instance__shared_fieldset__ok(): + + """ + Call with default parameters + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + name = 'Test fieldset' + + # act + service._create_instance( + name=name, + is_shared=True, + ) + + # assert + assert service.instance is not None + assert service.instance.name == name + assert service.instance.is_shared is True + assert service.instance.template_id is None + assert service.instance.title == '' + assert service.instance.description == '' + assert service.instance.kickoff_id is None + assert service.instance.task_id is None + assert service.instance.shared_fieldset_id is None + assert service.instance.api_name + assert service.instance.account_id == account.id + assert service.instance.label_position == LabelPosition.TOP + assert service.instance.layout == FieldSetLayout.VERTICAL + + +def test__create_instance__is_shared_api_name_provided__ok(): + + """ + is_shared=True, api_name provided + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + name = 'Shared fieldset' + api_name = 'fs-custom-1' + + # act + service._create_instance( + name=name, + is_shared=True, + api_name=api_name, + ) + + # assert + assert service.instance.api_name == api_name + assert service.instance.is_shared is True + assert service.instance.template_id is None + + +def test__create_instance__not_shared_no_template_id__raise_exception(): + + """ + is_shared=False, template_id missing + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + shared_fieldset = FieldsetTemplate.objects.create( + account=account, + name='Shared FS', + is_shared=True, + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + # act + with pytest.raises(FieldsetTemplateTemplateIdMissing) as ex: + service._create_instance( + name='Fieldset', + is_shared=False, + shared_fieldset_id=shared_fieldset.id, + ) + + # assert + assert ex.value.message == fs_messages.MSG_FS_0011 + + +def test__create_instance__not_shared_no_shared_fieldset_id__raise_exception(): + + """ + is_shared=False, shared_fieldset_id missing + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + # act + with pytest.raises(FieldsetTemplateSharedIdMissing) as ex: + service._create_instance( + name='Fieldset', + is_shared=False, + template_id=template.id, + ) + + # assert + assert ex.value.message == fs_messages.MSG_FS_0010 + + +def test__create_fields__with_data__ok(mocker): + + """ + Call with fields data + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + fields_data = [ + {'name': 'Field 1', 'type': 'string', 'order': 1}, + {'name': 'Field 2', 'type': 'number', 'order': 2}, + ] + + # mock + field_template_service_init_mock = mocker.patch.object( + FieldTemplateService, + attribute='__init__', + return_value=None, + ) + field_template_service_create_mock = mocker.patch( + 'src.processes.services.templates.field_template.' + 'FieldTemplateService.create', + ) + + # act + service._create_fields(fields_data=fields_data) + + # assert + assert field_template_service_init_mock.call_count == 2 + assert field_template_service_create_mock.call_count == 2 + field_template_service_create_mock.assert_has_calls( + [ + mocker.call( + fieldset_id=fieldset.id, + template_id=template.id, + name='Field 1', + type='string', + order=1, + ), + mocker.call( + fieldset_id=fieldset.id, + template_id=template.id, + name='Field 2', + type='number', + order=2, + ), + ], + any_order=True, + ) + + +def test_create_rules__with_data__ok(mocker): + + """ + Call with rules data + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + rules_data = [ + {'type': FieldSetRuleType.SUM_EQUAL, 'value': '100'}, + ] + + # mock + fieldset_template_rule_service_init_mock = mocker.patch.object( + FieldsetTemplateRuleService, + attribute='__init__', + return_value=None, + ) + fieldset_template_rule_service_create_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset_rule.' + 'FieldsetTemplateRuleService.create', + ) + + # act + service.create_rules(rules_data=rules_data) + + # assert + fieldset_template_rule_service_init_mock.assert_called_once_with( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + fieldset_template_rule_service_create_mock.assert_called_once_with( + fieldset_id=fieldset.id, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ) + + +def test__create_related__default_params__ok(mocker): + + """ + Call with default parameters (no rules, no fields) + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + # mock + create_rules_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService.create_rules', + ) + create_fields_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._create_fields', + ) + + # act + service._create_related() + + # assert + create_rules_mock.assert_not_called() + create_fields_mock.assert_not_called() + + +def test__create_related__rules_provided__ok(mocker): + + """ + Rules provided + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + rules = [{'type': FieldSetRuleType.SUM_EQUAL, 'value': '100'}] + + # mock + create_rules_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService.create_rules', + ) + create_fields_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._create_fields', + ) + + # act + service._create_related(rules=rules) + + # assert + create_rules_mock.assert_called_once_with(rules_data=rules) + create_fields_mock.assert_not_called() + + +def test__create_related__fields_provided__ok(mocker): + + """ + Fields provided + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + fields = [{'name': 'Field 1', 'type': 'string', 'order': 1}] + + # mock + create_rules_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService.create_rules', + ) + create_fields_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._create_fields', + ) + + # act + service._create_related(fields=fields) + + # assert + create_rules_mock.assert_not_called() + create_fields_mock.assert_called_once_with(fields_data=fields) + + +def test__create_related__both_provided__ok(mocker): + + """ + Both rules and fields provided + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + rules = [{'type': FieldSetRuleType.SUM_EQUAL, 'value': '100'}] + fields = [{'name': 'Field 1', 'type': 'string', 'order': 1}] + + # mock + create_rules_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService.create_rules', + ) + create_fields_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._create_fields', + ) + + # act + service._create_related(rules=rules, fields=fields) + + # assert + create_rules_mock.assert_called_once_with(rules_data=rules) + create_fields_mock.assert_called_once_with(fields_data=fields) + + +def test__update_fields__existing_field__ok(mocker): + + """ + Update existing field + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + field_1 = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Field 1', + type='string', + order=1, + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + fields_data = [{'api_name': field_1.api_name, 'name': 'Updated Field 1'}] + + # mock + field_template_service_init_mock = mocker.patch.object( + FieldTemplateService, + attribute='__init__', + return_value=None, + ) + field_template_service_partial_update_mock = mocker.patch( + 'src.processes.services.templates.field_template.' + 'FieldTemplateService.partial_update', + ) + field_template_service_create_mock = mocker.patch( + 'src.processes.services.templates.field_template.' + 'FieldTemplateService.create', + ) + + # act + service._update_fields(fields_data=fields_data) + + # assert + field_template_service_init_mock.assert_called_once_with( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=field_1, + ) + field_template_service_partial_update_mock.assert_called_once_with( + name='Updated Field 1', + force_save=True, + ) + field_template_service_create_mock.assert_not_called() + + +def test__update_fields__new_field__ok(mocker): + + """ + Create new field + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + fields_data = [ + {'name': 'New Field', 'type': 'number', 'order': 1}, + ] + + # mock + create_return = mocker.Mock() + create_return.api_name = 'field_api_name' + field_template_service_init_mock = mocker.patch.object( + FieldTemplateService, + attribute='__init__', + return_value=None, + ) + field_template_service_create_mock = mocker.patch( + 'src.processes.services.templates.field_template.' + 'FieldTemplateService.create', + return_value=create_return, + ) + field_template_service_partial_update_mock = mocker.patch( + 'src.processes.services.templates.field_template.' + 'FieldTemplateService.partial_update', + ) + + # act + service._update_fields(fields_data=fields_data) + + # assert + field_template_service_init_mock.assert_called_once_with( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + field_template_service_create_mock.assert_called_once_with( + fieldset_id=fieldset.id, + template_id=template.id, + name='New Field', + type='number', + order=1, + ) + field_template_service_partial_update_mock.assert_not_called() + + +def test__update_fields__orphan_fields__deleted(mocker): + + """ + Orphan fields deleted + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + field_1 = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Field 1', + type='string', + order=1, + ) + field_2 = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Field 2', + type='string', + order=2, + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + fields_data = [{'api_name': field_1.api_name, 'name': 'Updated Field 1'}] + + # mock + field_template_service_init_mock = mocker.patch.object( + FieldTemplateService, + attribute='__init__', + return_value=None, + ) + field_template_service_update_mock = mocker.patch( + 'src.processes.services.templates.field_template.' + 'FieldTemplateService.partial_update', + ) + + # act + service._update_fields(fields_data=fields_data) + + # assert + field_template_service_init_mock.assert_called_once() + field_template_service_update_mock.assert_called_once() + assert not FieldTemplate.objects.filter( + id=field_2.id, + ).exists() + assert FieldTemplate.objects.filter(id=field_1.id).exists() + + +def test__validate_rules__with_rules__ok(mocker): + + """ + Call with rules + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + rule_1 = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # mock + fieldset_template_rule_service_init_mock = mocker.patch.object( + FieldsetTemplateRuleService, + attribute='__init__', + return_value=None, + ) + fieldset_template_rule_service_validate_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset_rule.' + 'FieldsetTemplateRuleService._validate', + ) + + # act + service._validate_rules() + + # assert + fieldset_template_rule_service_init_mock.assert_called_once_with( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule_1, + ) + fieldset_template_rule_service_validate_mock.assert_called_once_with() + + +def test_update_rules__existing_rule__ok(mocker): + + """ + Update existing rule + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + rule_1 = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + rules_data = [{'api_name': rule_1.api_name, 'value': '200'}] + + # mock + fieldset_template_rule_service_init_mock = mocker.patch.object( + FieldsetTemplateRuleService, + attribute='__init__', + return_value=None, + ) + fieldset_template_rule_service_partial_update_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset_rule.' + 'FieldsetTemplateRuleService.partial_update', + ) + fieldset_template_rule_service_create_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset_rule.' + 'FieldsetTemplateRuleService.create', + ) + + # act + service.update_rules(rules_data=rules_data) + + # assert + fieldset_template_rule_service_init_mock.assert_called_once_with( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule_1, + ) + fs_rule_update_mock = ( + fieldset_template_rule_service_partial_update_mock + ) + fs_rule_update_mock.assert_called_once_with( + value='200', + ) + fieldset_template_rule_service_create_mock.assert_not_called() + + +def test_update_rules__new_rule__ok(mocker): + + """ + Create new rule + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + rules_data = [ + {'type': FieldSetRuleType.SUM_EQUAL, 'value': '100'}, + ] + + # mock + create_return = mocker.Mock() + create_return.api_name = 'new-rule-api' + fs_rule_init_mock = mocker.patch.object( + FieldsetTemplateRuleService, + attribute='__init__', + return_value=None, + ) + fs_rule_create_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset_rule.' + 'FieldsetTemplateRuleService.create', + return_value=create_return, + ) + fs_rule_update_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset_rule.' + 'FieldsetTemplateRuleService.partial_update', + ) + + # act + service.update_rules(rules_data=rules_data) + + # assert + fs_rule_init_mock.assert_called_once_with( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + fs_rule_create_mock.assert_called_once_with( + fieldset_id=fieldset.id, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ) + fs_rule_update_mock.assert_not_called() + + +def test_update_rules__orphan_rules__deleted(mocker): + + """ + Orphan rules deleted + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + rule_1 = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ) + rule_2 = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='200', + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + rules_data = [{'api_name': rule_1.api_name, 'value': '150'}] + + # mock + fs_rule_init_mock = mocker.patch.object( + FieldsetTemplateRuleService, + attribute='__init__', + return_value=None, + ) + fs_rule_update_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset_rule.' + 'FieldsetTemplateRuleService.partial_update', + ) + + # act + service.update_rules(rules_data=rules_data) + + # assert + fs_rule_init_mock.assert_called_once() + fs_rule_update_mock.assert_called_once() + assert not FieldsetTemplateRule.objects.filter( + id=rule_2.id, + ).exists() + assert FieldsetTemplateRule.objects.filter( + id=rule_1.id, + ).exists() + + +def test_update_rules__two_rules_same_type_no_api_name__create_new_rule(): + + """ + Two rules with the same type in rules_data: + one existing (with api_name) and one new (without api_name). + Both rules must be persisted after update_rules completes. + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + field_1 = fieldset.fields.first() + field_2 = FieldTemplate.objects.create( + account=account, + name='Field 2', + type=FieldType.NUMBER, + api_name='f2', + fieldset=fieldset, + ) + existing_rule = fieldset.rules.first() + existing_rule.fields.add(field_1) + existing_rule.fields.add(field_2) + + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + rules_data = [ + { + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '1', + 'fields': [field_1.api_name, field_2.api_name], + 'api_name': existing_rule.api_name, + }, + { + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '100', + 'fields': [field_1.api_name, field_2.api_name], + }, + ] + + # act + service.update_rules(rules_data=rules_data) + + # assert + assert fieldset.rules.count() == 2 + existing_rule.refresh_from_db() + assert existing_rule.value == '1' + assert existing_rule.type == FieldSetRuleType.SUM_EQUAL + assert existing_rule.fields.count() == 2 + assert existing_rule.fields.get(id=field_1.id) + assert existing_rule.fields.get(id=field_2.id) + + new_rule = fieldset.rules.exclude( + fieldset=fieldset, + api_name=existing_rule.api_name, + ).get() + assert new_rule.type == FieldSetRuleType.SUM_EQUAL + assert new_rule.value == '100' + assert new_rule.fields.count() == 2 + assert new_rule.fields.get(id=field_1.id) + assert new_rule.fields.get(id=field_2.id) + + +def test_partial_update_name__ok(mocker): + + """Call `partial_update` with default parameters""" + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + template = create_test_template(user=owner, tasks_count=1) + template.tasks.get(number=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + ) + mock_update_fields = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._update_fields', + ) + mock_update_rules = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService.update_rules', + ) + mock_validate_rules = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._validate_rules', + ) + service = FieldSetTemplateService(instance=fieldset, user=owner) + data = {"name": 'Updated Name'} + + # act + result = service.partial_update(**data) + + # assert + assert result.name == data['name'] + mock_update_fields.assert_not_called() + mock_update_rules.assert_not_called() + mock_validate_rules.assert_called_once_with() + + +def test_partial_update_fields_ok(mocker): + """Verify fields update logic for existing and new fields""" + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + template = create_test_template(user=owner, tasks_count=1) + template.tasks.get(number=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + ) + service = FieldSetTemplateService(user=owner, instance=fieldset) + + mock_update_fields = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._update_fields', + ) + mock_update_rules = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService.update_rules', + ) + mock_validate_rules = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._validate_rules', + ) + mock_super_partial_update = mocker.patch( + 'src.generics.base.service.' + 'BaseModelService.partial_update', + ) + data = { + "fields": [ + {"api_name": "field_1", "value": "val"}, + ], + } + + # act + service.partial_update(**data) + + # assert + mock_super_partial_update.assert_not_called() + mock_update_fields.assert_called_once_with(fields_data=data['fields']) + mock_update_rules.assert_not_called() + mock_validate_rules.assert_called_once_with() + + +def test_partial_update__rules__ok(mocker): + """Verify rules update logic for existing and new rules""" + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + template = create_test_template(user=owner, tasks_count=1) + template.tasks.get(number=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + ) + + mock_super_partial_update = mocker.patch( + 'src.generics.base.service.' + 'BaseModelService.partial_update', + ) + mock_update_fields = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._update_fields', + ) + mock_update_rules = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService.update_rules', + ) + mock_validate_rules = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._validate_rules', + ) + service = FieldSetTemplateService(user=owner, instance=fieldset) + data = { + 'rules': [ + {"api_name": "rule_1", "condition": "eq"}, + ], + } + + # act + result = service.partial_update(**data) + + # assert + assert result is fieldset + mock_super_partial_update.assert_not_called() + mock_update_fields.assert_not_called() + mock_update_rules.assert_called_once_with(rules_data=data['rules']) + mock_validate_rules.assert_called_once_with() + + +def test_delete__not_in_use__ok(): + + """ + Not in use → deleted + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # act + service.delete() + + # assert + assert not FieldsetTemplate.objects.filter(id=fieldset.id).exists() + + +def test_delete__used_by_kickoff_deleted_record__ok(): + + """ + Fieldset previously linked to kickoff but now cleared → deleted + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + kickoff = template.kickoff_instance + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + kickoff=kickoff, + ) + fieldset.kickoff = None + fieldset.save(update_fields=['kickoff']) + fieldset.refresh_from_db() + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # act + service.delete() + + # assert + assert not FieldsetTemplate.objects.filter(id=fieldset.id).exists() + + +def test_delete__used_by_task_deleted_record__ok(): + + """ + Fieldset previously linked to task but now cleared → deleted + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + task = template.tasks.get(number=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + task=task, + ) + fieldset.task = None + fieldset.save(update_fields=['task']) + fieldset.refresh_from_db() + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # act + service.delete() + + # assert + assert not FieldsetTemplate.objects.filter(id=fieldset.id).exists() + + +def test_delete__used_by_kickoff__raise_exception(): + + """ + In use by kickoff (kickoff_id is set) → exception + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + kickoff = template.kickoff_instance + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + kickoff=kickoff, + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # act + with pytest.raises(FieldsetTemplateInUseException) as ex: + service.delete() + + # assert + assert ex.value.message == fs_messages.MSG_FS_0001 + assert FieldsetTemplate.objects.filter(id=fieldset.id).exists() + + +def test_delete__used_by_task__raise_exception(): + + """ + In use by task → exception + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + task_template = template.tasks.first() + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + task=task_template, + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # act + with pytest.raises(FieldsetTemplateInUseException) as ex: + service.delete() + + # assert + assert ex.value.message == fs_messages.MSG_FS_0001 + assert FieldsetTemplate.objects.filter(id=fieldset.id).exists() + + +def test_create_shared_fieldset__ok(): + + """ + Default params + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + name = 'My Shared Fieldset' + + # act + result = service.create_shared_fieldset(name=name) + + # assert + assert result.name == name + assert result.is_shared is True + assert result.title == '' + assert result.description == '' + assert result.label_position == LabelPosition.TOP + assert result.layout == FieldSetLayout.VERTICAL + assert result.api_name + assert result.template_id is None + + +def test__create_shared_fieldset__all_params__ok(): + + """ + All params provided + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + name = 'Custom Fieldset' + title = 'Custom Title' + description = 'Custom description' + api_name = 'fs-custom' + label_position = LabelPosition.LEFT + layout = FieldSetLayout.HORIZONTAL + + # act + result = service.create_shared_fieldset( + name=name, + title=title, + description=description, + api_name=api_name, + label_position=label_position, + layout=layout, + ) + + # assert + assert result.name == name + assert result.title == title + assert result.description == description + assert result.api_name == api_name + assert result.label_position == label_position + assert result.layout == layout + assert result.is_shared is True + + +def test__replace_api_names__fields_and_rules__ok(mocker): + + """ + Default params, fields and rules present + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + old_field_api = 'old-field-1' + shared_fieldset_data = { + 'api_name': 'old-fs', + 'fields': [{'api_name': old_field_api, 'name': 'F 1'}], + 'rules': [{'api_name': 'old-rule-1', 'fields': [old_field_api]}], + } + new_fs_api = 'new-fs-1' + new_field_api = 'new-field-1' + new_rule_api = 'new-rule-1' + create_api_name_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.create_api_name', + side_effect=[new_fs_api, new_field_api, new_rule_api], + ) + + # act + result = service._replace_api_names( + shared_fieldset_data=shared_fieldset_data, + ) + + # assert + assert result['api_name'] == new_fs_api + assert result['fields'][0]['api_name'] == new_field_api + assert result['rules'][0]['api_name'] == new_rule_api + assert result['rules'][0]['fields'][0] == new_field_api + assert create_api_name_mock.call_count == 3 + create_api_name_mock.assert_has_calls( + [ + mocker.call(FieldsetTemplate.api_name_prefix), + mocker.call(FieldTemplate.api_name_prefix), + mocker.call(FieldsetTemplateRule.api_name_prefix), + ], + any_order=True, + ) + + +def test__replace_api_names__no_fields_key__ok(mocker): + + """ + No fields key + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + shared_fieldset_data = {'api_name': 'old-fs'} + create_api_name_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.create_api_name', + return_value='new-fs-1', + ) + + # act + result = service._replace_api_names( + shared_fieldset_data=shared_fieldset_data, + ) + + # assert + assert result['fields'] == [] + create_api_name_mock.assert_called_once_with( + FieldsetTemplate.api_name_prefix, + ) + + +def test__replace_api_names__empty_fields__ok(mocker): + + """ + Fields is empty list + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + shared_fieldset_data = {'api_name': 'old-fs', 'fields': []} + create_api_name_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.create_api_name', + return_value='new-fs-1', + ) + + # act + result = service._replace_api_names( + shared_fieldset_data=shared_fieldset_data, + ) + + # assert + assert result['fields'] == [] + create_api_name_mock.assert_called_once_with( + FieldsetTemplate.api_name_prefix, + ) + + +def test__replace_api_names__no_rules_key__ok(mocker): + + """ + No rules key + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + shared_fieldset_data = {'api_name': 'old-fs', 'fields': []} + create_api_name_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.create_api_name', + return_value='new-fs-1', + ) + + # act + result = service._replace_api_names( + shared_fieldset_data=shared_fieldset_data, + ) + + # assert + assert result['rules'] == [] + create_api_name_mock.assert_called_once_with( + FieldsetTemplate.api_name_prefix, + ) + + +def test__replace_api_names__empty_rules__ok(mocker): + + """ + Rules is empty list + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + shared_fieldset_data = {'api_name': 'old-fs', 'rules': []} + create_api_name_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.create_api_name', + return_value='new-fs-1', + ) + + # act + result = service._replace_api_names( + shared_fieldset_data=shared_fieldset_data, + ) + + # assert + assert result['rules'] == [] + create_api_name_mock.assert_called_once_with( + FieldsetTemplate.api_name_prefix, + ) + + +def test__replace_api_names__original_not_mutated__ok(mocker): + + """ + Original dict not mutated + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + old_field_api = 'old-field-1' + shared_fieldset_data = { + 'api_name': 'old-fs', + 'fields': [{'api_name': old_field_api, 'name': 'F 1'}], + 'rules': [], + } + create_api_name_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.create_api_name', + side_effect=['new-fs', 'new-field'], + ) + + # act + service._replace_api_names(shared_fieldset_data=shared_fieldset_data) + + # assert + assert shared_fieldset_data['api_name'] == 'old-fs' + assert shared_fieldset_data['fields'][0]['api_name'] == old_field_api + assert create_api_name_mock.call_count == 2 + + +def test__get_new_fieldset_data__default_params__ok(mocker): + + """ + Default params + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + shared_fieldset_data = {'api_name': 'old-fs'} + replace_api_names_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._replace_api_names', + return_value={'api_name': 'mocked-api', 'order': 3}, + ) + + # act + result = service.get_new_fieldset_data( + shared_fieldset_data=shared_fieldset_data, + ) + + # assert + assert 'order' not in result + replace_api_names_mock.assert_called_once_with(shared_fieldset_data) + + +def test__get_new_fieldset_data__api_name_provided__ok(mocker): + + """ + api_name provided + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + shared_fieldset_data = {'api_name': 'old-fs'} + override_api_name = 'custom-api' + replace_api_names_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._replace_api_names', + return_value={'api_name': 'mocked-api'}, + ) + + # act + result = service.get_new_fieldset_data( + shared_fieldset_data=shared_fieldset_data, + api_name=override_api_name, + ) + + # assert + assert result['api_name'] == override_api_name + replace_api_names_mock.assert_called_once_with(shared_fieldset_data) + + +def test__get_new_fieldset_data__api_name_omitted__ok(mocker): + + """ + api_name omitted + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + shared_fieldset_data = {'api_name': 'old-fs'} + mocked_api_name = 'mocked-api' + replace_api_names_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._replace_api_names', + return_value={'api_name': mocked_api_name}, + ) + + # act + result = service.get_new_fieldset_data( + shared_fieldset_data=shared_fieldset_data, + ) + + # assert + assert result['api_name'] == mocked_api_name + replace_api_names_mock.assert_called_once_with(shared_fieldset_data) + + +def test__get_new_fieldset_data__title_provided__ok(mocker): + + """ + title provided + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + shared_fieldset_data = {'api_name': 'old-fs'} + override_title = 'Custom Title' + replace_api_names_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._replace_api_names', + return_value={'api_name': 'api', 'title': 'Original'}, + ) + + # act + result = service.get_new_fieldset_data( + shared_fieldset_data=shared_fieldset_data, + title=override_title, + ) + + # assert + assert result['title'] == override_title + replace_api_names_mock.assert_called_once_with(shared_fieldset_data) + + +def test__get_new_fieldset_data__title_omitted__ok(mocker): + + """ + title omitted + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + shared_fieldset_data = {'api_name': 'old-fs'} + original_title = 'Original Title' + replace_api_names_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._replace_api_names', + return_value={'api_name': 'api', 'title': original_title}, + ) + + # act + result = service.get_new_fieldset_data( + shared_fieldset_data=shared_fieldset_data, + ) + + # assert + assert result['title'] == original_title + replace_api_names_mock.assert_called_once_with(shared_fieldset_data) + + +def test__get_new_fieldset_data__description_provided__ok(mocker): + + """ + description provided + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + shared_fieldset_data = {'api_name': 'old-fs'} + override_description = 'New description' + replace_api_names_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._replace_api_names', + return_value={'api_name': 'api', 'description': 'Old desc'}, + ) + + # act + result = service.get_new_fieldset_data( + shared_fieldset_data=shared_fieldset_data, + description=override_description, + ) + + # assert + assert result['description'] == override_description + replace_api_names_mock.assert_called_once_with(shared_fieldset_data) + + +def test__get_new_fieldset_data__description_omitted__ok(mocker): + + """ + description omitted + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + shared_fieldset_data = {'api_name': 'old-fs'} + original_description = 'Original description' + replace_api_names_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._replace_api_names', + return_value={'api_name': 'api', 'description': original_description}, + ) + + # act + result = service.get_new_fieldset_data( + shared_fieldset_data=shared_fieldset_data, + ) + + # assert + assert result['description'] == original_description + replace_api_names_mock.assert_called_once_with(shared_fieldset_data) + + +def test__get_new_fieldset_data__order_present__removed(mocker): + + """ + order present in data + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + shared_fieldset_data = {'api_name': 'old-fs', 'order': 5} + replace_api_names_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._replace_api_names', + return_value={'api_name': 'api', 'order': 5}, + ) + + # act + result = service.get_new_fieldset_data( + shared_fieldset_data=shared_fieldset_data, + ) + + # assert + assert 'order' not in result + replace_api_names_mock.assert_called_once_with(shared_fieldset_data) + + +def test__get_new_fieldset_data__no_order__ok(mocker): + + """ + order absent in data + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + shared_fieldset_data = {'api_name': 'old-fs'} + replace_api_names_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService._replace_api_names', + return_value={'api_name': 'api'}, + ) + + # act + result = service.get_new_fieldset_data( + shared_fieldset_data=shared_fieldset_data, + ) + + # assert + assert 'order' not in result + replace_api_names_mock.assert_called_once_with(shared_fieldset_data) + + +def test__create_from_shared__default_params__ok(mocker): + + """ + Default optional params + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + shared_fieldset_data = {'api_name': 'old-fs', 'name': 'Fieldset'} + shared_fieldset_id = 42 + fieldset_data_from_mock = {'api_name': 'new-fs', 'name': 'Fieldset'} + get_new_fieldset_data_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService.get_new_fieldset_data', + return_value=fieldset_data_from_mock, + ) + create_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService.create', + return_value=mocker.Mock(), + ) + + # act + result = service.create_from_shared( + shared_fieldset_data=shared_fieldset_data, + shared_fieldset_id=shared_fieldset_id, + template_id=template.id, + ) + + # assert + assert result is create_mock.return_value + get_new_fieldset_data_mock.assert_called_once_with( + shared_fieldset_data=shared_fieldset_data, + api_name=None, + title=None, + description=None, + ) + create_mock.assert_called_once_with( + api_name='new-fs', + name='Fieldset', + is_shared=False, + shared_fieldset_id=shared_fieldset_id, + order=0, + kickoff_id=None, + task_id=None, + template_id=template.id, + ) + + +def test__create_from_shared__all_params__ok(mocker): + + """ + All params provided + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + kickoff = template.kickoff_instance + task = template.tasks.get(number=1) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + shared_fieldset_data = {'api_name': 'old-fs', 'name': 'Fieldset'} + shared_fieldset_id = 10 + api_name = 'custom-api' + title = 'Custom Title' + description = 'Custom desc' + order = 3 + fieldset_data_from_mock = {'api_name': api_name, 'name': 'Fieldset'} + get_new_fieldset_data_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService.get_new_fieldset_data', + return_value=fieldset_data_from_mock, + ) + create_mock = mocker.patch( + 'src.processes.services.fieldsets.fieldset.' + 'FieldSetTemplateService.create', + return_value=mocker.Mock(), + ) + + # act + result = service.create_from_shared( + shared_fieldset_data=shared_fieldset_data, + shared_fieldset_id=shared_fieldset_id, + template_id=template.id, + order=order, + kickoff_id=kickoff.id, + task_id=task.id, + api_name=api_name, + title=title, + description=description, + ) + + # assert + assert result is create_mock.return_value + get_new_fieldset_data_mock.assert_called_once_with( + shared_fieldset_data=shared_fieldset_data, + api_name=api_name, + title=title, + description=description, + ) + create_mock.assert_called_once_with( + api_name=api_name, + name='Fieldset', + is_shared=False, + shared_fieldset_id=shared_fieldset_id, + order=order, + kickoff_id=kickoff.id, + task_id=task.id, + template_id=template.id, + ) + + +def test__partial_update_instance__no_kwargs__ok(): + + """ + No kwargs + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + ) + original_name = fieldset.name + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # act + result = service.partial_update_instance() + + # assert + assert result is fieldset + fieldset.refresh_from_db() + assert fieldset.name == original_name + + +def test__partial_update_instance__kwargs_provided__ok(): + + """ + kwargs provided + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + ) + new_name = 'Updated Fieldset Name' + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # act + result = service.partial_update_instance(name=new_name) + + # assert + assert result is not None + fieldset.refresh_from_db() + assert fieldset.name == new_name + + +def test__to_json__is_shared__ok(mocker): + + """ + is_shared=True + """ + + # arrange + account = create_test_account() + fieldset = FieldsetTemplate.objects.create( + account=account, + name='Shared FS', + is_shared=True, + ) + serializer_data = {'id': fieldset.id, 'name': 'Shared FS'} + shared_fs_slz_mock = mocker.patch( + 'src.processes.serializers.templates.fieldset.' + 'SharedFieldsetTemplateSerializer', + ) + shared_fs_slz_mock.return_value.data = serializer_data + + # act + result = FieldSetTemplateService.to_json(fieldset=fieldset) + + # assert + assert result == serializer_data + shared_fs_slz_mock.assert_called_once_with(fieldset) + + +def test__to_json__not_shared__ok(mocker): + + """ + is_shared=False + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + ) + serializer_data = {'id': fieldset.id, 'name': fieldset.name} + fieldset_template_serializer_mock = mocker.patch( + 'src.processes.serializers.templates.fieldset.' + 'FieldsetTemplateSerializer', + ) + fieldset_template_serializer_mock.return_value.data = serializer_data + + # act + result = FieldSetTemplateService.to_json(fieldset=fieldset) + + # assert + assert result == serializer_data + fieldset_template_serializer_mock.assert_called_once_with(fieldset) diff --git a/backend/src/processes/tests/test_services/test_tasks/test_task_service.py b/backend/src/processes/tests/test_services/test_tasks/test_task_service.py index 206a35ecf..379973062 100644 --- a/backend/src/processes/tests/test_services/test_tasks/test_task_service.py +++ b/backend/src/processes/tests/test_services/test_tasks/test_task_service.py @@ -13,11 +13,22 @@ ChecklistTemplate, ChecklistTemplateSelection, ) +from src.processes.models.templates.fields import FieldTemplate +from src.processes.models.templates.raw_due_date import RawDueDateTemplate from src.processes.models.workflows.fields import TaskField from src.processes.models.workflows.raw_due_date import RawDueDate +from src.processes.models.workflows.task import Delay from src.authentication.enums import AuthTokenType +from src.processes.services.tasks.checklist import ChecklistService +from src.processes.services.tasks.field import TaskFieldService from src.processes.services.tasks.task import TaskService +from src.processes.services.workflows.fieldsets.fieldset import FieldSetService from src.processes.tests.fixtures import ( + create_checklist_template, + create_test_account, + create_test_admin, + create_test_fieldset_template, + create_test_owner, create_test_template, create_test_user, create_test_workflow, @@ -1242,3 +1253,877 @@ def test_insert_fields_values__no_checklists__skip(mocker): markdown_clear_mock.assert_called_once_with(new_description) checklist_service_init_mock.assert_not_called() save_mock.assert_called_once_with() + + +def test_partial_update__default__ok(mocker): + + """ + Default call + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + markdown_clear_mock = mocker.patch( + 'src.processes.services.tasks.task.MarkdownService.clear', + ) + save_mock = mocker.patch( + 'src.processes.services.tasks.task.TaskService.save', + ) + service = TaskService(user=user, instance=task) + + # act + service.partial_update() + + # assert + markdown_clear_mock.assert_not_called() + save_mock.assert_not_called() + + +def test_partial_update__with_desc__clear_desc_set(mocker): + + """ + With description + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + description = 'New description' + clear_description = 'New clear description' + markdown_clear_mock = mocker.patch( + 'src.processes.services.tasks.task.MarkdownService.clear', + return_value=clear_description, + ) + save_mock = mocker.patch( + 'src.processes.services.tasks.task.TaskService.save', + ) + service = TaskService(user=user, instance=task) + + # act + service.partial_update(description=description) + + # assert + assert task.description == description + assert task.clear_description == clear_description + markdown_clear_mock.assert_called_once_with(description) + save_mock.assert_not_called() + + +def test_partial_update__no_desc__clear_desc_skip(mocker): + + """ + Without description + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + original_clear_description = task.clear_description + markdown_clear_mock = mocker.patch( + 'src.processes.services.tasks.task.MarkdownService.clear', + ) + save_mock = mocker.patch( + 'src.processes.services.tasks.task.TaskService.save', + ) + service = TaskService(user=user, instance=task) + + # act + service.partial_update(name='New name') + + # assert + assert task.name == 'New name' + assert task.clear_description == original_clear_description + markdown_clear_mock.assert_not_called() + save_mock.assert_not_called() + + +def test_partial_update__date_started_first__set(mocker): + + """ + date_started, first start + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + task.date_first_started = None + task.save(update_fields=['date_first_started']) + date_started = timezone.now() + markdown_clear_mock = mocker.patch( + 'src.processes.services.tasks.task.MarkdownService.clear', + ) + save_mock = mocker.patch( + 'src.processes.services.tasks.task.TaskService.save', + ) + service = TaskService(user=user, instance=task) + + # act + service.partial_update(date_started=date_started) + + # assert + assert task.date_first_started == date_started + assert 'date_first_started' in service.update_fields + markdown_clear_mock.assert_not_called() + save_mock.assert_not_called() + + +def test_partial_update__date_started_already__skip(mocker): + + """ + date_started, already started + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + original_date_first_started = timezone.now() - timedelta(hours=1) + task.date_first_started = original_date_first_started + task.save(update_fields=['date_first_started']) + date_started = timezone.now() + markdown_clear_mock = mocker.patch( + 'src.processes.services.tasks.task.MarkdownService.clear', + ) + save_mock = mocker.patch( + 'src.processes.services.tasks.task.TaskService.save', + ) + service = TaskService(user=user, instance=task) + + # act + service.partial_update(date_started=date_started) + + # assert + assert task.date_first_started == original_date_first_started + assert 'date_first_started' not in service.update_fields + markdown_clear_mock.assert_not_called() + save_mock.assert_not_called() + + +def test_partial_update__force_save__saved(mocker): + + """ + force_save True + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + markdown_clear_mock = mocker.patch( + 'src.processes.services.tasks.task.MarkdownService.clear', + ) + save_mock = mocker.patch( + 'src.processes.services.tasks.task.TaskService.save', + ) + service = TaskService(user=user, instance=task) + + # act + service.partial_update(force_save=True, name='Updated') + + # assert + assert task.name == 'Updated' + markdown_clear_mock.assert_not_called() + save_mock.assert_called_once_with() + + +def test_create_raw_performers_from_template__default__ok(mocker): + + """ + Default call + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + add_raw_performer_mock = mocker.patch( + 'src.processes.models.workflows.task.Task.add_raw_performer', + ) + update_raw_performers_mock = mocker.patch( + 'src.processes.models.workflows.task.Task' + '.update_raw_performers_from_task_template', + ) + service = TaskService(user=user, instance=task) + + # act + service.create_raw_performers_from_template( + instance_template=template_task, + ) + + # assert + add_raw_performer_mock.assert_not_called() + update_raw_performers_mock.assert_called_once_with(template_task) + + +def test_create_raw_performers_from_template__redefined__ok(mocker): + + """ + With redefined_performer + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + redefined_performer = create_test_admin(account=account) + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + add_raw_performer_mock = mocker.patch( + 'src.processes.models.workflows.task.Task.add_raw_performer', + ) + update_raw_performers_mock = mocker.patch( + 'src.processes.models.workflows.task.Task' + '.update_raw_performers_from_task_template', + ) + service = TaskService(user=user, instance=task) + + # act + service.create_raw_performers_from_template( + instance_template=template_task, + redefined_performer=redefined_performer, + ) + + # assert + add_raw_performer_mock.assert_called_once_with( + user=redefined_performer, + ) + update_raw_performers_mock.assert_not_called() + + +def test_create_fields_from_template__no_fields__ok(mocker): + + """ + Default call, no fields + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + task_field_service_init_mock = mocker.patch.object( + TaskFieldService, + '__init__', + return_value=None, + ) + service = TaskService(user=user, instance=task) + + # act + service.create_fields_from_template(instance_template=template_task) + + # assert + task_field_service_init_mock.assert_not_called() + + +def test_create_fields_from_template__outside_fs__ok(mocker): + + """ + Fields outside fieldsets + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + field_template_1 = FieldTemplate.objects.create( + name='Field 1', + type=FieldType.STRING, + task=template_task, + template=template, + order=1, + api_name='field-1', + account=user.account, + ) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + task_field_service_init_mock = mocker.patch.object( + TaskFieldService, + '__init__', + return_value=None, + ) + task_field_service_create_mock = mocker.patch( + 'src.processes.services.tasks.field.TaskFieldService.create', + ) + service = TaskService(user=user, instance=task) + + # act + service.create_fields_from_template(instance_template=template_task) + + # assert + task_field_service_init_mock.assert_called_once_with(user=user) + task_field_service_create_mock.assert_called_once_with( + instance_template=field_template_1, + workflow_id=task.workflow_id, + task_id=task.id, + skip_value=True, + ) + + +def test_create_fields_from_template__in_fieldsets__skip(mocker): + + """ + Fields inside fieldsets + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + create_test_fieldset_template( + account=user.account, + template=template, + task=template_task, + ) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + task_field_service_init_mock = mocker.patch.object( + TaskFieldService, + '__init__', + return_value=None, + ) + service = TaskService(user=user, instance=task) + + # act + service.create_fields_from_template(instance_template=template_task) + + # assert + task_field_service_init_mock.assert_not_called() + + +def test_create_fieldsets_from_template__no_fieldsets__ok(mocker): + + """ + Default call, no fieldsets + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + field_set_service_init_mock = mocker.patch.object( + FieldSetService, + '__init__', + return_value=None, + ) + service = TaskService(user=user, instance=task) + + # act + service.create_fieldsets_from_template(instance_template=template_task) + + # assert + field_set_service_init_mock.assert_not_called() + + +def test_create_fieldsets_from_template__with_fieldsets__ok(mocker): + + """ + With fieldsets + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + fieldset_template = create_test_fieldset_template( + account=user.account, + template=template, + task=template_task, + order=5, + ) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + field_set_service_init_mock = mocker.patch.object( + FieldSetService, + '__init__', + return_value=None, + ) + field_set_service_create_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset' + '.FieldSetService.create', + ) + service = TaskService(user=user, instance=task) + + # act + service.create_fieldsets_from_template(instance_template=template_task) + + # assert + field_set_service_init_mock.assert_called_once_with(user=user) + field_set_service_create_mock.assert_called_once_with( + instance_template=fieldset_template, + account_id=task.workflow.account_id, + workflow=task.workflow, + task=task, + order=5, + skip_value=True, + ) + + +def test_create_conditions_from_template__no_conditions__ok(mocker): + + """ + Default call, no conditions + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + + # remove default conditions + template_task.conditions.all().delete() + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + create_rules_mock = mocker.patch( + 'src.processes.services.tasks.mixins.ConditionMixin.create_rules', + ) + service = TaskService(user=user, instance=task) + + # act + service.create_conditions_from_template( + instance_template=template_task, + ) + + # assert + create_rules_mock.assert_not_called() + + +def test_create_conditions_from_template__with_conditions__ok(mocker): + + """ + With conditions + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=2) + template_task = template.tasks.get(number=2) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=2) + create_rules_mock = mocker.patch( + 'src.processes.services.tasks.mixins.ConditionMixin.create_rules', + ) + service = TaskService(user=user, instance=task) + + # act + service.create_conditions_from_template( + instance_template=template_task, + ) + + # assert + create_rules_mock.assert_called_once_with( + mocker.ANY, + mocker.ANY, + ) + + +def test_create_checklists_from_template__no_checklists__ok(mocker): + + """ + Default call, no checklists + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + checklist_service_init_mock = mocker.patch.object( + ChecklistService, + '__init__', + return_value=None, + ) + service = TaskService(user=user, instance=task) + + # act + service.create_checklists_from_template( + instance_template=template_task, + ) + + # assert + checklist_service_init_mock.assert_not_called() + + +def test_create_checklists_from_template__with_checklists__ok(mocker): + + """ + With checklists + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + checklist_template_1 = create_checklist_template( + task_template=template_task, + selections_count=1, + ) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + checklist_service_init_mock = mocker.patch.object( + ChecklistService, + '__init__', + return_value=None, + ) + checklist_service_create_mock = mocker.patch( + 'src.processes.services.tasks.checklist.ChecklistService.create', + ) + service = TaskService(user=user, instance=task) + + # act + service.create_checklists_from_template( + instance_template=template_task, + ) + + # assert + checklist_service_init_mock.assert_called_once_with( + user=user, + is_superuser=service.is_superuser, + auth_type=service.auth_type, + ) + checklist_service_create_mock.assert_called_once_with( + instance_template=checklist_template_1, + task=task, + ) + + +def test_create_raw_due_date_from_template__no_raw__ok(): + + """ + Default call, no raw_due_date + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + service = TaskService(user=user, instance=task) + + # act + service.create_raw_due_date_from_template( + instance_template=template_task, + ) + + # assert + assert not RawDueDate.objects.filter(task=task).exists() + + +def test_create_raw_due_date_from_template__with_raw__ok(): + + """ + With raw_due_date + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + duration = timedelta(hours=2) + duration_months = 1 + RawDueDateTemplate.objects.create( + task=template_task, + template=template, + duration=duration, + duration_months=duration_months, + rule=DueDateRule.AFTER_WORKFLOW_STARTED, + source_id=None, + ) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + + # remove any raw_due_date created by workflow fixture + RawDueDate.objects.filter(task=task).delete() + service = TaskService(user=user, instance=task) + + # act + service.create_raw_due_date_from_template( + instance_template=template_task, + ) + + # assert + raw_due_date = RawDueDate.objects.get(task=task) + assert raw_due_date.duration == duration + assert raw_due_date.duration_months == duration_months + assert raw_due_date.rule == DueDateRule.AFTER_WORKFLOW_STARTED + assert raw_due_date.source_id is None + + +def test_get_task_due_date__after_started_same__none(): + + """ + After task started, same, no date + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + task.date_first_started = None + task.save(update_fields=['date_first_started']) + RawDueDate.objects.create( + task=task, + duration=timedelta(days=1), + rule=DueDateRule.AFTER_TASK_STARTED, + source_id=task.api_name, + ) + service = TaskService(user=user, instance=task) + + # act + due_date = service.get_task_due_date() + + # assert + assert due_date is None + + +def test_get_task_due_date__field_no_value__none(): + + """ + Field rule, no value + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + task_2 = workflow.tasks.get(number=2) + field = TaskField.objects.create( + task=task_1, + name='date', + api_name='date-1', + type=FieldType.DATE, + value='', + workflow=workflow, + account=user.account, + ) + RawDueDate.objects.create( + task=task_2, + duration=timedelta(days=1), + rule=DueDateRule.AFTER_FIELD, + source_id=field.api_name, + ) + service = TaskService(user=user, instance=task_2) + + # act + due_date = service.get_task_due_date() + + # assert + assert due_date is None + + +def test_create_related__no_delay__ok(mocker): + + """ + Default call, no delay + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + template_task.delay = None + template_task.save(update_fields=['delay']) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + create_raw_performers_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_raw_performers_from_template', + ) + create_fields_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_fields_from_template', + ) + create_fieldsets_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_fieldsets_from_template', + ) + create_conditions_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_conditions_from_template', + ) + create_checklists_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_checklists_from_template', + ) + create_raw_due_date_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_raw_due_date_from_template', + ) + service = TaskService(user=user, instance=task) + + # act + service._create_related( + instance_template=template_task, + ) + + # assert + assert not Delay.objects.filter(task=task).exists() + create_raw_performers_mock.assert_called_once_with( + instance_template=template_task, + redefined_performer=None, + ) + create_fields_mock.assert_called_once_with(template_task) + create_fieldsets_mock.assert_called_once_with(template_task) + create_conditions_mock.assert_called_once_with(template_task) + create_checklists_mock.assert_called_once_with(template_task) + create_raw_due_date_mock.assert_called_once_with(template_task) + + +def test_create_related__with_delay__ok(mocker): + + """ + With delay + """ + + # arrange + user = create_test_owner() + template = create_test_template( + user=user, + tasks_count=1, + ) + template_task = template.tasks.get(number=1) + delay_value = timedelta(seconds=30) + template_task.delay = delay_value + template_task.save(update_fields=['delay']) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + + # remove delays created by workflow fixture + Delay.objects.filter(task=task).delete() + create_raw_performers_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_raw_performers_from_template', + ) + create_fields_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_fields_from_template', + ) + create_fieldsets_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_fieldsets_from_template', + ) + create_conditions_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_conditions_from_template', + ) + create_checklists_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_checklists_from_template', + ) + create_raw_due_date_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_raw_due_date_from_template', + ) + service = TaskService(user=user, instance=task) + + # act + service._create_related( + instance_template=template_task, + ) + + # assert + delay = Delay.objects.get(task=task) + assert delay.duration == delay_value + assert delay.workflow == workflow + create_raw_performers_mock.assert_called_once_with( + instance_template=template_task, + redefined_performer=None, + ) + create_fields_mock.assert_called_once_with(template_task) + create_fieldsets_mock.assert_called_once_with(template_task) + create_conditions_mock.assert_called_once_with(template_task) + create_checklists_mock.assert_called_once_with(template_task) + create_raw_due_date_mock.assert_called_once_with(template_task) + + +def test_set_due_date_directly__default__ok(mocker): + + """ + Default call + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + partial_update_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.partial_update', + ) + send_notifications_mock = mocker.patch( + 'src.notifications.tasks.send_due_date_changed.delay', + ) + due_date_changed_event_mock = mocker.patch( + 'src.processes.services.events.WorkflowEventService' + '.due_date_changed_event', + ) + service = TaskService(user=user, instance=task) + + # act + service.set_due_date_directly() + + # assert + partial_update_mock.assert_called_once_with( + due_date=None, + force_save=True, + ) + send_notifications_mock.assert_called_once_with( + logging=user.account.log_api_requests, + author_id=user.id, + task_id=task.id, + account_id=task.account_id, + logo_lg=user.account.logo_lg, + ) + due_date_changed_event_mock.assert_called_once_with( + task=task, + user=user, + ) + + +def test_create_fields_from_template__deleted_fieldsets__skip(mocker): + + """ + Field inside an active fieldset is excluded, + field inside a soft-deleted fieldset is created as standalone. + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + fieldset = create_test_fieldset_template( + account=user.account, + template=template, + task=template_task, + ) + fieldset.delete() + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + task_field_service_init_mock = mocker.patch.object( + TaskFieldService, + '__init__', + return_value=None, + ) + task_field_service_create_mock = mocker.patch( + 'src.processes.services.tasks.field.TaskFieldService.create', + ) + service = TaskService(user=user, instance=task) + + # act + service.create_fields_from_template(instance_template=template_task) + + # assert + task_field_service_init_mock.assert_not_called() + task_field_service_create_mock.assert_not_called() diff --git a/backend/src/processes/tests/test_services/test_tasks/test_task_version_service.py b/backend/src/processes/tests/test_services/test_tasks/test_task_version_service.py index 325c832ff..b55679202 100644 --- a/backend/src/processes/tests/test_services/test_tasks/test_task_version_service.py +++ b/backend/src/processes/tests/test_services/test_tasks/test_task_version_service.py @@ -1,6 +1,6 @@ -from datetime import timedelta - import pytest +from datetime import timedelta + from django.contrib.auth import get_user_model from django.utils import timezone @@ -13,6 +13,8 @@ PredicateOperator, WorkflowStatus, ) +from src.processes.models.workflows.fieldset import FieldSet, FieldSetRule +from src.processes.models.workflows.fields import FieldSelection, TaskField from src.processes.models.workflows.raw_due_date import RawDueDate from src.processes.models.workflows.task import ( Delay, @@ -35,8 +37,16 @@ create_test_owner, create_test_template, create_test_workflow, + create_test_fieldset, +) + +from src.processes.enums import ( + FieldSetLayout, + FieldSetRuleType, + LabelPosition, ) + UserModel = get_user_model() pytestmark = pytest.mark.django_db @@ -1833,3 +1843,916 @@ def test_update_performers__removed_group_user_already_performer__not_sent( get_data_for_list_mock.assert_not_called() send_new_task_notification_mock.assert_not_called() send_task_deleted_notification_mock.assert_not_called() + + +def test__update_field__fieldset_none__ok(): + + """ + Call with default `fieldset=None` + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + field_data = { + 'api_name': 'field-1', + 'name': 'Test Field', + 'description': 'Test description', + 'type': FieldType.STRING, + 'is_required': False, + 'is_hidden': False, + 'order': 1, + 'dataset_id': None, + } + + # act + field, created = service._update_field(field_data=field_data) + + # assert + assert created is True + assert field.api_name == 'field-1' + assert field.name == 'Test Field' + assert field.description == 'Test description' + assert field.type == FieldType.STRING + assert field.is_required is False + assert field.is_hidden is False + assert field.order == 1 + assert field.fieldset is None + assert field.task == task + assert field.workflow == workflow + assert field.account == user.account + + +def test__update_field__fieldset_provided__ok(): + + """ + Call with an explicit `fieldset` instance + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + ) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + field_data = { + 'api_name': 'field-1', + 'name': 'Test Field', + 'description': 'Test description', + 'type': FieldType.STRING, + 'is_required': False, + 'is_hidden': False, + 'order': 1, + 'dataset_id': None, + } + + # act + field, created = service._update_field( + field_data=field_data, + fieldset=fieldset, + ) + + # assert + assert created is True + assert field.api_name == 'field-1' + assert field.fieldset == fieldset + assert field.task == task + assert field.workflow == workflow + assert field.account == user.account + + +def test__update_field_selections__no_selections_key__skip(): + + """ + `field_data` has no `selections` key — `if` block is skipped + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + task_field = TaskField.objects.create( + task=task, + workflow=workflow, + account=user.account, + type=FieldType.DROPDOWN, + name='Test Field', + api_name='field-1', + order=0, + ) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + field_data = {} + + # act + service._update_field_selections(field=task_field, field_data=field_data) + + # assert + assert FieldSelection.objects.filter(field=task_field).count() == 0 + + +def test__update_field_selections__selections_empty__skip(): + + """ + `field_data['selections']` is an empty list — `if` block is skipped + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + task_field = TaskField.objects.create( + task=task, + workflow=workflow, + account=user.account, + type=FieldType.DROPDOWN, + name='Test Field', + api_name='field-1', + order=0, + ) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + field_data = {'selections': []} + + # act + service._update_field_selections(field=task_field, field_data=field_data) + + # assert + assert FieldSelection.objects.filter(field=task_field).count() == 0 + + +def test__update_field_selections__selections_exist__ok(): + + """ + `field_data['selections']` has items — `if` block executes + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + task_field = TaskField.objects.create( + task=task, + workflow=workflow, + account=user.account, + type=FieldType.DROPDOWN, + name='Test Field', + api_name='field-1', + order=0, + ) + old_selection = FieldSelection.objects.create( + field=task_field, + api_name='old-selection-1', + value='Old Value', + ) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + field_data = { + 'selections': [ + { + 'api_name': 'selection-1', + 'value': 'New Value', + }, + ], + } + + # act + service._update_field_selections(field=task_field, field_data=field_data) + + # assert + assert not FieldSelection.objects.filter(id=old_selection.id).exists() + assert FieldSelection.objects.filter( + field=task_field, + api_name='selection-1', + value='New Value', + ).exists() + + +def test__update_fieldset_rules__rules_data_none__skip(): + + """ + `rules_data=None` — treated as empty list, loop does not execute + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + ) + existing_rule = FieldSetRule.objects.create( + fieldset=fieldset, + account_id=user.account_id, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + api_name='rule-1', + ) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + + # act + service._update_fieldset_rules(fieldset=fieldset, rules_data=None) + + # assert + assert not FieldSetRule.objects.filter(id=existing_rule.id).exists() + + +def test__update_fieldset_rules__rules_data_empty__skip(): + + """ + `rules_data` is an empty list — loop does not execute + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + ) + existing_rule = FieldSetRule.objects.create( + fieldset=fieldset, + account_id=user.account_id, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + api_name='rule-1', + ) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + + # act + service._update_fieldset_rules(fieldset=fieldset, rules_data=[]) + + # assert + assert not FieldSetRule.objects.filter(id=existing_rule.id).exists() + + +def test__update_fieldset_rules__rules_data_provided__ok(): + + """ + `rules_data` has items — loop executes for each rule + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + ) + old_rule = FieldSetRule.objects.create( + fieldset=fieldset, + account_id=user.account_id, + type=FieldSetRuleType.SUM_EQUAL, + value='50', + api_name='old-rule-1', + ) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + rules_data = [ + { + 'api_name': 'rule-1', + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '100', + }, + ] + + # act + service._update_fieldset_rules(fieldset=fieldset, rules_data=rules_data) + + # assert + assert not FieldSetRule.objects.filter(id=old_rule.id).exists() + assert FieldSetRule.objects.filter( + fieldset=fieldset, + api_name='rule-1', + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ).exists() + + +def test__update_fieldset_fields__fields_data_none__skip(mocker): + + """ + `fields_data=None` — treated as empty list, loop does not execute + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + ) + old_field = TaskField.objects.create( + workflow=workflow, + account=user.account, + type=FieldType.STRING, + name='Old Field', + api_name='old-field-1', + fieldset=fieldset, + order=0, + ) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + _update_field_mock = mocker.patch( + 'src.processes.services.tasks.task_version.' + 'TaskUpdateVersionService._update_field', + ) + _update_field_selections_mock = mocker.patch( + 'src.processes.services.tasks.task_version.' + 'TaskUpdateVersionService._update_field_selections', + ) + _update_field_rules_mock = mocker.patch( + 'src.processes.services.tasks.task_version.' + 'TaskUpdateVersionService._update_field_rules', + ) + + # act + service._update_fieldset_fields(fieldset=fieldset, fields_data=None) + + # assert + assert not TaskField.objects.filter(id=old_field.id).exists() + _update_field_mock.assert_not_called() + _update_field_selections_mock.assert_not_called() + _update_field_rules_mock.assert_not_called() + + +def test__update_fieldset_fields__fields_data_empty__skip(mocker): + + """ + `fields_data` is an empty list — loop does not execute + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + ) + old_field = TaskField.objects.create( + workflow=workflow, + account=user.account, + type=FieldType.STRING, + name='Old Field', + api_name='old-field-1', + fieldset=fieldset, + order=0, + ) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + _update_field_mock = mocker.patch( + 'src.processes.services.tasks.task_version.' + 'TaskUpdateVersionService._update_field', + ) + _update_field_selections_mock = mocker.patch( + 'src.processes.services.tasks.task_version.' + 'TaskUpdateVersionService._update_field_selections', + ) + _update_field_rules_mock = mocker.patch( + 'src.processes.services.tasks.task_version.' + 'TaskUpdateVersionService._update_field_rules', + ) + + # act + service._update_fieldset_fields(fieldset=fieldset, fields_data=[]) + + # assert + assert not TaskField.objects.filter(id=old_field.id).exists() + _update_field_mock.assert_not_called() + _update_field_selections_mock.assert_not_called() + _update_field_rules_mock.assert_not_called() + + +def test__update_fieldset_fields__fields_data_provided__ok(mocker): + + """ + `fields_data` has items — loop executes for each field + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + ) + old_field = TaskField.objects.create( + workflow=workflow, + account=user.account, + type=FieldType.STRING, + name='Old Field', + api_name='old-field-1', + fieldset=fieldset, + order=0, + ) + new_field = TaskField.objects.create( + task=task, + workflow=workflow, + account=user.account, + type=FieldType.STRING, + name='New Field', + api_name='new-field-1', + fieldset=fieldset, + order=1, + ) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + fields_data = [ + { + 'api_name': 'new-field-1', + 'name': 'New Field', + 'description': '', + 'type': FieldType.STRING, + 'is_required': False, + 'is_hidden': False, + 'order': 1, + 'dataset_id': None, + }, + ] + _update_field_mock = mocker.patch( + 'src.processes.services.tasks.task_version.' + 'TaskUpdateVersionService._update_field', + return_value=(new_field, False), + ) + _update_field_selections_mock = mocker.patch( + 'src.processes.services.tasks.task_version.' + 'TaskUpdateVersionService._update_field_selections', + ) + _update_field_rules_mock = mocker.patch( + 'src.processes.services.tasks.task_version.' + 'TaskUpdateVersionService._update_field_rules', + ) + + # act + service._update_fieldset_fields(fieldset=fieldset, fields_data=fields_data) + + # assert + assert not TaskField.objects.filter(id=old_field.id).exists() + assert TaskField.objects.filter(id=new_field.id).exists() + _update_field_mock.assert_called_once_with( + fields_data[0], + fieldset=fieldset, + ) + _update_field_selections_mock.assert_called_once_with( + new_field, + fields_data[0], + ) + _update_field_rules_mock.assert_called_once_with( + new_field, + fields_data[0], + fieldset, + ) + + +def test__update_fieldsets__data_none__ok(mocker): + + """ + `data=None` — loop does not execute, all task fieldsets are deleted + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + old_fieldset = create_test_fieldset( + workflow=workflow, + task=task, + ) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + _update_fieldset_rules_mock = mocker.patch( + 'src.processes.services.tasks.task_version.' + 'TaskUpdateVersionService._update_fieldset_rules', + ) + _update_fieldset_fields_mock = mocker.patch( + 'src.processes.services.tasks.task_version.' + 'TaskUpdateVersionService._update_fieldset_fields', + ) + + # act + service._update_fieldsets(data=None) + + # assert + assert not FieldSet.objects.filter(id=old_fieldset.id).exists() + _update_fieldset_rules_mock.assert_not_called() + _update_fieldset_fields_mock.assert_not_called() + + +def test__update_fieldsets__data_empty__ok(mocker): + + """ + `data` is an empty list — loop does not execute, + all task fieldsets are deleted + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + old_fieldset = create_test_fieldset( + workflow=workflow, + task=task, + ) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + _update_fieldset_rules_mock = mocker.patch( + 'src.processes.services.tasks.task_version.' + 'TaskUpdateVersionService._update_fieldset_rules', + ) + _update_fieldset_fields_mock = mocker.patch( + 'src.processes.services.tasks.task_version.' + 'TaskUpdateVersionService._update_fieldset_fields', + ) + + # act + service._update_fieldsets(data=[]) + + # assert + assert not FieldSet.objects.filter(id=old_fieldset.id).exists() + _update_fieldset_rules_mock.assert_not_called() + _update_fieldset_fields_mock.assert_not_called() + + +def test__update_fieldsets__data_provided__ok(mocker): + + """ + `data` has items — loop executes for each fieldset + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + old_fieldset = create_test_fieldset( + workflow=workflow, + task=task_1, + ) + service = TaskUpdateVersionService( + user=user, + instance=task_1, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + data = [ + { + 'api_name': 'fieldset-1', + 'name': 'New Fieldset', + 'title': 'Test title', + 'description': 'Test description', + 'order': 2, + 'label_position': LabelPosition.TOP, + 'layout': FieldSetLayout.VERTICAL, + 'rules': [ + { + 'api_name': 'rule-1', + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '100', + }, + ], + 'fields': [ + { + 'api_name': 'field-1', + 'name': 'Test Field', + 'description': '', + 'type': FieldType.STRING, + 'is_required': False, + 'is_hidden': False, + 'order': 1, + 'dataset_id': None, + }, + ], + }, + ] + _update_fieldset_rules_mock = mocker.patch( + 'src.processes.services.tasks.task_version.' + 'TaskUpdateVersionService._update_fieldset_rules', + ) + _update_fieldset_fields_mock = mocker.patch( + 'src.processes.services.tasks.task_version.' + 'TaskUpdateVersionService._update_fieldset_fields', + ) + + # act + service._update_fieldsets(data=data) + + # assert + assert not FieldSet.objects.filter(id=old_fieldset.id).exists() + new_fieldset = FieldSet.objects.get( + api_name='fieldset-1', + task=task_1, + ) + assert new_fieldset.name == 'New Fieldset' + assert new_fieldset.description == 'Test description' + assert new_fieldset.order == 2 + _update_fieldset_rules_mock.assert_called_once_with( + fieldset=new_fieldset, + rules_data=data[0]['rules'], + ) + _update_fieldset_fields_mock.assert_called_once_with( + fieldset=new_fieldset, + fields_data=data[0]['fields'], + ) + + +def test__update_field_rules__rules_provided__ok(): + + """ + `field_data` contains a non-empty `rules` list — + matching FieldSetRule is linked to the field via M2M. + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fs-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.first() + field = fieldset.fields.first() + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + field_data = { + 'rules': [ + {'api_name': 'fs-1-rule-1'}, + ], + } + + # act + service._update_field_rules(field, field_data, fieldset) + + # assert + assert field.rules.count() == 1 + assert field.rules.filter(id=rule.id).exists() + + +def test__update_field_rules__rules_empty_list__clear(): + + """ + `field_data['rules']` is an empty list — + existing M2M relations are cleared. + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fs-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.get(api_name='fs-1-rule-1') + field = fieldset.fields.get(api_name='fs-1-field-1') + field.rules.add(rule) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + field_data = {'rules': []} + + # act + service._update_field_rules(field, field_data, fieldset) + + # assert + assert field.rules.count() == 0 + + +def test__update_field_rules__rules_key_missing__clear(): + + """ + `field_data` does not contain `rules` key — + `.get('rules', [])` returns `[]`, M2M is cleared. + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fs-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.get(api_name='fs-1-rule-1') + field = fieldset.fields.get(api_name='fs-1-field-1') + field.rules.add(rule) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + field_data = {} + + # act + service._update_field_rules(field, field_data, fieldset) + + # assert + assert field.rules.count() == 0 + + +def test__update_field_rules__multiple_rules__ok(): + + """ + `field_data` contains multiple rules — + all matching FieldSetRule instances are linked. + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fs-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule_1 = fieldset.rules.get(api_name='fs-1-rule-1') + rule_2 = FieldSetRule.objects.create( + fieldset=fieldset, + account_id=user.account_id, + type=FieldSetRuleType.SUM_EQUAL, + value='200', + api_name='fs-1-rule-2', + ) + field = fieldset.fields.get(api_name='fs-1-field-1') + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + field_data = { + 'rules': [ + {'api_name': 'fs-1-rule-1'}, + {'api_name': 'fs-1-rule-2'}, + ], + } + + # act + service._update_field_rules(field, field_data, fieldset) + + # assert + assert field.rules.count() == 2 + assert field.rules.filter(id=rule_1.id).exists() + assert field.rules.filter(id=rule_2.id).exists() + + +def test__update_field_rules__replaces_existing_rules__ok(): + + """ + Field already has a linked rule — it is replaced by the new one. + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fs-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='50', + ) + old_rule = fieldset.rules.get(api_name='fs-1-rule-1') + new_rule = FieldSetRule.objects.create( + fieldset=fieldset, + account_id=user.account_id, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + api_name='new-rule', + ) + field = fieldset.fields.get(api_name='fs-1-field-1') + field.rules.add(old_rule) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + field_data = {'rules': [{'api_name': 'new-rule'}]} + + # act + service._update_field_rules(field, field_data, fieldset) + + # assert + assert field.rules.count() == 1 + assert field.rules.filter(id=new_rule.id).exists() + assert not field.rules.filter(id=old_rule.id).exists() + + +def test__update_field_rules__nonexistent_api_name__skip(): + + """ + `api_name` in `field_data` does not match any FieldSetRule — + no rules are found, M2M is set to empty. + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fs-1', + ) + field = fieldset.fields.get(api_name='fs-1-field-1') + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + field_data = {'rules': [{'api_name': 'nonexistent-rule'}]} + + # act + service._update_field_rules(field, field_data, fieldset) + + # assert + assert field.rules.count() == 0 diff --git a/backend/src/processes/tests/test_services/test_tasks/test_taskfield.py b/backend/src/processes/tests/test_services/test_tasks/test_taskfield.py index e07d7162f..84a6c45ba 100644 --- a/backend/src/processes/tests/test_services/test_tasks/test_taskfield.py +++ b/backend/src/processes/tests/test_services/test_tasks/test_taskfield.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model from src.processes.enums import ( + FieldSetRuleType, FieldType, WorkflowEventType, ) @@ -10,8 +11,14 @@ FieldTemplate, FieldTemplateSelection, ) +from src.processes.models.templates.fieldset import ( + FieldsetTemplateRule, +) from src.processes.models.workflows.attachment import FileAttachment from src.processes.models.workflows.event import WorkflowEvent +from src.processes.models.workflows.fieldset import ( + FieldSetRule, +) from src.processes.models.workflows.fields import ( TaskField, FieldSelection, @@ -34,7 +41,10 @@ create_test_owner, create_test_template, create_test_user, - create_test_workflow, create_test_dataset, + create_test_workflow, + create_test_dataset, + create_test_fieldset_template, + create_test_fieldset, ) UserModel = get_user_model() @@ -869,6 +879,10 @@ def test__create_related__file_type_not_skip__ok(mocker): 'src.processes.services.tasks.field.' 'TaskFieldService._create_selections', ) + link_rules_mock = mocker.patch( + 'src.processes.services.tasks.field.' + 'TaskFieldService._link_rules', + ) service = TaskFieldService(instance=task_field, user=user) raw_value = ['123'] @@ -882,6 +896,7 @@ def test__create_related__file_type_not_skip__ok(mocker): # assert link_new_attachments_mock.assert_called_once_with(raw_value) create_selections_mock.assert_not_called() + link_rules_mock.assert_not_called() def test__create_related__file_type_skip__skip(mocker): @@ -917,6 +932,10 @@ def test__create_related__file_type_skip__skip(mocker): 'src.processes.services.tasks.field.' 'TaskFieldService._create_selections', ) + link_rules_mock = mocker.patch( + 'src.processes.services.tasks.field.' + 'TaskFieldService._link_rules', + ) service = TaskFieldService(instance=task_field, user=user) # act @@ -929,6 +948,7 @@ def test__create_related__file_type_skip__skip(mocker): # assert link_new_attachments_mock.assert_not_called() create_selections_mock.assert_not_called() + link_rules_mock.assert_not_called() def test__create_related__selection_type__ok(mocker): @@ -964,6 +984,10 @@ def test__create_related__selection_type__ok(mocker): 'src.processes.services.tasks.field.' 'TaskFieldService._create_selections', ) + link_rules_mock = mocker.patch( + 'src.processes.services.tasks.field.' + 'TaskFieldService._link_rules', + ) service = TaskFieldService(instance=task_field, user=user) # act @@ -974,6 +998,7 @@ def test__create_related__selection_type__ok(mocker): # assert create_selections_mock.assert_called_once_with(field_template) link_new_attachments_mock.assert_not_called() + link_rules_mock.assert_not_called() def test__create_related__other_type__skip(mocker): @@ -1009,6 +1034,10 @@ def test__create_related__other_type__skip(mocker): 'src.processes.services.tasks.field.' 'TaskFieldService._create_selections', ) + link_rules_mock = mocker.patch( + 'src.processes.services.tasks.field.' + 'TaskFieldService._link_rules', + ) service = TaskFieldService(instance=task_field, user=user) # act @@ -1019,6 +1048,59 @@ def test__create_related__other_type__skip(mocker): # assert link_new_attachments_mock.assert_not_called() create_selections_mock.assert_not_called() + link_rules_mock.assert_not_called() + + +def test__create_related__with_rules__ok(mocker): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + task_template = template.tasks.first() + fieldset_template = create_test_fieldset_template( + account=account, + template=template, + task=task_template, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + field_template = fieldset_template.fields.first() + rule_template = fieldset_template.rules.first() + rule_template.fields.add(field_template) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + task_field = TaskField.objects.create( + task=task, + api_name='string-field-1', + type=FieldType.STRING, + workflow=workflow, + account=account, + ) + link_new_attachments_mock = mocker.patch( + 'src.processes.services.tasks.field.' + 'TaskFieldService._link_new_attachments', + ) + create_selections_mock = mocker.patch( + 'src.processes.services.tasks.field.' + 'TaskFieldService._create_selections', + ) + link_rules_mock = mocker.patch( + 'src.processes.services.tasks.field.' + 'TaskFieldService._link_rules', + ) + service = TaskFieldService(instance=task_field, user=user) + kwargs = {'some': 'data'} + + # act + service._create_related( + instance_template=field_template, + **kwargs, + ) + + # assert + link_new_attachments_mock.assert_not_called() + create_selections_mock.assert_not_called() + link_rules_mock.assert_called_once_with(field_template, **kwargs) def test_partial_update__ok(mocker): @@ -2391,3 +2473,315 @@ def test__get_valid_value__not_required_and_null_value__ok( # assert assert result == FieldData() get_valid_string_value_mock.assert_not_called() + + +def test__link_rules__one_rule__ok(): + + """One template rule → one FieldSetRule linked""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + task_template = template.tasks.first() + fieldset_api_name = 'fs1' + fieldset_template = create_test_fieldset_template( + account=account, + template=template, + task=task_template, + api_name=fieldset_api_name, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + field_template = fieldset_template.fields.first() + rule_template = fieldset_template.rules.first() + rule_template.fields.add(field_template) + + workflow = create_test_workflow( + user=user, + template=template, + ) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name=fieldset_api_name, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + rule = fieldset.rules.first() + task_field = fieldset.fields.first() + + service = TaskFieldService( + instance=task_field, + user=user, + ) + + # act + service._link_rules( + instance_template=field_template, + fieldset_id=fieldset.id, + ) + + # assert + assert task_field.rules.count() == 1 + assert task_field.rules.first() == rule + + +def test__link_rules__multiple_rules__ok(): + + """Two template rules → two FieldSetRules linked""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + task_template = template.tasks.first() + fieldset_api_name = 'fs1' + fieldset_template = create_test_fieldset_template( + account=account, + template=template, + task=task_template, + api_name=fieldset_api_name, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + rule_tmpl_2 = FieldsetTemplateRule.objects.create( + fieldset=fieldset_template, + account=account, + api_name=f'{fieldset_api_name}-rule-2', + type=FieldSetRuleType.SUM_EQUAL, + value='200', + ) + field_template = fieldset_template.fields.first() + rule_tmpl_1 = fieldset_template.rules.get( + api_name=f'{fieldset_api_name}-rule-1', + ) + field_template.rules.set( + [rule_tmpl_1, rule_tmpl_2], + ) + + workflow = create_test_workflow( + user=user, + template=template, + ) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name=fieldset_api_name, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + rule_2 = FieldSetRule.objects.create( + fieldset=fieldset, + account=account, + api_name=f'{fieldset_api_name}-rule-2', + type=FieldSetRuleType.SUM_EQUAL, + value='200', + ) + rule_1 = fieldset.rules.exclude(id=rule_2.id).first() + task_field = fieldset.fields.first() + + service = TaskFieldService( + instance=task_field, + user=user, + ) + + # act + service._link_rules( + instance_template=field_template, + fieldset_id=fieldset.id, + ) + + # assert + assert task_field.rules.count() == 2 + linked_ids = set( + task_field.rules.values_list('id', flat=True), + ) + assert linked_ids == {rule_1.id, rule_2.id} + + +def test__link_rules__partial_match__ok(): + + """Two template rules, only one FieldSetRule exists + — only matched one linked""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + task_template = template.tasks.first() + fieldset_api_name = 'fs1' + fieldset_template = create_test_fieldset_template( + account=account, + template=template, + task=task_template, + api_name=fieldset_api_name, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + rule_tmpl_2 = FieldsetTemplateRule.objects.create( + fieldset=fieldset_template, + account=account, + api_name=f'{fieldset_api_name}-rule-2', + type=FieldSetRuleType.SUM_EQUAL, + value='200', + ) + field_template = fieldset_template.fields.first() + rule_tmpl_1 = fieldset_template.rules.get( + api_name=f'{fieldset_api_name}-rule-1', + ) + field_template.rules.set( + [rule_tmpl_1, rule_tmpl_2], + ) + + workflow = create_test_workflow( + user=user, + template=template, + ) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name=fieldset_api_name, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + rule = fieldset.rules.first() + task_field = fieldset.fields.first() + + service = TaskFieldService( + instance=task_field, + user=user, + ) + + # act + service._link_rules( + instance_template=field_template, + fieldset_id=fieldset.id, + ) + + # assert + assert task_field.rules.count() == 1 + assert task_field.rules.first() == rule + + +def test__link_rules__no_matching_rules__empty(): + + """Template has rule, but no FieldSetRule + with that api_name — M2M stays empty""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + task_template = template.tasks.first() + fieldset_api_name = 'fs1' + fieldset_template = create_test_fieldset_template( + account=account, + template=template, + task=task_template, + api_name=fieldset_api_name, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + field_template = fieldset_template.fields.first() + rule_template = fieldset_template.rules.first() + rule_template.fields.add(field_template) + + workflow = create_test_workflow( + user=user, + template=template, + ) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name=fieldset_api_name, + ) + FieldSetRule.objects.create( + fieldset=fieldset, + account=account, + api_name='different-rule', + type=FieldSetRuleType.SUM_EQUAL, + value='999', + ) + task_field = fieldset.fields.first() + + service = TaskFieldService( + instance=task_field, + user=user, + ) + + # act + service._link_rules( + instance_template=field_template, + fieldset_id=fieldset.id, + ) + + # assert + assert task_field.rules.count() == 0 + + +def test__link_rules__another_fieldset_rule__not_linked(): + + """FieldSetRule has matching api_name + but belongs to another fieldset — not linked""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + task_template = template.tasks.first() + fieldset_api_name = 'fs1' + fieldset_template = create_test_fieldset_template( + account=account, + template=template, + task=task_template, + api_name=fieldset_api_name, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + field_template = fieldset_template.fields.first() + rule_template = fieldset_template.rules.first() + rule_template.fields.add(field_template) + + workflow = create_test_workflow( + user=user, + template=template, + ) + task = workflow.tasks.get(number=1) + fieldset_1 = create_test_fieldset( + workflow=workflow, + task=task, + api_name=fieldset_api_name, + ) + task_field = fieldset_1.fields.first() + create_test_fieldset( + workflow=workflow, + task=task, + api_name='fs2', + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + + service = TaskFieldService( + instance=task_field, + user=user, + ) + + # act + service._link_rules( + instance_template=field_template, + fieldset_id=fieldset_1.id, + ) + + # assert + assert task_field.rules.count() == 0 diff --git a/backend/src/processes/tests/test_services/test_templates/test_ai/test_open_ai_service.py b/backend/src/processes/tests/test_services/test_templates/test_ai/test_open_ai_service.py index b9972fded..18a8c64f5 100644 --- a/backend/src/processes/tests/test_services/test_templates/test_ai/test_open_ai_service.py +++ b/backend/src/processes/tests/test_services/test_templates/test_ai/test_open_ai_service.py @@ -502,6 +502,7 @@ def test_get_template_data__ok(mocker): assert template_data['kickoff'] == { 'description': '', 'fields': [], + 'fieldsets': [], } task_1_data = template_data['tasks'][0] assert task_1_data['number'] == 1 diff --git a/backend/src/processes/tests/test_services/test_workflows/test_fieldset_rule_service.py b/backend/src/processes/tests/test_services/test_workflows/test_fieldset_rule_service.py new file mode 100644 index 000000000..809e13b41 --- /dev/null +++ b/backend/src/processes/tests/test_services/test_workflows/test_fieldset_rule_service.py @@ -0,0 +1,619 @@ +import pytest + +from src.authentication.enums import AuthTokenType +from src.processes.enums import ( + FieldSetRuleType, + FieldType, +) +from src.processes.messages import fieldset as fs_messages +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateRule, +) +from src.processes.models.workflows.fields import TaskField +from src.processes.services.exceptions import FieldsetServiceException +from src.processes.services.workflows.fieldsets.fieldset_rule import ( + FieldSetRuleService, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_owner, + create_test_template, + create_test_workflow, + create_test_fieldset, +) + +pytestmark = pytest.mark.django_db + + +def test__create_instance__with_template__ok(): + + """ + Call with instance_template and fieldset + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset = create_test_fieldset( + workflow=workflow, + name='Fieldset', + order=1, + ) + fieldset_template = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset tmpl', + ) + rule_template = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset_template, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ) + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + # act + service._create_instance( + instance_template=rule_template, + fieldset=fieldset, + ) + + # assert + assert service.instance is not None + assert service.instance.fieldset_id == fieldset.id + assert service.instance.type == FieldSetRuleType.SUM_EQUAL + assert service.instance.value == '100' + assert service.instance.api_name == rule_template.api_name + assert service.instance.account_id == account.id + + +def test__validate_sum_equal__within_threshold__ok(): + + """ + Total within threshold + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset = create_test_fieldset( + workflow=workflow, + name='Fieldset', + order=1, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.first() + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 1', + type=FieldType.NUMBER, + value='30', + order=1, + ) + field_1.rules.add(rule) + field_2 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + value='70', + order=2, + ) + field_2.rules.add(rule) + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + result = service._validate_sum_equal( + fieldset=fieldset, + value='100', + ) + + # assert + assert result is True + + +def test__validate_sum_equal__one_not_required_field_blank__not_count(): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset = create_test_fieldset( + workflow=workflow, + name='Fieldset', + order=1, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.first() + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 1', + type=FieldType.NUMBER, + value='', + order=1, + is_required=False, + ) + field_1.rules.add(rule) + field_2 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + value='100', + order=2, + is_required=True, + ) + field_2.rules.add(rule) + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + result = service._validate_sum_equal( + fieldset=fieldset, + value='100', + ) + + # assert + assert result is True + + +def test__validate_sum_equal__all_not_required_fields_blank__not_count(): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset = create_test_fieldset( + workflow=workflow, + name='Fieldset', + order=1, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.first() + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 1', + type=FieldType.NUMBER, + value='', + order=1, + is_required=False, + ) + field_1.rules.add(rule) + field_2 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + value='', + order=2, + is_required=False, + ) + field_2.rules.add(rule) + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + result = service._validate_sum_equal( + fieldset=fieldset, + value='100', + ) + + # assert + assert result is True + + +def test__validate_sum_equal__required_field_blank__raise_exception(): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset = create_test_fieldset( + workflow=workflow, + name='Fieldset', + order=1, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.first() + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 1', + type=FieldType.NUMBER, + value='', + order=1, + is_required=True, + ) + field_1.rules.add(rule) + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + with pytest.raises(FieldsetServiceException) as ex: + service._validate_sum_equal( + fieldset=fieldset, + value='100', + ) + + # assert + assert ex.value.message == fs_messages.MSG_FS_0002(100) + + +def test__validate_sum_equal__negative_value__ok(): + + """ + Total within threshold + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset = create_test_fieldset( + workflow=workflow, + name='Fieldset', + order=1, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='0', + ) + rule = fieldset.rules.first() + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 1', + type=FieldType.NUMBER, + value='30', + order=1, + ) + field_1.rules.add(rule) + field_2 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + value='-30', + order=2, + ) + field_2.rules.add(rule) + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + result = service._validate_sum_equal( + fieldset=fieldset, + value='0', + ) + + # assert + assert result is True + + +def test__validate_sum_equal__exceeds__raise_exception(): + + """ + Total exceeds threshold → exception + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset = create_test_fieldset( + workflow=workflow, + name='Fieldset', + order=1, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.first() + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 1', + type=FieldType.NUMBER, + value='60', + order=1, + ) + field_1.rules.add(rule) + field_2 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + value='50', + order=2, + ) + field_2.rules.add(rule) + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + with pytest.raises(FieldsetServiceException) as ex: + service._validate_sum_equal( + fieldset=fieldset, + value='100', + ) + + # assert + assert ex.value.message == fs_messages.MSG_FS_0002(100) + + +def test__validate_sum_equal__null_values__skip(): + + """ + Fields with null values skipped + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset = create_test_fieldset( + workflow=workflow, + name='Fieldset', + order=1, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.first() + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 1', + type=FieldType.NUMBER, + value='100', + order=1, + ) + field_1.rules.add(rule) + field_2 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + value='', + order=2, + ) + field_2.rules.add(rule) + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + result = service._validate_sum_equal( + fieldset=fieldset, + value='100', + ) + + # assert + assert result is True + + +def test_validate__ok(mocker): + + """ + Known type, validator exists + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user, tasks_count=1) + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=workflow.kickoff_instance, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + rule = fieldset.rules.first() + service = FieldSetRuleService( + instance=rule, + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + validate_sum_equal_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService._validate_sum_equal', + ) + kwargs = {'some': 'value'} + + # act + service.validate(**kwargs) + + # assert + validate_sum_equal_mock.assert_called_once_with(**kwargs) + + +def test_create__default_params__ok(mocker): + + """ + Call with default params + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + create_instance_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService._create_instance', + ) + create_related_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService._create_related', + ) + create_actions_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService._create_actions', + ) + validate_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService.validate', + ) + instance = mocker.Mock() + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=instance, + ) + kwargs = { + 'some': 'value', + } + + # act + result = service.create(**kwargs) + + # assert + create_instance_mock.assert_called_once_with(**kwargs) + create_related_mock.assert_called_once_with(**kwargs) + create_actions_mock.assert_called_once_with(**kwargs) + validate_mock.assert_not_called() + assert result is instance + + +def test_create__skip_validation_false__ok(mocker): + + """ + Call with skip_validation=False + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + create_instance_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService._create_instance', + ) + create_related_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService._create_related', + ) + create_actions_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService._create_actions', + ) + validate_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService.validate', + ) + instance = mocker.Mock() + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=instance, + ) + kwargs = { + 'skip_validation': False, + } + + # act + result = service.create(**kwargs) + + # assert + create_instance_mock.assert_called_once_with(**kwargs) + create_related_mock.assert_called_once_with(**kwargs) + create_actions_mock.assert_called_once_with(**kwargs) + validate_mock.assert_called_once_with(**kwargs) + assert result is instance + + +def test_partial_update__default_params__ok(mocker): + + """ + Call with default params + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + result_mock = mocker.Mock() + mock_super_partial_update = mocker.patch( + 'src.generics.base.service.' + 'BaseModelService.partial_update', + return_value=result_mock, + ) + validate_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService.validate', + ) + kwargs = { + 'value': 200, + } + + # act + result = service.partial_update(**kwargs) + + # assert + assert result is result_mock + mock_super_partial_update.assert_called_once_with(**kwargs) + validate_mock.assert_called_once_with(**kwargs) diff --git a/backend/src/processes/tests/test_services/test_workflows/test_fieldset_service.py b/backend/src/processes/tests/test_services/test_workflows/test_fieldset_service.py new file mode 100644 index 000000000..affcf9333 --- /dev/null +++ b/backend/src/processes/tests/test_services/test_workflows/test_fieldset_service.py @@ -0,0 +1,649 @@ +import pytest +from src.authentication.enums import AuthTokenType +from src.processes.enums import ( + FieldSetRuleType, + FieldType, +) +from src.processes.messages.fieldset import ( + MSG_FS_0002, + MSG_FS_0007, + MSG_FS_0012, +) +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateRule, +) +from src.processes.models.templates.fields import FieldTemplate +from src.processes.models.workflows.fieldset import ( + FieldSetRule, +) +from src.processes.services.exceptions import FieldsetServiceException +from src.processes.services.tasks.field import TaskFieldService +from src.processes.services.workflows.fieldsets.fieldset import ( + FieldSetService, +) +from src.processes.services.workflows.fieldsets.fieldset_rule import ( + FieldSetRuleService, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_fieldset, + create_test_owner, + create_test_template, + create_test_workflow, + create_test_fieldset_template, +) + +pytestmark = pytest.mark.django_db + + +def test__create_instance__with_kickoff__ok(): + + """ + Call with kickoff + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + kickoff = workflow.kickoff_instance + order = 11 + fieldset_template = FieldsetTemplate.objects.create( + template=template, + account=account, + order=order, + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + # act + service._create_instance( + instance_template=fieldset_template, + workflow=workflow, + kickoff=kickoff, + order=order, + ) + + # assert + assert service.instance.account == account + assert service.instance.workflow_id == workflow.id + assert service.instance.task is None + assert service.instance.kickoff == kickoff + assert service.instance.api_name == fieldset_template.api_name + assert service.instance.name == fieldset_template.name + assert service.instance.title == fieldset_template.title + assert service.instance.description == fieldset_template.description + assert service.instance.order == order + assert service.instance.label_position == fieldset_template.label_position + assert service.instance.layout == fieldset_template.layout + + +def test__create_instance__with_task__ok(): + + """ + Call with task + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.first() + order = 11 + fieldset_template = create_test_fieldset_template( + template=template, + account=account, + order=order, + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + # act + service._create_instance( + instance_template=fieldset_template, + workflow=workflow, + task=task, + ) + + # assert + assert service.instance.account == account + assert service.instance.workflow == workflow + assert service.instance.task == task + assert service.instance.kickoff is None + assert service.instance.api_name == fieldset_template.api_name + assert service.instance.name == fieldset_template.name + assert service.instance.title == fieldset_template.title + assert service.instance.description == fieldset_template.description + assert service.instance.order == order + assert service.instance.label_position == fieldset_template.label_position + assert service.instance.layout == fieldset_template.layout + + +def test__create_instance__no_kickoff_no_task__raise_exception(): + + """ + Call without kickoff and task + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset_template = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + order = 11 + + # act + with pytest.raises(FieldsetServiceException) as ex: + service._create_instance( + instance_template=fieldset_template, + workflow=workflow, + order=order, + ) + + # assert + assert ex.value.message == MSG_FS_0007 + + +def test__create_fields__default_params__ok(mocker): + + """ + Call with default parameters + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset_template = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + FieldTemplate.objects.create( + account=account, + fieldset=fieldset_template, + name='Field 1', + type=FieldType.NUMBER, + order=1, + ) + fieldset = create_test_fieldset( + workflow=workflow, + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + task_field_service_init_mock = mocker.patch.object( + TaskFieldService, + attribute='__init__', + return_value=None, + ) + task_field_service_create_mock = mocker.patch( + 'src.processes.services.tasks.field.' + 'TaskFieldService.create', + ) + + # act + service._create_fields( + instance_template=fieldset_template, + ) + + # assert + task_field_service_init_mock.assert_called_once_with( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + task_field_service_create_mock.assert_called_once_with( + instance_template=fieldset_template.fields.first(), + workflow_id=fieldset.workflow_id, + fieldset_id=fieldset.id, + skip_value=False, + value='', + ) + + +def test__create_fields__with_fields_data__ok(mocker): + + """ + Call with fields_data provided + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset_template = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + field_template_1 = FieldTemplate.objects.create( + account=account, + fieldset=fieldset_template, + name='Field 1', + type=FieldType.NUMBER, + order=1, + ) + fieldset = create_test_fieldset( + workflow=workflow, + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + fields_data = {field_template_1.api_name: '42'} + task_field_service_init_mock = mocker.patch.object( + TaskFieldService, + attribute='__init__', + return_value=None, + ) + task_field_service_create_mock = mocker.patch( + 'src.processes.services.tasks.field.' + 'TaskFieldService.create', + ) + + # act + service._create_fields( + instance_template=fieldset_template, + fields_data=fields_data, + ) + + # assert + task_field_service_init_mock.assert_called_once_with( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + task_field_service_create_mock.assert_called_once_with( + instance_template=field_template_1, + workflow_id=fieldset.workflow_id, + fieldset_id=fieldset.id, + skip_value=False, + value='42', + ) + + +def test__create_fields__skip_value_true__ok(mocker): + + """ + Call with skip_value=True + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset_template = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + FieldTemplate.objects.create( + account=account, + fieldset=fieldset_template, + name='Field 1', + type=FieldType.NUMBER, + order=1, + ) + fieldset = create_test_fieldset( + workflow=workflow, + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + task_field_service_init_mock = mocker.patch.object( + TaskFieldService, + attribute='__init__', + return_value=None, + ) + task_field_service_create_mock = mocker.patch( + 'src.processes.services.tasks.field.' + 'TaskFieldService.create', + ) + + # act + service._create_fields( + instance_template=fieldset_template, + skip_value=True, + ) + + # assert + task_field_service_init_mock.assert_called_once_with( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + task_field_service_create_mock.assert_called_once_with( + instance_template=fieldset_template.fields.first(), + workflow_id=fieldset.workflow_id, + fieldset_id=fieldset.id, + skip_value=True, + value='', + ) + + +def test__create_rules__with_template__ok(mocker): + + """ + Call with instance_template + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset_template = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + rule_template = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset_template, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ) + fieldset = create_test_fieldset( + workflow=workflow, + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + field_set_rule_service_init_mock = mocker.patch.object( + FieldSetRuleService, + attribute='__init__', + return_value=None, + ) + field_set_rule_service_create_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService.create', + ) + + # act + service._create_rules(instance_template=fieldset_template) + + # assert + field_set_rule_service_init_mock.assert_called_once_with( + user=user, + ) + field_set_rule_service_create_mock.assert_called_once_with( + instance_template=rule_template, + fieldset=fieldset, + skip_validation=None, + ) + + +def test__create_related__with_template__ok(mocker): + + """ + Call with instance_template + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset_template = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + create_fields_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset.' + 'FieldSetService._create_fields', + ) + create_rules_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset.' + 'FieldSetService._create_rules', + ) + + # act + service._create_related(instance_template=fieldset_template) + + # assert + create_rules_mock.assert_called_once_with( + fieldset_template, + ) + create_fields_mock.assert_called_once_with( + fieldset_template, + ) + + +def test_validate_rules__one_rule__ok(mocker): + + """ + Call with rules + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset = create_test_fieldset( + workflow=workflow, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.first() + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + field_set_rule_service_init_mock = mocker.patch.object( + FieldSetRuleService, + attribute='__init__', + return_value=None, + ) + field_set_rule_service_validate_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService.validate', + ) + + # act + service.validate_rules() + + # assert + field_set_rule_service_init_mock.assert_called_once_with( + user=user, + instance=rule, + ) + field_set_rule_service_validate_mock.assert_called_once_with() + + +def test_validate_rules__one_rule_none_matches__raise_exception(mocker): + + """ + Call with rules + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset = create_test_fieldset( + workflow=workflow, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + field = fieldset.fields.first() + rule_100 = fieldset.rules.first() + rule_100.fields.add(field) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # act + with pytest.raises(FieldsetServiceException) as ex: + service.validate_rules() + + # assert + assert ex.value.message == MSG_FS_0002('100') + + +def test_validate_rules__two_same_type_rules__first_value_matches__ok(): + + """ + Two sum_equal rules with different values. + Fields sum equals first rule value — OR-logic: validation passes. + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user) + fieldset = create_test_fieldset( + workflow=workflow, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='10', + ) + field = fieldset.fields.first() + rule_10 = fieldset.rules.first() + rule_10.fields.add(field) + rule_0 = FieldSetRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='0', + ) + rule_0.fields.add(field) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # act + service.validate_rules() + + # assert + assert service.instance == fieldset + + +def test_validate_rules__two_same_type_rules__second_value_matches__ok(): + + """ + Two sum_equal rules with different values. + Fields sum equals second rule value — OR-logic: validation passes. + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user) + fieldset = create_test_fieldset( + workflow=workflow, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + field = fieldset.fields.first() + rule_100 = fieldset.rules.first() + rule_100.fields.add(field) + rule_10 = FieldSetRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='10', + ) + rule_10.fields.add(field) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # act + service.validate_rules() + + # assert + assert service.instance == fieldset + + +def test_validate_rules__two_same_type_rules__none_matches__raise(): + + """ + Two sum_equal rules with different values. + Fields sum does not match any rule value — + raises FieldsetServiceException with MSG_FS_0012 + listing all values from the group. + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user) + fieldset = create_test_fieldset( + workflow=workflow, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + field = fieldset.fields.first() + rule_100 = fieldset.rules.first() + rule_100.fields.add(field) + rule_0 = FieldSetRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='0', + ) + rule_0.fields.add(field) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # act + with pytest.raises(FieldsetServiceException) as ex: + service.validate_rules() + + # assert + assert ex.value.message == MSG_FS_0012('100, 0') diff --git a/backend/src/processes/tests/test_services/test_workflows/test_kickoff_version_service.py b/backend/src/processes/tests/test_services/test_workflows/test_kickoff_version_service.py new file mode 100644 index 000000000..e002caf02 --- /dev/null +++ b/backend/src/processes/tests/test_services/test_workflows/test_kickoff_version_service.py @@ -0,0 +1,1107 @@ +import pytest + +from src.authentication.enums import AuthTokenType +from src.processes.enums import ( + FieldSetLayout, + FieldSetRuleType, + FieldType, + LabelPosition, +) +from src.processes.models.workflows.fields import ( + FieldSelection, + TaskField, +) +from src.processes.models.workflows.fieldset import ( + FieldSet, + FieldSetRule, +) +from src.processes.services.workflows.kickoff_version import ( + KickoffUpdateVersionService, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_fieldset, + create_test_owner, + create_test_template, + create_test_workflow, +) + + +pytestmark = pytest.mark.django_db + + +def test__update_field__no_fieldset__ok(): + + """ + fieldset is None (default) + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + field_data = { + 'api_name': 'field-1', + 'name': 'Test field', + 'description': 'Desc', + 'type': FieldType.STRING, + 'is_required': True, + 'is_hidden': False, + 'order': 1, + 'dataset_id': None, + } + + # act + field, created = service._update_field( + template=field_data, + fieldset=None, + ) + + # assert + assert created is True + assert field.kickoff == kickoff + assert field.api_name == 'field-1' + assert field.name == 'Test field' + assert field.description == 'Desc' + assert field.type == FieldType.STRING + assert field.is_required is True + assert field.is_hidden is False + assert field.order == 1 + assert field.workflow == kickoff.workflow + assert field.account == kickoff.account + assert field.dataset_id is None + assert field.fieldset is None + + +def test__update_field__fieldset__ok(): + + """ + fieldset provided + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + name='Test fieldset', + api_name='fs-1', + ) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + field_data = { + 'api_name': 'field-1', + 'name': 'Number field', + 'description': '', + 'type': FieldType.NUMBER, + 'is_required': False, + 'is_hidden': True, + 'order': 2, + 'dataset_id': None, + } + + # act + field, created = service._update_field( + template=field_data, + fieldset=fieldset, + ) + + # assert + assert created is True + assert field.kickoff == kickoff + assert field.fieldset == fieldset + assert field.api_name == 'field-1' + assert field.name == 'Number field' + assert field.type == FieldType.NUMBER + assert field.is_required is False + assert field.is_hidden is True + assert field.order == 2 + + +def test__update_field_selections__provided__ok(): + + """ + selections provided — creates and deletes stale + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + field = TaskField.objects.create( + kickoff=kickoff, + workflow=workflow, + account=account, + api_name='field-1', + name='Checkbox', + type=FieldType.CHECKBOX, + order=1, + ) + + # stale selection to be deleted + stale_selection = FieldSelection.objects.create( + field=field, + api_name='sel-old', + value='Old value', + ) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + field_data = { + 'selections': [ + { + 'api_name': 'sel-1', + 'value': 'First', + }, + { + 'api_name': 'sel-2', + 'value': 'Second', + }, + ], + } + + # act + service._update_field_selections( + field=field, + field_data=field_data, + ) + + # assert + selections = field.selections.order_by('api_name') + assert selections.count() == 2 + assert selections[0].api_name == 'sel-1' + assert selections[0].value == 'First' + assert selections[1].api_name == 'sel-2' + assert selections[1].value == 'Second' + assert FieldSelection.objects.filter( + id=stale_selection.id, + ).exists() is False + + +def test__update_field_selections__empty__skip(): + + """ + no selections — skips + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + field = TaskField.objects.create( + kickoff=kickoff, + workflow=workflow, + account=account, + api_name='field-1', + name='Text field', + type=FieldType.STRING, + order=1, + ) + existing_selection = FieldSelection.objects.create( + field=field, + api_name='sel-existing', + value='Existing', + ) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + field_data = {} + + # act + service._update_field_selections( + field=field, + field_data=field_data, + ) + + # assert + assert field.selections.count() == 1 + assert field.selections.filter( + id=existing_selection.id, + ).exists() is True + + +def test__update_fieldset_rules__rules_data_is_none__delete_all(): + + """ + rules_data is None — defaults to empty, deletes all + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + name='FS', + api_name='fs-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + existing_rule = fieldset.rules.first() + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + + # act + service._update_fieldset_rules( + fieldset=fieldset, + rules_data=None, + ) + + # assert + assert fieldset.rules.count() == 0 + assert FieldSetRule.objects.filter( + id=existing_rule.id, + ).exists() is False + + +def test__update_fieldset_rules__provided__ok(): + + """ + rules_data provided — creates and deletes stale + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + name='FS', + api_name='fs-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='50', + ) + + # stale rule to be deleted + stale_rule = fieldset.rules.first() + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + rules_data = [ + { + 'api_name': 'rule-1', + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '100', + }, + ] + + # act + service._update_fieldset_rules( + fieldset=fieldset, + rules_data=rules_data, + ) + + # assert + rules = fieldset.rules.all() + assert rules.count() == 1 + assert rules[0].api_name == 'rule-1' + assert rules[0].type == FieldSetRuleType.SUM_EQUAL + assert rules[0].value == '100' + assert rules[0].account_id == account.id + assert FieldSetRule.objects.filter( + id=stale_rule.id, + ).exists() is False + + +def test__update_field_rules__provided__ok(): + + """ + rules provided — links rules + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + name='FS', + api_name='fs-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule_1 = fieldset.rules.first() + field = TaskField.objects.create( + kickoff=kickoff, + workflow=workflow, + account=account, + fieldset=fieldset, + api_name='field-1', + name='Number field', + type=FieldType.NUMBER, + order=1, + ) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + field_data = { + 'rules': [ + {'api_name': rule_1.api_name}, + ], + } + + # act + service._update_field_rules( + field=field, + field_data=field_data, + fieldset=fieldset, + ) + + # assert + assert field.rules.count() == 1 + assert field.rules.filter(id=rule_1.id).exists() is True + + +def test__update_field_rules__empty__clear(): + + """ + no rules — clears rules + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + name='FS', + api_name='fs-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule_1 = fieldset.rules.first() + field = TaskField.objects.create( + kickoff=kickoff, + workflow=workflow, + account=account, + fieldset=fieldset, + api_name='field-1', + name='Number field', + type=FieldType.NUMBER, + order=1, + ) + field.rules.add(rule_1) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + field_data = {} + + # act + service._update_field_rules( + field=field, + field_data=field_data, + fieldset=fieldset, + ) + + # assert + assert field.rules.count() == 0 + + +def test__update_fields__provided__ok(mocker): + + """ + fields data provided — deletes stale fields + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + field_1 = TaskField.objects.create( + kickoff=kickoff, + workflow=workflow, + account=account, + api_name='field-1', + name='Field 1', + type=FieldType.STRING, + order=1, + ) + + # stale field to be deleted + stale_field = TaskField.objects.create( + kickoff=kickoff, + workflow=workflow, + account=account, + api_name='field-stale', + name='Stale', + type=FieldType.STRING, + order=2, + ) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + field_data_1 = { + 'api_name': 'field-1', + 'name': 'Field 1', + 'description': '', + 'type': FieldType.STRING, + 'is_required': False, + 'is_hidden': False, + 'order': 1, + 'dataset_id': None, + } + data = [field_data_1] + + update_field_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_field', + return_value=(field_1, True), + ) + update_field_selections_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_field_selections', + ) + + # act + service._update_fields(data=data) + + # assert + update_field_mock.assert_called_once_with( + field_data_1, fieldset=None, + ) + update_field_selections_mock.assert_called_once_with( + field_1, field_data_1, + ) + assert TaskField.objects.filter( + id=field_1.id, + ).exists() is True + assert TaskField.objects.filter( + id=stale_field.id, + ).exists() is False + + +def test__update_fs_fields__none__delete_all(mocker): + + """ + fields_data is None — defaults to empty, deletes all + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + name='FS', + api_name='fs-1', + ) + + # existing field to be deleted + existing_field = TaskField.objects.create( + kickoff=kickoff, + workflow=workflow, + account=account, + fieldset=fieldset, + api_name='field-old', + name='Old field', + type=FieldType.STRING, + order=1, + ) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + + update_field_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_field', + ) + update_field_selections_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_field_selections', + ) + update_field_rules_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_field_rules', + ) + + # act + service._update_fieldset_fields( + fieldset=fieldset, + fields_data=None, + ) + + # assert + update_field_mock.assert_not_called() + update_field_selections_mock.assert_not_called() + update_field_rules_mock.assert_not_called() + assert TaskField.objects.filter( + id=existing_field.id, + ).exists() is False + + +def test__update_fs_fields__provided__ok(mocker): + + """ + fields_data provided — deletes stale fields + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + name='FS', + api_name='fs-1', + ) + field_1 = TaskField.objects.create( + kickoff=kickoff, + workflow=workflow, + account=account, + fieldset=fieldset, + api_name='field-1', + name='Field 1', + type=FieldType.STRING, + order=1, + ) + + # stale field to be deleted + stale_field = TaskField.objects.create( + kickoff=kickoff, + workflow=workflow, + account=account, + fieldset=fieldset, + api_name='field-stale', + name='Stale', + type=FieldType.STRING, + order=2, + ) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + field_data_1 = {'api_name': 'field-1'} + fields_data = [field_data_1] + + update_field_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_field', + return_value=(field_1, True), + ) + update_field_selections_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_field_selections', + ) + update_field_rules_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_field_rules', + ) + + # act + service._update_fieldset_fields( + fieldset=fieldset, + fields_data=fields_data, + ) + + # assert + update_field_mock.assert_called_once_with( + field_data_1, fieldset=fieldset, + ) + update_field_selections_mock.assert_called_once_with( + field_1, field_data_1, + ) + update_field_rules_mock.assert_called_once_with( + field_1, field_data_1, fieldset, + ) + assert TaskField.objects.filter( + id=field_1.id, + ).exists() is True + assert TaskField.objects.filter( + id=stale_field.id, + ).exists() is False + + +def test__update_fieldsets__none__delete_all(mocker): + + """ + data is None — defaults to empty, deletes all + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + + # existing fieldset to be deleted + existing_fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + name='FS', + api_name='fs-old', + ) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + + update_fieldset_rules_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fieldset_rules', + ) + update_fieldset_fields_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fieldset_fields', + ) + + # act + service._update_fieldsets(data=None) + + # assert + update_fieldset_rules_mock.assert_not_called() + update_fieldset_fields_mock.assert_not_called() + assert FieldSet.objects.filter( + id=existing_fieldset.id, + ).exists() is False + + +def test__update_fieldsets__provided__ok(mocker): + + """ + data provided — creates and deletes stale + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + + # stale fieldset to be deleted + stale_fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + name='Stale FS', + api_name='fs-stale', + order=1, + ) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + rules_data_1 = [ + { + 'api_name': 'rule-1', + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '100', + }, + ] + fields_data_1 = [ + {'api_name': 'field-1'}, + ] + data = [ + { + 'api_name': 'fs-1', + 'name': 'Fieldset 1', + 'description': 'Desc', + 'kickoff_links': [ + { + 'order': 11, + }, + ], + 'label_position': LabelPosition.TOP, + 'layout': FieldSetLayout.VERTICAL, + 'rules': rules_data_1, + 'fields': fields_data_1, + }, + ] + + update_fieldset_rules_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fieldset_rules', + ) + update_fieldset_fields_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fieldset_fields', + ) + + # act + service._update_fieldsets(data=data) + + # assert + fieldset = FieldSet.objects.get( + kickoff=kickoff, + api_name='fs-1', + ) + assert fieldset.name == 'Fieldset 1' + assert fieldset.description == 'Desc' + assert fieldset.order == 11 + assert fieldset.label_position == LabelPosition.TOP + assert fieldset.layout == FieldSetLayout.VERTICAL + assert fieldset.account_id == account.id + update_fieldset_rules_mock.assert_called_once_with( + fieldset=fieldset, + rules_data=rules_data_1, + ) + update_fieldset_fields_mock.assert_called_once_with( + fieldset=fieldset, + fields_data=fields_data_1, + ) + assert FieldSet.objects.filter( + id=stale_fieldset.id, + ).exists() is False + + +def test__update_from_version__fields__ok(mocker): + + """ + fields provided + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + fields_data = [{'api_name': 'field-1'}] + data = {'fields': fields_data} + version = 1 + + update_fields_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fields', + ) + update_fieldsets_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fieldsets', + ) + + # act + service.update_from_version( + data=data, + version=version, + ) + + # assert + update_fields_mock.assert_called_once_with( + data=fields_data, + ) + update_fieldsets_mock.assert_not_called() + + +def test__update_from_version__no_fields__skip(mocker): + + """ + fields not provided + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + fieldsets_data = [] + data = {'fieldsets': fieldsets_data} + version = 1 + + update_fields_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fields', + ) + update_fieldsets_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fieldsets', + ) + + # act + service.update_from_version( + data=data, + version=version, + ) + + # assert + update_fields_mock.assert_not_called() + update_fieldsets_mock.assert_called_once_with( + data=fieldsets_data, + ) + + +def test__update_from_version__fieldsets__ok(mocker): + + """ + fieldsets provided (not None) + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + fieldsets_data = [{'api_name': 'fs-1'}] + data = {'fieldsets': fieldsets_data} + version = 1 + + update_fields_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fields', + ) + update_fieldsets_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fieldsets', + ) + + # act + service.update_from_version( + data=data, + version=version, + ) + + # assert + update_fields_mock.assert_not_called() + update_fieldsets_mock.assert_called_once_with( + data=fieldsets_data, + ) + + +def test__update_from_version__no_fieldsets__skip(mocker): + + """ + fieldsets key missing + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + data = {} + version = 1 + + update_fields_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fields', + ) + update_fieldsets_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fieldsets', + ) + + # act + service.update_from_version( + data=data, + version=version, + ) + + # assert + update_fields_mock.assert_not_called() + update_fieldsets_mock.assert_not_called() diff --git a/backend/src/processes/tests/test_services/test_workflows/test_workflow_version_service.py b/backend/src/processes/tests/test_services/test_workflows/test_workflow_version_service.py index adf846ebf..0e128d168 100644 --- a/backend/src/processes/tests/test_services/test_workflows/test_workflow_version_service.py +++ b/backend/src/processes/tests/test_services/test_workflows/test_workflow_version_service.py @@ -160,7 +160,7 @@ def test_update_from_version__ok(self, mocker): instance=workflow.kickoff_instance, ) kickoff_service_update_mock.assert_called_once_with( - data={'fields': []}, + data={'fields': [], 'fieldsets': []}, version=template_version.version, ) workflow_service_init_mock.assert_called_once_with( diff --git a/backend/src/processes/tests/test_views/test_fieldsets/__init__.py b/backend/src/processes/tests/test_views/test_fieldsets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/processes/tests/test_views/test_fieldsets/test_create.py b/backend/src/processes/tests/test_views/test_fieldsets/test_create.py new file mode 100644 index 000000000..37ed01f5e --- /dev/null +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_create.py @@ -0,0 +1,822 @@ +import pytest +from datetime import timedelta + +from django.utils import timezone + +from src.accounts.enums import BillingPlanType +from src.accounts.messages import MSG_A_0035, MSG_A_0037, MSG_A_0041 +from src.authentication.enums import AuthTokenType +from src.generics.exceptions import BaseServiceException +from src.processes.enums import ( + FieldSetLayout, + LabelPosition, + FieldSetRuleType, FieldType, +) +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateRule, +) +from src.processes.models.templates.fields import FieldTemplate + +from src.processes.services.fieldsets.fieldset import ( + FieldSetTemplateService, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_admin, + create_test_not_admin, + create_test_owner, + create_test_template, +) + +pytestmark = pytest.mark.django_db + + +def test_create_fieldset__all_fields__ok(api_client, mocker): + + """ + Create fieldset with all fields in request + and check all fields in response + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + task = template.tasks.first() + + data = { + 'name': 'All Fields Fieldset', + 'title': 'All Fields Title', + 'description': 'Description', + 'label_position': LabelPosition.LEFT, + 'layout': FieldSetLayout.HORIZONTAL, + 'api_name': 'fieldset_api_name', + 'fields': [ + { + 'name': 'Field 1', + 'type': FieldType.TEXT, + 'order': 1, + 'api_name': 'f1', + }, + ], + 'rules': [ + { + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '10', + 'api_name': 'r1', + 'fields': [], + }, + ], + } + + fieldset = FieldsetTemplate.objects.create( + account=account, + template=template, + name=data['name'], + title=data['title'], + description=data['description'], + label_position=data['label_position'], + layout=data['layout'], + api_name=data['api_name'], + ) + field = FieldTemplate.objects.create( + account=account, + template=template, + task=task, + name='Field 1', + type='text', + order=1, + api_name='f1', + fieldset=fieldset, + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='10', + api_name='r1', + ) + rule.fields.add(field) + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', + return_value=fieldset, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.post('/fieldsets', data=data) + + # assert + assert response.status_code == 201 + assert response.data['id'] == fieldset.id + assert response.data['name'] == data['name'] + assert response.data['title'] == data['title'] + assert response.data['description'] == data['description'] + assert response.data['label_position'] == data['label_position'] + assert response.data['layout'] == data['layout'] + assert response.data['api_name'] == data['api_name'] + + assert len(response.data['fields']) == 1 + assert response.data['fields'][0]['name'] == 'Field 1' + assert response.data['fields'][0]['api_name'] == 'f1' + + assert len(response.data['rules']) == 1 + assert response.data['rules'][0]['type'] == FieldSetRuleType.SUM_EQUAL + assert response.data['rules'][0]['api_name'] == 'r1' + + fieldset_service_init_mock.assert_called_once_with( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + fieldset_service_create_mock.assert_called_once_with( + name=data['name'], + title=data['title'], + description=data['description'], + layout=data['layout'], + label_position=data['label_position'], + api_name=data['api_name'], + rules=data['rules'], + fields=data['fields'], + ) + + +def test_create_fieldset__min_data__ok(api_client, mocker): + + """Create fieldset with minimal request data""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + data = { + 'name': 'Minimal Fieldset', + } + + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset = FieldsetTemplate.objects.create( + account=account, + template=template, + name='Minimal Fieldset', + ) + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', + return_value=fieldset, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.post('/fieldsets', data=data) + + # assert + assert response.status_code == 201 + fieldset_service_init_mock.assert_called_once_with( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + fieldset_service_create_mock.assert_called_once_with( + name='Minimal Fieldset', + rules=[], + fields=[], + ) + + +def test_create_fieldset__set_api_name__ok(api_client, mocker): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + data = { + 'name': 'Minimal Fieldset', + 'api_name': 'fs1', + } + + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset = FieldsetTemplate.objects.create( + account=account, + template=template, + name='Minimal Fieldset', + ) + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', + return_value=fieldset, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.post('/fieldsets', data=data) + + # assert + assert response.status_code == 201 + assert response.data['title'] == '' + fieldset_service_init_mock.assert_called_once_with( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + fieldset_service_create_mock.assert_called_once_with( + name=data['name'], + api_name=data['api_name'], + rules=[], + fields=[], + ) + + +def test_create_fieldset__rule_with_one_field__ok(api_client, mocker): + + """ + Create fieldset with fields in rule request and check fields in response + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + template.tasks.first() + field_api_name = 'f1' + data = { + 'name': 'All Fields Fieldset', + 'fields': [ + { + 'name': 'Field 1', + 'type': FieldType.STRING, + 'order': 2, + 'api_name': field_api_name, + }, + { + 'name': 'Field 2', + 'type': FieldType.URL, + 'order': 1, + 'api_name': 'f2', + }, + ], + 'rules': [ + { + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '10', + 'fields': [field_api_name], + }, + ], + } + + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + fieldset = FieldsetTemplate.objects.create( + account=account, + template=template, + name=data['name'], + ) + field = FieldTemplate.objects.create( + account=account, + template=template, + api_name=field_api_name, + fieldset=fieldset, + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='10', + ) + rule.fields.add(field) + + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', + return_value=fieldset, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.post('/fieldsets', data=data) + + # assert + assert response.status_code == 201 + + assert len(response.data['rules']) == 1 + assert response.data['rules'][0]['fields'] == [field_api_name] + + fieldset_service_init_mock.assert_called_once_with( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + fieldset_service_create_mock.assert_called_once_with( + name=data['name'], + rules=data['rules'], + fields=data['fields'], + ) + + +def test_create_fieldset__rule_with_two_fields__ok(api_client, mocker): + + """ + Create fieldset with fields in rule request and check fields in response + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + field_1_api_name = 'f1' + field_2_api_name = 'f2' + data = { + 'name': 'All Fields Fieldset', + 'fields': [ + { + 'name': 'Field 1', + 'type': FieldType.STRING, + 'order': 2, + 'api_name': field_1_api_name, + }, + { + 'name': 'Field 2', + 'type': FieldType.URL, + 'order': 1, + 'api_name': field_2_api_name, + }, + ], + 'rules': [ + { + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '10', + 'fields': [field_2_api_name, field_1_api_name], + }, + ], + } + + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + fieldset = FieldsetTemplate.objects.create( + account=account, + template=template, + name=data['name'], + ) + field_1 = FieldTemplate.objects.create( + account=account, + template=template, + api_name=field_1_api_name, + fieldset=fieldset, + ) + field_2 = FieldTemplate.objects.create( + account=account, + template=template, + api_name=field_2_api_name, + fieldset=fieldset, + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='10', + ) + rule.fields.set([field_1, field_2]) + + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', + return_value=fieldset, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.post('/fieldsets', data=data) + + # assert + assert response.status_code == 201 + + assert len(response.data['rules']) == 1 + assert response.data['rules'][0]['fields'] == [ + field_1_api_name, + field_2_api_name, + ] + + fieldset_service_init_mock.assert_called_once_with( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + fieldset_service_create_mock.assert_called_once_with( + name=data['name'], + rules=data['rules'], + fields=data['fields'], + ) + + +def test_create_fieldset__unauthenticated__unauthorized(api_client, mocker): + + """Unauthenticated request returns 401""" + + # arrange + data = { + 'name': 'New Fieldset', + } + + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', + ) + + # act + response = api_client.post('/fieldsets', data=data) + + # assert + assert response.status_code == 401 + fieldset_service_init_mock.assert_not_called() + fieldset_service_create_mock.assert_not_called() + + +def test_create_fieldset__expired_sub__permission_denied(api_client, mocker): + + """Expired subscription returns 403""" + + # arrange + account = create_test_account( + plan=BillingPlanType.PREMIUM, + plan_expiration=timezone.now() - timedelta(days=1), + ) + user = create_test_owner(account=account) + data = { + 'name': 'New Fieldset', + } + + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.post('/fieldsets', data=data) + + # assert + assert response.status_code == 403 + assert response.data['detail'] == MSG_A_0035 + fieldset_service_init_mock.assert_not_called() + fieldset_service_create_mock.assert_not_called() + + +def test_create_fieldset__billing_plan__permission_denied(api_client, mocker): + + """ Billing plan permission denied returns 403 """ + + # arrange + account = create_test_account(plan=None) + user = create_test_owner(account=account) + data = { + 'name': 'New Fieldset', + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.post('/fieldsets', data=data) + + # assert + assert response.status_code == 403 + assert response.data['detail'] == MSG_A_0041 + fieldset_service_init_mock.assert_not_called() + fieldset_service_create_mock.assert_not_called() + + +def test_create_fieldset__users_limit__permission_denied(api_client, mocker): + + """ Users overlimited returns 403 """ + + # arrange + account = create_test_account( + plan=BillingPlanType.PREMIUM, + max_users=1, + ) + user = create_test_owner(account=account) + create_test_not_admin( + account=account, + email='extra@pneumatic.app', + ) + account.active_users = 2 + account.save() + data = { + 'name': 'New Fieldset', + } + + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.post('/fieldsets', data=data) + + # assert + assert response.status_code == 403 + assert response.data['detail'] == MSG_A_0037 + fieldset_service_init_mock.assert_not_called() + fieldset_service_create_mock.assert_not_called() + + +def test_create_fieldset__non_admin__permission_denied(api_client, mocker): + + """ Non-admin non-owner user returns 403 """ + + # arrange + account = create_test_account() + create_test_owner(account=account) + user = create_test_not_admin(account=account) + data = { + 'name': 'New Fieldset', + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.post('/fieldsets', data=data) + + # assert + assert response.status_code == 403 + fieldset_service_init_mock.assert_not_called() + fieldset_service_create_mock.assert_not_called() + + +def test_create_fieldset__admin__ok(api_client, mocker): + + """ Admin (non-owner) user can create fieldset """ + + # arrange + account = create_test_account() + create_test_owner(account=account) + user = create_test_admin(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + data = { + 'name': 'New Fieldset', + } + fieldset = FieldsetTemplate.objects.create( + account=account, + template=template, + name=data['name'], + ) + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', + return_value=fieldset, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.post('/fieldsets', data=data) + + # assert + assert response.status_code == 201 + fieldset_service_init_mock.assert_called_once_with( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + fieldset_service_create_mock.assert_called_once_with( + name=data['name'], + rules=[], + fields=[], + ) + + +def test_create_fieldset__blank_name__validation_error(api_client, mocker): + + """ Invalid name field returns validation error """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + data = { + } + + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.post('/fieldsets', data=data) + + # assert + assert response.status_code == 400 + message = 'This field is required.' + assert response.data['message'] == message + assert response.data['details']['name'] == 'name' + fieldset_service_init_mock.assert_not_called() + fieldset_service_create_mock.assert_not_called() + + +def test_create_fieldset__invalid_layout__validation_error(api_client, mocker): + + """ Invalid layout field returns validation error """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + data = { + 'name': 'Test Fieldset', + 'layout': 'invalid_layout', + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.post('/fieldsets', data=data) + + # assert + assert response.status_code == 400 + message = '"invalid_layout" is not a valid choice.' + assert response.data['message'] == message + assert response.data['details']['name'] == 'layout' + fieldset_service_init_mock.assert_not_called() + fieldset_service_create_mock.assert_not_called() + + +def test_create_fieldset__invalid_label_position__validation_error( + api_client, + mocker, +): + + """ Invalid label_position field returns validation error """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + data = { + 'name': 'Test Fieldset', + 'label_position': 'invalid_position', + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.post('/fieldsets', data=data) + + # assert + assert response.status_code == 400 + message = '"invalid_position" is not a valid choice.' + assert response.data['message'] == message + assert response.data['details']['name'] == 'label_position' + fieldset_service_init_mock.assert_not_called() + fieldset_service_create_mock.assert_not_called() + + +def test_create_fieldset__service_exception__validation_error( + api_client, + mocker, +): + """ Service raises BaseServiceException returns validation error """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + data = { + 'name': 'Test Fieldset', + } + error_message = 'Service error occurred' + + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', + side_effect=BaseServiceException(message=error_message), + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.post('/fieldsets', data=data) + + # assert + assert response.status_code == 400 + assert response.data['message'] == error_message + fieldset_service_init_mock.assert_called_once_with( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + fieldset_service_create_mock.assert_called_once_with( + name='Test Fieldset', + rules=[], + fields=[], + ) diff --git a/backend/src/processes/tests/test_views/test_fieldsets/test_destroy.py b/backend/src/processes/tests/test_views/test_fieldsets/test_destroy.py new file mode 100644 index 000000000..4ac7b306e --- /dev/null +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_destroy.py @@ -0,0 +1,331 @@ +import pytest +from datetime import timedelta + +from django.utils import timezone + +from src.accounts.enums import BillingPlanType +from src.accounts.messages import MSG_A_0035, MSG_A_0037, MSG_A_0041 +from src.authentication.enums import AuthTokenType +from src.generics.exceptions import BaseServiceException +from src.processes.services.fieldsets.fieldset import ( + FieldSetTemplateService, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_not_admin, + create_test_owner, + create_test_shared_fieldset, +) + +pytestmark = pytest.mark.django_db + + +def test_destroy__ok(api_client, mocker): + """Delete existing fieldset""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + + # mock FieldSetTemplateService + field_set_template_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + field_set_template_service_delete_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.delete', + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.delete( + f'/fieldsets/{fieldset.id}', + ) + + # assert + assert response.status_code == 204 + field_set_template_service_init_mock.assert_called_once_with( + user=user, + instance=fieldset, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + field_set_template_service_delete_mock.assert_called_once_with() + + +def test_destroy__unauthenticated__unauthorized(api_client, mocker): + """Unauthenticated request returns 401""" + + # arrange + account = create_test_account() + create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + + field_set_template_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + field_set_template_service_delete_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.delete', + ) + # act + response = api_client.delete( + f'/fieldsets/{fieldset.id}', + ) + + # assert + assert response.status_code == 401 + field_set_template_service_init_mock.assert_not_called() + field_set_template_service_delete_mock.assert_not_called() + + +def test_destroy__expired_sub__permission_denied(api_client, mocker): + """Expired subscription returns 403""" + + # arrange + account = create_test_account( + plan=BillingPlanType.PREMIUM, + plan_expiration=timezone.now() - timedelta(days=1), + ) + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + + api_client.token_authenticate(user=user) + + field_set_template_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + field_set_template_service_delete_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.delete', + ) + # act + response = api_client.delete( + f'/fieldsets/{fieldset.id}', + ) + + # assert + assert response.status_code == 403 + assert response.data['detail'] == MSG_A_0035 + field_set_template_service_init_mock.assert_not_called() + field_set_template_service_delete_mock.assert_not_called() + + +def test_destroy__billing_plan__permission_denied(api_client, mocker): + """Billing plan permission denied returns 403""" + + # arrange + account = create_test_account(plan=None) + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + + api_client.token_authenticate(user=user) + + field_set_template_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + field_set_template_service_delete_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.delete', + ) + # act + response = api_client.delete( + f'/fieldsets/{fieldset.id}', + ) + + # assert + assert response.status_code == 403 + assert response.data['detail'] == MSG_A_0041 + field_set_template_service_init_mock.assert_not_called() + field_set_template_service_delete_mock.assert_not_called() + + +def test_destroy__users_overlimit__permission_denied(api_client, mocker): + """Users overlimited returns 403""" + + # arrange + account = create_test_account( + plan=BillingPlanType.PREMIUM, + max_users=1, + ) + user = create_test_owner(account=account) + create_test_not_admin( + account=account, + email='extra@pneumatic.app', + ) + account.active_users = 2 + account.save() + fieldset = create_test_shared_fieldset( + account=account, + ) + + api_client.token_authenticate(user=user) + + field_set_template_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + field_set_template_service_delete_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.delete', + ) + # act + response = api_client.delete( + f'/fieldsets/{fieldset.id}', + ) + + # assert + assert response.status_code == 403 + assert response.data['detail'] == MSG_A_0037 + field_set_template_service_init_mock.assert_not_called() + field_set_template_service_delete_mock.assert_not_called() + + +def test_destroy__non_admin__permission_denied(api_client, mocker): + """Non-admin non-owner user returns 403""" + + # arrange + account = create_test_account() + create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + user = create_test_not_admin(account=account) + + api_client.token_authenticate(user=user) + + field_set_template_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + field_set_template_service_delete_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.delete', + ) + # act + response = api_client.delete( + f'/fieldsets/{fieldset.id}', + ) + + # assert + assert response.status_code == 403 + field_set_template_service_init_mock.assert_not_called() + field_set_template_service_delete_mock.assert_not_called() + + +def test_destroy__service_exception__validation_error(api_client, mocker): + """Service raises BaseServiceException returns validation error""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + error_message = 'Service error occurred' + + # mock FieldSetTemplateService + field_set_template_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + field_set_template_service_delete_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.delete', + side_effect=BaseServiceException(message=error_message), + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.delete( + f'/fieldsets/{fieldset.id}', + ) + + # assert + assert response.status_code == 400 + assert response.data['message'] == error_message + field_set_template_service_init_mock.assert_called_once_with( + user=user, + instance=fieldset, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + field_set_template_service_delete_mock.assert_called_once_with() + + +def test_destroy__not_existing__not_found(api_client, mocker): + """Non-existent fieldset returns 404""" + + # arrange + mocker.Mock(id=1, api_name="dummy") + account = create_test_account() + user = create_test_owner(account=account) + nonexistent_id = 999999 + + api_client.token_authenticate(user=user) + + field_set_template_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + field_set_template_service_delete_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.delete', + ) + # act + response = api_client.delete( + f'/fieldsets/{nonexistent_id}', + ) + + # assert + assert response.status_code == 404 + + field_set_template_service_init_mock.assert_not_called() + field_set_template_service_delete_mock.assert_not_called() + + +def test_destroy__not_shared__not_found(api_client, mocker): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + fieldset.is_shared = False + fieldset.save() + field_set_template_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + field_set_template_service_delete_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.delete', + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.delete( + f'/fieldsets/{fieldset.id}', + ) + + # assert + assert response.status_code == 404 + field_set_template_service_init_mock.assert_not_called() + field_set_template_service_delete_mock.assert_not_called() diff --git a/backend/src/processes/tests/test_views/test_fieldsets/test_list.py b/backend/src/processes/tests/test_views/test_fieldsets/test_list.py new file mode 100644 index 000000000..ee7b470e4 --- /dev/null +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_list.py @@ -0,0 +1,737 @@ + +import pytest +from datetime import timedelta + +from django.utils import timezone + +from src.accounts.enums import BillingPlanType +from src.accounts.messages import MSG_A_0035, MSG_A_0037, MSG_A_0041 +from src.processes.enums import ( + FieldSetRuleType, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_admin, + create_test_shared_fieldset, + create_test_not_admin, + create_test_owner, + create_test_template, +) +from src.processes.models.templates.fieldset import FieldsetTemplate +from src.utils.validation import ErrorCode + +pytestmark = pytest.mark.django_db + + +def test_list_fieldsets__all_data__ok(api_client): + """List fieldsets returning all fields including title and order""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + create_test_template( + user=user, + tasks_count=1, + ) + rule_type = FieldSetRuleType.SUM_EQUAL + rule_value = '10' + fieldset = create_test_shared_fieldset( + account=account, + title='Fieldset Title', + order=3, + rule_type=rule_type, + rule_value=rule_value, + ) + field = fieldset.fields.get() + rule = fieldset.rules.get() + + api_client.token_authenticate(user=user) + + # act + response = api_client.get('/fieldsets') + + # assert + assert response.status_code == 200 + assert len(response.data) == 1 + item_1 = response.data[0] + assert item_1['id'] == fieldset.id + assert item_1['api_name'] == fieldset.api_name + assert item_1['name'] == fieldset.name + assert item_1['title'] == 'Fieldset Title' + assert item_1['order'] == 3 + assert item_1['description'] == '' + assert item_1['layout'] == fieldset.layout + assert item_1['label_position'] == fieldset.label_position + + assert len(item_1['rules']) == 1 + rules_data = item_1['rules'] + assert rules_data[0]['type'] == rule_type + assert rules_data[0]['value'] == rule_value + assert rules_data[0]['api_name'] == rule.api_name + + assert len(item_1['fields']) == 1 + fields_data = item_1['fields'] + assert fields_data[0]['name'] == field.name + assert fields_data[0]['type'] == field.type + assert fields_data[0]['api_name'] == field.api_name + assert fields_data[0]['description'] == '' + assert fields_data[0]['is_required'] is False + assert fields_data[0]['is_hidden'] is False + assert fields_data[0]['default'] == '' + assert 'dataset' not in fields_data[0] + assert 'selections' not in fields_data[0] + + +def test_list_fieldsets__shared_fieldset_has_rules_and_fields__ok(api_client): + + """List shared fieldsets returns rules and fields""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + rule_type = FieldSetRuleType.SUM_EQUAL + rule_value = '10' + fieldset = create_test_shared_fieldset( + account=account, + rule_type=rule_type, + rule_value=rule_value, + ) + field = fieldset.fields.get() + rule = fieldset.rules.get() + + api_client.token_authenticate(user=user) + + # act + response = api_client.get('/fieldsets') + + # assert + assert response.status_code == 200 + data = response.data[0] + assert data['id'] == fieldset.id + assert len(data['rules']) == 1 + assert data['rules'][0]['api_name'] == rule.api_name + assert len(data['fields']) == 1 + assert data['fields'][0]['api_name'] == field.api_name + + +def test_list_fieldsets__pagination__ok(api_client): + """Paginated list returns correct count and slice""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + create_test_template( + user=user, + tasks_count=1, + ) + fieldset_1 = create_test_shared_fieldset( + account=account, + ) + fieldset_2 = create_test_shared_fieldset( + account=account, + ) + create_test_shared_fieldset( + account=account, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + '/fieldsets', + data={'limit': 2, 'offset': 1}, + ) + + # assert + assert response.status_code == 200 + assert response.data['count'] == 3 + assert len(response.data['results']) == 2 + + item_1 = response.data['results'][0] + assert item_1['id'] == fieldset_2.id + + item_2 = response.data['results'][1] + assert item_2['id'] == fieldset_1.id + + +def test_list_fieldsets__different_accounts__ok(api_client): + """List fieldsets filtered by account — other accounts excluded""" + + # arrange + account_1 = create_test_account(name='Account 1') + user_1 = create_test_owner(account=account_1) + create_test_template( + user=user_1, + tasks_count=1, + ) + fieldset_1 = create_test_shared_fieldset( + account=account_1, + ) + + account_2 = create_test_account(name='Account 2') + create_test_owner( + account=account_2, + email='owner2@pneumatic.app', + ) + create_test_shared_fieldset( + account=account_2, + ) + + api_client.token_authenticate(user=user_1) + + # act + response = api_client.get('/fieldsets') + + # assert + assert response.status_code == 200 + assert len(response.data) == 1 + assert response.data[0]['id'] == fieldset_1.id + + +def test_list_fieldsets__rule_with_fields__ok(api_client): + """List fieldsets returning rules mapping to fields""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + create_test_template( + user=user, + tasks_count=1, + ) + rule_type = FieldSetRuleType.SUM_EQUAL + rule_value = '10' + fieldset = create_test_shared_fieldset( + account=account, + rule_type=rule_type, + rule_value=rule_value, + ) + field = fieldset.fields.get() + rule = fieldset.rules.get() + rule.fields.add(field) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get('/fieldsets') + + # assert + assert response.status_code == 200 + assert len(response.data) == 1 + item_1 = response.data[0] + + assert len(item_1['rules']) == 1 + rules_data = item_1['rules'] + assert rules_data[0]['fields'] == [field.api_name] + + +def test_list_fieldsets__unauthenticated__unauthorized(api_client): + """Unauthenticated request returns 401""" + + # act + response = api_client.get('/fieldsets') + + # assert + assert response.status_code == 401 + + +def test_list_fieldsets__expired_sub__permission_denied(api_client): + """Expired subscription returns 403""" + + # arrange + account = create_test_account( + plan=BillingPlanType.PREMIUM, + plan_expiration=timezone.now() - timedelta(days=1), + ) + user = create_test_owner(account=account) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get('/fieldsets') + + # assert + assert response.status_code == 403 + assert response.data['detail'] == MSG_A_0035 + + +def test_list_fieldsets__billing_plan__permission_denied(api_client): + """Billing plan permission denied returns 403""" + + # arrange + account = create_test_account(plan=None) + user = create_test_owner(account=account) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get('/fieldsets') + + # assert + assert response.status_code == 403 + assert response.data['detail'] == MSG_A_0041 + + +def test_list_fieldsets__users_overlimit__permission_denied(api_client): + """Users overlimited returns 403""" + + # arrange + account = create_test_account( + plan=BillingPlanType.PREMIUM, + max_users=1, + ) + user = create_test_owner(account=account) + create_test_not_admin( + account=account, + email='extra@pneumatic.app', + ) + account.active_users = 2 + account.save() + + api_client.token_authenticate(user=user) + + # act + response = api_client.get('/fieldsets') + + # assert + assert response.status_code == 403 + assert response.data['detail'] == MSG_A_0037 + + +def test_list_fieldsets__non_admin__permission_denied(api_client): + """Non-admin non-owner user returns 403""" + + # arrange + account = create_test_account() + create_test_owner(account=account) + user = create_test_not_admin(account=account) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get('/fieldsets') + + # assert + assert response.status_code == 403 + + +def test_list_fieldsets__admin__ok(api_client): + """Admin (non-owner) user can list fieldsets""" + + # arrange + account = create_test_account() + create_test_owner(account=account) + user = create_test_admin(account=account) + create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_shared_fieldset( + account=account, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get('/fieldsets') + + # assert + assert response.status_code == 200 + assert len(response.data) == 1 + assert response.data[0]['id'] == fieldset.id + + +def test_list_fieldsets__no_ordering__ok(api_client): + + """ No ordering param — default -date_created """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + create_test_template( + user=user, + tasks_count=1, + ) + now = timezone.now() + fieldset_1 = create_test_shared_fieldset( + account=account, + name='Oldest', + ) + FieldsetTemplate.objects.filter(id=fieldset_1.id).update( + date_created=now - timedelta(days=2), + ) + fieldset_2 = create_test_shared_fieldset( + account=account, + name='Middle', + ) + FieldsetTemplate.objects.filter(id=fieldset_2.id).update( + date_created=now - timedelta(days=1), + ) + fieldset_3 = create_test_shared_fieldset( + account=account, + name='Newest', + ) + FieldsetTemplate.objects.filter(id=fieldset_3.id).update( + date_created=now, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get('/fieldsets') + + # assert + assert response.status_code == 200 + assert len(response.data) == 3 + item_1 = response.data[0] + assert item_1['id'] == fieldset_3.id + item_2 = response.data[1] + assert item_2['id'] == fieldset_2.id + item_3 = response.data[2] + assert item_3['id'] == fieldset_1.id + + +def test_list_fieldsets__ordering_name_asc__ok(api_client): + + """ ordering=name — ascending by name """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + create_test_template( + user=user, + tasks_count=1, + ) + fieldset_1 = create_test_shared_fieldset( + account=account, + name='Alpha', + ) + fieldset_2 = create_test_shared_fieldset( + account=account, + name='Beta', + ) + fieldset_3 = create_test_shared_fieldset( + account=account, + name='Gamma', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + '/fieldsets', + data={'ordering': 'name'}, + ) + + # assert + assert response.status_code == 200 + assert len(response.data) == 3 + item_1 = response.data[0] + assert item_1['id'] == fieldset_1.id + assert item_1['name'] == 'Alpha' + item_2 = response.data[1] + assert item_2['id'] == fieldset_2.id + assert item_2['name'] == 'Beta' + item_3 = response.data[2] + assert item_3['id'] == fieldset_3.id + assert item_3['name'] == 'Gamma' + + +def test_list_fieldsets__ordering_name_desc__ok(api_client): + + """ ordering=-name — descending by name """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + create_test_template( + user=user, + tasks_count=1, + ) + fieldset_1 = create_test_shared_fieldset( + account=account, + name='Alpha', + ) + fieldset_2 = create_test_shared_fieldset( + account=account, + name='Beta', + ) + fieldset_3 = create_test_shared_fieldset( + account=account, + name='Gamma', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + '/fieldsets', + data={'ordering': '-name'}, + ) + + # assert + assert response.status_code == 200 + assert len(response.data) == 3 + item_1 = response.data[0] + assert item_1['id'] == fieldset_3.id + assert item_1['name'] == 'Gamma' + item_2 = response.data[1] + assert item_2['id'] == fieldset_2.id + assert item_2['name'] == 'Beta' + item_3 = response.data[2] + assert item_3['id'] == fieldset_1.id + assert item_3['name'] == 'Alpha' + + +def test_list_fieldsets__ordering_date_asc__ok(api_client): + + """ ordering=date — ascending by date_created """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + create_test_template( + user=user, + tasks_count=1, + ) + now = timezone.now() + fieldset_1 = create_test_shared_fieldset( + account=account, + name='Oldest', + ) + FieldsetTemplate.objects.filter(id=fieldset_1.id).update( + date_created=now - timedelta(days=2), + ) + fieldset_2 = create_test_shared_fieldset( + account=account, + name='Middle', + ) + FieldsetTemplate.objects.filter(id=fieldset_2.id).update( + date_created=now - timedelta(days=1), + ) + fieldset_3 = create_test_shared_fieldset( + account=account, + name='Newest', + ) + FieldsetTemplate.objects.filter(id=fieldset_3.id).update( + date_created=now, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + '/fieldsets', + data={'ordering': 'date'}, + ) + + # assert + assert response.status_code == 200 + assert len(response.data) == 3 + item_1 = response.data[0] + assert item_1['id'] == fieldset_1.id + item_2 = response.data[1] + assert item_2['id'] == fieldset_2.id + item_3 = response.data[2] + assert item_3['id'] == fieldset_3.id + + +def test_list_fieldsets__ordering_date_desc__ok(api_client): + + """ ordering=-date — descending by date_created """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + create_test_template( + user=user, + tasks_count=1, + ) + now = timezone.now() + fieldset_1 = create_test_shared_fieldset( + account=account, + name='Oldest', + ) + FieldsetTemplate.objects.filter(id=fieldset_1.id).update( + date_created=now - timedelta(days=2), + ) + fieldset_2 = create_test_shared_fieldset( + account=account, + name='Middle', + ) + FieldsetTemplate.objects.filter(id=fieldset_2.id).update( + date_created=now - timedelta(days=1), + ) + fieldset_3 = create_test_shared_fieldset( + account=account, + name='Newest', + ) + FieldsetTemplate.objects.filter(id=fieldset_3.id).update( + date_created=now, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + '/fieldsets', + data={'ordering': '-date'}, + ) + + # assert + assert response.status_code == 200 + assert len(response.data) == 3 + item_1 = response.data[0] + assert item_1['id'] == fieldset_3.id + item_2 = response.data[1] + assert item_2['id'] == fieldset_2.id + item_3 = response.data[2] + assert item_3['id'] == fieldset_1.id + + +def test_list_fieldsets__no_pagination__ok(api_client): + + """ No pagination params — flat list response """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + create_test_template( + user=user, + tasks_count=1, + ) + create_test_shared_fieldset( + account=account, + name='First', + ) + create_test_shared_fieldset( + account=account, + name='Second', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get('/fieldsets') + + # assert + assert response.status_code == 200 + assert isinstance(response.data, list) + assert len(response.data) == 2 + + +def test_list_fieldsets__ordering_invalid__validation_error(api_client): + + """ Invalid ordering value returns validation error """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + create_test_template( + user=user, + tasks_count=1, + ) + create_test_shared_fieldset( + account=account, + name='First', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + '/fieldsets', + data={'ordering': 'foobar'}, + ) + + # assert + assert response.status_code == 400 + message = '"foobar" is not a valid choice.' + assert response.data['message'] == message + assert response.data['code'] == ErrorCode.VALIDATION_ERROR + + +def test_list_fieldsets__ordering_empty__ok(api_client): + + """ Empty ordering value falls back to default """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + create_test_template( + user=user, + tasks_count=1, + ) + now = timezone.now() + fieldset_1 = create_test_shared_fieldset( + account=account, + name='First', + ) + FieldsetTemplate.objects.filter(id=fieldset_1.id).update( + date_created=now - timedelta(days=1), + ) + fieldset_2 = create_test_shared_fieldset( + account=account, + name='Second', + ) + FieldsetTemplate.objects.filter(id=fieldset_2.id).update( + date_created=now, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + '/fieldsets', + data={'ordering': ''}, + ) + + # assert + assert response.status_code == 200 + assert len(response.data) == 2 + + # default ordering is -date_created (newest first) + item_1 = response.data[0] + assert item_1['id'] == fieldset_2.id + item_2 = response.data[1] + assert item_2['id'] == fieldset_1.id + + +def test_list_fieldsets__soft_deleted__ok(api_client): + + """ Soft-deleted fieldsets are excluded """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_shared_fieldset( + account=account, + name='Deleted Fieldset', + ) + FieldsetTemplate.objects.filter(id=fieldset.id).update( + is_deleted=True, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get('/fieldsets') + + # assert + assert response.status_code == 200 + assert len(response.data) == 0 + + +def test_list_fieldsets__not_shared__empty_list(api_client): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + fieldset.is_shared = False + fieldset.save() + + api_client.token_authenticate(user=user) + + # act + response = api_client.get('/fieldsets') + + # assert + assert response.status_code == 200 + assert len(response.data) == 0 diff --git a/backend/src/processes/tests/test_views/test_fieldsets/test_partial_update.py b/backend/src/processes/tests/test_views/test_fieldsets/test_partial_update.py new file mode 100644 index 000000000..39846c015 --- /dev/null +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_partial_update.py @@ -0,0 +1,810 @@ +import pytest +from datetime import timedelta + +from django.utils import timezone + +from src.accounts.enums import BillingPlanType +from src.accounts.messages import MSG_A_0035, MSG_A_0037, MSG_A_0041 +from src.authentication.enums import AuthTokenType +from src.generics.exceptions import BaseServiceException +from src.processes.enums import ( + FieldSetLayout, + LabelPosition, + FieldSetRuleType, + FieldType, +) +from src.processes.models.templates.fieldset import FieldsetTemplateRule +from src.processes.models.templates.fields import FieldTemplate +from src.processes.services.fieldsets.fieldset import ( + FieldSetTemplateService, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_not_admin, + create_test_owner, + create_test_shared_fieldset, +) +from src.utils.validation import ErrorCode + +pytestmark = pytest.mark.django_db + + +def test_partial_update__all_fields__ok(api_client, mocker): + + """ Partial update with full request data """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + field_api_name = 'f1' + fieldset_api_name = 'fs1' + rule_api_name = 'r1' + data = { + 'name': 'Full Updated Fieldset', + 'description': 'Updated description', + 'api_name': fieldset_api_name, + 'layout': FieldSetLayout.HORIZONTAL, + 'label_position': LabelPosition.LEFT, + 'fields': [ + { + 'name': 'Field 1', + 'type': FieldType.NUMBER, + 'order': 1, + 'api_name': field_api_name, + }, + ], + 'rules': [ + { + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '10', + 'api_name': rule_api_name, + 'fields': [field_api_name], + }, + ], + } + fieldset = create_test_shared_fieldset( + account=account, + name=data['name'], + description=data['description'], + label_position=data['label_position'], + layout=data['layout'], + api_name=data['api_name'], + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + field = fieldset.fields.first() + rule = fieldset.rules.first() + rule.fields.add(field) + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + return_value=fieldset, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 200 + assert response.data['id'] == fieldset.id + assert response.data['name'] == data['name'] + assert response.data['description'] == data['description'] + assert response.data['label_position'] == data['label_position'] + assert response.data['layout'] == data['layout'] + assert response.data['api_name'] == data['api_name'] + + assert len(response.data['fields']) == 1 + assert response.data['fields'][0]['name'] == field.name + assert response.data['fields'][0]['api_name'] == field.api_name + + assert len(response.data['rules']) == 1 + assert response.data['rules'][0]['type'] == rule.type + assert response.data['rules'][0]['value'] == rule.value + assert response.data['rules'][0]['api_name'] == rule.api_name + assert response.data['rules'][0]['fields'] == [field.api_name] + fieldset_service_init_mock.assert_called_once_with( + user=user, + instance=fieldset, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + fieldset_partial_update_mock.assert_called_once_with( + name='Full Updated Fieldset', + api_name=fieldset.api_name, + description='Updated description', + layout=FieldSetLayout.HORIZONTAL, + label_position=LabelPosition.LEFT, + rules=data['rules'], + fields=data['fields'], + ) + + +def test_partial_update__name__ok(api_client, mocker): + + """ Partial update with minimal request data """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + data = { + 'name': 'Updated Name', + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + return_value=fieldset, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 200 + assert response.data['id'] == fieldset.id + fieldset_service_init_mock.assert_called_once_with( + user=user, + instance=fieldset, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + fieldset_partial_update_mock.assert_called_once_with( + name=data['name'], + ) + + +def test_partial_update__with_rule_fields__ok(api_client, mocker): + + """ + Partial update with fields in rule request + and check fields in response + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + field_api_name = 'f1' + data = { + 'name': 'Updated Fieldset', + 'fields': [ + { + 'name': 'Field 1', + 'type': FieldType.STRING, + 'order': 1, + 'api_name': field_api_name, + }, + ], + 'rules': [ + { + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '10', + 'api_name': 'r1', + 'fields': [field_api_name], + }, + ], + } + + # mock FieldSetTemplateService + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + # Pre-add the field to the fieldset for the mock response verification + field = fieldset.fields.first() + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='val', + api_name='r1', + ) + rule.fields.add(field) + + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + return_value=fieldset, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 200 + assert response.data['id'] == fieldset.id + + assert len(response.data['rules']) == 1 + assert response.data['rules'][0]['api_name'] == 'r1' + assert response.data['rules'][0]['fields'] == [field.api_name] + + fieldset_service_init_mock.assert_called_once_with( + user=user, + instance=fieldset, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + fieldset_partial_update_mock.assert_called_once_with( + name='Updated Fieldset', + rules=data['rules'], + fields=data['fields'], + ) + + +def test_partial_update__clear_fields__ok(api_client, mocker): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + data = { + 'name': 'Updated Fieldset', + 'rules': [ + { + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '10', + 'api_name': 'r1', + 'fields': [], + }, + ], + } + + # mock FieldSetTemplateService + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + return_value=fieldset, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 200 + fieldset_service_init_mock.assert_called_once_with( + user=user, + instance=fieldset, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + fieldset_partial_update_mock.assert_called_once_with( + name='Updated Fieldset', + rules=data['rules'], + ) + + +def test_partial_update__unauthenticated__unauthorized(api_client, mocker): + + """ Unauthenticated request returns 401 """ + + # arrange + account = create_test_account() + fieldset = create_test_shared_fieldset( + account=account, + ) + data = { + 'name': 'Updated Fieldset', + } + + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + ) + # act + response = api_client.patch( + f'/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 401 + fieldset_service_init_mock.assert_not_called() + fieldset_partial_update_mock.assert_not_called() + + +def test_partial_update__expired_sub__permission_denied(api_client, mocker): + + """ Expired subscription returns 403 """ + + # arrange + account = create_test_account( + plan=BillingPlanType.PREMIUM, + plan_expiration=timezone.now() - timedelta(days=1), + ) + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + data = { + 'name': 'Updated Fieldset', + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 403 + assert response.data['detail'] == MSG_A_0035 + fieldset_service_init_mock.assert_not_called() + fieldset_partial_update_mock.assert_not_called() + + +def test_partial_update__billing_plan__permission_denied(api_client, mocker): + + """ Billing plan permission denied returns 403 """ + + # arrange + account = create_test_account(plan=None) + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + data = { + 'name': 'Updated Fieldset', + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 403 + assert response.data['detail'] == MSG_A_0041 + fieldset_service_init_mock.assert_not_called() + fieldset_partial_update_mock.assert_not_called() + + +def test_partial_update__users_limit__permission_denied(api_client, mocker): + + """ Users overlimited returns 403 """ + + # arrange + account = create_test_account( + plan=BillingPlanType.PREMIUM, + max_users=1, + ) + user = create_test_owner(account=account) + create_test_not_admin( + account=account, + email='extra@pneumatic.app', + ) + account.active_users = 2 + account.save() + fieldset = create_test_shared_fieldset( + account=account, + ) + data = { + 'name': 'Updated Fieldset', + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 403 + assert response.data['detail'] == MSG_A_0037 + fieldset_service_init_mock.assert_not_called() + fieldset_partial_update_mock.assert_not_called() + + +def test_partial_update__non_admin__permission_denied(api_client, mocker): + + """ Non-admin non-owner user returns 403 """ + + # arrange + account = create_test_account() + create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + user = create_test_not_admin(account=account) + data = { + 'name': 'Updated Fieldset', + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 403 + fieldset_service_init_mock.assert_not_called() + fieldset_partial_update_mock.assert_not_called() + + +def test_partial_update__invalid_name__validation_error(api_client, mocker): + + """ Invalid name field returns validation error """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + data = { + 'name': '', + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 400 + message = 'This field may not be blank.' + assert response.data['message'] == message + assert response.data['details']['name'] == 'name' + fieldset_service_init_mock.assert_not_called() + fieldset_partial_update_mock.assert_not_called() + + +def test_partial_update__invalid_layout__validation_error(api_client, mocker): + + """ Invalid layout field returns validation error """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + data = { + 'layout': 'invalid_layout', + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 400 + message = '"invalid_layout" is not a valid choice.' + assert response.data['message'] == message + assert response.data['details']['name'] == 'layout' + fieldset_service_init_mock.assert_not_called() + fieldset_partial_update_mock.assert_not_called() + + +def test_partial_update__invalid_label_position__validation_error( + api_client, + mocker, +): + + """ Invalid label_position field returns validation error """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + data = { + 'label_position': 'invalid_position', + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 400 + message = '"invalid_position" is not a valid choice.' + assert response.data['message'] == message + assert response.data['details']['name'] == 'label_position' + fieldset_service_init_mock.assert_not_called() + fieldset_partial_update_mock.assert_not_called() + + +def test_partial_update__service_exception__validation_error( + api_client, + mocker, +): + """Service raises BaseServiceException returns validation error""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + data = { + 'name': 'Updated Fieldset', + } + error_message = 'Service error occurred' + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + side_effect=BaseServiceException(message=error_message), + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 400 + assert response.data['message'] == error_message + assert response.data['code'] == ErrorCode.VALIDATION_ERROR + fieldset_service_init_mock.assert_called_once_with( + user=user, + instance=fieldset, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + fieldset_partial_update_mock.assert_called_once_with( + name=data['name'], + ) + + +def test_partial_update__not_existing_fieldset__not_found(api_client, mocker): + + """ Non-existent fieldset returns 404 """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + nonexistent_id = 999999 + data = { + 'name': 'Updated Fieldset', + } + + api_client.token_authenticate(user=user) + + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + ) + # act + response = api_client.patch( + f'/fieldsets/{nonexistent_id}', + data=data, + ) + + # assert + assert response.status_code == 404 + fieldset_service_init_mock.assert_not_called() + fieldset_partial_update_mock.assert_not_called() + + +def test_partial_update__not_shared__not_found(api_client, mocker): + + """ Partial update with minimal request data """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + fieldset.is_shared = False + fieldset.save() + data = { + 'name': 'Updated Name', + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + return_value=fieldset, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 404 + fieldset_service_init_mock.assert_not_called() + fieldset_partial_update_mock.assert_not_called() + + +def test_partial_update__two_rules_same_type__ok(api_client, mocker): + + """ + Partial update with two rules of the same type: + one existing (with api_name) and one new (without api_name). + Both rules reference the same fields. + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + field_1 = fieldset.fields.first() + field_2 = FieldTemplate.objects.create( + account=account, + name='Field 2', + type=FieldType.NUMBER, + api_name='f2', + fieldset=fieldset, + ) + existing_rule = fieldset.rules.first() + existing_rule.fields.add(field_1) + existing_rule.fields.add(field_2) + + data = { + 'rules': [ + { + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '1', + 'fields': [field_1.api_name, field_2.api_name], + 'api_name': existing_rule.api_name, + }, + { + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '100', + 'fields': [field_1.api_name, field_2.api_name], + }, + ], + } + + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + return_value=fieldset, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 200 + fieldset_service_init_mock.assert_called_once_with( + user=user, + instance=fieldset, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + fieldset_partial_update_mock.assert_called_once_with( + rules=data['rules'], + ) diff --git a/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py b/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py new file mode 100644 index 000000000..4e2dc2d57 --- /dev/null +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py @@ -0,0 +1,268 @@ +import pytest +from datetime import timedelta + +from django.utils import timezone + +from src.accounts.enums import BillingPlanType +from src.accounts.messages import MSG_A_0035, MSG_A_0037, MSG_A_0041 +from src.processes.enums import FieldSetRuleType +from src.processes.tests.fixtures import ( + create_test_account, + create_test_shared_fieldset, + create_test_not_admin, + create_test_owner, +) + +pytestmark = pytest.mark.django_db + + +def test_retrieve__fieldset_all_data__ok(api_client): + """Retrieve existing fieldset""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + rule_type = FieldSetRuleType.SUM_EQUAL + rule_value = '10' + fieldset = create_test_shared_fieldset( + account=account, + rule_type=rule_type, + rule_value=rule_value, + ) + field = fieldset.fields.get() + rule = fieldset.rules.get() + rule.fields.add(field) + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/fieldsets/{fieldset.id}') + + # assert + assert response.status_code == 200 + assert response.data['id'] == fieldset.id + assert response.data['api_name'] == fieldset.api_name + assert response.data['name'] == fieldset.name + assert response.data['title'] == fieldset.title + assert response.data['description'] == fieldset.description + assert response.data['order'] == fieldset.order + assert response.data['layout'] == fieldset.layout + assert response.data['label_position'] == fieldset.label_position + + assert len(response.data['rules']) == 1 + assert response.data['rules'][0]['type'] == rule.type + assert response.data['rules'][0]['value'] == rule.value + assert response.data['rules'][0]['api_name'] == rule.api_name + assert response.data['rules'][0]['fields'] == [field.api_name] + + assert len(response.data['fields']) == 1 + assert response.data['fields'][0]['name'] == field.name + assert response.data['fields'][0]['description'] == '' + assert response.data['fields'][0]['type'] == field.type + assert response.data['fields'][0]['is_required'] == field.is_required + assert response.data['fields'][0]['is_hidden'] == field.is_hidden + assert response.data['fields'][0]['api_name'] == field.api_name + assert response.data['fields'][0]['default'] == field.default + assert response.data['fields'][0]['order'] == field.order + + +def test_retrieve__fieldset_rule_with_fields__ok(api_client): + + # arrange + account_1 = create_test_account(name='Account 1') + user_1 = create_test_owner(account=account_1) + fieldset = create_test_shared_fieldset( + account=account_1, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='10', + ) + field = fieldset.fields.get() + rule = fieldset.rules.get() + rule.fields.add(field) + + api_client.token_authenticate(user=user_1) + + # act + response = api_client.get(f'/fieldsets/{fieldset.id}') + + # assert + assert response.status_code == 200 + assert response.data['id'] == fieldset.id + assert len(response.data['rules']) == 1 + assert response.data['rules'][0]['type'] == rule.type + assert response.data['rules'][0]['value'] == rule.value + assert response.data['rules'][0]['api_name'] == rule.api_name + assert response.data['rules'][0]['fields'] == [field.api_name] + + +def test_retrieve__unauthenticated__unauthorized(api_client): + """Unauthenticated request returns 401""" + + # arrange + account = create_test_account() + create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + + # act + response = api_client.get(f'/fieldsets/{fieldset.id}') + + # assert + assert response.status_code == 401 + + +def test_retrieve__expired_sub__permission_denied(api_client): + """Expired subscription returns 403""" + + # arrange + account = create_test_account( + plan=BillingPlanType.PREMIUM, + plan_expiration=timezone.now() - timedelta(days=1), + ) + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/fieldsets/{fieldset.id}') + + # assert + assert response.status_code == 403 + assert response.data['detail'] == MSG_A_0035 + + +def test_retrieve__billing_plan__permission_denied(api_client): + """Billing plan permission denied returns 403""" + + # arrange + account = create_test_account(plan=None) + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/fieldsets/{fieldset.id}') + + # assert + assert response.status_code == 403 + assert response.data['detail'] == MSG_A_0041 + + +def test_retrieve__users_overlimit__permission_denied(api_client): + """Users overlimited returns 403""" + + # arrange + account = create_test_account( + plan=BillingPlanType.PREMIUM, + max_users=1, + ) + user = create_test_owner(account=account) + create_test_not_admin( + account=account, + email='extra@pneumatic.app', + ) + account.active_users = 2 + account.save() + fieldset = create_test_shared_fieldset( + account=account, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/fieldsets/{fieldset.id}') + + # assert + assert response.status_code == 403 + assert response.data['detail'] == MSG_A_0037 + + +def test_retrieve__non_admin__permission_denied(api_client): + """Non-admin non-owner user returns 403""" + + # arrange + account = create_test_account() + create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + ) + user = create_test_not_admin(account=account) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/fieldsets/{fieldset.id}') + + # assert + assert response.status_code == 403 + + +def test_retrieve__not_existing__not_found(api_client): + """Non-existent fieldset returns 404""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + nonexistent_id = 999999 + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/fieldsets/{nonexistent_id}') + + # assert + assert response.status_code == 404 + + +def test_retrieve__another_account__not_found(api_client): + + """Fieldset from another account returns 404""" + + # arrange + account_1 = create_test_account(name='Account 1') + create_test_owner(account=account_1) + fieldset = create_test_shared_fieldset( + account=account_1, + ) + + account_2 = create_test_account(name='Account 2') + user_2 = create_test_owner( + account=account_2, + email='owner2@pneumatic.app', + ) + + api_client.token_authenticate(user=user_2) + + # act + response = api_client.get(f'/fieldsets/{fieldset.id}') + + # assert + assert response.status_code == 404 + + +def test_retrieve__not_shared__not_found(api_client): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + fieldset = create_test_shared_fieldset( + account=account, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='10', + ) + fieldset.is_shared = False + fieldset.save() + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/fieldsets/{fieldset.id}') + + # assert + assert response.status_code == 404 diff --git a/backend/src/processes/tests/test_views/test_tasks/test_events.py b/backend/src/processes/tests/test_views/test_tasks/test_events.py index d460285a9..6bdd63fe7 100644 --- a/backend/src/processes/tests/test_views/test_tasks/test_events.py +++ b/backend/src/processes/tests/test_views/test_tasks/test_events.py @@ -4,6 +4,7 @@ from src.authentication.services.guest_auth import GuestJWTAuthService from src.processes.enums import WorkflowEventType, FieldType from src.processes.models.workflows.fields import TaskField +from src.processes.models.workflows.fieldset import FieldSet from src.processes.models.workflows.task import TaskPerformer from src.processes.services.events import ( WorkflowEventService, @@ -599,3 +600,159 @@ def test_events__task_complete_with_dataset__ok(api_client): assert field_data['api_name'] == field.api_name assert field_data['value'] == dataset_item.value assert field_data['order'] == field.order + + +def test_events__task_complete_fieldsets_present__ok(api_client): + + """ + GET task events: TASK_COMPLETE row includes non-null task.fieldsets when + the task has at least one FieldSet. + """ + + # arrange + + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user, tasks_count=1) + task_1 = workflow.tasks.get(number=1) + fieldset = FieldSet.objects.create( + account=account, + workflow=workflow, + task=task_1, + name='Fieldset 1', + order=1, + ) + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + task=task_1, + fieldset=fieldset, + name='Field 1', + type=FieldType.TEXT, + order=1, + ) + field_2 = TaskField.objects.create( + account=account, + workflow=workflow, + task=task_1, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + order=2, + ) + WorkflowEventService.task_complete_event( + task=task_1, + user=user, + after_create_actions=False, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + path=f'/v2/tasks/{task_1.id}/events', + ) + + # assert + assert response.status_code == 200 + event_data = response.data[0] + assert event_data['type'] == WorkflowEventType.TASK_COMPLETE + fieldsets_data = event_data['task']['fieldsets'] + assert fieldsets_data is not None + assert len(fieldsets_data) == 1 + fieldset.refresh_from_db() + field_1.refresh_from_db() + field_2.refresh_from_db() + fieldset_data = fieldsets_data[0] + assert fieldset_data['id'] == fieldset.id + assert fieldset_data['api_name'] == fieldset.api_name + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['order'] == fieldset.order + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + fields_data = fieldset_data['fields'] + assert len(fields_data) == 2 + field_2_data = fields_data[0] + assert field_2_data['id'] == field_2.id + assert field_2_data['order'] == field_2.order + assert field_2_data['type'] == field_2.type + assert field_2_data['is_required'] == field_2.is_required + assert field_2_data['is_hidden'] == field_2.is_hidden + assert field_2_data['description'] == field_2.description + assert field_2_data['api_name'] == field_2.api_name + assert field_2_data['name'] == field_2.name + assert field_2_data['value'] == field_2.value + assert field_2_data['markdown_value'] == field_2.markdown_value + assert field_2_data['clear_value'] == field_2.clear_value + assert field_2_data['user_id'] == field_2.user_id + assert field_2_data['group_id'] == field_2.group_id + assert field_2_data['selections'] == [] + assert field_2_data['attachments'] == [] + field_1_data = fields_data[1] + assert field_1_data['id'] == field_1.id + + +def test_events__task_complete_fieldsets_absent__ok(api_client): + + """ + GET task events: TASK_COMPLETE row has task.fieldsets equal to null when + the task has no FieldSet rows. + """ + + # arrange + + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user, tasks_count=1) + task_1 = workflow.tasks.get(number=1) + WorkflowEventService.task_complete_event( + task=task_1, + user=user, + after_create_actions=False, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + path=f'/v2/tasks/{task_1.id}/events', + ) + + # assert + + assert response.status_code == 200 + item_1 = response.data[0] + assert item_1['type'] == WorkflowEventType.TASK_COMPLETE + task_payload = item_1['task'] + assert task_payload['fieldsets'] is None + + +def test_events__non_complete_task_fieldsets_null__ok(api_client): + + """ + GET task events: non-TASK_COMPLETE event exposes task.fieldsets as null. + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user, tasks_count=1) + task_1 = workflow.tasks.get(number=1) + create_test_event( + workflow=workflow, + user=user, + task=task_1, + type_event=WorkflowEventType.COMMENT, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + path=f'/v2/tasks/{task_1.id}/events', + ) + + # assert + assert response.status_code == 200 + item_1 = response.data[0] + assert item_1['type'] == WorkflowEventType.COMMENT + task_payload = item_1['task'] + assert task_payload['fieldsets'] is None diff --git a/backend/src/processes/tests/test_views/test_tasks/test_webhook_example.py b/backend/src/processes/tests/test_views/test_tasks/test_webhook_example.py index 879f64f22..9aa61a756 100644 --- a/backend/src/processes/tests/test_views/test_tasks/test_webhook_example.py +++ b/backend/src/processes/tests/test_views/test_tasks/test_webhook_example.py @@ -43,6 +43,7 @@ def test_webhook_example__body__ok(api_client): 'contains_comments': False, 'require_completion_by_all': False, 'output': [], + 'fieldsets': [], 'delay': None, 'date_started_tsp': task.date_started.timestamp(), 'date_completed_tsp': None, @@ -90,6 +91,7 @@ def test_webhook_example__body__ok(api_client): 'kickoff': { 'id': workflow.kickoff_instance.id, 'output': [], + 'fieldsets': [], }, 'tasks': [ OrderedDict([ diff --git a/backend/src/processes/tests/test_views/test_templates/test_clone/test_fieldsets.py b/backend/src/processes/tests/test_views/test_templates/test_clone/test_fieldsets.py new file mode 100644 index 000000000..c3b1351f6 --- /dev/null +++ b/backend/src/processes/tests/test_views/test_templates/test_clone/test_fieldsets.py @@ -0,0 +1,544 @@ +import pytest + +from src.processes.enums import ( + FieldSetLayout, + FieldSetRuleType, + FieldType, + LabelPosition, + OwnerRole, + OwnerType, + PerformerType, +) +from src.processes.tests.fixtures import ( + create_test_shared_fieldset, + create_test_owner, + create_test_account, +) +from src.processes.models.templates.fields import ( + FieldTemplate, + FieldTemplateSelection, +) + +pytestmark = pytest.mark.django_db + + +def test_clone__kickoff_and_task_fieldsets__ok(api_client): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + shared = create_test_shared_fieldset( + account=account, + name='My Fieldset', + description='Some description', + label_position=LabelPosition.TOP, + layout=FieldSetLayout.VERTICAL, + ) + api_client.token_authenticate(user) + + create_response = api_client.post( + path='/templates', + data={ + 'name': 'Template', + 'is_active': False, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'fieldsets': [ + { + 'shared_fieldset_id': shared.id, + 'order': 1, + 'title': 'My Fieldset Title', + 'description': 'My Fieldset Description', + }, + ], + }, + 'tasks': [ + { + 'number': 1, + 'name': 'Step 1', + 'api_name': 'task-1', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'fieldsets': [ + { + 'shared_fieldset_id': shared.id, + 'order': 2, + 'title': 'My Fieldset Title 2', + 'description': 'My Fieldset Description 2', + }, + ], + }, + ], + }, + ) + assert create_response.status_code == 200 + template_id = create_response.data['id'] + + # act + response = api_client.post(f'/templates/{template_id}/clone') + + # assert + assert response.status_code == 200 + assert response.data['id'] != template_id + + assert len(response.data['kickoff']['fieldsets']) == 1 + clone_fieldsets_1 = response.data['kickoff']['fieldsets'][0] + assert clone_fieldsets_1['name'] == shared.name + assert clone_fieldsets_1['title'] == 'My Fieldset Title' + assert clone_fieldsets_1['description'] == 'My Fieldset Description' + assert clone_fieldsets_1['label_position'] == LabelPosition.TOP + assert clone_fieldsets_1['layout'] == FieldSetLayout.VERTICAL + assert clone_fieldsets_1['order'] == 1 + assert isinstance(clone_fieldsets_1['fields'], list) + assert clone_fieldsets_1['rules'] == [] + assert clone_fieldsets_1['shared_fieldset_id'] == shared.id + + assert len(response.data['tasks'][0]['fieldsets']) == 1 + clone_fieldsets_2 = response.data['tasks'][0]['fieldsets'][0] + assert clone_fieldsets_2['name'] == shared.name + assert clone_fieldsets_2['title'] == 'My Fieldset Title 2' + assert clone_fieldsets_2['description'] == 'My Fieldset Description 2' + assert clone_fieldsets_2['label_position'] == LabelPosition.TOP + assert clone_fieldsets_2['layout'] == FieldSetLayout.VERTICAL + assert clone_fieldsets_2['order'] == 2 + assert isinstance(clone_fieldsets_2['fields'], list) + assert clone_fieldsets_2['rules'] == [] + assert clone_fieldsets_2['shared_fieldset_id'] == shared.id + + +def test_clone__fieldset_with_fields__ok(api_client): + + """Cloning copies field records belonging to the fieldset.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + shared = create_test_shared_fieldset(account=account) + api_client.token_authenticate(user) + + create_response = api_client.post( + path='/templates', + data={ + 'name': 'Template', + 'is_active': False, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'fieldsets': [ + { + 'shared_fieldset_id': shared.id, + 'order': 1, + 'title': 'Fields Fieldset Title', + 'description': 'Fields Fieldset Description', + }, + ], + }, + 'tasks': [ + { + 'number': 1, + 'name': 'Step 1', + 'api_name': 'task-1', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + assert create_response.status_code == 200 + template_id = create_response.data['id'] + + # act + response = api_client.post(f'/templates/{template_id}/clone') + + # assert + assert response.status_code == 200 + field = shared.fields.first() + fields = response.data['kickoff']['fieldsets'][0]['fields'] + assert len(fields) == 1 + assert fields[0]['name'] == field.name + assert fields[0]['description'] == (field.description or '') + assert fields[0]['type'] == field.type + assert fields[0]['is_required'] == field.is_required + assert fields[0]['is_hidden'] == field.is_hidden + assert fields[0]['order'] == field.order + assert fields[0]['default'] == field.default + + +def test_clone__fieldset_with_selections__ok(api_client): + + """Cloning copies FieldTemplateSelection records + for dropdown fields in a fieldset.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + shared = create_test_shared_fieldset( + account=user.account, + name='Fieldset with dropdown', + ) + shared.fields.all().delete() + field = FieldTemplate.objects.create( + fieldset=shared, + account=user.account, + name='Dropdown field', + type=FieldType.DROPDOWN, + order=1, + ) + FieldTemplateSelection.objects.create( + field_template=field, + value='Option A', + ) + FieldTemplateSelection.objects.create( + field_template=field, + value='Option B', + ) + api_client.token_authenticate(user) + + create_response = api_client.post( + path='/templates', + data={ + 'name': 'Template', + 'is_active': False, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'fieldsets': [ + { + 'shared_fieldset_id': shared.id, + }, + ], + }, + 'tasks': [ + { + 'number': 1, + 'name': 'Step 1', + 'api_name': 'task-1', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + assert create_response.status_code == 200 + template_id = create_response.data['id'] + + # act + response = api_client.post(f'/templates/{template_id}/clone') + + # assert + assert response.status_code == 200 + field = response.data['kickoff']['fieldsets'][0]['fields'][0] + assert field['type'] == FieldType.DROPDOWN + selections = field['selections'] + assert len(selections) == 2 + assert selections[0]['value'] == 'Option A' + assert selections[1]['value'] == 'Option B' + + +def test_clone__fieldset_with_rules__ok(api_client): + + """Cloning copies rules and preserves the rule-field relationships.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + shared = create_test_shared_fieldset( + account=user.account, + name='Fieldset with rules', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + field = shared.fields.first() + rule = shared.rules.first() + field.rules.add(rule) + api_client.token_authenticate(user) + + create_response = api_client.post( + path='/templates', + data={ + 'name': 'Template', + 'is_active': False, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'fieldsets': [ + { + 'shared_fieldset_id': shared.id, + }, + ], + }, + 'tasks': [ + { + 'number': 1, + 'name': 'Step 1', + 'api_name': 'task-1', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + assert create_response.status_code == 200 + template_id = create_response.data['id'] + + # act + response = api_client.post(f'/templates/{template_id}/clone') + + # assert + assert response.status_code == 200 + fieldset = response.data['kickoff']['fieldsets'][0] + rules = fieldset['rules'] + assert len(rules) == 1 + rule_data = rules[0] + assert rule_data['type'] == FieldSetRuleType.SUM_EQUAL + assert rule_data['value'] == '100' + clone_field_api_names = [f['api_name'] for f in fieldset['fields']] + assert len(clone_field_api_names) == 1 + assert rule_data['fields'] == clone_field_api_names + + +def test_clone__kickoff_multiple_fieldsets__ok(api_client): + + """Cloning a template with multiple fieldsets copies all of them.""" + + # arrange + user = create_test_owner() + api_client.token_authenticate(user) + + shared_1 = create_test_shared_fieldset( + account=user.account, + name='Fieldset One', + ) + shared_2 = create_test_shared_fieldset( + account=user.account, + name='Fieldset Two', + ) + + create_response = api_client.post( + path='/templates', + data={ + 'name': 'Template', + 'is_active': False, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'fieldsets': [ + { + 'shared_fieldset_id': shared_1.id, + 'order': 1, + 'title': 'Fieldset One Title', + 'description': 'Fieldset One Description', + }, + { + 'shared_fieldset_id': shared_2.id, + 'order': 2, + 'title': 'Fieldset Two Title', + 'description': 'Fieldset Two Description', + }, + ], + }, + 'tasks': [ + { + 'number': 1, + 'name': 'Step 1', + 'api_name': 'task-1', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + assert create_response.status_code == 200 + template_id = create_response.data['id'] + + # act + response = api_client.post(f'/templates/{template_id}/clone') + + # assert + assert response.status_code == 200 + fieldsets = response.data['kickoff']['fieldsets'] + assert len(fieldsets) == 2 + fieldset_1 = fieldsets[0] + assert fieldset_1['name'] == shared_1.name + fieldset_2 = fieldsets[1] + assert fieldset_2['name'] == shared_2.name + + +def test_clone__no_fieldsets__ok(api_client): + + """Cloning a template without fieldsets still works + and creates no fieldsets on the clone.""" + + # arrange + user = create_test_owner() + api_client.token_authenticate(user) + + create_response = api_client.post( + path='/templates', + data={ + 'name': 'Template', + 'is_active': False, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': {}, + 'tasks': [ + { + 'number': 1, + 'name': 'Step 1', + 'api_name': 'task-1', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + assert create_response.status_code == 200 + template_id = create_response.data['id'] + + # act + response = api_client.post(f'/templates/{template_id}/clone') + + # assert + assert response.status_code == 200 + assert response.data['kickoff']['fieldsets'] == [] + + +def test_clone__fieldset_rule_multi_fields__ok(api_client): + + """Cloning preserves a rule linked to multiple fields.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + shared = create_test_shared_fieldset( + account=account, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='200', + ) + shared.fields.all().delete() + field_1 = FieldTemplate.objects.create( + fieldset=shared, + account=user.account, + name='Amount A', + type=FieldType.NUMBER, + order=3, + ) + field_2 = FieldTemplate.objects.create( + fieldset=shared, + account=user.account, + name='Amount B', + type=FieldType.NUMBER, + order=2, + ) + rule = shared.rules.first() + field_1.rules.add(rule) + field_2.rules.add(rule) + + api_client.token_authenticate(user) + create_response = api_client.post( + path='/templates', + data={ + 'name': 'Template', + 'is_active': False, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'fieldsets': [ + { + 'shared_fieldset_id': shared.id, + 'order': 1, + 'title': 'Multi-field Fieldset Title', + 'description': 'Multi-field Fieldset Description', + }, + ], + }, + 'tasks': [ + { + 'number': 1, + 'name': 'Step 1', + 'api_name': 'task-1', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + assert create_response.status_code == 200 + template_id = create_response.data['id'] + + # act + response = api_client.post(f'/templates/{template_id}/clone') + + # assert + assert response.status_code == 200 + clone_fieldsets = response.data['kickoff']['fieldsets'][0] + rule_data = clone_fieldsets['rules'][0] + assert rule_data['type'] == FieldSetRuleType.SUM_EQUAL + assert rule_data['value'] == '200' + clone_fields = clone_fieldsets['fields'] + clone_field_api_names = [f['api_name'] for f in clone_fields] + assert set(rule_data['fields']) == set(clone_field_api_names) diff --git a/backend/src/processes/tests/test_views/test_templates/test_clone/test_task.py b/backend/src/processes/tests/test_views/test_templates/test_clone/test_task.py index 4c9b3368c..a802a6457 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_clone/test_task.py +++ b/backend/src/processes/tests/test_views/test_templates/test_clone/test_task.py @@ -1,6 +1,5 @@ import pytest -from src.authentication.enums import AuthTokenType from src.processes.enums import ( OwnerRole, ConditionAction, @@ -99,15 +98,10 @@ def test_clone__ok(self, is_active, api_client): def test_clone__due_date__ok( self, - mocker, api_client, ): # arrange - analysis_mock = mocker.patch( - 'src.processes.serializers.templates.task.' - 'AnalyticService.templates_task_due_date_created', - ) user = create_test_user() api_client.token_authenticate(user) duration = '10:00:00' @@ -156,13 +150,6 @@ def test_clone__due_date__ok( assert response.status_code == 200 task_data = response.data['tasks'][0] assert task_data['raw_due_date']['duration'] == duration - analysis_mock.assert_called_once_with( - user=user, - template=template, - task=template.tasks.get(number=1), - auth_type=AuthTokenType.USER, - is_superuser=False, - ) def test_clone__return_task__ok( self, diff --git a/backend/src/processes/tests/test_views/test_templates/test_create/test_kickoff.py b/backend/src/processes/tests/test_views/test_templates/test_create/test_kickoff.py index 53791313f..fbc832625 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_create/test_kickoff.py +++ b/backend/src/processes/tests/test_views/test_templates/test_create/test_kickoff.py @@ -47,6 +47,7 @@ def test_create__only_required_fields__defaults_ok( 'is_active': True, 'kickoff': { 'fields': [], + 'fieldsets': [], }, 'tasks': [ { @@ -86,6 +87,7 @@ def test_create__all_fields__ok( api_client.token_authenticate(user) request_data = { 'fields': [], + 'fieldsets': [], } # act @@ -217,6 +219,7 @@ def test_create_draft__not_kickoff__ok( assert response.status_code == 200 assert response.data['kickoff'] == { 'fields': [], + 'fieldsets': [], } assert response.data['is_active'] is False kickoff_create_mock.assert_called_once() @@ -262,6 +265,7 @@ def test_create_draft__kickoff_is_null__ok( assert response.status_code == 200 assert response.data['kickoff'] == { 'fields': [], + 'fieldsets': [], } assert response.data['is_active'] is False kickoff_create_mock.assert_called_once() diff --git a/backend/src/processes/tests/test_views/test_templates/test_create/test_template.py b/backend/src/processes/tests/test_views/test_templates/test_create/test_template.py index ccb575808..a9efa8275 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_create/test_template.py +++ b/backend/src/processes/tests/test_views/test_templates/test_create/test_template.py @@ -21,6 +21,9 @@ OwnerType, PerformerType, PredicateOperator, + FieldSetRuleType, + LabelPosition, + FieldSetLayout, ) from src.processes.messages import template as messages from src.processes.models.templates.conditions import ( @@ -38,12 +41,14 @@ from src.processes.services.templates.integrations import ( TemplateIntegrationsService, ) +from src.processes.models.templates.fieldset import FieldsetTemplate from src.processes.tests.fixtures import ( create_invited_user, create_test_account, create_test_group, create_test_not_admin, create_test_owner, + create_test_shared_fieldset, create_test_template, create_test_user, ) @@ -3830,3 +3835,569 @@ def test_create__invalid_wf_name_template__validation_error( assert response.data['message'] == str(messages.MSG_PT_0008) templates_kickoff_created_mock.assert_not_called() templates_created_mock.assert_not_called() + + +def test_create__kickoff_fieldset_only_required_data__ok( + mocker, + api_client, +): + + """ Creating a template with one fieldset linked to kickoff + creates FieldsetTemplate linked to the kickoff. """ + + # arrange + account = create_test_account() + user = create_test_user(account=account) + api_client.token_authenticate(user) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_created', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_created', + ) + fs_title = 'Some title' + fs_description = 'Some desc' + fs_name = 'Some name' + fs_order = 3 + label_position = LabelPosition.LEFT + layout = FieldSetLayout.HORIZONTAL + rule_type = FieldSetRuleType.SUM_EQUAL + rule_value = '500' + api_name = 'fs-some-api-name' + shared_fieldset = create_test_shared_fieldset( + title=fs_title, + description=fs_description, + name=fs_name, + order=fs_order, + label_position=label_position, + layout=layout, + rule_type=rule_type, + rule_value=rule_value, + api_name=api_name, + account=account, + ) + shared_field = shared_fieldset.fields.first() + shared_rule = shared_fieldset.rules.first() + shared_rule.fields.add(shared_field) + request_data = { + 'name': 'Template with fieldset', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'is_active': True, + 'kickoff': { + 'fieldsets': [ + { + 'shared_fieldset_id': shared_fieldset.id, + }, + ], + }, + 'tasks': [ + { + 'number': 1, + 'name': 'First step', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + } + + # act + response = api_client.post( + path='/templates', + data=request_data, + ) + + # assert + assert response.status_code == 200 + template = Template.objects.get(id=response.data['id']) + kickoff = template.kickoff_instance + fieldset = FieldsetTemplate.objects.get( + kickoff=kickoff, + shared_fieldset=shared_fieldset, + is_shared=False, + ) + field = fieldset.fields.first() + rule = fieldset.rules.first() + + kickoff_data = response.data['kickoff'] + assert len(kickoff_data['fieldsets']) == 1 + fieldset_data = kickoff_data['fieldsets'][0] + assert fieldset_data['shared_fieldset_id'] == shared_fieldset.id + assert fieldset_data['order'] == 0 + assert fieldset_data['title'] == fs_title + assert fieldset_data['description'] == fs_description + assert fieldset_data['name'] == fs_name + assert len(fieldset_data['api_name']) + assert fieldset_data['api_name'] != shared_fieldset.api_name + assert fieldset_data['label_position'] == label_position + assert fieldset_data['layout'] == layout + + assert len(fieldset_data['rules']) == 1 + rule_data = fieldset_data['rules'][0] + assert rule_data['type'] == rule_type + assert rule_data['value'] == str(rule_value) + assert rule_data['api_name'] == rule.api_name + assert rule_data['fields'] == [field.api_name] + + assert len(fieldset_data['fields']) == 1 + field_data = fieldset_data['fields'][0] + assert field_data['name'] == shared_field.name + assert field_data['type'] == shared_field.type + assert field_data['order'] == shared_field.order + assert field_data['is_required'] == shared_field.is_required + assert field_data['is_hidden'] == shared_field.is_hidden + assert field_data['description'] == '' + assert field_data['default'] == '' + assert field_data['api_name'] == field.api_name + + +def test_create__kickoff_fieldset_all_fieldset_data__ok( + mocker, + api_client, +): + + # arrange + account = create_test_account() + user = create_test_user(account=account) + api_client.token_authenticate(user) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_created', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_created', + ) + fs_title = 'Some title' + fs_description = 'Some desc' + fs_order = 3 + fs_api_name = 'fs-some-api-name' + shared_fieldset = create_test_shared_fieldset(account=account) + shared_fieldset.fields.all().delete() + request_data = { + 'name': 'Template with fieldset', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'is_active': True, + 'kickoff': { + 'fieldsets': [ + { + 'shared_fieldset_id': shared_fieldset.id, + 'order': fs_order, + 'title': fs_title, + 'description': fs_description, + 'api_name': fs_api_name, + }, + ], + }, + 'tasks': [ + { + 'number': 1, + 'name': 'First step', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + } + + # act + response = api_client.post( + path='/templates', + data=request_data, + ) + + # assert + assert response.status_code == 200 + template = Template.objects.get(id=response.data['id']) + kickoff = template.kickoff_instance + assert FieldsetTemplate.objects.get( + kickoff=kickoff, + shared_fieldset=shared_fieldset, + is_shared=False, + api_name=fs_api_name, + ) + + kickoff_data = response.data['kickoff'] + assert len(kickoff_data['fieldsets']) == 1 + fieldset_data = kickoff_data['fieldsets'][0] + assert fieldset_data['shared_fieldset_id'] == shared_fieldset.id + assert fieldset_data['order'] == fs_order + assert fieldset_data['title'] == fs_title + assert fieldset_data['description'] == fs_description + assert fieldset_data['name'] == shared_fieldset.name + assert fieldset_data['api_name'] == fs_api_name + assert fieldset_data['label_position'] == shared_fieldset.label_position + assert fieldset_data['layout'] == shared_fieldset.layout + assert fieldset_data['rules'] == [] + assert fieldset_data['fields'] == [] + + +def test_create__kickoff_with_empty_fieldsets__no_created( + mocker, + api_client, +): + + """ Creating a template with empty fieldsets list does not + create any FieldsetTemplateKickoff records. """ + + # arrange + account = create_test_account() + user = create_test_user(account=account) + api_client.token_authenticate(user) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_created', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_created', + ) + request_data = { + 'name': 'Template no fieldsets', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'is_active': True, + 'kickoff': { + 'fieldsets': [], + }, + 'tasks': [ + { + 'number': 1, + 'name': 'First step', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + } + + # act + response = api_client.post( + path='/templates', + data=request_data, + ) + + # assert + assert response.status_code == 200 + template = Template.objects.get(id=response.data['id']) + kickoff = template.kickoff_instance + assert FieldsetTemplate.objects.filter( + kickoff=kickoff, + ).count() == 0 + + +def test_create__task_fieldset_only_required_data__ok( + mocker, + api_client, +): + + """ Creating a template with one fieldset linked to a task + creates FieldsetTemplate linked to the task. """ + + # arrange + account = create_test_account() + user = create_test_user(account=account) + api_client.token_authenticate(user) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_created', + ) + fs_title = 'Some title' + fs_description = 'Some desc' + fs_name = 'Some name' + fs_order = 3 + label_position = LabelPosition.LEFT + layout = FieldSetLayout.HORIZONTAL + rule_type = FieldSetRuleType.SUM_EQUAL + rule_value = '500' + api_name = 'fs-some-api-name' + shared_fieldset = create_test_shared_fieldset( + title=fs_title, + description=fs_description, + name=fs_name, + order=fs_order, + label_position=label_position, + layout=layout, + rule_type=rule_type, + rule_value=rule_value, + api_name=api_name, + account=account, + ) + shared_field = shared_fieldset.fields.first() + shared_rule = shared_fieldset.rules.first() + shared_rule.fields.add(shared_field) + request_data = { + 'name': 'Template with task fieldset', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'is_active': True, + 'kickoff': {}, + 'tasks': [ + { + 'number': 1, + 'name': 'First step', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'fieldsets': [ + { + 'shared_fieldset_id': shared_fieldset.id, + }, + ], + }, + ], + } + + # act + response = api_client.post( + path='/templates', + data=request_data, + ) + + # assert + assert response.status_code == 200 + template = Template.objects.get(id=response.data['id']) + task = template.tasks.first() + fieldset = FieldsetTemplate.objects.get( + task=task, + shared_fieldset=shared_fieldset, + is_shared=False, + ) + field = fieldset.fields.first() + rule = fieldset.rules.first() + + task_data = response.data['tasks'][0] + assert len(task_data['fieldsets']) == 1 + fieldset_data = task_data['fieldsets'][0] + assert fieldset_data['shared_fieldset_id'] == shared_fieldset.id + assert fieldset_data['order'] == 0 + assert fieldset_data['title'] == fs_title + assert fieldset_data['description'] == fs_description + assert fieldset_data['name'] == fs_name + assert len(fieldset_data['api_name']) + assert fieldset_data['api_name'] != shared_fieldset.api_name + assert fieldset_data['label_position'] == label_position + assert fieldset_data['layout'] == layout + + assert len(fieldset_data['rules']) == 1 + rule_data = fieldset_data['rules'][0] + assert rule_data['type'] == rule_type + assert rule_data['value'] == rule_value + assert rule_data['api_name'] == rule.api_name + assert rule_data['fields'] == [field.api_name] + + assert len(fieldset_data['fields']) == 1 + field_data = fieldset_data['fields'][0] + assert field_data['name'] == shared_field.name + assert field_data['type'] == shared_field.type + assert field_data['order'] == shared_field.order + assert field_data['is_required'] == shared_field.is_required + assert field_data['is_hidden'] == shared_field.is_hidden + assert field_data['description'] == '' + assert field_data['default'] == '' + assert field_data['api_name'] == field.api_name + + +def test_create__task_fieldset_all_fieldset_data__ok( + mocker, + api_client, +): + + # arrange + account = create_test_account() + user = create_test_user(account=account) + api_client.token_authenticate(user) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_created', + ) + fs_title = 'Some title' + fs_description = 'Some desc' + fs_order = 3 + fs_api_name = 'fs-some-api-name' + shared_fieldset = create_test_shared_fieldset(account=account) + shared_fieldset.fields.all().delete() + request_data = { + 'name': 'Template with task fieldset', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'is_active': True, + 'kickoff': {}, + 'tasks': [ + { + 'number': 1, + 'name': 'First step', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'fieldsets': [ + { + 'shared_fieldset_id': shared_fieldset.id, + 'order': fs_order, + 'title': fs_title, + 'description': fs_description, + 'api_name': fs_api_name, + }, + ], + }, + ], + } + + # act + response = api_client.post( + path='/templates', + data=request_data, + ) + + # assert + assert response.status_code == 200 + template = Template.objects.get(id=response.data['id']) + task = template.tasks.first() + assert FieldsetTemplate.objects.get( + task=task, + shared_fieldset=shared_fieldset, + is_shared=False, + api_name=fs_api_name, + ) + + task_data = response.data['tasks'][0] + assert len(task_data['fieldsets']) == 1 + fieldset_data = task_data['fieldsets'][0] + assert fieldset_data['shared_fieldset_id'] == shared_fieldset.id + assert fieldset_data['order'] == fs_order + assert fieldset_data['title'] == fs_title + assert fieldset_data['description'] == fs_description + assert fieldset_data['name'] == shared_fieldset.name + assert fieldset_data['api_name'] == fs_api_name + assert fieldset_data['label_position'] == shared_fieldset.label_position + assert fieldset_data['layout'] == shared_fieldset.layout + assert fieldset_data['rules'] == [] + assert fieldset_data['fields'] == [] + + +def test_create__task_with_empty_fieldsets__no_created( + mocker, + api_client, +): + + # arrange + account = create_test_account() + user = create_test_user(account=account) + api_client.token_authenticate(user) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_created', + ) + request_data = { + 'name': 'Template without task fieldsets', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'is_active': True, + 'kickoff': {}, + 'tasks': [ + { + 'number': 1, + 'name': 'First step', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'fieldsets': [], + }, + ], + } + + # act + response = api_client.post( + path='/templates', + data=request_data, + ) + + # assert + assert response.status_code == 200 + template = Template.objects.get(id=response.data['id']) + task = template.tasks.first() + assert FieldsetTemplate.objects.filter( + task=task, + ).count() == 0 diff --git a/backend/src/processes/tests/test_views/test_templates/test_export.py b/backend/src/processes/tests/test_views/test_templates/test_export.py index e5d8d2fa7..b5a57c02f 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_export.py +++ b/backend/src/processes/tests/test_views/test_templates/test_export.py @@ -19,6 +19,7 @@ FieldTemplate, FieldTemplateSelection, ) + from src.processes.models.templates.kickoff import Kickoff from src.processes.models.templates.owner import TemplateOwner from src.processes.models.templates.raw_due_date import RawDueDateTemplate @@ -27,6 +28,7 @@ from src.processes.tests.fixtures import ( create_test_account, create_test_admin, + create_test_fieldset_template, create_test_group, create_test_guest, create_test_not_admin, @@ -199,6 +201,51 @@ def test_export__response_format__ok(api_client): assert predicates_template[0]['operator'] == predicate.operator +def test_export__fieldsets__ok(api_client): + # arrange + account = create_test_account() + account_owner = create_test_owner(account=account) + api_client.token_authenticate(account_owner) + template = create_test_template( + user=account_owner, + tasks_count=1, + ) + kickoff = template.kickoff_instance + task = template.tasks.first() + + kickoff_fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + order=0, + ) + + task_fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + order=1, + ) + + # act + response = api_client.get('/templates/export') + + # assert + assert response.status_code == 200 + assert len(response.data) == 1 + response_data = response.data[0] + + kickoff_fieldsets = response_data['kickoff']['fieldsets'] + assert len(kickoff_fieldsets) == 1 + assert kickoff_fieldsets[0]['api_name'] == kickoff_fieldset.api_name + assert kickoff_fieldsets[0]['order'] == 0 + + task_fieldsets = response_data['tasks'][0]['fieldsets'] + assert len(task_fieldsets) == 1 + assert task_fieldsets[0]['api_name'] == task_fieldset.api_name + assert task_fieldsets[0]['order'] == 1 + + def test_export__not_auth__permission_denied(api_client): # act response = api_client.get('/templates/export') diff --git a/backend/src/processes/tests/test_views/test_templates/test_fields.py b/backend/src/processes/tests/test_views/test_templates/test_fields.py index fd6322286..9a760958e 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_fields.py +++ b/backend/src/processes/tests/test_views/test_templates/test_fields.py @@ -9,7 +9,10 @@ OwnerType, PerformerType, ) -from src.processes.models.templates.fields import FieldTemplate +from src.processes.models.templates.fields import ( + FieldTemplate, + FieldTemplateSelection, +) from src.processes.models.templates.owner import TemplateOwner from src.processes.models.templates.template import Template from src.processes.models.workflows.task import TaskPerformer @@ -21,6 +24,7 @@ create_test_owner, create_test_template, create_test_workflow, + create_test_fieldset_template, create_test_dataset, ) pytestmark = pytest.mark.django_db @@ -63,6 +67,7 @@ def test_fields__active_template__ok(api_client): data = response.data assert data['id'] == template.id assert len(data['kickoff']['fields']) == 1 + assert data['kickoff']['fieldsets'] == [] field_1_data = data['kickoff']['fields'][0] assert field_1_data['name'] == kickoff_field.name @@ -73,11 +78,11 @@ def test_fields__active_template__ok(api_client): assert len(data['tasks']) == 1 task_data = data['tasks'][0] - assert task_data['id'] == task.id assert task_data['name'] == task.name assert task_data['number'] == task.number assert task_data['api_name'] == task.api_name assert len(task_data['fields']) == 1 + assert task_data['fieldsets'] == [] field_2_data = task_data['fields'][0] assert field_2_data['name'] == task_field.name @@ -156,7 +161,7 @@ def test_fields__draft_template__return_from_db(api_client): # assert assert response.status_code == 200 - assert response.data['kickoff'] == {'fields': []} + assert response.data['kickoff'] == {'fields': [], 'fieldsets': []} assert response.data['tasks'] == [] @@ -520,3 +525,125 @@ def test_fields__ordering__ok(api_client): assert data['tasks'][1]['fields'][0]['order'] == 2 assert data['tasks'][1]['fields'][1]['api_name'] == task_2_field_1.api_name assert data['tasks'][1]['fields'][1]['order'] == 1 + + +def test_fields__kickoff_fieldset__ok(api_client): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user, tasks_count=1, is_active=True) + kickoff = template.kickoff_instance + fieldset_order = 999 + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + order=fieldset_order, + ) + fieldset.fields.delete() + dataset = create_test_dataset(account=account) + field = FieldTemplate.objects.create( + name='First task performer', + description='Some description', + type=FieldType.DROPDOWN, + fieldset=fieldset, + template=template, + order=1, + api_name='field-1', + account=account, + dataset=dataset, + ) + FieldTemplateSelection.objects.create( + field_template=field, + value='Value 1', + template=template, + ) + api_client.token_authenticate(user) + + # act + response = api_client.get(f'/templates/{template.id}/fields') + + # assert + assert response.status_code == 200 + data = response.data + assert data['id'] == template.id + assert len(data['kickoff']['fieldsets']) == 1 + fieldset_data = data['kickoff']['fieldsets'][0] + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + assert fieldset_data['api_name'] == fieldset.api_name + assert fieldset_data['order'] == fieldset_order + assert len(fieldset_data['fields']) == 1 + + field_data = fieldset_data['fields'][0] + assert field_data['name'] == field.name + assert field_data['type'] == field.type + assert field_data['order'] == field.order + assert field_data['description'] == field.description + assert field_data['is_hidden'] == field.is_hidden + assert field_data['api_name'] == field.api_name + assert 'selections' not in field_data + assert 'dataset' not in field_data + + +def test_fields__task_fieldset__ok(api_client): + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user, tasks_count=1, is_active=True) + task = template.tasks.get(number=1) + fieldset_order = 888 + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + order=fieldset_order, + ) + fieldset.fields.delete() + dataset = create_test_dataset(account=account) + field = FieldTemplate.objects.create( + name='First task performer', + description='Some description', + type=FieldType.DROPDOWN, + fieldset=fieldset, + template=template, + order=1, + api_name='field-1', + account=account, + dataset=dataset, + ) + FieldTemplateSelection.objects.create( + field_template=field, + value='Value 1', + template=template, + ) + api_client.token_authenticate(user) + + # act + response = api_client.get(f'/templates/{template.id}/fields') + + # assert + assert response.status_code == 200 + data = response.data + assert data['id'] == template.id + assert len(data['tasks'][0]['fieldsets']) == 1 + fieldset_data = data['tasks'][0]['fieldsets'][0] + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + assert fieldset_data['order'] == fieldset_order + assert fieldset_data['api_name'] == fieldset.api_name + assert len(fieldset_data['fields']) == 1 + field_data = fieldset_data['fields'][0] + assert field_data['name'] == field.name + assert field_data['type'] == field.type + assert field_data['order'] == field.order + assert field_data['description'] == field.description + assert field_data['is_hidden'] == field.is_hidden + assert field_data['api_name'] == field.api_name + assert 'selections' not in field_data + assert 'dataset' not in field_data diff --git a/backend/src/processes/tests/test_views/test_templates/test_list.py b/backend/src/processes/tests/test_views/test_templates/test_list.py index a268ed044..2e3766c36 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_list.py +++ b/backend/src/processes/tests/test_views/test_templates/test_list.py @@ -21,8 +21,10 @@ create_invited_user, create_test_account, create_test_dataset, + create_test_fieldset_template, create_test_group, create_test_owner, + create_test_shared_fieldset, create_test_template, create_test_user, create_test_workflow, @@ -1429,3 +1431,127 @@ def test_list__kickoff_field_is_hidden_true(api_client): assert field_data['api_name'] == field.api_name assert field_data['is_hidden'] is True assert field_data['is_required'] is False + + +def test_list__kickoff_fieldset__ok(api_client): + + """ GET /templates returns kickoff fieldset with all fields. """ + + # arrange + user = create_test_owner() + template = create_test_template( + user=user, + tasks_count=1, + is_active=True, + ) + kickoff = template.kickoff_instance + shared = create_test_shared_fieldset( + account=user.account, + description='Enter your personal information', + api_name='shared-fieldset-personal', + ) + fieldset = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=kickoff, + shared_fieldset=shared, + api_name='fieldset-personal', + order=5, + ) + fieldset_field = fieldset.fields.first() + api_client.token_authenticate(user) + + # act + response = api_client.get('/templates') + + # assert + assert response.status_code == 200 + fieldsets = response.data[0]['kickoff']['fieldsets'] + assert len(fieldsets) == 1 + fieldset_data = fieldsets[0] + assert fieldset_data['shared_fieldset_id'] == shared.id + assert fieldset_data['order'] == fieldset.order + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['api_name'] == fieldset.api_name + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + assert len(fieldset_data['fields']) == 1 + field_data = fieldset_data['fields'][0] + assert field_data['api_name'] == fieldset_field.api_name + assert field_data['name'] == fieldset_field.name + assert field_data['type'] == fieldset_field.type + assert field_data['order'] == fieldset_field.order + + +def test_list__kickoff_no_fieldsets__empty_list(api_client): + + """ GET /templates returns empty fieldsets list when none exist. """ + + # arrange + user = create_test_owner() + create_test_template( + user=user, + tasks_count=1, + is_active=True, + ) + api_client.token_authenticate(user) + + # act + response = api_client.get('/templates') + + # assert + assert response.status_code == 200 + fieldsets = response.data[0]['kickoff']['fieldsets'] + assert fieldsets == [] + + +def test_list__kickoff_multiple_fieldsets_ordered(api_client): + + """ GET /templates returns kickoff fieldsets ordered by order. """ + + # arrange + user = create_test_owner() + template = create_test_template( + user=user, + tasks_count=1, + is_active=True, + ) + kickoff = template.kickoff_instance + shared_2 = create_test_shared_fieldset( + account=user.account, + api_name='shared-fieldset-second', + ) + fieldset_2 = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=kickoff, + shared_fieldset=shared_2, + api_name='fieldset-second', + order=2, + ) + shared_1 = create_test_shared_fieldset( + account=user.account, + api_name='shared-fieldset-first', + ) + fieldset_1 = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=kickoff, + shared_fieldset=shared_1, + api_name='fieldset-first', + order=1, + ) + api_client.token_authenticate(user) + + # act + response = api_client.get('/templates') + + # assert + assert response.status_code == 200 + fieldsets = response.data[0]['kickoff']['fieldsets'] + assert len(fieldsets) == 2 + assert fieldsets[0]['order'] == fieldset_1.order + assert fieldsets[0]['api_name'] == fieldset_1.api_name + assert fieldsets[1]['order'] == fieldset_2.order + assert fieldsets[1]['api_name'] == fieldset_2.api_name diff --git a/backend/src/processes/tests/test_views/test_templates/test_public/test_retrieve.py b/backend/src/processes/tests/test_views/test_templates/test_public/test_retrieve.py index ae26a7921..ac56ba344 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_public/test_retrieve.py +++ b/backend/src/processes/tests/test_views/test_templates/test_public/test_retrieve.py @@ -5,15 +5,17 @@ PublicToken, ) from src.processes.enums import ( - FieldType, + FieldType, FieldSetRuleType, ) from src.processes.models.templates.fields import FieldTemplate, \ FieldTemplateSelection from src.processes.tests.fixtures import ( create_test_template, + create_test_fieldset_template, create_test_owner, create_test_dataset, create_test_account, + create_test_shared_fieldset, ) pytestmark = pytest.mark.django_db @@ -419,6 +421,196 @@ def test_retrieve__disable_captcha__ok(self, api_client, mocker): get_template_mock.assert_called_once_with(token) anonymous_user_workflow_exists_mock.assert_not_called() + def test_retrieve__kickoff_fieldset__ok( + self, + api_client, + mocker, + ): + + """ GET /templates/public returns kickoff fieldset. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + is_public=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + description='Enter info', + api_name='fieldset-personal', + order=5, + ) + fieldset_field = fieldset.fields.first() + auth_header_value = ( + f'Token {template.public_id}' + ) + token = PublicToken(template.public_id) + get_token_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_token', + return_value=token, + ) + get_template_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_template', + return_value=template, + ) + settings_mock = mocker.patch( + 'src.processes.views.public.' + 'template.settings', + ) + settings_mock.PROJECT_CONF = {'CAPTCHA': True} + + # act + response = api_client.get( + path='/templates/public', + **{'X-Public-Authorization': auth_header_value}, + ) + + # assert + assert response.status_code == 200 + fieldsets = response.data['kickoff']['fieldsets'] + assert len(fieldsets) == 1 + fieldset_data = fieldsets[0] + assert fieldset_data['order'] == fieldset.order + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['api_name'] == fieldset.api_name + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + assert len(fieldset_data['fields']) == 1 + field_data = fieldset_data['fields'][0] + assert field_data['api_name'] == fieldset_field.api_name + assert field_data['name'] == fieldset_field.name + assert field_data['type'] == fieldset_field.type + assert field_data['order'] == fieldset_field.order + get_token_mock.assert_called_once() + get_template_mock.assert_called_once_with(token) + + def test_retrieve__kickoff_no_fieldsets__ok( + self, + api_client, + mocker, + ): + + """ GET /templates/public returns empty fieldsets. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + is_public=True, + tasks_count=1, + ) + auth_header_value = ( + f'Token {template.public_id}' + ) + token = PublicToken(template.public_id) + get_token_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_token', + return_value=token, + ) + get_template_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_template', + return_value=template, + ) + settings_mock = mocker.patch( + 'src.processes.views.public.' + 'template.settings', + ) + settings_mock.PROJECT_CONF = {'CAPTCHA': True} + + # act + response = api_client.get( + path='/templates/public', + **{'X-Public-Authorization': auth_header_value}, + ) + + # assert + assert response.status_code == 200 + fieldsets = response.data['kickoff']['fieldsets'] + assert fieldsets == [] + get_token_mock.assert_called_once() + get_template_mock.assert_called_once_with(token) + + def test_retrieve__kickoff_fieldsets_ordered( + self, + api_client, + mocker, + ): + + """ GET /templates/public returns ordered fieldsets. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + is_public=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + order=2, + ) + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + order=1, + ) + auth_header_value = ( + f'Token {template.public_id}' + ) + token = PublicToken(template.public_id) + get_token_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_token', + return_value=token, + ) + get_template_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_template', + return_value=template, + ) + settings_mock = mocker.patch( + 'src.processes.views.public.' + 'template.settings', + ) + settings_mock.PROJECT_CONF = {'CAPTCHA': True} + + # act + response = api_client.get( + path='/templates/public', + **{'X-Public-Authorization': auth_header_value}, + ) + + # assert + assert response.status_code == 200 + fieldsets = response.data['kickoff']['fieldsets'] + assert len(fieldsets) == 2 + assert fieldsets[0]['order'] == fieldset_1.order + assert fieldsets[0]['api_name'] == fieldset_1.api_name + assert fieldsets[1]['order'] == fieldset_2.order + assert fieldsets[1]['api_name'] == fieldset_2.api_name + get_token_mock.assert_called_once() + get_template_mock.assert_called_once_with(token) + class TestRetrieveEmbedTemplate: @@ -636,3 +828,203 @@ def test_retrieve__disable_captcha__ok(self, api_client, mocker): get_token_mock.assert_called_once() get_template_mock.assert_called_once_with(token) anonymous_user_workflow_exists_mock.assert_not_called() + + def test_retrieve__kickoff_fieldset__ok( + self, + api_client, + mocker, + ): + + """ GET /templates/public returns embed fieldset. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + is_embedded=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + shared_fieldset = create_test_shared_fieldset( + account=account, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + fieldset = create_test_fieldset_template( + shared_fieldset=shared_fieldset, + account=account, + template=template, + kickoff=kickoff, + api_name='fieldset-personal', + order=5, + ) + fieldset_field = fieldset.fields.first() + rule = fieldset.rules.first() + rule.fields.add(fieldset_field) + auth_header_value = ( + f'Token {template.embed_id}' + ) + token = EmbedToken(template.embed_id) + get_token_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_token', + return_value=token, + ) + get_template_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_template', + return_value=template, + ) + settings_mock = mocker.patch( + 'src.processes.views.public.' + 'template.settings', + ) + settings_mock.PROJECT_CONF = {'CAPTCHA': True} + + # act + response = api_client.get( + path='/templates/public', + **{'X-Public-Authorization': auth_header_value}, + ) + + # assert + assert response.status_code == 200 + fieldsets = response.data['kickoff']['fieldsets'] + assert len(fieldsets) == 1 + fieldset_data = fieldsets[0] + assert fieldset_data['order'] == fieldset.order + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['title'] == fieldset.title + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['api_name'] == fieldset.api_name + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + assert fieldset_data['shared_fieldset_id'] == shared_fieldset.id + assert len(fieldset_data['fields']) == 1 + field_data = fieldset_data['fields'][0] + assert field_data['api_name'] == fieldset_field.api_name + assert field_data['name'] == fieldset_field.name + assert field_data['type'] == fieldset_field.type + assert field_data['order'] == fieldset_field.order + get_token_mock.assert_called_once() + get_template_mock.assert_called_once_with(token) + + def test_retrieve__kickoff_no_fieldsets__ok( + self, + api_client, + mocker, + ): + + """ GET /templates/public returns empty embed fieldsets. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + is_embedded=True, + tasks_count=1, + ) + auth_header_value = ( + f'Token {template.embed_id}' + ) + token = EmbedToken(template.embed_id) + get_token_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_token', + return_value=token, + ) + get_template_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_template', + return_value=template, + ) + settings_mock = mocker.patch( + 'src.processes.views.public.' + 'template.settings', + ) + settings_mock.PROJECT_CONF = {'CAPTCHA': True} + + # act + response = api_client.get( + path='/templates/public', + **{'X-Public-Authorization': auth_header_value}, + ) + + # assert + assert response.status_code == 200 + fieldsets = response.data['kickoff']['fieldsets'] + assert fieldsets == [] + get_token_mock.assert_called_once() + get_template_mock.assert_called_once_with(token) + + def test_retrieve__kickoff_fieldsets_ordered( + self, + api_client, + mocker, + ): + + """ GET /templates/public returns ordered embed fieldsets. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + is_embedded=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + api_name='fieldset-second', + order=2, + ) + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + api_name='fieldset-first', + order=1, + ) + auth_header_value = ( + f'Token {template.embed_id}' + ) + token = EmbedToken(template.embed_id) + get_token_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_token', + return_value=token, + ) + get_template_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_template', + return_value=template, + ) + settings_mock = mocker.patch( + 'src.processes.views.public.' + 'template.settings', + ) + settings_mock.PROJECT_CONF = {'CAPTCHA': True} + + # act + response = api_client.get( + path='/templates/public', + **{'X-Public-Authorization': auth_header_value}, + ) + + # assert + assert response.status_code == 200 + fieldsets = response.data['kickoff']['fieldsets'] + assert len(fieldsets) == 2 + assert fieldsets[0]['order'] == fieldset_1.order + assert fieldsets[0]['api_name'] == fieldset_1.api_name + assert fieldsets[1]['order'] == fieldset_2.order + assert fieldsets[1]['api_name'] == fieldset_2.api_name + get_token_mock.assert_called_once() + get_template_mock.assert_called_once_with(token) diff --git a/backend/src/processes/tests/test_views/test_templates/test_retrieve.py b/backend/src/processes/tests/test_views/test_templates/test_retrieve.py index 9197bb208..00a35101e 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_retrieve.py +++ b/backend/src/processes/tests/test_views/test_templates/test_retrieve.py @@ -27,6 +27,7 @@ from src.processes.tests.fixtures import ( create_invited_user, create_test_account, + create_test_fieldset_template, create_test_group, create_test_not_admin, create_test_owner, @@ -763,3 +764,52 @@ def test_retrieve__not_found__not_found(api_client): # assert assert response.status_code == 404 + + +def test_retrieve__fieldsets__ok(api_client): + # arrange + account = create_test_account() + account_owner = create_test_owner(account=account) + api_client.token_authenticate(account_owner) + template = create_test_template( + user=account_owner, + is_active=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + task = template.tasks.first() + + kickoff_fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + description='Kickoff fieldset desc', + api_name='fieldset-kickoff-1', + order=0, + ) + + task_fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + description='Task fieldset desc', + api_name='fieldset-task-1', + order=1, + ) + + # act + response = api_client.get(f'/templates/{template.id}') + + # assert + assert response.status_code == 200 + response_data = response.data + + kickoff_fieldsets = response_data['kickoff']['fieldsets'] + assert len(kickoff_fieldsets) == 1 + assert kickoff_fieldsets[0]['api_name'] == kickoff_fieldset.api_name + assert kickoff_fieldsets[0]['order'] == 0 + + task_fieldsets = response_data['tasks'][0]['fieldsets'] + assert len(task_fieldsets) == 1 + assert task_fieldsets[0]['api_name'] == task_fieldset.api_name + assert task_fieldsets[0]['order'] == 1 diff --git a/backend/src/processes/tests/test_views/test_templates/test_run.py b/backend/src/processes/tests/test_views/test_templates/test_run.py index c5fa5484b..56f20b099 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_run.py +++ b/backend/src/processes/tests/test_views/test_templates/test_run.py @@ -20,6 +20,7 @@ ConditionAction, DirectlyStatus, DueDateRule, + FieldSetRuleType, FieldType, OwnerRole, OwnerType, @@ -28,8 +29,11 @@ TaskStatus, WorkflowEventType, WorkflowStatus, + LabelPosition, + FieldSetLayout, ) from src.processes.messages import workflow as messages +from src.processes.messages.fieldset import MSG_FS_0002 from src.processes.messages.workflow import ( MSG_PW_0028, MSG_PW_0030, @@ -67,18 +71,22 @@ from src.processes.tests.fixtures import ( create_test_account, create_test_admin, + create_test_dataset, + create_test_fieldset_template, create_test_group, create_test_guest, + create_test_not_admin, create_test_owner, create_test_template, create_test_user, create_test_workflow, create_wf_completed_webhook, - create_wf_created_webhook, create_test_not_admin, create_test_dataset, + create_wf_created_webhook, create_test_shared_fieldset, ) from src.utils.dates import date_format from src.utils.validation import ErrorCode + pytestmark = pytest.mark.django_db @@ -5182,3 +5190,389 @@ def test_run__template_starter_not_admin__ok(mocker, api_client): # assert assert response.status_code == 200 assert Workflow.objects.filter(id=response.data['id']).exists() + + +def test_run__kickoff_with_one_fieldset__ok(mocker, api_client): + + """ Kickoff with one fieldset creates FieldSet. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + fs_title = 'Some title' + fs_description = 'Some desc' + fs_name = 'Some name' + fs_order = 3 + label_position = LabelPosition.LEFT + layout = FieldSetLayout.HORIZONTAL + rule_type = FieldSetRuleType.SUM_EQUAL + rule_value = '100' + shared_fieldset = create_test_shared_fieldset( + account=account, + title=fs_title, + description=fs_description, + name=fs_name, + label_position=label_position, + layout=layout, + rule_type=rule_type, + rule_value=rule_value, + ) + fieldset_template = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + order=fs_order, + shared_fieldset=shared_fieldset, + ) + field_template = fieldset_template.fields.first() + field_value = '100' + mocker.patch( + 'src.processes.services.workflow_action.' + 'WorkflowEventService.workflow_run_event', + ) + mocker.patch( + 'src.analysis.services.AnalyticService.' + 'workflows_started', + ) + api_client.token_authenticate(user) + + # act + response = api_client.post( + path=f'/templates/{template.id}/run', + data={ + 'kickoff': { + field_template.api_name: field_value, + }, + }, + ) + + # assert + assert response.status_code == 200 + workflow = Workflow.objects.get(id=response.data['id']) + kickoff_value = KickoffValue.objects.get(workflow=workflow) + assert kickoff_value.fieldsets.count() == 1 + fieldset = kickoff_value.fieldsets.first() + assert fieldset.name == fieldset_template.name + assert fieldset.api_name == fieldset_template.api_name + fieldsets_data = response.data['kickoff']['fieldsets'] + assert len(fieldsets_data) == 1 + fieldset_data = fieldsets_data[0] + assert fieldset_data['id'] == fieldset.id + assert fieldset_data['api_name'] == fieldset_template.api_name + assert fieldset_data['name'] == fs_name + assert fieldset_data['description'] == fs_description + assert fieldset_data['order'] == fs_order + assert fieldset_data['label_position'] == fieldset_template.label_position + assert fieldset_data['layout'] == fieldset_template.layout + + assert len(fieldset_data['fields']) == 1 + field_data = fieldset_data['fields'][0] + assert field_data['id'] + assert field_data['api_name'] == field_template.api_name + assert field_data['name'] == field_template.name + assert field_data['type'] == field_template.type + assert field_data['order'] == field_template.order + assert field_data['is_required'] == field_template.is_required + assert field_data['is_hidden'] == field_template.is_hidden + assert field_data['description'] == field_template.description + assert field_data['value'] == field_value + assert field_data['markdown_value'] == field_value + assert field_data['clear_value'] == field_value + assert field_data['user_id'] is None + assert field_data['group_id'] is None + assert field_data['selections'] == [] + assert field_data['attachments'] == [] + + +def test_run__kickoff_with_multiple_fieldsets__ok(mocker, api_client): + + """ Multiple fieldsets created with correct order. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + shared_fieldset_1 = create_test_shared_fieldset( + account=account, + title='First fieldset', + ) + fieldset_1 = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=kickoff, + order=0, + shared_fieldset=shared_fieldset_1, + ) + shared_fieldset_2 = create_test_shared_fieldset( + account=account, + title='Second fieldset', + ) + fieldset_2 = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=kickoff, + order=1, + shared_fieldset=shared_fieldset_2, + ) + field_1 = fieldset_1.fields.first() + field_2 = fieldset_2.fields.first() + field_value_1 = 'value 1' + field_value_2 = 'value 2' + mocker.patch( + 'src.processes.services.workflow_action.' + 'WorkflowEventService.workflow_run_event', + ) + mocker.patch( + 'src.analysis.services.AnalyticService.' + 'workflows_started', + ) + api_client.token_authenticate(user) + + # act + response = api_client.post( + path=f'/templates/{template.id}/run', + data={ + 'kickoff': { + field_1.api_name: field_value_1, + field_2.api_name: field_value_2, + }, + }, + ) + + # assert + assert response.status_code == 200 + workflow = Workflow.objects.get(id=response.data['id']) + kickoff_value = KickoffValue.objects.get( + workflow=workflow, + ) + assert kickoff_value.fieldsets.count() == 2 + fieldsets_data = response.data['kickoff']['fieldsets'] + assert len(fieldsets_data) == 2 + assert fieldsets_data[0]['title'] == fieldset_2.title + assert fieldsets_data[0]['order'] == 1 + assert fieldsets_data[1]['title'] == fieldset_1.title + assert fieldsets_data[1]['order'] == 0 + + +def test_run__kickoff_fieldset_and_standalone__ok( + mocker, + api_client, +): + + """ Fieldset and standalone field both created. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + fieldset_template = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + order=0, + ) + field_template_1 = fieldset_template.fields.first() + field_template_2 = FieldTemplate.objects.create( + name='Standalone field', + type=FieldType.STRING, + is_required=False, + kickoff=template.kickoff_instance, + template=template, + account=user.account, + ) + field_value_1 = 'fieldset field value' + field_value_2 = 'standalone field value' + wf_run_mock = mocker.patch( + 'src.processes.services.workflow_action.' + 'WorkflowEventService.workflow_run_event', + ) + analytics_mock = mocker.patch( + 'src.analysis.services.AnalyticService.' + 'workflows_started', + ) + api_client.token_authenticate(user) + + # act + response = api_client.post( + path=f'/templates/{template.id}/run', + data={ + 'kickoff': { + field_template_1.api_name: field_value_1, + field_template_2.api_name: field_value_2, + }, + }, + ) + + # assert + wf_run_mock.assert_called_once() + analytics_mock.assert_called_once() + assert response.status_code == 200 + workflow = Workflow.objects.get(id=response.data['id']) + kickoff_value = workflow.kickoff_instance + assert kickoff_value.output.count() == 1 + assert kickoff_value.output.get(api_name=field_template_2.api_name) + + assert kickoff_value.fieldsets.count() == 1 + fieldset = kickoff_value.fieldsets.get(api_name=fieldset_template.api_name) + assert fieldset.fields.get(api_name=field_template_1.api_name) + + fieldset_fields_data = response.data['kickoff']['fieldsets'][0]['fields'] + assert len(fieldset_fields_data) == 1 + assert fieldset_fields_data[0]['api_name'] == field_template_1.api_name + + fields_data = response.data['kickoff']['output'] + assert len(fields_data) == 1 + assert fields_data[0]['api_name'] == field_template_2.api_name + + +def test_run__kickoff_fieldset_sum_equal__ok( + mocker, + api_client, +): + + """ Fieldset sum_equal rule passes on correct sum. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + fieldset_template = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + order=0, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + field_1 = fieldset_template.fields.first() + field_2 = FieldTemplate.objects.create( + name='Second number', + type=FieldType.NUMBER, + fieldset=fieldset_template, + template=template, + order=2, + api_name=( + f'{fieldset_template.api_name}-field-2' + ), + account=user.account, + ) + rule_template = fieldset_template.rules.first() + field_1.rules.add(rule_template) + field_2.rules.add(rule_template) + wf_run_mock = mocker.patch( + 'src.processes.services.workflow_action.' + 'WorkflowEventService.workflow_run_event', + ) + analytics_mock = mocker.patch( + 'src.analysis.services.AnalyticService.' + 'workflows_started', + ) + api_client.token_authenticate(user) + + # act + response = api_client.post( + path=f'/templates/{template.id}/run', + data={ + 'kickoff': { + field_1.api_name: 60, + field_2.api_name: 40, + }, + }, + ) + + # assert + assert response.status_code == 200 + workflow = Workflow.objects.get(id=response.data['id']) + kickoff_value = KickoffValue.objects.get( + workflow=workflow, + ) + fieldset = kickoff_value.fieldsets.first() + assert fieldset.fields.count() == 2 + assert fieldset.rules.count() == 1 + wf_run_mock.assert_called_once() + analytics_mock.assert_called_once() + + +def test_run__kickoff_fieldset_sum_equal__validation_error( + mocker, + api_client, +): + + """ Fieldset sum_equal returns 400 on wrong sum. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + fieldset_template = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + order=0, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + field_1 = fieldset_template.fields.first() + field_2 = FieldTemplate.objects.create( + name='Second number', + type=FieldType.NUMBER, + fieldset=fieldset_template, + template=template, + order=2, + api_name=( + f'{fieldset_template.api_name}-field-2' + ), + account=user.account, + ) + rule_template = fieldset_template.rules.first() + field_1.rules.add(rule_template) + field_2.rules.add(rule_template) + wf_run_mock = mocker.patch( + 'src.processes.services.workflow_action.' + 'WorkflowEventService.workflow_run_event', + ) + analytics_mock = mocker.patch( + 'src.analysis.services.AnalyticService.' + 'workflows_started', + ) + api_client.token_authenticate(user) + + # act + response = api_client.post( + path=f'/templates/{template.id}/run', + data={ + 'kickoff': { + field_1.api_name: 60, + field_2.api_name: 50, + }, + }, + ) + + # assert + assert response.status_code == 400 + assert response.data['code'] == ErrorCode.VALIDATION_ERROR + assert response.data['message'] == MSG_FS_0002('100') + wf_run_mock.assert_not_called() + analytics_mock.assert_not_called() diff --git a/backend/src/processes/tests/test_views/test_templates/test_steps.py b/backend/src/processes/tests/test_views/test_templates/test_steps.py index 18bbcd0d7..d43fc5a9b 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_steps.py +++ b/backend/src/processes/tests/test_views/test_templates/test_steps.py @@ -95,7 +95,7 @@ def test_steps__admin__ok(api_client): # assert assert len(response.data) == 1 assert response.data[0]['number'] == 1 - assert response.data[0]['id'] == template_task.id + assert response.data[0]['name'] == template_task.name assert response.data[0]['api_name'] == template_task.api_name @@ -126,7 +126,7 @@ def test_steps__not_admin__ok(api_client): # assert assert len(response.data) == 1 assert response.data[0]['number'] == 1 - assert response.data[0]['id'] == template_task.id + assert response.data[0]['name'] == template_task.name assert response.data[0]['api_name'] == template_task.api_name @@ -161,7 +161,7 @@ def test_steps__group_is_template_owner__ok(api_client): assert response.status_code == 200 assert len(response.data) == 1 assert response.data[0]['number'] == template_task.number - assert response.data[0]['id'] == template_task.id + assert response.data[0]['name'] == template_task.name assert response.data[0]['api_name'] == template_task.api_name @@ -498,7 +498,7 @@ def test_steps__with_tasks_in_progress_true__another_task_completed__ok( assert response.status_code == 200 assert len(response.data) == 1 assert response.data[0]['number'] == template_task_2.number - assert response.data[0]['id'] == template_task_2.id + assert response.data[0]['name'] == template_task_2.name assert response.data[0]['api_name'] == template_task_2.api_name @@ -535,7 +535,7 @@ def test_steps__with_tasks_in_progress_true__not_template_owner__ok( # assert assert response.status_code == 200 assert len(response.data) == 1 - assert response.data[0]['id'] == template_task.id + assert response.data[0]['name'] == template_task.name assert response.data[0]['number'] == template_task.number assert response.data[0]['api_name'] == template_task.api_name @@ -576,7 +576,7 @@ def test_steps__with_tasks_in_progress_true__performer_group__ok( assert response.status_code == 200 assert len(response.data) == 1 assert response.data[0]['number'] == 1 - assert response.data[0]['id'] == template_task_1.id + assert response.data[0]['name'] == template_task_1.name assert response.data[0]['api_name'] == template_task_1.api_name @@ -694,7 +694,7 @@ def test_steps__with_tasks_in_progress_false__completed_task__ok( assert response.status_code == 200 assert len(response.data) == 1 assert response.data[0]['number'] == template_task.number - assert response.data[0]['id'] == template_task.id + assert response.data[0]['name'] == template_task.name assert response.data[0]['api_name'] == template_task.api_name @@ -771,7 +771,7 @@ def test_steps__with_tasks_in_progress_false__performer_group__ok( assert response.status_code == 200 assert len(response.data) == 1 assert response.data[0]['number'] == 1 - assert response.data[0]['id'] == template_task_1.id + assert response.data[0]['name'] == template_task_1.name assert response.data[0]['api_name'] == template_task_1.api_name diff --git a/backend/src/processes/tests/test_views/test_templates/test_titles_by_owners.py b/backend/src/processes/tests/test_views/test_templates/test_titles_by_owners.py index 6231e869d..fa2580d79 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_titles_by_owners.py +++ b/backend/src/processes/tests/test_views/test_templates/test_titles_by_owners.py @@ -1,12 +1,16 @@ import pytest from src.accounts.enums import BillingPlanType -from src.processes.enums import OwnerRole, OwnerType, FieldType -from src.processes.models.templates.fields import FieldTemplate, \ - FieldTemplateSelection +from src.processes.enums import OwnerRole, OwnerType, FieldType, \ + FieldSetRuleType +from src.processes.models.templates.fields import ( + FieldTemplate, + FieldTemplateSelection, +) from src.processes.models.templates.owner import TemplateOwner from src.processes.tests.fixtures import ( create_test_account, + create_test_fieldset_template, create_test_group, create_test_template, create_test_owner, @@ -442,3 +446,133 @@ def test_titles_by_owners__kickoff_field_with_dataset_and_selections__ok( assert len(field_data['selections']) == 2 assert field_data['selections'][0] == selection.value assert field_data['selections'][1] == dataset_item.value + + +def test_titles_by_owners__kickoff_fieldset__ok(api_client): + + """ GET titles-by-owners returns kickoff fieldset with all fields. """ + + # arrange + user = create_test_owner() + template = create_test_template( + user=user, + tasks_count=1, + is_active=True, + ) + kickoff = template.kickoff_instance + fieldset = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=kickoff, + title='Personal', + description='Enter your personal information', + api_name='fieldset-personal', + order=5, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='1', + ) + fieldset_field = fieldset.fields.first() + rule = fieldset.rules.all().first() + rule.fields.add(fieldset_field) + api_client.token_authenticate(user) + + # act + response = api_client.get('/templates/titles-by-owners') + + # assert + assert response.status_code == 200 + fieldsets = response.data[0]['kickoff']['fieldsets'] + assert len(fieldsets) == 1 + fieldset_data = fieldsets[0] + assert fieldset_data['order'] == fieldset.order + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['title'] == fieldset.title + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['api_name'] == fieldset.api_name + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + assert len(fieldset_data['rules']) == 1 + + rule_data = fieldset_data['rules'][0] + assert rule_data['type'] == rule.type + assert rule_data['value'] == rule.value + assert rule_data['api_name'] == rule.api_name + assert rule_data['fields'] == [fieldset_field.api_name] + + assert len(fieldset_data['fields']) == 1 + field_data = fieldset_data['fields'][0] + assert field_data['api_name'] == fieldset_field.api_name + assert field_data['name'] == fieldset_field.name + assert field_data['description'] == '' + assert field_data['type'] == fieldset_field.type + assert field_data['is_required'] == fieldset_field.is_required + assert field_data['is_hidden'] == fieldset_field.is_hidden + assert field_data['default'] == fieldset_field.default + assert field_data['order'] == fieldset_field.order + + +def test_titles_by_owners__kickoff_no_fieldsets__empty_list( + api_client, +): + + """ GET titles-by-owners returns empty fieldsets when none exist. """ + + # arrange + user = create_test_owner() + create_test_template( + user=user, + tasks_count=1, + is_active=True, + ) + api_client.token_authenticate(user) + + # act + response = api_client.get('/templates/titles-by-owners') + + # assert + assert response.status_code == 200 + fieldsets = response.data[0]['kickoff']['fieldsets'] + assert fieldsets == [] + + +def test_titles_by_owners__kickoff_fieldsets_ordered( + api_client, +): + + """ GET titles-by-owners returns fieldsets ordered by order. """ + + # arrange + user = create_test_owner() + template = create_test_template( + user=user, + tasks_count=1, + is_active=True, + ) + kickoff = template.kickoff_instance + fieldset_2 = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=kickoff, + api_name='fieldset-second', + order=2, + ) + fieldset_1 = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=kickoff, + api_name='fieldset-first', + order=1, + ) + api_client.token_authenticate(user) + + # act + response = api_client.get('/templates/titles-by-owners') + + # assert + assert response.status_code == 200 + fieldsets = response.data[0]['kickoff']['fieldsets'] + assert len(fieldsets) == 2 + assert fieldsets[0]['order'] == fieldset_1.order + assert fieldsets[0]['api_name'] == fieldset_1.api_name + assert fieldsets[1]['order'] == fieldset_2.order + assert fieldsets[1]['api_name'] == fieldset_2.api_name diff --git a/backend/src/processes/tests/test_views/test_templates/test_update/test_fieldsets.py b/backend/src/processes/tests/test_views/test_templates/test_update/test_fieldsets.py new file mode 100644 index 000000000..0b10f49fd --- /dev/null +++ b/backend/src/processes/tests/test_views/test_templates/test_update/test_fieldsets.py @@ -0,0 +1,1083 @@ +import pytest + +from src.processes.enums import ( + FieldSetLayout, + FieldSetRuleType, + LabelPosition, + OwnerRole, + OwnerType, + PerformerType, +) +from src.processes.models.templates.fieldset import FieldsetTemplate +from src.processes.tests.fixtures import ( + create_test_account, + create_test_fieldset_template, + create_test_owner, + create_test_shared_fieldset, + create_test_template, +) + +pytestmark = pytest.mark.django_db + +# Kickoff fieldsets + + +def test_update__create_kickoff_fieldset_only_required_data__ok( + mocker, + api_client, +): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user, is_active=True, tasks_count=1) + kickoff = template.kickoff_instance + task = template.tasks.first() + fs_title = 'Some title' + fs_description = 'Some desc' + fs_name = 'Some name' + fs_order = 3 + label_position = LabelPosition.LEFT + layout = FieldSetLayout.HORIZONTAL + rule_type = FieldSetRuleType.SUM_EQUAL + rule_value = '100' + shared_fieldset = create_test_shared_fieldset( + account=account, + title=fs_title, + description=fs_description, + name=fs_name, + order=fs_order, + label_position=label_position, + layout=layout, + rule_type=rule_type, + rule_value=rule_value, + ) + shared_field = shared_fieldset.fields.first() + shared_rule = shared_fieldset.rules.first() + shared_field.rules.add(shared_rule) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data={ + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + 'fieldsets': [ + { + 'shared_fieldset_id': shared_fieldset.id, + }, + ], + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + fieldset = FieldsetTemplate.objects.get( + kickoff=kickoff, + shared_fieldset=shared_fieldset, + is_shared=False, + ) + field = fieldset.fields.first() + rule = fieldset.rules.first() + + fieldsets = response.data['kickoff']['fieldsets'] + assert len(fieldsets) == 1 + fieldset_data = fieldsets[0] + assert fieldset_data['shared_fieldset_id'] == shared_fieldset.id + assert fieldset_data['order'] == 0 + assert fieldset_data['name'] == fs_name + assert fieldset_data['title'] == fs_title + assert fieldset_data['description'] == fs_description + assert fieldset_data['label_position'] == label_position + assert fieldset_data['layout'] == layout + assert fieldset_data['api_name'] == fieldset.api_name + assert len(fieldset_data['fields']) == 1 + field_data = fieldset_data['fields'][0] + assert field_data['name'] == shared_field.name + assert field_data['description'] == '' + assert field_data['type'] == shared_field.type + assert field_data['is_required'] == shared_field.is_required + assert field_data['is_hidden'] == shared_field.is_hidden + assert field_data['order'] == shared_field.order + assert field_data['default'] == shared_field.default + assert field_data['api_name'] == field.api_name + assert len(fieldset_data['rules']) == 1 + rule_data = fieldset_data['rules'][0] + assert rule_data['type'] == rule_type + assert rule_data['value'] == rule_value + assert rule_data['api_name'] == rule.api_name + assert rule_data['fields'] == [field.api_name] + + +def test_update__kickoff_fieldset_all_fieldset_data__ok( + mocker, + api_client, +): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user, is_active=True, tasks_count=1) + kickoff = template.kickoff_instance + task = template.tasks.first() + fs_title = 'Some title' + fs_description = 'Some desc' + fs_order = 3 + fs_api_name = 'fs-some-api-name' + shared_fieldset = create_test_shared_fieldset(account=account) + shared_fieldset.fields.all().delete() + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data={ + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + 'fieldsets': [ + { + 'shared_fieldset_id': shared_fieldset.id, + 'order': fs_order, + 'title': fs_title, + 'description': fs_description, + 'api_name': fs_api_name, + }, + ], + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + assert FieldsetTemplate.objects.get( + kickoff=kickoff, + shared_fieldset=shared_fieldset, + is_shared=False, + api_name=fs_api_name, + ) + + fieldsets = response.data['kickoff']['fieldsets'] + assert len(fieldsets) == 1 + fieldset_data = fieldsets[0] + assert fieldset_data['shared_fieldset_id'] == shared_fieldset.id + assert fieldset_data['order'] == fs_order + assert fieldset_data['title'] == fs_title + assert fieldset_data['description'] == fs_description + assert fieldset_data['name'] == shared_fieldset.name + assert fieldset_data['api_name'] == fs_api_name + assert fieldset_data['label_position'] == shared_fieldset.label_position + assert fieldset_data['layout'] == shared_fieldset.layout + assert fieldset_data['rules'] == [] + assert fieldset_data['fields'] == [] + + +def test_update__kickoff_create_two_fieldsets__ok( + mocker, + api_client, +): + + """ Updating a template with multiple fieldsets linked to + kickoff creates multiple child fieldset records. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user, is_active=True, tasks_count=1) + kickoff = template.kickoff_instance + task = template.tasks.first() + shared_1 = create_test_shared_fieldset(account=account) + shared_2 = create_test_shared_fieldset(account=account) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data={ + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + 'fieldsets': [ + { + 'shared_fieldset_id': shared_1.id, + 'order': 0, + }, + { + 'shared_fieldset_id': shared_2.id, + 'order': 1, + }, + ], + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + fieldsets = response.data['kickoff']['fieldsets'] + assert len(fieldsets) == 2 + assert kickoff.fieldsets.filter( + shared_fieldset_id=shared_1.id, + order=0, + ).count() == 1 + assert kickoff.fieldsets.filter( + shared_fieldset_id=shared_2.id, + order=1, + ).count() == 1 + + +def test_update__kickoff_replace_fieldset__ok( + mocker, + api_client, +): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user, is_active=True, tasks_count=1) + kickoff = template.kickoff_instance + task = template.tasks.first() + shared_1 = create_test_shared_fieldset(account=account) + shared_2 = create_test_shared_fieldset(account=account) + # create an fieldset child fieldset linked to kickoff from shared_1 + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + shared_fieldset=shared_1, + order=0, + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data={ + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + 'fieldsets': [ + { + 'shared_fieldset_id': shared_2.id, + 'order': 2, + }, + ], + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + fieldsets = response.data['kickoff']['fieldsets'] + assert len(fieldsets) == 1 + assert fieldsets[0]['shared_fieldset_id'] == shared_2.id + assert fieldsets[0]['order'] == 2 + assert not kickoff.fieldsets.filter(id=fieldset.id).exists() + assert kickoff.fieldsets.filter( + shared_fieldset_id=shared_2.id, + order=2, + ).count() == 1 + + +def test_update__kickoff_remove_fieldset__ok( + mocker, + api_client, +): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user, is_active=True, tasks_count=1) + kickoff = template.kickoff_instance + task = template.tasks.first() + shared_fieldset = create_test_shared_fieldset(account=account) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + shared_fieldset=shared_fieldset, + order=0, + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data={ + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + 'fieldsets': [], + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + assert response.data['kickoff']['fieldsets'] == [] + assert not kickoff.fieldsets.filter(id=fieldset.id).exists() + + +# Task fieldsets + + +def test_update__create_task_fieldset_only_required_data__ok( + mocker, + api_client, +): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user, is_active=True, tasks_count=1) + kickoff = template.kickoff_instance + task = template.tasks.first() + fs_title = 'Some title' + fs_description = 'Some desc' + fs_name = 'Some name' + fs_order = 3 + label_position = LabelPosition.LEFT + layout = FieldSetLayout.HORIZONTAL + rule_type = FieldSetRuleType.SUM_EQUAL + rule_value = '200' + shared_fieldset = create_test_shared_fieldset( + account=account, + title=fs_title, + description=fs_description, + name=fs_name, + order=fs_order, + label_position=label_position, + layout=layout, + rule_type=rule_type, + rule_value=rule_value, + ) + shared_field = shared_fieldset.fields.first() + shared_rule = shared_fieldset.rules.first() + shared_field.rules.add(shared_rule) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data={ + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'fieldsets': [ + { + 'shared_fieldset_id': shared_fieldset.id, + }, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + fieldset = FieldsetTemplate.objects.get( + task=task, + shared_fieldset=shared_fieldset, + is_shared=False, + ) + field = fieldset.fields.first() + rule = fieldset.rules.first() + + fieldsets = response.data['tasks'][0]['fieldsets'] + assert len(fieldsets) == 1 + fieldset_data = fieldsets[0] + assert fieldset_data['shared_fieldset_id'] == shared_fieldset.id + assert fieldset_data['order'] == 0 + assert fieldset_data['name'] == fs_name + assert fieldset_data['title'] == fs_title + assert fieldset_data['description'] == fs_description + assert fieldset_data['label_position'] == label_position + assert fieldset_data['layout'] == layout + assert fieldset_data['api_name'] == fieldset.api_name + assert len(fieldset_data['fields']) == 1 + field_data = fieldset_data['fields'][0] + assert field_data['name'] == shared_field.name + assert field_data['description'] == '' + assert field_data['type'] == shared_field.type + assert field_data['is_required'] == shared_field.is_required + assert field_data['is_hidden'] == shared_field.is_hidden + assert field_data['order'] == shared_field.order + assert field_data['default'] == shared_field.default + assert field_data['api_name'] == field.api_name + assert len(fieldset_data['rules']) == 1 + rule_data = fieldset_data['rules'][0] + assert rule_data['type'] == rule_type + assert rule_data['value'] == rule_value + assert rule_data['api_name'] == rule.api_name + assert rule_data['fields'] == [field.api_name] + + +def test_update__task_fieldset_all_fieldset_data__ok( + mocker, + api_client, +): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user, is_active=True, tasks_count=1) + kickoff = template.kickoff_instance + task = template.tasks.first() + fs_title = 'Some title' + fs_description = 'Some desc' + fs_order = 3 + fs_api_name = 'fs-some-api-name' + shared_fieldset = create_test_shared_fieldset(account=account) + shared_fieldset.fields.all().delete() + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data={ + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'fieldsets': [ + { + 'shared_fieldset_id': shared_fieldset.id, + 'order': fs_order, + 'title': fs_title, + 'description': fs_description, + 'api_name': fs_api_name, + }, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + assert FieldsetTemplate.objects.get( + task=task, + shared_fieldset=shared_fieldset, + is_shared=False, + api_name=fs_api_name, + ) + + task_data = response.data['tasks'][0] + assert len(task_data['fieldsets']) == 1 + fieldset_data = task_data['fieldsets'][0] + assert fieldset_data['shared_fieldset_id'] == shared_fieldset.id + assert fieldset_data['order'] == fs_order + assert fieldset_data['title'] == fs_title + assert fieldset_data['description'] == fs_description + assert fieldset_data['name'] == shared_fieldset.name + assert fieldset_data['api_name'] == fs_api_name + assert fieldset_data['label_position'] == shared_fieldset.label_position + assert fieldset_data['layout'] == shared_fieldset.layout + assert fieldset_data['rules'] == [] + assert fieldset_data['fields'] == [] + + +def test_update__task_create_two_fieldsets__ok( + mocker, + api_client, +): + + """ Updating a template with multiple fieldsets linked to a task + creates multiple child fieldset records. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user, is_active=True, tasks_count=1) + task = template.tasks.first() + kickoff = template.kickoff_instance + shared_1 = create_test_shared_fieldset(account=account) + shared_2 = create_test_shared_fieldset(account=account) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data={ + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'fieldsets': [ + { + 'shared_fieldset_id': shared_1.id, + 'order': 1, + }, + { + 'shared_fieldset_id': shared_2.id, + 'order': 0, + }, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + fieldsets = response.data['tasks'][0]['fieldsets'] + assert len(fieldsets) == 2 + assert task.fieldsets.filter( + shared_fieldset_id=shared_1.id, + order=1, + ).count() == 1 + assert task.fieldsets.filter( + shared_fieldset_id=shared_2.id, + order=0, + ).count() == 1 + + +def test_update__task_replace_fieldset__ok( + mocker, + api_client, +): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user, is_active=True, tasks_count=1) + kickoff = template.kickoff_instance + task = template.tasks.first() + shared_1 = create_test_shared_fieldset(account=account) + shared_2 = create_test_shared_fieldset(account=account) + # create an fieldset child fieldset linked to task from shared_1 + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + shared_fieldset=shared_1, + order=0, + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data={ + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'fieldsets': [ + { + 'shared_fieldset_id': shared_2.id, + 'order': 2, + }, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + fieldsets = response.data['tasks'][0]['fieldsets'] + assert len(fieldsets) == 1 + assert fieldsets[0]['shared_fieldset_id'] == shared_2.id + assert fieldsets[0]['order'] == 2 + assert not task.fieldsets.filter(id=fieldset.id).exists() + assert task.fieldsets.filter( + shared_fieldset_id=shared_2.id, + order=2, + ).count() == 1 + + +def test_update__tasks_remove_fieldset__ok( + mocker, + api_client, +): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user, is_active=True, tasks_count=1) + kickoff = template.kickoff_instance + task = template.tasks.first() + shared_fieldset = create_test_shared_fieldset(account=account) + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + shared_fieldset=shared_fieldset, + order=0, + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data={ + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'fieldsets': [], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + assert response.data['tasks'][0]['fieldsets'] == [] + assert not task.fieldsets.filter(id=fieldset.id).exists() + + +def test_update__task_with_empty_fieldsets__no_create_fieldsets( + mocker, + api_client, +): + + """ Updating a template with empty fieldsets list in task does not + create any task fieldset records. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user, is_active=True, tasks_count=1) + kickoff = template.kickoff_instance + task = template.tasks.first() + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data={ + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'fieldsets': [], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + assert task.fieldsets.count() == 0 diff --git a/backend/src/processes/tests/test_views/test_templates/test_update/test_kickoff.py b/backend/src/processes/tests/test_views/test_templates/test_update/test_kickoff.py index 6e3f5f393..088be71eb 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_update/test_kickoff.py +++ b/backend/src/processes/tests/test_views/test_templates/test_update/test_kickoff.py @@ -211,6 +211,7 @@ def test_update_draft__not_kickoff__ok( assert response.status_code == 200 assert response.data['kickoff'] == { 'fields': [], + 'fieldsets': [], } assert response.data['is_active'] is False kickoff_update_mock.assert_not_called() @@ -267,6 +268,7 @@ def test_update_draft__kickoff_id_null__ok( assert response.status_code == 200 assert response.data['kickoff'] == { 'fields': [], + 'fieldsets': [], } assert response.data['is_active'] is False kickoff_update_mock.assert_not_called() @@ -309,5 +311,6 @@ def test_update_draft__not_send_kickoff_id__ok( assert response.status_code == 200 assert response.data['kickoff'] == { 'fields': [], + 'fieldsets': [], } kickoff_update_mock.assert_not_called() diff --git a/backend/src/processes/tests/test_views/test_templates/test_update/test_template.py b/backend/src/processes/tests/test_views/test_templates/test_update/test_template.py index ab13bff56..4e208a3cc 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_update/test_template.py +++ b/backend/src/processes/tests/test_views/test_templates/test_update/test_template.py @@ -40,6 +40,7 @@ create_test_template, create_test_user, create_test_workflow, + create_test_fieldset_template, create_test_shared_fieldset, ) pytestmark = pytest.mark.django_db @@ -2829,3 +2830,74 @@ def test_update__inactive_template__analytics_skipped(mocker, api_client): api_request_mock.assert_not_called() templates_kickoff_updated_mock.assert_not_called() templates_updated_mock.assert_not_called() + + +def test_update__wf_name_template_with_fieldset_field__ok( + mocker, + api_client, +): + # arrange + account = create_test_account() + user = create_test_owner(account=account) + api_client.token_authenticate(user) + template = create_test_template(user=user, tasks_count=1) + task = template.tasks.first() + kickoff = template.kickoff_instance + shared_fieldset = create_test_shared_fieldset(account=account) + fieldset = create_test_fieldset_template( + account=account, + shared_fieldset=shared_fieldset, + ) + field = fieldset.fields.first() + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + wf_name_template = f'Template {{ {field.api_name} }}' + + request_data = { + 'id': template.id, + 'is_active': True, + 'wf_name_template': wf_name_template, + 'name': template.name, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + 'fieldsets': [ + { + 'shared_fieldset_id': shared_fieldset.id, + 'order': 1, + }, + ], + }, + 'tasks': [ + { + 'id': task.id, + 'number': task.number, + 'name': task.name, + 'api_name': task.api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + } + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert response.data['wf_name_template'] == wf_name_template diff --git a/backend/src/processes/tests/test_views/test_workflow/test_events.py b/backend/src/processes/tests/test_views/test_workflow/test_events.py index 330e614bf..693095712 100644 --- a/backend/src/processes/tests/test_views/test_workflow/test_events.py +++ b/backend/src/processes/tests/test_views/test_workflow/test_events.py @@ -36,6 +36,8 @@ TaskPerformer, ) from src.processes.models.workflows.workflow import Workflow +from src.processes.models.workflows.fields import TaskField +from src.processes.models.workflows.fieldset import FieldSet from src.processes.services.events import ( WorkflowEventService, ) @@ -54,6 +56,7 @@ create_test_template, create_test_user, create_test_workflow, + create_test_event, ) pytestmark = pytest.mark.django_db @@ -1716,3 +1719,166 @@ def test_workflow_events__template_starter_own_workflow__ok(api_client): # assert assert response.status_code == status.HTTP_200_OK + + +def test_events__task_complete_fieldsets_present__ok(api_client): + + """ + GET workflow events: TASK_COMPLETE row includes non-null task.fieldsets + when the task has at least one FieldSet. + """ + + # arrange + + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user, tasks_count=1) + task_1 = workflow.tasks.get(number=1) + fieldset = FieldSet.objects.create( + account=account, + workflow=workflow, + task=task_1, + name='Fieldset 1', + order=1, + ) + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + task=task_1, + fieldset=fieldset, + name='Field 1', + type=FieldType.TEXT, + order=1, + ) + field_2 = TaskField.objects.create( + account=account, + workflow=workflow, + task=task_1, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + order=2, + ) + WorkflowEventService.task_complete_event( + task=task_1, + user=user, + after_create_actions=False, + ) + api_client.token_authenticate(user=user) + + # act + + response = api_client.get( + path=f'/workflows/{workflow.id}/events', + ) + + # assert + + assert response.status_code == 200 + event_data = response.data[0] + assert event_data['type'] == WorkflowEventType.TASK_COMPLETE + fieldsets_data = event_data['task']['fieldsets'] + assert fieldsets_data is not None + assert len(fieldsets_data) == 1 + fieldset.refresh_from_db() + field_1.refresh_from_db() + field_2.refresh_from_db() + fieldset_data = fieldsets_data[0] + assert fieldset_data['id'] == fieldset.id + assert fieldset_data['api_name'] == fieldset.api_name + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['order'] == fieldset.order + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + fields_data = fieldset_data['fields'] + assert len(fields_data) == 2 + field_2_data = fields_data[0] + assert field_2_data['id'] == field_2.id + assert field_2_data['order'] == field_2.order + assert field_2_data['type'] == field_2.type + assert field_2_data['is_required'] == field_2.is_required + assert field_2_data['is_hidden'] == field_2.is_hidden + assert field_2_data['description'] == field_2.description + assert field_2_data['api_name'] == field_2.api_name + assert field_2_data['name'] == field_2.name + assert field_2_data['value'] == field_2.value + assert field_2_data['markdown_value'] == field_2.markdown_value + assert field_2_data['clear_value'] == field_2.clear_value + assert field_2_data['user_id'] == field_2.user_id + assert field_2_data['group_id'] == field_2.group_id + assert field_2_data['selections'] == [] + assert field_2_data['attachments'] == [] + field_1_data = fields_data[1] + assert field_1_data['id'] == field_1.id + + +def test_events__task_complete_fieldsets_absent__ok(api_client): + + """ + GET workflow events: TASK_COMPLETE row has task.fieldsets equal to null + when the task has no FieldSet rows. + """ + + # arrange + + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user, tasks_count=1) + task_1 = workflow.tasks.get(number=1) + WorkflowEventService.task_complete_event( + task=task_1, + user=user, + after_create_actions=False, + ) + api_client.token_authenticate(user=user) + + # act + + response = api_client.get( + path=f'/workflows/{workflow.id}/events', + ) + + # assert + + assert response.status_code == 200 + item_1 = response.data[0] + assert item_1['type'] == WorkflowEventType.TASK_COMPLETE + task_payload = item_1['task'] + assert task_payload['fieldsets'] is None + + +def test_events__non_complete_task_fieldsets_null__ok(api_client): + + """ + GET workflow events: non-TASK_COMPLETE event still exposes task.fieldsets + as null in serialized task payload. + """ + + # arrange + + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user, tasks_count=1) + task_1 = workflow.tasks.get(number=1) + create_test_event( + workflow=workflow, + user=user, + task=task_1, + type_event=WorkflowEventType.COMMENT, + ) + api_client.token_authenticate(user=user) + + # act + + response = api_client.get( + path=f'/workflows/{workflow.id}/events', + ) + + # assert + + assert response.status_code == 200 + item_1 = response.data[0] + assert item_1['type'] == WorkflowEventType.COMMENT + task_payload = item_1['task'] + assert task_payload['fieldsets'] is None diff --git a/backend/src/processes/tests/test_views/test_workflow/test_fields.py b/backend/src/processes/tests/test_views/test_workflow/test_fields.py index 31ff2c1b1..d269793b9 100644 --- a/backend/src/processes/tests/test_views/test_workflow/test_fields.py +++ b/backend/src/processes/tests/test_views/test_workflow/test_fields.py @@ -22,6 +22,7 @@ from src.processes.tests.fixtures import ( create_test_account, create_test_admin, + create_test_fieldset, create_test_guest, create_test_not_admin, create_test_owner, @@ -865,3 +866,195 @@ def test_fields__all_params__ok(api_client): # assert assert response.status_code == 200 + + +def test_fields__filter_by_fields_fieldset__ok(api_client): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=1) + task = workflow.tasks.get(number=1) + + create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-2', + ) + field = fieldset.fields.first() + + api_client.token_authenticate(owner) + + # act + response = api_client.get( + '/workflows/fields', + data={ + 'fields': [field.api_name], + }, + ) + + # assert + assert response.status_code == 200 + data = response.data['results'] + assert len(data) == 1 + assert data[0]['id'] == workflow.id + fields_data = data[0]['fields'] + assert len(fields_data) == 1 + assert fields_data[0]['id'] == field.id + assert fields_data[0]['fieldset_id'] == fieldset.id + + +def test_fields__filter_by_multiple_fields_fieldset__ok(api_client): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + + fieldset_1 = create_test_fieldset( + workflow=workflow, + kickoff=workflow.kickoff_instance, + api_name='fieldset-1', + ) + field_1 = fieldset_1.fields.first() + + fieldset_2 = create_test_fieldset( + workflow=workflow, + task=task_1, + api_name='fieldset-2', + ) + field_2 = fieldset_2.fields.first() + field_2.order = 2 + field_2.save() + + create_test_fieldset( + workflow=workflow, + task=task_1, + api_name='fieldset-non-selected', + ) + + api_client.token_authenticate(owner) + + # act + response = api_client.get( + '/workflows/fields', + data={ + 'fields': ( + f'{field_1.api_name},' + f'{field_2.api_name}' + ), + }, + ) + + # assert + assert response.status_code == 200 + data = response.data['results'] + assert len(data) == 1 + assert data[0]['id'] == workflow.id + + fields_data = data[0]['fields'] + assert len(fields_data) == 2 + assert fields_data[0]['id'] == field_2.id + assert fields_data[0]['task_id'] == task_1.id + assert fields_data[0]['fieldset_id'] == fieldset_2.id + assert fields_data[1]['id'] == field_1.id + assert fields_data[1]['kickoff_id'] is None + assert fields_data[1]['fieldset_id'] == fieldset_1.id + + +def test_fields__multiple_workflows_and_multiple_fields_fieldset__ok( + api_client, +): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=2) + + fieldset_1 = create_test_fieldset( + workflow=workflow, + kickoff=workflow.kickoff_instance, + api_name='fieldset-1', + ) + field_1 = fieldset_1.fields.first() + + workflow_2 = create_test_workflow(owner, tasks_count=2) + + fieldset_2 = create_test_fieldset( + workflow=workflow_2, + kickoff=workflow_2.kickoff_instance, + api_name='fieldset-2', + ) + field_2 = fieldset_2.fields.first() + + api_client.token_authenticate(owner) + + # act + response = api_client.get( + '/workflows/fields', + data={ + 'fields': ( + f'{field_1.api_name},' + f'{field_2.api_name}' + ), + }, + ) + + # assert + assert response.status_code == 200 + data = response.data['results'] + assert len(data) == 2 + assert data[0]['id'] == workflow_2.id + fields_data = data[0]['fields'] + assert len(fields_data) == 1 + assert fields_data[0]['id'] == field_2.id + assert fields_data[0]['fieldset_id'] == fieldset_2.id + + assert data[1]['id'] == workflow.id + fields_data = data[1]['fields'] + assert len(fields_data) == 1 + assert fields_data[0]['id'] == field_1.id + assert fields_data[0]['fieldset_id'] == fieldset_1.id + + +def test_fields__filter_by_invalid_fields_fieldset__ok(api_client): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=1) + task = workflow.tasks.get(number=1) + + create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-2', + ) + api_client.token_authenticate(owner) + + # act + response = api_client.get( + '/workflows/fields', + data={ + 'fields': 'field-non-existing', + }, + ) + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 1 + data = response.data['results'][0] + assert data['id'] == workflow.id + assert len(data['fields']) == 0 diff --git a/backend/src/processes/tests/test_views/test_workflow/test_list.py b/backend/src/processes/tests/test_views/test_workflow/test_list.py index 0b0d91d28..e8ca9ef65 100644 --- a/backend/src/processes/tests/test_views/test_workflow/test_list.py +++ b/backend/src/processes/tests/test_views/test_workflow/test_list.py @@ -46,6 +46,7 @@ create_invited_user, create_test_account, create_test_admin, + create_test_fieldset, create_test_group, create_test_guest, create_test_not_admin, @@ -2181,6 +2182,7 @@ def test_list__filter_fields_kickoff_field__ok(api_client): assert field_data['is_required'] == field.is_required assert field_data['task_id'] is None assert field_data['kickoff_id'] == workflow.kickoff_instance.id + assert field_data['fieldset_id'] is None assert field_data['type'] == field.type assert field_data['api_name'] == field.api_name assert field_data['name'] == field.name @@ -2548,6 +2550,427 @@ def test_list__filter_fields_and_another_template_id__empty_list(api_client): assert len(response.data['results']) == 0 +def test_list__filter_fields_kickoff_fieldset__ok(api_client): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=1) + + create_test_fieldset( + workflow=workflow, + kickoff=workflow.kickoff_instance, + api_name='fieldset-1', + ) + + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=workflow.kickoff_instance, + api_name='fieldset-2', + ) + field = fieldset.fields.first() + field.value = 'raw value' + field.clear_value = 'clear value' + field.markdown_value = '**bold** value' + field.order = 2 + field.description = 'desc' + field.is_required = True + field.save() + + api_client.token_authenticate(owner) + + # act + response = api_client.get(f'/workflows?fields={field.api_name}') + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == workflow.id + assert len(response.data['results'][0]['fields']) == 1 + field_data = response.data['results'][0]['fields'][0] + assert field_data['id'] == field.id + assert field_data['order'] == field.order + assert field_data['is_required'] == field.is_required + assert field_data['fieldset_id'] == fieldset.id + assert field_data['task_id'] is None + assert field_data['kickoff_id'] is None + assert field_data['type'] == field.type + assert field_data['api_name'] == field.api_name + assert field_data['name'] == field.name + assert field_data['description'] == field.description + assert field_data['value'] == field.value + assert field_data['markdown_value'] == field.markdown_value + assert field_data['clear_value'] == field.clear_value + assert field_data['user_id'] is None + assert field_data['group_id'] is None + + +def test_list__filter_fields_task_fieldset__ok(api_client): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=1) + task = workflow.tasks.first() + + create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-2', + ) + field = fieldset.fields.first() + field.value = 'raw value' + field.clear_value = 'clear value' + field.markdown_value = '**bold** value' + field.order = 2 + field.description = 'desc' + field.is_required = True + field.save() + + api_client.token_authenticate(owner) + + # act + response = api_client.get(f'/workflows?fields={field.api_name}') + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == workflow.id + assert len(response.data['results'][0]['fields']) == 1 + field_data = response.data['results'][0]['fields'][0] + assert field_data['id'] == field.id + assert field_data['order'] == field.order + assert field_data['is_required'] == field.is_required + assert field_data['task_id'] == task.id + assert field_data['kickoff_id'] is None + assert field_data['fieldset_id'] == fieldset.id + assert field_data['type'] == field.type + assert field_data['api_name'] == field.api_name + assert field_data['name'] == field.name + assert field_data['description'] == field.description + assert field_data['value'] == field.value + assert field_data['markdown_value'] == field.markdown_value + assert field_data['clear_value'] == field.clear_value + assert field_data['user_id'] is None + assert field_data['group_id'] is None + + +def test_list__filter_fields__fieldset_ordering__ok(api_client): + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + + fieldset_4 = create_test_fieldset( + workflow=workflow, + task=task_1, + api_name='fs-4', + ) + field_4 = fieldset_4.fields.first() + field_4.order = 3 + field_4.save() + + task_2 = workflow.tasks.get(number=2) + + fieldset_3 = create_test_fieldset( + workflow=workflow, + task=task_2, + api_name='fs-3', + ) + field_3 = fieldset_3.fields.first() + field_3.order = 2 + field_3.save() + + fieldset_2 = create_test_fieldset( + workflow=workflow, + task=task_2, + api_name='fs-2', + ) + field_2 = fieldset_2.fields.first() + field_2.order = 1 + field_2.save() + + kickoff = workflow.kickoff_instance + fieldset_1 = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + api_name='fs-1', + ) + field_1 = fieldset_1.fields.first() + field_1.order = 5 + field_1.save() + + task_1.number = 3 + task_1.save() + task_2.number = 1 + task_2.save() + + api_client.token_authenticate(user) + fields_filter = ( + f'{field_4.api_name},' + f'{field_3.api_name},' + f'{field_2.api_name},' + f'{field_1.api_name}' + ) + + # act + response = api_client.get(f'/workflows?fields={fields_filter}') + + # assert + assert response.status_code == 200 + fields_data = response.data['results'][0]['fields'] + assert len(fields_data) == 4 + assert fields_data[0]['id'] == field_3.id + assert fields_data[1]['id'] == field_2.id + assert fields_data[2]['id'] == field_4.id + assert fields_data[3]['id'] == field_1.id + + +def test_list__filter_fields_multiple_values_fieldset__ok(api_client): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=1) + task = workflow.tasks.first() + + fieldset_1 = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + field_1 = fieldset_1.fields.first() + field_1.order = 3 + field_1.save() + + fieldset_2 = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-2', + ) + field_2 = fieldset_2.fields.first() + field_2.type = FieldType.NUMBER + field_2.value = '2.13' + field_2.order = 2 + field_2.save() + + api_client.token_authenticate(owner) + + # act + response = api_client.get( + f'/workflows?fields={field_1.api_name},{field_2.api_name}', + ) + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == workflow.id + assert len(response.data['results'][0]['fields']) == 2 + field_1_data = response.data['results'][0]['fields'][0] + assert field_1_data['id'] == field_1.id + field_2_data = response.data['results'][0]['fields'][1] + assert field_2_data['id'] == field_2.id + + +def test_list__filter_fields_blank_fieldset__empty_list(api_client): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=1) + task = workflow.tasks.first() + + create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + api_client.token_authenticate(owner) + + # act + response = api_client.get('/workflows?fields=') + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == workflow.id + assert len(response.data['results'][0]['fields']) == 0 + + +def test_list__filter_fields_not_existent_fieldset__empty_list(api_client): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=1) + task = workflow.tasks.first() + + create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + api_client.token_authenticate(owner) + + # act + response = api_client.get('/workflows?fields=not-existent') + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == workflow.id + assert len(response.data['results'][0]['fields']) == 0 + + +def test_list__filter_fields__another_account_fieldset__empty_list( + api_client, +): + + # arrange + another_account = create_test_account() + another_owner = create_test_owner(account=another_account) + workflow = create_test_workflow(another_owner, tasks_count=1) + task = workflow.tasks.first() + + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + field = fieldset.fields.first() + + account = create_test_account() + owner = create_test_owner( + account=account, + email='owner2@pneumatic.app', + ) + workflow_2 = create_test_workflow(user=owner, tasks_count=1) + api_client.token_authenticate(owner) + + # act + response = api_client.get(f'/workflows?fields={field.api_name}') + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == workflow_2.id + assert len(response.data['results'][0]['fields']) == 0 + + +def test_list__filter_fields__another_workflow_fieldset__empty_list( + api_client, +): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=1) + task = workflow.tasks.first() + + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + field = fieldset.fields.first() + + user = create_test_admin(account=account) + workflow_2 = create_test_workflow(user, tasks_count=1) + api_client.token_authenticate(user) + + # act + response = api_client.get(f'/workflows?fields={field.api_name}') + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == workflow_2.id + assert len(response.data['results'][0]['fields']) == 0 + + +def test_list__filter_fields_and_template_fieldset__ok(api_client): + + # arrange + user = create_test_owner() + template_1 = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template_1, + ) + task = workflow.tasks.first() + + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + field = fieldset.fields.first() + api_client.token_authenticate(user) + + # act + response = api_client.get( + f'/workflows?fields={field.api_name};template_id={template_1.id}', + ) + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == workflow.id + assert len(response.data['results'][0]['fields']) == 1 + field_data = response.data['results'][0]['fields'][0] + assert field_data['id'] == field.id + assert field_data['fieldset_id'] == fieldset.id + + +def test_list__filter_fields_and_another_template_id_fieldset__empty_list( + api_client, +): + + # arrange + user = create_test_owner() + template_1 = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template_1, + ) + task = workflow.tasks.first() + + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + field = fieldset.fields.first() + + another_template = create_test_template(user=user, tasks_count=1) + api_client.token_authenticate(user) + + # act + response = api_client.get( + f'/workflows?fields={field.api_name};' + f'template_id={another_template.id}', + ) + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 0 + + def test_list__ordering_oldest__ok(api_client): # arrange diff --git a/backend/src/processes/tests/test_views/test_workflow/test_webhook_example.py b/backend/src/processes/tests/test_views/test_workflow/test_webhook_example.py index 177004fd3..c7b0f2ac6 100644 --- a/backend/src/processes/tests/test_views/test_workflow/test_webhook_example.py +++ b/backend/src/processes/tests/test_views/test_workflow/test_webhook_example.py @@ -61,6 +61,7 @@ def test_webhook_example__body__ok(api_client): 'kickoff': { 'id': workflow.kickoff_instance.id, 'output': [], + 'fieldsets': [], }, 'tasks': [ OrderedDict([ diff --git a/backend/src/processes/tests/test_webhooks/test_webhooks.py b/backend/src/processes/tests/test_webhooks/test_webhooks.py index bb3462cb6..d1e15f53c 100644 --- a/backend/src/processes/tests/test_webhooks/test_webhooks.py +++ b/backend/src/processes/tests/test_webhooks/test_webhooks.py @@ -58,6 +58,7 @@ def test_send_task_completed_webhook__ok(api_client, mocker): 'contains_comments': False, 'require_completion_by_all': False, 'output': [], + 'fieldsets': [], 'delay': None, 'date_started_tsp': task_1.date_started.timestamp(), 'date_completed_tsp': task_1.date_completed.timestamp(), @@ -108,6 +109,7 @@ def test_send_task_completed_webhook__ok(api_client, mocker): 'kickoff': { 'id': workflow.kickoff_instance.id, 'output': [], + 'fieldsets': [], }, 'tasks': [ OrderedDict([ @@ -226,6 +228,7 @@ def test_send_task_completed_webhook__sub_workflows__ok(api_client, mocker): 'contains_comments': False, 'require_completion_by_all': False, 'output': [], + 'fieldsets': [], 'delay': None, 'date_started_tsp': task_1.date_started.timestamp(), 'date_completed_tsp': task_1.date_completed.timestamp(), @@ -343,6 +346,7 @@ def test_send_task_completed_webhook__sub_workflows__ok(api_client, mocker): 'kickoff': { 'id': workflow.kickoff_instance.id, 'output': [], + 'fieldsets': [], }, 'tasks': [ OrderedDict([ @@ -446,6 +450,7 @@ def test_send_task_returned_webhook__ok(api_client, mocker): 'contains_comments': False, 'require_completion_by_all': False, 'output': [], + 'fieldsets': [], 'delay': None, 'date_started_tsp': None, 'date_completed_tsp': None, @@ -499,6 +504,7 @@ def test_send_task_returned_webhook__ok(api_client, mocker): 'kickoff': { 'id': workflow.kickoff_instance.id, 'output': [], + 'fieldsets': [], }, 'tasks': [ OrderedDict([ @@ -605,6 +611,7 @@ def test_send_workflow_started_webhook__ok(api_client, mocker): 'kickoff': { 'id': workflow.kickoff_instance.id, 'output': [], + 'fieldsets': [], }, 'tasks': [ OrderedDict([ @@ -708,6 +715,7 @@ def test_send_workflow_completed_webhook__ok(api_client, mocker): 'kickoff': { 'id': workflow.kickoff_instance.id, 'output': [], + 'fieldsets': [], }, 'tasks': [ OrderedDict([ diff --git a/backend/src/processes/urls/templates.py b/backend/src/processes/urls/templates.py index e4ba1202f..1f4b4e667 100644 --- a/backend/src/processes/urls/templates.py +++ b/backend/src/processes/urls/templates.py @@ -13,6 +13,7 @@ ) from src.processes.views.template_preset import TemplatePresetViewSet + router = DefaultRouter(trailing_slash=False) router.register( prefix='system', diff --git a/backend/src/processes/views/fieldset.py b/backend/src/processes/views/fieldset.py new file mode 100644 index 000000000..da757e102 --- /dev/null +++ b/backend/src/processes/views/fieldset.py @@ -0,0 +1,132 @@ +from rest_framework.viewsets import GenericViewSet + +from src.accounts.permissions import ( + BillingPlanPermission, + ExpiredSubscriptionPermission, + UserIsAdminOrAccountOwner, + UsersOverlimitedPermission, +) +from src.generics.exceptions import BaseServiceException +from src.generics.filters import PneumaticFilterBackend +from src.generics.mixins.views import CustomViewSetMixin +from src.generics.permissions import UserIsAuthenticated +from src.processes.filters import FieldSetFilter +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, +) +from src.processes.serializers.templates.fieldset import ( + SharedFieldsetTemplateSerializer, +) +from src.processes.serializers.templates.template import ( + FieldsetTemplateFilterSerializer, +) +from src.processes.services.fieldsets.fieldset import ( + FieldSetTemplateService, +) +from src.utils.validation import raise_validation_error + + +class SharedFieldsetTemplateViewSet( + CustomViewSetMixin, + GenericViewSet, +): + serializer_class = SharedFieldsetTemplateSerializer + filter_backends = (PneumaticFilterBackend,) + + action_filterset_classes = { + 'list': FieldSetFilter, + } + + def get_serializer_context(self, **kwargs): + context = super().get_serializer_context(**kwargs) + context['user'] = self.request.user + context['account'] = self.request.user.account + context['is_superuser'] = self.request.is_superuser + context['auth_type'] = self.request.token_type + return context + + def get_permissions(self): + return ( + UserIsAuthenticated(), + ExpiredSubscriptionPermission(), + BillingPlanPermission(), + UsersOverlimitedPermission(), + UserIsAdminOrAccountOwner(), + ) + + def get_queryset(self): + user = self.request.user + return ( + FieldsetTemplate.objects + .select_related('template') + .shared() + .on_account(user.account_id) + ) + + def list(self, request, *args, **kwargs): + filter_slz = FieldsetTemplateFilterSerializer(data=request.GET) + filter_slz.is_valid(raise_exception=True) + queryset = self.filter_queryset(self.get_queryset()) + return self.paginated_response(queryset) + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + service = FieldSetTemplateService( + user=request.user, + is_superuser=request.is_superuser, + auth_type=request.token_type, + ) + try: + fieldset = service.create_shared_fieldset( + **serializer.validated_data, + ) + except BaseServiceException as ex: + raise_validation_error(message=ex.message) + else: + response_serializer = SharedFieldsetTemplateSerializer(fieldset) + return self.response_created(response_serializer.data) + + def retrieve(self, request, *args, **kwargs): + fieldset = self.get_object() + serializer = self.get_serializer(fieldset) + return self.response_ok(serializer.data) + + def partial_update(self, request, *args, **kwargs): + fieldset = self.get_object() + serializer = self.get_serializer( + fieldset, + data=request.data, + partial=True, + extra_fields={'template': fieldset.template}, + ) + serializer.is_valid(raise_exception=True) + service = FieldSetTemplateService( + user=request.user, + instance=fieldset, + is_superuser=request.is_superuser, + auth_type=request.token_type, + ) + try: + fieldset = service.partial_update( + **serializer.validated_data, + ) + except BaseServiceException as ex: + raise_validation_error(message=ex.message) + fieldset.refresh_from_db() + response_serializer = SharedFieldsetTemplateSerializer(fieldset) + return self.response_ok(response_serializer.data) + + def destroy(self, request, *args, **kwargs): + fieldset = self.get_object() + service = FieldSetTemplateService( + user=request.user, + instance=fieldset, + is_superuser=request.is_superuser, + auth_type=request.token_type, + ) + try: + service.delete() + except BaseServiceException as ex: + raise_validation_error(message=ex.message) + return self.response_ok() diff --git a/backend/src/processes/views/task.py b/backend/src/processes/views/task.py index 03faf7bb5..62abe6821 100644 --- a/backend/src/processes/views/task.py +++ b/backend/src/processes/views/task.py @@ -76,6 +76,7 @@ from src.processes.services.exceptions import ( CommentServiceException, WorkflowActionServiceException, + FieldsetServiceException, ) from src.processes.services.tasks.exceptions import ( GroupPerformerServiceException, @@ -269,14 +270,32 @@ def prefetch_queryset( if self.action == 'retrieve': queryset = queryset.prefetch_related( 'checklists__selections', - 'output__attachments', Prefetch( - 'output__selections', + 'output', + queryset=TaskField.objects.filter( + fieldset__isnull=True, + ).prefetch_related( + 'attachments', + Prefetch( + 'selections', + queryset=FieldSelection.objects.order_by('id'), + to_attr='selections_values', + ), + Prefetch( + 'dataset__items', + queryset=DatasetItem.objects.order_by('order'), + to_attr='dataset_values', + ), + ), + ), + 'fieldsets__fields__attachments', + Prefetch( + 'fieldsets__fields__selections', queryset=FieldSelection.objects.order_by('id'), to_attr='selections_values', ), Prefetch( - 'output__dataset__items', + 'fieldsets__fields__dataset__items', queryset=DatasetItem.objects.order_by('order'), to_attr='dataset_values', ), @@ -505,7 +524,10 @@ def complete(self, request, *args, **kwargs): fields_values=serializer.validated_data.get('output'), ) service.check_delay_workflow() - except WorkflowActionServiceException as ex: + except ( + WorkflowActionServiceException, + FieldsetServiceException, + ) as ex: raise_validation_error(message=ex.message) except TaskFieldException as ex: raise_validation_error( @@ -516,7 +538,9 @@ def complete(self, request, *args, **kwargs): instance=Task.objects.prefetch_related( Prefetch( lookup='output', - queryset=TaskField.objects.all().prefetch_related( + queryset=TaskField.objects.filter( + fieldset__isnull=True, + ).prefetch_related( Prefetch( lookup='selections', queryset=FieldSelection.objects.order_by('id'), @@ -530,6 +554,17 @@ def complete(self, request, *args, **kwargs): 'attachments', ), ), + 'fieldsets__fields__attachments', + Prefetch( + 'fieldsets__fields__selections', + queryset=FieldSelection.objects.order_by('id'), + to_attr='selections_values', + ), + Prefetch( + 'fieldsets__fields__dataset__items', + queryset=DatasetItem.objects.order_by('order'), + to_attr='dataset_values', + ), ).get(pk=task.pk), context={'user': request.user}, ) diff --git a/backend/src/processes/views/template.py b/backend/src/processes/views/template.py index 0ca92d79a..54428dc75 100644 --- a/backend/src/processes/views/template.py +++ b/backend/src/processes/views/template.py @@ -17,12 +17,13 @@ from src.analysis.services import AnalyticService from src.authentication.enums import AuthTokenType from src.executor import RawSqlExecutor -from src.generics.filters import PneumaticFilterBackend from src.generics.mixins.views import ( CustomViewSetMixin, ) from src.generics.permissions import UserIsAuthenticated -from src.processes.filters import TemplateFilter +from src.processes.filters import ( + FieldSetFilter, +) from src.processes.models.templates.fields import FieldTemplate from src.processes.models.templates.kickoff import Kickoff from src.processes.models.templates.preset import TemplatePreset @@ -30,6 +31,7 @@ from src.processes.models.templates.task import TaskTemplate from src.processes.models.templates.template import Template from src.processes.models.templates.owner import TemplateOwner +from src.processes.models.templates.fields import FieldTemplateSelection from src.processes.permissions import ( TemplateAccessPermission, TemplateAdminOwnerPermission, @@ -51,6 +53,9 @@ TemplateStepFilterSerializer, TemplateStepNameSerializer, ) +from src.processes.serializers.templates.template_fields import ( + TemplateOnlyFieldsSerializer, +) from src.processes.serializers.templates.template import ( TemplateAiSerializer, TemplateByNameSerializer, @@ -58,7 +63,6 @@ TemplateExportFilterSerializer, TemplateListFilterSerializer, TemplateListSerializer, - TemplateOnlyFieldsSerializer, TemplateSerializer, TemplateTitlesByEventsSerializer, TemplateTitlesByTasksSerializer, @@ -110,8 +114,6 @@ class TemplateViewSet( GenericViewSet, ): pagination_class = LimitOffsetPagination - filter_backends = (PneumaticFilterBackend,) - filterset_class = TemplateFilter serializer_class = TemplateSerializer action_serializer_classes = { 'list': TemplateListSerializer, @@ -128,6 +130,9 @@ class TemplateViewSet( 'presets': TemplatePresetSerializer, 'preset': TemplatePresetSerializer, } + action_filterset_classes = { + 'list_fieldsets': FieldSetFilter, + } def get_permissions(self): if self.action in ( @@ -242,6 +247,7 @@ def prefetch_queryset( 'kickoff', 'kickoff__fields', 'kickoff__fields__selections', + 'kickoff__fieldsets', Prefetch('owners', queryset=owners_qs), Prefetch( lookup='tasks', @@ -251,6 +257,7 @@ def prefetch_queryset( .prefetch_related( 'fields', 'fields__selections', + 'fieldsets', 'checklists', 'checklists__selections', 'conditions', @@ -271,10 +278,21 @@ def prefetch_queryset( Prefetch( lookup='fields', queryset=( - FieldTemplate.objects.all() + FieldTemplate.objects.prefetch_related( + Prefetch( + 'selections', + queryset=( + FieldTemplateSelection.objects + .order_by('id') + ), + to_attr='selections_values', + ), + ) + .all() .order_by('-order') ), ), + 'fieldsets', ) ), ), @@ -291,6 +309,7 @@ def prefetch_queryset( .order_by('-order') ), ), + 'fieldsets', ) ), ), @@ -421,6 +440,7 @@ def list(self, request, *args, **kwargs): SQL pagination (by LIMIT, OFFSET) is not possible because it is impossible to calculate response 'count' value """ + filter_slz = TemplateListFilterSerializer(data=request.GET) filter_slz.is_valid(raise_exception=True) diff --git a/backend/src/reports/serializers.py b/backend/src/reports/serializers.py index 30bf3eff2..f62021075 100644 --- a/backend/src/reports/serializers.py +++ b/backend/src/reports/serializers.py @@ -14,6 +14,7 @@ from src.processes.serializers.workflows.field import ( TaskFieldEventSerializer, ) +from src.processes.serializers.workflows.fieldset import FieldSetSerializer UserModel = get_user_model() @@ -45,9 +46,11 @@ class Meta: model = KickoffValue fields = ( 'output', + 'fieldsets', ) output = TaskFieldEventSerializer(many=True) + fieldsets = FieldSetSerializer(many=True) class ActivityWorkflowSerializer(serializers.ModelSerializer): diff --git a/backend/src/reports/tests/test_views/test_highlights.py b/backend/src/reports/tests/test_views/test_highlights.py index de83c5c33..f53ca8f90 100644 --- a/backend/src/reports/tests/test_views/test_highlights.py +++ b/backend/src/reports/tests/test_views/test_highlights.py @@ -21,6 +21,7 @@ from src.processes.models.workflows.fields import TaskField from src.processes.models.workflows.task import Delay from src.processes.models.workflows.workflow import Workflow +from src.processes.models.workflows.fieldset import FieldSet from src.processes.services.events import ( WorkflowEventService, ) @@ -34,7 +35,7 @@ create_test_template, create_test_user, create_test_workflow, create_test_owner, create_test_dataset, - create_test_event, + create_test_event, create_test_admin, create_test_fieldset, ) from src.utils.validation import ErrorCode @@ -1514,7 +1515,6 @@ def test_complete_task_event__task_field_with_dataset__ok(api_client): assert field_data['id'] == field.id assert field_data['type'] == FieldType.DROPDOWN assert field_data['value'] == dataset_item.value - assert 'selections' not in field_data def test_complete_task_event__kickoff_field_with_dataset__ok(api_client): @@ -1553,4 +1553,283 @@ def test_complete_task_event__kickoff_field_with_dataset__ok(api_client): assert field_data['id'] == field.id assert field_data['type'] == FieldType.DROPDOWN assert field_data['value'] == dataset_item.value - assert 'selections' not in field_data + + +def test_highlights__task_complete_fieldsets_present__ok(api_client): + + """ + GET reports highlights: TASK_COMPLETE item includes non-null task.fieldsets + when the task has at least one FieldSet. + """ + + # arrange + + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user, tasks_count=1) + task_1 = workflow.tasks.get(number=1) + fieldset = FieldSet.objects.create( + account=account, + workflow=workflow, + task=task_1, + name='Fieldset 1', + order=1, + ) + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + task=task_1, + fieldset=fieldset, + name='Field 1', + type=FieldType.TEXT, + order=1, + ) + field_2 = TaskField.objects.create( + account=account, + workflow=workflow, + task=task_1, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + order=2, + ) + WorkflowEventService.task_complete_event( + task=task_1, + user=user, + after_create_actions=False, + ) + api_client.token_authenticate(user=user) + + # act + + response = api_client.get( + path='/reports/highlights', + ) + + # assert + assert response.status_code == 200 + event_data = response.data[0] + assert event_data['type'] == WorkflowEventType.TASK_COMPLETE + fieldsets_data = event_data['task']['fieldsets'] + assert len(fieldsets_data) == 1 + + fieldset_data = fieldsets_data[0] + assert fieldset_data['id'] == fieldset.id + assert fieldset_data['api_name'] == fieldset.api_name + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['order'] == fieldset.order + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + fields_data = fieldset_data['fields'] + assert len(fields_data) == 2 + field_2_data = fields_data[0] + assert field_2_data['id'] == field_2.id + assert field_2_data['order'] == field_2.order + assert field_2_data['type'] == field_2.type + assert field_2_data['is_required'] == field_2.is_required + assert field_2_data['is_hidden'] == field_2.is_hidden + assert field_2_data['description'] == field_2.description + assert field_2_data['api_name'] == field_2.api_name + assert field_2_data['name'] == field_2.name + assert field_2_data['value'] == field_2.value + assert field_2_data['markdown_value'] == field_2.markdown_value + assert field_2_data['clear_value'] == field_2.clear_value + assert field_2_data['user_id'] == field_2.user_id + assert field_2_data['group_id'] == field_2.group_id + assert field_2_data['attachments'] == [] + field_1_data = fields_data[1] + assert field_1_data['id'] == field_1.id + + +def test_highlights__task_complete_fieldsets_absent__ok(api_client): + + """ + GET reports highlights: TASK_COMPLETE item has task.fieldsets null when the + task has no FieldSet rows. + """ + + # arrange + + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user, tasks_count=1) + task_1 = workflow.tasks.get(number=1) + WorkflowEventService.task_complete_event( + task=task_1, + user=user, + after_create_actions=False, + ) + api_client.token_authenticate(user=user) + + # act + + response = api_client.get( + path='/reports/highlights', + ) + + # assert + + assert response.status_code == 200 + item_1 = response.data[0] + assert item_1['type'] == WorkflowEventType.TASK_COMPLETE + task_payload = item_1['task'] + assert task_payload['fieldsets'] is None + + +def test_highlights__non_complete_fieldsets_null__ok(api_client): + + """ + GET reports highlights: non-TASK_COMPLETE highlight exposes task.fieldsets + as null. + """ + + # arrange + + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user, tasks_count=1) + task_1 = workflow.tasks.get(number=1) + create_test_event( + workflow=workflow, + user=user, + task=task_1, + type_event=WorkflowEventType.COMMENT, + ) + api_client.token_authenticate(user=user) + + # act + + response = api_client.get( + path='/reports/highlights', + ) + + # assert + + assert response.status_code == 200 + item_1 = response.data[0] + assert item_1['type'] == WorkflowEventType.COMMENT + task_payload = item_1['task'] + assert task_payload['fieldsets'] is None + + +def test_highlights__start_workflow_kickoff_fieldset_present__ok(api_client): + + # arrange + account = create_test_account() + create_test_owner(account=account) + user = create_test_admin(account=account) + workflow = create_test_workflow(user, tasks_count=2, active_task_number=2) + kickoff = workflow.kickoff_instance + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + order=1, + ) + field = fieldset.fields.first() + WorkflowEventService.workflow_run_event(workflow) + api_client.token_authenticate(user) + + # act + response = api_client.get('/reports/highlights') + + # assert + assert response.status_code == 200 + event_data = response.data[0] + assert event_data['type'] == WorkflowEventType.RUN + fieldsets_data = event_data['workflow']['kickoff']['fieldsets'] + assert len(fieldsets_data) == 1 + fieldset_data = fieldsets_data[0] + assert fieldset_data['id'] == fieldset.id + assert fieldset_data['api_name'] == fieldset.api_name + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['order'] == fieldset.order + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + fields_data = fieldset_data['fields'] + assert len(fields_data) == 1 + field_data = fields_data[0] + assert field_data['id'] == field.id + assert field_data['order'] == field.order + assert field_data['type'] == field.type + assert field_data['is_required'] == field.is_required + assert field_data['is_hidden'] == field.is_hidden + assert field_data['description'] == field.description + assert field_data['api_name'] == field.api_name + assert field_data['name'] == field.name + assert field_data['value'] == field.value + assert field_data['markdown_value'] == field.markdown_value + assert field_data['clear_value'] == field.clear_value + assert field_data['user_id'] == field.user_id + assert field_data['group_id'] == field.group_id + assert field_data['attachments'] == [] + + +def test_highlights__start_workflow_kickoff_field_present__ok(api_client): + + # arrange + account = create_test_account() + create_test_owner(account=account) + user = create_test_admin(account=account) + workflow = create_test_workflow(user, tasks_count=2, active_task_number=2) + kickoff = workflow.kickoff_instance + + field = TaskField.objects.create( + type=FieldType.DROPDOWN, + name='dropdown', + kickoff=kickoff, + value='some value', + workflow=workflow, + account=account, + ) + WorkflowEventService.workflow_run_event(workflow) + api_client.token_authenticate(user) + + # act + response = api_client.get('/reports/highlights') + + # assert + assert response.status_code == 200 + event_data = response.data[0] + assert event_data['type'] == WorkflowEventType.RUN + fields_data = event_data['workflow']['kickoff']['output'] + assert len(fields_data) == 1 + field_data = fields_data[0] + + assert field_data['id'] == field.id + assert field_data['order'] == field.order + assert field_data['type'] == field.type + assert field_data['is_required'] == field.is_required + assert field_data['is_hidden'] == field.is_hidden + assert field_data['description'] == field.description + assert field_data['api_name'] == field.api_name + assert field_data['name'] == field.name + assert field_data['value'] == field.value + assert field_data['markdown_value'] == field.markdown_value + assert field_data['clear_value'] == field.clear_value + assert field_data['user_id'] == field.user_id + assert field_data['group_id'] == field.group_id + assert field_data['attachments'] == [] + + +def test_highlights__start_workflow_fieldset_absent__ok(api_client): + + # arrange + account = create_test_account() + create_test_owner(account=account) + user = create_test_admin(account=account) + workflow = create_test_workflow(user, tasks_count=2, active_task_number=2) + + WorkflowEventService.workflow_run_event(workflow) + api_client.token_authenticate(user) + + # act + response = api_client.get('/reports/highlights') + + # assert + assert response.status_code == 200 + event_data = response.data[0] + assert event_data['type'] == WorkflowEventType.RUN + assert event_data['workflow']['kickoff']['fieldsets'] == [] + assert event_data['workflow']['kickoff']['output'] == [] diff --git a/backend/src/services/locale/de/LC_MESSAGES/django.po b/backend/src/services/locale/de/LC_MESSAGES/django.po index 4c78de97e..14fdd4edb 100644 --- a/backend/src/services/locale/de/LC_MESSAGES/django.po +++ b/backend/src/services/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/services/locale/django.pot b/backend/src/services/locale/django.pot index 4c78de97e..14fdd4edb 100644 --- a/backend/src/services/locale/django.pot +++ b/backend/src/services/locale/django.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/services/locale/es/LC_MESSAGES/django.po b/backend/src/services/locale/es/LC_MESSAGES/django.po index 4c78de97e..14fdd4edb 100644 --- a/backend/src/services/locale/es/LC_MESSAGES/django.po +++ b/backend/src/services/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/services/locale/fr/LC_MESSAGES/django.po b/backend/src/services/locale/fr/LC_MESSAGES/django.po index 4c78de97e..14fdd4edb 100644 --- a/backend/src/services/locale/fr/LC_MESSAGES/django.po +++ b/backend/src/services/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/services/locale/ru/LC_MESSAGES/django.po b/backend/src/services/locale/ru/LC_MESSAGES/django.po index afa6bfaef..77647008d 100644 --- a/backend/src/services/locale/ru/LC_MESSAGES/django.po +++ b/backend/src/services/locale/ru/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/settings.py b/backend/src/settings.py index 10b911fd3..01b970f73 100644 --- a/backend/src/settings.py +++ b/backend/src/settings.py @@ -403,10 +403,10 @@ class Common(Configuration): DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': env.get('POSTGRES_DB', 'pneumatic'), - 'USER': env.get('POSTGRES_USER', 'pneumatic'), - 'PASSWORD': env.get('POSTGRES_PASSWORD', 'pneumatic'), - 'HOST': env.get('POSTGRES_HOST', 'localhost'), + 'NAME': env.get('POSTGRES_DB'), + 'USER': env.get('POSTGRES_USER'), + 'PASSWORD': env.get('POSTGRES_PASSWORD'), + 'HOST': env.get('POSTGRES_HOST'), 'PORT': env.get('POSTGRES_PORT', '5432'), }, } @@ -553,18 +553,18 @@ class Staging(Development): DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': env.get('POSTGRES_DB', 'pneumatic'), - 'USER': env.get('POSTGRES_USER', 'pneumatic'), - 'PASSWORD': env.get('POSTGRES_PASSWORD', 'pneumatic'), - 'HOST': env.get('POSTGRES_HOST', 'localhost'), + 'NAME': env.get('POSTGRES_DB'), + 'USER': env.get('POSTGRES_USER'), + 'PASSWORD': env.get('POSTGRES_PASSWORD'), + 'HOST': env.get('POSTGRES_HOST'), 'PORT': env.get('POSTGRES_PORT', '5432'), }, 'replica': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': env.get('POSTGRES_REPLICA_DB', 'pneumatic'), - 'USER': env.get('POSTGRES_REPLICA_USER', 'pneumatic'), - 'PASSWORD': env.get('POSTGRES_REPLICA_PASSWORD', 'pneumatic'), - 'HOST': env.get('POSTGRES_REPLICA_HOST', 'localhost'), + 'NAME': env.get('POSTGRES_REPLICA_DB'), + 'USER': env.get('POSTGRES_REPLICA_USER'), + 'PASSWORD': env.get('POSTGRES_REPLICA_PASSWORD'), + 'HOST': env.get('POSTGRES_REPLICA_HOST'), 'PORT': env.get('POSTGRES_REPLICA_PORT', '5432'), }, } diff --git a/backend/src/urls.py b/backend/src/urls.py index 56d39be38..d6d5ab9bc 100644 --- a/backend/src/urls.py +++ b/backend/src/urls.py @@ -22,6 +22,9 @@ TasksListView, TaskViewSet, ) +from src.processes.views.fieldset import ( + SharedFieldsetTemplateViewSet, +) from src.datasets.views import DatasetViewSet, DatasetItemViewSet from src.processes.views.template import ( TemplateViewSet, @@ -54,7 +57,11 @@ router.register('faq', FaqViewSet, basename='faq') router.register('datasets', DatasetViewSet, basename='datasets') router.register('datasets/items', DatasetItemViewSet, basename='dataset-items') - +router.register( + prefix='fieldsets', + viewset=SharedFieldsetTemplateViewSet, + basename='fieldsets', +) urlpatterns = [ path('', views.index, name='index'), diff --git a/backend/src/webhooks/locale/de/LC_MESSAGES/django.po b/backend/src/webhooks/locale/de/LC_MESSAGES/django.po index 845dad6b8..9939fa64a 100644 --- a/backend/src/webhooks/locale/de/LC_MESSAGES/django.po +++ b/backend/src/webhooks/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/webhooks/locale/django.pot b/backend/src/webhooks/locale/django.pot index 377162eb8..d6ea74e5e 100644 --- a/backend/src/webhooks/locale/django.pot +++ b/backend/src/webhooks/locale/django.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/webhooks/locale/es/LC_MESSAGES/django.po b/backend/src/webhooks/locale/es/LC_MESSAGES/django.po index 5671f5944..6daacbb82 100644 --- a/backend/src/webhooks/locale/es/LC_MESSAGES/django.po +++ b/backend/src/webhooks/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/webhooks/locale/fr/LC_MESSAGES/django.po b/backend/src/webhooks/locale/fr/LC_MESSAGES/django.po index ba4ab911e..71e7f0a78 100644 --- a/backend/src/webhooks/locale/fr/LC_MESSAGES/django.po +++ b/backend/src/webhooks/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/webhooks/locale/ru/LC_MESSAGES/django.po b/backend/src/webhooks/locale/ru/LC_MESSAGES/django.po index 80904b730..c63a2d6ba 100644 --- a/backend/src/webhooks/locale/ru/LC_MESSAGES/django.po +++ b/backend/src/webhooks/locale/ru/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n"