From 6f7605c6055a1a1e961716e214c1848b2b210f2b Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Fri, 24 Apr 2026 03:46:07 +0500 Subject: [PATCH 01/46] 45773 feat(fieldsets) backend init commit --- backend/README.md | 2 +- .../accounts/locale/de/LC_MESSAGES/django.po | 2 +- backend/src/accounts/locale/django.pot | 2 +- .../accounts/locale/es/LC_MESSAGES/django.po | 2 +- .../accounts/locale/fr/LC_MESSAGES/django.po | 2 +- .../accounts/locale/ru/LC_MESSAGES/django.po | 2 +- .../migrations/0142_auto_20260409_0907.py | 18 + .../analysis/locale/de/LC_MESSAGES/django.po | 2 +- backend/src/analysis/locale/django.pot | 2 +- .../analysis/locale/es/LC_MESSAGES/django.po | 2 +- .../analysis/locale/fr/LC_MESSAGES/django.po | 2 +- .../analysis/locale/ru/LC_MESSAGES/django.po | 2 +- .../locale/de/LC_MESSAGES/django.po | 2 +- backend/src/authentication/locale/django.pot | 2 +- .../locale/es/LC_MESSAGES/django.po | 2 +- .../locale/fr/LC_MESSAGES/django.po | 2 +- .../locale/ru/LC_MESSAGES/django.po | 2 +- .../datasets/locale/de/LC_MESSAGES/django.po | 2 +- backend/src/datasets/locale/django.pot | 2 +- .../datasets/locale/es/LC_MESSAGES/django.po | 2 +- .../datasets/locale/fr/LC_MESSAGES/django.po | 2 +- .../datasets/locale/ru/LC_MESSAGES/django.po | 2 +- backend/src/datasets/services/dataset.py | 8 +- backend/src/datasets/services/dataset_item.py | 6 - backend/src/generics/base/service.py | 4 +- backend/src/generics/fields.py | 67 +- .../generics/locale/de/LC_MESSAGES/django.po | 2 +- backend/src/generics/locale/django.pot | 2 +- .../generics/locale/es/LC_MESSAGES/django.po | 2 +- .../generics/locale/fr/LC_MESSAGES/django.po | 2 +- .../generics/locale/ru/LC_MESSAGES/django.po | 2 +- backend/src/generics/messages.py | 2 +- backend/src/logs/service.py | 3 - .../locale/de/LC_MESSAGES/django.po | 2 +- backend/src/notifications/locale/django.pot | 2 +- .../locale/es/LC_MESSAGES/django.po | 2 +- .../locale/fr/LC_MESSAGES/django.po | 2 +- .../locale/ru/LC_MESSAGES/django.po | 2 +- .../payment/locale/de/LC_MESSAGES/django.po | 2 +- backend/src/payment/locale/django.pot | 2 +- .../payment/locale/es/LC_MESSAGES/django.po | 2 +- .../payment/locale/fr/LC_MESSAGES/django.po | 2 +- .../payment/locale/ru/LC_MESSAGES/django.po | 2 +- backend/src/processes/enums.py | 40 +- .../processes/locale/de/LC_MESSAGES/django.po | 17 +- backend/src/processes/locale/django.pot | 15 +- .../processes/locale/es/LC_MESSAGES/django.po | 17 +- .../processes/locale/fr/LC_MESSAGES/django.po | 17 +- .../processes/locale/ru/LC_MESSAGES/django.po | 17 +- backend/src/processes/messages/fieldset.py | 22 + .../migrations/0250_add_fieldsets.py | 133 ++ backend/src/processes/models/mixins.py | 47 +- .../src/processes/models/templates/fields.py | 16 + .../processes/models/templates/fieldset.py | 88 ++ .../processes/models/templates/template.py | 55 +- .../src/processes/models/workflows/fields.py | 15 + .../processes/models/workflows/fieldset.py | 63 + .../processes/models/workflows/workflow.py | 55 +- backend/src/processes/querysets.py | 24 + .../serializers/templates/fieldset.py | 86 ++ .../serializers/templates/kickoff.py | 44 +- .../serializers/templates/public/kickoff.py | 7 + .../processes/serializers/templates/task.py | 16 + .../serializers/templates/template.py | 60 +- .../processes/serializers/workflows/events.py | 14 + .../serializers/workflows/fieldset.py | 22 + .../serializers/workflows/kickoff_value.py | 87 +- .../processes/serializers/workflows/task.py | 5 + .../serializers/workflows/workflow.py | 31 +- backend/src/processes/services/events.py | 3 - backend/src/processes/services/exceptions.py | 52 + backend/src/processes/services/tasks/field.py | 20 + backend/src/processes/services/tasks/task.py | 23 +- .../processes/services/tasks/task_version.py | 151 +- .../services/templates/field_template.py | 103 ++ .../templates/field_template_selection.py | 25 + .../services/templates/fieldsets/__init__.py | 0 .../services/templates/fieldsets/fieldset.py | 219 +++ .../templates/fieldsets/fieldset_rule.py | 119 ++ .../processes/services/templates/preset.py | 3 - .../processes/services/templates/template.py | 1 + .../processes/services/versioning/schemas.py | 58 + .../src/processes/services/workflow_action.py | 32 +- .../services/workflows/fieldsets/__init__.py | 0 .../services/workflows/fieldsets/fieldset.py | 73 + .../workflows/fieldsets/fieldset_rule.py | 55 + .../services/workflows/kickoff_version.py | 145 +- backend/src/processes/tests/fixtures.py | 119 +- .../tests/test_models/test_workflow.py | 562 ++++++-- .../test_tasks/test_task_version_service.py | 926 ++++++++++++- .../test_tasks/test_taskfield.py | 396 +++++- .../test_ai/test_open_ai_service.py | 1 + .../test_fieldset_template_rule_service.py | 905 ++++++++++++ .../test_fieldset_template_service.py | 1232 +++++++++++++++++ .../test_fieldset_rule_service.py | 468 +++++++ .../test_workflows/test_fieldset_service.py | 511 +++++++ .../test_kickoff_version_service.py | 1103 +++++++++++++++ .../test_workflow_version_service.py | 2 +- .../test_views/test_fieldsets/__init__.py | 0 .../test_views/test_fieldsets/test_create.py | 1132 +++++++++++++++ .../test_views/test_fieldsets/test_destroy.py | 341 +++++ .../test_views/test_fieldsets/test_list.py | 469 +++++++ .../test_fieldsets/test_partial_update.py | 1027 ++++++++++++++ .../test_fieldsets/test_retrieve.py | 340 +++++ .../test_views/test_tasks/test_events.py | 157 +++ .../test_tasks/test_webhook_example.py | 2 + .../test_create/test_kickoff.py | 4 + .../test_views/test_templates/test_fields.py | 52 +- .../test_update/test_kickoff.py | 3 + .../test_views/test_workflow/test_events.py | 166 +++ .../test_workflow/test_webhook_example.py | 1 + .../tests/test_webhooks/test_webhooks.py | 8 + backend/src/processes/urls/templates.py | 9 + backend/src/processes/views/fieldset.py | 97 ++ backend/src/processes/views/task.py | 45 +- backend/src/processes/views/template.py | 48 +- .../tests/test_views/test_highlights.py | 165 +++ .../services/locale/de/LC_MESSAGES/django.po | 2 +- backend/src/services/locale/django.pot | 2 +- .../services/locale/es/LC_MESSAGES/django.po | 2 +- .../services/locale/fr/LC_MESSAGES/django.po | 2 +- .../services/locale/ru/LC_MESSAGES/django.po | 2 +- backend/src/settings.py | 24 +- .../webhooks/locale/de/LC_MESSAGES/django.po | 2 +- backend/src/webhooks/locale/django.pot | 2 +- .../webhooks/locale/es/LC_MESSAGES/django.po | 2 +- .../webhooks/locale/fr/LC_MESSAGES/django.po | 2 +- .../webhooks/locale/ru/LC_MESSAGES/django.po | 2 +- 128 files changed, 12270 insertions(+), 340 deletions(-) create mode 100644 backend/src/accounts/migrations/0142_auto_20260409_0907.py create mode 100644 backend/src/processes/messages/fieldset.py create mode 100644 backend/src/processes/migrations/0250_add_fieldsets.py create mode 100644 backend/src/processes/models/templates/fieldset.py create mode 100644 backend/src/processes/models/workflows/fieldset.py create mode 100644 backend/src/processes/serializers/templates/fieldset.py create mode 100644 backend/src/processes/serializers/workflows/fieldset.py create mode 100644 backend/src/processes/services/templates/field_template.py create mode 100644 backend/src/processes/services/templates/field_template_selection.py create mode 100644 backend/src/processes/services/templates/fieldsets/__init__.py create mode 100644 backend/src/processes/services/templates/fieldsets/fieldset.py create mode 100644 backend/src/processes/services/templates/fieldsets/fieldset_rule.py create mode 100644 backend/src/processes/services/workflows/fieldsets/__init__.py create mode 100644 backend/src/processes/services/workflows/fieldsets/fieldset.py create mode 100644 backend/src/processes/services/workflows/fieldsets/fieldset_rule.py create mode 100644 backend/src/processes/tests/test_services/test_templates/test_fieldset_template_rule_service.py create mode 100644 backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py create mode 100644 backend/src/processes/tests/test_services/test_workflows/test_fieldset_rule_service.py create mode 100644 backend/src/processes/tests/test_services/test_workflows/test_fieldset_service.py create mode 100644 backend/src/processes/tests/test_services/test_workflows/test_kickoff_version_service.py create mode 100644 backend/src/processes/tests/test_views/test_fieldsets/__init__.py create mode 100644 backend/src/processes/tests/test_views/test_fieldsets/test_create.py create mode 100644 backend/src/processes/tests/test_views/test_fieldsets/test_destroy.py create mode 100644 backend/src/processes/tests/test_views/test_fieldsets/test_list.py create mode 100644 backend/src/processes/tests/test_views/test_fieldsets/test_partial_update.py create mode 100644 backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py create mode 100644 backend/src/processes/views/fieldset.py diff --git a/backend/README.md b/backend/README.md index 92f2c72b8..6337a5596 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://form.localhost 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 eaae77a0a..5d423c354 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 6d473bf87..dba256501 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 78d257cf2..7498b71a5 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 c08d6a74f..1dc503077 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 5e40962c7..7a9436a21 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/0142_auto_20260409_0907.py b/backend/src/accounts/migrations/0142_auto_20260409_0907.py new file mode 100644 index 000000000..8e44b4c11 --- /dev/null +++ b/backend/src/accounts/migrations/0142_auto_20260409_0907.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2 on 2026-04-09 09:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0141_notification_text_default'), + ] + + 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..8a36181c2 100644 --- a/backend/src/generics/base/service.py +++ b/backend/src/generics/base/service.py @@ -30,7 +30,6 @@ def __init__( self.instance = instance self.update_fields = set() - @abstractmethod def _create_related( self, **kwargs, @@ -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 10ff539fc..2bb16e417 100644 --- a/backend/src/processes/enums.py +++ b/backend/src/processes/enums.py @@ -714,4 +714,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/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/messages/fieldset.py b/backend/src/processes/messages/fieldset.py new file mode 100644 index 000000000..5c8e7d8f7 --- /dev/null +++ b/backend/src/processes/messages/fieldset.py @@ -0,0 +1,22 @@ +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', +) diff --git a/backend/src/processes/migrations/0250_add_fieldsets.py b/backend/src/processes/migrations/0250_add_fieldsets.py new file mode 100644 index 000000000..b1e5306bc --- /dev/null +++ b/backend/src/processes/migrations/0250_add_fieldsets.py @@ -0,0 +1,133 @@ +# Generated by Django 2.2 on 2026-04-13 23:55 + +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import src.generics.mixins.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0142_auto_20260409_0907'), + ('processes', '0249_auto_20260403_1221'), + ] + + 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='')), + ('order', models.IntegerField(default=0)), + ('layout', models.CharField(choices=[('horizontal', 'Horizontal'), ('vertical', 'Vertical')], default='vertical', max_length=200)), + ('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')), + ], + 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='')), + ('order', models.IntegerField(default=0)), + ('layout', models.CharField(choices=[('horizontal', 'Horizontal'), ('vertical', 'Vertical')], default='vertical', max_length=200)), + ('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.SET_NULL, related_name='fieldsets', to='processes.Kickoff')), + ('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='fieldsets', to='processes.TaskTemplate')), + ('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.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='rules', + field=models.ManyToManyField(blank=True, related_name='fields', to='processes.FieldsetTemplateRule'), + ), + migrations.AlterField( + model_name='task', + name='parents', + field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=200), default=list, help_text='Api names of task parents', size=None), + ), + migrations.AddField( + model_name='fieldset', + name='task', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Task'), + ), + migrations.AddField( + model_name='fieldset', + name='workflow', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Workflow'), + ), + 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='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'), + ), + migrations.AddConstraint( + model_name='fieldsettemplate', + constraint=models.UniqueConstraint(condition=models.Q(is_deleted=False), fields=('account', 'api_name'), name='fieldsettemplate_account_api_name_unique'), + ), + ] diff --git a/backend/src/processes/models/mixins.py b/backend/src/processes/models/mixins.py index f19aaef3a..cf262ffd1 100644 --- a/backend/src/processes/models/mixins.py +++ b/backend/src/processes/models/mixins.py @@ -7,13 +7,15 @@ 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, + PredicateType, FieldSetRuleType, ) from src.datasets.models import Dataset @@ -175,7 +177,7 @@ class Meta: ) -class FieldMixin(AccountBaseMixin): +class FieldMixin(models.Model): class Meta: abstract = True @@ -315,3 +317,42 @@ 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) + description = models.TextField(blank=True, default='') + order = models.IntegerField(default=0) + 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..9f98abae7 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) diff --git a/backend/src/processes/models/templates/fieldset.py b/backend/src/processes/models/templates/fieldset.py new file mode 100644 index 000000000..7463039ec --- /dev/null +++ b/backend/src/processes/models/templates/fieldset.py @@ -0,0 +1,88 @@ +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 ( + 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=['account', 'api_name'], + condition=Q(is_deleted=False), + name='fieldsettemplate_account_api_name_unique', + ), + ] + + api_name_prefix = 'fieldset' + + template = models.ForeignKey( + Template, + on_delete=models.CASCADE, + related_name='fieldsets', + ) + task = models.ForeignKey( + TaskTemplate, + on_delete=models.SET_NULL, + related_name='fieldsets', + null=True, + blank=True, + ) + kickoff = models.ForeignKey( + Kickoff, + on_delete=models.SET_NULL, + related_name='fieldsets', + null=True, + blank=True, + ) + + objects = BaseSoftDeleteManager.from_queryset( + FieldsetTemplateQuerySet, + )() + + def __str__(self): + return self.name + + +class FieldsetTemplateRule( + BaseApiNameModel, + BaseFieldSetRuleMixin, + AccountBaseMixin, +): + + class Meta: + ordering = ['-id'] + + 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 diff --git a/backend/src/processes/models/templates/template.py b/backend/src/processes/models/templates/template.py index 2acc7ad53..6a7f6993d 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, @@ -166,24 +168,33 @@ def get_tasks_output_fields( """ Return the output fields from tasks """ - from src.processes.models.templates \ - .fields import FieldTemplate + 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 = { + f'fieldset__{key}': value + for key, value in tasks_filter.items() + } + tasks_q = Q(**tasks_filter) + fieldset_q = Q(**fieldset_filter) - if tasks_filter_kwargs is None: - tasks_filter_kwargs = { - 'task__template_id': self.id, - 'task__account_id': self.account_id, + if tasks_exclude_kwargs: + fieldset_exclude_kwargs = { + f'fieldset__{key}': value + for key, value in tasks_exclude_kwargs.items() } - 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) + 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 0163d0a41..ed3e0b7f6 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', ) ) @@ -1259,3 +1263,23 @@ def by_user(self, user, template_id): class SearchContentQuerySet(AccountBaseQuerySet): pass + + +class FieldsetTemplateQuerySet(AccountBaseQuerySet): + + pass + + +class FieldsetTemplateRuleQuerySet(AccountBaseQuerySet): + + pass + + +class FieldSetQuerySet(AccountBaseQuerySet): + + pass + + +class FieldSetRuleQuerySet(AccountBaseQuerySet): + + pass diff --git a/backend/src/processes/serializers/templates/fieldset.py b/backend/src/processes/serializers/templates/fieldset.py new file mode 100644 index 000000000..c43ea81ee --- /dev/null +++ b/backend/src/processes/serializers/templates/fieldset.py @@ -0,0 +1,86 @@ +from rest_framework.fields import CharField +from rest_framework.serializers import ( + IntegerField, + ModelSerializer, +) + +from src.generics.fields import ( + RelatedApiNameField, + RelatedApiNameListField, +) +from src.generics.mixins.serializers import CustomValidationErrorMixin +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateRule, +) +from src.processes.models.templates.task import TaskTemplate +from src.processes.serializers.templates.field import FieldTemplateSerializer + + +class FieldsetTemplateRuleSerializer( + CustomValidationErrorMixin, + ModelSerializer, +): + + class Meta: + model = FieldsetTemplateRule + fields = ( + 'id', + 'type', + 'value', + 'api_name', + 'fields', + ) + + id = IntegerField(required=False) + api_name = CharField(required=False, max_length=200) + fields = RelatedApiNameListField( + required=False, + allow_empty=True, + default=list, + ) + + +class FieldsetTemplateSerializer( + CustomValidationErrorMixin, + ModelSerializer, +): + + class Meta: + model = FieldsetTemplate + fields = ( + 'id', + 'name', + 'description', + 'order', + 'task', + 'label_position', + 'layout', + 'rules', + 'fields', + 'api_name', + ) + + id = IntegerField(required=False) + task = RelatedApiNameField( + queryset=TaskTemplate.objects.all(), + required=False, + allow_null=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, + ) + + def validate(self, attrs): + if 'task' in attrs and attrs['task'] is not None: + task = attrs.pop('task') + attrs['task_id'] = task.id + return attrs diff --git a/backend/src/processes/serializers/templates/kickoff.py b/backend/src/processes/serializers/templates/kickoff.py index 668e10b0f..03578dfdb 100644 --- a/backend/src/processes/serializers/templates/kickoff.py +++ b/backend/src/processes/serializers/templates/kickoff.py @@ -4,16 +4,21 @@ ModelSerializer, ) +from src.generics.fields import AccountPrimaryKeyRelatedField from src.generics.mixins.serializers import ( AdditionalValidationMixin, CustomValidationErrorMixin, ) +from src.processes.models.templates.fieldset import FieldsetTemplate from src.processes.models.templates.kickoff import Kickoff 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, @@ -32,22 +37,34 @@ 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 = AccountPrimaryKeyRelatedField( + many=True, + queryset=FieldsetTemplate.objects.all(), + required=False, + allow_empty=True, + default=list, + ) - 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) + fieldsets = validated_data.pop('fieldsets', None) or [] instance = self.create_or_update_instance( validated_data={ 'template': self.context['template'], @@ -55,6 +72,7 @@ def create(self, validated_data: Dict[str, Any]): **validated_data, }, ) + instance.fieldsets.set(fieldsets) self.create_or_update_related( data=validated_data.get('fields'), ancestors_data={ @@ -75,6 +93,7 @@ def update( validated_data: Dict[str, Any], ): self.additional_validate(validated_data) + fieldsets = validated_data.pop('fieldsets', None) or [] instance = self.create_or_update_instance( instance=instance, validated_data={ @@ -83,6 +102,7 @@ def update( **validated_data, }, ) + instance.fieldsets.set(fieldsets) self.create_or_update_related( data=validated_data.get('fields'), ancestors_data={ @@ -103,6 +123,7 @@ class Meta: model = Kickoff fields = ( 'fields', + 'fieldsets', ) fields = FieldTemplateShortViewSerializer( @@ -111,6 +132,15 @@ class Meta: 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() + if instance is None: + return {'fields': [], 'fieldsets': []} + return super().to_representation(instance) + class KickoffListSerializer(ModelSerializer): @@ -118,6 +148,8 @@ class Meta: model = Kickoff fields = ( 'fields', + 'fieldsets', ) fields = FieldTemplateListSerializer(many=True) + fieldsets = FieldsetTemplateSerializer(many=True) diff --git a/backend/src/processes/serializers/templates/public/kickoff.py b/backend/src/processes/serializers/templates/public/kickoff.py index d7fd10d6f..a7dac5b79 100644 --- a/backend/src/processes/serializers/templates/public/kickoff.py +++ b/backend/src/processes/serializers/templates/public/kickoff.py @@ -9,6 +9,9 @@ from src.processes.serializers.templates.field import ( PublicFieldTemplateSerializer, ) +from src.processes.serializers.templates.fieldset import ( + FieldsetTemplateSerializer, +) class PublicKickoffSerializer(ModelSerializer): @@ -18,10 +21,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 +34,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/task.py b/backend/src/processes/serializers/templates/task.py index 3ad2c86ef..c3be19e4e 100644 --- a/backend/src/processes/serializers/templates/task.py +++ b/backend/src/processes/serializers/templates/task.py @@ -12,6 +12,7 @@ ) from src.analysis.services import AnalyticService +from src.generics.fields import AccountPrimaryKeyRelatedField from src.generics.mixins.serializers import ( AdditionalValidationMixin, CustomValidationErrorMixin, @@ -22,6 +23,7 @@ SystemVariable, ) from src.processes.messages import template as messages +from src.processes.models.templates.fieldset import FieldsetTemplate from src.processes.models.templates.fields import FieldTemplate from src.processes.models.templates.task import TaskTemplate from src.processes.serializers.templates.checklist import ( @@ -73,6 +75,7 @@ class Meta: 'require_completion_by_all', 'delay', 'fields', + 'fieldsets', 'conditions', 'api_name', 'raw_performers', @@ -99,6 +102,13 @@ class Meta: number = IntegerField() api_name = CharField(max_length=200, required=False) fields = FieldTemplateSerializer(many=True, required=False) + fieldsets = AccountPrimaryKeyRelatedField( + many=True, + queryset=FieldsetTemplate.objects.all(), + required=False, + allow_empty=True, + default=list, + ) checklists = ChecklistTemplateSerializer(many=True, required=False) conditions = ConditionTemplateSerializer(many=True, required=False) raw_performers = RawPerformerSerializer( @@ -378,6 +388,7 @@ def create(self, validated_data: Dict[str, Any]): api_name = validated_data['api_name'] parents = self.context['parents_by_tasks'][api_name] ancestors = list(self.context['ancestors_by_tasks'][api_name]) + fieldsets = validated_data.pop('fieldsets', None) or [] instance = self.create_or_update_instance( validated_data={ 'template': self.context['template'], @@ -387,6 +398,7 @@ def create(self, validated_data: Dict[str, Any]): **validated_data, }, ) + instance.fieldsets.set(fieldsets) template = self.context['template'] if template.is_active and validated_data.get('raw_due_date'): AnalyticService.templates_task_due_date_created( @@ -487,6 +499,7 @@ def update( and not hasattr(self.instance, 'raw_due_date') and validated_data.get('raw_due_date') ) + fieldsets = validated_data.pop('fieldsets', None) or [] instance = self.create_or_update_instance( instance=instance, validated_data={ @@ -497,6 +510,8 @@ def update( **validated_data, }, ) + if fieldsets is not None: + instance.fieldsets.set(fieldsets) if raw_due_date_created: AnalyticService.templates_task_due_date_created( user=self.context['user'], @@ -601,6 +616,7 @@ class Meta: 'number', 'api_name', 'fields', + 'fieldsets', ) fields = FieldTemplateShortViewSerializer( diff --git a/backend/src/processes/serializers/templates/template.py b/backend/src/processes/serializers/templates/template.py index d766f4047..106b2209f 100644 --- a/backend/src/processes/serializers/templates/template.py +++ b/backend/src/processes/serializers/templates/template.py @@ -40,6 +40,7 @@ WorkflowApiStatus, TaskStatus, ) from src.processes.messages import template as messages +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.template import ( @@ -170,26 +171,43 @@ 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: + return result + + fields_data = kickoff_data.get('fields') or [] + for field in fields_data: 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 + 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 + fieldset_ids = kickoff_data.get('fieldsets') or [] + normalized_fieldset_ids = [] + for elem in fieldset_ids: + try: + normalized_fieldset_ids.append(int(elem)) + except (TypeError, ValueError): + continue + if normalized_fieldset_ids: + account = self.context.get('account') + fieldset_fields = FieldTemplate.objects.filter( + fieldset_id__in=normalized_fieldset_ids, + account_id=account.id, + ) + for field_template in fieldset_fields: + result.append({ + 'name': field_template.name, + 'api_name': field_template.api_name, + 'is_required': field_template.is_required, + }) return result def _get_template_performers_ids(self, data: Dict[str, Any]) -> Set[int]: @@ -454,8 +472,12 @@ def _get_normalized_kickoff_draft( ) -> dict: if isinstance(data, dict): data['fields'] = data.get('fields', []) + data['fieldsets'] = data.get('fieldsets', []) else: - data = {'fields': []} + data = { + 'fields': [], + 'fieldsets': [], + } return data def save_as_draft(self) -> Template: 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/fieldset.py b/backend/src/processes/serializers/workflows/fieldset.py new file mode 100644 index 000000000..d69c7fca1 --- /dev/null +++ b/backend/src/processes/serializers/workflows/fieldset.py @@ -0,0 +1,22 @@ +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', + '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..c98dd760d 100644 --- a/backend/src/processes/serializers/workflows/kickoff_value.py +++ b/backend/src/processes/serializers/workflows/kickoff_value.py @@ -1,31 +1,43 @@ 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.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 +81,40 @@ 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 = ( + kickoff.fieldsets + .prefetch_related('rules', 'fields') + .order_by('id') + ) + 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, + kickoff_id=instance.id, + 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, FieldsetServiceException) as ex: + self.raise_validation_error( + message=ex.message, + api_name=ex.api_name, + ) return instance def update( @@ -90,24 +122,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 a8f81fcf2..59605bab8 100644 --- a/backend/src/processes/serializers/workflows/task.py +++ b/backend/src/processes/serializers/workflows/task.py @@ -27,6 +27,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 ( TaskUserGroupPerformerSerializer, ) @@ -118,6 +121,7 @@ class Meta: 'status', 'revert_tasks', 'is_read_only_viewer', + 'fieldsets', ) date_started_tsp = TimeStampField(source='date_started') @@ -139,6 +143,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_is_completed(self, instance): # TODO Remove in 41258 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 fd5abced1..ef274bca7 100644 --- a/backend/src/processes/services/events.py +++ b/backend/src/processes/services/events.py @@ -576,9 +576,6 @@ def task_skip_no_performers_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..e0cad4b7d 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,54 @@ 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 FieldsetTemplateInUseException( + FieldsetTemplateServiceException, +): + + default_message = fs_messages.MSG_FS_0001 + + +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 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 938f4c267..04c904a72 100644 --- a/backend/src/processes/services/tasks/task.py +++ b/backend/src/processes/services/tasks/task.py @@ -42,6 +42,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, ) @@ -100,6 +103,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,7 +204,9 @@ def create_conditions_from_template( def create_fields_from_template(self, instance_template: TaskTemplate): - for field_template in instance_template.fields.all(): + for field_template in instance_template.fields.exclude( + fieldset__in=instance_template.fieldsets.all(), + ): service = TaskFieldService(user=self.user) service.create( instance_template=field_template, @@ -209,6 +215,21 @@ def create_fields_from_template(self, instance_template: TaskTemplate): skip_value=True, ) + def create_fieldsets_from_template( + self, + instance_template: TaskTemplate, + ): + for fs_template in instance_template.fieldsets.all().order_by('id'): + service = FieldSetService(user=self.user) + service.create( + instance_template=fs_template, + account_id=self.instance.workflow.account_id, + workflow=self.instance.workflow, + task=self.instance, + task_id=self.instance.id, + 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 042ccb8b7..0677fc908 100644 --- a/backend/src/processes/services/tasks/task_version.py +++ b/backend/src/processes/services/tasks/task_version.py @@ -16,6 +16,10 @@ Predicate, Rule, ) +from src.processes.models.workflows.fieldset import ( + FieldSet, + FieldSetRule, +) from src.processes.models.workflows.fields import ( FieldSelection, TaskField, @@ -53,6 +57,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, @@ -63,22 +88,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): @@ -152,26 +164,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, @@ -404,6 +505,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, @@ -422,6 +524,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/fieldsets/__init__.py b/backend/src/processes/services/templates/fieldsets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/processes/services/templates/fieldsets/fieldset.py b/backend/src/processes/services/templates/fieldsets/fieldset.py new file mode 100644 index 000000000..8bd372bf4 --- /dev/null +++ b/backend/src/processes/services/templates/fieldsets/fieldset.py @@ -0,0 +1,219 @@ +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.fieldset import FieldsetTemplate +from src.processes.models.templates.kickoff import Kickoff +from src.processes.services.exceptions import ( + FieldsetTemplateInUseException, +) +from src.processes.services.templates.field_template import ( + FieldTemplateService, +) +from src.processes.services.templates.fieldsets.fieldset_rule import \ + FieldsetTemplateRuleService + +UserModel = get_user_model() + + +class FieldSetTemplateService(BaseModelService): + + def _create_instance( + self, + name: str, + template_id: int, + order: int = 0, + description: str = '', + label_position: LabelPosition.LITERALS = LabelPosition.TOP, + layout: FieldSetLayout.LITERALS = FieldSetLayout.VERTICAL, + kickoff_id: Optional[int] = None, + task_id: Optional[int] = None, + **kwargs, + ): + self.instance = FieldsetTemplate.objects.create( + template_id=template_id, + account=self.account, + name=name, + description=description, + order=order, + label_position=label_position, + layout=layout, + kickoff_id=kickoff_id, + task_id=task_id, + ) + return self.instance + + 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( + self, + **kwargs, + ) -> FieldsetTemplate: + + template_id = kwargs['template_id'] + if kwargs.get('task_id') is None: + # Bind fieldset to the kickoff if task_id is not provided + kwargs['kickoff_id'] = ( + Kickoff.objects.get(template_id=template_id).id + ) + super().create(**kwargs) + return self.instance + + def partial_update( + self, + **update_kwargs, + ) -> FieldsetTemplate: + + rules_data = update_kwargs.pop('rules', None) + fields_data = update_kwargs.pop('fields', None) + if 'task_id' in update_kwargs: + if update_kwargs['task_id'] is None: + # Unbind fieldset from the task and bind to the kickoff + template_id = self.instance.template_id + update_kwargs['kickoff_id'] = ( + Kickoff.objects.get(template_id=template_id).id + ) + else: + # Unbind fieldset from the kickoff and bind to the task + update_kwargs['kickoff_id'] = 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 _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 delete(self) -> None: + if self.instance.kickoff or self.instance.task: + raise FieldsetTemplateInUseException + self.instance.delete() + + 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.id: rule for rule in self.instance.rules.all()} + rules_ids = set() + for rule_data in rules_data: + rule_id = rule_data.pop('id', None) + if rule_id and rule_id in existing_rules: + service = FieldsetTemplateRuleService( + user=self.user, + is_superuser=self.is_superuser, + auth_type=self.auth_type, + instance=existing_rules[rule_id], + ) + service.partial_update(**rule_data) + rules_ids.add(rule_id) + 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, + ) + rules_ids.add(rule.id) + + self.instance.rules.exclude(id__in=rules_ids).delete() + + 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, + ) + 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() diff --git a/backend/src/processes/services/templates/fieldsets/fieldset_rule.py b/backend/src/processes/services/templates/fieldsets/fieldset_rule.py new file mode 100644 index 000000000..98a440192 --- /dev/null +++ b/backend/src/processes/services/templates/fieldsets/fieldset_rule.py @@ -0,0 +1,119 @@ +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, + **kwargs, + ): + self.instance = FieldsetTemplateRule.objects.create( + account=self.account, + type=type, + value=value, + fieldset_id=fieldset_id, + ) + 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/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 c8f802b20..8af1664f2 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,34 @@ 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 = ( + 'api_name', + 'name', + 'description', + 'order', + '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 +108,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): @@ -192,6 +243,7 @@ class Meta: 'number', 'require_completion_by_all', 'fields', + 'fieldsets', 'delay', 'conditions', 'raw_performers', @@ -202,6 +254,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 16f07c82e..f352ef1ce 100644 --- a/backend/src/processes/services/workflow_action.py +++ b/backend/src/processes/services/workflow_action.py @@ -26,11 +26,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 ( @@ -43,6 +45,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, @@ -799,15 +802,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..7d5b57d84 --- /dev/null +++ b/backend/src/processes/services/workflows/fieldsets/fieldset.py @@ -0,0 +1,73 @@ +from typing import List, Optional, Dict +from django.contrib.auth import get_user_model + +from src.generics.base.service import BaseModelService +from src.processes.models.templates.fieldset import FieldsetTemplate +from src.processes.models.workflows.fieldset import FieldSet +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, + ): + self.instance = FieldSet.objects.create( + account=self.account, + workflow=kwargs['workflow'], + kickoff=kwargs.get('kickoff'), + task=kwargs.get('task'), + api_name=instance_template.api_name, + name=instance_template.name, + 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): + for rule in self.instance.rules.all(): + service = FieldSetRuleService(user=self.user, instance=rule) + service.validate() 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..1f32d59cc --- /dev/null +++ b/backend/src/processes/services/workflows/fieldsets/fieldset_rule.py @@ -0,0 +1,55 @@ +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 + for field in self.instance.fields.all(): + if field.value not in self.NULL_VALUES: + total += float(field.value) + if total != float(self.instance.value): + raise FieldsetServiceException(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..6489f8a6f 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,100 @@ 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 []: + 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': fs_data['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 +172,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 36a4e14d7..da9a719bf 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,10 @@ 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 +70,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 +826,113 @@ def create_test_dataset( order=i, ) return dataset + + +def create_test_fieldset_template( + account: Account, + template: Optional[Template] = None, + kickoff: Optional[Kickoff] = None, + task: Optional[TaskTemplate] = 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, +) -> FieldsetTemplate: + + """Creating fieldset templates.""" + + fieldset = FieldsetTemplate.objects.create( + account=account, + template=template, + kickoff=kickoff, + task=task, + name=name, + description=description, + order=order, + label_position=label_position, + layout=layout, + api_name=api_name, + ) + 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, + template=template, + order=1, + 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_tasks/test_task_version_service.py b/backend/src/processes/tests/test_services/test_tasks/test_task_version_service.py index dd145aadf..bdd859d85 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 @@ +import pytest from datetime import timedelta -import pytest from django.contrib.auth import get_user_model from django.utils import timezone @@ -13,7 +13,8 @@ PredicateOperator, WorkflowStatus, TaskStatus, ) -from src.processes.models.workflows.fields import TaskField +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,9 +36,16 @@ create_test_not_admin, create_test_owner, create_test_template, - create_test_workflow, + create_test_workflow, create_test_fieldset, +) + +from src.processes.enums import ( + FieldSetLayout, + FieldSetRuleType, + LabelPosition, ) + UserModel = get_user_model() pytestmark = pytest.mark.django_db @@ -2054,3 +2062,915 @@ def test_update_performers__removed_group_user_already_performer__not_sent( send_new_task_notification_mock.assert_not_called() send_new_task_websocket_mock.assert_not_called() send_removed_task_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=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, + ) + data = [ + { + 'api_name': 'fieldset-1', + 'name': 'New Fieldset', + 'description': 'Test description', + 'order': 1, + '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, + ) + assert new_fieldset.name == 'New Fieldset' + assert new_fieldset.description == 'Test description' + assert new_fieldset.order == 1 + _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_templates/test_fieldset_template_rule_service.py b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_rule_service.py new file mode 100644 index 000000000..199df8c6d --- /dev/null +++ b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_rule_service.py @@ -0,0 +1,905 @@ +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.templates.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', + order=1, + ) + 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 + + +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', + order=1, + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + # act + service._create_instance( + type=FieldSetRuleType.SUM_EQUAL, + value='100', + fieldset_id=fieldset.id, + ) + + # assert + assert service.instance is not None + assert service.instance.type == FieldSetRuleType.SUM_EQUAL + assert service.instance.value == '100' + assert service.instance.fieldset_id == fieldset.id + assert service.instance.account_id == account.id + + +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', + order=1, + ) + 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', + order=1, + ) + 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', + order=1, + ) + 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', + order=1, + ) + 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', + order=1, + ) + 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.templates.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', + order=1, + ) + 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', + order=1, + ) + 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', + order=1, + ) + 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', + order=1, + ) + 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', + 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(['missing1', 'missing2']) + + # assert + assert ex.value.message == ( + fs_messages.MSG_FS_0005( + rule=FieldSetRuleType.SUM_EQUAL, + field='missing1', + ) or 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', + order=1, + ) + 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.templates.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', + order=1, + ) + 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.templates.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.templates.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.templates.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.templates.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', + order=1, + ) + 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.templates.fieldsets.fieldset_rule.' + 'FieldsetTemplateRuleService._create_instance', + ) + create_related_mock = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset_rule.' + 'FieldsetTemplateRuleService._create_related', + ) + create_actions_mock = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset_rule.' + 'FieldsetTemplateRuleService._create_actions', + ) + validate_mock = mocker.patch( + 'src.processes.services.templates.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', + order=1, + ) + 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.templates.fieldsets' + '.fieldset_rule.FieldsetTemplateRuleService' + '._set_fields', + ) + validate_mock = mocker.patch( + 'src.processes.services.templates.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', + order=1, + ) + 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.templates.fieldsets' + '.fieldset_rule.FieldsetTemplateRuleService' + '._set_fields', + ) + validate_mock = mocker.patch( + 'src.processes.services.templates.fieldsets' + '.fieldset_rule.FieldsetTemplateRuleService' + '._validate', + ) + super_partial_mock = mocker.patch( + 'src.processes.services.templates.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_templates/test_fieldset_template_service.py b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py new file mode 100644 index 000000000..67f713bee --- /dev/null +++ b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py @@ -0,0 +1,1232 @@ +import pytest +from src.authentication.enums import AuthTokenType +from src.processes.enums import ( + FieldSetLayout, + FieldSetRuleType, + 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, +) +from src.processes.services.templates.field_template import ( + FieldTemplateService, +) +from src.processes.services.templates.fieldsets.fieldset import ( + FieldSetTemplateService, +) +from src.processes.services.templates.fieldsets.fieldset_rule import ( + FieldsetTemplateRuleService, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_owner, + create_test_template, + create_test_fieldset_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) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + name = 'Test fieldset' + order = 1 + + # act + service._create_instance( + name=name, + order=order, + template_id=template.id, + ) + + # assert + assert service.instance is not None + assert service.instance.name == name + assert service.instance.order == order + assert service.instance.template_id == template.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) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + name = 'Test fieldset' + order = 2 + description = 'Test description' + label_position = LabelPosition.LEFT + layout = FieldSetLayout.HORIZONTAL + + # act + service._create_instance( + name=name, + order=order, + template_id=template.id, + description=description, + label_position=label_position, + layout=layout, + ) + + # assert + assert service.instance.name == name + assert service.instance.order == order + assert service.instance.template_id == template.id + assert service.instance.description == description + assert service.instance.label_position == label_position + assert service.instance.layout == layout + + +def test__create_instance__with_kickoff_id__ok(): + + """Persist kickoff_id when creating a fieldset template.""" + + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + kickoff = template.kickoff_instance + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + service._create_instance( + name='Kickoff fieldset', + order=1, + template_id=template.id, + kickoff_id=kickoff.id, + ) + + assert service.instance.kickoff_id == kickoff.id + assert service.instance.task_id is None + + +def test__create_instance__with_task_id__ok(): + + """Persist task_id when creating a fieldset template.""" + + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + task = template.tasks.first() + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + service._create_instance( + name='Task fieldset', + order=1, + template_id=template.id, + task_id=task.id, + ) + + assert service.instance.task_id == task.id + assert service.instance.kickoff_id is None + + +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', + order=1, + ) + 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', + order=1, + ) + 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.templates.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.templates.fieldsets.fieldset.' + 'FieldSetTemplateService.create_rules', + ) + create_fields_mock = mocker.patch( + 'src.processes.services.templates.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.templates.fieldsets.fieldset.' + 'FieldSetTemplateService.create_rules', + ) + create_fields_mock = mocker.patch( + 'src.processes.services.templates.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.templates.fieldsets.fieldset.' + 'FieldSetTemplateService.create_rules', + ) + create_fields_mock = mocker.patch( + 'src.processes.services.templates.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.templates.fieldsets.fieldset.' + 'FieldSetTemplateService.create_rules', + ) + create_fields_mock = mocker.patch( + 'src.processes.services.templates.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', + order=1, + ) + 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', + order=1, + ) + 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', + order=1, + ) + 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.templates.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', + order=1, + ) + 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 = [{'id': rule_1.id, '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.templates.fieldsets.fieldset_rule.' + 'FieldsetTemplateRuleService.partial_update', + ) + fieldset_template_rule_service_create_mock = mocker.patch( + 'src.processes.services.templates.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', + force_save=True, + ) + 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', + order=1, + ) + 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.id = 999 + fs_rule_init_mock = mocker.patch.object( + FieldsetTemplateRuleService, + attribute='__init__', + return_value=None, + ) + fs_rule_create_mock = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset_rule.' + 'FieldsetTemplateRuleService.create', + return_value=create_return, + ) + fs_rule_update_mock = mocker.patch( + 'src.processes.services.templates.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', + order=1, + ) + 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 = [{'id': rule_1.id, '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.templates.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_create__bind_kickoff__ok(mocker): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + template = create_test_template(user=owner, tasks_count=1) + kickoff = template.kickoff_instance + data = { + "template_id": template.id, + "name": "Test Fieldset", + "fields": [], + "rules": [], + } + + mock_create_instance = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset.' + 'FieldSetTemplateService._create_instance', + ) + mock_create_related = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset.' + 'FieldSetTemplateService._create_related', + ) + mock_create_actions = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset.' + 'FieldSetTemplateService._create_actions', + ) + service = FieldSetTemplateService(user=owner) + + # act + service.create(**data) + + # assert + mock_create_instance.assert_called_once_with(**data, kickoff_id=kickoff.id) + mock_create_related.assert_called_once_with(**data, kickoff_id=kickoff.id) + mock_create_actions.assert_called_once_with(**data, kickoff_id=kickoff.id) + + +def test_create_with_task_bind_task_ok(mocker): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + template = create_test_template(user=owner, tasks_count=1) + task = template.tasks.get(number=1) + data = { + "template_id": template.id, + "name": "Test Fieldset", + "fields": [], + "rules": [], + "task_id": task.id, + } + + mock_create_instance = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset.' + 'FieldSetTemplateService._create_instance', + ) + mock_create_related = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset.' + 'FieldSetTemplateService._create_related', + ) + mock_create_actions = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset.' + 'FieldSetTemplateService._create_actions', + ) + service = FieldSetTemplateService(user=owner) + + # act + service.create(**data) + + # assert + mock_create_instance.assert_called_once_with(**data) + mock_create_related.assert_called_once_with(**data) + mock_create_actions.assert_called_once_with(**data) + + +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) + task = template.tasks.get(number=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + ) + mock_update_fields = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset.' + 'FieldSetTemplateService._update_fields', + ) + mock_update_rules = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset.' + 'FieldSetTemplateService.update_rules', + ) + mock_validate_rules = mocker.patch( + 'src.processes.services.templates.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_unbind_task_ok(mocker): + + """Test handling of `task_id` being set to None (unbind from task)""" + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + template = create_test_template(user=owner, tasks_count=1) + task = template.tasks.get(number=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + ) + + mock_update_fields = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset.' + 'FieldSetTemplateService._update_fields', + ) + mock_update_rules = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset.' + 'FieldSetTemplateService.update_rules', + ) + mock_validate_rules = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset.' + 'FieldSetTemplateService._validate_rules', + ) + service = FieldSetTemplateService(instance=fieldset, user=owner) + data = {"task_id": None} + + # act + result = service.partial_update(**data) + + # assert + fieldset.refresh_from_db() + assert result is fieldset + assert fieldset.task is None + assert fieldset.kickoff == template.kickoff_instance + mock_update_fields.assert_not_called() + mock_update_rules.assert_not_called() + mock_validate_rules.assert_called_once_with() + + +def test_partial_update_bind_task_ok(mocker): + """Test handling of `task_id` being provided (bind to task)""" + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + template = create_test_template(user=owner, tasks_count=1) + task = template.tasks.get(number=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + + mock_update_fields = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset.' + 'FieldSetTemplateService._update_fields', + ) + mock_update_rules = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset.' + 'FieldSetTemplateService.update_rules', + ) + mock_validate_rules = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset.' + 'FieldSetTemplateService._validate_rules', + ) + service = FieldSetTemplateService(instance=fieldset, user=owner) + data = {"task_id": task.id} + + # act + result = service.partial_update(**data) + + # assert + fieldset.refresh_from_db() + assert result is fieldset + assert fieldset.task == task + assert fieldset.kickoff is None + 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) + task = template.tasks.get(number=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + ) + service = FieldSetTemplateService(user=owner, instance=fieldset) + + mock_update_fields = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset.' + 'FieldSetTemplateService._update_fields', + ) + mock_update_rules = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset.' + 'FieldSetTemplateService.update_rules', + ) + mock_validate_rules = mocker.patch( + 'src.processes.services.templates.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) + task = template.tasks.get(number=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + ) + + mock_super_partial_update = mocker.patch( + 'src.generics.base.service.' + 'BaseModelService.partial_update', + ) + mock_update_fields = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset.' + 'FieldSetTemplateService._update_fields', + ) + mock_update_rules = mocker.patch( + 'src.processes.services.templates.fieldsets.fieldset.' + 'FieldSetTemplateService.update_rules', + ) + mock_validate_rules = mocker.patch( + 'src.processes.services.templates.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', + order=1, + ) + 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 → 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, + order=1, + ) + 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, + task=task_template, + name='Fieldset', + order=1, + ) + 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() 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..e43f5562d --- /dev/null +++ b/backend/src/processes/tests/test_services/test_workflows/test_fieldset_rule_service.py @@ -0,0 +1,468 @@ +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', + order=1, + ) + 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__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..1a958eb34 --- /dev/null +++ b/backend/src/processes/tests/test_services/test_workflows/test_fieldset_service.py @@ -0,0 +1,511 @@ +import pytest +from src.authentication.enums import AuthTokenType +from src.processes.enums import ( + FieldSetRuleType, + FieldType, +) +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateRule, +) +from src.processes.models.templates.fields import FieldTemplate +from src.processes.models.workflows.fieldset import ( + FieldSet, + FieldSetRule, +) +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_owner, + create_test_template, + create_test_workflow, +) + +pytestmark = pytest.mark.django_db + + +def test__create_instance__with_kickoff__ok(mocker): + + """ + 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 + fieldset_template = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + description='Description', + order=1, + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + # act + service._create_instance( + instance_template=fieldset_template, + workflow=workflow, + kickoff=kickoff, + ) + + # assert + assert service.instance is not None + assert service.instance.workflow_id == workflow.id + assert service.instance.kickoff_id == kickoff.id + assert service.instance.task is None + assert service.instance.api_name == fieldset_template.api_name + assert service.instance.name == 'Fieldset' + assert service.instance.description == 'Description' + assert service.instance.order == 1 + + +def test__create_instance__with_task__ok(mocker): + + """ + 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() + fieldset_template = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + order=1, + ) + 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 is not None + assert service.instance.workflow_id == workflow.id + assert service.instance.task_id == task.id + assert service.instance.kickoff is None + + +def test__create_instance__no_kickoff_no_task__ok(mocker): + + """ + 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', + order=1, + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + # act + service._create_instance( + instance_template=fieldset_template, + workflow=workflow, + ) + + # assert + assert service.instance is not None + assert service.instance.workflow_id == workflow.id + assert service.instance.kickoff is None + assert service.instance.task is None + + +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', + order=1, + ) + FieldTemplate.objects.create( + account=account, + fieldset=fieldset_template, + name='Field 1', + type=FieldType.NUMBER, + order=1, + ) + fieldset = FieldSet.objects.create( + account=account, + workflow=workflow, + name='Fieldset', + order=1, + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # mock + 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', + order=1, + ) + field_template_1 = FieldTemplate.objects.create( + account=account, + fieldset=fieldset_template, + name='Field 1', + type=FieldType.NUMBER, + order=1, + ) + fieldset = FieldSet.objects.create( + account=account, + workflow=workflow, + name='Fieldset', + order=1, + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + fields_data = {field_template_1.api_name: '42'} + + # mock + 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', + order=1, + ) + FieldTemplate.objects.create( + account=account, + fieldset=fieldset_template, + name='Field 1', + type=FieldType.NUMBER, + order=1, + ) + fieldset = FieldSet.objects.create( + account=account, + workflow=workflow, + name='Fieldset', + order=1, + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # mock + 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', + order=1, + ) + rule_template = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset_template, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ) + fieldset = FieldSet.objects.create( + account=account, + workflow=workflow, + name='Fieldset', + order=1, + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # mock + 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', + order=1, + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + # mock + 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_fields_mock.assert_called_once_with( + fieldset_template, + ) + create_rules_mock.assert_called_once_with( + fieldset_template, + ) + + +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) + workflow = create_test_workflow(user=user, template=template) + fieldset = FieldSet.objects.create( + account=account, + workflow=workflow, + name='Fieldset', + order=1, + ) + rule = FieldSetRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # mock + 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() 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..dbba7ed80 --- /dev/null +++ b/backend/src/processes/tests/test_services/test_workflows/test_kickoff_version_service.py @@ -0,0 +1,1103 @@ +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', + 'order': 0, + '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 == 0 + 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..9724bdfbc --- /dev/null +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_create.py @@ -0,0 +1,1132 @@ +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.messages import template as messages +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateRule, +) +from src.processes.models.templates.fields import FieldTemplate + +from src.processes.services.templates.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', + 'description': 'Description', + 'order': 2, + 'task': task.api_name, + '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': 'val', + 'api_name': 'r1', + 'fields': [], + }, + ], + } + + fieldset = FieldsetTemplate.objects.create( + account=account, + template=template, + task=task, + name=data['name'], + description=data['description'], + order=data['order'], + 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='val', + 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.template.FieldSetTemplateService.create', + return_value=fieldset, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{template.id}/fieldsets', + data=data, + ) + + # assert + assert response.status_code == 201 + assert response.data['id'] == fieldset.id + assert response.data['name'] == data['name'] + assert response.data['description'] == data['description'] + assert response.data['order'] == data['order'] + assert response.data['task'] == task.api_name + 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( + template_id=template.id, + name=data['name'], + order=data['order'], + description=data['description'], + layout=data['layout'], + label_position=data['label_position'], + api_name=data['api_name'], + task_id=task.id, + rules=data['rules'], + fields=data['fields'], + ) + + +def test_create_fieldset__with_kickoff_id__ok(api_client, mocker): + + """Create fieldset linked to template kickoff via kickoff_id.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + data = { + 'name': 'Kickoff Fieldset', + 'order': 1, + } + + fieldset = FieldsetTemplate.objects.create( + account=account, + template=template, + kickoff=template.kickoff_instance, + name=data['name'], + order=data['order'], + ) + + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.template.FieldSetTemplateService.create', + return_value=fieldset, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{template.id}/fieldsets', + data=data, + ) + + # assert + assert response.status_code == 201 + assert response.data['task'] is None + 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( + template_id=template.id, + name=data['name'], + order=data['order'], + rules=[], + fields=[], + ) + + +def test_create_fieldset__with_task__ok(api_client, mocker): + """Create fieldset linked to a template task via task_id.""" + + # 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': 'Task Fieldset', + 'order': 1, + 'task': task.api_name, + } + + fieldset = FieldsetTemplate.objects.create( + account=account, + template=template, + task=task, + name=data['name'], + order=data['order'], + ) + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.template.FieldSetTemplateService.create', + return_value=fieldset, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{template.id}/fieldsets', + data=data, + ) + + # assert + assert response.status_code == 201 + assert response.data['task'] == task.api_name + api_name = response.data['api_name'] + assert FieldsetTemplate.objects.get( + api_name=api_name, + task=task, + ) + 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( + template_id=template.id, + task_id=task.id, + name=data['name'], + order=data['order'], + rules=[], + 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', + 'order': 1, + } + + # mock FieldSetTemplateService + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset = FieldsetTemplate.objects.create( + account=account, + template=template, + name='Minimal Fieldset', + order=1, + ) + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.template.FieldSetTemplateService.create', + return_value=fieldset, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{template.id}/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( + template_id=template.id, + name='Minimal Fieldset', + order=1, + 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', + 'order': 1, + 'api_name': 'fs1', + } + + # mock FieldSetTemplateService + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset = FieldsetTemplate.objects.create( + account=account, + template=template, + name='Minimal Fieldset', + order=1, + ) + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.template.FieldSetTemplateService.create', + return_value=fieldset, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{template.id}/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( + template_id=template.id, + name=data['name'], + api_name=data['api_name'], + order=data['order'], + 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, + ) + task = template.tasks.first() + field_api_name = 'f1' + data = { + 'name': 'All Fields Fieldset', + 'order': 1, + 'task': task.api_name, + '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], + }, + ], + } + + # mock FieldSetTemplateService + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + fieldset = FieldsetTemplate.objects.create( + account=account, + template=template, + task=task, + name=data['name'], + order=data['order'], + ) + 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.template.FieldSetTemplateService.create', + return_value=fieldset, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{template.id}/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( + template_id=template.id, + task_id=task.id, + name=data['name'], + order=data['order'], + 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, + ) + kickoff = template.kickoff_instance + field_1_api_name = 'f1' + field_2_api_name = 'f2' + data = { + 'name': 'All Fields Fieldset', + 'order': 1, + '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], + }, + ], + } + + # mock FieldSetTemplateService + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + fieldset = FieldsetTemplate.objects.create( + account=account, + template=template, + kickoff=kickoff, + name=data['name'], + order=data['order'], + ) + 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.template.FieldSetTemplateService.create', + return_value=fieldset, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{template.id}/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( + template_id=template.id, + name=data['name'], + order=data['order'], + rules=data['rules'], + fields=data['fields'], + ) + + +def test_create_fieldset_another_template_task__validation_error( + api_client, + mocker, +): + """Create fieldset linked to a template task via task_id.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + another_template = create_test_template( + user=user, + tasks_count=1, + ) + another_template_task = another_template.tasks.get(number=1) + data = { + 'name': 'Task Fieldset', + 'order': 1, + 'task': another_template_task.api_name, + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.template.FieldSetTemplateService.create', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{template.id}/fieldsets', + data=data, + ) + + # assert + assert response.status_code == 400 + message = ( + f'Object with api_name={another_template_task.api_name} ' + f'does not exist.' + ) + assert response.data['message'] == message + assert response.data['details']['name'] == 'task' + fieldset_service_init_mock.assert_not_called() + fieldset_service_create_mock.assert_not_called() + + +def test_create_fieldset__unauthenticated__unauthorized(api_client, mocker): + + """Unauthenticated request returns 401""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + data = { + 'name': 'New Fieldset', + 'order': 1, + } + + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.template.FieldSetTemplateService.create', + ) + + # act + response = api_client.post( + f'/templates/{template.id}/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + data = { + 'name': 'New Fieldset', + 'order': 1, + } + + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.template.FieldSetTemplateService.create', + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{template.id}/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + data = { + 'name': 'New Fieldset', + 'order': 1, + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.template.FieldSetTemplateService.create', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{template.id}/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() + template = create_test_template( + user=user, + tasks_count=1, + ) + data = { + 'name': 'New Fieldset', + 'order': 1, + } + + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.template.FieldSetTemplateService.create', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{template.id}/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() + owner = create_test_owner(account=account) + template = create_test_template( + user=owner, + tasks_count=1, + ) + user = create_test_not_admin(account=account) + data = { + 'name': 'New Fieldset', + 'order': 1, + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.template.FieldSetTemplateService.create', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{template.id}/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__not_tpl_owner__permission_denied(api_client, mocker): + + """ Template admin owner permission denied returns 403 """ + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + template = create_test_template( + user=owner, + tasks_count=1, + ) + user = create_test_admin(account=account) + data = { + 'name': 'New Fieldset', + 'order': 1, + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.template.FieldSetTemplateService.create', + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{template.id}/fieldsets', + data=data, + ) + + # assert + assert response.status_code == 403 + assert response.data['detail'] == messages.MSG_PT_0023 + fieldset_service_init_mock.assert_not_called() + fieldset_service_create_mock.assert_not_called() + + +def test_create_fieldset__invalid_name__validation_error(api_client, mocker): + + """ Invalid name field returns validation error """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + data = { + 'order': 1, + } + + fieldset = mocker.Mock(id=1, api_name='dummy') + + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.template.FieldSetTemplateService.create', + return_value=fieldset, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{template.id}/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + data = { + 'name': 'Test Fieldset', + 'order': 1, + '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.template.FieldSetTemplateService.create', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{template.id}/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + data = { + 'name': 'Test Fieldset', + 'order': 1, + '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.template.FieldSetTemplateService.create', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{template.id}/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + data = { + 'name': 'Test Fieldset', + 'order': 1, + } + error_message = 'Service error occurred' + + # mock FieldSetTemplateService + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.template.FieldSetTemplateService.create', + side_effect=BaseServiceException(message=error_message), + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{template.id}/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( + template_id=template.id, + name='Test Fieldset', + order=1, + rules=[], + fields=[], + ) + + +def test_create_fieldset__not_existing_tpl__not_found(api_client, mocker): + + """ Non-existent template returns 404 """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + nonexistent_id = 999999 + data = { + 'name': 'New Fieldset', + 'order': 1, + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.template.FieldSetTemplateService.create', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{nonexistent_id}/fieldsets', + data=data, + ) + + # assert + assert response.status_code == 404 + fieldset_service_init_mock.assert_not_called() + fieldset_service_create_mock.assert_not_called() 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..8855e44fe --- /dev/null +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_destroy.py @@ -0,0 +1,341 @@ +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.generics.exceptions import BaseServiceException +from src.processes.services.templates.fieldsets.fieldset import ( + FieldSetTemplateService, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_fieldset_template, + create_test_not_admin, + create_test_owner, + create_test_template, +) + +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) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + + # 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'/templates/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=mocker.ANY, + ) + 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() + 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, + ) + + 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'/templates/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + + 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'/templates/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + + 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'/templates/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() + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + + 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'/templates/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() + owner = create_test_owner(account=account) + template = create_test_template( + user=owner, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + 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'/templates/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + 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'/templates/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=mocker.ANY, + ) + 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'/templates/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() 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..1aa6788d4 --- /dev/null +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_list.py @@ -0,0 +1,469 @@ +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.messages import template as messages +from src.processes.tests.fixtures import ( + create_test_account, + create_test_admin, + create_test_fieldset_template, + create_test_not_admin, + create_test_owner, + create_test_template, +) + +pytestmark = pytest.mark.django_db + + +def test_list_fieldsets__all_data__ok(api_client): + """List fieldsets for existing template""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + kickoff = template.kickoff_instance + rule_type = FieldSetRuleType.SUM_EQUAL + rule_value = '10' + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Kickoff Fieldset', + order=1, + 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( + f'/templates/{template.id}/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['description'] == '' + assert item_1['order'] == fieldset.order + assert item_1['layout'] == fieldset.layout + assert item_1['label_position'] == fieldset.label_position + assert item_1['task'] is None + + assert len(item_1['rules']) == 1 + rules_data = item_1['rules'] + assert rules_data[0]['id'] == rule.id + 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]['order'] == 1 + 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__two_fieldsets__ok(api_client): + """List fieldsets for existing template""" + + # 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_1 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + order=1, + ) + template_task = template.tasks.first() + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + task=template_task, + order=2, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/fieldsets', + ) + + # assert + assert response.status_code == 200 + assert len(response.data) == 2 + + # ordered by -id (newest first) + item_1 = response.data[0] + assert item_1['id'] == fieldset_2.id + assert item_1['task'] == template_task.api_name + assert item_1['order'] == 2 + + item_2 = response.data[1] + assert item_2['id'] == fieldset_1.id + assert item_2['task'] is None + assert item_2['order'] == 1 + + +def test_list_fieldsets__pagination__ok(api_client): + """List fieldsets for existing template""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + template_task = template.tasks.first() + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + task=template_task, + order=3, + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + task=template_task, + order=2, + ) + create_test_fieldset_template( + account=account, + template=template, + task=template_task, + order=1, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/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 + assert item_1['task'] == template_task.api_name + assert item_1['order'] == 2 + + item_2 = response.data['results'][1] + assert item_2['id'] == fieldset_1.id + assert item_2['task'] == template_task.api_name + assert item_2['order'] == 3 + + +def test_list_fieldsets__different_accounts__ok(api_client): + """List fieldsets filtered by account""" + + # arrange + account_1 = create_test_account(name='Account 1') + user_1 = create_test_owner(account=account_1) + template_1 = create_test_template( + user=user_1, + tasks_count=1, + ) + fieldset_1 = create_test_fieldset_template( + account=account_1, + template=template_1, + kickoff=template_1.kickoff_instance, + name='Account 1 Fieldset', + ) + + account_2 = create_test_account(name='Account 2') + user_2 = create_test_owner( + account=account_2, + email='owner2@pneumatic.app', + ) + template_2 = create_test_template( + user=user_2, + tasks_count=1, + ) + create_test_fieldset_template( + account=account_2, + template=template_2, + kickoff=template_2.kickoff_instance, + ) + + api_client.token_authenticate(user=user_1) + + # act + response = api_client.get( + f'/templates/{template_1.id}/fieldsets', + ) + + # assert + assert response.status_code == 200 + assert len(response.data) == 1 + assert response.data[0]['id'] == fieldset_1.id + + +def test_list_fieldsets__different_templates__ok(api_client): + """List fieldsets filtered by template_id""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template_1 = create_test_template( + user=user, + tasks_count=1, + ) + fieldset_1 = create_test_fieldset_template( + account=account, + template=template_1, + kickoff=template_1.kickoff_instance, + ) + template_2 = create_test_template( + user=user, + tasks_count=1, + ) + create_test_fieldset_template( + account=account, + template=template_2, + kickoff=template_2.kickoff_instance, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template_1.id}/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 for existing template returning rules mapping fields""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + kickoff = template.kickoff_instance + rule_type = FieldSetRuleType.SUM_EQUAL + rule_value = '10' + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Kickoff Fieldset', + order=1, + 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'/templates/{template.id}/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]['id'] == rule.id + assert rules_data[0]['fields'] == [field.api_name] + + +def test_list_fieldsets__unauthenticated__unauthorized(api_client): + """Unauthenticated request returns 401""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + + # act + response = api_client.get(f'/templates/{template.id}/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/templates/{template.id}/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/templates/{template.id}/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() + template = create_test_template( + user=user, + tasks_count=1, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/templates/{template.id}/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() + owner = create_test_owner(account=account) + template = create_test_template( + user=owner, + tasks_count=1, + ) + user = create_test_not_admin(account=account) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/templates/{template.id}/fieldsets') + + # assert + assert response.status_code == 403 + + +def test_list_fieldsets__not_tpl_owner__permission_denied(api_client): + """Template admin owner permission denied returns 403""" + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + template = create_test_template( + user=owner, + tasks_count=1, + ) + user = create_test_admin(account=account) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/templates/{template.id}/fieldsets') + + # assert + assert response.status_code == 403 + assert response.data['detail'] == messages.MSG_PT_0023 + + +def test_list_fieldsets__not_existing_tpl__not_found(api_client): + """Non-existent template 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'/templates/{nonexistent_id}/fieldsets') + + # assert + assert response.status_code == 404 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..a21b88a30 --- /dev/null +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_partial_update.py @@ -0,0 +1,1027 @@ +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.services.templates.fieldsets.fieldset import ( + FieldSetTemplateService, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_fieldset_template, + create_test_not_admin, + create_test_owner, + create_test_template, +) +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) + template = create_test_template( + user=user, + tasks_count=1, + ) + field_api_name = 'f1' + fieldset_api_name = 'fs1' + data = { + 'name': 'Full Updated Fieldset', + 'description': 'Updated description', + 'api_name': fieldset_api_name, + 'order': 10, + 'layout': FieldSetLayout.HORIZONTAL, + 'label_position': LabelPosition.LEFT, + 'fields': [ + { + 'name': 'Field 1', + 'type': FieldType.TEXT, + 'order': 1, + 'api_name': field_api_name, + }, + ], + 'rules': [ + { + 'id': 123, + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '10', + 'api_name': 'r1', + 'fields': [field_api_name], + }, + ], + } + fieldset = create_test_fieldset_template( + account=account, + template=template, + name=data['name'], + description=data['description'], + order=data['order'], + label_position=data['label_position'], + layout=data['layout'], + kickoff=template.kickoff_instance, + 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'/templates/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['order'] == data['order'] + assert response.data['task'] is None + 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', + order=10, + 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) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + 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'/templates/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__task_id__ok(api_client, mocker): + + """ Move fieldset from kickoff to task in one PATCH + (clear kickoff, set task).""" + + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + task = template.tasks.first() + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + ) + 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, + ) + data = { + 'task': task.api_name, + } + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/templates/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 200 + assert response.data['task'] == task.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( + task_id=task.id, + ) + + +def test_partial_update__task_is_null_set_kickoff__ok(api_client, mocker): + + """ Move fieldset from kickoff to task in one PATCH + (clear kickoff, set task). """ + + 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, + ) + 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'/templates/fieldsets/{fieldset.id}', + data={ + 'task': None, + }, + ) + + # assert + assert response.status_code == 200 + assert response.data['task'] is None + 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( + task=None, + ) + + +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) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + field_api_name = 'f1' + data = { + 'name': 'Updated Fieldset', + 'fields': [ + { + 'name': 'Field 1', + 'type': FieldType.STRING, + 'order': 1, + 'api_name': field_api_name, + }, + ], + 'rules': [ + { + 'id': 123, + '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'/templates/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + data = { + 'name': 'Updated Fieldset', + 'rules': [ + { + 'id': 123, + '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'/templates/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__not_existent__validation_error(api_client, mocker): + + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + task = template.tasks.first() + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + ) + 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', + ) + data = { + 'task': 'not-exist', + } + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/templates/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 400 + message = 'Object with api_name=not-exist does not exist.' + assert response.data['message'] == message + assert response.data['details']['name'] == 'task' + fieldset_service_init_mock.assert_not_called() + fieldset_partial_update_mock.assert_not_called() + + +def test_partial_update__another_account_task__validation_error( + api_client, + mocker, +): + + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + task = template.tasks.first() + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + ) + another_user = create_test_owner(email='another@test.test') + another_template = create_test_template(user=another_user, tasks_count=1) + another_account_task = another_template.tasks.get(number=1) + 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', + ) + data = { + 'task': another_account_task.api_name, + } + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/templates/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 400 + message = ( + f'Object with api_name={another_account_task.api_name} ' + f'does not exist.' + ) + assert response.data['message'] == message + assert response.data['details']['name'] == 'task' + fieldset_service_init_mock.assert_not_called() + fieldset_partial_update_mock.assert_not_called() + + +def test_partial_update__another_template_task__validation_error( + api_client, + mocker, +): + + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + task = template.tasks.first() + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + ) + another_template = create_test_template(user=user, tasks_count=1) + another_template_task = another_template.tasks.get(number=1) + 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', + ) + data = { + 'task': another_template_task.api_name, + } + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/templates/fieldsets/{fieldset.id}', + data=data, + ) + + # assert + assert response.status_code == 400 + message = ( + f'Object with api_name={another_template_task.api_name} ' + f'does not exist.' + ) + assert response.data['message'] == message + assert response.data['details']['name'] == 'task' + fieldset_service_init_mock.assert_not_called() + fieldset_partial_update_mock.assert_not_called() + + +def test_partial_update__unauthenticated__unauthorized(api_client, mocker): + + """ Unauthenticated request returns 401 """ + + # 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, + ) + 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'/templates/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + 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'/templates/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + 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'/templates/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() + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + 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'/templates/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() + owner = create_test_owner(account=account) + template = create_test_template( + user=owner, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + 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'/templates/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + 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'/templates/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + 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'/templates/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + 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'/templates/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + 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'/templates/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'/templates/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() 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..506462485 --- /dev/null +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py @@ -0,0 +1,340 @@ +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 ( + FieldSetLayout, + LabelPosition, + FieldSetRuleType, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_fieldset_template, + create_test_not_admin, + create_test_owner, + create_test_template, +) + +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) + template = create_test_template( + user=user, + tasks_count=1, + ) + rule_type = FieldSetRuleType.SUM_EQUAL + rule_value = '10' + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + name='My Fieldset', + description='Fieldset description', + order=3, + layout=FieldSetLayout.HORIZONTAL, + label_position=LabelPosition.LEFT, + 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(f'/templates/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'] == 'My Fieldset' + assert response.data['description'] == 'Fieldset description' + assert response.data['order'] == 3 + assert response.data['layout'] == FieldSetLayout.HORIZONTAL + assert response.data['label_position'] == LabelPosition.LEFT + assert response.data['task'] is None + + assert len(response.data['rules']) == 1 + rules_data = response.data['rules'] + assert rules_data[0]['id'] == rule.id + 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(response.data['fields']) == 1 + fields_data = response.data['fields'] + assert fields_data[0]['name'] == field.name + assert fields_data[0]['type'] == field.type + assert fields_data[0]['order'] == 1 + 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_retrieve__task_fieldset__ok(api_client): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + template_task = template.tasks.get(number=1) + create_test_fieldset_template( + account=account, + template=template, + task=template_task, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=template_task, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/templates/fieldsets/{fieldset.id}') + + # assert + assert response.status_code == 200 + assert response.data['id'] == fieldset.id + assert response.data['task'] == template_task.api_name + + +def test_retrieve__fieldset_rule_with_fields__ok(api_client): + """Retrieve existing fieldset returning rule mapping fields""" + + # arrange + account_1 = create_test_account(name='Account 1') + user_1 = create_test_owner(account=account_1) + template_1 = create_test_template( + user=user_1, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account_1, + template=template_1, + kickoff=template_1.kickoff_instance, + 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'/templates/fieldsets/{fieldset.id}') + + # assert + assert response.status_code == 200 + assert response.data['id'] == fieldset.id + + assert len(response.data['rules']) == 1 + rules_data = response.data['rules'] + assert rules_data[0]['id'] == rule.id + assert rules_data[0]['fields'] == [field.api_name] + + +def test_retrieve__unauthenticated__unauthorized(api_client): + """Unauthenticated request returns 401""" + + # 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, + ) + + # act + response = api_client.get(f'/templates/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/templates/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/templates/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() + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/templates/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() + owner = create_test_owner(account=account) + template = create_test_template( + user=owner, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + user = create_test_not_admin(account=account) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/templates/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'/templates/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') + owner_1 = create_test_owner(account=account_1) + template_1 = create_test_template( + user=owner_1, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account_1, + template=template_1, + kickoff=template_1.kickoff_instance, + ) + + 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'/templates/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_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_fields.py b/backend/src/processes/tests/test_views/test_templates/test_fields.py index fd6322286..667191331 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 @@ -21,6 +21,7 @@ create_test_owner, create_test_template, create_test_workflow, + create_test_fieldset_template, ) pytestmark = pytest.mark.django_db @@ -63,6 +64,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 @@ -78,6 +80,7 @@ def test_fields__active_template__ok(api_client): 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 +159,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 +523,50 @@ 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 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + ) + 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 data['kickoff']['fieldsets'] == [fieldset.id] + + +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 = create_test_fieldset_template( + account=account, + template=template, + task=task, + ) + 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 data['tasks'][0]['fieldsets'] == [fieldset.id] 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_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_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..4e5c1ce45 100644 --- a/backend/src/processes/urls/templates.py +++ b/backend/src/processes/urls/templates.py @@ -1,6 +1,9 @@ from django.urls import path from rest_framework.routers import DefaultRouter +from src.processes.views.fieldset import ( + FieldsetTemplateViewSet, +) from src.processes.views.public.template import ( PublicTemplateViewSet, ) @@ -13,6 +16,7 @@ ) from src.processes.views.template_preset import TemplatePresetViewSet + router = DefaultRouter(trailing_slash=False) router.register( prefix='system', @@ -34,6 +38,11 @@ viewset=TemplatePresetViewSet, basename='presets', ) +router.register( + prefix='fieldsets', + viewset=FieldsetTemplateViewSet, + basename='fieldsets', +) urlpatterns = [ path('public', PublicTemplateViewSet.as_view({'get': 'retrieve'})), path('public/run', PublicTemplateViewSet.as_view({'post': 'run'})), diff --git a/backend/src/processes/views/fieldset.py b/backend/src/processes/views/fieldset.py new file mode 100644 index 000000000..6e4cf0688 --- /dev/null +++ b/backend/src/processes/views/fieldset.py @@ -0,0 +1,97 @@ +from rest_framework.viewsets import GenericViewSet + +from src.accounts.permissions import ( + BillingPlanPermission, + ExpiredSubscriptionPermission, + UserIsAdminOrAccountOwner, + UsersOverlimitedPermission, +) +from src.generics.exceptions import BaseServiceException +from src.generics.mixins.views import CustomViewSetMixin +from src.generics.permissions import UserIsAuthenticated +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, +) +from src.processes.serializers.templates.fieldset import ( + FieldsetTemplateSerializer, +) +from src.processes.services.templates.fieldsets.fieldset import ( + FieldSetTemplateService, +) +from src.utils.validation import raise_validation_error + + +class FieldsetTemplateViewSet( + CustomViewSetMixin, + GenericViewSet, +): + serializer_class = FieldsetTemplateSerializer + + 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') + .on_account(user.account_id) + ) + + 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 = FieldsetTemplateSerializer(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..223fcfe4a 100644 --- a/backend/src/processes/views/template.py +++ b/backend/src/processes/views/template.py @@ -78,6 +78,14 @@ TemplateServiceException, WorkflowServiceException, ) +from src.generics.exceptions import BaseServiceException +from src.processes.serializers.templates.fieldset import ( + FieldsetTemplateSerializer, +) +from src.processes.models.templates.fieldset import FieldsetTemplate +from src.processes.services.templates.fieldsets.fieldset import ( + FieldSetTemplateService, +) from src.processes.services.templates.ai import ( OpenAiService, ) @@ -127,6 +135,8 @@ class TemplateViewSet( 'fields': TemplateOnlyFieldsSerializer, 'presets': TemplatePresetSerializer, 'preset': TemplatePresetSerializer, + 'list_fieldsets': FieldsetTemplateSerializer, + 'create_fieldset': FieldsetTemplateSerializer, } def get_permissions(self): @@ -136,6 +146,7 @@ def get_permissions(self): 'destroy', 'discard_changes', 'preset', + 'create_fieldset', ): return ( UserIsAuthenticated(), @@ -153,7 +164,7 @@ def get_permissions(self): UsersOverlimitedPermission(), TemplateAccessPermission(), ) - if self.action == 'retrieve': + if self.action in ('retrieve', 'list_fieldsets'): return ( UserIsAuthenticated(), ExpiredSubscriptionPermission(), @@ -242,6 +253,7 @@ def prefetch_queryset( 'kickoff', 'kickoff__fields', 'kickoff__fields__selections', + 'kickoff__fieldsets', Prefetch('owners', queryset=owners_qs), Prefetch( lookup='tasks', @@ -251,6 +263,7 @@ def prefetch_queryset( .prefetch_related( 'fields', 'fields__selections', + 'fieldsets', 'checklists', 'checklists__selections', 'conditions', @@ -275,6 +288,7 @@ def prefetch_queryset( .order_by('-order') ), ), + 'fieldsets', ) ), ), @@ -291,6 +305,7 @@ def prefetch_queryset( .order_by('-order') ), ), + 'fieldsets', ) ), ), @@ -743,6 +758,37 @@ def preset(self, request, *args, **kwargs): return self.response_ok(self.get_serializer(preset).data) + @action(methods=['GET'], detail=True, url_path='fieldsets') + def list_fieldsets(self, request, *args, **kwargs): + template = self.get_object() + queryset = FieldsetTemplate.objects.on_account( + request.user.account_id, + ).filter(template_id=template.id) + return self.paginated_response(queryset) + + @list_fieldsets.mapping.post + def create_fieldset(self, request, *args, **kwargs): + template = self.get_object() + serializer = self.get_serializer( + data=request.data, + extra_fields={'template': template}, + ) + 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( + template_id=template.id, + **serializer.validated_data, + ) + except BaseServiceException as ex: + raise_validation_error(message=ex.message) + response_serializer = FieldsetTemplateSerializer(fieldset) + return self.response_created(response_serializer.data) + class TemplateIntegrationsViewSet( CustomViewSetMixin, diff --git a/backend/src/reports/tests/test_views/test_highlights.py b/backend/src/reports/tests/test_views/test_highlights.py index de83c5c33..96d28be38 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, ) @@ -1554,3 +1555,167 @@ def test_complete_task_event__kickoff_field_with_dataset__ok(api_client): 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 + 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_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 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 b7704164d..385984535 100644 --- a/backend/src/settings.py +++ b/backend/src/settings.py @@ -383,10 +383,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'), }, } @@ -533,18 +533,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/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" From 21797abc8c13514c72b76c19f5e7230c498d23fa Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 28 Apr 2026 01:24:00 +0500 Subject: [PATCH 02/46] 45773 feat(fieldsets): add ordering parameter to the GET /templates/id/fieldsets endpoint --- backend/src/processes/filters.py | 35 +- .../migrations/0250_add_fieldsets.py | 1 + .../processes/models/templates/fieldset.py | 3 + .../serializers/templates/template.py | 19 + .../test_views/test_fieldsets/test_list.py | 438 ++++++++++++++++++ backend/src/processes/views/template.py | 26 +- 6 files changed, 499 insertions(+), 23 deletions(-) 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/migrations/0250_add_fieldsets.py b/backend/src/processes/migrations/0250_add_fieldsets.py index b1e5306bc..d3a3fd8b8 100644 --- a/backend/src/processes/migrations/0250_add_fieldsets.py +++ b/backend/src/processes/migrations/0250_add_fieldsets.py @@ -64,6 +64,7 @@ class Migration(migrations.Migration): ('kickoff', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='fieldsets', to='processes.Kickoff')), ('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='fieldsets', to='processes.TaskTemplate')), ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Template')), + ('date_created', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now)) ], options={ 'ordering': ['-id'], diff --git a/backend/src/processes/models/templates/fieldset.py b/backend/src/processes/models/templates/fieldset.py index 7463039ec..091af888d 100644 --- a/backend/src/processes/models/templates/fieldset.py +++ b/backend/src/processes/models/templates/fieldset.py @@ -35,6 +35,9 @@ class Meta: api_name_prefix = 'fieldset' + date_created = models.DateTimeField( + auto_now_add=True, + ) template = models.ForeignKey( Template, on_delete=models.CASCADE, diff --git a/backend/src/processes/serializers/templates/template.py b/backend/src/processes/serializers/templates/template.py index 106b2209f..7662e37f7 100644 --- a/backend/src/processes/serializers/templates/template.py +++ b/backend/src/processes/serializers/templates/template.py @@ -1060,3 +1060,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/tests/test_views/test_fieldsets/test_list.py b/backend/src/processes/tests/test_views/test_fieldsets/test_list.py index 1aa6788d4..5240b4c16 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_list.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_list.py @@ -1,3 +1,4 @@ + import pytest from datetime import timedelta @@ -17,6 +18,8 @@ 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 @@ -467,3 +470,438 @@ def test_list_fieldsets__not_existing_tpl__not_found(api_client): # assert assert response.status_code == 404 + + +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) + template = create_test_template( + user=user, + tasks_count=1, + ) + kickoff = template.kickoff_instance + now = timezone.now() + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Oldest', + ) + FieldsetTemplate.objects.filter(id=fieldset_1.id).update( + date_created=now - timedelta(days=2), + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Middle', + ) + FieldsetTemplate.objects.filter(id=fieldset_2.id).update( + date_created=now - timedelta(days=1), + ) + fieldset_3 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Newest', + ) + FieldsetTemplate.objects.filter(id=fieldset_3.id).update( + date_created=now, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + kickoff = template.kickoff_instance + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Alpha', + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Beta', + ) + fieldset_3 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Gamma', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + kickoff = template.kickoff_instance + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Alpha', + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Beta', + ) + fieldset_3 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Gamma', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + kickoff = template.kickoff_instance + now = timezone.now() + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Oldest', + ) + FieldsetTemplate.objects.filter(id=fieldset_1.id).update( + date_created=now - timedelta(days=2), + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Middle', + ) + FieldsetTemplate.objects.filter(id=fieldset_2.id).update( + date_created=now - timedelta(days=1), + ) + fieldset_3 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Newest', + ) + FieldsetTemplate.objects.filter(id=fieldset_3.id).update( + date_created=now, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + kickoff = template.kickoff_instance + now = timezone.now() + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Oldest', + ) + FieldsetTemplate.objects.filter(id=fieldset_1.id).update( + date_created=now - timedelta(days=2), + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Middle', + ) + FieldsetTemplate.objects.filter(id=fieldset_2.id).update( + date_created=now - timedelta(days=1), + ) + fieldset_3 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Newest', + ) + FieldsetTemplate.objects.filter(id=fieldset_3.id).update( + date_created=now, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + kickoff = template.kickoff_instance + create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='First', + ) + create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Second', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + kickoff = template.kickoff_instance + create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='First', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + kickoff = template.kickoff_instance + now = timezone.now() + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='First', + ) + FieldsetTemplate.objects.filter(id=fieldset_1.id).update( + date_created=now - timedelta(days=1), + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Second', + ) + FieldsetTemplate.objects.filter(id=fieldset_2.id).update( + date_created=now, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/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) + template = create_test_template( + user=user, + tasks_count=1, + ) + kickoff = template.kickoff_instance + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Deleted Fieldset', + ) + FieldsetTemplate.objects.filter(id=fieldset.id).update( + is_deleted=True, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/fieldsets', + ) + + # assert + assert response.status_code == 200 + assert len(response.data) == 0 diff --git a/backend/src/processes/views/template.py b/backend/src/processes/views/template.py index 223fcfe4a..8a13ad14c 100644 --- a/backend/src/processes/views/template.py +++ b/backend/src/processes/views/template.py @@ -22,7 +22,9 @@ 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 @@ -64,6 +66,7 @@ TemplateTitlesByTasksSerializer, TemplateTitlesByWorkflowsSerializer, TemplateTitlesSerializer, + FieldsetTemplateFilterSerializer, ) from src.processes.serializers.workflows.workflow import ( WorkflowCreateSerializer, @@ -118,8 +121,6 @@ class TemplateViewSet( GenericViewSet, ): pagination_class = LimitOffsetPagination - filter_backends = (PneumaticFilterBackend,) - filterset_class = TemplateFilter serializer_class = TemplateSerializer action_serializer_classes = { 'list': TemplateListSerializer, @@ -138,6 +139,9 @@ class TemplateViewSet( 'list_fieldsets': FieldsetTemplateSerializer, 'create_fieldset': FieldsetTemplateSerializer, } + action_filterset_classes = { + 'list_fieldsets': FieldSetFilter, + } def get_permissions(self): if self.action in ( @@ -436,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) @@ -761,9 +766,18 @@ def preset(self, request, *args, **kwargs): @action(methods=['GET'], detail=True, url_path='fieldsets') def list_fieldsets(self, request, *args, **kwargs): template = self.get_object() - queryset = FieldsetTemplate.objects.on_account( - request.user.account_id, - ).filter(template_id=template.id) + filter_slz = FieldsetTemplateFilterSerializer(data=request.GET) + filter_slz.is_valid(raise_exception=True) + queryset = ( + FieldsetTemplate.objects + .on_account(request.user.account_id) + .filter(template_id=template.id) + ) + queryset = PneumaticFilterBackend().filter_queryset( + queryset=queryset, + request=request, + view=self, + ) return self.paginated_response(queryset) @list_fieldsets.mapping.post From 5496bf686a22d908c233006823f178972a8a83f6 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 28 Apr 2026 01:56:22 +0500 Subject: [PATCH 03/46] 45773 feat(fieldsets): remove "order" field from fieldsets api --- .../serializers/templates/fieldset.py | 3 - .../test_views/test_fieldsets/test_create.py | 37 +----------- .../test_views/test_fieldsets/test_list.py | 59 ------------------- .../test_fieldsets/test_partial_update.py | 7 --- .../test_fieldsets/test_retrieve.py | 5 -- 5 files changed, 1 insertion(+), 110 deletions(-) diff --git a/backend/src/processes/serializers/templates/fieldset.py b/backend/src/processes/serializers/templates/fieldset.py index c43ea81ee..38cefe712 100644 --- a/backend/src/processes/serializers/templates/fieldset.py +++ b/backend/src/processes/serializers/templates/fieldset.py @@ -25,14 +25,12 @@ class FieldsetTemplateRuleSerializer( class Meta: model = FieldsetTemplateRule fields = ( - 'id', 'type', 'value', 'api_name', 'fields', ) - id = IntegerField(required=False) api_name = CharField(required=False, max_length=200) fields = RelatedApiNameListField( required=False, @@ -52,7 +50,6 @@ class Meta: 'id', 'name', 'description', - 'order', 'task', 'label_position', 'layout', 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 index 9724bdfbc..6d8eb8364 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_create.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_create.py @@ -52,7 +52,6 @@ def test_create_fieldset__all_fields__ok(api_client, mocker): data = { 'name': 'All Fields Fieldset', 'description': 'Description', - 'order': 2, 'task': task.api_name, 'label_position': LabelPosition.LEFT, 'layout': FieldSetLayout.HORIZONTAL, @@ -81,7 +80,6 @@ def test_create_fieldset__all_fields__ok(api_client, mocker): task=task, name=data['name'], description=data['description'], - order=data['order'], label_position=data['label_position'], layout=data['layout'], api_name=data['api_name'], @@ -128,7 +126,6 @@ def test_create_fieldset__all_fields__ok(api_client, mocker): assert response.data['id'] == fieldset.id assert response.data['name'] == data['name'] assert response.data['description'] == data['description'] - assert response.data['order'] == data['order'] assert response.data['task'] == task.api_name assert response.data['label_position'] == data['label_position'] assert response.data['layout'] == data['layout'] @@ -150,7 +147,6 @@ def test_create_fieldset__all_fields__ok(api_client, mocker): fieldset_service_create_mock.assert_called_once_with( template_id=template.id, name=data['name'], - order=data['order'], description=data['description'], layout=data['layout'], label_position=data['label_position'], @@ -174,7 +170,6 @@ def test_create_fieldset__with_kickoff_id__ok(api_client, mocker): ) data = { 'name': 'Kickoff Fieldset', - 'order': 1, } fieldset = FieldsetTemplate.objects.create( @@ -182,7 +177,6 @@ def test_create_fieldset__with_kickoff_id__ok(api_client, mocker): template=template, kickoff=template.kickoff_instance, name=data['name'], - order=data['order'], ) fieldset_service_init_mock = mocker.patch.object( @@ -214,7 +208,6 @@ def test_create_fieldset__with_kickoff_id__ok(api_client, mocker): fieldset_service_create_mock.assert_called_once_with( template_id=template.id, name=data['name'], - order=data['order'], rules=[], fields=[], ) @@ -233,7 +226,6 @@ def test_create_fieldset__with_task__ok(api_client, mocker): task = template.tasks.first() data = { 'name': 'Task Fieldset', - 'order': 1, 'task': task.api_name, } @@ -242,7 +234,6 @@ def test_create_fieldset__with_task__ok(api_client, mocker): template=template, task=task, name=data['name'], - order=data['order'], ) fieldset_service_init_mock = mocker.patch.object( FieldSetTemplateService, @@ -278,7 +269,6 @@ def test_create_fieldset__with_task__ok(api_client, mocker): template_id=template.id, task_id=task.id, name=data['name'], - order=data['order'], rules=[], fields=[], ) @@ -297,7 +287,6 @@ def test_create_fieldset__min_data__ok(api_client, mocker): ) data = { 'name': 'Minimal Fieldset', - 'order': 1, } # mock FieldSetTemplateService @@ -310,7 +299,6 @@ def test_create_fieldset__min_data__ok(api_client, mocker): account=account, template=template, name='Minimal Fieldset', - order=1, ) fieldset_service_create_mock = mocker.patch( 'src.processes.views.template.FieldSetTemplateService.create', @@ -335,7 +323,6 @@ def test_create_fieldset__min_data__ok(api_client, mocker): fieldset_service_create_mock.assert_called_once_with( template_id=template.id, name='Minimal Fieldset', - order=1, rules=[], fields=[], ) @@ -352,7 +339,6 @@ def test_create_fieldset__set_api_name__ok(api_client, mocker): ) data = { 'name': 'Minimal Fieldset', - 'order': 1, 'api_name': 'fs1', } @@ -366,7 +352,6 @@ def test_create_fieldset__set_api_name__ok(api_client, mocker): account=account, template=template, name='Minimal Fieldset', - order=1, ) fieldset_service_create_mock = mocker.patch( 'src.processes.views.template.FieldSetTemplateService.create', @@ -392,7 +377,6 @@ def test_create_fieldset__set_api_name__ok(api_client, mocker): template_id=template.id, name=data['name'], api_name=data['api_name'], - order=data['order'], rules=[], fields=[], ) @@ -415,7 +399,6 @@ def test_create_fieldset__rule_with_one_field__ok(api_client, mocker): field_api_name = 'f1' data = { 'name': 'All Fields Fieldset', - 'order': 1, 'task': task.api_name, 'fields': [ { @@ -452,7 +435,6 @@ def test_create_fieldset__rule_with_one_field__ok(api_client, mocker): template=template, task=task, name=data['name'], - order=data['order'], ) field = FieldTemplate.objects.create( account=account, @@ -496,7 +478,6 @@ def test_create_fieldset__rule_with_one_field__ok(api_client, mocker): template_id=template.id, task_id=task.id, name=data['name'], - order=data['order'], rules=data['rules'], fields=data['fields'], ) @@ -520,7 +501,6 @@ def test_create_fieldset__rule_with_two_fields__ok(api_client, mocker): field_2_api_name = 'f2' data = { 'name': 'All Fields Fieldset', - 'order': 1, 'fields': [ { 'name': 'Field 1', @@ -556,7 +536,6 @@ def test_create_fieldset__rule_with_two_fields__ok(api_client, mocker): template=template, kickoff=kickoff, name=data['name'], - order=data['order'], ) field_1 = FieldTemplate.objects.create( account=account, @@ -608,7 +587,6 @@ def test_create_fieldset__rule_with_two_fields__ok(api_client, mocker): fieldset_service_create_mock.assert_called_once_with( template_id=template.id, name=data['name'], - order=data['order'], rules=data['rules'], fields=data['fields'], ) @@ -634,7 +612,6 @@ def test_create_fieldset_another_template_task__validation_error( another_template_task = another_template.tasks.get(number=1) data = { 'name': 'Task Fieldset', - 'order': 1, 'task': another_template_task.api_name, } fieldset_service_init_mock = mocker.patch.object( @@ -678,7 +655,6 @@ def test_create_fieldset__unauthenticated__unauthorized(api_client, mocker): ) data = { 'name': 'New Fieldset', - 'order': 1, } fieldset_service_init_mock = mocker.patch.object( @@ -718,7 +694,6 @@ def test_create_fieldset__expired_sub__permission_denied(api_client, mocker): ) data = { 'name': 'New Fieldset', - 'order': 1, } fieldset_service_init_mock = mocker.patch.object( @@ -758,7 +733,6 @@ def test_create_fieldset__billing_plan__permission_denied(api_client, mocker): ) data = { 'name': 'New Fieldset', - 'order': 1, } fieldset_service_init_mock = mocker.patch.object( FieldSetTemplateService, @@ -805,7 +779,6 @@ def test_create_fieldset__users_limit__permission_denied(api_client, mocker): ) data = { 'name': 'New Fieldset', - 'order': 1, } fieldset_service_init_mock = mocker.patch.object( @@ -846,7 +819,6 @@ def test_create_fieldset__non_admin__permission_denied(api_client, mocker): user = create_test_not_admin(account=account) data = { 'name': 'New Fieldset', - 'order': 1, } fieldset_service_init_mock = mocker.patch.object( FieldSetTemplateService, @@ -885,7 +857,6 @@ def test_create_fieldset__not_tpl_owner__permission_denied(api_client, mocker): user = create_test_admin(account=account) data = { 'name': 'New Fieldset', - 'order': 1, } fieldset_service_init_mock = mocker.patch.object( FieldSetTemplateService, @@ -911,7 +882,7 @@ def test_create_fieldset__not_tpl_owner__permission_denied(api_client, mocker): fieldset_service_create_mock.assert_not_called() -def test_create_fieldset__invalid_name__validation_error(api_client, mocker): +def test_create_fieldset__blank_name__validation_error(api_client, mocker): """ Invalid name field returns validation error """ @@ -923,7 +894,6 @@ def test_create_fieldset__invalid_name__validation_error(api_client, mocker): tasks_count=1, ) data = { - 'order': 1, } fieldset = mocker.Mock(id=1, api_name='dummy') @@ -968,7 +938,6 @@ def test_create_fieldset__invalid_layout__validation_error(api_client, mocker): ) data = { 'name': 'Test Fieldset', - 'order': 1, 'layout': 'invalid_layout', } fieldset_service_init_mock = mocker.patch.object( @@ -1013,7 +982,6 @@ def test_create_fieldset__invalid_label_position__validation_error( ) data = { 'name': 'Test Fieldset', - 'order': 1, 'label_position': 'invalid_position', } fieldset_service_init_mock = mocker.patch.object( @@ -1057,7 +1025,6 @@ def test_create_fieldset__service_exception__validation_error( ) data = { 'name': 'Test Fieldset', - 'order': 1, } error_message = 'Service error occurred' @@ -1091,7 +1058,6 @@ def test_create_fieldset__service_exception__validation_error( fieldset_service_create_mock.assert_called_once_with( template_id=template.id, name='Test Fieldset', - order=1, rules=[], fields=[], ) @@ -1107,7 +1073,6 @@ def test_create_fieldset__not_existing_tpl__not_found(api_client, mocker): nonexistent_id = 999999 data = { 'name': 'New Fieldset', - 'order': 1, } fieldset_service_init_mock = mocker.patch.object( FieldSetTemplateService, 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 index 5240b4c16..51589fc05 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_list.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_list.py @@ -42,7 +42,6 @@ def test_list_fieldsets__all_data__ok(api_client): template=template, kickoff=kickoff, name='Kickoff Fieldset', - order=1, rule_type=rule_type, rule_value=rule_value, ) @@ -64,14 +63,12 @@ def test_list_fieldsets__all_data__ok(api_client): assert item_1['api_name'] == fieldset.api_name assert item_1['name'] == fieldset.name assert item_1['description'] == '' - assert item_1['order'] == fieldset.order assert item_1['layout'] == fieldset.layout assert item_1['label_position'] == fieldset.label_position assert item_1['task'] is None assert len(item_1['rules']) == 1 rules_data = item_1['rules'] - assert rules_data[0]['id'] == rule.id assert rules_data[0]['type'] == rule_type assert rules_data[0]['value'] == rule_value assert rules_data[0]['api_name'] == rule.api_name @@ -80,7 +77,6 @@ def test_list_fieldsets__all_data__ok(api_client): fields_data = item_1['fields'] assert fields_data[0]['name'] == field.name assert fields_data[0]['type'] == field.type - assert fields_data[0]['order'] == 1 assert fields_data[0]['api_name'] == field.api_name assert fields_data[0]['description'] == '' assert fields_data[0]['is_required'] is False @@ -90,54 +86,6 @@ def test_list_fieldsets__all_data__ok(api_client): assert 'selections' not in fields_data[0] -def test_list_fieldsets__two_fieldsets__ok(api_client): - """List fieldsets for existing template""" - - # 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_1 = create_test_fieldset_template( - account=account, - template=template, - kickoff=kickoff, - order=1, - ) - template_task = template.tasks.first() - fieldset_2 = create_test_fieldset_template( - account=account, - template=template, - task=template_task, - order=2, - ) - - api_client.token_authenticate(user=user) - - # act - response = api_client.get( - f'/templates/{template.id}/fieldsets', - ) - - # assert - assert response.status_code == 200 - assert len(response.data) == 2 - - # ordered by -id (newest first) - item_1 = response.data[0] - assert item_1['id'] == fieldset_2.id - assert item_1['task'] == template_task.api_name - assert item_1['order'] == 2 - - item_2 = response.data[1] - assert item_2['id'] == fieldset_1.id - assert item_2['task'] is None - assert item_2['order'] == 1 - - def test_list_fieldsets__pagination__ok(api_client): """List fieldsets for existing template""" @@ -153,19 +101,16 @@ def test_list_fieldsets__pagination__ok(api_client): account=account, template=template, task=template_task, - order=3, ) fieldset_2 = create_test_fieldset_template( account=account, template=template, task=template_task, - order=2, ) create_test_fieldset_template( account=account, template=template, task=template_task, - order=1, ) api_client.token_authenticate(user=user) @@ -184,12 +129,10 @@ def test_list_fieldsets__pagination__ok(api_client): item_1 = response.data['results'][0] assert item_1['id'] == fieldset_2.id assert item_1['task'] == template_task.api_name - assert item_1['order'] == 2 item_2 = response.data['results'][1] assert item_2['id'] == fieldset_1.id assert item_2['task'] == template_task.api_name - assert item_2['order'] == 3 def test_list_fieldsets__different_accounts__ok(api_client): @@ -293,7 +236,6 @@ def test_list_fieldsets__rule_with_fields__ok(api_client): template=template, kickoff=kickoff, name='Kickoff Fieldset', - order=1, rule_type=rule_type, rule_value=rule_value, ) @@ -315,7 +257,6 @@ def test_list_fieldsets__rule_with_fields__ok(api_client): assert len(item_1['rules']) == 1 rules_data = item_1['rules'] - assert rules_data[0]['id'] == rule.id assert rules_data[0]['fields'] == [field.api_name] 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 index a21b88a30..b00f31e34 100644 --- 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 @@ -46,7 +46,6 @@ def test_partial_update__all_fields__ok(api_client, mocker): 'name': 'Full Updated Fieldset', 'description': 'Updated description', 'api_name': fieldset_api_name, - 'order': 10, 'layout': FieldSetLayout.HORIZONTAL, 'label_position': LabelPosition.LEFT, 'fields': [ @@ -59,7 +58,6 @@ def test_partial_update__all_fields__ok(api_client, mocker): ], 'rules': [ { - 'id': 123, 'type': FieldSetRuleType.SUM_EQUAL, 'value': '10', 'api_name': 'r1', @@ -72,7 +70,6 @@ def test_partial_update__all_fields__ok(api_client, mocker): template=template, name=data['name'], description=data['description'], - order=data['order'], label_position=data['label_position'], layout=data['layout'], kickoff=template.kickoff_instance, @@ -105,7 +102,6 @@ def test_partial_update__all_fields__ok(api_client, mocker): assert response.data['id'] == fieldset.id assert response.data['name'] == data['name'] assert response.data['description'] == data['description'] - assert response.data['order'] == data['order'] assert response.data['task'] is None assert response.data['label_position'] == data['label_position'] assert response.data['layout'] == data['layout'] @@ -130,7 +126,6 @@ def test_partial_update__all_fields__ok(api_client, mocker): name='Full Updated Fieldset', api_name=fieldset_api_name, description='Updated description', - order=10, layout=FieldSetLayout.HORIZONTAL, label_position=LabelPosition.LEFT, rules=data['rules'], @@ -320,7 +315,6 @@ def test_partial_update__with_rule_fields__ok(api_client, mocker): ], 'rules': [ { - 'id': 123, 'type': FieldSetRuleType.SUM_EQUAL, 'value': '10', 'api_name': 'r1', @@ -399,7 +393,6 @@ def test_partial_update__clear_fields__ok(api_client, mocker): 'name': 'Updated Fieldset', 'rules': [ { - 'id': 123, 'type': FieldSetRuleType.SUM_EQUAL, 'value': '10', 'api_name': 'r1', 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 index 506462485..f74c34826 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py @@ -39,7 +39,6 @@ def test_retrieve__fieldset_all_data__ok(api_client): kickoff=template.kickoff_instance, name='My Fieldset', description='Fieldset description', - order=3, layout=FieldSetLayout.HORIZONTAL, label_position=LabelPosition.LEFT, rule_type=rule_type, @@ -59,14 +58,12 @@ def test_retrieve__fieldset_all_data__ok(api_client): assert response.data['api_name'] == fieldset.api_name assert response.data['name'] == 'My Fieldset' assert response.data['description'] == 'Fieldset description' - assert response.data['order'] == 3 assert response.data['layout'] == FieldSetLayout.HORIZONTAL assert response.data['label_position'] == LabelPosition.LEFT assert response.data['task'] is None assert len(response.data['rules']) == 1 rules_data = response.data['rules'] - assert rules_data[0]['id'] == rule.id assert rules_data[0]['type'] == rule_type assert rules_data[0]['value'] == rule_value assert rules_data[0]['api_name'] == rule.api_name @@ -75,7 +72,6 @@ def test_retrieve__fieldset_all_data__ok(api_client): fields_data = response.data['fields'] assert fields_data[0]['name'] == field.name assert fields_data[0]['type'] == field.type - assert fields_data[0]['order'] == 1 assert fields_data[0]['api_name'] == field.api_name assert fields_data[0]['description'] == '' assert fields_data[0]['is_required'] is False @@ -149,7 +145,6 @@ def test_retrieve__fieldset_rule_with_fields__ok(api_client): assert len(response.data['rules']) == 1 rules_data = response.data['rules'] - assert rules_data[0]['id'] == rule.id assert rules_data[0]['fields'] == [field.api_name] From 03e6896565155e5b1f9e4e21161b894d2c9f7cc0 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 28 Apr 2026 14:56:18 +0500 Subject: [PATCH 04/46] 45773 feat(fieldsets): add an m2m relation betweet TaskTemplate, Kickoff and FieldSetTemplate models --- backend/src/processes/messages/fieldset.py | 3 + .../migrations/0250_add_fieldsets.py | 85 +- backend/src/processes/models/mixins.py | 1 - .../processes/models/templates/fieldset.py | 73 +- .../processes/models/templates/template.py | 26 +- .../processes/models/workflows/fieldset.py | 1 + backend/src/processes/querysets.py | 10 + .../serializers/templates/fieldset.py | 29 +- .../processes/serializers/templates/task.py | 1 - .../serializers/workflows/kickoff_value.py | 10 +- backend/src/processes/services/tasks/task.py | 11 +- .../services/templates/fieldsets/fieldset.py | 34 +- .../services/workflows/fieldsets/fieldset.py | 13 +- backend/src/processes/tests/fixtures.py | 19 +- .../test_tasks/test_task_service.py | 848 ++++++++++++++++++ .../test_fieldset_template_rule_service.py | 25 +- .../test_fieldset_template_service.py | 241 +---- .../test_fieldset_rule_service.py | 1 - .../test_workflows/test_fieldset_service.py | 37 +- .../test_views/test_fieldsets/test_create.py | 179 +--- .../test_views/test_fieldsets/test_list.py | 102 ++- .../test_fieldsets/test_partial_update.py | 258 +----- .../test_fieldsets/test_retrieve.py | 92 +- .../test_views/test_templates/test_run.py | 432 ++++++++- .../test_views/test_templates/test_steps.py | 16 +- 25 files changed, 1669 insertions(+), 878 deletions(-) diff --git a/backend/src/processes/messages/fieldset.py b/backend/src/processes/messages/fieldset.py index 5c8e7d8f7..d13c9c79e 100644 --- a/backend/src/processes/messages/fieldset.py +++ b/backend/src/processes/messages/fieldset.py @@ -20,3 +20,6 @@ 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.', +) diff --git a/backend/src/processes/migrations/0250_add_fieldsets.py b/backend/src/processes/migrations/0250_add_fieldsets.py index d3a3fd8b8..440d8723e 100644 --- a/backend/src/processes/migrations/0250_add_fieldsets.py +++ b/backend/src/processes/migrations/0250_add_fieldsets.py @@ -1,6 +1,5 @@ -# Generated by Django 2.2 on 2026-04-13 23:55 +# Generated by Django 2.2 on 2026-04-28 09:42 -import django.contrib.postgres.fields from django.db import migrations, models import django.db.models.deletion import src.generics.mixins.models @@ -23,10 +22,12 @@ class Migration(migrations.Migration): ('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='')), - ('order', models.IntegerField(default=0)), ('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'], @@ -58,13 +59,10 @@ class Migration(migrations.Migration): ('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='')), - ('order', models.IntegerField(default=0)), ('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')), - ('kickoff', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='fieldsets', to='processes.Kickoff')), - ('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='fieldsets', to='processes.TaskTemplate')), ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Template')), - ('date_created', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now)) ], options={ 'ordering': ['-id'], @@ -87,36 +85,69 @@ class Migration(migrations.Migration): }, bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model), ), - 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.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='fieldtemplate', - name='rules', - field=models.ManyToManyField(blank=True, related_name='fields', to='processes.FieldsetTemplateRule'), + model_name='fieldsettemplate', + name='kickoffs', + field=models.ManyToManyField(blank=True, related_name='fieldsets', through='processes.FieldsetTemplateKickoff', to='processes.Kickoff'), ), - migrations.AlterField( - model_name='task', - name='parents', - field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=200), default=list, help_text='Api names of task parents', size=None), + migrations.AddConstraint( + model_name='fieldsettemplate', + constraint=models.UniqueConstraint( + condition=models.Q(is_deleted=False), + fields=('account', 'api_name'), + name='fieldsettemplate_account_api_name_unique'), ), migrations.AddField( - model_name='fieldset', - name='task', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Task'), + model_name='fieldsettemplate', + name='tasks', + field=models.ManyToManyField(blank=True, related_name='fieldsets', through='processes.FieldsetTemplateTaskTemplate', to='processes.TaskTemplate'), ), - migrations.AddField( - model_name='fieldset', - name='workflow', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Workflow'), + 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', @@ -127,8 +158,4 @@ class Migration(migrations.Migration): name='rules', field=models.ManyToManyField(blank=True, related_name='fields', to='processes.FieldSetRule'), ), - migrations.AddConstraint( - model_name='fieldsettemplate', - constraint=models.UniqueConstraint(condition=models.Q(is_deleted=False), fields=('account', 'api_name'), name='fieldsettemplate_account_api_name_unique'), - ), ] diff --git a/backend/src/processes/models/mixins.py b/backend/src/processes/models/mixins.py index cf262ffd1..f4f6eb11d 100644 --- a/backend/src/processes/models/mixins.py +++ b/backend/src/processes/models/mixins.py @@ -338,7 +338,6 @@ class Meta: name = models.TextField(max_length=1000) description = models.TextField(blank=True, default='') - order = models.IntegerField(default=0) layout = models.CharField( max_length=200, choices=FieldSetLayout.CHOICES, diff --git a/backend/src/processes/models/templates/fieldset.py b/backend/src/processes/models/templates/fieldset.py index 091af888d..d92686470 100644 --- a/backend/src/processes/models/templates/fieldset.py +++ b/backend/src/processes/models/templates/fieldset.py @@ -3,6 +3,7 @@ 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, @@ -14,6 +15,7 @@ from src.processes.querysets import ( FieldsetTemplateQuerySet, FieldsetTemplateRuleQuerySet, + FieldsetTemplateTaskTemplateQuerySet, FieldsetTemplateKickoffQuerySet, ) @@ -43,18 +45,16 @@ class Meta: on_delete=models.CASCADE, related_name='fieldsets', ) - task = models.ForeignKey( + tasks = models.ManyToManyField( TaskTemplate, - on_delete=models.SET_NULL, + through='FieldsetTemplateTaskTemplate', related_name='fieldsets', - null=True, blank=True, ) - kickoff = models.ForeignKey( + kickoffs = models.ManyToManyField( Kickoff, - on_delete=models.SET_NULL, + through='FieldsetTemplateKickoff', related_name='fieldsets', - null=True, blank=True, ) @@ -66,6 +66,67 @@ def __str__(self): return self.name +class FieldsetTemplateTaskTemplate(SoftDeleteModel): + + """ + Model for the relationship between + "TaskTemplate" <- m2m -> "FieldsetTemplate" + """ + + 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) + + objects = BaseSoftDeleteManager.from_queryset( + FieldsetTemplateTaskTemplateQuerySet, + )() + + def __str__(self): + return ( + f'{self.fieldset_template} - {self.task_template} ' + f'(order={self.order})' + ) + + +class FieldsetTemplateKickoff(SoftDeleteModel): + + """ + Model for the relationship + "Kickoff" <- m2m -> "FieldsetTemplate" + """ + + 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) + + objects = BaseSoftDeleteManager.from_queryset( + FieldsetTemplateKickoffQuerySet, + )() + + def __str__(self): + return f'{self.fieldset_template} - kickoff (order={self.order})' + + class FieldsetTemplateRule( BaseApiNameModel, BaseFieldSetRuleMixin, diff --git a/backend/src/processes/models/templates/template.py b/backend/src/processes/models/templates/template.py index 6a7f6993d..57584467b 100644 --- a/backend/src/processes/models/templates/template.py +++ b/backend/src/processes/models/templates/template.py @@ -152,7 +152,7 @@ def get_kickoff_output_fields( qst = FieldTemplate.objects.filter( Q( Q(kickoff_id=kickoff.id) | - Q(fieldset__kickoff_id=kickoff.id), + Q(fieldset__kickoffs__id=kickoff.id), ), ) if fields_filter_kwargs: @@ -166,7 +166,13 @@ 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. + + Fields are collected from two sources: + - directly attached to tasks (task FK) + - indirectly via fieldsets linked to tasks through M2M + """ from src.processes.models.templates.fields import FieldTemplate @@ -175,18 +181,18 @@ def get_tasks_output_fields( fields_filter_kwargs = fields_filter_kwargs or {} tasks_filter = {'task__template_id': self.id, **tasks_filter_kwargs} - fieldset_filter = { - f'fieldset__{key}': value - for key, value in tasks_filter.items() - } + fieldset_filter = {'fieldset__tasks__template_id': self.id} + for key, value in tasks_filter_kwargs.items(): + _key = key.replace('task', 'tasks') + fieldset_filter[f'fieldset__{_key}'] = value 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() - } + fieldset_exclude_kwargs = {} + for key, value in tasks_exclude_kwargs.items(): + _key = key.replace('task', 'tasks') + 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)) diff --git a/backend/src/processes/models/workflows/fieldset.py b/backend/src/processes/models/workflows/fieldset.py index 86e08842e..eeaaf2687 100644 --- a/backend/src/processes/models/workflows/fieldset.py +++ b/backend/src/processes/models/workflows/fieldset.py @@ -41,6 +41,7 @@ class Meta: blank=True, related_name='fieldsets', ) + order = models.IntegerField(default=0) objects = BaseSoftDeleteManager.from_queryset(FieldSetQuerySet)() diff --git a/backend/src/processes/querysets.py b/backend/src/processes/querysets.py index ed3e0b7f6..5810b9bab 100644 --- a/backend/src/processes/querysets.py +++ b/backend/src/processes/querysets.py @@ -1270,6 +1270,16 @@ class FieldsetTemplateQuerySet(AccountBaseQuerySet): pass +class FieldsetTemplateTaskTemplateQuerySet(BaseQuerySet): + + pass + + +class FieldsetTemplateKickoffQuerySet(BaseQuerySet): + + pass + + class FieldsetTemplateRuleQuerySet(AccountBaseQuerySet): pass diff --git a/backend/src/processes/serializers/templates/fieldset.py b/backend/src/processes/serializers/templates/fieldset.py index 38cefe712..c13339048 100644 --- a/backend/src/processes/serializers/templates/fieldset.py +++ b/backend/src/processes/serializers/templates/fieldset.py @@ -1,11 +1,10 @@ -from rest_framework.fields import CharField +from rest_framework.fields import CharField, SerializerMethodField from rest_framework.serializers import ( IntegerField, ModelSerializer, ) from src.generics.fields import ( - RelatedApiNameField, RelatedApiNameListField, ) from src.generics.mixins.serializers import CustomValidationErrorMixin @@ -13,8 +12,10 @@ FieldsetTemplate, FieldsetTemplateRule, ) -from src.processes.models.templates.task import TaskTemplate from src.processes.serializers.templates.field import FieldTemplateSerializer +from src.processes.serializers.templates.task import ( + TemplateStepNameSerializer, +) class FieldsetTemplateRuleSerializer( @@ -50,20 +51,16 @@ class Meta: 'id', 'name', 'description', - 'task', 'label_position', 'layout', 'rules', 'fields', 'api_name', + 'tasks', + 'kickoff', ) id = IntegerField(required=False) - task = RelatedApiNameField( - queryset=TaskTemplate.objects.all(), - required=False, - allow_null=True, - ) api_name = CharField(required=False, max_length=200) rules = FieldsetTemplateRuleSerializer( many=True, @@ -75,9 +72,13 @@ class Meta: required=False, default=list, ) + tasks = TemplateStepNameSerializer( + many=True, + read_only=True, + default=list, + ) + kickoff = SerializerMethodField() - def validate(self, attrs): - if 'task' in attrs and attrs['task'] is not None: - task = attrs.pop('task') - attrs['task_id'] = task.id - return attrs + def get_kickoff(self, instance: FieldsetTemplate): + kickoff = instance.kickoffs.all().first() + return kickoff.id if kickoff else None diff --git a/backend/src/processes/serializers/templates/task.py b/backend/src/processes/serializers/templates/task.py index c3be19e4e..b1d15fe94 100644 --- a/backend/src/processes/serializers/templates/task.py +++ b/backend/src/processes/serializers/templates/task.py @@ -600,7 +600,6 @@ class TemplateStepNameSerializer(ModelSerializer): class Meta: model = TaskTemplate fields = ( - 'id', # Deprecated 'name', 'number', 'api_name', diff --git a/backend/src/processes/serializers/workflows/kickoff_value.py b/backend/src/processes/serializers/workflows/kickoff_value.py index c98dd760d..21acdf9e2 100644 --- a/backend/src/processes/serializers/workflows/kickoff_value.py +++ b/backend/src/processes/serializers/workflows/kickoff_value.py @@ -3,6 +3,7 @@ from rest_framework import serializers from src.generics.serializers import CustomValidationErrorMixin +from src.processes.models.templates.fieldset import FieldsetTemplateKickoff from src.processes.models.templates.kickoff import Kickoff from src.processes.models.workflows.fieldset import FieldSet from src.processes.models.workflows.fields import TaskField @@ -89,13 +90,20 @@ def create(self, validated_data: Dict[str, Any]): ) try: for fieldset_template in fieldset_templates: + fieldset_through = ( + FieldsetTemplateKickoff.objects + .get( + fieldset=fieldset_template, + kickoff=kickoff, + ) + ) service = FieldSetService(user=self.context['user']) service.create( instance_template=fieldset_template, account_id=workflow.account_id, workflow=workflow, kickoff=instance, - kickoff_id=instance.id, + order=fieldset_through.order, fields_data=fields_data, ) try: diff --git a/backend/src/processes/services/tasks/task.py b/backend/src/processes/services/tasks/task.py index 04c904a72..64eb684d4 100644 --- a/backend/src/processes/services/tasks/task.py +++ b/backend/src/processes/services/tasks/task.py @@ -13,6 +13,8 @@ from src.processes.models.templates.checklist import ( ChecklistTemplateSelection, ) +from src.processes.models.templates.fieldset import \ + FieldsetTemplateTaskTemplate from src.processes.models.templates.task import TaskTemplate from src.processes.models.workflows.conditions import ( Condition, @@ -220,13 +222,20 @@ def create_fieldsets_from_template( instance_template: TaskTemplate, ): for fs_template in instance_template.fieldsets.all().order_by('id'): + fieldset_through = ( + FieldsetTemplateTaskTemplate.objects + .get( + fieldset=fs_template, + task=instance_template, + ) + ) service = FieldSetService(user=self.user) service.create( instance_template=fs_template, account_id=self.instance.workflow.account_id, workflow=self.instance.workflow, task=self.instance, - task_id=self.instance.id, + order=fieldset_through.order, skip_value=True, ) diff --git a/backend/src/processes/services/templates/fieldsets/fieldset.py b/backend/src/processes/services/templates/fieldsets/fieldset.py index 8bd372bf4..2e91aea73 100644 --- a/backend/src/processes/services/templates/fieldsets/fieldset.py +++ b/backend/src/processes/services/templates/fieldsets/fieldset.py @@ -5,7 +5,6 @@ from src.generics.base.service import BaseModelService from src.processes.enums import LabelPosition, FieldSetLayout from src.processes.models.templates.fieldset import FieldsetTemplate -from src.processes.models.templates.kickoff import Kickoff from src.processes.services.exceptions import ( FieldsetTemplateInUseException, ) @@ -24,12 +23,9 @@ def _create_instance( self, name: str, template_id: int, - order: int = 0, description: str = '', label_position: LabelPosition.LITERALS = LabelPosition.TOP, layout: FieldSetLayout.LITERALS = FieldSetLayout.VERTICAL, - kickoff_id: Optional[int] = None, - task_id: Optional[int] = None, **kwargs, ): self.instance = FieldsetTemplate.objects.create( @@ -37,11 +33,8 @@ def _create_instance( account=self.account, name=name, description=description, - order=order, label_position=label_position, layout=layout, - kickoff_id=kickoff_id, - task_id=task_id, ) return self.instance @@ -56,20 +49,6 @@ def _create_related( if rules: self.create_rules(rules_data=rules) - def create( - self, - **kwargs, - ) -> FieldsetTemplate: - - template_id = kwargs['template_id'] - if kwargs.get('task_id') is None: - # Bind fieldset to the kickoff if task_id is not provided - kwargs['kickoff_id'] = ( - Kickoff.objects.get(template_id=template_id).id - ) - super().create(**kwargs) - return self.instance - def partial_update( self, **update_kwargs, @@ -77,17 +56,6 @@ def partial_update( rules_data = update_kwargs.pop('rules', None) fields_data = update_kwargs.pop('fields', None) - if 'task_id' in update_kwargs: - if update_kwargs['task_id'] is None: - # Unbind fieldset from the task and bind to the kickoff - template_id = self.instance.template_id - update_kwargs['kickoff_id'] = ( - Kickoff.objects.get(template_id=template_id).id - ) - else: - # Unbind fieldset from the kickoff and bind to the task - update_kwargs['kickoff_id'] = None - with transaction.atomic(): if update_kwargs: self.instance = super().partial_update( @@ -113,7 +81,7 @@ def _validate_rules(self): service._validate() def delete(self) -> None: - if self.instance.kickoff or self.instance.task: + if self.instance.kickoffs.exists() or self.instance.tasks.exists(): raise FieldsetTemplateInUseException self.instance.delete() diff --git a/backend/src/processes/services/workflows/fieldsets/fieldset.py b/backend/src/processes/services/workflows/fieldsets/fieldset.py index 7d5b57d84..ac2661e39 100644 --- a/backend/src/processes/services/workflows/fieldsets/fieldset.py +++ b/backend/src/processes/services/workflows/fieldsets/fieldset.py @@ -2,8 +2,10 @@ from django.contrib.auth import get_user_model from src.generics.base.service import BaseModelService +from src.processes.messages.fieldset import MSG_FS_0007 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, @@ -19,15 +21,20 @@ def _create_instance( instance_template: FieldsetTemplate, **kwargs, ): + task = kwargs.get('task') + kickoff = kwargs.get('kickoff') + if not (task or kickoff): + raise FieldsetServiceException(MSG_FS_0007) + self.instance = FieldSet.objects.create( account=self.account, workflow=kwargs['workflow'], - kickoff=kwargs.get('kickoff'), - task=kwargs.get('task'), + kickoff=kickoff, + task=task, api_name=instance_template.api_name, name=instance_template.name, description=instance_template.description, - order=instance_template.order, + order=kwargs['order'], label_position=instance_template.label_position, layout=instance_template.layout, ) diff --git a/backend/src/processes/tests/fixtures.py b/backend/src/processes/tests/fixtures.py index da9a719bf..7a47d047f 100644 --- a/backend/src/processes/tests/fixtures.py +++ b/backend/src/processes/tests/fixtures.py @@ -48,7 +48,9 @@ RuleTemplate, ) from src.processes.models.templates.fieldset import ( - FieldsetTemplate, FieldsetTemplateRule, + FieldsetTemplate, + FieldsetTemplateRule, + FieldsetTemplateTaskTemplate, FieldsetTemplateKickoff, ) from src.processes.models.templates.fields import FieldTemplate from src.processes.models.templates.kickoff import Kickoff @@ -848,15 +850,24 @@ def create_test_fieldset_template( fieldset = FieldsetTemplate.objects.create( account=account, template=template, - kickoff=kickoff, - task=task, name=name, description=description, - order=order, label_position=label_position, layout=layout, api_name=api_name, ) + if task: + FieldsetTemplateTaskTemplate.objects.create( + fieldset=fieldset, + task=task, + order=order, + ) + if kickoff: + FieldsetTemplateKickoff.objects.create( + fieldset=fieldset, + kickoff=kickoff, + order=order, + ) if rule_type: FieldsetTemplateRule.objects.create( fieldset=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 ee6d60685..13c87a5ee 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, @@ -1207,3 +1218,840 @@ 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, + ) diff --git a/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_rule_service.py b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_rule_service.py index 199df8c6d..edcab5a8f 100644 --- a/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_rule_service.py +++ b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_rule_service.py @@ -41,7 +41,6 @@ def test__create_instance__default_params__ok(): template=template, account=account, name='Fieldset', - order=1, ) service = FieldsetTemplateRuleService( user=user, @@ -78,7 +77,6 @@ def test__create_instance__all_params__ok(): template=template, account=account, name='Fieldset', - order=1, ) service = FieldsetTemplateRuleService( user=user, @@ -115,7 +113,6 @@ def test__validate_sum_equal__valid__ok(): template=template, account=account, name='Fieldset', - order=1, ) field = FieldTemplate.objects.create( account=account, @@ -160,7 +157,6 @@ def test__validate_sum_equal__empty_value__raise_exception(): template=template, account=account, name='Fieldset', - order=1, ) rule = FieldsetTemplateRule.objects.create( account=account, @@ -197,7 +193,6 @@ def test__validate_sum_equal__non_numeric__raise_exception(): template=template, account=account, name='Fieldset', - order=1, ) rule = FieldsetTemplateRule.objects.create( account=account, @@ -248,7 +243,6 @@ def test__validate_sum_equal__non_number_type__raise_exception(field_type): template=template, account=account, name='Fieldset', - order=1, ) field = FieldTemplate.objects.create( account=account, @@ -290,7 +284,6 @@ def test__validate__call_method_by_type__ok(mocker): template=template, account=account, name='Fieldset', - order=1, ) rule = FieldsetTemplateRule.objects.create( account=account, @@ -331,7 +324,6 @@ def test_get_valid_fields__all_found__ok(): template=template, account=account, name='Fieldset', - order=1, ) field_1_api_name = 'field_1' field1 = FieldTemplate.objects.create( @@ -385,7 +377,6 @@ def test_get_valid_fields__type_from_kwargs__ok(): template=template, account=account, name='Fieldset', - order=1, ) field_api_name = 'field_1' field = FieldTemplate.objects.create( @@ -431,7 +422,6 @@ def test_get_valid_fields__type_from_instance__ok(): template=template, account=account, name='Fieldset', - order=1, ) field_api_name = 'field_1' field = FieldTemplate.objects.create( @@ -476,7 +466,6 @@ def test_get_valid_fields__one_failed__raise_exception(): template=template, account=account, name='Fieldset', - order=1, ) FieldTemplate.objects.create( account=account, @@ -522,7 +511,6 @@ def test_get_valid_fields__two_failed__raise_exception(): template=template, account=account, name='Fieldset', - order=1, ) service = FieldsetTemplateRuleService( user=user, @@ -540,15 +528,15 @@ def test_get_valid_fields__two_failed__raise_exception(): service._get_valid_fields(['missing1', 'missing2']) # assert - assert ex.value.message == ( + assert ex.value.message in { fs_messages.MSG_FS_0005( rule=FieldSetRuleType.SUM_EQUAL, field='missing1', - ) or fs_messages.MSG_FS_0005( + ), fs_messages.MSG_FS_0005( rule=FieldSetRuleType.SUM_EQUAL, field='missing2', - ) - ) + ), + } def test_set_fields__fields_provided__set_fields(mocker): @@ -565,7 +553,6 @@ def test_set_fields__fields_provided__set_fields(mocker): template=template, account=account, name='Fieldset', - order=1, ) field_api_name = 'num' field = FieldTemplate.objects.create( @@ -616,7 +603,6 @@ def test_set_fields__fields_not_provided__clear_fields(mocker): template=template, account=account, name='Fieldset', - order=1, ) field = FieldTemplate.objects.create( account=account, @@ -748,7 +734,6 @@ def test_create__valid_data__ok(mocker): template=template, account=account, name='Fieldset', - order=1, ) rule = FieldsetTemplateRule.objects.create( account=account, @@ -811,7 +796,6 @@ def test_partial_update__with_fields__ok(mocker): template=template, account=account, name='Fieldset', - order=1, ) rule = FieldsetTemplateRule.objects.create( account=account, @@ -863,7 +847,6 @@ def test_partial_update__without_fields__ok(mocker): template=template, account=account, name='Fieldset', - order=1, ) rule = FieldsetTemplateRule.objects.create( account=account, diff --git a/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py index 67f713bee..115d4a530 100644 --- a/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py +++ b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py @@ -49,19 +49,16 @@ def test__create_instance__default_params__ok(): auth_type=AuthTokenType.USER, ) name = 'Test fieldset' - order = 1 # act service._create_instance( name=name, - order=order, template_id=template.id, ) # assert assert service.instance is not None assert service.instance.name == name - assert service.instance.order == order assert service.instance.template_id == template.id assert service.instance.account_id == account.id assert service.instance.description == '' @@ -85,7 +82,6 @@ def test__create_instance__all_params__ok(): auth_type=AuthTokenType.USER, ) name = 'Test fieldset' - order = 2 description = 'Test description' label_position = LabelPosition.LEFT layout = FieldSetLayout.HORIZONTAL @@ -93,7 +89,6 @@ def test__create_instance__all_params__ok(): # act service._create_instance( name=name, - order=order, template_id=template.id, description=description, label_position=label_position, @@ -102,63 +97,12 @@ def test__create_instance__all_params__ok(): # assert assert service.instance.name == name - assert service.instance.order == order assert service.instance.template_id == template.id assert service.instance.description == description assert service.instance.label_position == label_position assert service.instance.layout == layout -def test__create_instance__with_kickoff_id__ok(): - - """Persist kickoff_id when creating a fieldset template.""" - - account = create_test_account() - user = create_test_owner(account=account) - template = create_test_template(user=user, tasks_count=1) - kickoff = template.kickoff_instance - service = FieldSetTemplateService( - user=user, - is_superuser=False, - auth_type=AuthTokenType.USER, - ) - - service._create_instance( - name='Kickoff fieldset', - order=1, - template_id=template.id, - kickoff_id=kickoff.id, - ) - - assert service.instance.kickoff_id == kickoff.id - assert service.instance.task_id is None - - -def test__create_instance__with_task_id__ok(): - - """Persist task_id when creating a fieldset template.""" - - account = create_test_account() - user = create_test_owner(account=account) - template = create_test_template(user=user, tasks_count=1) - task = template.tasks.first() - service = FieldSetTemplateService( - user=user, - is_superuser=False, - auth_type=AuthTokenType.USER, - ) - - service._create_instance( - name='Task fieldset', - order=1, - template_id=template.id, - task_id=task.id, - ) - - assert service.instance.task_id == task.id - assert service.instance.kickoff_id is None - - def test__create_fields__with_data__ok(mocker): """ @@ -173,7 +117,6 @@ def test__create_fields__with_data__ok(mocker): template=template, account=account, name='Fieldset', - order=1, ) service = FieldSetTemplateService( user=user, @@ -238,7 +181,6 @@ def test_create_rules__with_data__ok(mocker): template=template, account=account, name='Fieldset', - order=1, ) service = FieldSetTemplateService( user=user, @@ -427,7 +369,6 @@ def test__update_fields__existing_field__ok(mocker): template=template, account=account, name='Fieldset', - order=1, ) field_1 = FieldTemplate.objects.create( account=account, @@ -553,7 +494,6 @@ def test__update_fields__orphan_fields__deleted(mocker): template=template, account=account, name='Fieldset', - order=1, ) field_1 = FieldTemplate.objects.create( account=account, @@ -614,7 +554,6 @@ def test__validate_rules__with_rules__ok(mocker): template=template, account=account, name='Fieldset', - order=1, ) rule_1 = FieldsetTemplateRule.objects.create( account=account, @@ -667,7 +606,6 @@ def test_update_rules__existing_rule__ok(mocker): template=template, account=account, name='Fieldset', - order=1, ) rule_1 = FieldsetTemplateRule.objects.create( account=account, @@ -713,7 +651,6 @@ def test_update_rules__existing_rule__ok(mocker): ) fs_rule_update_mock.assert_called_once_with( value='200', - force_save=True, ) fieldset_template_rule_service_create_mock.assert_not_called() @@ -732,7 +669,6 @@ def test_update_rules__new_rule__ok(mocker): template=template, account=account, name='Fieldset', - order=1, ) service = FieldSetTemplateService( user=user, @@ -793,7 +729,6 @@ def test_update_rules__orphan_rules__deleted(mocker): template=template, account=account, name='Fieldset', - order=1, ) rule_1 = FieldsetTemplateRule.objects.create( account=account, @@ -840,81 +775,6 @@ def test_update_rules__orphan_rules__deleted(mocker): ).exists() -def test_create__bind_kickoff__ok(mocker): - - # arrange - account = create_test_account() - owner = create_test_owner(account=account) - template = create_test_template(user=owner, tasks_count=1) - kickoff = template.kickoff_instance - data = { - "template_id": template.id, - "name": "Test Fieldset", - "fields": [], - "rules": [], - } - - mock_create_instance = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' - 'FieldSetTemplateService._create_instance', - ) - mock_create_related = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' - 'FieldSetTemplateService._create_related', - ) - mock_create_actions = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' - 'FieldSetTemplateService._create_actions', - ) - service = FieldSetTemplateService(user=owner) - - # act - service.create(**data) - - # assert - mock_create_instance.assert_called_once_with(**data, kickoff_id=kickoff.id) - mock_create_related.assert_called_once_with(**data, kickoff_id=kickoff.id) - mock_create_actions.assert_called_once_with(**data, kickoff_id=kickoff.id) - - -def test_create_with_task_bind_task_ok(mocker): - - # arrange - account = create_test_account() - owner = create_test_owner(account=account) - template = create_test_template(user=owner, tasks_count=1) - task = template.tasks.get(number=1) - data = { - "template_id": template.id, - "name": "Test Fieldset", - "fields": [], - "rules": [], - "task_id": task.id, - } - - mock_create_instance = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' - 'FieldSetTemplateService._create_instance', - ) - mock_create_related = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' - 'FieldSetTemplateService._create_related', - ) - mock_create_actions = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' - 'FieldSetTemplateService._create_actions', - ) - service = FieldSetTemplateService(user=owner) - - # act - service.create(**data) - - # assert - mock_create_instance.assert_called_once_with(**data) - mock_create_related.assert_called_once_with(**data) - mock_create_actions.assert_called_once_with(**data) - - def test_partial_update_name__ok(mocker): """Call `partial_update` with default parameters""" @@ -923,11 +783,10 @@ def test_partial_update_name__ok(mocker): account = create_test_account() owner = create_test_owner(account=account) template = create_test_template(user=owner, tasks_count=1) - task = template.tasks.get(number=1) + template.tasks.get(number=1) fieldset = create_test_fieldset_template( account=account, template=template, - task=task, ) mock_update_fields = mocker.patch( 'src.processes.services.templates.fieldsets.fieldset.' @@ -954,91 +813,6 @@ def test_partial_update_name__ok(mocker): mock_validate_rules.assert_called_once_with() -def test_partial_update_unbind_task_ok(mocker): - - """Test handling of `task_id` being set to None (unbind from task)""" - - # arrange - account = create_test_account() - owner = create_test_owner(account=account) - template = create_test_template(user=owner, tasks_count=1) - task = template.tasks.get(number=1) - fieldset = create_test_fieldset_template( - account=account, - template=template, - task=task, - ) - - mock_update_fields = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' - 'FieldSetTemplateService._update_fields', - ) - mock_update_rules = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' - 'FieldSetTemplateService.update_rules', - ) - mock_validate_rules = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' - 'FieldSetTemplateService._validate_rules', - ) - service = FieldSetTemplateService(instance=fieldset, user=owner) - data = {"task_id": None} - - # act - result = service.partial_update(**data) - - # assert - fieldset.refresh_from_db() - assert result is fieldset - assert fieldset.task is None - assert fieldset.kickoff == template.kickoff_instance - mock_update_fields.assert_not_called() - mock_update_rules.assert_not_called() - mock_validate_rules.assert_called_once_with() - - -def test_partial_update_bind_task_ok(mocker): - """Test handling of `task_id` being provided (bind to task)""" - - # arrange - account = create_test_account() - owner = create_test_owner(account=account) - template = create_test_template(user=owner, tasks_count=1) - task = template.tasks.get(number=1) - fieldset = create_test_fieldset_template( - account=account, - template=template, - kickoff=template.kickoff_instance, - ) - - mock_update_fields = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' - 'FieldSetTemplateService._update_fields', - ) - mock_update_rules = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' - 'FieldSetTemplateService.update_rules', - ) - mock_validate_rules = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' - 'FieldSetTemplateService._validate_rules', - ) - service = FieldSetTemplateService(instance=fieldset, user=owner) - data = {"task_id": task.id} - - # act - result = service.partial_update(**data) - - # assert - fieldset.refresh_from_db() - assert result is fieldset - assert fieldset.task == task - assert fieldset.kickoff is None - 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""" @@ -1046,11 +820,10 @@ def test_partial_update_fields_ok(mocker): account = create_test_account() owner = create_test_owner(account=account) template = create_test_template(user=owner, tasks_count=1) - task = template.tasks.get(number=1) + template.tasks.get(number=1) fieldset = create_test_fieldset_template( account=account, template=template, - task=task, ) service = FieldSetTemplateService(user=owner, instance=fieldset) @@ -1093,11 +866,10 @@ def test_partial_update__rules__ok(mocker): account = create_test_account() owner = create_test_owner(account=account) template = create_test_template(user=owner, tasks_count=1) - task = template.tasks.get(number=1) + template.tasks.get(number=1) fieldset = create_test_fieldset_template( account=account, template=template, - task=task, ) mock_super_partial_update = mocker.patch( @@ -1148,7 +920,6 @@ def test_delete__not_in_use__ok(): template=template, account=account, name='Fieldset', - order=1, ) service = FieldSetTemplateService( user=user, @@ -1179,9 +950,8 @@ def test_delete__used_by_kickoff__raise_exception(): template=template, account=account, name='Fieldset', - kickoff=kickoff, - order=1, ) + fieldset.kickoffs.add(kickoff) service = FieldSetTemplateService( user=user, is_superuser=False, @@ -1212,10 +982,9 @@ def test_delete__used_by_task__raise_exception(): fieldset = FieldsetTemplate.objects.create( template=template, account=account, - task=task_template, name='Fieldset', - order=1, ) + fieldset.tasks.add(task_template) service = FieldSetTemplateService( user=user, is_superuser=False, 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 index e43f5562d..d957ae2f1 100644 --- 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 @@ -46,7 +46,6 @@ def test__create_instance__with_template__ok(): template=template, account=account, name='Fieldset tmpl', - order=1, ) rule_template = FieldsetTemplateRule.objects.create( account=account, 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 index 1a958eb34..40459b3ee 100644 --- 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 @@ -4,6 +4,7 @@ FieldSetRuleType, FieldType, ) +from src.processes.messages.fieldset import MSG_FS_0007 from src.processes.models.templates.fieldset import ( FieldsetTemplate, FieldsetTemplateRule, @@ -13,6 +14,7 @@ FieldSet, 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, @@ -47,19 +49,20 @@ def test__create_instance__with_kickoff__ok(mocker): account=account, name='Fieldset', description='Description', - order=1, ) service = FieldSetService( user=user, is_superuser=False, auth_type=AuthTokenType.USER, ) + order = 11 # act service._create_instance( instance_template=fieldset_template, workflow=workflow, kickoff=kickoff, + order=order, ) # assert @@ -70,10 +73,10 @@ def test__create_instance__with_kickoff__ok(mocker): assert service.instance.api_name == fieldset_template.api_name assert service.instance.name == 'Fieldset' assert service.instance.description == 'Description' - assert service.instance.order == 1 + assert service.instance.order == order -def test__create_instance__with_task__ok(mocker): +def test__create_instance__with_task__ok(): """ Call with task @@ -89,29 +92,31 @@ def test__create_instance__with_task__ok(mocker): template=template, account=account, name='Fieldset', - order=1, ) service = FieldSetService( user=user, is_superuser=False, auth_type=AuthTokenType.USER, ) + order = 11 # act service._create_instance( instance_template=fieldset_template, workflow=workflow, task=task, + order=order, ) # assert assert service.instance is not None assert service.instance.workflow_id == workflow.id assert service.instance.task_id == task.id + assert service.instance.order == order assert service.instance.kickoff is None -def test__create_instance__no_kickoff_no_task__ok(mocker): +def test__create_instance__no_kickoff_no_task__raise_exception(): """ Call without kickoff and task @@ -126,25 +131,24 @@ def test__create_instance__no_kickoff_no_task__ok(mocker): template=template, account=account, name='Fieldset', - order=1, ) service = FieldSetService( user=user, is_superuser=False, auth_type=AuthTokenType.USER, ) + order = 11 # act - service._create_instance( - instance_template=fieldset_template, - workflow=workflow, - ) + with pytest.raises(FieldsetServiceException) as ex: + service._create_instance( + instance_template=fieldset_template, + workflow=workflow, + order=order, + ) # assert - assert service.instance is not None - assert service.instance.workflow_id == workflow.id - assert service.instance.kickoff is None - assert service.instance.task is None + assert ex.value.message == MSG_FS_0007 def test__create_fields__default_params__ok(mocker): @@ -162,7 +166,6 @@ def test__create_fields__default_params__ok(mocker): template=template, account=account, name='Fieldset', - order=1, ) FieldTemplate.objects.create( account=account, @@ -230,7 +233,6 @@ def test__create_fields__with_fields_data__ok(mocker): template=template, account=account, name='Fieldset', - order=1, ) field_template_1 = FieldTemplate.objects.create( account=account, @@ -300,7 +302,6 @@ def test__create_fields__skip_value_true__ok(mocker): template=template, account=account, name='Fieldset', - order=1, ) FieldTemplate.objects.create( account=account, @@ -369,7 +370,6 @@ def test__create_rules__with_template__ok(mocker): template=template, account=account, name='Fieldset', - order=1, ) rule_template = FieldsetTemplateRule.objects.create( account=account, @@ -429,7 +429,6 @@ def test__create_related__with_template__ok(mocker): template=template, account=account, name='Fieldset', - order=1, ) service = FieldSetService( user=user, 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 index 6d8eb8364..39650bbc1 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_create.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_create.py @@ -52,7 +52,6 @@ def test_create_fieldset__all_fields__ok(api_client, mocker): data = { 'name': 'All Fields Fieldset', 'description': 'Description', - 'task': task.api_name, 'label_position': LabelPosition.LEFT, 'layout': FieldSetLayout.HORIZONTAL, 'api_name': 'fieldset_api_name', @@ -77,7 +76,6 @@ def test_create_fieldset__all_fields__ok(api_client, mocker): fieldset = FieldsetTemplate.objects.create( account=account, template=template, - task=task, name=data['name'], description=data['description'], label_position=data['label_position'], @@ -126,7 +124,7 @@ def test_create_fieldset__all_fields__ok(api_client, mocker): assert response.data['id'] == fieldset.id assert response.data['name'] == data['name'] assert response.data['description'] == data['description'] - assert response.data['task'] == task.api_name + assert response.data['tasks'] == [] assert response.data['label_position'] == data['label_position'] assert response.data['layout'] == data['layout'] assert response.data['api_name'] == data['api_name'] @@ -151,129 +149,11 @@ def test_create_fieldset__all_fields__ok(api_client, mocker): layout=data['layout'], label_position=data['label_position'], api_name=data['api_name'], - task_id=task.id, rules=data['rules'], fields=data['fields'], ) -def test_create_fieldset__with_kickoff_id__ok(api_client, mocker): - - """Create fieldset linked to template kickoff via kickoff_id.""" - - # arrange - account = create_test_account() - user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) - data = { - 'name': 'Kickoff Fieldset', - } - - fieldset = FieldsetTemplate.objects.create( - account=account, - template=template, - kickoff=template.kickoff_instance, - 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.template.FieldSetTemplateService.create', - return_value=fieldset, - ) - api_client.token_authenticate(user=user) - - # act - response = api_client.post( - f'/templates/{template.id}/fieldsets', - data=data, - ) - - # assert - assert response.status_code == 201 - assert response.data['task'] is None - 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( - template_id=template.id, - name=data['name'], - rules=[], - fields=[], - ) - - -def test_create_fieldset__with_task__ok(api_client, mocker): - """Create fieldset linked to a template task via task_id.""" - - # 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': 'Task Fieldset', - 'task': task.api_name, - } - - fieldset = FieldsetTemplate.objects.create( - account=account, - template=template, - task=task, - 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.template.FieldSetTemplateService.create', - return_value=fieldset, - ) - api_client.token_authenticate(user=user) - - # act - response = api_client.post( - f'/templates/{template.id}/fieldsets', - data=data, - ) - - # assert - assert response.status_code == 201 - assert response.data['task'] == task.api_name - api_name = response.data['api_name'] - assert FieldsetTemplate.objects.get( - api_name=api_name, - task=task, - ) - 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( - template_id=template.id, - task_id=task.id, - name=data['name'], - rules=[], - fields=[], - ) - - def test_create_fieldset__min_data__ok(api_client, mocker): """Create fieldset with minimal request data""" @@ -395,11 +275,10 @@ def test_create_fieldset__rule_with_one_field__ok(api_client, mocker): user=user, tasks_count=1, ) - task = template.tasks.first() + template.tasks.first() field_api_name = 'f1' data = { 'name': 'All Fields Fieldset', - 'task': task.api_name, 'fields': [ { 'name': 'Field 1', @@ -433,7 +312,6 @@ def test_create_fieldset__rule_with_one_field__ok(api_client, mocker): fieldset = FieldsetTemplate.objects.create( account=account, template=template, - task=task, name=data['name'], ) field = FieldTemplate.objects.create( @@ -476,7 +354,6 @@ def test_create_fieldset__rule_with_one_field__ok(api_client, mocker): ) fieldset_service_create_mock.assert_called_once_with( template_id=template.id, - task_id=task.id, name=data['name'], rules=data['rules'], fields=data['fields'], @@ -496,7 +373,6 @@ def test_create_fieldset__rule_with_two_fields__ok(api_client, mocker): user=user, tasks_count=1, ) - kickoff = template.kickoff_instance field_1_api_name = 'f1' field_2_api_name = 'f2' data = { @@ -534,7 +410,6 @@ def test_create_fieldset__rule_with_two_fields__ok(api_client, mocker): fieldset = FieldsetTemplate.objects.create( account=account, template=template, - kickoff=kickoff, name=data['name'], ) field_1 = FieldTemplate.objects.create( @@ -592,56 +467,6 @@ def test_create_fieldset__rule_with_two_fields__ok(api_client, mocker): ) -def test_create_fieldset_another_template_task__validation_error( - api_client, - mocker, -): - """Create fieldset linked to a template task via task_id.""" - - # arrange - account = create_test_account() - user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) - another_template = create_test_template( - user=user, - tasks_count=1, - ) - another_template_task = another_template.tasks.get(number=1) - data = { - 'name': 'Task Fieldset', - 'task': another_template_task.api_name, - } - fieldset_service_init_mock = mocker.patch.object( - FieldSetTemplateService, - attribute='__init__', - return_value=None, - ) - fieldset_service_create_mock = mocker.patch( - 'src.processes.views.template.FieldSetTemplateService.create', - ) - api_client.token_authenticate(user=user) - - # act - response = api_client.post( - f'/templates/{template.id}/fieldsets', - data=data, - ) - - # assert - assert response.status_code == 400 - message = ( - f'Object with api_name={another_template_task.api_name} ' - f'does not exist.' - ) - assert response.data['message'] == message - assert response.data['details']['name'] == 'task' - fieldset_service_init_mock.assert_not_called() - fieldset_service_create_mock.assert_not_called() - - def test_create_fieldset__unauthenticated__unauthorized(api_client, mocker): """Unauthenticated request returns 401""" 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 index 51589fc05..bd792e13d 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_list.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_list.py @@ -34,13 +34,11 @@ def test_list_fieldsets__all_data__ok(api_client): user=user, tasks_count=1, ) - kickoff = template.kickoff_instance rule_type = FieldSetRuleType.SUM_EQUAL rule_value = '10' fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Kickoff Fieldset', rule_type=rule_type, rule_value=rule_value, @@ -65,7 +63,7 @@ def test_list_fieldsets__all_data__ok(api_client): assert item_1['description'] == '' assert item_1['layout'] == fieldset.layout assert item_1['label_position'] == fieldset.label_position - assert item_1['task'] is None + assert item_1['tasks'] == [] assert len(item_1['rules']) == 1 rules_data = item_1['rules'] @@ -86,6 +84,61 @@ def test_list_fieldsets__all_data__ok(api_client): assert 'selections' not in fields_data[0] +def test_list_fieldsets__tasks_and_kickoff_fieldset__ok(api_client): + + """List fieldsets for existing template""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=2, + ) + kickoff = template.kickoff_instance + template_task_1 = template.tasks.get(number=1) + template_task_2 = template.tasks.get(number=2) + rule_type = FieldSetRuleType.SUM_EQUAL + rule_value = '10' + fieldset = create_test_fieldset_template( + account=account, + template=template, + name='Kickoff Fieldset', + task=template_task_1, + kickoff=kickoff, + rule_type=rule_type, + rule_value=rule_value, + ) + fieldset.tasks.add(template_task_2) + fieldset.fields.get() + fieldset.rules.get() + + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/fieldsets', + ) + + # assert + assert response.status_code == 200 + data = response.data[0] + assert data['id'] == fieldset.id + assert data['kickoff'] == kickoff.id + assert data['tasks'] == [ + { + 'number': template_task_1.number, + 'name': template_task_1.name, + 'api_name': template_task_1.api_name, + }, + { + 'number': template_task_2.number, + 'name': template_task_2.name, + 'api_name': template_task_2.api_name, + }, + ] + + def test_list_fieldsets__pagination__ok(api_client): """List fieldsets for existing template""" @@ -96,21 +149,18 @@ def test_list_fieldsets__pagination__ok(api_client): user=user, tasks_count=1, ) - template_task = template.tasks.first() + template.tasks.first() fieldset_1 = create_test_fieldset_template( account=account, template=template, - task=template_task, ) fieldset_2 = create_test_fieldset_template( account=account, template=template, - task=template_task, ) create_test_fieldset_template( account=account, template=template, - task=template_task, ) api_client.token_authenticate(user=user) @@ -128,11 +178,9 @@ def test_list_fieldsets__pagination__ok(api_client): item_1 = response.data['results'][0] assert item_1['id'] == fieldset_2.id - assert item_1['task'] == template_task.api_name item_2 = response.data['results'][1] assert item_2['id'] == fieldset_1.id - assert item_2['task'] == template_task.api_name def test_list_fieldsets__different_accounts__ok(api_client): @@ -148,7 +196,6 @@ def test_list_fieldsets__different_accounts__ok(api_client): fieldset_1 = create_test_fieldset_template( account=account_1, template=template_1, - kickoff=template_1.kickoff_instance, name='Account 1 Fieldset', ) @@ -164,7 +211,6 @@ def test_list_fieldsets__different_accounts__ok(api_client): create_test_fieldset_template( account=account_2, template=template_2, - kickoff=template_2.kickoff_instance, ) api_client.token_authenticate(user=user_1) @@ -193,7 +239,6 @@ def test_list_fieldsets__different_templates__ok(api_client): fieldset_1 = create_test_fieldset_template( account=account, template=template_1, - kickoff=template_1.kickoff_instance, ) template_2 = create_test_template( user=user, @@ -202,7 +247,6 @@ def test_list_fieldsets__different_templates__ok(api_client): create_test_fieldset_template( account=account, template=template_2, - kickoff=template_2.kickoff_instance, ) api_client.token_authenticate(user=user) @@ -228,13 +272,11 @@ def test_list_fieldsets__rule_with_fields__ok(api_client): user=user, tasks_count=1, ) - kickoff = template.kickoff_instance rule_type = FieldSetRuleType.SUM_EQUAL rule_value = '10' fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Kickoff Fieldset', rule_type=rule_type, rule_value=rule_value, @@ -424,12 +466,10 @@ def test_list_fieldsets__no_ordering__ok(api_client): user=user, tasks_count=1, ) - kickoff = template.kickoff_instance now = timezone.now() fieldset_1 = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Oldest', ) FieldsetTemplate.objects.filter(id=fieldset_1.id).update( @@ -438,7 +478,6 @@ def test_list_fieldsets__no_ordering__ok(api_client): fieldset_2 = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Middle', ) FieldsetTemplate.objects.filter(id=fieldset_2.id).update( @@ -447,7 +486,6 @@ def test_list_fieldsets__no_ordering__ok(api_client): fieldset_3 = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Newest', ) FieldsetTemplate.objects.filter(id=fieldset_3.id).update( @@ -482,23 +520,19 @@ def test_list_fieldsets__ordering_name_asc__ok(api_client): user=user, tasks_count=1, ) - kickoff = template.kickoff_instance fieldset_1 = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Alpha', ) fieldset_2 = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Beta', ) fieldset_3 = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Gamma', ) api_client.token_authenticate(user=user) @@ -534,23 +568,19 @@ def test_list_fieldsets__ordering_name_desc__ok(api_client): user=user, tasks_count=1, ) - kickoff = template.kickoff_instance fieldset_1 = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Alpha', ) fieldset_2 = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Beta', ) fieldset_3 = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Gamma', ) api_client.token_authenticate(user=user) @@ -586,12 +616,10 @@ def test_list_fieldsets__ordering_date_asc__ok(api_client): user=user, tasks_count=1, ) - kickoff = template.kickoff_instance now = timezone.now() fieldset_1 = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Oldest', ) FieldsetTemplate.objects.filter(id=fieldset_1.id).update( @@ -600,7 +628,6 @@ def test_list_fieldsets__ordering_date_asc__ok(api_client): fieldset_2 = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Middle', ) FieldsetTemplate.objects.filter(id=fieldset_2.id).update( @@ -609,7 +636,6 @@ def test_list_fieldsets__ordering_date_asc__ok(api_client): fieldset_3 = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Newest', ) FieldsetTemplate.objects.filter(id=fieldset_3.id).update( @@ -645,12 +671,10 @@ def test_list_fieldsets__ordering_date_desc__ok(api_client): user=user, tasks_count=1, ) - kickoff = template.kickoff_instance now = timezone.now() fieldset_1 = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Oldest', ) FieldsetTemplate.objects.filter(id=fieldset_1.id).update( @@ -659,7 +683,6 @@ def test_list_fieldsets__ordering_date_desc__ok(api_client): fieldset_2 = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Middle', ) FieldsetTemplate.objects.filter(id=fieldset_2.id).update( @@ -668,7 +691,6 @@ def test_list_fieldsets__ordering_date_desc__ok(api_client): fieldset_3 = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Newest', ) FieldsetTemplate.objects.filter(id=fieldset_3.id).update( @@ -704,17 +726,14 @@ def test_list_fieldsets__no_pagination__ok(api_client): user=user, tasks_count=1, ) - kickoff = template.kickoff_instance create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='First', ) create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Second', ) api_client.token_authenticate(user=user) @@ -743,11 +762,9 @@ def test_list_fieldsets__ordering_invalid__validation_error( user=user, tasks_count=1, ) - kickoff = template.kickoff_instance create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='First', ) api_client.token_authenticate(user=user) @@ -776,12 +793,10 @@ def test_list_fieldsets__ordering_empty__ok(api_client): user=user, tasks_count=1, ) - kickoff = template.kickoff_instance now = timezone.now() fieldset_1 = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='First', ) FieldsetTemplate.objects.filter(id=fieldset_1.id).update( @@ -790,7 +805,6 @@ def test_list_fieldsets__ordering_empty__ok(api_client): fieldset_2 = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Second', ) FieldsetTemplate.objects.filter(id=fieldset_2.id).update( @@ -826,11 +840,9 @@ def test_list_fieldsets__soft_deleted__ok(api_client): user=user, tasks_count=1, ) - kickoff = template.kickoff_instance fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=kickoff, name='Deleted Fieldset', ) FieldsetTemplate.objects.filter(id=fieldset.id).update( 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 index b00f31e34..24de5b815 100644 --- 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 @@ -72,7 +72,6 @@ def test_partial_update__all_fields__ok(api_client, mocker): description=data['description'], label_position=data['label_position'], layout=data['layout'], - kickoff=template.kickoff_instance, api_name=data['api_name'], rule_type=FieldSetRuleType.SUM_EQUAL, ) @@ -102,7 +101,7 @@ def test_partial_update__all_fields__ok(api_client, mocker): assert response.data['id'] == fieldset.id assert response.data['name'] == data['name'] assert response.data['description'] == data['description'] - assert response.data['task'] is None + assert response.data['tasks'] == [] assert response.data['label_position'] == data['label_position'] assert response.data['layout'] == data['layout'] assert response.data['api_name'] == data['api_name'] @@ -147,7 +146,6 @@ def test_partial_update__name__ok(api_client, mocker): fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=template.kickoff_instance, ) data = { 'name': 'Updated Name', @@ -183,106 +181,6 @@ def test_partial_update__name__ok(api_client, mocker): ) -def test_partial_update__task_id__ok(api_client, mocker): - - """ Move fieldset from kickoff to task in one PATCH - (clear kickoff, set task).""" - - account = create_test_account() - user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) - task = template.tasks.first() - fieldset = create_test_fieldset_template( - account=account, - template=template, - task=task, - ) - 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, - ) - data = { - 'task': task.api_name, - } - api_client.token_authenticate(user=user) - - # act - response = api_client.patch( - f'/templates/fieldsets/{fieldset.id}', - data=data, - ) - - # assert - assert response.status_code == 200 - assert response.data['task'] == task.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( - task_id=task.id, - ) - - -def test_partial_update__task_is_null_set_kickoff__ok(api_client, mocker): - - """ Move fieldset from kickoff to task in one PATCH - (clear kickoff, set task). """ - - 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, - ) - 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'/templates/fieldsets/{fieldset.id}', - data={ - 'task': None, - }, - ) - - # assert - assert response.status_code == 200 - assert response.data['task'] is None - 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( - task=None, - ) - - def test_partial_update__with_rule_fields__ok(api_client, mocker): """ @@ -300,7 +198,6 @@ def test_partial_update__with_rule_fields__ok(api_client, mocker): fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=template.kickoff_instance, ) field_api_name = 'f1' data = { @@ -387,7 +284,6 @@ def test_partial_update__clear_fields__ok(api_client, mocker): fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=template.kickoff_instance, ) data = { 'name': 'Updated Fieldset', @@ -434,149 +330,6 @@ def test_partial_update__clear_fields__ok(api_client, mocker): ) -def test_partial_update__not_existent__validation_error(api_client, mocker): - - account = create_test_account() - user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) - task = template.tasks.first() - fieldset = create_test_fieldset_template( - account=account, - template=template, - task=task, - ) - 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', - ) - data = { - 'task': 'not-exist', - } - api_client.token_authenticate(user=user) - - # act - response = api_client.patch( - f'/templates/fieldsets/{fieldset.id}', - data=data, - ) - - # assert - assert response.status_code == 400 - message = 'Object with api_name=not-exist does not exist.' - assert response.data['message'] == message - assert response.data['details']['name'] == 'task' - fieldset_service_init_mock.assert_not_called() - fieldset_partial_update_mock.assert_not_called() - - -def test_partial_update__another_account_task__validation_error( - api_client, - mocker, -): - - account = create_test_account() - user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) - task = template.tasks.first() - fieldset = create_test_fieldset_template( - account=account, - template=template, - task=task, - ) - another_user = create_test_owner(email='another@test.test') - another_template = create_test_template(user=another_user, tasks_count=1) - another_account_task = another_template.tasks.get(number=1) - 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', - ) - data = { - 'task': another_account_task.api_name, - } - api_client.token_authenticate(user=user) - - # act - response = api_client.patch( - f'/templates/fieldsets/{fieldset.id}', - data=data, - ) - - # assert - assert response.status_code == 400 - message = ( - f'Object with api_name={another_account_task.api_name} ' - f'does not exist.' - ) - assert response.data['message'] == message - assert response.data['details']['name'] == 'task' - fieldset_service_init_mock.assert_not_called() - fieldset_partial_update_mock.assert_not_called() - - -def test_partial_update__another_template_task__validation_error( - api_client, - mocker, -): - - account = create_test_account() - user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) - task = template.tasks.first() - fieldset = create_test_fieldset_template( - account=account, - template=template, - task=task, - ) - another_template = create_test_template(user=user, tasks_count=1) - another_template_task = another_template.tasks.get(number=1) - 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', - ) - data = { - 'task': another_template_task.api_name, - } - api_client.token_authenticate(user=user) - - # act - response = api_client.patch( - f'/templates/fieldsets/{fieldset.id}', - data=data, - ) - - # assert - assert response.status_code == 400 - message = ( - f'Object with api_name={another_template_task.api_name} ' - f'does not exist.' - ) - assert response.data['message'] == message - assert response.data['details']['name'] == 'task' - fieldset_service_init_mock.assert_not_called() - fieldset_partial_update_mock.assert_not_called() - - def test_partial_update__unauthenticated__unauthorized(api_client, mocker): """ Unauthenticated request returns 401 """ @@ -591,7 +344,6 @@ def test_partial_update__unauthenticated__unauthorized(api_client, mocker): fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=template.kickoff_instance, ) data = { 'name': 'Updated Fieldset', @@ -634,7 +386,6 @@ def test_partial_update__expired_sub__permission_denied(api_client, mocker): fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=template.kickoff_instance, ) data = { 'name': 'Updated Fieldset', @@ -676,7 +427,6 @@ def test_partial_update__billing_plan__permission_denied(api_client, mocker): fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=template.kickoff_instance, ) data = { 'name': 'Updated Fieldset', @@ -727,7 +477,6 @@ def test_partial_update__users_limit__permission_denied(api_client, mocker): fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=template.kickoff_instance, ) data = { 'name': 'Updated Fieldset', @@ -769,7 +518,6 @@ def test_partial_update__non_admin__permission_denied(api_client, mocker): fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=template.kickoff_instance, ) user = create_test_not_admin(account=account) data = { @@ -811,7 +559,6 @@ def test_partial_update__invalid_name__validation_error(api_client, mocker): fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=template.kickoff_instance, ) data = { 'name': '', @@ -855,7 +602,6 @@ def test_partial_update__invalid_layout__validation_error(api_client, mocker): fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=template.kickoff_instance, ) data = { 'layout': 'invalid_layout', @@ -902,7 +648,6 @@ def test_partial_update__invalid_label_position__validation_error( fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=template.kickoff_instance, ) data = { 'label_position': 'invalid_position', @@ -948,7 +693,6 @@ def test_partial_update__service_exception__validation_error( fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=template.kickoff_instance, ) data = { 'name': 'Updated Fieldset', 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 index f74c34826..8bc1e9316 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py @@ -36,7 +36,6 @@ def test_retrieve__fieldset_all_data__ok(api_client): fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=template.kickoff_instance, name='My Fieldset', description='Fieldset description', layout=FieldSetLayout.HORIZONTAL, @@ -60,7 +59,8 @@ def test_retrieve__fieldset_all_data__ok(api_client): assert response.data['description'] == 'Fieldset description' assert response.data['layout'] == FieldSetLayout.HORIZONTAL assert response.data['label_position'] == LabelPosition.LEFT - assert response.data['task'] is None + assert response.data['kickoff'] is None + assert response.data['tasks'] == [] assert len(response.data['rules']) == 1 rules_data = response.data['rules'] @@ -81,6 +81,35 @@ def test_retrieve__fieldset_all_data__ok(api_client): assert 'selections' not in fields_data[0] +def test_retrieve__kickoff_fieldset__ok(api_client): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + kickoff = template.kickoff_instance + template_task = template.tasks.get(number=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=template_task, + kickoff=kickoff, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/templates/fieldsets/{fieldset.id}') + + # assert + assert response.status_code == 200 + assert response.data['id'] == fieldset.id + assert response.data['kickoff'] == kickoff.id + + def test_retrieve__task_fieldset__ok(api_client): # arrange @@ -110,7 +139,56 @@ def test_retrieve__task_fieldset__ok(api_client): # assert assert response.status_code == 200 assert response.data['id'] == fieldset.id - assert response.data['task'] == template_task.api_name + assert response.data['tasks'] == [ + { + 'number': template_task.number, + 'name': template_task.name, + 'api_name': template_task.api_name, + }, + ] + + +def test_retrieve__tasks_and_kickoff_fieldset__ok(api_client): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=2, + ) + kickoff = template.kickoff_instance + template_task_1 = template.tasks.get(number=1) + template_task_2 = template.tasks.get(number=2) + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=template_task_1, + kickoff=kickoff, + ) + fieldset.tasks.add(template_task_2) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/templates/fieldsets/{fieldset.id}') + + # assert + assert response.status_code == 200 + assert response.data['id'] == fieldset.id + assert response.data['kickoff'] == kickoff.id + assert response.data['tasks'] == [ + { + 'number': template_task_1.number, + 'name': template_task_1.name, + 'api_name': template_task_1.api_name, + }, + { + 'number': template_task_2.number, + 'name': template_task_2.name, + 'api_name': template_task_2.api_name, + }, + ] def test_retrieve__fieldset_rule_with_fields__ok(api_client): @@ -126,7 +204,6 @@ def test_retrieve__fieldset_rule_with_fields__ok(api_client): fieldset = create_test_fieldset_template( account=account_1, template=template_1, - kickoff=template_1.kickoff_instance, rule_type=FieldSetRuleType.SUM_EQUAL, rule_value='10', ) @@ -161,7 +238,6 @@ def test_retrieve__unauthenticated__unauthorized(api_client): fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=template.kickoff_instance, ) # act @@ -187,7 +263,6 @@ def test_retrieve__expired_sub__permission_denied(api_client): fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=template.kickoff_instance, ) api_client.token_authenticate(user=user) @@ -213,7 +288,6 @@ def test_retrieve__billing_plan__permission_denied(api_client): fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=template.kickoff_instance, ) api_client.token_authenticate(user=user) @@ -248,7 +322,6 @@ def test_retrieve__users_overlimit__permission_denied(api_client): fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=template.kickoff_instance, ) api_client.token_authenticate(user=user) @@ -274,7 +347,6 @@ def test_retrieve__non_admin__permission_denied(api_client): fieldset = create_test_fieldset_template( account=account, template=template, - kickoff=template.kickoff_instance, ) user = create_test_not_admin(account=account) @@ -305,6 +377,7 @@ def test_retrieve__not_existing__not_found(api_client): def test_retrieve__another_account__not_found(api_client): + """Fieldset from another account returns 404""" # arrange @@ -317,7 +390,6 @@ def test_retrieve__another_account__not_found(api_client): fieldset = create_test_fieldset_template( account=account_1, template=template_1, - kickoff=template_1.kickoff_instance, ) account_2 = create_test_account(name='Account 2') 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..be1f3c8ca 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, @@ -30,6 +31,7 @@ WorkflowStatus, ) 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, @@ -40,6 +42,9 @@ PredicateTemplate, RuleTemplate, ) +from src.processes.models.templates.fieldset import ( + FieldsetTemplateKickoff, +) from src.processes.models.templates.fields import ( FieldTemplate, FieldTemplateSelection, @@ -67,18 +72,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, ) from src.utils.dates import date_format from src.utils.validation import ErrorCode + pytestmark = pytest.mark.django_db @@ -5182,3 +5191,424 @@ 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, + ) + fieldset_template = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + name='Personal info', + order=0, + ) + FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset_template, + ).update(order=11) + field_template = fieldset_template.fields.first() + field_value = 'test 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.api_name: field_value, + }, + }, + ) + + # 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 = 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'] == fieldset_template.name + assert fieldset_data['description'] == fieldset_template.description + assert fieldset_data['order'] == 11 + 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, + ) + fieldset_1 = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + name='First fieldset', + order=0, + ) + fieldset_2 = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + name='Second fieldset', + order=1, + ) + field_1 = fieldset_1.fields.first() + field_2 = fieldset_2.fields.first() + field_value_1 = 'value 1' + field_value_2 = 'value 2' + 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: field_value_1, + field_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 = 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]['name'] == fieldset_2.name + assert fieldsets_data[0]['order'] == 1 + assert fieldsets_data[1]['name'] == fieldset_1.name + 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, + name='Grouped fields', + 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, + name='Budget split', + 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, + name='Budget split', + 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() + + +def test_run__kickoff_fieldset_required_empty__validation_error( + mocker, + api_client, +): + + """ Required fieldset field returns 400 when empty. """ + + # 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, + name='Required fieldset', + order=0, + ) + field_template = fieldset_template.fields.first() + field_template.is_required = True + field_template.save(update_fields=['is_required']) + 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': {}, + }, + ) + + # assert + wf_run_mock.assert_not_called() + analytics_mock.assert_not_called() + assert response.status_code == 400 + assert response.data['code'] == ErrorCode.VALIDATION_ERROR + assert response.data['message'] == messages.MSG_PW_0023 + assert response.data['details']['api_name'] == field_template.api_name 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 From 589f6856f8d7501a0a697ab760d423caac32cbc9 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Wed, 29 Apr 2026 04:57:37 +0500 Subject: [PATCH 05/46] 45773 feat(fieldsets): Passing the FieldSet.order property from the intermediate m2m models --- .../processes/services/tasks/task_version.py | 7 +++- .../processes/services/versioning/schemas.py | 34 ++++++++++++++++++- .../services/workflows/kickoff_version.py | 3 +- .../test_tasks/test_task_version_service.py | 24 +++++++++---- .../test_kickoff_version_service.py | 8 +++-- 5 files changed, 64 insertions(+), 12 deletions(-) diff --git a/backend/src/processes/services/tasks/task_version.py b/backend/src/processes/services/tasks/task_version.py index 0677fc908..2df0a88b6 100644 --- a/backend/src/processes/services/tasks/task_version.py +++ b/backend/src/processes/services/tasks/task_version.py @@ -247,6 +247,11 @@ def _update_fieldsets(self, data: Optional[List]) -> None: fieldset_api_names = set() for fieldset_data in data or []: + task_link = next( + link for link in fieldset_data['task_links'] + if link['task_api_name'] == self.instance.api_name + ) + order = task_link['order'] fieldset, _ = FieldSet.objects.update_or_create( workflow=self.instance.workflow, task=self.instance, @@ -255,7 +260,7 @@ def _update_fieldsets(self, data: Optional[List]) -> None: 'account_id': self.instance.account_id, 'name': fieldset_data['name'], 'description': fieldset_data['description'], - 'order': fieldset_data['order'], + 'order': order, 'label_position': fieldset_data['label_position'], 'layout': fieldset_data['layout'], }, diff --git a/backend/src/processes/services/versioning/schemas.py b/backend/src/processes/services/versioning/schemas.py index 8af1664f2..f4fef8cd7 100644 --- a/backend/src/processes/services/versioning/schemas.py +++ b/backend/src/processes/services/versioning/schemas.py @@ -11,7 +11,9 @@ ) from src.processes.models.templates.fieldset import ( FieldsetTemplate, + FieldsetTemplateKickoff, FieldsetTemplateRule, + FieldsetTemplateTaskTemplate, ) from src.processes.models.templates.fields import ( FieldTemplate, @@ -79,6 +81,25 @@ class Meta: ) +class FieldsetTemplateTaskTemplateSchemaV1(serializers.ModelSerializer): + + class Meta: + model = FieldsetTemplateTaskTemplate + fields = ( + 'task_api_name', + 'order', + ) + + task_api_name = serializers.CharField(source='task.api_name') + + +class FieldsetTemplateKickoffSchemaV1(serializers.ModelSerializer): + + class Meta: + model = FieldsetTemplateKickoff + fields = ('order',) + + class FieldSetSchemaV1(serializers.ModelSerializer): class Meta: @@ -87,11 +108,12 @@ class Meta: 'api_name', 'name', 'description', - 'order', 'label_position', 'layout', 'fields', 'rules', + 'task_links', + 'kickoff_links', ) fields = FieldSchemaV1(many=True, allow_null=True, allow_empty=True) @@ -100,6 +122,16 @@ class Meta: allow_null=True, allow_empty=True, ) + task_links = FieldsetTemplateTaskTemplateSchemaV1( + source='fieldsettemplatetasktemplate_set', + many=True, + required=False, + ) + kickoff_links = FieldsetTemplateKickoffSchemaV1( + source='fieldsettemplatekickoff_set', + many=True, + required=False, + ) class KickoffSchemaV1(serializers.ModelSerializer): diff --git a/backend/src/processes/services/workflows/kickoff_version.py b/backend/src/processes/services/workflows/kickoff_version.py index 6489f8a6f..6bb093882 100644 --- a/backend/src/processes/services/workflows/kickoff_version.py +++ b/backend/src/processes/services/workflows/kickoff_version.py @@ -137,6 +137,7 @@ 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, @@ -145,7 +146,7 @@ def _update_fieldsets(self, data: Optional[List]) -> None: 'account_id': self.instance.account_id, 'name': fs_data['name'], 'description': fs_data['description'], - 'order': fs_data['order'], + 'order': order, 'label_position': fs_data['label_position'], 'layout': fs_data['layout'], }, 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 bdd859d85..6a0ddc002 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 @@ -2667,15 +2667,16 @@ def test__update_fieldsets__data_provided__ok(mocker): # arrange user = create_test_owner() - workflow = create_test_workflow(user=user, tasks_count=1) - task = workflow.tasks.get(number=1) + workflow = create_test_workflow(user=user, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + task_2 = workflow.tasks.get(number=2) old_fieldset = create_test_fieldset( workflow=workflow, - task=task, + task=task_1, ) service = TaskUpdateVersionService( user=user, - instance=task, + instance=task_1, auth_type=AuthTokenType.USER, is_superuser=False, ) @@ -2684,7 +2685,16 @@ def test__update_fieldsets__data_provided__ok(mocker): 'api_name': 'fieldset-1', 'name': 'New Fieldset', 'description': 'Test description', - 'order': 1, + 'task_links': [ + { + 'task_api_name': task_1.api_name, + 'order': 2, + }, + { + 'task_api_name': task_2.api_name, + 'order': 1, + }, + ], 'label_position': LabelPosition.TOP, 'layout': FieldSetLayout.VERTICAL, 'rules': [ @@ -2724,11 +2734,11 @@ def test__update_fieldsets__data_provided__ok(mocker): assert not FieldSet.objects.filter(id=old_fieldset.id).exists() new_fieldset = FieldSet.objects.get( api_name='fieldset-1', - task=task, + task=task_1, ) assert new_fieldset.name == 'New Fieldset' assert new_fieldset.description == 'Test description' - assert new_fieldset.order == 1 + assert new_fieldset.order == 2 _update_fieldset_rules_mock.assert_called_once_with( fieldset=new_fieldset, rules_data=data[0]['rules'], 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 index dbba7ed80..e002caf02 100644 --- 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 @@ -858,7 +858,11 @@ def test__update_fieldsets__provided__ok(mocker): 'api_name': 'fs-1', 'name': 'Fieldset 1', 'description': 'Desc', - 'order': 0, + 'kickoff_links': [ + { + 'order': 11, + }, + ], 'label_position': LabelPosition.TOP, 'layout': FieldSetLayout.VERTICAL, 'rules': rules_data_1, @@ -885,7 +889,7 @@ def test__update_fieldsets__provided__ok(mocker): ) assert fieldset.name == 'Fieldset 1' assert fieldset.description == 'Desc' - assert fieldset.order == 0 + assert fieldset.order == 11 assert fieldset.label_position == LabelPosition.TOP assert fieldset.layout == FieldSetLayout.VERTICAL assert fieldset.account_id == account.id From b81d55a97db58efb39609e68547a02374f9da0f3 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Wed, 29 Apr 2026 06:51:02 +0500 Subject: [PATCH 06/46] 45773 feat(fieldsets): allow adding one same fieldset to different template tasks --- .../serializers/templates/fieldset_link.py | 37 + .../serializers/templates/kickoff.py | 18 +- .../processes/serializers/templates/task.py | 38 +- .../serializers/templates/template.py | 63 ++ .../services/templates/fieldsets/fieldset.py | 174 +++-- .../test_create/test_template.py | 418 ++++++++++ .../test_update/test_template.py | 720 ++++++++++++++++++ 7 files changed, 1390 insertions(+), 78 deletions(-) create mode 100644 backend/src/processes/serializers/templates/fieldset_link.py diff --git a/backend/src/processes/serializers/templates/fieldset_link.py b/backend/src/processes/serializers/templates/fieldset_link.py new file mode 100644 index 000000000..5226697c3 --- /dev/null +++ b/backend/src/processes/serializers/templates/fieldset_link.py @@ -0,0 +1,37 @@ +from rest_framework.fields import CharField +from rest_framework.serializers import ( + ModelSerializer, +) +from src.generics.mixins.serializers import CustomValidationErrorMixin +from src.processes.models.templates.fieldset import ( + FieldsetTemplateTaskTemplate, + FieldsetTemplateKickoff, +) + + +class FieldsetTemplateTaskTemplateSerializer( + CustomValidationErrorMixin, + ModelSerializer, +): + class Meta: + model = FieldsetTemplateTaskTemplate + fields = ( + 'order', + 'api_name', + ) + + api_name = CharField(source='fieldset.api_name') + + +class FieldsetTemplateKickoffSerializer( + CustomValidationErrorMixin, + ModelSerializer, +): + class Meta: + model = FieldsetTemplateKickoff + fields = ( + 'order', + 'api_name', + ) + + api_name = CharField(source='fieldset.api_name') diff --git a/backend/src/processes/serializers/templates/kickoff.py b/backend/src/processes/serializers/templates/kickoff.py index 03578dfdb..742d32aba 100644 --- a/backend/src/processes/serializers/templates/kickoff.py +++ b/backend/src/processes/serializers/templates/kickoff.py @@ -3,13 +3,10 @@ from rest_framework.serializers import ( ModelSerializer, ) - -from src.generics.fields import AccountPrimaryKeyRelatedField from src.generics.mixins.serializers import ( AdditionalValidationMixin, CustomValidationErrorMixin, ) -from src.processes.models.templates.fieldset import FieldsetTemplate from src.processes.models.templates.kickoff import Kickoff from src.processes.serializers.templates.field import ( FieldTemplateListSerializer, @@ -19,6 +16,9 @@ from src.processes.serializers.templates.fieldset import ( FieldsetTemplateSerializer, ) +from src.processes.serializers.templates.fieldset_link import ( + FieldsetTemplateKickoffSerializer, +) from src.processes.serializers.templates.mixins import ( CreateOrUpdateInstanceMixin, CreateOrUpdateRelatedMixin, @@ -45,12 +45,11 @@ class Meta: } fields = FieldTemplateSerializer(many=True, required=False, default=list) - fieldsets = AccountPrimaryKeyRelatedField( + fieldsets = FieldsetTemplateKickoffSerializer( + source='fieldsettemplatekickoff_set', many=True, - queryset=FieldsetTemplate.objects.all(), required=False, allow_empty=True, - default=list, ) def to_representation(self, instance): @@ -64,7 +63,7 @@ def to_representation(self, instance): def create(self, validated_data: Dict[str, Any]): self.additional_validate(validated_data) - fieldsets = validated_data.pop('fieldsets', None) or [] + validated_data.pop('fieldsettemplatekickoff_set', None) instance = self.create_or_update_instance( validated_data={ 'template': self.context['template'], @@ -72,7 +71,6 @@ def create(self, validated_data: Dict[str, Any]): **validated_data, }, ) - instance.fieldsets.set(fieldsets) self.create_or_update_related( data=validated_data.get('fields'), ancestors_data={ @@ -93,7 +91,7 @@ def update( validated_data: Dict[str, Any], ): self.additional_validate(validated_data) - fieldsets = validated_data.pop('fieldsets', None) or [] + validated_data.pop('fieldsettemplatekickoff_set', None) instance = self.create_or_update_instance( instance=instance, validated_data={ @@ -102,7 +100,7 @@ def update( **validated_data, }, ) - instance.fieldsets.set(fieldsets) + self.create_or_update_related( data=validated_data.get('fields'), ancestors_data={ diff --git a/backend/src/processes/serializers/templates/task.py b/backend/src/processes/serializers/templates/task.py index b1d15fe94..3311b881e 100644 --- a/backend/src/processes/serializers/templates/task.py +++ b/backend/src/processes/serializers/templates/task.py @@ -12,7 +12,6 @@ ) from src.analysis.services import AnalyticService -from src.generics.fields import AccountPrimaryKeyRelatedField from src.generics.mixins.serializers import ( AdditionalValidationMixin, CustomValidationErrorMixin, @@ -23,7 +22,6 @@ SystemVariable, ) from src.processes.messages import template as messages -from src.processes.models.templates.fieldset import FieldsetTemplate from src.processes.models.templates.fields import FieldTemplate from src.processes.models.templates.task import TaskTemplate from src.processes.serializers.templates.checklist import ( @@ -36,6 +34,9 @@ FieldTemplateSerializer, FieldTemplateShortViewSerializer, ) +from src.processes.serializers.templates.fieldset_link import ( + FieldsetTemplateTaskTemplateSerializer, +) from src.processes.serializers.templates.mixins import ( CreateOrUpdateInstanceMixin, CreateOrUpdateRelatedMixin, @@ -47,6 +48,9 @@ from src.processes.serializers.templates.raw_performer import ( RawPerformerSerializer, ) +from src.processes.services.templates.fieldsets.fieldset import ( + FieldSetTemplateService, +) from src.processes.utils.common import ( VAR_PATTERN, create_api_name, @@ -102,12 +106,11 @@ class Meta: number = IntegerField() api_name = CharField(max_length=200, required=False) fields = FieldTemplateSerializer(many=True, required=False) - fieldsets = AccountPrimaryKeyRelatedField( + fieldsets = FieldsetTemplateTaskTemplateSerializer( + source='fieldsettemplatetasktemplate_set', many=True, - queryset=FieldsetTemplate.objects.all(), required=False, allow_empty=True, - default=list, ) checklists = ChecklistTemplateSerializer(many=True, required=False) conditions = ConditionTemplateSerializer(many=True, required=False) @@ -388,7 +391,7 @@ def create(self, validated_data: Dict[str, Any]): api_name = validated_data['api_name'] parents = self.context['parents_by_tasks'][api_name] ancestors = list(self.context['ancestors_by_tasks'][api_name]) - fieldsets = validated_data.pop('fieldsets', None) or [] + validated_data.pop('fieldsettemplatetasktemplate_set', None) instance = self.create_or_update_instance( validated_data={ 'template': self.context['template'], @@ -398,7 +401,15 @@ def create(self, validated_data: Dict[str, Any]): **validated_data, }, ) - instance.fieldsets.set(fieldsets) + fieldsets_links = self.context.get( + 'tasks_fieldsets', {}, + ).get(api_name, []) + if fieldsets_links: + FieldSetTemplateService.create_or_update_tasks_links( + task=instance, + template=self.context['template'], + fieldsets_links=fieldsets_links, + ) template = self.context['template'] if template.is_active and validated_data.get('raw_due_date'): AnalyticService.templates_task_due_date_created( @@ -499,7 +510,7 @@ def update( and not hasattr(self.instance, 'raw_due_date') and validated_data.get('raw_due_date') ) - fieldsets = validated_data.pop('fieldsets', None) or [] + validated_data.pop('fieldsettemplatetasktemplate_set', None) instance = self.create_or_update_instance( instance=instance, validated_data={ @@ -510,8 +521,15 @@ def update( **validated_data, }, ) - if fieldsets is not None: - instance.fieldsets.set(fieldsets) + fieldsets_links = self.context.get( + 'tasks_fieldsets', {}, + ).get(api_name, []) + if fieldsets_links: + FieldSetTemplateService.create_or_update_tasks_links( + task=instance, + template=self.context['template'], + fieldsets_links=fieldsets_links, + ) if raw_due_date_created: AnalyticService.templates_task_due_date_created( user=self.context['user'], diff --git a/backend/src/processes/serializers/templates/template.py b/backend/src/processes/serializers/templates/template.py index 7662e37f7..affcc9a39 100644 --- a/backend/src/processes/serializers/templates/template.py +++ b/backend/src/processes/serializers/templates/template.py @@ -65,6 +65,9 @@ TaskTemplateSerializer, TemplateTaskOnlyFieldsSerializer, ) +from src.processes.services.templates.fieldsets.fieldset import ( + FieldSetTemplateService, +) from src.processes.services.templates.integrations import ( TemplateIntegrationsService, ) @@ -630,6 +633,9 @@ def create(self, validated_data: Dict[str, Any]) -> Template: 'template': instance, }, ) + fieldsets_links_raw = validated_data['kickoff'].pop( + 'fieldsettemplatekickoff_set', None, + ) or [] self.create_or_update_related_one( slz_cls=KickoffSerializer, data=validated_data['kickoff'], @@ -642,9 +648,35 @@ def create(self, validated_data: Dict[str, Any]) -> Template: 'template': instance, }, ) + if fieldsets_links_raw: + fieldsets_links = [ + { + 'api_name': link['fieldset']['api_name'], + 'order': link['order'], + } + for link in fieldsets_links_raw + ] + FieldSetTemplateService.create_or_update_kickoff_links( + kickoff=instance.kickoff_instance, + template=instance, + fieldsets_links=fieldsets_links, + ) 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) + tasks_fieldsets = {} + for task_data in validated_data['tasks']: + fieldsets_raw = task_data.pop( + 'fieldsettemplatetasktemplate_set', None, + ) or [] + if fieldsets_raw: + tasks_fieldsets[task_data['api_name']] = [ + { + 'api_name': link['fieldset']['api_name'], + 'order': link['order'], + } + for link in fieldsets_raw + ] self.create_or_update_related( data=validated_data['tasks'], ancestors_data={ @@ -658,6 +690,7 @@ def create(self, validated_data: Dict[str, Any]) -> Template: 'tasks_api_names': tasks_api_names, 'parents_by_tasks': parents_by_tasks, 'ancestors_by_tasks': ancestors_by_tasks, + 'tasks_fieldsets': tasks_fieldsets, }, ) @@ -701,6 +734,9 @@ def update( 'template': instance, }, ) + fieldsets_links_raw = validated_data['kickoff'].pop( + 'fieldsettemplatekickoff_set', None, + ) or [] self.create_or_update_related_one( slz_cls=KickoffSerializer, data=validated_data['kickoff'], @@ -713,9 +749,35 @@ def update( 'template': instance, }, ) + if fieldsets_links_raw: + fieldsets_links = [ + { + 'api_name': link['fieldset']['api_name'], + 'order': link['order'], + } + for link in fieldsets_links_raw + ] + FieldSetTemplateService.create_or_update_kickoff_links( + kickoff=instance.kickoff_instance, + template=instance, + fieldsets_links=fieldsets_links, + ) 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) + tasks_fieldsets = {} + for task_data in validated_data['tasks']: + fieldsets_raw = task_data.pop( + 'fieldsettemplatetasktemplate_set', None, + ) or [] + if fieldsets_raw: + tasks_fieldsets[task_data['api_name']] = [ + { + 'api_name': link['fieldset']['api_name'], + 'order': link['order'], + } + for link in fieldsets_raw + ] self.create_or_update_related( data=validated_data['tasks'], ancestors_data={ @@ -729,6 +791,7 @@ def update( 'tasks_api_names': tasks_api_names, 'parents_by_tasks': parents_by_tasks, 'ancestors_by_tasks': ancestors_by_tasks, + 'tasks_fieldsets': tasks_fieldsets, }, ) diff --git a/backend/src/processes/services/templates/fieldsets/fieldset.py b/backend/src/processes/services/templates/fieldsets/fieldset.py index 2e91aea73..05f0b435b 100644 --- a/backend/src/processes/services/templates/fieldsets/fieldset.py +++ b/backend/src/processes/services/templates/fieldsets/fieldset.py @@ -4,7 +4,11 @@ from src.generics.base.service import BaseModelService from src.processes.enums import LabelPosition, FieldSetLayout -from src.processes.models.templates.fieldset import FieldsetTemplate +from src.processes.models.templates.fieldset import FieldsetTemplate, \ + FieldsetTemplateKickoff, FieldsetTemplateTaskTemplate +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.services.exceptions import ( FieldsetTemplateInUseException, ) @@ -49,6 +53,69 @@ def _create_related( 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, + ) + 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, @@ -70,16 +137,6 @@ def partial_update( self._validate_rules() return self.instance - 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 delete(self) -> None: if self.instance.kickoffs.exists() or self.instance.tasks.exists(): raise FieldsetTemplateInUseException @@ -133,55 +190,56 @@ def update_rules( self.instance.rules.exclude(id__in=rules_ids).delete() - def _create_fields( - self, - fields_data: List[Dict], + @classmethod + def create_or_update_kickoff_links( + cls, + fieldsets_links: List[dict], + template: Template, + kickoff: Optional[Kickoff] = None, ): - for field_data in fields_data: - service = FieldTemplateService( - user=self.user, - is_superuser=self.is_superuser, - auth_type=self.auth_type, + api_names = {e['api_name'] for e in fieldsets_links} + for fieldset_link in fieldsets_links: + fieldset_template = FieldsetTemplate.objects.get( + template=template, + api_name=fieldset_link['api_name'], ) - service.create( - fieldset_id=self.instance.id, - template_id=self.instance.template_id, - **field_data, + FieldsetTemplateKickoff.objects.update_or_create( + fieldset=fieldset_template, + kickoff=kickoff, + defaults={ + 'order': fieldset_link['order'], + }, ) + ( + FieldsetTemplateKickoff.objects + .filter(kickoff=kickoff) + .exclude(fieldset__api_name__in=api_names) + .delete() + ) - def _update_fields( - self, - fields_data: List[Dict], + @classmethod + def create_or_update_tasks_links( + cls, + fieldsets_links: List[dict], + template: Template, + task: Optional[TaskTemplate] = None, ): - """ 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() + api_names = {e['api_name'] for e in fieldsets_links} + for fieldset_link in fieldsets_links: + fieldset_template = FieldsetTemplate.objects.get( + template=template, + api_name=fieldset_link['api_name'], + ) + FieldsetTemplateTaskTemplate.objects.update_or_create( + fieldset=fieldset_template, + task=task, + defaults={ + 'order': fieldset_link['order'], + }, + ) + ( + FieldsetTemplateTaskTemplate.objects + .filter(task=task) + .exclude(fieldset__api_name__in=api_names) + .delete() + ) 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..21408dc43 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 @@ -32,6 +32,10 @@ FieldTemplate, FieldTemplateSelection, ) +from src.processes.models.templates.fieldset import ( + FieldsetTemplateKickoff, + FieldsetTemplateTaskTemplate, +) from src.processes.models.templates.raw_performer import RawPerformerTemplate from src.processes.models.templates.task import TaskTemplate from src.processes.models.templates.template import Template @@ -3830,3 +3834,417 @@ 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_with_one_fieldset__ok( + mocker, + api_client, +): + + """ Creating a template with one fieldset linked to kickoff + calls create_or_update_kickoff_links with correct data. """ + + # 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', + ) + service_mock = mocker.patch( + 'src.processes.serializers.templates.template.' + 'FieldSetTemplateService.create_or_update_kickoff_links', + ) + fieldset_api_name = 'fieldset-test-1' + request_data = { + 'name': 'Template with fieldset', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'is_active': True, + 'kickoff': { + 'fieldsets': [ + { + 'api_name': fieldset_api_name, + 'order': 1, + }, + ], + }, + '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 kickoff is not None + service_mock.assert_called_once_with( + kickoff=kickoff, + template=template, + fieldsets_links=[ + {'api_name': fieldset_api_name, 'order': 1}, + ], + ) + + +def test_create__kickoff_with_empty_fieldsets__no_links_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 FieldsetTemplateKickoff.objects.filter( + kickoff=kickoff, + ).count() == 0 + + +def test_create__kickoff_without_fieldsets_key__no_links_created( + mocker, + api_client, +): + + """ Creating a template without fieldsets key in kickoff 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 key', + '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, + }, + ], + }, + ], + } + + # 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 FieldsetTemplateKickoff.objects.filter( + kickoff=kickoff, + ).count() == 0 + + +def test_create__task_with_one_fieldset__ok( + mocker, + api_client, +): + + """ Creating a template with one fieldset linked to a task + calls create_or_update_tasks_links with correct data. """ + + # 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', + ) + service_mock = mocker.patch( + 'src.processes.serializers.templates.task.' + 'FieldSetTemplateService.create_or_update_tasks_links', + ) + fieldset_api_name = 'fieldset-task-1' + 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': [ + { + 'api_name': fieldset_api_name, + 'order': 1, + }, + ], + }, + ], + } + + # 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 task is not None + service_mock.assert_called_once_with( + task=task, + template=template, + fieldsets_links=[ + {'api_name': fieldset_api_name, 'order': 1}, + ], + ) + + +def test_create__task_with_empty_fieldsets__no_links_created( + mocker, + api_client, +): + + """ Creating a template with empty fieldsets list in task does not + create any FieldsetTemplateTaskTemplate 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_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 FieldsetTemplateTaskTemplate.objects.filter( + task=task, + ).count() == 0 + + +def test_create__task_without_fieldsets_key__no_links_created( + mocker, + api_client, +): + + """ Creating a template without fieldsets key in task does not + create any FieldsetTemplateTaskTemplate 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_kickoff_created', + ) + request_data = { + 'name': 'Template no fieldsets key', + '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, + }, + ], + }, + ], + } + + # 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 FieldsetTemplateTaskTemplate.objects.filter( + task=task, + ).count() == 0 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..b50d524d1 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 @@ -26,7 +26,12 @@ ) from src.processes.messages import template as messages from src.processes.models.templates.fields import FieldTemplate +from src.processes.models.templates.fieldset import ( + FieldsetTemplateKickoff, + FieldsetTemplateTaskTemplate, +) from src.processes.models.templates.raw_performer import RawPerformerTemplate +from src.processes.models.templates.task import TaskTemplate from src.processes.models.templates.template import Template from src.processes.services.templates.integrations import ( TemplateIntegrationsService, @@ -34,6 +39,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, @@ -2829,3 +2835,717 @@ 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__kickoff_with_one_fieldset__ok( + mocker, + api_client, +): + + """ Updating a template with one fieldset linked to kickoff + creates a FieldsetTemplateKickoff record. """ + + # arrange + user = create_test_user() + api_client.token_authenticate(user) + template = create_test_template( + user, + is_active=True, + tasks_count=1, + ) + task = template.tasks.first() + fieldset = create_test_fieldset_template( + account=user.account, + template=template, + api_name='fieldset-update-1', + ) + 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', + ) + request_data = { + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': template.kickoff_instance.id, + 'fieldsets': [ + { + 'api_name': fieldset.api_name, + 'order': 3, + }, + ], + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.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 + kickoff = template.kickoff_instance + link = FieldsetTemplateKickoff.objects.get(kickoff=kickoff) + assert link.fieldset.api_name == fieldset.api_name + assert link.order == 3 + + +def test_update__kickoff_with_multiple_fieldsets__ok( + mocker, + api_client, +): + + """ Updating a template with multiple fieldsets linked to + kickoff creates multiple FieldsetTemplateKickoff records. """ + + # arrange + user = create_test_user() + api_client.token_authenticate(user) + template = create_test_template( + user, + is_active=True, + tasks_count=1, + ) + task = template.tasks.first() + create_test_fieldset_template( + account=user.account, + template=template, + api_name='fieldset-x', + ) + create_test_fieldset_template( + account=user.account, + template=template, + api_name='fieldset-y', + ) + 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', + ) + request_data = { + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': template.kickoff_instance.id, + 'fieldsets': [ + { + 'api_name': 'fieldset-x', + 'order': 0, + }, + { + 'api_name': 'fieldset-y', + '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, + }, + ], + }, + ], + } + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + kickoff = template.kickoff_instance + links = list( + FieldsetTemplateKickoff.objects.filter( + kickoff=kickoff, + ).order_by('order'), + ) + assert len(links) == 2 + assert links[0].fieldset.api_name == 'fieldset-x' + assert links[0].order == 0 + assert links[1].fieldset.api_name == 'fieldset-y' + assert links[1].order == 1 + + +def test_update__kickoff_with_empty_fieldsets__no_links_created( + mocker, + api_client, +): + + """ Updating a template with empty fieldsets list does not + create any FieldsetTemplateKickoff records. """ + + # arrange + user = create_test_user() + api_client.token_authenticate(user) + template = create_test_template( + user, + is_active=True, + tasks_count=1, + ) + 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', + ) + request_data = { + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': template.kickoff_instance.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, + }, + ], + }, + ], + } + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + kickoff = template.kickoff_instance + assert FieldsetTemplateKickoff.objects.filter( + kickoff=kickoff, + ).count() == 0 + + +def test_update__kickoff_without_fieldsets_key__no_links_created( + mocker, + api_client, +): + + """ Updating a template without fieldsets key in kickoff does not + create any FieldsetTemplateKickoff records. """ + + # arrange + user = create_test_user() + api_client.token_authenticate(user) + template = create_test_template( + user, + is_active=True, + tasks_count=1, + ) + 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', + ) + request_data = { + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': template.kickoff_instance.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, + }, + ], + }, + ], + } + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + kickoff = template.kickoff_instance + assert FieldsetTemplateKickoff.objects.filter( + kickoff=kickoff, + ).count() == 0 + + +def test_update__task_with_one_fieldset__ok( + mocker, + api_client, +): + + """ Updating a template with one fieldset linked to a task + creates a FieldsetTemplateTaskTemplate record. """ + + # arrange + user = create_test_user() + api_client.token_authenticate(user) + template = create_test_template( + user, + is_active=True, + tasks_count=1, + ) + task = template.tasks.first() + fieldset = create_test_fieldset_template( + account=user.account, + template=template, + api_name='fieldset-task-update-1', + ) + 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', + ) + request_data = { + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': template.kickoff_instance.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': [ + { + 'api_name': fieldset.api_name, + 'order': 2, + }, + ], + }, + ], + } + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + updated_task = TaskTemplate.objects.get( + template=template, + api_name=task.api_name, + ) + link = FieldsetTemplateTaskTemplate.objects.get(task=updated_task) + assert link.fieldset.api_name == fieldset.api_name + assert link.order == 2 + + +def test_update__task_with_multiple_fieldsets__ok( + mocker, + api_client, +): + + """ Updating a template with multiple fieldsets linked to a task + creates multiple FieldsetTemplateTaskTemplate records. """ + + # arrange + user = create_test_user() + api_client.token_authenticate(user) + template = create_test_template( + user, + is_active=True, + tasks_count=1, + ) + task = template.tasks.first() + create_test_fieldset_template( + account=user.account, + template=template, + api_name='fieldset-x', + ) + create_test_fieldset_template( + account=user.account, + template=template, + api_name='fieldset-y', + ) + 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', + ) + request_data = { + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': template.kickoff_instance.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': [ + { + 'api_name': 'fieldset-x', + 'order': 0, + }, + { + 'api_name': 'fieldset-y', + 'order': 1, + }, + ], + }, + ], + } + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + updated_task = TaskTemplate.objects.get( + template=template, + api_name=task.api_name, + ) + links = list( + FieldsetTemplateTaskTemplate.objects.filter( + task=updated_task, + ).order_by('order'), + ) + assert len(links) == 2 + assert links[0].fieldset.api_name == 'fieldset-x' + assert links[0].order == 0 + assert links[1].fieldset.api_name == 'fieldset-y' + assert links[1].order == 1 + + +def test_update__task_with_empty_fieldsets__no_links_created( + mocker, + api_client, +): + + """ Updating a template with empty fieldsets list in task does not + create any FieldsetTemplateTaskTemplate records. """ + + # arrange + user = create_test_user() + api_client.token_authenticate(user) + template = create_test_template( + user, + is_active=True, + tasks_count=1, + ) + 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', + ) + request_data = { + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': template.kickoff_instance.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': [], + }, + ], + } + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + updated_task = TaskTemplate.objects.get( + template=template, + api_name=task.api_name, + ) + assert FieldsetTemplateTaskTemplate.objects.filter( + task=updated_task, + ).count() == 0 + + +def test_update__task_without_fieldsets_key__no_links_created( + mocker, + api_client, +): + + """ Updating a template without fieldsets key in task does not + create any FieldsetTemplateTaskTemplate records. """ + + # arrange + user = create_test_user() + api_client.token_authenticate(user) + template = create_test_template( + user, + is_active=True, + tasks_count=1, + ) + 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', + ) + request_data = { + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': template.kickoff_instance.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, + }, + ], + }, + ], + } + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + updated_task = TaskTemplate.objects.get( + template=template, + api_name=task.api_name, + ) + assert FieldsetTemplateTaskTemplate.objects.filter( + task=updated_task, + ).count() == 0 From 9ac5c6c7e942230b96af6d5928a0d949441ce302 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Mon, 4 May 2026 16:53:00 +0500 Subject: [PATCH 07/46] 45773 fix(templates): correct removing fieldset from task and kickoff --- .../serializers/templates/fieldset.py | 10 +- .../processes/serializers/templates/task.py | 8 +- .../serializers/templates/template.py | 16 +- .../services/templates/fieldsets/fieldset.py | 12 +- .../test_fieldset_template_service.py | 64 ++ .../test_fieldsets/test_retrieve.py | 28 + .../test_update/test_fieldsets.py | 997 ++++++++++++++++++ .../test_update/test_template.py | 720 ------------- 8 files changed, 1119 insertions(+), 736 deletions(-) create mode 100644 backend/src/processes/tests/test_views/test_templates/test_update/test_fieldsets.py diff --git a/backend/src/processes/serializers/templates/fieldset.py b/backend/src/processes/serializers/templates/fieldset.py index c13339048..d68cc0cf2 100644 --- a/backend/src/processes/serializers/templates/fieldset.py +++ b/backend/src/processes/serializers/templates/fieldset.py @@ -10,7 +10,7 @@ from src.generics.mixins.serializers import CustomValidationErrorMixin from src.processes.models.templates.fieldset import ( FieldsetTemplate, - FieldsetTemplateRule, + FieldsetTemplateRule, FieldsetTemplateKickoff, ) from src.processes.serializers.templates.field import FieldTemplateSerializer from src.processes.serializers.templates.task import ( @@ -80,5 +80,9 @@ class Meta: kickoff = SerializerMethodField() def get_kickoff(self, instance: FieldsetTemplate): - kickoff = instance.kickoffs.all().first() - return kickoff.id if kickoff else None + through = FieldsetTemplateKickoff.objects.filter( + fieldset=instance, + ).first() + if through: + return through.kickoff_id + return None diff --git a/backend/src/processes/serializers/templates/task.py b/backend/src/processes/serializers/templates/task.py index 3311b881e..4a8c30470 100644 --- a/backend/src/processes/serializers/templates/task.py +++ b/backend/src/processes/serializers/templates/task.py @@ -403,8 +403,8 @@ def create(self, validated_data: Dict[str, Any]): ) fieldsets_links = self.context.get( 'tasks_fieldsets', {}, - ).get(api_name, []) - if fieldsets_links: + ).get(api_name) + if fieldsets_links is not None: FieldSetTemplateService.create_or_update_tasks_links( task=instance, template=self.context['template'], @@ -523,8 +523,8 @@ def update( ) fieldsets_links = self.context.get( 'tasks_fieldsets', {}, - ).get(api_name, []) - if fieldsets_links: + ).get(api_name) + if fieldsets_links is not None: FieldSetTemplateService.create_or_update_tasks_links( task=instance, template=self.context['template'], diff --git a/backend/src/processes/serializers/templates/template.py b/backend/src/processes/serializers/templates/template.py index affcc9a39..50331a8b0 100644 --- a/backend/src/processes/serializers/templates/template.py +++ b/backend/src/processes/serializers/templates/template.py @@ -635,7 +635,7 @@ def create(self, validated_data: Dict[str, Any]) -> Template: ) fieldsets_links_raw = validated_data['kickoff'].pop( 'fieldsettemplatekickoff_set', None, - ) or [] + ) self.create_or_update_related_one( slz_cls=KickoffSerializer, data=validated_data['kickoff'], @@ -648,7 +648,7 @@ def create(self, validated_data: Dict[str, Any]) -> Template: 'template': instance, }, ) - if fieldsets_links_raw: + if fieldsets_links_raw is not None: fieldsets_links = [ { 'api_name': link['fieldset']['api_name'], @@ -668,8 +668,8 @@ def create(self, validated_data: Dict[str, Any]) -> Template: for task_data in validated_data['tasks']: fieldsets_raw = task_data.pop( 'fieldsettemplatetasktemplate_set', None, - ) or [] - if fieldsets_raw: + ) + if fieldsets_raw is not None: tasks_fieldsets[task_data['api_name']] = [ { 'api_name': link['fieldset']['api_name'], @@ -736,7 +736,7 @@ def update( ) fieldsets_links_raw = validated_data['kickoff'].pop( 'fieldsettemplatekickoff_set', None, - ) or [] + ) self.create_or_update_related_one( slz_cls=KickoffSerializer, data=validated_data['kickoff'], @@ -749,7 +749,7 @@ def update( 'template': instance, }, ) - if fieldsets_links_raw: + if fieldsets_links_raw is not None: fieldsets_links = [ { 'api_name': link['fieldset']['api_name'], @@ -769,8 +769,8 @@ def update( for task_data in validated_data['tasks']: fieldsets_raw = task_data.pop( 'fieldsettemplatetasktemplate_set', None, - ) or [] - if fieldsets_raw: + ) + if fieldsets_raw is not None: tasks_fieldsets[task_data['api_name']] = [ { 'api_name': link['fieldset']['api_name'], diff --git a/backend/src/processes/services/templates/fieldsets/fieldset.py b/backend/src/processes/services/templates/fieldsets/fieldset.py index 05f0b435b..0bca35c3c 100644 --- a/backend/src/processes/services/templates/fieldsets/fieldset.py +++ b/backend/src/processes/services/templates/fieldsets/fieldset.py @@ -138,7 +138,17 @@ def partial_update( return self.instance def delete(self) -> None: - if self.instance.kickoffs.exists() or self.instance.tasks.exists(): + kickoffs_exists = ( + FieldsetTemplateKickoff.objects + .filter(fieldset=self.instance) + .exists() + ) + tasks_exists = ( + FieldsetTemplateTaskTemplate.objects + .filter(fieldset=self.instance) + .exists() + ) + if kickoffs_exists or tasks_exists: raise FieldsetTemplateInUseException self.instance.delete() diff --git a/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py index 115d4a530..e3717bc6c 100644 --- a/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py +++ b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py @@ -935,6 +935,70 @@ def test_delete__not_in_use__ok(): assert not FieldsetTemplate.objects.filter(id=fieldset.id).exists() +def test_delete__used_by_kickoff_deleted_record__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) + kickoff = template.kickoff_instance + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + fieldset.kickoffs.add(kickoff) + fieldset.kickoffs.clear() + 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(): + + """ + Not in use → 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', + ) + fieldset.tasks.add(task) + fieldset.tasks.clear() + 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(): """ 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 index 8bc1e9316..57f4f0ede 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py @@ -110,6 +110,34 @@ def test_retrieve__kickoff_fieldset__ok(api_client): assert response.data['kickoff'] == kickoff.id +def test_retrieve__used_by_kickoff_deleted_record__empty_kickoff(api_client): + + # 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 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + ) + fieldset.kickoffs.clear() + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/templates/fieldsets/{fieldset.id}') + + # assert + assert response.status_code == 200 + assert response.data['id'] == fieldset.id + assert response.data['kickoff'] is None + + def test_retrieve__task_fieldset__ok(api_client): # arrange 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..d5aff43b1 --- /dev/null +++ b/backend/src/processes/tests/test_views/test_templates/test_update/test_fieldsets.py @@ -0,0 +1,997 @@ +import pytest + +from src.processes.enums import ( + OwnerRole, + OwnerType, + PerformerType, +) +from src.processes.models.templates.fieldset import ( + FieldsetTemplateKickoff, + FieldsetTemplateTaskTemplate, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_fieldset_template, + create_test_owner, + create_test_template, +) + +pytestmark = pytest.mark.django_db + +# Kickoff fieldsets + + +def test_update__kickoff_with_one_fieldset__ok( + mocker, + api_client, +): + + """ Updating a template with one fieldset linked to kickoff + creates a FieldsetTemplateKickoff record. """ + + # 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() + fieldset = create_test_fieldset_template( + account=account, + template=template, + api_name='fieldset-update-1', + ) + 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', + ) + request_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': [ + { + 'api_name': fieldset.api_name, + 'order': 3, + }, + ], + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert response.data['kickoff'] == { + 'fields': [], + 'fieldsets': [ + { + 'api_name': fieldset.api_name, + 'order': 3, + }, + ], + } + assert FieldsetTemplateKickoff.objects.get( + kickoff=kickoff, + fieldset=fieldset, + order=3, + ) + + +def test_update__kickoff_create_two_fieldsets__ok( + mocker, + api_client, +): + + """ Updating a template with multiple fieldsets linked to + kickoff creates multiple FieldsetTemplateKickoff 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() + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + api_name='fieldset-x', + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + api_name='fieldset-y', + ) + 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', + ) + request_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': [ + { + 'api_name': fieldset_1.api_name, + 'order': 0, + }, + { + 'api_name': fieldset_2.api_name, + '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, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert response.data['kickoff'] == { + 'fields': [], + 'fieldsets': [ + { + 'api_name': fieldset_1.api_name, + 'order': 0, + }, + { + 'api_name': fieldset_2.api_name, + 'order': 1, + }, + ], + } + assert FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset_1, + kickoff=kickoff, + order=0, + ).count() == 1 + assert FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset_2, + kickoff=kickoff, + 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() + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + ) + 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', + ) + request_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': [ + { + 'api_name': fieldset_2.api_name, + '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, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert response.data['kickoff'] == { + 'fields': [], + 'fieldsets': [ + { + 'api_name': fieldset_2.api_name, + 'order': 2, + }, + ], + } + assert FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset_1, + kickoff=kickoff, + ).count() == 0 + assert FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset_2, + kickoff=kickoff, + 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() + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + ) + 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', + ) + request_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, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert response.data['kickoff'] == { + 'fields': [], + 'fieldsets': [], + } + fieldset.refresh_from_db() + assert FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset, + kickoff=kickoff, + ).count() == 0 + + +def test_update__kickoff_skip_fieldsets__no_fieldsets_created( + mocker, + api_client, +): + + """ Updating a template without fieldsets key in kickoff does not + create any FieldsetTemplateKickoff 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', + ) + request_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, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert FieldsetTemplateKickoff.objects.filter( + kickoff=kickoff, + ).count() == 0 + + +# Task fieldsets + + +def test_update__task_with_one_fieldset__ok( + mocker, + api_client, +): + + """ Updating a template with one fieldset linked to a task + creates a FieldsetTemplateTaskTemplate record. """ + + # 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() + fieldset = create_test_fieldset_template( + account=account, + template=template, + api_name='fieldset-task-update-1', + ) + 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', + ) + request_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': [ + { + 'api_name': fieldset.api_name, + 'order': 2, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert response.data['tasks'][0]['fieldsets'] == [ + { + 'api_name': fieldset.api_name, + 'order': 2, + }, + ] + assert FieldsetTemplateTaskTemplate.objects.get( + task=task, + fieldset=fieldset, + order=2, + ) + + +def test_update__task_create_two_fieldsets__ok( + mocker, + api_client, +): + + """ Updating a template with multiple fieldsets linked to a task + creates multiple FieldsetTemplateTaskTemplate 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 + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + api_name='fieldset-x', + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + api_name='fieldset-y', + ) + 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', + ) + request_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': [ + { + 'api_name': fieldset_1.api_name, + 'order': 1, + }, + { + 'api_name': fieldset_2.api_name, + 'order': 0, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert response.data['tasks'][0]['fieldsets'] == [ + { + 'api_name': fieldset_2.api_name, + 'order': 0, + }, + { + 'api_name': fieldset_1.api_name, + 'order': 1, + }, + ] + assert FieldsetTemplateTaskTemplate.objects.get( + task=task, + fieldset=fieldset_1, + order=1, + ) + assert FieldsetTemplateTaskTemplate.objects.get( + task=task, + fieldset=fieldset_2, + order=0, + ) + + +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() + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + task=task, + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + ) + 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', + ) + request_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': [ + { + 'api_name': fieldset_2.api_name, + 'order': 2, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert response.data['tasks'][0]['fieldsets'] == [ + { + 'api_name': fieldset_2.api_name, + 'order': 2, + }, + ] + fieldset_1.refresh_from_db() + assert FieldsetTemplateTaskTemplate.objects.filter( + task=task, + fieldset=fieldset_1, + ).count() == 0 + assert FieldsetTemplateTaskTemplate.objects.filter( + task=task, + fieldset=fieldset_2, + 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() + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + ) + 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', + ) + request_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': [], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert response.data['tasks'][0]['fieldsets'] == [] + assert FieldsetTemplateTaskTemplate.objects.filter( + task=task, + fieldset=fieldset, + ).count() == 0 + + +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 FieldsetTemplateTaskTemplate 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', + ) + request_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': [], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert FieldsetTemplateTaskTemplate.objects.filter(task=task).count() == 0 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 b50d524d1..ab13bff56 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 @@ -26,12 +26,7 @@ ) from src.processes.messages import template as messages from src.processes.models.templates.fields import FieldTemplate -from src.processes.models.templates.fieldset import ( - FieldsetTemplateKickoff, - FieldsetTemplateTaskTemplate, -) from src.processes.models.templates.raw_performer import RawPerformerTemplate -from src.processes.models.templates.task import TaskTemplate from src.processes.models.templates.template import Template from src.processes.services.templates.integrations import ( TemplateIntegrationsService, @@ -39,7 +34,6 @@ 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, @@ -2835,717 +2829,3 @@ 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__kickoff_with_one_fieldset__ok( - mocker, - api_client, -): - - """ Updating a template with one fieldset linked to kickoff - creates a FieldsetTemplateKickoff record. """ - - # arrange - user = create_test_user() - api_client.token_authenticate(user) - template = create_test_template( - user, - is_active=True, - tasks_count=1, - ) - task = template.tasks.first() - fieldset = create_test_fieldset_template( - account=user.account, - template=template, - api_name='fieldset-update-1', - ) - 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', - ) - request_data = { - 'id': template.id, - 'is_active': True, - 'name': 'Updated template', - 'owners': [ - { - 'type': OwnerType.USER, - 'source_id': user.id, - 'role': OwnerRole.OWNER, - }, - ], - 'kickoff': { - 'id': template.kickoff_instance.id, - 'fieldsets': [ - { - 'api_name': fieldset.api_name, - 'order': 3, - }, - ], - }, - 'tasks': [ - { - 'id': task.id, - 'api_name': task.api_name, - 'number': task.number, - 'name': task.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 - kickoff = template.kickoff_instance - link = FieldsetTemplateKickoff.objects.get(kickoff=kickoff) - assert link.fieldset.api_name == fieldset.api_name - assert link.order == 3 - - -def test_update__kickoff_with_multiple_fieldsets__ok( - mocker, - api_client, -): - - """ Updating a template with multiple fieldsets linked to - kickoff creates multiple FieldsetTemplateKickoff records. """ - - # arrange - user = create_test_user() - api_client.token_authenticate(user) - template = create_test_template( - user, - is_active=True, - tasks_count=1, - ) - task = template.tasks.first() - create_test_fieldset_template( - account=user.account, - template=template, - api_name='fieldset-x', - ) - create_test_fieldset_template( - account=user.account, - template=template, - api_name='fieldset-y', - ) - 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', - ) - request_data = { - 'id': template.id, - 'is_active': True, - 'name': 'Updated template', - 'owners': [ - { - 'type': OwnerType.USER, - 'source_id': user.id, - 'role': OwnerRole.OWNER, - }, - ], - 'kickoff': { - 'id': template.kickoff_instance.id, - 'fieldsets': [ - { - 'api_name': 'fieldset-x', - 'order': 0, - }, - { - 'api_name': 'fieldset-y', - '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, - }, - ], - }, - ], - } - - # act - response = api_client.put( - path=f'/templates/{template.id}', - data=request_data, - ) - - # assert - assert response.status_code == 200 - kickoff = template.kickoff_instance - links = list( - FieldsetTemplateKickoff.objects.filter( - kickoff=kickoff, - ).order_by('order'), - ) - assert len(links) == 2 - assert links[0].fieldset.api_name == 'fieldset-x' - assert links[0].order == 0 - assert links[1].fieldset.api_name == 'fieldset-y' - assert links[1].order == 1 - - -def test_update__kickoff_with_empty_fieldsets__no_links_created( - mocker, - api_client, -): - - """ Updating a template with empty fieldsets list does not - create any FieldsetTemplateKickoff records. """ - - # arrange - user = create_test_user() - api_client.token_authenticate(user) - template = create_test_template( - user, - is_active=True, - tasks_count=1, - ) - 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', - ) - request_data = { - 'id': template.id, - 'is_active': True, - 'name': 'Updated template', - 'owners': [ - { - 'type': OwnerType.USER, - 'source_id': user.id, - 'role': OwnerRole.OWNER, - }, - ], - 'kickoff': { - 'id': template.kickoff_instance.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, - }, - ], - }, - ], - } - - # act - response = api_client.put( - path=f'/templates/{template.id}', - data=request_data, - ) - - # assert - assert response.status_code == 200 - kickoff = template.kickoff_instance - assert FieldsetTemplateKickoff.objects.filter( - kickoff=kickoff, - ).count() == 0 - - -def test_update__kickoff_without_fieldsets_key__no_links_created( - mocker, - api_client, -): - - """ Updating a template without fieldsets key in kickoff does not - create any FieldsetTemplateKickoff records. """ - - # arrange - user = create_test_user() - api_client.token_authenticate(user) - template = create_test_template( - user, - is_active=True, - tasks_count=1, - ) - 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', - ) - request_data = { - 'id': template.id, - 'is_active': True, - 'name': 'Updated template', - 'owners': [ - { - 'type': OwnerType.USER, - 'source_id': user.id, - 'role': OwnerRole.OWNER, - }, - ], - 'kickoff': { - 'id': template.kickoff_instance.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, - }, - ], - }, - ], - } - - # act - response = api_client.put( - path=f'/templates/{template.id}', - data=request_data, - ) - - # assert - assert response.status_code == 200 - kickoff = template.kickoff_instance - assert FieldsetTemplateKickoff.objects.filter( - kickoff=kickoff, - ).count() == 0 - - -def test_update__task_with_one_fieldset__ok( - mocker, - api_client, -): - - """ Updating a template with one fieldset linked to a task - creates a FieldsetTemplateTaskTemplate record. """ - - # arrange - user = create_test_user() - api_client.token_authenticate(user) - template = create_test_template( - user, - is_active=True, - tasks_count=1, - ) - task = template.tasks.first() - fieldset = create_test_fieldset_template( - account=user.account, - template=template, - api_name='fieldset-task-update-1', - ) - 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', - ) - request_data = { - 'id': template.id, - 'is_active': True, - 'name': 'Updated template', - 'owners': [ - { - 'type': OwnerType.USER, - 'source_id': user.id, - 'role': OwnerRole.OWNER, - }, - ], - 'kickoff': { - 'id': template.kickoff_instance.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': [ - { - 'api_name': fieldset.api_name, - 'order': 2, - }, - ], - }, - ], - } - - # act - response = api_client.put( - path=f'/templates/{template.id}', - data=request_data, - ) - - # assert - assert response.status_code == 200 - updated_task = TaskTemplate.objects.get( - template=template, - api_name=task.api_name, - ) - link = FieldsetTemplateTaskTemplate.objects.get(task=updated_task) - assert link.fieldset.api_name == fieldset.api_name - assert link.order == 2 - - -def test_update__task_with_multiple_fieldsets__ok( - mocker, - api_client, -): - - """ Updating a template with multiple fieldsets linked to a task - creates multiple FieldsetTemplateTaskTemplate records. """ - - # arrange - user = create_test_user() - api_client.token_authenticate(user) - template = create_test_template( - user, - is_active=True, - tasks_count=1, - ) - task = template.tasks.first() - create_test_fieldset_template( - account=user.account, - template=template, - api_name='fieldset-x', - ) - create_test_fieldset_template( - account=user.account, - template=template, - api_name='fieldset-y', - ) - 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', - ) - request_data = { - 'id': template.id, - 'is_active': True, - 'name': 'Updated template', - 'owners': [ - { - 'type': OwnerType.USER, - 'source_id': user.id, - 'role': OwnerRole.OWNER, - }, - ], - 'kickoff': { - 'id': template.kickoff_instance.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': [ - { - 'api_name': 'fieldset-x', - 'order': 0, - }, - { - 'api_name': 'fieldset-y', - 'order': 1, - }, - ], - }, - ], - } - - # act - response = api_client.put( - path=f'/templates/{template.id}', - data=request_data, - ) - - # assert - assert response.status_code == 200 - updated_task = TaskTemplate.objects.get( - template=template, - api_name=task.api_name, - ) - links = list( - FieldsetTemplateTaskTemplate.objects.filter( - task=updated_task, - ).order_by('order'), - ) - assert len(links) == 2 - assert links[0].fieldset.api_name == 'fieldset-x' - assert links[0].order == 0 - assert links[1].fieldset.api_name == 'fieldset-y' - assert links[1].order == 1 - - -def test_update__task_with_empty_fieldsets__no_links_created( - mocker, - api_client, -): - - """ Updating a template with empty fieldsets list in task does not - create any FieldsetTemplateTaskTemplate records. """ - - # arrange - user = create_test_user() - api_client.token_authenticate(user) - template = create_test_template( - user, - is_active=True, - tasks_count=1, - ) - 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', - ) - request_data = { - 'id': template.id, - 'is_active': True, - 'name': 'Updated template', - 'owners': [ - { - 'type': OwnerType.USER, - 'source_id': user.id, - 'role': OwnerRole.OWNER, - }, - ], - 'kickoff': { - 'id': template.kickoff_instance.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': [], - }, - ], - } - - # act - response = api_client.put( - path=f'/templates/{template.id}', - data=request_data, - ) - - # assert - assert response.status_code == 200 - updated_task = TaskTemplate.objects.get( - template=template, - api_name=task.api_name, - ) - assert FieldsetTemplateTaskTemplate.objects.filter( - task=updated_task, - ).count() == 0 - - -def test_update__task_without_fieldsets_key__no_links_created( - mocker, - api_client, -): - - """ Updating a template without fieldsets key in task does not - create any FieldsetTemplateTaskTemplate records. """ - - # arrange - user = create_test_user() - api_client.token_authenticate(user) - template = create_test_template( - user, - is_active=True, - tasks_count=1, - ) - 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', - ) - request_data = { - 'id': template.id, - 'is_active': True, - 'name': 'Updated template', - 'owners': [ - { - 'type': OwnerType.USER, - 'source_id': user.id, - 'role': OwnerRole.OWNER, - }, - ], - 'kickoff': { - 'id': template.kickoff_instance.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, - }, - ], - }, - ], - } - - # act - response = api_client.put( - path=f'/templates/{template.id}', - data=request_data, - ) - - # assert - assert response.status_code == 200 - updated_task = TaskTemplate.objects.get( - template=template, - api_name=task.api_name, - ) - assert FieldsetTemplateTaskTemplate.objects.filter( - task=updated_task, - ).count() == 0 From 060065ef971da3d8a4f93eaa5c64b015a13b7fad Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 5 May 2026 10:36:09 +0500 Subject: [PATCH 08/46] 45773 fix(migrtions): rename migrations --- .../migrations/0142_auto_20260409_0907.py | 18 ------------------ ..._add_fieldsets.py => 0252_add_fieldsets.py} | 4 ++-- 2 files changed, 2 insertions(+), 20 deletions(-) delete mode 100644 backend/src/accounts/migrations/0142_auto_20260409_0907.py rename backend/src/processes/migrations/{0250_add_fieldsets.py => 0252_add_fieldsets.py} (98%) diff --git a/backend/src/accounts/migrations/0142_auto_20260409_0907.py b/backend/src/accounts/migrations/0142_auto_20260409_0907.py deleted file mode 100644 index 8e44b4c11..000000000 --- a/backend/src/accounts/migrations/0142_auto_20260409_0907.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2 on 2026-04-09 09:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0141_notification_text_default'), - ] - - 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/processes/migrations/0250_add_fieldsets.py b/backend/src/processes/migrations/0252_add_fieldsets.py similarity index 98% rename from backend/src/processes/migrations/0250_add_fieldsets.py rename to backend/src/processes/migrations/0252_add_fieldsets.py index 440d8723e..f868ba1a8 100644 --- a/backend/src/processes/migrations/0250_add_fieldsets.py +++ b/backend/src/processes/migrations/0252_add_fieldsets.py @@ -8,8 +8,8 @@ class Migration(migrations.Migration): dependencies = [ - ('accounts', '0142_auto_20260409_0907'), - ('processes', '0249_auto_20260403_1221'), + ('accounts', '0142_vacation_fields'), + ('processes', '0251_add_skip_for_starter'), ] operations = [ From 2cc2b382b3485538e97967174c841b7a854050d1 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 5 May 2026 13:31:16 +0500 Subject: [PATCH 09/46] 45773 fix(workflows): skip deleted fieldsets when workflow creates --- .../serializers/workflows/kickoff_value.py | 20 +- backend/src/processes/services/tasks/task.py | 26 +-- .../test_tasks/test_task_service.py | 47 +++++ .../test_views/test_templates/test_run.py | 174 ++++++++++++++++++ 4 files changed, 244 insertions(+), 23 deletions(-) diff --git a/backend/src/processes/serializers/workflows/kickoff_value.py b/backend/src/processes/serializers/workflows/kickoff_value.py index 21acdf9e2..c60d20f1f 100644 --- a/backend/src/processes/serializers/workflows/kickoff_value.py +++ b/backend/src/processes/serializers/workflows/kickoff_value.py @@ -83,20 +83,16 @@ def create(self, validated_data: Dict[str, Any]): clear_description=clear_description, ) workflow = validated_data['workflow'] - fieldset_templates = ( - kickoff.fieldsets - .prefetch_related('rules', 'fields') - .order_by('id') + fieldset_through_records = ( + FieldsetTemplateKickoff.objects + .filter(kickoff=kickoff) + .select_related('fieldset') + .prefetch_related('fieldset__rules', 'fieldset__fields') + .order_by('order') ) try: - for fieldset_template in fieldset_templates: - fieldset_through = ( - FieldsetTemplateKickoff.objects - .get( - fieldset=fieldset_template, - kickoff=kickoff, - ) - ) + for fieldset_through in fieldset_through_records: + fieldset_template = fieldset_through.fieldset service = FieldSetService(user=self.context['user']) service.create( instance_template=fieldset_template, diff --git a/backend/src/processes/services/tasks/task.py b/backend/src/processes/services/tasks/task.py index 3aaba23b9..00f88c9a8 100644 --- a/backend/src/processes/services/tasks/task.py +++ b/backend/src/processes/services/tasks/task.py @@ -206,9 +206,13 @@ def create_conditions_from_template( self.create_rules(conditions, conditions_tree) def create_fields_from_template(self, instance_template: TaskTemplate): - + active_fieldset_ids = ( + FieldsetTemplateTaskTemplate.objects + .filter(task=instance_template) + .values_list('fieldset_id', flat=True) + ) for field_template in instance_template.fields.exclude( - fieldset__in=instance_template.fieldsets.all(), + fieldset__in=active_fieldset_ids, ): service = TaskFieldService(user=self.user) service.create( @@ -222,17 +226,17 @@ def create_fieldsets_from_template( self, instance_template: TaskTemplate, ): - for fs_template in instance_template.fieldsets.all().order_by('id'): - fieldset_through = ( - FieldsetTemplateTaskTemplate.objects - .get( - fieldset=fs_template, - task=instance_template, - ) - ) + fieldset_through_records = ( + FieldsetTemplateTaskTemplate.objects + .filter(task=instance_template) + .select_related('fieldset') + .prefetch_related('fieldset__rules', 'fieldset__fields') + .order_by('order') + ) + for fieldset_through in fieldset_through_records: service = FieldSetService(user=self.user) service.create( - instance_template=fs_template, + instance_template=fieldset_through.fieldset, account_id=self.instance.workflow.account_id, workflow=self.instance.workflow, task=self.instance, 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 9fe7c2439..1fd2daab0 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,6 +13,9 @@ ChecklistTemplate, ChecklistTemplateSelection, ) +from src.processes.models.templates.fieldset import ( + FieldsetTemplateTaskTemplate, +) 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 @@ -2090,3 +2093,47 @@ def test_set_due_date_directly__default__ok(mocker): 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_deleted = create_test_fieldset_template( + account=user.account, + template=template, + task=template_task, + name='Deleted fieldset', + order=0, + ) + FieldsetTemplateTaskTemplate.objects.filter( + fieldset=fieldset_deleted, + task=template_task, + ).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_views/test_templates/test_run.py b/backend/src/processes/tests/test_views/test_templates/test_run.py index be1f3c8ca..dd542d6f6 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 @@ -44,6 +44,7 @@ ) from src.processes.models.templates.fieldset import ( FieldsetTemplateKickoff, + FieldsetTemplateTaskTemplate, ) from src.processes.models.templates.fields import ( FieldTemplate, @@ -5612,3 +5613,176 @@ def test_run__kickoff_fieldset_required_empty__validation_error( assert response.data['code'] == ErrorCode.VALIDATION_ERROR assert response.data['message'] == messages.MSG_PW_0023 assert response.data['details']['api_name'] == field_template.api_name + + +def test_run__kickoff_soft_deleted_fieldset_through__ok( + mocker, + api_client, +): + + """ Soft-deleted FieldsetTemplateKickoff is skipped. """ + + # 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, + name='Deleted fieldset', + order=0, + ) + FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset_template, + kickoff=template.kickoff_instance, + ).delete() + 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': {}, + }, + ) + + # 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() == 0 + assert response.data['kickoff']['fieldsets'] == [] + + +def test_run__kickoff_deleted_fieldset_among_active__ok( + mocker, + api_client, +): + + """ Only active FieldsetTemplateKickoff records produce FieldSets. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + fieldset_deleted = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + name='Deleted fieldset', + order=0, + ) + fieldset_active = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + name='Active fieldset', + order=1, + ) + FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset_deleted, + kickoff=template.kickoff_instance, + ).delete() + field_template = fieldset_active.fields.first() + field_value = 'test value' + 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_active.name + assert fieldset.api_name == fieldset_active.api_name + fieldsets_data = response.data['kickoff']['fieldsets'] + assert len(fieldsets_data) == 1 + assert fieldsets_data[0]['name'] == fieldset_active.name + assert fieldsets_data[0]['order'] == 1 + + +def test_run__task_soft_deleted_fieldset_through__ok( + mocker, + api_client, +): + + """ Soft-deleted FieldsetTemplateTaskTemplate is skipped. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + task_template = template.tasks.first() + fieldset_template = create_test_fieldset_template( + account=user.account, + template=template, + task=task_template, + name='Deleted task fieldset', + order=0, + ) + FieldsetTemplateTaskTemplate.objects.filter( + fieldset=fieldset_template, + task=task_template, + ).delete() + 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': {}, + }, + ) + + # assert + assert response.status_code == 200 + workflow = Workflow.objects.get(id=response.data['id']) + task = workflow.tasks.first() + assert task.fieldsets.count() == 0 From cd8ef70393c8e3e86a1390a516df002c0a8c4938 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Wed, 6 May 2026 13:35:57 +0500 Subject: [PATCH 10/46] 45773 feat(templates): add fieldsets to GET /templates/id/fieldsets --- .../serializers/templates/fieldset.py | 43 +++++++++++++++---- .../serializers/templates/kickoff.py | 11 +++-- .../processes/serializers/templates/task.py | 12 +++++- .../test_views/test_templates/test_fields.py | 31 +++++++++++-- 4 files changed, 80 insertions(+), 17 deletions(-) diff --git a/backend/src/processes/serializers/templates/fieldset.py b/backend/src/processes/serializers/templates/fieldset.py index d68cc0cf2..be4298a3c 100644 --- a/backend/src/processes/serializers/templates/fieldset.py +++ b/backend/src/processes/serializers/templates/fieldset.py @@ -1,3 +1,4 @@ +# ruff: noqa: PLC0415 from rest_framework.fields import CharField, SerializerMethodField from rest_framework.serializers import ( IntegerField, @@ -12,9 +13,9 @@ FieldsetTemplate, FieldsetTemplateRule, FieldsetTemplateKickoff, ) -from src.processes.serializers.templates.field import FieldTemplateSerializer -from src.processes.serializers.templates.task import ( - TemplateStepNameSerializer, +from src.processes.serializers.templates.field import ( + FieldTemplateSerializer, + FieldTemplateShortViewSerializer, ) @@ -72,11 +73,7 @@ class Meta: required=False, default=list, ) - tasks = TemplateStepNameSerializer( - many=True, - read_only=True, - default=list, - ) + tasks = SerializerMethodField() kickoff = SerializerMethodField() def get_kickoff(self, instance: FieldsetTemplate): @@ -86,3 +83,33 @@ def get_kickoff(self, instance: FieldsetTemplate): if through: return through.kickoff_id return None + + def get_tasks(self, instance): + # Resolve cyclic imports with TemplateTaskOnlyFieldsSerializer + from src.processes.serializers.templates.task import ( + TemplateStepNameSerializer, + ) + return TemplateStepNameSerializer( + instance=instance.tasks.all(), + many=True, + default=list, + ).data + + +class FieldsetTemplateShortViewSerializer(ModelSerializer): + + class Meta: + model = FieldsetTemplate + fields = ( + 'name', + 'description', + 'fields', + 'api_name', + ) + + api_name = CharField(required=False, max_length=200) + fields = FieldTemplateShortViewSerializer( + many=True, + required=False, + read_only=True, + ) diff --git a/backend/src/processes/serializers/templates/kickoff.py b/backend/src/processes/serializers/templates/kickoff.py index 742d32aba..5d8c87fab 100644 --- a/backend/src/processes/serializers/templates/kickoff.py +++ b/backend/src/processes/serializers/templates/kickoff.py @@ -14,7 +14,7 @@ FieldTemplateShortViewSerializer, ) from src.processes.serializers.templates.fieldset import ( - FieldsetTemplateSerializer, + FieldsetTemplateSerializer, FieldsetTemplateShortViewSerializer, ) from src.processes.serializers.templates.fieldset_link import ( FieldsetTemplateKickoffSerializer, @@ -126,7 +126,12 @@ class Meta: fields = FieldTemplateShortViewSerializer( many=True, - required=False, + default=[], + read_only=True, + ) + fieldsets = FieldsetTemplateShortViewSerializer( + many=True, + default=[], read_only=True, ) @@ -135,8 +140,6 @@ def to_representation(self, instance): 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) diff --git a/backend/src/processes/serializers/templates/task.py b/backend/src/processes/serializers/templates/task.py index d1aa1f901..520a7224b 100644 --- a/backend/src/processes/serializers/templates/task.py +++ b/backend/src/processes/serializers/templates/task.py @@ -34,6 +34,9 @@ FieldTemplateSerializer, FieldTemplateShortViewSerializer, ) +from src.processes.serializers.templates.fieldset import ( + FieldsetTemplateShortViewSerializer, +) from src.processes.serializers.templates.fieldset_link import ( FieldsetTemplateTaskTemplateSerializer, ) @@ -627,10 +630,10 @@ class Meta: class TemplateTaskOnlyFieldsSerializer(ModelSerializer): + class Meta: model = TaskTemplate fields = ( - 'id', # Deprecated 'name', 'number', 'api_name', @@ -640,7 +643,12 @@ class Meta: fields = FieldTemplateShortViewSerializer( many=True, - required=False, + default=[], + read_only=True, + ) + fieldsets = FieldsetTemplateShortViewSerializer( + many=True, + default=[], read_only=True, ) 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 667191331..4f081b40f 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 @@ -75,7 +75,6 @@ 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 @@ -537,6 +536,7 @@ def test_fields__kickoff_fieldset__ok(api_client): template=template, kickoff=kickoff, ) + field = fieldset.fields.first() api_client.token_authenticate(user) # act @@ -546,7 +546,19 @@ def test_fields__kickoff_fieldset__ok(api_client): assert response.status_code == 200 data = response.data assert data['id'] == template.id - assert data['kickoff']['fieldsets'] == [fieldset.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['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 def test_fields__task_fieldset__ok(api_client): @@ -560,6 +572,7 @@ def test_fields__task_fieldset__ok(api_client): template=template, task=task, ) + field = fieldset.fields.first() api_client.token_authenticate(user) # act @@ -569,4 +582,16 @@ def test_fields__task_fieldset__ok(api_client): assert response.status_code == 200 data = response.data assert data['id'] == template.id - assert data['tasks'][0]['fieldsets'] == [fieldset.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['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 From 4fa0dec80d40db79a7ecc7dd795faad7b0bf9673 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Wed, 6 May 2026 22:17:52 +0500 Subject: [PATCH 11/46] 45773 fix(templates): add fieldset order to the GET /templates API --- .../processes/models/templates/fieldset.py | 11 +- .../serializers/templates/fieldset_link.py | 21 +++ .../serializers/templates/kickoff.py | 8 +- .../serializers/workflows/kickoff_value.py | 6 +- .../services/workflows/fieldsets/fieldset.py | 4 +- .../workflows/fieldsets/fieldset_rule.py | 4 +- .../test_views/test_templates/test_list.py | 125 +++++++++++++++++ .../test_templates/test_titles_by_owners.py | 129 ++++++++++++++++++ 8 files changed, 300 insertions(+), 8 deletions(-) diff --git a/backend/src/processes/models/templates/fieldset.py b/backend/src/processes/models/templates/fieldset.py index d92686470..075cf8b2f 100644 --- a/backend/src/processes/models/templates/fieldset.py +++ b/backend/src/processes/models/templates/fieldset.py @@ -93,8 +93,9 @@ class Meta: def __str__(self): return ( - f'{self.fieldset_template} - {self.task_template} ' - f'(order={self.order})' + f'Fieldset: {self.fieldset_id} - ' + f'Task: {self.task_template_id} - ' + f'Order: {self.order}' ) @@ -124,7 +125,11 @@ class Meta: )() def __str__(self): - return f'{self.fieldset_template} - kickoff (order={self.order})' + return ( + f'Fieldset: {self.fieldset_id} - ' + f'Kickoff: {self.kickoff_id} - ' + f'Order: {self.order}' + ) class FieldsetTemplateRule( diff --git a/backend/src/processes/serializers/templates/fieldset_link.py b/backend/src/processes/serializers/templates/fieldset_link.py index 5226697c3..28550818b 100644 --- a/backend/src/processes/serializers/templates/fieldset_link.py +++ b/backend/src/processes/serializers/templates/fieldset_link.py @@ -2,6 +2,7 @@ from rest_framework.serializers import ( ModelSerializer, ) +from src.processes.serializers.templates.field import FieldTemplateSerializer from src.generics.mixins.serializers import CustomValidationErrorMixin from src.processes.models.templates.fieldset import ( FieldsetTemplateTaskTemplate, @@ -35,3 +36,23 @@ class Meta: ) api_name = CharField(source='fieldset.api_name') + + +class FieldsetTemplateKickoffListSerializer(ModelSerializer): + class Meta: + model = FieldsetTemplateKickoff + fields = ( + 'order', + 'name', + 'description', + 'fields', + 'api_name', + ) + + name = CharField(source='fieldset.name') + description = CharField(source='fieldset.description') + api_name = CharField(source='fieldset.api_name') + fields = FieldTemplateSerializer( + source='fieldset.fields', + many=True, + ) diff --git a/backend/src/processes/serializers/templates/kickoff.py b/backend/src/processes/serializers/templates/kickoff.py index 5d8c87fab..0d2145fa0 100644 --- a/backend/src/processes/serializers/templates/kickoff.py +++ b/backend/src/processes/serializers/templates/kickoff.py @@ -14,10 +14,11 @@ FieldTemplateShortViewSerializer, ) from src.processes.serializers.templates.fieldset import ( - FieldsetTemplateSerializer, FieldsetTemplateShortViewSerializer, + FieldsetTemplateShortViewSerializer, ) from src.processes.serializers.templates.fieldset_link import ( FieldsetTemplateKickoffSerializer, + FieldsetTemplateKickoffListSerializer, ) from src.processes.serializers.templates.mixins import ( CreateOrUpdateInstanceMixin, @@ -153,4 +154,7 @@ class Meta: ) fields = FieldTemplateListSerializer(many=True) - fieldsets = FieldsetTemplateSerializer(many=True) + fieldsets = FieldsetTemplateKickoffListSerializer( + source='fieldsettemplatekickoff_set', + many=True, + ) diff --git a/backend/src/processes/serializers/workflows/kickoff_value.py b/backend/src/processes/serializers/workflows/kickoff_value.py index c60d20f1f..d796f551e 100644 --- a/backend/src/processes/serializers/workflows/kickoff_value.py +++ b/backend/src/processes/serializers/workflows/kickoff_value.py @@ -114,11 +114,15 @@ def create(self, validated_data: Dict[str, Any]): kickoff_id=instance.id, value=fields_data.get(field_template.api_name), ) - except (TaskFieldException, FieldsetServiceException) as ex: + 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( diff --git a/backend/src/processes/services/workflows/fieldsets/fieldset.py b/backend/src/processes/services/workflows/fieldsets/fieldset.py index ac2661e39..3c2d6b0db 100644 --- a/backend/src/processes/services/workflows/fieldsets/fieldset.py +++ b/backend/src/processes/services/workflows/fieldsets/fieldset.py @@ -24,7 +24,9 @@ def _create_instance( task = kwargs.get('task') kickoff = kwargs.get('kickoff') if not (task or kickoff): - raise FieldsetServiceException(MSG_FS_0007) + raise FieldsetServiceException( + message=MSG_FS_0007, + ) self.instance = FieldSet.objects.create( account=self.account, diff --git a/backend/src/processes/services/workflows/fieldsets/fieldset_rule.py b/backend/src/processes/services/workflows/fieldsets/fieldset_rule.py index 1f32d59cc..6330e0db1 100644 --- a/backend/src/processes/services/workflows/fieldsets/fieldset_rule.py +++ b/backend/src/processes/services/workflows/fieldsets/fieldset_rule.py @@ -20,7 +20,9 @@ def _validate_sum_equal(self, **kwargs): if field.value not in self.NULL_VALUES: total += float(field.value) if total != float(self.instance.value): - raise FieldsetServiceException(MSG_FS_0002(self.instance.value)) + raise FieldsetServiceException( + message=MSG_FS_0002(self.instance.value), + ) return True def validate(self, **kwargs): 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..17af38898 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 @@ -15,12 +15,16 @@ ) from src.processes.models.templates.fields import FieldTemplate from src.processes.models.templates.fields import FieldTemplateSelection +from src.processes.models.templates.fieldset import ( + FieldsetTemplateKickoff, +) from src.processes.models.templates.owner import TemplateOwner from src.processes.models.templates.template import Template from src.processes.tests.fixtures import ( create_invited_user, create_test_account, create_test_dataset, + create_test_fieldset_template, create_test_group, create_test_owner, create_test_template, @@ -1429,3 +1433,124 @@ 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 + fieldset = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=kickoff, + name='Personal Info', + description='Enter your personal information', + api_name='fieldset-personal', + order=5, + ) + fieldset_link = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset, + kickoff=kickoff, + ) + 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['order'] == fieldset_link.order + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['api_name'] == fieldset.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['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 + fieldset_2 = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=kickoff, + name='Second Fieldset', + api_name='fieldset-second', + order=2, + ) + link_2 = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset_2, + kickoff=kickoff, + ) + fieldset_1 = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=kickoff, + name='First Fieldset', + api_name='fieldset-first', + order=1, + ) + link_1 = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset_1, + kickoff=kickoff, + ) + 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'] == link_1.order + assert fieldsets[0]['api_name'] == fieldset_1.api_name + assert fieldsets[1]['order'] == link_2.order + assert fieldsets[1]['api_name'] == fieldset_2.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..bb355a948 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 @@ -4,9 +4,13 @@ from src.processes.enums import OwnerRole, OwnerType, FieldType from src.processes.models.templates.fields import FieldTemplate, \ FieldTemplateSelection +from src.processes.models.templates.fieldset import ( + FieldsetTemplateKickoff, +) 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,128 @@ 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, + name='Personal Info', + description='Enter your personal information', + api_name='fieldset-personal', + order=5, + ) + fieldset_link = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset, + kickoff=kickoff, + ) + fieldset_field = fieldset.fields.first() + 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_link.order + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['api_name'] == fieldset.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['type'] == fieldset_field.type + 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, + name='Second Fieldset', + api_name='fieldset-second', + order=2, + ) + link_2 = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset_2, + kickoff=kickoff, + ) + fieldset_1 = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=kickoff, + name='First Fieldset', + api_name='fieldset-first', + order=1, + ) + link_1 = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset_1, + kickoff=kickoff, + ) + 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'] == link_1.order + assert fieldsets[0]['api_name'] == fieldset_1.api_name + assert fieldsets[1]['order'] == link_2.order + assert fieldsets[1]['api_name'] == fieldset_2.api_name From 4e44624424c9c83fcf5b35b163fca75eb7774291 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Wed, 6 May 2026 23:34:18 +0500 Subject: [PATCH 12/46] 45773 fix(templates): add fieldset to the GET /templates/public --- .../serializers/templates/fieldset_link.py | 6 + .../serializers/templates/public/kickoff.py | 11 +- .../serializers/templates/public/template.py | 21 +- .../test_views/test_templates/test_list.py | 2 + .../test_public/test_retrieve.py | 418 ++++++++++++++++++ .../test_templates/test_titles_by_owners.py | 2 + 6 files changed, 446 insertions(+), 14 deletions(-) diff --git a/backend/src/processes/serializers/templates/fieldset_link.py b/backend/src/processes/serializers/templates/fieldset_link.py index 28550818b..abf953d13 100644 --- a/backend/src/processes/serializers/templates/fieldset_link.py +++ b/backend/src/processes/serializers/templates/fieldset_link.py @@ -47,11 +47,17 @@ class Meta: 'description', 'fields', 'api_name', + 'label_position', + 'layout', ) name = CharField(source='fieldset.name') description = CharField(source='fieldset.description') api_name = CharField(source='fieldset.api_name') + label_position = CharField( + source='fieldset.label_position', + ) + layout = CharField(source='fieldset.layout') fields = FieldTemplateSerializer( source='fieldset.fields', many=True, diff --git a/backend/src/processes/serializers/templates/public/kickoff.py b/backend/src/processes/serializers/templates/public/kickoff.py index a7dac5b79..9f8890281 100644 --- a/backend/src/processes/serializers/templates/public/kickoff.py +++ b/backend/src/processes/serializers/templates/public/kickoff.py @@ -4,14 +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, -) +from src.processes.serializers.templates.fieldset_link import \ + FieldsetTemplateKickoffListSerializer class PublicKickoffSerializer(ModelSerializer): @@ -26,7 +24,10 @@ class Meta: description = CharField(allow_blank=True, default='') fields = PublicFieldTemplateSerializer(many=True, required=False) - fieldsets = FieldsetTemplateSerializer(many=True, required=False) + fieldsets = FieldsetTemplateKickoffListSerializer( + source='fieldsettemplatekickoff_set', + many=True, + ) def to_representation(self, data: Dict[str, Any]): data = super().to_representation(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/tests/test_views/test_templates/test_list.py b/backend/src/processes/tests/test_views/test_templates/test_list.py index 17af38898..0594923df 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 @@ -1475,6 +1475,8 @@ def test_list__kickoff_fieldset__ok(api_client): 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 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..ce3f58a08 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 @@ -9,8 +9,12 @@ ) from src.processes.models.templates.fields import FieldTemplate, \ FieldTemplateSelection +from src.processes.models.templates.fieldset import ( + FieldsetTemplateKickoff, +) from src.processes.tests.fixtures import ( create_test_template, + create_test_fieldset_template, create_test_owner, create_test_dataset, create_test_account, @@ -419,6 +423,213 @@ 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, + name='Personal Info', + description='Enter info', + api_name='fieldset-personal', + order=5, + ) + fieldset_link = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset, + kickoff=kickoff, + ) + 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_link.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, + name='Second Fieldset', + api_name='fieldset-second', + order=2, + ) + link_2 = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset_2, + kickoff=kickoff, + ) + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='First Fieldset', + api_name='fieldset-first', + order=1, + ) + link_1 = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset_1, + kickoff=kickoff, + ) + 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'] == link_1.order + assert fieldsets[0]['api_name'] == fieldset_1.api_name + assert fieldsets[1]['order'] == link_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 +847,210 @@ 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 + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Personal Info', + description='Enter info', + api_name='fieldset-personal', + order=5, + ) + fieldset_link = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset, + kickoff=kickoff, + ) + fieldset_field = fieldset.fields.first() + 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_link.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 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, + name='Second Fieldset', + api_name='fieldset-second', + order=2, + ) + link_2 = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset_2, + kickoff=kickoff, + ) + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='First Fieldset', + api_name='fieldset-first', + order=1, + ) + link_1 = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset_1, + kickoff=kickoff, + ) + 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'] == link_1.order + assert fieldsets[0]['api_name'] == fieldset_1.api_name + assert fieldsets[1]['order'] == link_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_titles_by_owners.py b/backend/src/processes/tests/test_views/test_templates/test_titles_by_owners.py index bb355a948..88da68ed6 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 @@ -488,6 +488,8 @@ def test_titles_by_owners__kickoff_fieldset__ok(api_client): 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 From fcf7b98432fb0cee8718685e4103ce8207eada51 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Fri, 8 May 2026 12:45:29 +0500 Subject: [PATCH 13/46] 45773 feat(fieldsets): not raise "sum_equal" exception for a blank and not requred fields --- .../workflows/fieldsets/fieldset_rule.py | 9 +- .../test_fieldset_rule_service.py | 152 ++++++++++++++++++ 2 files changed, 159 insertions(+), 2 deletions(-) diff --git a/backend/src/processes/services/workflows/fieldsets/fieldset_rule.py b/backend/src/processes/services/workflows/fieldsets/fieldset_rule.py index 6330e0db1..4540da22f 100644 --- a/backend/src/processes/services/workflows/fieldsets/fieldset_rule.py +++ b/backend/src/processes/services/workflows/fieldsets/fieldset_rule.py @@ -16,10 +16,15 @@ class FieldSetRuleService(BaseModelService): def _validate_sum_equal(self, **kwargs): total = 0 + values_exists = False for field in self.instance.fields.all(): - if field.value not in self.NULL_VALUES: + if field.value in self.NULL_VALUES: + if field.is_required: + values_exists = True + else: total += float(field.value) - if total != float(self.instance.value): + values_exists = True + if values_exists and total != float(self.instance.value): raise FieldsetServiceException( message=MSG_FS_0002(self.instance.value), ) 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 index d957ae2f1..809e13b41 100644 --- 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 @@ -130,6 +130,158 @@ def test__validate_sum_equal__within_threshold__ok(): 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(): """ From 1a7c0f3fc652a4f45e6a27263f55730bc41f33b5 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Fri, 8 May 2026 19:40:57 +0500 Subject: [PATCH 14/46] 45773 feat(workflow): GET /workflows?fields api now returns fieldset fields too --- backend/src/processes/querysets.py | 7 +- .../processes/serializers/workflows/field.py | 1 + .../test_views/test_workflow/test_list.py | 423 ++++++++++++++++++ 3 files changed, 430 insertions(+), 1 deletion(-) diff --git a/backend/src/processes/querysets.py b/backend/src/processes/querysets.py index 7cb19f033..f895daf49 100644 --- a/backend/src/processes/querysets.py +++ b/backend/src/processes/querysets.py @@ -690,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', + ) ), ), ) 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/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 From d528e16ff83e12c1b5f7c19fe2f130fcb977e34e Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Fri, 8 May 2026 20:27:27 +0500 Subject: [PATCH 15/46] 45773 feat(workflow): GET /workflows/fields api now returns fieldset fields too --- .../test_views/test_workflow/test_fields.py | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) 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 From c53259fe668052cfbc1ce06ccc08cf11c7c84b5b Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 12 May 2026 11:58:23 +0500 Subject: [PATCH 16/46] 45773 feat(fieldsets): update fieldsets API: add template_id to the response --- backend/src/processes/serializers/templates/fieldset.py | 1 + .../src/processes/tests/test_views/test_fieldsets/test_create.py | 1 + .../src/processes/tests/test_views/test_fieldsets/test_list.py | 1 + .../tests/test_views/test_fieldsets/test_partial_update.py | 1 + .../processes/tests/test_views/test_fieldsets/test_retrieve.py | 1 + 5 files changed, 5 insertions(+) diff --git a/backend/src/processes/serializers/templates/fieldset.py b/backend/src/processes/serializers/templates/fieldset.py index be4298a3c..a6f33db15 100644 --- a/backend/src/processes/serializers/templates/fieldset.py +++ b/backend/src/processes/serializers/templates/fieldset.py @@ -59,6 +59,7 @@ class Meta: 'api_name', 'tasks', 'kickoff', + 'template_id', ) id = IntegerField(required=False) 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 index 39650bbc1..b5c82af58 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_create.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_create.py @@ -124,6 +124,7 @@ def test_create_fieldset__all_fields__ok(api_client, mocker): assert response.data['id'] == fieldset.id assert response.data['name'] == data['name'] assert response.data['description'] == data['description'] + assert response.data['template_id'] == template.id assert response.data['tasks'] == [] assert response.data['label_position'] == data['label_position'] assert response.data['layout'] == data['layout'] 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 index bd792e13d..aea9958b1 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_list.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_list.py @@ -61,6 +61,7 @@ def test_list_fieldsets__all_data__ok(api_client): assert item_1['api_name'] == fieldset.api_name assert item_1['name'] == fieldset.name assert item_1['description'] == '' + assert item_1['template_id'] == template.id assert item_1['layout'] == fieldset.layout assert item_1['label_position'] == fieldset.label_position assert item_1['tasks'] == [] 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 index 24de5b815..147b207a4 100644 --- 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 @@ -101,6 +101,7 @@ def test_partial_update__all_fields__ok(api_client, mocker): assert response.data['id'] == fieldset.id assert response.data['name'] == data['name'] assert response.data['description'] == data['description'] + assert response.data['template_id'] == template.id assert response.data['tasks'] == [] assert response.data['label_position'] == data['label_position'] assert response.data['layout'] == data['layout'] 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 index 57f4f0ede..17a5a2ff4 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py @@ -57,6 +57,7 @@ def test_retrieve__fieldset_all_data__ok(api_client): assert response.data['api_name'] == fieldset.api_name assert response.data['name'] == 'My Fieldset' assert response.data['description'] == 'Fieldset description' + assert response.data['template_id'] == template.id assert response.data['layout'] == FieldSetLayout.HORIZONTAL assert response.data['label_position'] == LabelPosition.LEFT assert response.data['kickoff'] is None From be7df1d51dad7453e8df5ec0885726d88712b180 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 12 May 2026 14:14:57 +0500 Subject: [PATCH 17/46] 45773 feat(conditions): add new PredicateOperator "SKIPPED" --- backend/src/processes/enums.py | 6 +- .../services/condition_check/comparator.py | 4 + .../condition_check/resolvers/task.py | 6 +- .../test_condition_check/test_service.py | 113 ++++++++++- .../test_create/test_condition_template.py | 130 +++++++++++++ .../test_views/test_templates/test_export.py | 53 ++++++ .../test_templates/test_retrieve.py | 52 ++++++ .../test_update/test_condition_template.py | 176 ++++++++++++++++++ 8 files changed, 532 insertions(+), 8 deletions(-) diff --git a/backend/src/processes/enums.py b/backend/src/processes/enums.py index 708f05430..ddf2ac2a0 100644 --- a/backend/src/processes/enums.py +++ b/backend/src/processes/enums.py @@ -248,6 +248,7 @@ class PredicateOperator: MORE_THAN = 'more_than' LESS_THAN = 'less_than' COMPLETED = 'completed' + SKIPPED = 'skipped' CHOICES = ( (EQUAL, 'Equal'), (NOT_EQUAL, 'Not equal'), @@ -258,10 +259,11 @@ class PredicateOperator: (MORE_THAN, 'More than'), (LESS_THAN, 'Less than'), (COMPLETED, COMPLETED), + (SKIPPED, SKIPPED), ) ALLOWED_OPERATORS = { PredicateType.KICKOFF: {COMPLETED}, - PredicateType.TASK: {COMPLETED}, + PredicateType.TASK: {COMPLETED, SKIPPED}, PredicateType.USER: {EQUAL, NOT_EQUAL, EXIST, NOT_EXIST}, PredicateType.GROUP: {EQUAL, NOT_EQUAL, EXIST, NOT_EXIST}, PredicateType.FILE: {EXIST, NOT_EXIST}, @@ -316,7 +318,7 @@ class PredicateOperator: NOT_EXIST, }, } - UNARY_OPERATORS = {EXIST, NOT_EXIST, COMPLETED} + UNARY_OPERATORS = {EXIST, NOT_EXIST, COMPLETED, SKIPPED} class ConditionAction: diff --git a/backend/src/processes/services/condition_check/comparator.py b/backend/src/processes/services/condition_check/comparator.py index 6c4bf9cc0..36e0d17ba 100644 --- a/backend/src/processes/services/condition_check/comparator.py +++ b/backend/src/processes/services/condition_check/comparator.py @@ -57,3 +57,7 @@ def less_than(cls, a, b): @classmethod def completed(cls, a: bool): return a + + @classmethod + def skipped(cls, a: bool): + return a diff --git a/backend/src/processes/services/condition_check/resolvers/task.py b/backend/src/processes/services/condition_check/resolvers/task.py index 923f048d0..d09d92c91 100644 --- a/backend/src/processes/services/condition_check/resolvers/task.py +++ b/backend/src/processes/services/condition_check/resolvers/task.py @@ -1,3 +1,4 @@ +from src.processes.enums import PredicateOperator from src.processes.models.workflows.task import Task from .base import Resolver @@ -10,4 +11,7 @@ def _prepare_args(self): api_name=self._predicate.field, workflow_id=self._workflow_id, ) - self.field_value = (task.is_completed or task.is_skipped) + if self._predicate.operator == PredicateOperator.SKIPPED: + self.field_value = task.is_skipped + else: + self.field_value = task.is_completed diff --git a/backend/src/processes/tests/test_services/test_condition_check/test_service.py b/backend/src/processes/tests/test_services/test_condition_check/test_service.py index 1ce2636ff..301ea323b 100644 --- a/backend/src/processes/tests/test_services/test_condition_check/test_service.py +++ b/backend/src/processes/tests/test_services/test_condition_check/test_service.py @@ -3928,7 +3928,7 @@ def test_check__group__not_exist__group_set__fail(): # region task / kickoff -def test_check__task_completed__return_true(): +def test_check_task_completed__completed__return_true(): """Check returns True when the referenced task has completed status.""" @@ -3963,7 +3963,7 @@ def test_check__task_completed__return_true(): assert result is True -def test_check__task_not_completed__return_false(): +def test_check_task_completed__not_completed__return_false(): """Check returns False when the referenced task has not completed.""" @@ -3996,7 +3996,112 @@ def test_check__task_not_completed__return_false(): assert result is False -def test_check__kickoff_completed__return_true(): +def test_check_task_completed__skipped__return_false(): + + """Check returns False when the referenced task has skipped status.""" + + # arrange + owner = create_test_owner() + workflow = create_test_workflow(user=owner, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + task_1.status = TaskStatus.SKIPPED + task_1.save() + task_2 = workflow.tasks.get(number=2) + condition = Condition.objects.create( + task=task_2, + action=ConditionAction.SKIP_TASK, + order=1, + ) + rule = Rule.objects.create(condition=condition) + Predicate.objects.create( + rule=rule, + operator=PredicateOperator.COMPLETED, + field_type=PredicateType.TASK, + field=task_1.api_name, + value=None, + ) + + # act + result = ConditionCheckService.check( + condition=condition, + workflow_id=workflow.id, + ) + + # assert + assert result is False + + +def test_check_task_skipped__skipped__return_true(): + + """Check returns True when the referenced task has skipped status.""" + + # arrange + owner = create_test_owner() + workflow = create_test_workflow(user=owner, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + task_1.status = TaskStatus.SKIPPED + task_1.save() + task_2 = workflow.tasks.get(number=2) + condition = Condition.objects.create( + task=task_2, + action=ConditionAction.SKIP_TASK, + order=1, + ) + rule = Rule.objects.create(condition=condition) + Predicate.objects.create( + rule=rule, + operator=PredicateOperator.SKIPPED, + field_type=PredicateType.TASK, + field=task_1.api_name, + value=None, + ) + + # act + result = ConditionCheckService.check( + condition=condition, + workflow_id=workflow.id, + ) + + # assert + assert result is True + + +def test_check_task_skipped__not_skipped__return_false(): + + """Check returns True when the referenced task has skipped status.""" + + # arrange + owner = create_test_owner() + workflow = create_test_workflow(user=owner, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + task_1.status = TaskStatus.COMPLETED + task_1.save() + task_2 = workflow.tasks.get(number=2) + condition = Condition.objects.create( + task=task_2, + action=ConditionAction.SKIP_TASK, + order=1, + ) + rule = Rule.objects.create(condition=condition) + Predicate.objects.create( + rule=rule, + operator=PredicateOperator.SKIPPED, + field_type=PredicateType.TASK, + field=task_1.api_name, + value=None, + ) + + # act + result = ConditionCheckService.check( + condition=condition, + workflow_id=workflow.id, + ) + + # assert + assert result is False + + +def test_check_kickoff_completed__return_true(): """Check returns True when predicate type is kickoff (always completed).""" @@ -4026,5 +4131,3 @@ def test_check__kickoff_completed__return_true(): # assert assert result is True - -# endregion diff --git a/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py b/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py index 36124c467..3e50413be 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py +++ b/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py @@ -2122,6 +2122,136 @@ def test_create__predicate_type_task__completed__ok( assert predicate['field'] == task_1_api_name +def test_create__predicate_type_task__skipped__ok( + mocker, + api_client, +): + # arrange + account = create_test_account(plan=BillingPlanType.UNLIMITED) + user = create_test_user(account=account) + mocker.patch( + 'src.processes.serializers.templates.' + 'condition.AnalyticService.templates_task_condition_created', + ) + predicate_api_name = 'predicate-skip' + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + task_3_api_name = 'task-3' + # START_TASK condition with COMPLETED makes task-1 an ancestor of task-3 + start_condition_data = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-start', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + # SKIP_TASK condition with SKIPPED checks if task-1 was skipped + skip_condition_data = { + 'order': 2, + 'action': ConditionAction.SKIP_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.SKIPPED, + 'api_name': predicate_api_name, + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.post( + path='/templates', + data={ + 'name': 'Template', + 'is_active': True, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'fields': [ + { + 'order': 1, + 'name': 'First step performer', + 'type': FieldType.USER, + 'api_name': 'user-field-1', + 'is_required': True, + }, + ], + }, + 'tasks': [ + { + 'number': 1, + 'name': 'Step 1', + 'api_name': task_1_api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + { + 'number': 2, + 'name': 'Step 2', + 'api_name': task_2_api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + { + 'number': 3, + 'name': 'Step 3', + 'api_name': task_3_api_name, + 'conditions': [ + start_condition_data, + skip_condition_data, + ], + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + condition = response.data['tasks'][2]['conditions'][1] + predicate = condition['rules'][0]['predicates'][0] + assert predicate['field_type'] == PredicateType.TASK + assert predicate['api_name'] == predicate_api_name + assert predicate['operator'] == PredicateOperator.SKIPPED + assert predicate['value'] is None + assert predicate['field'] == task_1_api_name + + @pytest.mark.parametrize( 'case', ( (PredicateOperator.EQUAL, FieldType.STRING, 'yes'), 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..880aebc4e 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,57 @@ 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, + name='Kickoff Fieldset', + description='Kickoff fieldset desc', + api_name='fieldset-kickoff-1', + order=0, + ) + + task_fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + name='Task Fieldset', + description='Task fieldset desc', + api_name='fieldset-task-1', + 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_retrieve.py b/backend/src/processes/tests/test_views/test_templates/test_retrieve.py index 9197bb208..fa77d20e4 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,54 @@ 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, + name='Kickoff Fieldset', + description='Kickoff fieldset desc', + api_name='fieldset-kickoff-1', + order=0, + ) + + task_fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + name='Task Fieldset', + 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_update/test_condition_template.py b/backend/src/processes/tests/test_views/test_templates/test_update/test_condition_template.py index 4710953ce..fea755696 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_update/test_condition_template.py +++ b/backend/src/processes/tests/test_views/test_templates/test_update/test_condition_template.py @@ -476,6 +476,182 @@ def test_update__predicate_to_type_kickoff_completed__ok( assert predicate.operator == PredicateOperator.COMPLETED +def test_update__predicate_to_type_task_skipped__ok( + mocker, + api_client, +): + # arrange + account = create_test_account(plan=BillingPlanType.UNLIMITED) + user = create_test_user(account=account) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + template = create_test_template( + user=user, + tasks_count=3, + is_active=True, + ) + first_task = template.tasks.order_by('number').first() + second_task = template.tasks.get(number=2) + third_task = template.tasks.get(number=3) + + # START_TASK condition on third_task with COMPLETED on first_task + # to establish first_task as an ancestor of third_task + start_condition = ConditionTemplate.objects.create( + action=ConditionAction.START_TASK, + order=1, + task=third_task, + template=template, + ) + start_rule = RuleTemplate.objects.create( + condition=start_condition, + template=template, + ) + PredicateTemplate.objects.create( + rule=start_rule, + operator=PredicateOperator.COMPLETED, + field_type=PredicateType.TASK, + field=first_task.api_name, + value=None, + template=template, + ) + + # SKIP_TASK condition on third_task (will be updated to use SKIPPED) + skip_condition = ConditionTemplate.objects.create( + action=ConditionAction.SKIP_TASK, + order=2, + task=third_task, + template=template, + ) + skip_rule = RuleTemplate.objects.create( + condition=skip_condition, + template=template, + ) + predicate = PredicateTemplate.objects.create( + rule=skip_rule, + operator=PredicateOperator.COMPLETED, + field_type=PredicateType.TASK, + field=first_task.api_name, + value=None, + template=template, + ) + + start_request_data = { + 'api_name': start_condition.api_name, + 'order': start_condition.order, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'api_name': start_rule.api_name, + 'predicates': [ + { + 'api_name': start_rule.predicates.first().api_name, + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'field': first_task.api_name, + 'value': None, + }, + ], + }, + ], + } + skip_request_data = { + 'api_name': skip_condition.api_name, + 'order': skip_condition.order, + 'action': ConditionAction.SKIP_TASK, + 'rules': [ + { + 'api_name': skip_rule.api_name, + 'predicates': [ + { + 'api_name': predicate.api_name, + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.SKIPPED, + 'field': first_task.api_name, + 'value': None, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data={ + 'id': template.id, + 'name': template.name, + 'is_active': True, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': {'id': template.kickoff_instance.id}, + 'tasks': [ + { + 'id': first_task.id, + 'number': first_task.number, + 'name': first_task.name, + 'api_name': first_task.api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + { + 'id': second_task.id, + 'number': second_task.number, + 'name': second_task.name, + 'api_name': second_task.api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + { + 'id': third_task.id, + 'number': third_task.number, + 'name': third_task.name, + 'api_name': third_task.api_name, + 'conditions': [ + start_request_data, + skip_request_data, + ], + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + condition_data = response.data['tasks'][2]['conditions'][1] + predicate_data = condition_data['rules'][0]['predicates'][0] + assert predicate_data['field_type'] == PredicateType.TASK + assert predicate_data['api_name'] == predicate.api_name + assert predicate_data['operator'] == PredicateOperator.SKIPPED + assert predicate_data['value'] is None + assert predicate_data['field'] == first_task.api_name + + predicate.refresh_from_db() + assert predicate.field_type == PredicateType.TASK + assert predicate.operator == PredicateOperator.SKIPPED + + def test_update__predicate_to_type_number__ok( mocker, api_client, From d5a17808cb5b370d4e5302524f51e3c4143ca989 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 12 May 2026 23:01:48 +0500 Subject: [PATCH 18/46] 45773 fix(highlights): add kickoff fields and fieldsets to the workflow run event --- backend/src/reports/serializers.py | 3 + .../tests/test_views/test_highlights.py | 134 ++++++++++++++++-- 2 files changed, 127 insertions(+), 10 deletions(-) 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 96d28be38..f53ca8f90 100644 --- a/backend/src/reports/tests/test_views/test_highlights.py +++ b/backend/src/reports/tests/test_views/test_highlights.py @@ -35,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 @@ -1515,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): @@ -1554,7 +1553,6 @@ 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): @@ -1608,18 +1606,13 @@ def test_highlights__task_complete_fieldsets_present__ok(api_client): path='/reports/highlights', ) - # assert - # 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 @@ -1644,7 +1637,6 @@ def test_highlights__task_complete_fieldsets_present__ok(api_client): 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 @@ -1719,3 +1711,125 @@ def test_highlights__non_complete_fieldsets_null__ok(api_client): 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'] == [] From 6f5c3f8faa07c13cf5fced34b2dfe88c1ec638eb Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Wed, 13 May 2026 14:20:55 +0500 Subject: [PATCH 19/46] 45773 fix(templates): clone fieldsets with the template --- .../test_clone/test_fieldsets.py | 370 ++++++++++++++++++ backend/src/processes/views/template.py | 57 +++ 2 files changed, 427 insertions(+) create mode 100644 backend/src/processes/tests/test_views/test_templates/test_clone/test_fieldsets.py 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..86af84573 --- /dev/null +++ b/backend/src/processes/tests/test_views/test_templates/test_clone/test_fieldsets.py @@ -0,0 +1,370 @@ +import pytest + +from src.processes.enums import ( + FieldSetLayout, + FieldSetRuleType, + FieldType, + LabelPosition, +) +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateKickoff, + FieldsetTemplateRule, + FieldsetTemplateTaskTemplate, +) +from src.processes.models.templates.fields import ( + FieldTemplate, + FieldTemplateSelection, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_fieldset_template, + create_test_owner, + create_test_template, +) + +pytestmark = pytest.mark.django_db + + +def test_clone__fieldset_copied__ok(api_client): + + """Cloning a template copies its FieldsetTemplate + to the new template with correct attributes.""" + + # 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) + fieldset = create_test_fieldset_template( + account=account, + template=template, + name='My Fieldset', + description='Some description', + label_position=LabelPosition.LEFT, + layout=FieldSetLayout.HORIZONTAL, + ) + + # act + response = api_client.post(f'/templates/{template.id}/clone') + + # assert + assert response.status_code == 200 + new_template_id = response.data['id'] + assert new_template_id != template.id + + new_fieldsets = FieldsetTemplate.objects.filter( + template_id=new_template_id, + ) + assert new_fieldsets.count() == 1 + new_fs = new_fieldsets.first() + assert new_fs.name == fieldset.name + assert new_fs.description == fieldset.description + assert new_fs.label_position == fieldset.label_position + assert new_fs.layout == fieldset.layout + assert new_fs.account_id == account.id + assert new_fs.api_name != fieldset.api_name + + +def test_clone__fieldset_with_fields__ok(api_client): + + """Cloning copies FieldTemplate records + belonging to the fieldset.""" + + # 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) + fieldset = create_test_fieldset_template( + account=account, + template=template, + name='Fieldset with fields', + ) + field_1 = fieldset.fields.first() + # fixture creates one STRING field; add a second one + field_2 = FieldTemplate.objects.create( + template=template, + fieldset=fieldset, + account=account, + name='Second field', + type=FieldType.NUMBER, + order=2, + is_required=False, + is_hidden=True, + ) + + # act + response = api_client.post(f'/templates/{template.id}/clone') + + # assert + assert response.status_code == 200 + new_template_id = response.data['id'] + new_fs = FieldsetTemplate.objects.get(template_id=new_template_id) + new_fields = FieldTemplate.objects.filter( + fieldset=new_fs, + ).order_by('order') + assert new_fields.count() == 2 + + nf1 = new_fields[0] + assert nf1.name == field_1.name + assert nf1.type == field_1.type + assert nf1.order == field_1.order + assert nf1.template_id == new_template_id + assert nf1.kickoff is None + assert nf1.task is None + + nf2 = new_fields[1] + assert nf2.name == field_2.name + assert nf2.type == field_2.type + assert nf2.order == field_2.order + assert nf2.is_hidden == field_2.is_hidden + + +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) + api_client.token_authenticate(user) + template = create_test_template(user=user, tasks_count=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + name='Fieldset with dropdown', + ) + # Replace the default STRING field with a DROPDOWN + selections + fieldset.fields.all().delete() + field = FieldTemplate.objects.create( + template=template, + fieldset=fieldset, + account=account, + name='Dropdown field', + type=FieldType.DROPDOWN, + order=1, + ) + sel_1 = FieldTemplateSelection.objects.create( + template=template, + field_template=field, + value='Option A', + ) + sel_2 = FieldTemplateSelection.objects.create( + template=template, + field_template=field, + value='Option B', + ) + + # act + response = api_client.post(f'/templates/{template.id}/clone') + + # assert + assert response.status_code == 200 + new_template_id = response.data['id'] + new_fs = FieldsetTemplate.objects.get(template_id=new_template_id) + new_field = FieldTemplate.objects.get(fieldset=new_fs) + new_selections = FieldTemplateSelection.objects.filter( + field_template=new_field, + ).order_by('value') + assert new_selections.count() == 2 + assert new_selections[0].value == sel_1.value + assert new_selections[0].template_id == new_template_id + assert new_selections[1].value == sel_2.value + assert new_selections[1].template_id == new_template_id + + +def test_clone__fieldset_with_rules__ok(api_client): + + """Cloning copies FieldsetTemplateRule records + and preserves the rule-field M2M relationships.""" + + # 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) + fieldset = create_test_fieldset_template( + account=account, + template=template, + name='Fieldset with rules', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + # fixture creates a NUMBER field + rule; link them via M2M + field = fieldset.fields.first() + rule = fieldset.rules.first() + field.rules.add(rule) + + # act + response = api_client.post(f'/templates/{template.id}/clone') + + # assert + assert response.status_code == 200 + new_template_id = response.data['id'] + new_fs = FieldsetTemplate.objects.get(template_id=new_template_id) + + new_rules = FieldsetTemplateRule.objects.filter(fieldset=new_fs) + assert new_rules.count() == 1 + new_rule = new_rules.first() + assert new_rule.type == rule.type + assert new_rule.value == rule.value + assert new_rule.id != rule.id + assert new_rule.api_name != rule.api_name + + new_field = FieldTemplate.objects.get(fieldset=new_fs) + assert list(new_field.rules.all()) == [new_rule] + + +def test_clone__multiple_fieldsets__ok(api_client): + + """Cloning a template with multiple fieldsets + copies all of them.""" + + # 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) + fs_1 = create_test_fieldset_template( + account=account, + template=template, + name='Fieldset One', + ) + fs_2 = create_test_fieldset_template( + account=account, + template=template, + name='Fieldset Two', + ) + + # act + response = api_client.post(f'/templates/{template.id}/clone') + + # assert + assert response.status_code == 200 + new_template_id = response.data['id'] + new_fieldsets = FieldsetTemplate.objects.filter( + template_id=new_template_id, + ).order_by('name') + assert new_fieldsets.count() == 2 + assert new_fieldsets[0].name == fs_1.name + assert new_fieldsets[1].name == fs_2.name + + assert new_fieldsets[0].fields.count() == 1 + assert new_fieldsets[1].fields.count() == 1 + + +def test_clone__no_kickoff_task_links__ok(api_client): + + """Cloning does NOT create FieldsetTemplateKickoff + or FieldsetTemplateTaskTemplate records.""" + + # 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 + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + task=task, + name='Linked fieldset', + ) + # Verify original has links + assert FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset, + ).exists() + assert FieldsetTemplateTaskTemplate.objects.filter( + fieldset=fieldset, + ).exists() + + # act + response = api_client.post(f'/templates/{template.id}/clone') + + # assert + assert response.status_code == 200 + new_template_id = response.data['id'] + new_fs = FieldsetTemplate.objects.get(template_id=new_template_id) + + assert not FieldsetTemplateKickoff.objects.filter( + fieldset=new_fs, + ).exists() + assert not FieldsetTemplateTaskTemplate.objects.filter( + fieldset=new_fs, + ).exists() + + +def test_clone__no_fieldsets__ok(api_client): + + """Cloning a template without fieldsets still works + and creates no fieldsets on the clone.""" + + # 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) + + # act + response = api_client.post(f'/templates/{template.id}/clone') + + # assert + assert response.status_code == 200 + new_template_id = response.data['id'] + assert FieldsetTemplate.objects.filter( + template_id=new_template_id, + ).count() == 0 + + +def test_clone__fieldset_rule_multi_fields__ok(api_client): + + """Cloning preserves a rule linked to multiple fields + via M2M.""" + + # 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) + fieldset = create_test_fieldset_template( + account=account, + template=template, + name='Multi-field rule fieldset', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='200', + ) + field_1 = fieldset.fields.first() + # fixture creates one NUMBER field; add a second one + field_2 = FieldTemplate.objects.create( + template=template, + fieldset=fieldset, + account=account, + name='Amount B', + type=FieldType.NUMBER, + order=2, + ) + rule = fieldset.rules.first() + # Link both fields to the rule + for field in fieldset.fields.all(): + field.rules.add(rule) + + # act + response = api_client.post(f'/templates/{template.id}/clone') + + # assert + assert response.status_code == 200 + new_template_id = response.data['id'] + new_fs = FieldsetTemplate.objects.get(template_id=new_template_id) + + new_rule = FieldsetTemplateRule.objects.get(fieldset=new_fs) + assert new_rule.value == rule.value + assert new_rule.type == rule.type + + rule_fields = new_rule.fields.order_by('order') + assert rule_fields.count() == 2 + assert rule_fields[0].name == field_1.name + assert rule_fields[1].name == field_2.name diff --git a/backend/src/processes/views/template.py b/backend/src/processes/views/template.py index 8a13ad14c..0c3afa75b 100644 --- a/backend/src/processes/views/template.py +++ b/backend/src/processes/views/template.py @@ -429,6 +429,63 @@ def clone(self, request, *args, **kwargs): serializer = self.get_serializer(data=template_data_clone) with transaction.atomic(): serializer.save_as_draft() + + # TODO Temporary: copy FieldsetTemplate entities --- + # Remove after creating global fieldsets + new_template = serializer.instance + original_fieldsets = FieldsetTemplate.objects.filter( + template=template, + ).prefetch_related( + 'rules', 'rules__fields', + 'fields', 'fields__selections', + ) + + for original_fs in original_fieldsets: + fields_data = [ + { + 'name': f.name, + 'type': f.type, + 'description': f.description or '', + 'is_required': f.is_required, + 'order': f.order, + 'is_hidden': f.is_hidden, + 'default': f.default, + 'api_name': f.api_name, + 'dataset': f.dataset, + 'selections': [ + {'value': sel.value} + for sel in f.selections.all() + ], + } + for f in original_fs.fields.all() + ] + rules_data = [ + { + 'type': r.type, + 'value': r.value, + 'fields': [ + f.api_name + for f in r.fields.all() + ], + } + for r in original_fs.rules.all() + ] + service = FieldSetTemplateService( + user=request.user, + is_superuser=request.is_superuser, + auth_type=request.token_type, + ) + service.create( + template_id=new_template.id, + name=original_fs.name, + description=original_fs.description, + label_position=original_fs.label_position, + layout=original_fs.layout, + fields=fields_data, + rules=rules_data, + ) + # TODO --- End temporary code --- + return self.response_ok(serializer.get_response_data()) def list(self, request, *args, **kwargs): From a4558f771b9ed61e7cbbfb4c3a49bd6389ad99a5 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Wed, 13 May 2026 17:11:18 +0500 Subject: [PATCH 20/46] 45773 fix(templates): clone fieldset and fieldset rule api_names --- .../migrations/0252_add_fieldsets.py | 4 +- .../processes/models/templates/fieldset.py | 4 +- .../services/templates/fieldsets/fieldset.py | 20 ++- .../templates/fieldsets/fieldset_rule.py | 16 +- .../test_fieldset_template_rule_service.py | 14 +- .../test_fieldset_template_service.py | 4 + .../test_clone/test_fieldsets.py | 138 +++++++++--------- backend/src/processes/views/template.py | 7 +- 8 files changed, 119 insertions(+), 88 deletions(-) diff --git a/backend/src/processes/migrations/0252_add_fieldsets.py b/backend/src/processes/migrations/0252_add_fieldsets.py index f868ba1a8..b3acfa7cb 100644 --- a/backend/src/processes/migrations/0252_add_fieldsets.py +++ b/backend/src/processes/migrations/0252_add_fieldsets.py @@ -125,8 +125,8 @@ class Migration(migrations.Migration): model_name='fieldsettemplate', constraint=models.UniqueConstraint( condition=models.Q(is_deleted=False), - fields=('account', 'api_name'), - name='fieldsettemplate_account_api_name_unique'), + fields=('template', 'api_name'), + name='fieldsettemplate_template_api_name_unique'), ), migrations.AddField( model_name='fieldsettemplate', diff --git a/backend/src/processes/models/templates/fieldset.py b/backend/src/processes/models/templates/fieldset.py index 075cf8b2f..85035556a 100644 --- a/backend/src/processes/models/templates/fieldset.py +++ b/backend/src/processes/models/templates/fieldset.py @@ -29,9 +29,9 @@ class Meta: ordering = ['-id'] constraints = [ UniqueConstraint( - fields=['account', 'api_name'], + fields=['api_name', 'template'], condition=Q(is_deleted=False), - name='fieldsettemplate_account_api_name_unique', + name='fieldsettemplate_api_name_template_unique', ), ] diff --git a/backend/src/processes/services/templates/fieldsets/fieldset.py b/backend/src/processes/services/templates/fieldsets/fieldset.py index 0bca35c3c..6aa0b6700 100644 --- a/backend/src/processes/services/templates/fieldsets/fieldset.py +++ b/backend/src/processes/services/templates/fieldsets/fieldset.py @@ -28,18 +28,22 @@ def _create_instance( name: str, template_id: int, description: str = '', + api_name: Optional[str] = None, label_position: LabelPosition.LITERALS = LabelPosition.TOP, layout: FieldSetLayout.LITERALS = FieldSetLayout.VERTICAL, **kwargs, ): - self.instance = FieldsetTemplate.objects.create( - template_id=template_id, - account=self.account, - name=name, - description=description, - label_position=label_position, - layout=layout, - ) + create_kwargs = { + 'template_id': template_id, + 'account': self.account, + 'name': name, + 'description': description, + 'label_position': label_position, + 'layout': layout, + } + if api_name: + create_kwargs['api_name'] = api_name + self.instance = FieldsetTemplate.objects.create(**create_kwargs) return self.instance def _create_related( diff --git a/backend/src/processes/services/templates/fieldsets/fieldset_rule.py b/backend/src/processes/services/templates/fieldsets/fieldset_rule.py index 98a440192..3c5975af0 100644 --- a/backend/src/processes/services/templates/fieldsets/fieldset_rule.py +++ b/backend/src/processes/services/templates/fieldsets/fieldset_rule.py @@ -46,14 +46,18 @@ def _create_instance( type: FieldSetRuleType.LITERALS, # noqa: A002 value: Optional[str] = None, fieldset_id: Optional[int] = None, + api_name: Optional[str] = None, **kwargs, ): - self.instance = FieldsetTemplateRule.objects.create( - account=self.account, - type=type, - value=value, - fieldset_id=fieldset_id, - ) + 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( diff --git a/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_rule_service.py b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_rule_service.py index edcab5a8f..b0a17287c 100644 --- a/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_rule_service.py +++ b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_rule_service.py @@ -61,6 +61,7 @@ def test__create_instance__default_params__ok(): 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(): @@ -83,20 +84,25 @@ def test__create_instance__all_params__ok(): is_superuser=False, auth_type=AuthTokenType.USER, ) + value = '100' + api_name = 'rule-1' + rule_type = FieldSetRuleType.SUM_EQUAL # act service._create_instance( - type=FieldSetRuleType.SUM_EQUAL, - value='100', + type=rule_type, + value=value, + api_name=api_name, fieldset_id=fieldset.id, ) # assert assert service.instance is not None - assert service.instance.type == FieldSetRuleType.SUM_EQUAL - assert service.instance.value == '100' + 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(): diff --git a/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py index e3717bc6c..b10ca13e8 100644 --- a/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py +++ b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py @@ -59,6 +59,7 @@ def test__create_instance__default_params__ok(): # 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.account_id == account.id assert service.instance.description == '' @@ -85,6 +86,7 @@ def test__create_instance__all_params__ok(): description = 'Test description' label_position = LabelPosition.LEFT layout = FieldSetLayout.HORIZONTAL + api_name = 'fs-1' # act service._create_instance( @@ -93,6 +95,7 @@ def test__create_instance__all_params__ok(): description=description, label_position=label_position, layout=layout, + api_name=api_name, ) # assert @@ -101,6 +104,7 @@ def test__create_instance__all_params__ok(): 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_fields__with_data__ok(mocker): 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 index 86af84573..18ebad721 100644 --- 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 @@ -53,17 +53,17 @@ def test_clone__fieldset_copied__ok(api_client): new_template_id = response.data['id'] assert new_template_id != template.id - new_fieldsets = FieldsetTemplate.objects.filter( + field_clones = FieldsetTemplate.objects.filter( template_id=new_template_id, ) - assert new_fieldsets.count() == 1 - new_fs = new_fieldsets.first() - assert new_fs.name == fieldset.name - assert new_fs.description == fieldset.description - assert new_fs.label_position == fieldset.label_position - assert new_fs.layout == fieldset.layout - assert new_fs.account_id == account.id - assert new_fs.api_name != fieldset.api_name + assert field_clones.count() == 1 + fieldset_clone = field_clones.first() + assert fieldset_clone.name == fieldset.name + assert fieldset_clone.api_name == fieldset.api_name + assert fieldset_clone.description == fieldset.description + assert fieldset_clone.label_position == fieldset.label_position + assert fieldset_clone.layout == fieldset.layout + assert fieldset_clone.account_id == account.id def test_clone__fieldset_with_fields__ok(api_client): @@ -100,25 +100,27 @@ def test_clone__fieldset_with_fields__ok(api_client): # assert assert response.status_code == 200 new_template_id = response.data['id'] - new_fs = FieldsetTemplate.objects.get(template_id=new_template_id) - new_fields = FieldTemplate.objects.filter( - fieldset=new_fs, + fieldset_clone = FieldsetTemplate.objects.get(template_id=new_template_id) + field_clones = FieldTemplate.objects.filter( + fieldset=fieldset_clone, ).order_by('order') - assert new_fields.count() == 2 + assert field_clones.count() == 2 - nf1 = new_fields[0] - assert nf1.name == field_1.name - assert nf1.type == field_1.type - assert nf1.order == field_1.order - assert nf1.template_id == new_template_id - assert nf1.kickoff is None - assert nf1.task is None + field_1_clone = field_clones[0] + assert field_1_clone.name == field_1.name + assert field_1_clone.api_name == field_1.api_name + assert field_1_clone.type == field_1.type + assert field_1_clone.order == field_1.order + assert field_1_clone.template_id == new_template_id + assert field_1_clone.kickoff is None + assert field_1_clone.task is None - nf2 = new_fields[1] - assert nf2.name == field_2.name - assert nf2.type == field_2.type - assert nf2.order == field_2.order - assert nf2.is_hidden == field_2.is_hidden + field_2_clone = field_clones[1] + assert field_2_clone.name == field_2.name + assert field_2_clone.api_name == field_2.api_name + assert field_2_clone.type == field_2.type + assert field_2_clone.order == field_2.order + assert field_2_clone.is_hidden == field_2.is_hidden def test_clone__fieldset_with_selections__ok(api_client): @@ -146,12 +148,12 @@ def test_clone__fieldset_with_selections__ok(api_client): type=FieldType.DROPDOWN, order=1, ) - sel_1 = FieldTemplateSelection.objects.create( + selection_1 = FieldTemplateSelection.objects.create( template=template, field_template=field, value='Option A', ) - sel_2 = FieldTemplateSelection.objects.create( + selection_2 = FieldTemplateSelection.objects.create( template=template, field_template=field, value='Option B', @@ -163,16 +165,19 @@ def test_clone__fieldset_with_selections__ok(api_client): # assert assert response.status_code == 200 new_template_id = response.data['id'] - new_fs = FieldsetTemplate.objects.get(template_id=new_template_id) - new_field = FieldTemplate.objects.get(fieldset=new_fs) - new_selections = FieldTemplateSelection.objects.filter( - field_template=new_field, + fieldset_clone = FieldsetTemplate.objects.get(template_id=new_template_id) + field_clone = FieldTemplate.objects.get(fieldset=fieldset_clone) + selections_clone = FieldTemplateSelection.objects.filter( + field_template=field_clone, ).order_by('value') - assert new_selections.count() == 2 - assert new_selections[0].value == sel_1.value - assert new_selections[0].template_id == new_template_id - assert new_selections[1].value == sel_2.value - assert new_selections[1].template_id == new_template_id + assert selections_clone.count() == 2 + assert selections_clone[0].value == selection_1.value + assert selections_clone[0].template_id == new_template_id + assert selections_clone[0].api_name == selection_1.api_name + + assert selections_clone[1].value == selection_2.value + assert selections_clone[1].template_id == new_template_id + assert selections_clone[1].api_name == selection_2.api_name def test_clone__fieldset_with_rules__ok(api_client): @@ -203,18 +208,18 @@ def test_clone__fieldset_with_rules__ok(api_client): # assert assert response.status_code == 200 new_template_id = response.data['id'] - new_fs = FieldsetTemplate.objects.get(template_id=new_template_id) + fieldset_clone = FieldsetTemplate.objects.get(template_id=new_template_id) - new_rules = FieldsetTemplateRule.objects.filter(fieldset=new_fs) - assert new_rules.count() == 1 - new_rule = new_rules.first() - assert new_rule.type == rule.type - assert new_rule.value == rule.value - assert new_rule.id != rule.id - assert new_rule.api_name != rule.api_name + rules_clone = FieldsetTemplateRule.objects.filter(fieldset=fieldset_clone) + assert rules_clone.count() == 1 + rule_clone = rules_clone.first() + assert rule_clone.type == rule.type + assert rule_clone.value == rule.value + assert rule_clone.id != rule.id + assert rule_clone.api_name == rule.api_name - new_field = FieldTemplate.objects.get(fieldset=new_fs) - assert list(new_field.rules.all()) == [new_rule] + field_clone = FieldTemplate.objects.get(fieldset=fieldset_clone) + assert list(field_clone.rules.all()) == [rule_clone] def test_clone__multiple_fieldsets__ok(api_client): @@ -244,15 +249,17 @@ def test_clone__multiple_fieldsets__ok(api_client): # assert assert response.status_code == 200 new_template_id = response.data['id'] - new_fieldsets = FieldsetTemplate.objects.filter( + fieldset_clones = FieldsetTemplate.objects.filter( template_id=new_template_id, ).order_by('name') - assert new_fieldsets.count() == 2 - assert new_fieldsets[0].name == fs_1.name - assert new_fieldsets[1].name == fs_2.name + assert fieldset_clones.count() == 2 + assert fieldset_clones[0].name == fs_1.name + assert fieldset_clones[0].api_name == fs_1.api_name + assert fieldset_clones[0].fields.count() == 1 - assert new_fieldsets[0].fields.count() == 1 - assert new_fieldsets[1].fields.count() == 1 + assert fieldset_clones[1].name == fs_2.name + assert fieldset_clones[1].api_name == fs_2.api_name + assert fieldset_clones[1].fields.count() == 1 def test_clone__no_kickoff_task_links__ok(api_client): @@ -288,13 +295,13 @@ def test_clone__no_kickoff_task_links__ok(api_client): # assert assert response.status_code == 200 new_template_id = response.data['id'] - new_fs = FieldsetTemplate.objects.get(template_id=new_template_id) + fieldset_clone = FieldsetTemplate.objects.get(template_id=new_template_id) assert not FieldsetTemplateKickoff.objects.filter( - fieldset=new_fs, + fieldset=fieldset_clone, ).exists() assert not FieldsetTemplateTaskTemplate.objects.filter( - fieldset=new_fs, + fieldset=fieldset_clone, ).exists() @@ -315,9 +322,9 @@ def test_clone__no_fieldsets__ok(api_client): # assert assert response.status_code == 200 new_template_id = response.data['id'] - assert FieldsetTemplate.objects.filter( - template_id=new_template_id, - ).count() == 0 + assert not ( + FieldsetTemplate.objects.filter(template_id=new_template_id).exists() + ) def test_clone__fieldset_rule_multi_fields__ok(api_client): @@ -358,13 +365,14 @@ def test_clone__fieldset_rule_multi_fields__ok(api_client): # assert assert response.status_code == 200 new_template_id = response.data['id'] - new_fs = FieldsetTemplate.objects.get(template_id=new_template_id) + fieldset_clone = FieldsetTemplate.objects.get(template_id=new_template_id) - new_rule = FieldsetTemplateRule.objects.get(fieldset=new_fs) - assert new_rule.value == rule.value - assert new_rule.type == rule.type + rule_clone = FieldsetTemplateRule.objects.get(fieldset=fieldset_clone) + assert rule_clone.value == rule.value + assert rule_clone.type == rule.type + assert rule_clone.api_name == rule.api_name - rule_fields = new_rule.fields.order_by('order') + rule_fields = rule_clone.fields.order_by('order') assert rule_fields.count() == 2 - assert rule_fields[0].name == field_1.name - assert rule_fields[1].name == field_2.name + assert rule_fields[0].api_name == field_1.api_name + assert rule_fields[1].api_name == field_2.api_name diff --git a/backend/src/processes/views/template.py b/backend/src/processes/views/template.py index 0c3afa75b..3df72bfce 100644 --- a/backend/src/processes/views/template.py +++ b/backend/src/processes/views/template.py @@ -453,7 +453,10 @@ def clone(self, request, *args, **kwargs): 'api_name': f.api_name, 'dataset': f.dataset, 'selections': [ - {'value': sel.value} + { + 'value': sel.value, + 'api_name': sel.api_name, + } for sel in f.selections.all() ], } @@ -463,6 +466,7 @@ def clone(self, request, *args, **kwargs): { 'type': r.type, 'value': r.value, + 'api_name': r.api_name, 'fields': [ f.api_name for f in r.fields.all() @@ -478,6 +482,7 @@ def clone(self, request, *args, **kwargs): service.create( template_id=new_template.id, name=original_fs.name, + api_name=original_fs.api_name, description=original_fs.description, label_position=original_fs.label_position, layout=original_fs.layout, From 4125cbe1aebb5879131ffa231fe372849e7abaa1 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Wed, 13 May 2026 17:59:15 +0500 Subject: [PATCH 21/46] 45773 feat(conditions): add new PredicateOperator "COMPLETED_OR_SKIPPED" --- backend/src/processes/enums.py | 12 +- .../services/condition_check/comparator.py | 4 + .../condition_check/resolvers/task.py | 7 +- backend/src/processes/tests/fixtures.py | 2 +- .../test_condition_check/test_service.py | 103 ++++++++++ .../test_create/test_condition_template.py | 130 +++++++++++++ .../test_update/test_condition_template.py | 177 ++++++++++++++++++ 7 files changed, 430 insertions(+), 5 deletions(-) diff --git a/backend/src/processes/enums.py b/backend/src/processes/enums.py index ddf2ac2a0..dda698eb3 100644 --- a/backend/src/processes/enums.py +++ b/backend/src/processes/enums.py @@ -248,6 +248,7 @@ class PredicateOperator: MORE_THAN = 'more_than' LESS_THAN = 'less_than' COMPLETED = 'completed' + COMPLETED_OR_SKIPPED = 'completed_or_skipped' SKIPPED = 'skipped' CHOICES = ( (EQUAL, 'Equal'), @@ -260,10 +261,11 @@ class PredicateOperator: (LESS_THAN, 'Less than'), (COMPLETED, COMPLETED), (SKIPPED, SKIPPED), + (COMPLETED_OR_SKIPPED, COMPLETED_OR_SKIPPED), ) ALLOWED_OPERATORS = { PredicateType.KICKOFF: {COMPLETED}, - PredicateType.TASK: {COMPLETED, SKIPPED}, + PredicateType.TASK: {COMPLETED, SKIPPED, COMPLETED_OR_SKIPPED}, PredicateType.USER: {EQUAL, NOT_EQUAL, EXIST, NOT_EXIST}, PredicateType.GROUP: {EQUAL, NOT_EQUAL, EXIST, NOT_EXIST}, PredicateType.FILE: {EXIST, NOT_EXIST}, @@ -318,7 +320,13 @@ class PredicateOperator: NOT_EXIST, }, } - UNARY_OPERATORS = {EXIST, NOT_EXIST, COMPLETED, SKIPPED} + UNARY_OPERATORS = { + EXIST, + NOT_EXIST, + COMPLETED, + SKIPPED, + COMPLETED_OR_SKIPPED, + } class ConditionAction: diff --git a/backend/src/processes/services/condition_check/comparator.py b/backend/src/processes/services/condition_check/comparator.py index 36e0d17ba..9fdcad14f 100644 --- a/backend/src/processes/services/condition_check/comparator.py +++ b/backend/src/processes/services/condition_check/comparator.py @@ -61,3 +61,7 @@ def completed(cls, a: bool): @classmethod def skipped(cls, a: bool): return a + + @classmethod + def completed_or_skipped(cls, a: bool): + return a diff --git a/backend/src/processes/services/condition_check/resolvers/task.py b/backend/src/processes/services/condition_check/resolvers/task.py index d09d92c91..c722a3697 100644 --- a/backend/src/processes/services/condition_check/resolvers/task.py +++ b/backend/src/processes/services/condition_check/resolvers/task.py @@ -11,7 +11,10 @@ def _prepare_args(self): api_name=self._predicate.field, workflow_id=self._workflow_id, ) - if self._predicate.operator == PredicateOperator.SKIPPED: + operator = self._predicate.operator + if operator == PredicateOperator.SKIPPED: self.field_value = task.is_skipped - else: + elif operator == PredicateOperator.COMPLETED: self.field_value = task.is_completed + else: + self.field_value = (task.is_completed or task.is_skipped) diff --git a/backend/src/processes/tests/fixtures.py b/backend/src/processes/tests/fixtures.py index 7a47d047f..2c03d558b 100644 --- a/backend/src/processes/tests/fixtures.py +++ b/backend/src/processes/tests/fixtures.py @@ -383,7 +383,7 @@ def create_test_template( ) PredicateTemplate.objects.create( rule=rule, - operator=PredicateOperator.COMPLETED, + operator=PredicateOperator.COMPLETED_OR_SKIPPED, field_type=PredicateType.TASK, field=parents[0], value=None, diff --git a/backend/src/processes/tests/test_services/test_condition_check/test_service.py b/backend/src/processes/tests/test_services/test_condition_check/test_service.py index 301ea323b..d0f556173 100644 --- a/backend/src/processes/tests/test_services/test_condition_check/test_service.py +++ b/backend/src/processes/tests/test_services/test_condition_check/test_service.py @@ -4101,6 +4101,109 @@ def test_check_task_skipped__not_skipped__return_false(): assert result is False +def test_check_task_completed_or_skipped__skipped__return_true(): + + """Check returns True when the referenced task has skipped status.""" + + # arrange + owner = create_test_owner() + workflow = create_test_workflow(user=owner, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + task_1.status = TaskStatus.SKIPPED + task_1.save() + task_2 = workflow.tasks.get(number=2) + condition = Condition.objects.create( + task=task_2, + action=ConditionAction.SKIP_TASK, + order=1, + ) + rule = Rule.objects.create(condition=condition) + Predicate.objects.create( + rule=rule, + operator=PredicateOperator.COMPLETED_OR_SKIPPED, + field_type=PredicateType.TASK, + field=task_1.api_name, + value=None, + ) + + # act + result = ConditionCheckService.check( + condition=condition, + workflow_id=workflow.id, + ) + + # assert + assert result is True + + +def test_check_task_completed_or_skipped__completed__return_true(): + + """Check returns True when the referenced task has completed status.""" + + # arrange + owner = create_test_owner() + workflow = create_test_workflow(user=owner, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + task_1.status = TaskStatus.COMPLETED + task_1.save() + task_2 = workflow.tasks.get(number=2) + condition = Condition.objects.create( + task=task_2, + action=ConditionAction.SKIP_TASK, + order=1, + ) + rule = Rule.objects.create(condition=condition) + Predicate.objects.create( + rule=rule, + operator=PredicateOperator.COMPLETED_OR_SKIPPED, + field_type=PredicateType.TASK, + field=task_1.api_name, + value=None, + ) + + # act + result = ConditionCheckService.check( + condition=condition, + workflow_id=workflow.id, + ) + + # assert + assert result is True + + +def test_check_task_completed_or_skipped__pending__return_false(): + + """Check returns False when the referenced task is still pending.""" + + # arrange + owner = create_test_owner() + workflow = create_test_workflow(user=owner, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + task_2 = workflow.tasks.get(number=2) + condition = Condition.objects.create( + task=task_2, + action=ConditionAction.SKIP_TASK, + order=1, + ) + rule = Rule.objects.create(condition=condition) + Predicate.objects.create( + rule=rule, + operator=PredicateOperator.COMPLETED_OR_SKIPPED, + field_type=PredicateType.TASK, + field=task_1.api_name, + value=None, + ) + + # act + result = ConditionCheckService.check( + condition=condition, + workflow_id=workflow.id, + ) + + # assert + assert result is False + + def test_check_kickoff_completed__return_true(): """Check returns True when predicate type is kickoff (always completed).""" diff --git a/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py b/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py index 3e50413be..30f95af6b 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py +++ b/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py @@ -2252,6 +2252,136 @@ def test_create__predicate_type_task__skipped__ok( assert predicate['field'] == task_1_api_name +def test_create__predicate_type_task_completed_or_skipped__ok( + mocker, + api_client, +): + # arrange + account = create_test_account(plan=BillingPlanType.UNLIMITED) + user = create_test_user(account=account) + mocker.patch( + 'src.processes.serializers.templates.' + 'condition.AnalyticService.templates_task_condition_created', + ) + predicate_api_name = 'predicate-completed-or-skipped' + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + task_3_api_name = 'task-3' + # START_TASK condition with COMPLETED makes task-1 an ancestor of task-3 + start_condition_data = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-start', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + # SKIP_TASK condition with COMPLETED_OR_SKIPPED + skip_condition_data = { + 'order': 2, + 'action': ConditionAction.SKIP_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED_OR_SKIPPED, + 'api_name': predicate_api_name, + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.post( + path='/templates', + data={ + 'name': 'Template', + 'is_active': True, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'fields': [ + { + 'order': 1, + 'name': 'First step performer', + 'type': FieldType.USER, + 'api_name': 'user-field-1', + 'is_required': True, + }, + ], + }, + 'tasks': [ + { + 'number': 1, + 'name': 'Step 1', + 'api_name': task_1_api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + { + 'number': 2, + 'name': 'Step 2', + 'api_name': task_2_api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + { + 'number': 3, + 'name': 'Step 3', + 'api_name': task_3_api_name, + 'conditions': [ + start_condition_data, + skip_condition_data, + ], + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + condition = response.data['tasks'][2]['conditions'][1] + predicate = condition['rules'][0]['predicates'][0] + assert predicate['field_type'] == PredicateType.TASK + assert predicate['api_name'] == predicate_api_name + assert predicate['operator'] == PredicateOperator.COMPLETED_OR_SKIPPED + assert predicate['value'] is None + assert predicate['field'] == task_1_api_name + + @pytest.mark.parametrize( 'case', ( (PredicateOperator.EQUAL, FieldType.STRING, 'yes'), diff --git a/backend/src/processes/tests/test_views/test_templates/test_update/test_condition_template.py b/backend/src/processes/tests/test_views/test_templates/test_update/test_condition_template.py index fea755696..cc80c7dfb 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_update/test_condition_template.py +++ b/backend/src/processes/tests/test_views/test_templates/test_update/test_condition_template.py @@ -652,6 +652,183 @@ def test_update__predicate_to_type_task_skipped__ok( assert predicate.operator == PredicateOperator.SKIPPED +def test_update__predicate_to_type_task_completed_or_skipped__ok( + mocker, + api_client, +): + # arrange + account = create_test_account(plan=BillingPlanType.UNLIMITED) + user = create_test_user(account=account) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + template = create_test_template( + user=user, + tasks_count=3, + is_active=True, + ) + first_task = template.tasks.order_by('number').first() + second_task = template.tasks.get(number=2) + third_task = template.tasks.get(number=3) + + # START_TASK condition on third_task with COMPLETED on first_task + # to establish first_task as an ancestor of third_task + start_condition = ConditionTemplate.objects.create( + action=ConditionAction.START_TASK, + order=1, + task=third_task, + template=template, + ) + start_rule = RuleTemplate.objects.create( + condition=start_condition, + template=template, + ) + PredicateTemplate.objects.create( + rule=start_rule, + operator=PredicateOperator.COMPLETED, + field_type=PredicateType.TASK, + field=first_task.api_name, + value=None, + template=template, + ) + + # SKIP_TASK condition on third_task + # (will be updated to use COMPLETED_OR_SKIPPED) + skip_condition = ConditionTemplate.objects.create( + action=ConditionAction.SKIP_TASK, + order=2, + task=third_task, + template=template, + ) + skip_rule = RuleTemplate.objects.create( + condition=skip_condition, + template=template, + ) + predicate = PredicateTemplate.objects.create( + rule=skip_rule, + operator=PredicateOperator.COMPLETED, + field_type=PredicateType.TASK, + field=first_task.api_name, + value=None, + template=template, + ) + + start_request_data = { + 'api_name': start_condition.api_name, + 'order': start_condition.order, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'api_name': start_rule.api_name, + 'predicates': [ + { + 'api_name': start_rule.predicates.first().api_name, + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'field': first_task.api_name, + 'value': None, + }, + ], + }, + ], + } + skip_request_data = { + 'api_name': skip_condition.api_name, + 'order': skip_condition.order, + 'action': ConditionAction.SKIP_TASK, + 'rules': [ + { + 'api_name': skip_rule.api_name, + 'predicates': [ + { + 'api_name': predicate.api_name, + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED_OR_SKIPPED, + 'field': first_task.api_name, + 'value': None, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data={ + 'id': template.id, + 'name': template.name, + 'is_active': True, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': {'id': template.kickoff_instance.id}, + 'tasks': [ + { + 'id': first_task.id, + 'number': first_task.number, + 'name': first_task.name, + 'api_name': first_task.api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + { + 'id': second_task.id, + 'number': second_task.number, + 'name': second_task.name, + 'api_name': second_task.api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + { + 'id': third_task.id, + 'number': third_task.number, + 'name': third_task.name, + 'api_name': third_task.api_name, + 'conditions': [ + start_request_data, + skip_request_data, + ], + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + condition_data = response.data['tasks'][2]['conditions'][1] + predicate_data = condition_data['rules'][0]['predicates'][0] + assert predicate_data['field_type'] == PredicateType.TASK + assert predicate_data['api_name'] == predicate.api_name + assert predicate_data['operator'] == PredicateOperator.COMPLETED_OR_SKIPPED + assert predicate_data['value'] is None + assert predicate_data['field'] == first_task.api_name + + predicate.refresh_from_db() + assert predicate.field_type == PredicateType.TASK + assert predicate.operator == PredicateOperator.COMPLETED_OR_SKIPPED + + def test_update__predicate_to_type_number__ok( mocker, api_client, From 3736734042b7a757d49ebfbf69ae9a82b5d387e8 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Wed, 13 May 2026 22:39:14 +0500 Subject: [PATCH 22/46] 45773 fix(templates): allow add fieldset fields to the "workflow name template" --- .../serializers/templates/template.py | 16 +++-- .../test_update/test_template.py | 71 +++++++++++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/backend/src/processes/serializers/templates/template.py b/backend/src/processes/serializers/templates/template.py index 50331a8b0..a45e94af4 100644 --- a/backend/src/processes/serializers/templates/template.py +++ b/backend/src/processes/serializers/templates/template.py @@ -1,6 +1,7 @@ import re from typing import Any, Dict, List, Optional, Set +from billiard.sharedctypes import template from django.contrib.auth import get_user_model from django.core.exceptions import ( ValidationError as ValidationCoreError, @@ -192,18 +193,21 @@ def _get_raw_fields_from_kickoff(self, data: Dict[str, Any]) -> List[dict]: }) except (TypeError, AttributeError): continue - fieldset_ids = kickoff_data.get('fieldsets') or [] - normalized_fieldset_ids = [] - for elem in fieldset_ids: + fieldset_link_data = ( + kickoff_data.get('fieldsettemplatekickoff_set') or [] + ) + fieldsets_api_names = [] + for elem in fieldset_link_data: try: - normalized_fieldset_ids.append(int(elem)) + fieldsets_api_names.append(elem['fieldset']['api_name']) except (TypeError, ValueError): continue - if normalized_fieldset_ids: + if fieldsets_api_names: account = self.context.get('account') fieldset_fields = FieldTemplate.objects.filter( - fieldset_id__in=normalized_fieldset_ids, + fieldset__api_name__in=fieldsets_api_names, account_id=account.id, + template_id=template.id, ) for field_template in fieldset_fields: result.append({ 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..53b861efa 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, ) pytestmark = pytest.mark.django_db @@ -2829,3 +2830,73 @@ 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 + fieldset = create_test_fieldset_template( + account=account, + template=template, + ) + 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': [ + { + 'api_name': fieldset.api_name, + '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 From 33618db1837674bd9fa6667690852c8da316ac8f Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Wed, 13 May 2026 23:35:46 +0500 Subject: [PATCH 23/46] 45773 fix(templates): allow add fieldset fields to the "workflow name template" --- backend/src/processes/serializers/templates/template.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/src/processes/serializers/templates/template.py b/backend/src/processes/serializers/templates/template.py index a45e94af4..72a297865 100644 --- a/backend/src/processes/serializers/templates/template.py +++ b/backend/src/processes/serializers/templates/template.py @@ -1,7 +1,6 @@ import re from typing import Any, Dict, List, Optional, Set -from billiard.sharedctypes import template from django.contrib.auth import get_user_model from django.core.exceptions import ( ValidationError as ValidationCoreError, @@ -207,7 +206,6 @@ def _get_raw_fields_from_kickoff(self, data: Dict[str, Any]) -> List[dict]: fieldset_fields = FieldTemplate.objects.filter( fieldset__api_name__in=fieldsets_api_names, account_id=account.id, - template_id=template.id, ) for field_template in fieldset_fields: result.append({ From e5d253ffce6c7eaf35a694402c304e379e813b2c Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Thu, 14 May 2026 19:39:11 +0500 Subject: [PATCH 24/46] 45773 fix(conditions): allow "skipped" and "completed_or_skipped" for "start_task" actions --- backend/src/processes/messages/template.py | 3 +- .../serializers/templates/predicate.py | 3 +- .../test_create/test_condition_template.py | 46 ++++++++++--------- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/backend/src/processes/messages/template.py b/backend/src/processes/messages/template.py index d2e415a7d..940ff780b 100644 --- a/backend/src/processes/messages/template.py +++ b/backend/src/processes/messages/template.py @@ -245,7 +245,8 @@ MSG_PT_0064 = lambda name: format_lazy( _( 'Task condition "{name}": ' - 'Only the "completed" operator is allowed for the "start_task" action', + 'Only the "completed", "skipped" or "completed_or_skipped" ' + 'operators is allowed for the "start_task" action', ), name=name, ) diff --git a/backend/src/processes/serializers/templates/predicate.py b/backend/src/processes/serializers/templates/predicate.py index fdf0a336e..20fa99caf 100644 --- a/backend/src/processes/serializers/templates/predicate.py +++ b/backend/src/processes/serializers/templates/predicate.py @@ -85,7 +85,8 @@ def _validate_allowed_operators( condition = self.context['condition'] if ( condition.action == ConditionAction.START_TASK - and operator != PredicateOperator.COMPLETED + and operator not in + PredicateOperator.ALLOWED_OPERATORS[PredicateType.TASK] ): raise_validation_error( message=MSG_PT_0064(name=task.name), diff --git a/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py b/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py index 30f95af6b..7079c2328 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py +++ b/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py @@ -1944,7 +1944,7 @@ def test_create__predicates_with_equal_api_names__validation_error( assert response.data['details']['api_name'] == predicate_api_name -def test_create__predicate_type_kickoff_completed__ok( +def test_create__start_task_predicate_kickoff_completed__ok( mocker, api_client, ): @@ -2026,21 +2026,32 @@ def test_create__predicate_type_kickoff_completed__ok( assert predicate['field'] is None -def test_create__predicate_type_task__completed__ok( +@pytest.mark.parametrize( + 'operator', + ( + PredicateOperator.SKIPPED, + PredicateOperator.COMPLETED, + PredicateOperator.COMPLETED_OR_SKIPPED, + ), +) +def test_create__start_task_allowed_predicates__ok( mocker, + operator, api_client, ): + # arrange - account = create_test_account(plan=BillingPlanType.UNLIMITED) + account = create_test_account() user = create_test_user(account=account) mocker.patch( 'src.processes.serializers.templates.' 'condition.AnalyticService.templates_task_condition_created', ) - predicate_api_name = 'predicate-1' + predicate_api_name = 'predicate-skip' task_1_api_name = 'task-1' task_2_api_name = 'task-2' - condition_data = { + # START_TASK condition with COMPLETED makes task-1 an ancestor of task-2 + start_condition_data = { 'order': 1, 'action': ConditionAction.START_TASK, 'rules': [ @@ -2048,7 +2059,7 @@ def test_create__predicate_type_task__completed__ok( 'predicates': [ { 'field_type': PredicateType.TASK, - 'operator': PredicateOperator.COMPLETED, + 'operator': operator, 'api_name': predicate_api_name, 'field': task_1_api_name, 'value': None, @@ -2057,6 +2068,7 @@ def test_create__predicate_type_task__completed__ok( }, ], } + api_client.token_authenticate(user) # act @@ -2072,17 +2084,7 @@ def test_create__predicate_type_task__completed__ok( 'role': OwnerRole.OWNER, }, ], - 'kickoff': { - 'fields': [ - { - 'order': 1, - 'name': 'First step performer', - 'type': FieldType.USER, - 'api_name': 'user-field-1', - 'is_required': True, - }, - ], - }, + 'kickoff': {}, 'tasks': [ { 'number': 1, @@ -2099,7 +2101,9 @@ def test_create__predicate_type_task__completed__ok( 'number': 2, 'name': 'Step 2', 'api_name': task_2_api_name, - 'conditions': [condition_data], + 'conditions': [ + start_condition_data, + ], 'raw_performers': [ { 'type': PerformerType.USER, @@ -2117,12 +2121,12 @@ def test_create__predicate_type_task__completed__ok( predicate = condition['rules'][0]['predicates'][0] assert predicate['field_type'] == PredicateType.TASK assert predicate['api_name'] == predicate_api_name - assert predicate['operator'] == PredicateOperator.COMPLETED + assert predicate['operator'] == operator assert predicate['value'] is None assert predicate['field'] == task_1_api_name -def test_create__predicate_type_task__skipped__ok( +def test_create__skip_task_predicate_task_skipped__ok( mocker, api_client, ): @@ -2252,7 +2256,7 @@ def test_create__predicate_type_task__skipped__ok( assert predicate['field'] == task_1_api_name -def test_create__predicate_type_task_completed_or_skipped__ok( +def test_create__skip_task_predicate_type_task_completed_or_skipped__ok( mocker, api_client, ): From cfe8ed5fba26dedce68d8b1795b3421fddb5ecad Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Fri, 15 May 2026 18:36:50 +0500 Subject: [PATCH 25/46] 45773 fix(conditions): allow "skipped" and "completed_or_skipped" for "start_task" actions --- .../test_utils/test_get_tasks_parents.py | 989 ++++++++++++++---- .../test_create/test_condition_template.py | 103 ++ backend/src/processes/utils/common.py | 7 +- 3 files changed, 878 insertions(+), 221 deletions(-) diff --git a/backend/src/processes/tests/test_utils/test_get_tasks_parents.py b/backend/src/processes/tests/test_utils/test_get_tasks_parents.py index 59a17d661..355ad9187 100644 --- a/backend/src/processes/tests/test_utils/test_get_tasks_parents.py +++ b/backend/src/processes/tests/test_utils/test_get_tasks_parents.py @@ -10,281 +10,830 @@ ) -def test_get_parents__task_without_conditions__ok(): - - # arrange - task_1_api_name = 'task-1' - task_2_api_name = 'task-2' - tasks_data = [ - { - 'number': 1, - 'name': 'Task 1', - 'api_name': task_1_api_name, - }, - { - 'number': 2, - 'name': 'Task 2', - 'api_name': task_2_api_name, - 'conditions': [ +class TestCompletedTaskOperator: + + def test_get_parents__task_without_conditions__ok(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + condition = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ { - 'order': 1, - 'action': ConditionAction.START_TASK, - 'rules': [ + 'predicates': [ { - 'predicates': [ - { - 'field_type': PredicateType.TASK, - 'operator': PredicateOperator.COMPLETED, - 'api_name': 'predicate-1', - 'field': task_1_api_name, - 'value': None, - }, - ], + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, }, ], }, ], - }, - ] + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [condition], + }, + ] - # act - ancestors = get_tasks_parents(tasks_data) + # act + ancestors = get_tasks_parents(tasks_data) - # assert - assert ancestors[task_1_api_name] == [] - assert ancestors[task_2_api_name] == [task_1_api_name] + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [task_1_api_name] + @pytest.mark.parametrize( + 'cond_action', + (ConditionAction.SKIP_TASK, ConditionAction.END_WORKFLOW), + ) + def test_get_parents__task_with_not_start_conditions__ok( + self, + cond_action, + ): -@pytest.mark.parametrize( - 'cond_action', - (ConditionAction.SKIP_TASK, ConditionAction.END_WORKFLOW), -) -def test_get_parents__task_with_not_start_conditions__ok(cond_action): - - # arrange - task_1_api_name = 'task-1' - task_2_api_name = 'task-2' - tasks_data = [ - { - 'number': 1, - 'name': 'Task 1', - 'api_name': task_1_api_name, - }, - { - 'number': 2, - 'name': 'Task 2', - 'api_name': task_2_api_name, - 'conditions': [ + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + condition = { + 'order': 1, + 'action': cond_action, + 'rules': [ { - 'order': 1, - 'action': cond_action, - 'rules': [ + 'predicates': [ { - 'predicates': [ - { - 'field_type': PredicateType.TASK, - 'operator': PredicateOperator.COMPLETED, - 'api_name': 'predicate-1', - 'field': task_1_api_name, - 'value': None, - }, - ], + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, }, ], }, ], - }, - ] + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [condition], + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) - # act - ancestors = get_tasks_parents(tasks_data) + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [] + + def test_get_parents__two_start_predicates__ok(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + task_3_api_name = 'task-3' + condition = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'field': task_2_api_name, + 'value': None, + }, + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'field': task_3_api_name, + 'value': None, + }, + ], + }, + ], + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + 'conditions': [condition], + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + }, + { + 'number': 3, + 'name': 'Task 3', + 'api_name': task_3_api_name, + }, + ] - # assert - assert ancestors[task_1_api_name] == [] - assert ancestors[task_2_api_name] == [] + # act + ancestors = get_tasks_parents(tasks_data) + # assert + assert ancestors[task_1_api_name] == [task_2_api_name, task_3_api_name] + assert ancestors[task_2_api_name] == [] + assert ancestors[task_3_api_name] == [] -def test_get_parents__two_start_predicates__ok(): + def test_get_parents__linear_template__ok(self): - # arrange - task_1_api_name = 'task-1' - task_2_api_name = 'task-2' - task_3_api_name = 'task-3' - tasks_data = [ - { - 'number': 1, - 'name': 'Task 1', - 'api_name': task_1_api_name, - 'conditions': [ + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + condition_1 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ { - 'order': 1, - 'action': ConditionAction.START_TASK, - 'rules': [ + 'predicates': [ { - 'predicates': [ - { - 'field_type': PredicateType.TASK, - 'operator': PredicateOperator.COMPLETED, - 'field': task_2_api_name, - 'value': None, - }, - { - 'field_type': PredicateType.TASK, - 'operator': PredicateOperator.COMPLETED, - 'field': task_3_api_name, - 'value': None, - }, - ], + 'field_type': PredicateType.KICKOFF, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-1', + 'field': None, + 'value': None, }, ], }, ], - }, - { - 'number': 2, - 'name': 'Task 2', - 'api_name': task_2_api_name, - }, - { - 'number': 3, - 'name': 'Task 3', - 'api_name': task_3_api_name, - }, - ] - - # act - ancestors = get_tasks_parents(tasks_data) - - # assert - assert ancestors[task_1_api_name] == [task_2_api_name, task_3_api_name] - assert ancestors[task_2_api_name] == [] - assert ancestors[task_3_api_name] == [] - - -def test_get_parents__linear_template__ok(): - - # arrange - task_1_api_name = 'task-1' - task_2_api_name = 'task-2' - tasks_data = [ - { - 'number': 1, - 'name': 'Task 1', - 'api_name': task_1_api_name, - 'conditions': [ + } + condition_2 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + 'conditions': [condition_1], + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [condition_2], + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [task_1_api_name] + + def test_get_parents__deleted_task_in_conditions__skip(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + deleted_task_api_name = 'task-3' + condition_1 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-1', + 'field': deleted_task_api_name, + 'value': None, + }, + ], + }, + ], + } + + condition_2 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + 'conditions': [condition_1], + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [condition_2], + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [task_1_api_name] + + +class TestSkippedTaskOperator: + + def test_get_parents__task_without_conditions__ok(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [ + { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.SKIPPED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + }, + ], + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [task_1_api_name] + + @pytest.mark.parametrize( + 'cond_action', + (ConditionAction.SKIP_TASK, ConditionAction.END_WORKFLOW), + ) + def test_get_parents__task_with_not_start_conditions__ok( + self, + cond_action, + ): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [ + { + 'order': 1, + 'action': cond_action, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.SKIPPED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + }, + ], + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [] + + def test_get_parents__two_start_predicates__ok(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + task_3_api_name = 'task-3' + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + 'conditions': [ + { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.SKIPPED, + 'field': task_2_api_name, + 'value': None, + }, + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.SKIPPED, + 'field': task_3_api_name, + 'value': None, + }, + ], + }, + ], + }, + ], + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + }, + { + 'number': 3, + 'name': 'Task 3', + 'api_name': task_3_api_name, + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [task_2_api_name, task_3_api_name] + assert ancestors[task_2_api_name] == [] + assert ancestors[task_3_api_name] == [] + + def test_get_parents__linear_template__ok(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + condition_1 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ { - 'order': 1, - 'action': ConditionAction.START_TASK, - 'rules': [ + 'predicates': [ { - 'predicates': [ - { - 'field_type': PredicateType.KICKOFF, - 'operator': PredicateOperator.COMPLETED, - 'api_name': 'predicate-1', - 'field': None, - 'value': None, - }, - ], + 'field_type': PredicateType.KICKOFF, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-1', + 'field': None, + 'value': None, }, ], }, ], - }, - { - 'number': 2, - 'name': 'Task 2', - 'api_name': task_2_api_name, - 'conditions': [ + } + condition_2 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ { - 'order': 1, - 'action': ConditionAction.START_TASK, - 'rules': [ + 'predicates': [ { - 'predicates': [ - { - 'field_type': PredicateType.TASK, - 'operator': PredicateOperator.COMPLETED, - 'api_name': 'predicate-1', - 'field': task_1_api_name, - 'value': None, - }, - ], + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.SKIPPED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, }, ], }, ], - }, - ] + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + 'conditions': [condition_1], + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [condition_2], + }, + ] - # act - ancestors = get_tasks_parents(tasks_data) + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [task_1_api_name] + + def test_get_parents__deleted_task_in_conditions__skip(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + deleted_task_api_name = 'task-3' + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + 'conditions': [ + { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.SKIPPED, + 'api_name': 'predicate-1', + 'field': deleted_task_api_name, + 'value': None, + }, + ], + }, + ], + }, + ], + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [ + { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.SKIPPED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + }, + ], + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [task_1_api_name] + + +class TestCompletedOrSkippedTaskOperator: + + def test_get_parents__task_without_conditions__ok(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + conditions_1 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED_OR_SKIPPED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [conditions_1], + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [task_1_api_name] + + @pytest.mark.parametrize( + 'cond_action', + (ConditionAction.SKIP_TASK, ConditionAction.END_WORKFLOW), + ) + def test_get_parents__task_with_not_start_conditions__ok( + self, + cond_action, + ): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + condition_1 = { + 'order': 1, + 'action': cond_action, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED_OR_SKIPPED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [condition_1], + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [] + + def test_get_parents__two_start_predicates__ok(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + task_3_api_name = 'task-3' + condition_1 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED_OR_SKIPPED, + 'field': task_2_api_name, + 'value': None, + }, + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED_OR_SKIPPED, + 'field': task_3_api_name, + 'value': None, + }, + ], + }, + ], + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + 'conditions': [condition_1], + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + }, + { + 'number': 3, + 'name': 'Task 3', + 'api_name': task_3_api_name, + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [task_2_api_name, task_3_api_name] + assert ancestors[task_2_api_name] == [] + assert ancestors[task_3_api_name] == [] + + def test_get_parents__linear_template__ok(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + condition_1 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.KICKOFF, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-1', + 'field': None, + 'value': None, + }, + ], + }, + ], + } + condition_2 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED_OR_SKIPPED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + 'conditions': [condition_1], + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [condition_2], + }, + ] - # assert - assert ancestors[task_1_api_name] == [] - assert ancestors[task_2_api_name] == [task_1_api_name] + # act + ancestors = get_tasks_parents(tasks_data) + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [task_1_api_name] -def test_get_parents__deleted_task_in_conditions__skip(): + def test_get_parents__deleted_task_in_conditions__skip(self): - # arrange - task_1_api_name = 'task-1' - task_2_api_name = 'task-2' - deleted_task_api_name = 'task-3' - tasks_data = [ - { - 'number': 1, - 'name': 'Task 1', - 'api_name': task_1_api_name, - 'conditions': [ + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + deleted_task_api_name = 'task-3' + condition_1 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ { - 'order': 1, - 'action': ConditionAction.START_TASK, - 'rules': [ + 'predicates': [ { - 'predicates': [ - { - 'field_type': PredicateType.TASK, - 'operator': PredicateOperator.COMPLETED, - 'api_name': 'predicate-1', - 'field': deleted_task_api_name, - 'value': None, - }, - ], + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED_OR_SKIPPED, + 'api_name': 'predicate-1', + 'field': deleted_task_api_name, + 'value': None, }, ], }, ], - }, - { - 'number': 2, - 'name': 'Task 2', - 'api_name': task_2_api_name, - 'conditions': [ + } + condition_2 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ { - 'order': 1, - 'action': ConditionAction.START_TASK, - 'rules': [ + 'predicates': [ { - 'predicates': [ - { - 'field_type': PredicateType.TASK, - 'operator': PredicateOperator.COMPLETED, - 'api_name': 'predicate-1', - 'field': task_1_api_name, - 'value': None, - }, - ], + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED_OR_SKIPPED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, }, ], }, ], - }, - ] + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + 'conditions': [condition_1], + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [condition_2], + }, + ] - # act - ancestors = get_tasks_parents(tasks_data) + # act + ancestors = get_tasks_parents(tasks_data) - # assert - assert ancestors[task_1_api_name] == [] - assert ancestors[task_2_api_name] == [task_1_api_name] + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [task_1_api_name] diff --git a/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py b/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py index 7079c2328..2d73226db 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py +++ b/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py @@ -2386,6 +2386,109 @@ def test_create__skip_task_predicate_type_task_completed_or_skipped__ok( assert predicate['field'] == task_1_api_name +@pytest.mark.parametrize( + 'operator', + (PredicateOperator.SKIPPED, PredicateOperator.COMPLETED_OR_SKIPPED), +) +def test_create__start_and_skip_condition_on_the_same_task__allowed( + mocker, + operator, + api_client, +): + # arrange + account = create_test_account() + user = create_test_owner(account=account) + mocker.patch( + 'src.processes.serializers.templates.' + 'condition.AnalyticService.templates_task_condition_created', + ) + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + condition_1 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': operator, + 'api_name': 'predicate-start', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + condition_2 = { + 'order': 2, + 'action': ConditionAction.SKIP_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': operator, + 'api_name': 'predicate-skip', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.post( + path='/templates', + data={ + 'name': 'Template', + 'is_active': True, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': {}, + 'tasks': [ + { + 'number': 1, + 'name': 'Step 1', + 'api_name': task_1_api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + { + 'number': 2, + 'name': 'Step 2', + 'api_name': task_2_api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'conditions': [ + condition_1, + condition_2, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + + @pytest.mark.parametrize( 'case', ( (PredicateOperator.EQUAL, FieldType.STRING, 'yes'), diff --git a/backend/src/processes/utils/common.py b/backend/src/processes/utils/common.py index 83644ccc6..998f5d81a 100644 --- a/backend/src/processes/utils/common.py +++ b/backend/src/processes/utils/common.py @@ -166,6 +166,11 @@ def get_tasks_parents(tasks_data: List[Dict]) -> dict: """ Find and return task parents api_names """ + allowed_operators = { + PredicateOperator.COMPLETED, + PredicateOperator.SKIPPED, + PredicateOperator.COMPLETED_OR_SKIPPED, + } parents_by_tasks = {} available_api_names = { e['api_name'] for e in tasks_data if e.get('api_name') @@ -181,7 +186,7 @@ def get_tasks_parents(tasks_data: List[Dict]) -> dict: for p in rule.get('predicates', ()): try: if ( - p['operator'] == PredicateOperator.COMPLETED + p['operator'] in allowed_operators and p['field_type'] == PredicateType.TASK and p['field'] in available_api_names ): From a3c6cb0192662cbc0798e1c43994c3066637af8d Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Fri, 15 May 2026 19:41:00 +0500 Subject: [PATCH 26/46] 45773 feat(templates): add the fieldset order field to the GET /templates/id/fields response --- .../processes/serializers/templates/field.py | 9 +-- .../serializers/templates/fieldset.py | 20 ------ .../serializers/templates/fieldset_link.py | 27 +++++++ .../serializers/templates/kickoff.py | 34 ++------- .../processes/serializers/templates/task.py | 10 +-- .../serializers/templates/template.py | 5 +- .../test_views/test_templates/test_fields.py | 70 +++++++++++++++++-- backend/src/processes/views/template.py | 13 +++- 8 files changed, 120 insertions(+), 68 deletions(-) diff --git a/backend/src/processes/serializers/templates/field.py b/backend/src/processes/serializers/templates/field.py index 7b92a8321..c7c13f93e 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', @@ -234,12 +234,13 @@ 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 index a6f33db15..bfa99da42 100644 --- a/backend/src/processes/serializers/templates/fieldset.py +++ b/backend/src/processes/serializers/templates/fieldset.py @@ -15,7 +15,6 @@ ) from src.processes.serializers.templates.field import ( FieldTemplateSerializer, - FieldTemplateShortViewSerializer, ) @@ -95,22 +94,3 @@ def get_tasks(self, instance): many=True, default=list, ).data - - -class FieldsetTemplateShortViewSerializer(ModelSerializer): - - class Meta: - model = FieldsetTemplate - fields = ( - 'name', - 'description', - 'fields', - 'api_name', - ) - - api_name = CharField(required=False, max_length=200) - fields = FieldTemplateShortViewSerializer( - many=True, - required=False, - read_only=True, - ) diff --git a/backend/src/processes/serializers/templates/fieldset_link.py b/backend/src/processes/serializers/templates/fieldset_link.py index abf953d13..2027953a3 100644 --- a/backend/src/processes/serializers/templates/fieldset_link.py +++ b/backend/src/processes/serializers/templates/fieldset_link.py @@ -62,3 +62,30 @@ class Meta: source='fieldset.fields', many=True, ) + + +class FieldsetTemplateTaskListSerializer(ModelSerializer): + + class Meta: + model = FieldsetTemplateTaskTemplate + fields = ( + 'order', + 'name', + 'description', + 'fields', + 'api_name', + 'label_position', + 'layout', + ) + + name = CharField(source='fieldset.name') + description = CharField(source='fieldset.description') + api_name = CharField(source='fieldset.api_name') + label_position = CharField( + source='fieldset.label_position', + ) + layout = CharField(source='fieldset.layout') + fields = FieldTemplateSerializer( + source='fieldset.fields', + many=True, + ) diff --git a/backend/src/processes/serializers/templates/kickoff.py b/backend/src/processes/serializers/templates/kickoff.py index 0d2145fa0..27f458fff 100644 --- a/backend/src/processes/serializers/templates/kickoff.py +++ b/backend/src/processes/serializers/templates/kickoff.py @@ -11,10 +11,6 @@ from src.processes.serializers.templates.field import ( FieldTemplateListSerializer, FieldTemplateSerializer, - FieldTemplateShortViewSerializer, -) -from src.processes.serializers.templates.fieldset import ( - FieldsetTemplateShortViewSerializer, ) from src.processes.serializers.templates.fieldset_link import ( FieldsetTemplateKickoffSerializer, @@ -117,7 +113,8 @@ def update( return instance -class KickoffOnlyFieldsSerializer(ModelSerializer): +class KickoffListSerializer(ModelSerializer): + class Meta: model = Kickoff fields = ( @@ -125,15 +122,10 @@ class Meta: 'fieldsets', ) - fields = FieldTemplateShortViewSerializer( - many=True, - default=[], - read_only=True, - ) - fieldsets = FieldsetTemplateShortViewSerializer( + fields = FieldTemplateListSerializer(many=True) + fieldsets = FieldsetTemplateKickoffListSerializer( + source='fieldsettemplatekickoff_set', many=True, - default=[], - read_only=True, ) def to_representation(self, instance): @@ -142,19 +134,3 @@ def to_representation(self, instance): if isinstance(instance, models.Manager): instance = instance.first() return super().to_representation(instance) - - -class KickoffListSerializer(ModelSerializer): - - class Meta: - model = Kickoff - fields = ( - 'fields', - 'fieldsets', - ) - - fields = FieldTemplateListSerializer(many=True) - fieldsets = FieldsetTemplateKickoffListSerializer( - source='fieldsettemplatekickoff_set', - many=True, - ) diff --git a/backend/src/processes/serializers/templates/task.py b/backend/src/processes/serializers/templates/task.py index 520a7224b..a15c9804f 100644 --- a/backend/src/processes/serializers/templates/task.py +++ b/backend/src/processes/serializers/templates/task.py @@ -34,11 +34,8 @@ FieldTemplateSerializer, FieldTemplateShortViewSerializer, ) -from src.processes.serializers.templates.fieldset import ( - FieldsetTemplateShortViewSerializer, -) from src.processes.serializers.templates.fieldset_link import ( - FieldsetTemplateTaskTemplateSerializer, + FieldsetTemplateTaskTemplateSerializer, FieldsetTemplateTaskListSerializer, ) from src.processes.serializers.templates.mixins import ( CreateOrUpdateInstanceMixin, @@ -646,10 +643,9 @@ class Meta: default=[], read_only=True, ) - fieldsets = FieldsetTemplateShortViewSerializer( + fieldsets = FieldsetTemplateTaskListSerializer( + source='fieldsettemplatetasktemplate_set', many=True, - default=[], - read_only=True, ) diff --git a/backend/src/processes/serializers/templates/template.py b/backend/src/processes/serializers/templates/template.py index 72a297865..f3dfd6ef9 100644 --- a/backend/src/processes/serializers/templates/template.py +++ b/backend/src/processes/serializers/templates/template.py @@ -49,7 +49,6 @@ ) from src.processes.serializers.templates.kickoff import ( KickoffListSerializer, - KickoffOnlyFieldsSerializer, KickoffSerializer, ) from src.processes.serializers.templates.mixins import ( @@ -941,7 +940,7 @@ class Meta: 'tasks', ) - kickoff = KickoffOnlyFieldsSerializer(required=False, read_only=True) + kickoff = KickoffListSerializer(required=False, read_only=True) tasks = TemplateTaskOnlyFieldsSerializer( many=True, required=False, @@ -957,7 +956,7 @@ def to_representation(self, data: Dict[str, Any]): # 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( + kickoff_slz = KickoffListSerializer( instance=self.instance.kickoff_instance, ) data['kickoff'] = kickoff_slz.data 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 4f081b40f..164f55621 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,7 +24,7 @@ create_test_owner, create_test_template, create_test_workflow, - create_test_fieldset_template, + create_test_fieldset_template, create_test_dataset, ) pytestmark = pytest.mark.django_db @@ -531,12 +534,31 @@ def test_fields__kickoff_fieldset__ok(api_client): 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, + ) + selection = FieldTemplateSelection.objects.create( + field_template=field, + value='Value 1', + template=template, ) - field = fieldset.fields.first() api_client.token_authenticate(user) # act @@ -550,8 +572,12 @@ def test_fields__kickoff_fieldset__ok(api_client): 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 @@ -559,6 +585,13 @@ def test_fields__kickoff_fieldset__ok(api_client): assert field_data['description'] == field.description assert field_data['is_hidden'] == field.is_hidden assert field_data['api_name'] == field.api_name + assert field_data['selections'] == [ + { + 'value': selection.value, + 'api_name': selection.api_name, + }, + ] + assert field_data['dataset'] == dataset.id def test_fields__task_fieldset__ok(api_client): @@ -567,12 +600,31 @@ def test_fields__task_fieldset__ok(api_client): 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, + ) + selection = FieldTemplateSelection.objects.create( + field_template=field, + value='Value 1', + template=template, ) - field = fieldset.fields.first() api_client.token_authenticate(user) # act @@ -586,6 +638,9 @@ def test_fields__task_fieldset__ok(api_client): 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] @@ -595,3 +650,10 @@ def test_fields__task_fieldset__ok(api_client): assert field_data['description'] == field.description assert field_data['is_hidden'] == field.is_hidden assert field_data['api_name'] == field.api_name + assert field_data['selections'] == [ + { + 'value': selection.value, + 'api_name': selection.api_name, + }, + ] + assert field_data['dataset'] == dataset.id diff --git a/backend/src/processes/views/template.py b/backend/src/processes/views/template.py index 3df72bfce..a6f10a440 100644 --- a/backend/src/processes/views/template.py +++ b/backend/src/processes/views/template.py @@ -32,6 +32,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, @@ -288,7 +289,17 @@ 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') ), ), From 435b1ec3a622802b5cbc0c6d90d4b9e648a61ad1 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Sat, 16 May 2026 00:31:13 +0500 Subject: [PATCH 27/46] 45773 feat(templates): add the fieldset order field to the GET /templates/id/fields response --- .../processes/serializers/templates/field.py | 13 -- .../processes/serializers/templates/task.py | 26 +-- .../serializers/templates/template.py | 38 ---- .../serializers/templates/template_fields.py | 177 ++++++++++++++++++ .../test_views/test_templates/test_fields.py | 22 +-- backend/src/processes/views/template.py | 4 +- 6 files changed, 187 insertions(+), 93 deletions(-) create mode 100644 backend/src/processes/serializers/templates/template_fields.py diff --git a/backend/src/processes/serializers/templates/field.py b/backend/src/processes/serializers/templates/field.py index c7c13f93e..48ea10dff 100644 --- a/backend/src/processes/serializers/templates/field.py +++ b/backend/src/processes/serializers/templates/field.py @@ -215,19 +215,6 @@ 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: diff --git a/backend/src/processes/serializers/templates/task.py b/backend/src/processes/serializers/templates/task.py index a15c9804f..b053bf1ef 100644 --- a/backend/src/processes/serializers/templates/task.py +++ b/backend/src/processes/serializers/templates/task.py @@ -32,10 +32,9 @@ ) from src.processes.serializers.templates.field import ( FieldTemplateSerializer, - FieldTemplateShortViewSerializer, ) from src.processes.serializers.templates.fieldset_link import ( - FieldsetTemplateTaskTemplateSerializer, FieldsetTemplateTaskListSerializer, + FieldsetTemplateTaskTemplateSerializer, ) from src.processes.serializers.templates.mixins import ( CreateOrUpdateInstanceMixin, @@ -626,29 +625,6 @@ class Meta: ) -class TemplateTaskOnlyFieldsSerializer(ModelSerializer): - - class Meta: - model = TaskTemplate - fields = ( - 'name', - 'number', - 'api_name', - 'fields', - 'fieldsets', - ) - - fields = FieldTemplateShortViewSerializer( - many=True, - default=[], - read_only=True, - ) - fieldsets = FieldsetTemplateTaskListSerializer( - source='fieldsettemplatetasktemplate_set', - many=True, - ) - - class TaskTemplatePrivilegesSerializer(ModelSerializer): class Meta: model = TaskTemplate diff --git a/backend/src/processes/serializers/templates/template.py b/backend/src/processes/serializers/templates/template.py index f3dfd6ef9..50b1e94b9 100644 --- a/backend/src/processes/serializers/templates/template.py +++ b/backend/src/processes/serializers/templates/template.py @@ -62,7 +62,6 @@ ShortTaskSerializer, TaskTemplatePrivilegesSerializer, TaskTemplateSerializer, - TemplateTaskOnlyFieldsSerializer, ) from src.processes.services.templates.fieldsets.fieldset import ( FieldSetTemplateService, @@ -931,43 +930,6 @@ def get_is_editable(self, instance: Template) -> bool: ).exists() -class TemplateOnlyFieldsSerializer(ModelSerializer): - class Meta: - model = Template - fields = ( - 'id', - 'kickoff', - 'tasks', - ) - - kickoff = KickoffListSerializer(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 = KickoffListSerializer( - 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, 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..009e5444c --- /dev/null +++ b/backend/src/processes/serializers/templates/template_fields.py @@ -0,0 +1,177 @@ +from typing import Any, Dict + +from rest_framework.fields import CharField +from rest_framework.serializers import ( + ModelSerializer, +) +from src.processes.models.templates.fields import FieldTemplate +from src.processes.models.templates.fieldset import ( + FieldsetTemplateTaskTemplate, + FieldsetTemplateKickoff, +) +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 FieldsetTemplateKickoffListSerializer(ModelSerializer): + + class Meta: + model = FieldsetTemplateKickoff + fields = ( + 'order', + 'name', + 'description', + 'fields', + 'api_name', + 'label_position', + 'layout', + ) + + name = CharField(source='fieldset.name') + description = CharField(source='fieldset.description') + api_name = CharField(source='fieldset.api_name') + label_position = CharField( + source='fieldset.label_position', + ) + layout = CharField(source='fieldset.layout') + fields = FieldTemplateOnlyFieldsSerializer( + source='fieldset.fields', + many=True, + ) + + +class FieldsetTaskTemplateOnlyFieldsSerializer(ModelSerializer): + + class Meta: + model = FieldsetTemplateTaskTemplate + fields = ( + 'order', + 'name', + 'description', + 'fields', + 'api_name', + 'label_position', + 'layout', + ) + + name = CharField(source='fieldset.name') + description = CharField(source='fieldset.description') + api_name = CharField(source='fieldset.api_name') + label_position = CharField( + source='fieldset.label_position', + ) + layout = CharField(source='fieldset.layout') + fields = FieldTemplateOnlyFieldsSerializer( + source='fieldset.fields', + many=True, + ) + + +class KickoffOnlyFieldsSerializer(ModelSerializer): + + class Meta: + model = Kickoff + fields = ( + 'fields', + 'fieldsets', + ) + + fields = FieldTemplateOnlyFieldsSerializer( + many=True, + default=[], + read_only=True, + ) + fieldsets = FieldsetTemplateKickoffListSerializer( + source='fieldsettemplatekickoff_set', + 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 = FieldsetTaskTemplateOnlyFieldsSerializer( + source='fieldsettemplatetasktemplate_set', + 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/tests/test_views/test_templates/test_fields.py b/backend/src/processes/tests/test_views/test_templates/test_fields.py index 164f55621..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 @@ -554,7 +554,7 @@ def test_fields__kickoff_fieldset__ok(api_client): account=account, dataset=dataset, ) - selection = FieldTemplateSelection.objects.create( + FieldTemplateSelection.objects.create( field_template=field, value='Value 1', template=template, @@ -585,13 +585,8 @@ def test_fields__kickoff_fieldset__ok(api_client): assert field_data['description'] == field.description assert field_data['is_hidden'] == field.is_hidden assert field_data['api_name'] == field.api_name - assert field_data['selections'] == [ - { - 'value': selection.value, - 'api_name': selection.api_name, - }, - ] - assert field_data['dataset'] == dataset.id + assert 'selections' not in field_data + assert 'dataset' not in field_data def test_fields__task_fieldset__ok(api_client): @@ -620,7 +615,7 @@ def test_fields__task_fieldset__ok(api_client): account=account, dataset=dataset, ) - selection = FieldTemplateSelection.objects.create( + FieldTemplateSelection.objects.create( field_template=field, value='Value 1', template=template, @@ -650,10 +645,5 @@ def test_fields__task_fieldset__ok(api_client): assert field_data['description'] == field.description assert field_data['is_hidden'] == field.is_hidden assert field_data['api_name'] == field.api_name - assert field_data['selections'] == [ - { - 'value': selection.value, - 'api_name': selection.api_name, - }, - ] - assert field_data['dataset'] == dataset.id + assert 'selections' not in field_data + assert 'dataset' not in field_data diff --git a/backend/src/processes/views/template.py b/backend/src/processes/views/template.py index a6f10a440..295c72102 100644 --- a/backend/src/processes/views/template.py +++ b/backend/src/processes/views/template.py @@ -54,6 +54,9 @@ TemplateStepFilterSerializer, TemplateStepNameSerializer, ) +from src.processes.serializers.templates.template_fields import ( + TemplateOnlyFieldsSerializer, +) from src.processes.serializers.templates.template import ( TemplateAiSerializer, TemplateByNameSerializer, @@ -61,7 +64,6 @@ TemplateExportFilterSerializer, TemplateListFilterSerializer, TemplateListSerializer, - TemplateOnlyFieldsSerializer, TemplateSerializer, TemplateTitlesByEventsSerializer, TemplateTitlesByTasksSerializer, From 8c8f0900760a385dcc07eaa2f2889988b279022c Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 19 May 2026 01:36:06 +0500 Subject: [PATCH 28/46] 45773 feat(conditions): replace predicate operator "completed" to "completed_or_skipped" for backward compatibility --- .../migrations/0252_add_fieldsets.py | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/backend/src/processes/migrations/0252_add_fieldsets.py b/backend/src/processes/migrations/0252_add_fieldsets.py index b3acfa7cb..d7a90ca02 100644 --- a/backend/src/processes/migrations/0252_add_fieldsets.py +++ b/backend/src/processes/migrations/0252_add_fieldsets.py @@ -126,7 +126,7 @@ class Migration(migrations.Migration): constraint=models.UniqueConstraint( condition=models.Q(is_deleted=False), fields=('template', 'api_name'), - name='fieldsettemplate_template_api_name_unique'), + name='fieldsettemplate_api_name_template_unique'), ), migrations.AddField( model_name='fieldsettemplate', @@ -158,4 +158,48 @@ class Migration(migrations.Migration): name='rules', field=models.ManyToManyField(blank=True, related_name='fields', to='processes.FieldSetRule'), ), + migrations.AlterField( + model_name='predicate', + name='operator', + field=models.CharField( + choices=[('equals', 'Equal'), ('not_equals', 'Not equal'), + ('exists', 'Exists'), ('not_exists', 'Not exists'), + ('contains', 'Contains'), + ('not_contains', 'Not contains'), + ('more_than', 'More than'), + ('less_than', 'Less than'), + ('completed', 'completed'), ('skipped', 'skipped'), + ('completed_or_skipped', 'completed_or_skipped')], + max_length=30), + ), + migrations.AlterField( + model_name='predicatetemplate', + name='operator', + field=models.CharField( + choices=[('equals', 'Equal'), ('not_equals', 'Not equal'), + ('exists', 'Exists'), ('not_exists', 'Not exists'), + ('contains', 'Contains'), + ('not_contains', 'Not contains'), + ('more_than', 'More than'), + ('less_than', 'Less than'), + ('completed', 'completed'), ('skipped', 'skipped'), + ('completed_or_skipped', 'completed_or_skipped')], + max_length=30), + ), + migrations.RunSQL(""" + UPDATE processes_predicate p + SET operator = 'completed_or_skipped' + FROM processes_rule r + WHERE p.rule_id = r.id + AND p.operator = 'completed' + AND p.field_type = 'task' + """), + migrations.RunSQL(""" + UPDATE processes_predicatetemplate pt + SET operator = 'completed_or_skipped' + FROM processes_ruletemplate rt + WHERE pt.rule_id = rt.id + AND pt.operator = 'completed' + AND pt.field_type = 'task' + """) ] From f34b880fb2c4939198124639ff499f3152a4100c Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 19 May 2026 02:37:13 +0500 Subject: [PATCH 29/46] 45773 feat(conditions): replace predicate operator "completed" to "completed_or_skipped" in the drafts --- .../migrations/0252_add_fieldsets.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/backend/src/processes/migrations/0252_add_fieldsets.py b/backend/src/processes/migrations/0252_add_fieldsets.py index d7a90ca02..274d579d7 100644 --- a/backend/src/processes/migrations/0252_add_fieldsets.py +++ b/backend/src/processes/migrations/0252_add_fieldsets.py @@ -201,5 +201,74 @@ class Migration(migrations.Migration): WHERE pt.rule_id = rt.id AND pt.operator = 'completed' AND pt.field_type = 'task' + """), + migrations.RunSQL(""" + UPDATE processes_templatedraft td + SET draft = ( + SELECT jsonb_set( + td.draft, + '{tasks}', + jsonb_agg( + CASE + WHEN jsonb_typeof(task->'conditions') = 'array' THEN + jsonb_set( + task, + '{conditions}', + ( + SELECT jsonb_agg( + CASE + WHEN jsonb_typeof(cond->'rules') = 'array' THEN + jsonb_set( + cond, + '{rules}', + ( + SELECT jsonb_agg( + CASE + WHEN jsonb_typeof(rule->'predicates') = 'array' THEN + jsonb_set( + rule, + '{predicates}', + ( + SELECT jsonb_agg( + CASE + WHEN (pred->>'operator') = 'completed' + AND (pred->>'field_type') = 'task' + THEN jsonb_set(pred, '{operator}', '"completed_or_skipped"') + ELSE pred + END + ) + FROM jsonb_array_elements(rule->'predicates') AS pred + ) + ) + ELSE rule + END + ) + FROM jsonb_array_elements(cond->'rules') AS rule + ) + ) + ELSE cond + END + ) + FROM jsonb_array_elements(task->'conditions') AS cond + ) + ) + ELSE task + END + ) + ) + FROM jsonb_array_elements(td.draft->'tasks') AS task + ) + WHERE td.is_deleted = FALSE + AND td.draft IS NOT NULL + AND jsonb_typeof(td.draft->'tasks') = 'array' + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(td.draft->'tasks') AS task, + jsonb_array_elements(task->'conditions') AS cond, + jsonb_array_elements(cond->'rules') AS rule, + jsonb_array_elements(rule->'predicates') AS pred + WHERE (pred->>'operator') = 'completed' + AND (pred->>'field_type') = 'task' + ) """) ] From ae15aede65403b548435a548404bed348af0f807 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 9 Jun 2026 16:41:05 +0500 Subject: [PATCH 30/46] refactor(migrations) rename fiedlsets migration file from 0253 to 0254 --- .../migrations/{0253_add_fieldsets.py => 0254_add_fieldsets.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/src/processes/migrations/{0253_add_fieldsets.py => 0254_add_fieldsets.py} (100%) diff --git a/backend/src/processes/migrations/0253_add_fieldsets.py b/backend/src/processes/migrations/0254_add_fieldsets.py similarity index 100% rename from backend/src/processes/migrations/0253_add_fieldsets.py rename to backend/src/processes/migrations/0254_add_fieldsets.py From 5936b6333ecabaa0f566e517fe84c05dcde5e279 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 16 Jun 2026 13:35:22 +0500 Subject: [PATCH 31/46] 45773 feat(fieldsets): Add API for a shared fieldsets --- .../migrations/0144_auto_20260609_1910.py | 18 + backend/src/generics/base/service.py | 8 +- backend/src/processes/messages/fieldset.py | 6 + .../migrations/0254_add_fieldsets.py | 274 ----- .../migrations/0254_auto_20260609_1910.py | 161 +++ backend/src/processes/models/mixins.py | 2 + .../src/processes/models/templates/fields.py | 2 + .../processes/models/templates/fieldset.py | 88 +- .../processes/models/templates/template.py | 10 +- .../processes/models/workflows/fieldset.py | 1 - backend/src/processes/querysets.py | 10 - .../serializers/templates/fieldset.py | 87 +- .../serializers/templates/fieldset_link.py | 91 -- .../serializers/templates/kickoff.py | 38 +- .../processes/serializers/templates/mixins.py | 94 ++ .../serializers/templates/public/kickoff.py | 9 +- .../processes/serializers/templates/task.py | 61 +- .../serializers/templates/template.py | 135 +-- .../serializers/templates/template_fields.py | 54 +- .../serializers/workflows/fieldset.py | 1 + .../serializers/workflows/kickoff_value.py | 13 +- backend/src/processes/services/exceptions.py | 14 +- backend/src/processes/services/tasks/task.py | 20 +- .../processes/services/tasks/task_version.py | 7 +- .../services/templates/fieldsets/fieldset.py | 224 ++-- .../processes/services/versioning/schemas.py | 37 +- .../services/workflows/fieldsets/fieldset.py | 3 +- backend/src/processes/tests/fixtures.py | 84 +- .../test_tasks/test_task_service.py | 9 +- .../test_tasks/test_task_version_service.py | 13 +- .../test_fieldset_template_service.py | 2 +- .../test_views/test_fieldsets/test_create.py | 232 +--- .../test_views/test_fieldsets/test_destroy.py | 16 +- .../test_views/test_fieldsets/test_list.py | 333 ++--- .../test_fieldsets/test_partial_update.py | 127 +- .../test_fieldsets/test_retrieve.py | 280 +---- .../test_clone/test_fieldsets.py | 634 ++++++---- .../test_templates/test_clone/test_task.py | 13 - .../test_create/test_template.py | 61 +- .../test_views/test_templates/test_export.py | 6 - .../test_views/test_templates/test_list.py | 41 +- .../test_public/test_retrieve.py | 61 +- .../test_templates/test_retrieve.py | 2 - .../test_views/test_templates/test_run.py | 263 +--- .../test_templates/test_titles_by_owners.py | 49 +- .../test_update/test_fieldsets.py | 1066 ++++++++--------- .../test_update/test_template.py | 7 +- backend/src/processes/urls/templates.py | 8 - backend/src/processes/views/fieldset.py | 39 +- backend/src/processes/views/template.py | 117 +- backend/src/urls.py | 9 +- 51 files changed, 1983 insertions(+), 2957 deletions(-) create mode 100644 backend/src/accounts/migrations/0144_auto_20260609_1910.py delete mode 100644 backend/src/processes/migrations/0254_add_fieldsets.py create mode 100644 backend/src/processes/migrations/0254_auto_20260609_1910.py delete mode 100644 backend/src/processes/serializers/templates/fieldset_link.py 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/generics/base/service.py b/backend/src/generics/base/service.py index 8a36181c2..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,13 +17,14 @@ 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 @@ -53,7 +53,7 @@ def _create_actions( def create( self, **kwargs, - ) -> Model: + ): with transaction.atomic(): self._create_instance(**kwargs) self._create_related(**kwargs) @@ -69,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(): diff --git a/backend/src/processes/messages/fieldset.py b/backend/src/processes/messages/fieldset.py index d13c9c79e..b165e01ef 100644 --- a/backend/src/processes/messages/fieldset.py +++ b/backend/src/processes/messages/fieldset.py @@ -23,3 +23,9 @@ 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.', +) diff --git a/backend/src/processes/migrations/0254_add_fieldsets.py b/backend/src/processes/migrations/0254_add_fieldsets.py deleted file mode 100644 index e0dd4ab75..000000000 --- a/backend/src/processes/migrations/0254_add_fieldsets.py +++ /dev/null @@ -1,274 +0,0 @@ -# Generated by Django 2.2 on 2026-04-28 09:42 - -from django.db import migrations, models -import django.db.models.deletion -import src.generics.mixins.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0142_vacation_fields'), - ('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'), - ), - migrations.AlterField( - model_name='predicate', - name='operator', - field=models.CharField( - choices=[('equals', 'Equal'), ('not_equals', 'Not equal'), - ('exists', 'Exists'), ('not_exists', 'Not exists'), - ('contains', 'Contains'), - ('not_contains', 'Not contains'), - ('more_than', 'More than'), - ('less_than', 'Less than'), - ('completed', 'completed'), ('skipped', 'skipped'), - ('completed_or_skipped', 'completed_or_skipped')], - max_length=30), - ), - migrations.AlterField( - model_name='predicatetemplate', - name='operator', - field=models.CharField( - choices=[('equals', 'Equal'), ('not_equals', 'Not equal'), - ('exists', 'Exists'), ('not_exists', 'Not exists'), - ('contains', 'Contains'), - ('not_contains', 'Not contains'), - ('more_than', 'More than'), - ('less_than', 'Less than'), - ('completed', 'completed'), ('skipped', 'skipped'), - ('completed_or_skipped', 'completed_or_skipped')], - max_length=30), - ), - migrations.RunSQL(""" - UPDATE processes_predicate p - SET operator = 'completed_or_skipped' - FROM processes_rule r - WHERE p.rule_id = r.id - AND p.operator = 'completed' - AND p.field_type = 'task' - """), - migrations.RunSQL(""" - UPDATE processes_predicatetemplate pt - SET operator = 'completed_or_skipped' - FROM processes_ruletemplate rt - WHERE pt.rule_id = rt.id - AND pt.operator = 'completed' - AND pt.field_type = 'task' - """), - migrations.RunSQL(""" - UPDATE processes_templatedraft td - SET draft = ( - SELECT jsonb_set( - td.draft, - '{tasks}', - jsonb_agg( - CASE - WHEN jsonb_typeof(task->'conditions') = 'array' THEN - jsonb_set( - task, - '{conditions}', - ( - SELECT jsonb_agg( - CASE - WHEN jsonb_typeof(cond->'rules') = 'array' THEN - jsonb_set( - cond, - '{rules}', - ( - SELECT jsonb_agg( - CASE - WHEN jsonb_typeof(rule->'predicates') = 'array' THEN - jsonb_set( - rule, - '{predicates}', - ( - SELECT jsonb_agg( - CASE - WHEN (pred->>'operator') = 'completed' - AND (pred->>'field_type') = 'task' - THEN jsonb_set(pred, '{operator}', '"completed_or_skipped"') - ELSE pred - END - ) - FROM jsonb_array_elements(rule->'predicates') AS pred - ) - ) - ELSE rule - END - ) - FROM jsonb_array_elements(cond->'rules') AS rule - ) - ) - ELSE cond - END - ) - FROM jsonb_array_elements(task->'conditions') AS cond - ) - ) - ELSE task - END - ) - ) - FROM jsonb_array_elements(td.draft->'tasks') AS task - ) - WHERE td.is_deleted = FALSE - AND td.draft IS NOT NULL - AND jsonb_typeof(td.draft->'tasks') = 'array' - AND EXISTS ( - SELECT 1 - FROM jsonb_array_elements(td.draft->'tasks') AS task, - jsonb_array_elements(task->'conditions') AS cond, - jsonb_array_elements(cond->'rules') AS rule, - jsonb_array_elements(rule->'predicates') AS pred - WHERE (pred->>'operator') = 'completed' - AND (pred->>'field_type') = 'task' - ) - """) - ] diff --git a/backend/src/processes/migrations/0254_auto_20260609_1910.py b/backend/src/processes/migrations/0254_auto_20260609_1910.py new file mode 100644 index 000000000..81dcbf3ca --- /dev/null +++ b/backend/src/processes/migrations/0254_auto_20260609_1910.py @@ -0,0 +1,161 @@ +# Generated by Django 2.2 on 2026-06-09 19:10 + +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import src.generics.mixins.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0144_auto_20260609_1910'), + ('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)), + ('title', models.TextField(blank=True, default='')), + ('order', models.IntegerField(default=0)), + ('description', models.TextField(blank=True, default='')), + ('layout', models.CharField(choices=[('horizontal', 'Horizontal'), ('vertical', 'Vertical')], default='vertical', max_length=200)), + ('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')), + ], + 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)), + ('title', models.TextField(blank=True, default='')), + ('order', models.IntegerField(default=0)), + ('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)), + ('is_shared', models.BooleanField(default=True)), + ('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.Kickoff')), + ('shared_fieldset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_fieldsets', to='processes.FieldsetTemplate')), + ('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.TaskTemplate')), + ('template', models.ForeignKey(blank=True, null=True, 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.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.AlterField( + model_name='rawperformer', + name='type', + field=models.CharField(choices=[('user', 'user'), ('group', 'group'), ('workflow_starter', 'workflow_starter'), ('field', 'field'), ('manager', 'manager')], max_length=100), + ), + migrations.AlterField( + model_name='rawperformertemplate', + name='type', + field=models.CharField(choices=[('user', 'user'), ('group', 'group'), ('workflow_starter', 'workflow_starter'), ('field', 'field'), ('manager', 'manager')], max_length=100), + ), + migrations.AlterField( + model_name='task', + name='parents', + field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=200), default=list, help_text='Api names of task parents', size=None), + ), + migrations.AlterField( + model_name='taskperformer', + name='type', + field=models.CharField(choices=[('user', 'user'), ('group', 'group'), ('workflow_starter', 'workflow_starter'), ('field', 'field'), ('manager', 'manager')], default='user', max_length=100), + ), + migrations.AddField( + model_name='fieldset', + name='task', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Task'), + ), + migrations.AddField( + model_name='fieldset', + name='workflow', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Workflow'), + ), + 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'), + ), + migrations.AddConstraint( + model_name='fieldsettemplate', + constraint=models.UniqueConstraint(condition=models.Q(is_deleted=False), fields=('api_name', 'template'), name='fieldsettemplate_api_name_template_unique'), + ), + 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'), + ), + ] diff --git a/backend/src/processes/models/mixins.py b/backend/src/processes/models/mixins.py index 28e96cb03..632ff711b 100644 --- a/backend/src/processes/models/mixins.py +++ b/backend/src/processes/models/mixins.py @@ -348,6 +348,8 @@ 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, diff --git a/backend/src/processes/models/templates/fields.py b/backend/src/processes/models/templates/fields.py index 9f98abae7..3c36b8f03 100644 --- a/backend/src/processes/models/templates/fields.py +++ b/backend/src/processes/models/templates/fields.py @@ -99,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 index 85035556a..3e6e41068 100644 --- a/backend/src/processes/models/templates/fieldset.py +++ b/backend/src/processes/models/templates/fieldset.py @@ -3,7 +3,6 @@ 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, @@ -15,7 +14,6 @@ from src.processes.querysets import ( FieldsetTemplateQuerySet, FieldsetTemplateRuleQuerySet, - FieldsetTemplateTaskTemplateQuerySet, FieldsetTemplateKickoffQuerySet, ) @@ -40,21 +38,33 @@ class Meta: 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, ) - tasks = models.ManyToManyField( + task = models.ForeignKey( TaskTemplate, - through='FieldsetTemplateTaskTemplate', + on_delete=models.CASCADE, related_name='fieldsets', + null=True, blank=True, ) - kickoffs = models.ManyToManyField( + kickoff = models.ForeignKey( Kickoff, - through='FieldsetTemplateKickoff', + on_delete=models.CASCADE, related_name='fieldsets', + null=True, blank=True, ) @@ -66,72 +76,6 @@ def __str__(self): return self.name -class FieldsetTemplateTaskTemplate(SoftDeleteModel): - - """ - Model for the relationship between - "TaskTemplate" <- m2m -> "FieldsetTemplate" - """ - - 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) - - objects = BaseSoftDeleteManager.from_queryset( - FieldsetTemplateTaskTemplateQuerySet, - )() - - def __str__(self): - return ( - f'Fieldset: {self.fieldset_id} - ' - f'Task: {self.task_template_id} - ' - f'Order: {self.order}' - ) - - -class FieldsetTemplateKickoff(SoftDeleteModel): - - """ - Model for the relationship - "Kickoff" <- m2m -> "FieldsetTemplate" - """ - - 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) - - objects = BaseSoftDeleteManager.from_queryset( - FieldsetTemplateKickoffQuerySet, - )() - - def __str__(self): - return ( - f'Fieldset: {self.fieldset_id} - ' - f'Kickoff: {self.kickoff_id} - ' - f'Order: {self.order}' - ) - - class FieldsetTemplateRule( BaseApiNameModel, BaseFieldSetRuleMixin, diff --git a/backend/src/processes/models/templates/template.py b/backend/src/processes/models/templates/template.py index 57584467b..12bba56d1 100644 --- a/backend/src/processes/models/templates/template.py +++ b/backend/src/processes/models/templates/template.py @@ -152,7 +152,7 @@ def get_kickoff_output_fields( qst = FieldTemplate.objects.filter( Q( Q(kickoff_id=kickoff.id) | - Q(fieldset__kickoffs__id=kickoff.id), + Q(fieldset__kickoff__id=kickoff.id), ), ) if fields_filter_kwargs: @@ -181,18 +181,16 @@ def get_tasks_output_fields( fields_filter_kwargs = fields_filter_kwargs or {} tasks_filter = {'task__template_id': self.id, **tasks_filter_kwargs} - fieldset_filter = {'fieldset__tasks__template_id': self.id} + fieldset_filter = {'fieldset__task__template_id': self.id} for key, value in tasks_filter_kwargs.items(): - _key = key.replace('task', 'tasks') - fieldset_filter[f'fieldset__{_key}'] = value + 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(): - _key = key.replace('task', 'tasks') - fieldset_exclude_kwargs[f'fieldset__{_key}'] = value + 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)) diff --git a/backend/src/processes/models/workflows/fieldset.py b/backend/src/processes/models/workflows/fieldset.py index eeaaf2687..86e08842e 100644 --- a/backend/src/processes/models/workflows/fieldset.py +++ b/backend/src/processes/models/workflows/fieldset.py @@ -41,7 +41,6 @@ class Meta: blank=True, related_name='fieldsets', ) - order = models.IntegerField(default=0) objects = BaseSoftDeleteManager.from_queryset(FieldSetQuerySet)() diff --git a/backend/src/processes/querysets.py b/backend/src/processes/querysets.py index f895daf49..e05c08033 100644 --- a/backend/src/processes/querysets.py +++ b/backend/src/processes/querysets.py @@ -1291,16 +1291,6 @@ class FieldsetTemplateQuerySet(AccountBaseQuerySet): pass -class FieldsetTemplateTaskTemplateQuerySet(BaseQuerySet): - - pass - - -class FieldsetTemplateKickoffQuerySet(BaseQuerySet): - - pass - - class FieldsetTemplateRuleQuerySet(AccountBaseQuerySet): pass diff --git a/backend/src/processes/serializers/templates/fieldset.py b/backend/src/processes/serializers/templates/fieldset.py index bfa99da42..3f7cd3992 100644 --- a/backend/src/processes/serializers/templates/fieldset.py +++ b/backend/src/processes/serializers/templates/fieldset.py @@ -1,17 +1,12 @@ -# ruff: noqa: PLC0415 -from rest_framework.fields import CharField, SerializerMethodField -from rest_framework.serializers import ( - IntegerField, - ModelSerializer, -) - +from rest_framework.fields import CharField +from rest_framework.serializers import ModelSerializer from src.generics.fields import ( - RelatedApiNameListField, + RelatedApiNameListField, AccountPrimaryKeyRelatedField, ) from src.generics.mixins.serializers import CustomValidationErrorMixin from src.processes.models.templates.fieldset import ( FieldsetTemplate, - FieldsetTemplateRule, FieldsetTemplateKickoff, + FieldsetTemplateRule, ) from src.processes.serializers.templates.field import ( FieldTemplateSerializer, @@ -41,27 +36,37 @@ class Meta: class FieldsetTemplateSerializer( - CustomValidationErrorMixin, ModelSerializer, + CustomValidationErrorMixin, ): class Meta: model = FieldsetTemplate fields = ( - 'id', - 'name', + 'title', + 'order', 'description', + 'api_name', + 'shared_fieldset_id', + 'name', 'label_position', 'layout', 'rules', 'fields', - 'api_name', - 'tasks', - 'kickoff', - 'template_id', ) - id = IntegerField(required=False) + 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, @@ -73,24 +78,36 @@ class Meta: required=False, default=list, ) - tasks = SerializerMethodField() - kickoff = SerializerMethodField() - def get_kickoff(self, instance: FieldsetTemplate): - through = FieldsetTemplateKickoff.objects.filter( - fieldset=instance, - ).first() - if through: - return through.kickoff_id - return None - def get_tasks(self, instance): - # Resolve cyclic imports with TemplateTaskOnlyFieldsSerializer - from src.processes.serializers.templates.task import ( - TemplateStepNameSerializer, +class SharedFieldsetTemplateSerializer( + CustomValidationErrorMixin, + ModelSerializer, +): + + class Meta: + model = FieldsetTemplate + fields = ( + 'id', + 'title', + 'order', + 'description', + 'api_name', + 'name', + 'label_position', + 'layout', + 'rules', + 'fields', ) - return TemplateStepNameSerializer( - instance=instance.tasks.all(), - many=True, - default=list, - ).data + + 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/fieldset_link.py b/backend/src/processes/serializers/templates/fieldset_link.py deleted file mode 100644 index 2027953a3..000000000 --- a/backend/src/processes/serializers/templates/fieldset_link.py +++ /dev/null @@ -1,91 +0,0 @@ -from rest_framework.fields import CharField -from rest_framework.serializers import ( - ModelSerializer, -) -from src.processes.serializers.templates.field import FieldTemplateSerializer -from src.generics.mixins.serializers import CustomValidationErrorMixin -from src.processes.models.templates.fieldset import ( - FieldsetTemplateTaskTemplate, - FieldsetTemplateKickoff, -) - - -class FieldsetTemplateTaskTemplateSerializer( - CustomValidationErrorMixin, - ModelSerializer, -): - class Meta: - model = FieldsetTemplateTaskTemplate - fields = ( - 'order', - 'api_name', - ) - - api_name = CharField(source='fieldset.api_name') - - -class FieldsetTemplateKickoffSerializer( - CustomValidationErrorMixin, - ModelSerializer, -): - class Meta: - model = FieldsetTemplateKickoff - fields = ( - 'order', - 'api_name', - ) - - api_name = CharField(source='fieldset.api_name') - - -class FieldsetTemplateKickoffListSerializer(ModelSerializer): - class Meta: - model = FieldsetTemplateKickoff - fields = ( - 'order', - 'name', - 'description', - 'fields', - 'api_name', - 'label_position', - 'layout', - ) - - name = CharField(source='fieldset.name') - description = CharField(source='fieldset.description') - api_name = CharField(source='fieldset.api_name') - label_position = CharField( - source='fieldset.label_position', - ) - layout = CharField(source='fieldset.layout') - fields = FieldTemplateSerializer( - source='fieldset.fields', - many=True, - ) - - -class FieldsetTemplateTaskListSerializer(ModelSerializer): - - class Meta: - model = FieldsetTemplateTaskTemplate - fields = ( - 'order', - 'name', - 'description', - 'fields', - 'api_name', - 'label_position', - 'layout', - ) - - name = CharField(source='fieldset.name') - description = CharField(source='fieldset.description') - api_name = CharField(source='fieldset.api_name') - label_position = CharField( - source='fieldset.label_position', - ) - layout = CharField(source='fieldset.layout') - fields = FieldTemplateSerializer( - source='fieldset.fields', - many=True, - ) diff --git a/backend/src/processes/serializers/templates/kickoff.py b/backend/src/processes/serializers/templates/kickoff.py index 27f458fff..f5befc87d 100644 --- a/backend/src/processes/serializers/templates/kickoff.py +++ b/backend/src/processes/serializers/templates/kickoff.py @@ -12,13 +12,13 @@ FieldTemplateListSerializer, FieldTemplateSerializer, ) -from src.processes.serializers.templates.fieldset_link import ( - FieldsetTemplateKickoffSerializer, - FieldsetTemplateKickoffListSerializer, +from src.processes.serializers.templates.fieldset import ( + FieldsetTemplateSerializer, ) from src.processes.serializers.templates.mixins import ( CreateOrUpdateInstanceMixin, CreateOrUpdateRelatedMixin, + FieldsetMixin, ) @@ -27,6 +27,7 @@ class KickoffSerializer( CreateOrUpdateRelatedMixin, CustomValidationErrorMixin, AdditionalValidationMixin, + FieldsetMixin, ModelSerializer, ): @@ -42,8 +43,7 @@ class Meta: } fields = FieldTemplateSerializer(many=True, required=False, default=list) - fieldsets = FieldsetTemplateKickoffSerializer( - source='fieldsettemplatekickoff_set', + fieldsets = FieldsetTemplateSerializer( many=True, required=False, allow_empty=True, @@ -60,19 +60,24 @@ def to_representation(self, instance): def create(self, validated_data: Dict[str, Any]): self.additional_validate(validated_data) - validated_data.pop('fieldsettemplatekickoff_set', None) + 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, + ) 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={ @@ -88,21 +93,25 @@ def update( validated_data: Dict[str, Any], ): self.additional_validate(validated_data) - validated_data.pop('fieldsettemplatekickoff_set', None) + 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, + ) 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={ @@ -123,10 +132,7 @@ class Meta: ) fields = FieldTemplateListSerializer(many=True) - fieldsets = FieldsetTemplateKickoffListSerializer( - source='fieldsettemplatekickoff_set', - many=True, - ) + fieldsets = FieldsetTemplateSerializer(many=True) def to_representation(self, instance): # TODO Delete when the Template <-> Kickoff relation becomes o2o diff --git a/backend/src/processes/serializers/templates/mixins.py b/backend/src/processes/serializers/templates/mixins.py index 95e81d6a4..a0e4d3b62 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.templates.fieldsets.fieldset import ( + FieldSetTemplateService, +) from src.utils.validation import raise_validation_error UserModel = get_user_model() @@ -240,3 +248,89 @@ def additional_validate_api_name( new_api_name=value, ), ) + + +class FieldsetMixin: + + @staticmethod + def create_or_update_fieldsets( + fieldsets_data: List[Dict], + template: Template, + 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.pop('api_name', None) + 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) + 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(account=template.account) + 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 9f8890281..9a529bf7e 100644 --- a/backend/src/processes/serializers/templates/public/kickoff.py +++ b/backend/src/processes/serializers/templates/public/kickoff.py @@ -8,8 +8,8 @@ from src.processes.serializers.templates.field import ( PublicFieldTemplateSerializer, ) -from src.processes.serializers.templates.fieldset_link import \ - FieldsetTemplateKickoffListSerializer +from src.processes.serializers.templates.fieldset import \ + FieldsetTemplateSerializer class PublicKickoffSerializer(ModelSerializer): @@ -24,10 +24,7 @@ class Meta: description = CharField(allow_blank=True, default='') fields = PublicFieldTemplateSerializer(many=True, required=False) - fieldsets = FieldsetTemplateKickoffListSerializer( - source='fieldsettemplatekickoff_set', - many=True, - ) + fieldsets = FieldsetTemplateSerializer(many=True, required=False) def to_representation(self, data: Dict[str, Any]): data = super().to_representation(data) diff --git a/backend/src/processes/serializers/templates/task.py b/backend/src/processes/serializers/templates/task.py index 0100b498f..fe598f85b 100644 --- a/backend/src/processes/serializers/templates/task.py +++ b/backend/src/processes/serializers/templates/task.py @@ -33,13 +33,14 @@ from src.processes.serializers.templates.field import ( FieldTemplateSerializer, ) -from src.processes.serializers.templates.fieldset_link import ( - FieldsetTemplateTaskTemplateSerializer, +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, @@ -47,9 +48,6 @@ from src.processes.serializers.templates.raw_performer import ( RawPerformerSerializer, ) -from src.processes.services.templates.fieldsets.fieldset import ( - FieldSetTemplateService, -) from src.processes.utils.common import ( VAR_PATTERN, create_api_name, @@ -64,6 +62,7 @@ class TaskTemplateSerializer( CustomValidationErrorMixin, AdditionalValidationMixin, CustomValidationApiNameMixin, + FieldsetMixin, ModelSerializer, ): @@ -107,8 +106,7 @@ class Meta: number = IntegerField() api_name = CharField(max_length=200, required=False) fields = FieldTemplateSerializer(many=True, required=False) - fieldsets = FieldsetTemplateTaskTemplateSerializer( - source='fieldsettemplatetasktemplate_set', + fieldsets = FieldsetTemplateSerializer( many=True, required=False, allow_empty=True, @@ -418,7 +416,6 @@ def create(self, validated_data: Dict[str, Any]): api_name = validated_data['api_name'] parents = self.context['parents_by_tasks'][api_name] ancestors = list(self.context['ancestors_by_tasks'][api_name]) - validated_data.pop('fieldsettemplatetasktemplate_set', None) instance = self.create_or_update_instance( validated_data={ 'template': self.context['template'], @@ -428,25 +425,12 @@ def create(self, validated_data: Dict[str, Any]): **validated_data, }, ) - fieldsets_links = self.context.get( - 'tasks_fieldsets', {}, - ).get(api_name) - if fieldsets_links is not None: - FieldSetTemplateService.create_or_update_tasks_links( - task=instance, - template=self.context['template'], - fieldsets_links=fieldsets_links, - ) 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, + ) self.create_or_update_related( data=validated_data.get('fields'), ancestors_data={ @@ -508,8 +492,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'], @@ -520,6 +505,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( @@ -548,15 +541,11 @@ def update( **validated_data, }, ) - fieldsets_links = self.context.get( - 'tasks_fieldsets', {}, - ).get(api_name) - if fieldsets_links is not None: - FieldSetTemplateService.create_or_update_tasks_links( - task=instance, - template=self.context['template'], - fieldsets_links=fieldsets_links, - ) + self.create_or_update_fieldsets( + fieldsets_data=validated_data.pop('fieldsets', []), + template=template, + task=instance, + ) if raw_due_date_created: AnalyticService.templates_task_due_date_created( user=self.context['user'], diff --git a/backend/src/processes/serializers/templates/template.py b/backend/src/processes/serializers/templates/template.py index c47ade380..1ec4bb3b4 100644 --- a/backend/src/processes/serializers/templates/template.py +++ b/backend/src/processes/serializers/templates/template.py @@ -37,10 +37,10 @@ SystemVariable, TemplateOrdering, TemplateType, - WorkflowApiStatus, TaskStatus, + WorkflowApiStatus, + TaskStatus, ) from src.processes.messages import template as messages -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.template import ( @@ -53,7 +53,7 @@ ) from src.processes.serializers.templates.mixins import ( CreateOrUpdateInstanceMixin, - CreateOrUpdateRelatedMixin, + CreateOrUpdateRelatedMixin, FieldsetMixin, ) from src.processes.serializers.templates.owner import ( TemplateOwnerSerializer, @@ -63,9 +63,6 @@ TaskTemplatePrivilegesSerializer, TaskTemplateSerializer, ) -from src.processes.services.templates.fieldsets.fieldset import ( - FieldSetTemplateService, -) from src.processes.services.templates.integrations import ( TemplateIntegrationsService, ) @@ -92,6 +89,7 @@ class TemplateSerializer( AdditionalValidationMixin, CreateOrUpdateInstanceMixin, CreateOrUpdateRelatedMixin, + FieldsetMixin, ModelSerializer, ): """ @@ -159,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: @@ -177,40 +192,11 @@ def _get_raw_fields_from_kickoff(self, data: Dict[str, Any]) -> List[dict]: return result fields_data = kickoff_data.get('fields') or [] - 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 - fieldset_link_data = ( - kickoff_data.get('fieldsettemplatekickoff_set') or [] - ) - fieldsets_api_names = [] - for elem in fieldset_link_data: - try: - fieldsets_api_names.append(elem['fieldset']['api_name']) - except (TypeError, ValueError): - continue - if fieldsets_api_names: - account = self.context.get('account') - fieldset_fields = FieldTemplate.objects.filter( - fieldset__api_name__in=fieldsets_api_names, - account_id=account.id, - ) - for field_template in fieldset_fields: - result.append({ - 'name': field_template.name, - 'api_name': field_template.api_name, - 'is_required': field_template.is_required, - }) + 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]: @@ -475,7 +461,9 @@ def _get_normalized_kickoff_draft( ) -> dict: if isinstance(data, dict): data['fields'] = data.get('fields', []) - data['fieldsets'] = data.get('fieldsets', []) + data['fieldsets'] = self.get_draft_fieldsets( + data.get('fieldsets'), + ) else: data = { 'fields': [], @@ -507,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, @@ -633,9 +625,6 @@ def create(self, validated_data: Dict[str, Any]) -> Template: 'template': instance, }, ) - fieldsets_links_raw = validated_data['kickoff'].pop( - 'fieldsettemplatekickoff_set', None, - ) self.create_or_update_related_one( slz_cls=KickoffSerializer, data=validated_data['kickoff'], @@ -648,35 +637,10 @@ def create(self, validated_data: Dict[str, Any]) -> Template: 'template': instance, }, ) - if fieldsets_links_raw is not None: - fieldsets_links = [ - { - 'api_name': link['fieldset']['api_name'], - 'order': link['order'], - } - for link in fieldsets_links_raw - ] - FieldSetTemplateService.create_or_update_kickoff_links( - kickoff=instance.kickoff_instance, - template=instance, - fieldsets_links=fieldsets_links, - ) 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) - tasks_fieldsets = {} - for task_data in validated_data['tasks']: - fieldsets_raw = task_data.pop( - 'fieldsettemplatetasktemplate_set', None, - ) - if fieldsets_raw is not None: - tasks_fieldsets[task_data['api_name']] = [ - { - 'api_name': link['fieldset']['api_name'], - 'order': link['order'], - } - for link in fieldsets_raw - ] + self.create_or_update_related( data=validated_data['tasks'], ancestors_data={ @@ -690,7 +654,6 @@ def create(self, validated_data: Dict[str, Any]) -> Template: 'tasks_api_names': tasks_api_names, 'parents_by_tasks': parents_by_tasks, 'ancestors_by_tasks': ancestors_by_tasks, - 'tasks_fieldsets': tasks_fieldsets, 'all_tasks_data': validated_data['tasks'], }, ) @@ -735,9 +698,6 @@ def update( 'template': instance, }, ) - fieldsets_links_raw = validated_data['kickoff'].pop( - 'fieldsettemplatekickoff_set', None, - ) self.create_or_update_related_one( slz_cls=KickoffSerializer, data=validated_data['kickoff'], @@ -750,35 +710,9 @@ def update( 'template': instance, }, ) - if fieldsets_links_raw is not None: - fieldsets_links = [ - { - 'api_name': link['fieldset']['api_name'], - 'order': link['order'], - } - for link in fieldsets_links_raw - ] - FieldSetTemplateService.create_or_update_kickoff_links( - kickoff=instance.kickoff_instance, - template=instance, - fieldsets_links=fieldsets_links, - ) 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) - tasks_fieldsets = {} - for task_data in validated_data['tasks']: - fieldsets_raw = task_data.pop( - 'fieldsettemplatetasktemplate_set', None, - ) - if fieldsets_raw is not None: - tasks_fieldsets[task_data['api_name']] = [ - { - 'api_name': link['fieldset']['api_name'], - 'order': link['order'], - } - for link in fieldsets_raw - ] self.create_or_update_related( data=validated_data['tasks'], ancestors_data={ @@ -792,7 +726,6 @@ def update( 'tasks_api_names': tasks_api_names, 'parents_by_tasks': parents_by_tasks, 'ancestors_by_tasks': ancestors_by_tasks, - 'tasks_fieldsets': tasks_fieldsets, 'all_tasks_data': validated_data['tasks'], }, ) diff --git a/backend/src/processes/serializers/templates/template_fields.py b/backend/src/processes/serializers/templates/template_fields.py index 009e5444c..1d6d36de1 100644 --- a/backend/src/processes/serializers/templates/template_fields.py +++ b/backend/src/processes/serializers/templates/template_fields.py @@ -1,13 +1,11 @@ from typing import Any, Dict -from rest_framework.fields import CharField from rest_framework.serializers import ( ModelSerializer, ) from src.processes.models.templates.fields import FieldTemplate from src.processes.models.templates.fieldset import ( - FieldsetTemplateTaskTemplate, - FieldsetTemplateKickoff, + FieldsetTemplate, ) from src.processes.models.templates.kickoff import Kickoff from src.processes.models.templates.template import Template @@ -31,13 +29,14 @@ class Meta: ) -class FieldsetTemplateKickoffListSerializer(ModelSerializer): +class FieldsetTemplateOnlyFieldsSerializer(ModelSerializer): class Meta: - model = FieldsetTemplateKickoff + model = FieldsetTemplate fields = ( 'order', 'name', + 'title', 'description', 'fields', 'api_name', @@ -45,44 +44,7 @@ class Meta: 'layout', ) - name = CharField(source='fieldset.name') - description = CharField(source='fieldset.description') - api_name = CharField(source='fieldset.api_name') - label_position = CharField( - source='fieldset.label_position', - ) - layout = CharField(source='fieldset.layout') - fields = FieldTemplateOnlyFieldsSerializer( - source='fieldset.fields', - many=True, - ) - - -class FieldsetTaskTemplateOnlyFieldsSerializer(ModelSerializer): - - class Meta: - model = FieldsetTemplateTaskTemplate - fields = ( - 'order', - 'name', - 'description', - 'fields', - 'api_name', - 'label_position', - 'layout', - ) - - name = CharField(source='fieldset.name') - description = CharField(source='fieldset.description') - api_name = CharField(source='fieldset.api_name') - label_position = CharField( - source='fieldset.label_position', - ) - layout = CharField(source='fieldset.layout') - fields = FieldTemplateOnlyFieldsSerializer( - source='fieldset.fields', - many=True, - ) + fields = FieldTemplateOnlyFieldsSerializer(many=True) class KickoffOnlyFieldsSerializer(ModelSerializer): @@ -99,8 +61,7 @@ class Meta: default=[], read_only=True, ) - fieldsets = FieldsetTemplateKickoffListSerializer( - source='fieldsettemplatekickoff_set', + fieldsets = FieldsetTemplateOnlyFieldsSerializer( many=True, default=[], read_only=True, @@ -131,8 +92,7 @@ class Meta: default=[], read_only=True, ) - fieldsets = FieldsetTaskTemplateOnlyFieldsSerializer( - source='fieldsettemplatetasktemplate_set', + fieldsets = FieldsetTemplateOnlyFieldsSerializer( many=True, default=[], read_only=True, diff --git a/backend/src/processes/serializers/workflows/fieldset.py b/backend/src/processes/serializers/workflows/fieldset.py index d69c7fca1..e8a4de857 100644 --- a/backend/src/processes/serializers/workflows/fieldset.py +++ b/backend/src/processes/serializers/workflows/fieldset.py @@ -12,6 +12,7 @@ class Meta: 'id', 'api_name', 'name', + 'title', 'description', 'order', 'label_position', diff --git a/backend/src/processes/serializers/workflows/kickoff_value.py b/backend/src/processes/serializers/workflows/kickoff_value.py index d796f551e..6eea83236 100644 --- a/backend/src/processes/serializers/workflows/kickoff_value.py +++ b/backend/src/processes/serializers/workflows/kickoff_value.py @@ -3,7 +3,7 @@ from rest_framework import serializers from src.generics.serializers import CustomValidationErrorMixin -from src.processes.models.templates.fieldset import FieldsetTemplateKickoff +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 @@ -83,23 +83,20 @@ def create(self, validated_data: Dict[str, Any]): clear_description=clear_description, ) workflow = validated_data['workflow'] - fieldset_through_records = ( - FieldsetTemplateKickoff.objects + fieldset_templates = ( + FieldsetTemplate.objects .filter(kickoff=kickoff) - .select_related('fieldset') - .prefetch_related('fieldset__rules', 'fieldset__fields') + .prefetch_related('rules', 'fields') .order_by('order') ) try: - for fieldset_through in fieldset_through_records: - fieldset_template = fieldset_through.fieldset + 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, - order=fieldset_through.order, fields_data=fields_data, ) try: diff --git a/backend/src/processes/services/exceptions.py b/backend/src/processes/services/exceptions.py index e0cad4b7d..b00ccedc9 100644 --- a/backend/src/processes/services/exceptions.py +++ b/backend/src/processes/services/exceptions.py @@ -223,13 +223,16 @@ class FieldsetTemplateServiceException(BaseServiceException): pass -class FieldsetTemplateInUseException( - FieldsetTemplateServiceException, -): +class FieldsetTemplateInUseException(FieldsetTemplateServiceException): default_message = fs_messages.MSG_FS_0001 +class FieldsetTemplateInUseException2(FieldsetTemplateServiceException): + + default_message = fs_messages.MSG_FS_0009 + + class FieldTemplateServiceException(BaseServiceException): pass @@ -248,3 +251,8 @@ class FieldTemplateUserMustBeRequired(FieldTemplateServiceException): class FieldsetServiceException(BaseServiceException): pass + + +class SharedFieldsetNotFoundException(FieldsetServiceException): + + default_message = fs_messages.MSG_FS_0008 diff --git a/backend/src/processes/services/tasks/task.py b/backend/src/processes/services/tasks/task.py index 00f88c9a8..a68c8260f 100644 --- a/backend/src/processes/services/tasks/task.py +++ b/backend/src/processes/services/tasks/task.py @@ -13,8 +13,7 @@ from src.processes.models.templates.checklist import ( ChecklistTemplateSelection, ) -from src.processes.models.templates.fieldset import \ - FieldsetTemplateTaskTemplate +from src.processes.models.templates.fieldset import FieldsetTemplate from src.processes.models.templates.task import TaskTemplate from src.processes.models.workflows.conditions import ( Condition, @@ -207,9 +206,9 @@ def create_conditions_from_template( def create_fields_from_template(self, instance_template: TaskTemplate): active_fieldset_ids = ( - FieldsetTemplateTaskTemplate.objects + FieldsetTemplate.objects .filter(task=instance_template) - .values_list('fieldset_id', flat=True) + .values_list('id', flat=True) ) for field_template in instance_template.fields.exclude( fieldset__in=active_fieldset_ids, @@ -226,21 +225,20 @@ def create_fieldsets_from_template( self, instance_template: TaskTemplate, ): - fieldset_through_records = ( - FieldsetTemplateTaskTemplate.objects + fieldsets = ( + FieldsetTemplate.objects .filter(task=instance_template) - .select_related('fieldset') - .prefetch_related('fieldset__rules', 'fieldset__fields') + .prefetch_related('rules', 'fields') .order_by('order') ) - for fieldset_through in fieldset_through_records: + for fieldset in fieldsets: service = FieldSetService(user=self.user) service.create( - instance_template=fieldset_through.fieldset, + instance_template=fieldset, account_id=self.instance.workflow.account_id, workflow=self.instance.workflow, task=self.instance, - order=fieldset_through.order, + order=fieldset.order, skip_value=True, ) diff --git a/backend/src/processes/services/tasks/task_version.py b/backend/src/processes/services/tasks/task_version.py index c13e662f7..038f1fe32 100644 --- a/backend/src/processes/services/tasks/task_version.py +++ b/backend/src/processes/services/tasks/task_version.py @@ -248,11 +248,6 @@ def _update_fieldsets(self, data: Optional[List]) -> None: fieldset_api_names = set() for fieldset_data in data or []: - task_link = next( - link for link in fieldset_data['task_links'] - if link['task_api_name'] == self.instance.api_name - ) - order = task_link['order'] fieldset, _ = FieldSet.objects.update_or_create( workflow=self.instance.workflow, task=self.instance, @@ -261,7 +256,7 @@ def _update_fieldsets(self, data: Optional[List]) -> None: 'account_id': self.instance.account_id, 'name': fieldset_data['name'], 'description': fieldset_data['description'], - 'order': order, + 'order': fieldset_data['order'], 'label_position': fieldset_data['label_position'], 'layout': fieldset_data['layout'], }, diff --git a/backend/src/processes/services/templates/fieldsets/fieldset.py b/backend/src/processes/services/templates/fieldsets/fieldset.py index 6aa0b6700..d61300ab7 100644 --- a/backend/src/processes/services/templates/fieldsets/fieldset.py +++ b/backend/src/processes/services/templates/fieldsets/fieldset.py @@ -1,22 +1,27 @@ +# 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.fieldset import FieldsetTemplate, \ - FieldsetTemplateKickoff, FieldsetTemplateTaskTemplate -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.models.templates.fields import FieldTemplate +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateRule, +) from src.processes.services.exceptions import ( FieldsetTemplateInUseException, + FieldsetTemplateInUseException2, ) from src.processes.services.templates.field_template import ( FieldTemplateService, ) from src.processes.services.templates.fieldsets.fieldset_rule import \ FieldsetTemplateRuleService +from src.processes.utils.common import create_api_name + UserModel = get_user_model() @@ -27,7 +32,13 @@ def _create_instance( self, name: str, template_id: int, + is_shared: bool, + 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, @@ -36,16 +47,47 @@ def _create_instance( 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_related( self, rules: Optional[List[Dict]] = None, @@ -66,6 +108,7 @@ def _create_fields( user=self.user, is_superuser=self.is_superuser, auth_type=self.auth_type, + account=self.account, ) service.create( fieldset_id=self.instance.id, @@ -125,6 +168,9 @@ def partial_update( **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(): @@ -141,21 +187,102 @@ def partial_update( 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: - kickoffs_exists = ( - FieldsetTemplateKickoff.objects - .filter(fieldset=self.instance) - .exists() - ) - tasks_exists = ( - FieldsetTemplateTaskTemplate.objects - .filter(fieldset=self.instance) - .exists() - ) - if kickoffs_exists or tasks_exists: + if self.instance.is_shared and self.instance.child_fieldsets.exists(): raise FieldsetTemplateInUseException self.instance.delete() + def _replace_api_names(self, 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_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=True, + shared_fieldset_id=shared_fieldset_id, + order=order, + kickoff_id=kickoff_id, + task_id=task_id, + template_id=template_id, + ) + def create_rules( self, rules_data: List[Dict], @@ -164,6 +291,7 @@ def create_rules( user=self.user, is_superuser=self.is_superuser, auth_type=self.auth_type, + account=self.account, ) for rule_data in rules_data: service.create( @@ -186,6 +314,7 @@ def update_rules( user=self.user, is_superuser=self.is_superuser, auth_type=self.auth_type, + account=self.account, instance=existing_rules[rule_id], ) service.partial_update(**rule_data) @@ -195,6 +324,7 @@ def update_rules( user=self.user, is_superuser=self.is_superuser, auth_type=self.auth_type, + account=self.account, ) rule = service.create( fieldset_id=self.instance.id, @@ -204,56 +334,16 @@ def update_rules( self.instance.rules.exclude(id__in=rules_ids).delete() - @classmethod - def create_or_update_kickoff_links( - cls, - fieldsets_links: List[dict], - template: Template, - kickoff: Optional[Kickoff] = None, - ): - api_names = {e['api_name'] for e in fieldsets_links} - for fieldset_link in fieldsets_links: - fieldset_template = FieldsetTemplate.objects.get( - template=template, - api_name=fieldset_link['api_name'], + @staticmethod + def to_json(fieldset: FieldsetTemplate) -> dict: + if fieldset.is_shared: + from src.processes.serializers.templates.fieldset import ( + SharedFieldsetTemplateSerializer, ) - FieldsetTemplateKickoff.objects.update_or_create( - fieldset=fieldset_template, - kickoff=kickoff, - defaults={ - 'order': fieldset_link['order'], - }, + slz_cls = SharedFieldsetTemplateSerializer + else: + from src.processes.serializers.templates.fieldset import ( + FieldsetTemplateSerializer, ) - ( - FieldsetTemplateKickoff.objects - .filter(kickoff=kickoff) - .exclude(fieldset__api_name__in=api_names) - .delete() - ) - - @classmethod - def create_or_update_tasks_links( - cls, - fieldsets_links: List[dict], - template: Template, - task: Optional[TaskTemplate] = None, - ): - api_names = {e['api_name'] for e in fieldsets_links} - for fieldset_link in fieldsets_links: - fieldset_template = FieldsetTemplate.objects.get( - template=template, - api_name=fieldset_link['api_name'], - ) - FieldsetTemplateTaskTemplate.objects.update_or_create( - fieldset=fieldset_template, - task=task, - defaults={ - 'order': fieldset_link['order'], - }, - ) - ( - FieldsetTemplateTaskTemplate.objects - .filter(task=task) - .exclude(fieldset__api_name__in=api_names) - .delete() - ) + slz_cls = FieldsetTemplateSerializer + return dict(slz_cls(fieldset).data) diff --git a/backend/src/processes/services/versioning/schemas.py b/backend/src/processes/services/versioning/schemas.py index a6fbb261c..f78e18eb2 100644 --- a/backend/src/processes/services/versioning/schemas.py +++ b/backend/src/processes/services/versioning/schemas.py @@ -11,9 +11,7 @@ ) from src.processes.models.templates.fieldset import ( FieldsetTemplate, - FieldsetTemplateKickoff, FieldsetTemplateRule, - FieldsetTemplateTaskTemplate, ) from src.processes.models.templates.fields import ( FieldTemplate, @@ -81,39 +79,20 @@ class Meta: ) -class FieldsetTemplateTaskTemplateSchemaV1(serializers.ModelSerializer): - - class Meta: - model = FieldsetTemplateTaskTemplate - fields = ( - 'task_api_name', - 'order', - ) - - task_api_name = serializers.CharField(source='task.api_name') - - -class FieldsetTemplateKickoffSchemaV1(serializers.ModelSerializer): - - class Meta: - model = FieldsetTemplateKickoff - fields = ('order',) - - class FieldSetSchemaV1(serializers.ModelSerializer): class Meta: model = FieldsetTemplate fields = ( - 'api_name', 'name', + 'title', 'description', + 'order', + 'api_name', 'label_position', 'layout', 'fields', 'rules', - 'task_links', - 'kickoff_links', ) fields = FieldSchemaV1(many=True, allow_null=True, allow_empty=True) @@ -122,16 +101,6 @@ class Meta: allow_null=True, allow_empty=True, ) - task_links = FieldsetTemplateTaskTemplateSchemaV1( - source='fieldsettemplatetasktemplate_set', - many=True, - required=False, - ) - kickoff_links = FieldsetTemplateKickoffSchemaV1( - source='fieldsettemplatekickoff_set', - many=True, - required=False, - ) class KickoffSchemaV1(serializers.ModelSerializer): diff --git a/backend/src/processes/services/workflows/fieldsets/fieldset.py b/backend/src/processes/services/workflows/fieldsets/fieldset.py index 3c2d6b0db..e2ae407e0 100644 --- a/backend/src/processes/services/workflows/fieldsets/fieldset.py +++ b/backend/src/processes/services/workflows/fieldsets/fieldset.py @@ -35,8 +35,9 @@ def _create_instance( task=task, api_name=instance_template.api_name, name=instance_template.name, + title=instance_template.title, description=instance_template.description, - order=kwargs['order'], + order=instance_template.order, label_position=instance_template.label_position, layout=instance_template.layout, ) diff --git a/backend/src/processes/tests/fixtures.py b/backend/src/processes/tests/fixtures.py index 2c03d558b..9fbaa85b4 100644 --- a/backend/src/processes/tests/fixtures.py +++ b/backend/src/processes/tests/fixtures.py @@ -50,7 +50,6 @@ from src.processes.models.templates.fieldset import ( FieldsetTemplate, FieldsetTemplateRule, - FieldsetTemplateTaskTemplate, FieldsetTemplateKickoff, ) from src.processes.models.templates.fields import FieldTemplate from src.processes.models.templates.kickoff import Kickoff @@ -830,12 +829,10 @@ def create_test_dataset( return dataset -def create_test_fieldset_template( +def create_test_shared_fieldset( account: Account, - template: Optional[Template] = None, - kickoff: Optional[Kickoff] = None, - task: Optional[TaskTemplate] = None, name: str = 'Test Fieldset', + title: str = '', description: str = '', order: int = 0, label_position: LabelPosition.LITERALS = LabelPosition.TOP, @@ -849,25 +846,15 @@ def create_test_fieldset_template( fieldset = FieldsetTemplate.objects.create( account=account, - template=template, name=name, + title=title, description=description, + order=order, label_position=label_position, layout=layout, api_name=api_name, + is_shared=True, ) - if task: - FieldsetTemplateTaskTemplate.objects.create( - fieldset=fieldset, - task=task, - order=order, - ) - if kickoff: - FieldsetTemplateKickoff.objects.create( - fieldset=fieldset, - kickoff=kickoff, - order=order, - ) if rule_type: FieldsetTemplateRule.objects.create( fieldset=fieldset, @@ -885,7 +872,6 @@ def create_test_fieldset_template( name='Fieldset field', type=field_type, fieldset=fieldset, - template=template, order=1, api_name=f'{fieldset.api_name}-field-1', account=account, @@ -893,6 +879,66 @@ def create_test_fieldset_template( 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, 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 1fd2daab0..62f0121f2 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,9 +13,6 @@ ChecklistTemplate, ChecklistTemplateSelection, ) -from src.processes.models.templates.fieldset import ( - FieldsetTemplateTaskTemplate, -) 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 @@ -2108,16 +2105,12 @@ def test_create_fields_from_template__deleted_fieldsets__skip( user = create_test_owner() template = create_test_template(user=user, tasks_count=1) template_task = template.tasks.get(number=1) - fieldset_deleted = create_test_fieldset_template( + create_test_fieldset_template( account=user.account, template=template, task=template_task, name='Deleted fieldset', order=0, - ) - FieldsetTemplateTaskTemplate.objects.filter( - fieldset=fieldset_deleted, - task=template_task, ).delete() workflow = create_test_workflow(user=user, template=template) task = workflow.tasks.get(number=1) 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 0bfe90c3e..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 @@ -2450,7 +2450,6 @@ def test__update_fieldsets__data_provided__ok(mocker): 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) old_fieldset = create_test_fieldset( workflow=workflow, task=task_1, @@ -2465,17 +2464,9 @@ def test__update_fieldsets__data_provided__ok(mocker): { 'api_name': 'fieldset-1', 'name': 'New Fieldset', + 'title': 'Test title', 'description': 'Test description', - 'task_links': [ - { - 'task_api_name': task_1.api_name, - 'order': 2, - }, - { - 'task_api_name': task_2.api_name, - 'order': 1, - }, - ], + 'order': 2, 'label_position': LabelPosition.TOP, 'layout': FieldSetLayout.VERTICAL, 'rules': [ diff --git a/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py index b10ca13e8..e055efe88 100644 --- a/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py +++ b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py @@ -1051,8 +1051,8 @@ def test_delete__used_by_task__raise_exception(): template=template, account=account, name='Fieldset', + task=task_template, ) - fieldset.tasks.add(task_template) service = FieldSetTemplateService( user=user, is_superuser=False, 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 index b5c82af58..b73cd2b44 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_create.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_create.py @@ -12,7 +12,6 @@ LabelPosition, FieldSetRuleType, FieldType, ) -from src.processes.messages import template as messages from src.processes.models.templates.fieldset import ( FieldsetTemplate, FieldsetTemplateRule, @@ -51,6 +50,7 @@ def test_create_fieldset__all_fields__ok(api_client, mocker): data = { 'name': 'All Fields Fieldset', + 'title': 'All Fields Title', 'description': 'Description', 'label_position': LabelPosition.LEFT, 'layout': FieldSetLayout.HORIZONTAL, @@ -77,6 +77,7 @@ def test_create_fieldset__all_fields__ok(api_client, mocker): account=account, template=template, name=data['name'], + title=data['title'], description=data['description'], label_position=data['label_position'], layout=data['layout'], @@ -107,25 +108,21 @@ def test_create_fieldset__all_fields__ok(api_client, mocker): ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.template.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.create', return_value=fieldset, ) api_client.token_authenticate(user=user) # act - response = api_client.post( - f'/templates/{template.id}/fieldsets', - data=data, - ) + 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['template_id'] == template.id - assert response.data['tasks'] == [] assert response.data['label_position'] == data['label_position'] assert response.data['layout'] == data['layout'] assert response.data['api_name'] == data['api_name'] @@ -144,8 +141,8 @@ def test_create_fieldset__all_fields__ok(api_client, mocker): auth_type=AuthTokenType.USER, ) fieldset_service_create_mock.assert_called_once_with( - template_id=template.id, name=data['name'], + title=data['title'], description=data['description'], layout=data['layout'], label_position=data['label_position'], @@ -170,7 +167,6 @@ def test_create_fieldset__min_data__ok(api_client, mocker): 'name': 'Minimal Fieldset', } - # mock FieldSetTemplateService fieldset_service_init_mock = mocker.patch.object( FieldSetTemplateService, attribute='__init__', @@ -182,17 +178,14 @@ def test_create_fieldset__min_data__ok(api_client, mocker): name='Minimal Fieldset', ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.template.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.create', return_value=fieldset, ) api_client.token_authenticate(user=user) # act - response = api_client.post( - f'/templates/{template.id}/fieldsets', - data=data, - ) + response = api_client.post('/fieldsets', data=data) # assert assert response.status_code == 201 @@ -202,7 +195,6 @@ def test_create_fieldset__min_data__ok(api_client, mocker): auth_type=AuthTokenType.USER, ) fieldset_service_create_mock.assert_called_once_with( - template_id=template.id, name='Minimal Fieldset', rules=[], fields=[], @@ -223,7 +215,6 @@ def test_create_fieldset__set_api_name__ok(api_client, mocker): 'api_name': 'fs1', } - # mock FieldSetTemplateService fieldset_service_init_mock = mocker.patch.object( FieldSetTemplateService, attribute='__init__', @@ -235,27 +226,24 @@ def test_create_fieldset__set_api_name__ok(api_client, mocker): name='Minimal Fieldset', ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.template.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.create', return_value=fieldset, ) api_client.token_authenticate(user=user) # act - response = api_client.post( - f'/templates/{template.id}/fieldsets', - data=data, - ) + 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( - template_id=template.id, name=data['name'], api_name=data['api_name'], rules=[], @@ -303,7 +291,6 @@ def test_create_fieldset__rule_with_one_field__ok(api_client, mocker): ], } - # mock FieldSetTemplateService fieldset_service_init_mock = mocker.patch.object( FieldSetTemplateService, attribute='__init__', @@ -330,17 +317,14 @@ def test_create_fieldset__rule_with_one_field__ok(api_client, mocker): rule.fields.add(field) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.template.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.create', return_value=fieldset, ) api_client.token_authenticate(user=user) # act - response = api_client.post( - f'/templates/{template.id}/fieldsets', - data=data, - ) + response = api_client.post('/fieldsets', data=data) # assert assert response.status_code == 201 @@ -354,7 +338,6 @@ def test_create_fieldset__rule_with_one_field__ok(api_client, mocker): auth_type=AuthTokenType.USER, ) fieldset_service_create_mock.assert_called_once_with( - template_id=template.id, name=data['name'], rules=data['rules'], fields=data['fields'], @@ -401,7 +384,6 @@ def test_create_fieldset__rule_with_two_fields__ok(api_client, mocker): ], } - # mock FieldSetTemplateService fieldset_service_init_mock = mocker.patch.object( FieldSetTemplateService, attribute='__init__', @@ -434,17 +416,14 @@ def test_create_fieldset__rule_with_two_fields__ok(api_client, mocker): rule.fields.set([field_1, field_2]) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.template.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.create', return_value=fieldset, ) api_client.token_authenticate(user=user) # act - response = api_client.post( - f'/templates/{template.id}/fieldsets', - data=data, - ) + response = api_client.post('/fieldsets', data=data) # assert assert response.status_code == 201 @@ -461,7 +440,6 @@ def test_create_fieldset__rule_with_two_fields__ok(api_client, mocker): auth_type=AuthTokenType.USER, ) fieldset_service_create_mock.assert_called_once_with( - template_id=template.id, name=data['name'], rules=data['rules'], fields=data['fields'], @@ -473,12 +451,6 @@ def test_create_fieldset__unauthenticated__unauthorized(api_client, mocker): """Unauthenticated request returns 401""" # arrange - account = create_test_account() - user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) data = { 'name': 'New Fieldset', } @@ -489,14 +461,11 @@ def test_create_fieldset__unauthenticated__unauthorized(api_client, mocker): return_value=None, ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.template.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.create', ) # act - response = api_client.post( - f'/templates/{template.id}/fieldsets', - data=data, - ) + response = api_client.post('/fieldsets', data=data) # assert assert response.status_code == 401 @@ -514,10 +483,6 @@ def test_create_fieldset__expired_sub__permission_denied(api_client, mocker): plan_expiration=timezone.now() - timedelta(days=1), ) user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) data = { 'name': 'New Fieldset', } @@ -528,16 +493,13 @@ def test_create_fieldset__expired_sub__permission_denied(api_client, mocker): return_value=None, ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.template.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.create', ) api_client.token_authenticate(user=user) # act - response = api_client.post( - f'/templates/{template.id}/fieldsets', - data=data, - ) + response = api_client.post('/fieldsets', data=data) # assert assert response.status_code == 403 @@ -553,10 +515,6 @@ def test_create_fieldset__billing_plan__permission_denied(api_client, mocker): # arrange account = create_test_account(plan=None) user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) data = { 'name': 'New Fieldset', } @@ -566,15 +524,12 @@ def test_create_fieldset__billing_plan__permission_denied(api_client, mocker): return_value=None, ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.template.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.create', ) api_client.token_authenticate(user=user) # act - response = api_client.post( - f'/templates/{template.id}/fieldsets', - data=data, - ) + response = api_client.post('/fieldsets', data=data) # assert assert response.status_code == 403 @@ -599,10 +554,6 @@ def test_create_fieldset__users_limit__permission_denied(api_client, mocker): ) account.active_users = 2 account.save() - template = create_test_template( - user=user, - tasks_count=1, - ) data = { 'name': 'New Fieldset', } @@ -614,15 +565,12 @@ def test_create_fieldset__users_limit__permission_denied(api_client, mocker): ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.template.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.create', ) api_client.token_authenticate(user=user) # act - response = api_client.post( - f'/templates/{template.id}/fieldsets', - data=data, - ) + response = api_client.post('/fieldsets', data=data) # assert assert response.status_code == 403 @@ -637,11 +585,7 @@ def test_create_fieldset__non_admin__permission_denied(api_client, mocker): # arrange account = create_test_account() - owner = create_test_owner(account=account) - template = create_test_template( - user=owner, - tasks_count=1, - ) + create_test_owner(account=account) user = create_test_not_admin(account=account) data = { 'name': 'New Fieldset', @@ -653,15 +597,12 @@ def test_create_fieldset__non_admin__permission_denied(api_client, mocker): ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.template.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.create', ) api_client.token_authenticate(user=user) # act - response = api_client.post( - f'/templates/{template.id}/fieldsets', - data=data, - ) + response = api_client.post('/fieldsets', data=data) # assert assert response.status_code == 403 @@ -669,43 +610,53 @@ def test_create_fieldset__non_admin__permission_denied(api_client, mocker): fieldset_service_create_mock.assert_not_called() -def test_create_fieldset__not_tpl_owner__permission_denied(api_client, mocker): +def test_create_fieldset__admin__ok(api_client, mocker): - """ Template admin owner permission denied returns 403 """ + """ Admin (non-owner) user can create fieldset """ # arrange account = create_test_account() - owner = create_test_owner(account=account) + create_test_owner(account=account) + user = create_test_admin(account=account) template = create_test_template( - user=owner, + user=user, tasks_count=1, ) - user = create_test_admin(account=account) 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.template.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.create', + return_value=fieldset, ) api_client.token_authenticate(user=user) # act - response = api_client.post( - f'/templates/{template.id}/fieldsets', - data=data, - ) + response = api_client.post('/fieldsets', data=data) # assert - assert response.status_code == 403 - assert response.data['detail'] == messages.MSG_PT_0023 - fieldset_service_init_mock.assert_not_called() - fieldset_service_create_mock.assert_not_called() + 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): @@ -715,15 +666,9 @@ def test_create_fieldset__blank_name__validation_error(api_client, mocker): # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) data = { } - fieldset = mocker.Mock(id=1, api_name='dummy') - fieldset_service_init_mock = mocker.patch.object( FieldSetTemplateService, attribute='__init__', @@ -731,16 +676,12 @@ def test_create_fieldset__blank_name__validation_error(api_client, mocker): ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.template.FieldSetTemplateService.create', - return_value=fieldset, + 'src.processes.views.fieldset.FieldSetTemplateService.create', ) api_client.token_authenticate(user=user) # act - response = api_client.post( - f'/templates/{template.id}/fieldsets', - data=data, - ) + response = api_client.post('/fieldsets', data=data) # assert assert response.status_code == 400 @@ -758,10 +699,6 @@ def test_create_fieldset__invalid_layout__validation_error(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': 'Test Fieldset', 'layout': 'invalid_layout', @@ -773,15 +710,12 @@ def test_create_fieldset__invalid_layout__validation_error(api_client, mocker): ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.template.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.create', ) api_client.token_authenticate(user=user) # act - response = api_client.post( - f'/templates/{template.id}/fieldsets', - data=data, - ) + response = api_client.post('/fieldsets', data=data) # assert assert response.status_code == 400 @@ -802,10 +736,6 @@ def test_create_fieldset__invalid_label_position__validation_error( # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) data = { 'name': 'Test Fieldset', 'label_position': 'invalid_position', @@ -817,15 +747,12 @@ def test_create_fieldset__invalid_label_position__validation_error( ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.template.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.create', ) api_client.token_authenticate(user=user) # act - response = api_client.post( - f'/templates/{template.id}/fieldsets', - data=data, - ) + response = api_client.post('/fieldsets', data=data) # assert assert response.status_code == 400 @@ -845,33 +772,25 @@ def test_create_fieldset__service_exception__validation_error( # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) data = { 'name': 'Test Fieldset', } error_message = 'Service error occurred' - # mock FieldSetTemplateService fieldset_service_init_mock = mocker.patch.object( FieldSetTemplateService, attribute='__init__', return_value=None, ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.template.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.create', side_effect=BaseServiceException(message=error_message), ) api_client.token_authenticate(user=user) # act - response = api_client.post( - f'/templates/{template.id}/fieldsets', - data=data, - ) + response = api_client.post('/fieldsets', data=data) # assert assert response.status_code == 400 @@ -882,42 +801,7 @@ def test_create_fieldset__service_exception__validation_error( auth_type=AuthTokenType.USER, ) fieldset_service_create_mock.assert_called_once_with( - template_id=template.id, name='Test Fieldset', rules=[], fields=[], ) - - -def test_create_fieldset__not_existing_tpl__not_found(api_client, mocker): - - """ Non-existent template returns 404 """ - - # arrange - account = create_test_account() - user = create_test_owner(account=account) - nonexistent_id = 999999 - 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.template.FieldSetTemplateService.create', - ) - api_client.token_authenticate(user=user) - - # act - response = api_client.post( - f'/templates/{nonexistent_id}/fieldsets', - data=data, - ) - - # assert - assert response.status_code == 404 - fieldset_service_init_mock.assert_not_called() - fieldset_service_create_mock.assert_not_called() 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 index 8855e44fe..f87059dc1 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_destroy.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_destroy.py @@ -50,7 +50,7 @@ def test_destroy__ok(api_client, mocker): # act response = api_client.delete( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', ) # assert @@ -90,7 +90,7 @@ def test_destroy__unauthenticated__unauthorized(api_client, mocker): ) # act response = api_client.delete( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', ) # assert @@ -130,7 +130,7 @@ def test_destroy__expired_sub__permission_denied(api_client, mocker): ) # act response = api_client.delete( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', ) # assert @@ -168,7 +168,7 @@ def test_destroy__billing_plan__permission_denied(api_client, mocker): ) # act response = api_client.delete( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', ) # assert @@ -215,7 +215,7 @@ def test_destroy__users_overlimit__permission_denied(api_client, mocker): ) # act response = api_client.delete( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', ) # assert @@ -254,7 +254,7 @@ def test_destroy__non_admin__permission_denied(api_client, mocker): ) # act response = api_client.delete( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', ) # assert @@ -295,7 +295,7 @@ def test_destroy__service_exception__validation_error(api_client, mocker): # act response = api_client.delete( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', ) # assert @@ -331,7 +331,7 @@ def test_destroy__not_existing__not_found(api_client, mocker): ) # act response = api_client.delete( - f'/templates/fieldsets/{nonexistent_id}', + f'/fieldsets/{nonexistent_id}', ) # assert 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 index aea9958b1..3dddc1506 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_list.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_list.py @@ -9,11 +9,10 @@ from src.processes.enums import ( FieldSetRuleType, ) -from src.processes.messages import template as messages from src.processes.tests.fixtures import ( create_test_account, create_test_admin, - create_test_fieldset_template, + create_test_shared_fieldset, create_test_not_admin, create_test_owner, create_test_template, @@ -25,21 +24,21 @@ def test_list_fieldsets__all_data__ok(api_client): - """List fieldsets for existing template""" + """List fieldsets returning all fields including title and order""" # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( + create_test_template( user=user, tasks_count=1, ) rule_type = FieldSetRuleType.SUM_EQUAL rule_value = '10' - fieldset = create_test_fieldset_template( + fieldset = create_test_shared_fieldset( account=account, - template=template, - name='Kickoff Fieldset', + title='Fieldset Title', + order=3, rule_type=rule_type, rule_value=rule_value, ) @@ -49,9 +48,7 @@ def test_list_fieldsets__all_data__ok(api_client): api_client.token_authenticate(user=user) # act - response = api_client.get( - f'/templates/{template.id}/fieldsets', - ) + response = api_client.get('/fieldsets') # assert assert response.status_code == 200 @@ -60,11 +57,11 @@ def test_list_fieldsets__all_data__ok(api_client): 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['template_id'] == template.id assert item_1['layout'] == fieldset.layout assert item_1['label_position'] == fieldset.label_position - assert item_1['tasks'] == [] assert len(item_1['rules']) == 1 rules_data = item_1['rules'] @@ -85,90 +82,63 @@ def test_list_fieldsets__all_data__ok(api_client): assert 'selections' not in fields_data[0] -def test_list_fieldsets__tasks_and_kickoff_fieldset__ok(api_client): +def test_list_fieldsets__shared_fieldset_has_rules_and_fields__ok(api_client): - """List fieldsets for existing template""" + """List shared fieldsets returns rules and fields""" # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=2, - ) - kickoff = template.kickoff_instance - template_task_1 = template.tasks.get(number=1) - template_task_2 = template.tasks.get(number=2) rule_type = FieldSetRuleType.SUM_EQUAL rule_value = '10' - fieldset = create_test_fieldset_template( + fieldset = create_test_shared_fieldset( account=account, - template=template, - name='Kickoff Fieldset', - task=template_task_1, - kickoff=kickoff, rule_type=rule_type, rule_value=rule_value, ) - fieldset.tasks.add(template_task_2) - fieldset.fields.get() - fieldset.rules.get() + field = fieldset.fields.get() + rule = fieldset.rules.get() api_client.token_authenticate(user=user) # act - response = api_client.get( - f'/templates/{template.id}/fieldsets', - ) + response = api_client.get('/fieldsets') # assert assert response.status_code == 200 data = response.data[0] assert data['id'] == fieldset.id - assert data['kickoff'] == kickoff.id - assert data['tasks'] == [ - { - 'number': template_task_1.number, - 'name': template_task_1.name, - 'api_name': template_task_1.api_name, - }, - { - 'number': template_task_2.number, - 'name': template_task_2.name, - 'api_name': template_task_2.api_name, - }, - ] + 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): - """List fieldsets for existing template""" + """Paginated list returns correct count and slice""" # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( + create_test_template( user=user, tasks_count=1, ) - template.tasks.first() - fieldset_1 = create_test_fieldset_template( + fieldset_1 = create_test_shared_fieldset( account=account, - template=template, ) - fieldset_2 = create_test_fieldset_template( + fieldset_2 = create_test_shared_fieldset( account=account, - template=template, ) - create_test_fieldset_template( + create_test_shared_fieldset( account=account, - template=template, ) api_client.token_authenticate(user=user) # act response = api_client.get( - f'/templates/{template.id}/fieldsets', + '/fieldsets', data={'limit': 2, 'offset': 1}, ) @@ -185,77 +155,32 @@ def test_list_fieldsets__pagination__ok(api_client): def test_list_fieldsets__different_accounts__ok(api_client): - """List fieldsets filtered by account""" + """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) - template_1 = create_test_template( + create_test_template( user=user_1, tasks_count=1, ) - fieldset_1 = create_test_fieldset_template( + fieldset_1 = create_test_shared_fieldset( account=account_1, - template=template_1, - name='Account 1 Fieldset', ) account_2 = create_test_account(name='Account 2') - user_2 = create_test_owner( + create_test_owner( account=account_2, email='owner2@pneumatic.app', ) - template_2 = create_test_template( - user=user_2, - tasks_count=1, - ) - create_test_fieldset_template( + create_test_shared_fieldset( account=account_2, - template=template_2, ) api_client.token_authenticate(user=user_1) # act - response = api_client.get( - f'/templates/{template_1.id}/fieldsets', - ) - - # assert - assert response.status_code == 200 - assert len(response.data) == 1 - assert response.data[0]['id'] == fieldset_1.id - - -def test_list_fieldsets__different_templates__ok(api_client): - """List fieldsets filtered by template_id""" - - # arrange - account = create_test_account() - user = create_test_owner(account=account) - template_1 = create_test_template( - user=user, - tasks_count=1, - ) - fieldset_1 = create_test_fieldset_template( - account=account, - template=template_1, - ) - template_2 = create_test_template( - user=user, - tasks_count=1, - ) - create_test_fieldset_template( - account=account, - template=template_2, - ) - - api_client.token_authenticate(user=user) - - # act - response = api_client.get( - f'/templates/{template_1.id}/fieldsets', - ) + response = api_client.get('/fieldsets') # assert assert response.status_code == 200 @@ -264,21 +189,19 @@ def test_list_fieldsets__different_templates__ok(api_client): def test_list_fieldsets__rule_with_fields__ok(api_client): - """List fieldsets for existing template returning rules mapping fields""" + """List fieldsets returning rules mapping to fields""" # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( + create_test_template( user=user, tasks_count=1, ) rule_type = FieldSetRuleType.SUM_EQUAL rule_value = '10' - fieldset = create_test_fieldset_template( + fieldset = create_test_shared_fieldset( account=account, - template=template, - name='Kickoff Fieldset', rule_type=rule_type, rule_value=rule_value, ) @@ -289,9 +212,7 @@ def test_list_fieldsets__rule_with_fields__ok(api_client): api_client.token_authenticate(user=user) # act - response = api_client.get( - f'/templates/{template.id}/fieldsets', - ) + response = api_client.get('/fieldsets') # assert assert response.status_code == 200 @@ -306,16 +227,8 @@ def test_list_fieldsets__rule_with_fields__ok(api_client): def test_list_fieldsets__unauthenticated__unauthorized(api_client): """Unauthenticated request returns 401""" - # arrange - account = create_test_account() - user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) - # act - response = api_client.get(f'/templates/{template.id}/fieldsets') + response = api_client.get('/fieldsets') # assert assert response.status_code == 401 @@ -330,15 +243,11 @@ def test_list_fieldsets__expired_sub__permission_denied(api_client): plan_expiration=timezone.now() - timedelta(days=1), ) user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) api_client.token_authenticate(user=user) # act - response = api_client.get(f'/templates/{template.id}/fieldsets') + response = api_client.get('/fieldsets') # assert assert response.status_code == 403 @@ -351,15 +260,11 @@ def test_list_fieldsets__billing_plan__permission_denied(api_client): # arrange account = create_test_account(plan=None) user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) api_client.token_authenticate(user=user) # act - response = api_client.get(f'/templates/{template.id}/fieldsets') + response = api_client.get('/fieldsets') # assert assert response.status_code == 403 @@ -381,15 +286,11 @@ def test_list_fieldsets__users_overlimit__permission_denied(api_client): ) account.active_users = 2 account.save() - template = create_test_template( - user=user, - tasks_count=1, - ) api_client.token_authenticate(user=user) # act - response = api_client.get(f'/templates/{template.id}/fieldsets') + response = api_client.get('/fieldsets') # assert assert response.status_code == 403 @@ -401,59 +302,42 @@ def test_list_fieldsets__non_admin__permission_denied(api_client): # arrange account = create_test_account() - owner = create_test_owner(account=account) - template = create_test_template( - user=owner, - tasks_count=1, - ) + create_test_owner(account=account) user = create_test_not_admin(account=account) api_client.token_authenticate(user=user) # act - response = api_client.get(f'/templates/{template.id}/fieldsets') + response = api_client.get('/fieldsets') # assert assert response.status_code == 403 -def test_list_fieldsets__not_tpl_owner__permission_denied(api_client): - """Template admin owner permission denied returns 403""" +def test_list_fieldsets__admin__ok(api_client): + """Admin (non-owner) user can list fieldsets""" # arrange account = create_test_account() - owner = create_test_owner(account=account) - template = create_test_template( - user=owner, + create_test_owner(account=account) + user = create_test_admin(account=account) + create_test_template( + user=user, tasks_count=1, ) - user = create_test_admin(account=account) - - api_client.token_authenticate(user=user) - - # act - response = api_client.get(f'/templates/{template.id}/fieldsets') - - # assert - assert response.status_code == 403 - assert response.data['detail'] == messages.MSG_PT_0023 - - -def test_list_fieldsets__not_existing_tpl__not_found(api_client): - """Non-existent template returns 404""" - - # arrange - account = create_test_account() - user = create_test_owner(account=account) - nonexistent_id = 999999 + fieldset = create_test_shared_fieldset( + account=account, + ) api_client.token_authenticate(user=user) # act - response = api_client.get(f'/templates/{nonexistent_id}/fieldsets') + response = api_client.get('/fieldsets') # assert - assert response.status_code == 404 + 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): @@ -463,30 +347,27 @@ def test_list_fieldsets__no_ordering__ok(api_client): # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( + create_test_template( user=user, tasks_count=1, ) now = timezone.now() - fieldset_1 = create_test_fieldset_template( + fieldset_1 = create_test_shared_fieldset( account=account, - template=template, name='Oldest', ) FieldsetTemplate.objects.filter(id=fieldset_1.id).update( date_created=now - timedelta(days=2), ) - fieldset_2 = create_test_fieldset_template( + fieldset_2 = create_test_shared_fieldset( account=account, - template=template, name='Middle', ) FieldsetTemplate.objects.filter(id=fieldset_2.id).update( date_created=now - timedelta(days=1), ) - fieldset_3 = create_test_fieldset_template( + fieldset_3 = create_test_shared_fieldset( account=account, - template=template, name='Newest', ) FieldsetTemplate.objects.filter(id=fieldset_3.id).update( @@ -495,9 +376,7 @@ def test_list_fieldsets__no_ordering__ok(api_client): api_client.token_authenticate(user=user) # act - response = api_client.get( - f'/templates/{template.id}/fieldsets', - ) + response = api_client.get('/fieldsets') # assert assert response.status_code == 200 @@ -517,30 +396,27 @@ def test_list_fieldsets__ordering_name_asc__ok(api_client): # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( + create_test_template( user=user, tasks_count=1, ) - fieldset_1 = create_test_fieldset_template( + fieldset_1 = create_test_shared_fieldset( account=account, - template=template, name='Alpha', ) - fieldset_2 = create_test_fieldset_template( + fieldset_2 = create_test_shared_fieldset( account=account, - template=template, name='Beta', ) - fieldset_3 = create_test_fieldset_template( + fieldset_3 = create_test_shared_fieldset( account=account, - template=template, name='Gamma', ) api_client.token_authenticate(user=user) # act response = api_client.get( - f'/templates/{template.id}/fieldsets', + '/fieldsets', data={'ordering': 'name'}, ) @@ -565,30 +441,27 @@ def test_list_fieldsets__ordering_name_desc__ok(api_client): # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( + create_test_template( user=user, tasks_count=1, ) - fieldset_1 = create_test_fieldset_template( + fieldset_1 = create_test_shared_fieldset( account=account, - template=template, name='Alpha', ) - fieldset_2 = create_test_fieldset_template( + fieldset_2 = create_test_shared_fieldset( account=account, - template=template, name='Beta', ) - fieldset_3 = create_test_fieldset_template( + fieldset_3 = create_test_shared_fieldset( account=account, - template=template, name='Gamma', ) api_client.token_authenticate(user=user) # act response = api_client.get( - f'/templates/{template.id}/fieldsets', + '/fieldsets', data={'ordering': '-name'}, ) @@ -613,30 +486,27 @@ def test_list_fieldsets__ordering_date_asc__ok(api_client): # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( + create_test_template( user=user, tasks_count=1, ) now = timezone.now() - fieldset_1 = create_test_fieldset_template( + fieldset_1 = create_test_shared_fieldset( account=account, - template=template, name='Oldest', ) FieldsetTemplate.objects.filter(id=fieldset_1.id).update( date_created=now - timedelta(days=2), ) - fieldset_2 = create_test_fieldset_template( + fieldset_2 = create_test_shared_fieldset( account=account, - template=template, name='Middle', ) FieldsetTemplate.objects.filter(id=fieldset_2.id).update( date_created=now - timedelta(days=1), ) - fieldset_3 = create_test_fieldset_template( + fieldset_3 = create_test_shared_fieldset( account=account, - template=template, name='Newest', ) FieldsetTemplate.objects.filter(id=fieldset_3.id).update( @@ -646,7 +516,7 @@ def test_list_fieldsets__ordering_date_asc__ok(api_client): # act response = api_client.get( - f'/templates/{template.id}/fieldsets', + '/fieldsets', data={'ordering': 'date'}, ) @@ -668,30 +538,27 @@ def test_list_fieldsets__ordering_date_desc__ok(api_client): # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( + create_test_template( user=user, tasks_count=1, ) now = timezone.now() - fieldset_1 = create_test_fieldset_template( + fieldset_1 = create_test_shared_fieldset( account=account, - template=template, name='Oldest', ) FieldsetTemplate.objects.filter(id=fieldset_1.id).update( date_created=now - timedelta(days=2), ) - fieldset_2 = create_test_fieldset_template( + fieldset_2 = create_test_shared_fieldset( account=account, - template=template, name='Middle', ) FieldsetTemplate.objects.filter(id=fieldset_2.id).update( date_created=now - timedelta(days=1), ) - fieldset_3 = create_test_fieldset_template( + fieldset_3 = create_test_shared_fieldset( account=account, - template=template, name='Newest', ) FieldsetTemplate.objects.filter(id=fieldset_3.id).update( @@ -701,7 +568,7 @@ def test_list_fieldsets__ordering_date_desc__ok(api_client): # act response = api_client.get( - f'/templates/{template.id}/fieldsets', + '/fieldsets', data={'ordering': '-date'}, ) @@ -723,26 +590,22 @@ def test_list_fieldsets__no_pagination__ok(api_client): # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( + create_test_template( user=user, tasks_count=1, ) - create_test_fieldset_template( + create_test_shared_fieldset( account=account, - template=template, name='First', ) - create_test_fieldset_template( + create_test_shared_fieldset( account=account, - template=template, name='Second', ) api_client.token_authenticate(user=user) # act - response = api_client.get( - f'/templates/{template.id}/fieldsets', - ) + response = api_client.get('/fieldsets') # assert assert response.status_code == 200 @@ -750,29 +613,26 @@ def test_list_fieldsets__no_pagination__ok(api_client): assert len(response.data) == 2 -def test_list_fieldsets__ordering_invalid__validation_error( - api_client, -): +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) - template = create_test_template( + create_test_template( user=user, tasks_count=1, ) - create_test_fieldset_template( + create_test_shared_fieldset( account=account, - template=template, name='First', ) api_client.token_authenticate(user=user) # act response = api_client.get( - f'/templates/{template.id}/fieldsets', + '/fieldsets', data={'ordering': 'foobar'}, ) @@ -790,22 +650,20 @@ def test_list_fieldsets__ordering_empty__ok(api_client): # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( + create_test_template( user=user, tasks_count=1, ) now = timezone.now() - fieldset_1 = create_test_fieldset_template( + fieldset_1 = create_test_shared_fieldset( account=account, - template=template, name='First', ) FieldsetTemplate.objects.filter(id=fieldset_1.id).update( date_created=now - timedelta(days=1), ) - fieldset_2 = create_test_fieldset_template( + fieldset_2 = create_test_shared_fieldset( account=account, - template=template, name='Second', ) FieldsetTemplate.objects.filter(id=fieldset_2.id).update( @@ -815,7 +673,7 @@ def test_list_fieldsets__ordering_empty__ok(api_client): # act response = api_client.get( - f'/templates/{template.id}/fieldsets', + '/fieldsets', data={'ordering': ''}, ) @@ -837,13 +695,12 @@ def test_list_fieldsets__soft_deleted__ok(api_client): # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( + create_test_template( user=user, tasks_count=1, ) - fieldset = create_test_fieldset_template( + fieldset = create_test_shared_fieldset( account=account, - template=template, name='Deleted Fieldset', ) FieldsetTemplate.objects.filter(id=fieldset.id).update( @@ -852,9 +709,7 @@ def test_list_fieldsets__soft_deleted__ok(api_client): api_client.token_authenticate(user=user) # act - response = api_client.get( - f'/templates/{template.id}/fieldsets', - ) + response = api_client.get('/fieldsets') # assert assert response.status_code == 200 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 index 147b207a4..b1f0d2ef9 100644 --- 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 @@ -19,10 +19,9 @@ ) from src.processes.tests.fixtures import ( create_test_account, - create_test_fieldset_template, create_test_not_admin, create_test_owner, - create_test_template, + create_test_shared_fieldset, ) from src.utils.validation import ErrorCode @@ -36,10 +35,6 @@ def test_partial_update__all_fields__ok(api_client, mocker): # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) field_api_name = 'f1' fieldset_api_name = 'fs1' data = { @@ -65,9 +60,8 @@ def test_partial_update__all_fields__ok(api_client, mocker): }, ], } - fieldset = create_test_fieldset_template( + fieldset = create_test_shared_fieldset( account=account, - template=template, name=data['name'], description=data['description'], label_position=data['label_position'], @@ -92,7 +86,7 @@ def test_partial_update__all_fields__ok(api_client, mocker): # act response = api_client.patch( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', data=data, ) @@ -101,8 +95,6 @@ def test_partial_update__all_fields__ok(api_client, mocker): assert response.data['id'] == fieldset.id assert response.data['name'] == data['name'] assert response.data['description'] == data['description'] - assert response.data['template_id'] == template.id - assert response.data['tasks'] == [] assert response.data['label_position'] == data['label_position'] assert response.data['layout'] == data['layout'] assert response.data['api_name'] == data['api_name'] @@ -140,13 +132,8 @@ def test_partial_update__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, - ) - fieldset = create_test_fieldset_template( + fieldset = create_test_shared_fieldset( account=account, - template=template, ) data = { 'name': 'Updated Name', @@ -164,7 +151,7 @@ def test_partial_update__name__ok(api_client, mocker): # act response = api_client.patch( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', data=data, ) @@ -192,13 +179,8 @@ def test_partial_update__with_rule_fields__ok(api_client, mocker): # 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( + fieldset = create_test_shared_fieldset( account=account, - template=template, ) field_api_name = 'f1' data = { @@ -248,7 +230,7 @@ def test_partial_update__with_rule_fields__ok(api_client, mocker): # act response = api_client.patch( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', data=data, ) @@ -278,13 +260,8 @@ def test_partial_update__clear_fields__ok(api_client, mocker): # 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( + fieldset = create_test_shared_fieldset( account=account, - template=template, ) data = { 'name': 'Updated Fieldset', @@ -313,7 +290,7 @@ def test_partial_update__clear_fields__ok(api_client, mocker): # act response = api_client.patch( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', data=data, ) @@ -337,14 +314,8 @@ def test_partial_update__unauthenticated__unauthorized(api_client, mocker): # 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( + fieldset = create_test_shared_fieldset( account=account, - template=template, ) data = { 'name': 'Updated Fieldset', @@ -360,7 +331,7 @@ def test_partial_update__unauthenticated__unauthorized(api_client, mocker): ) # act response = api_client.patch( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', data=data, ) @@ -380,13 +351,8 @@ def test_partial_update__expired_sub__permission_denied(api_client, mocker): plan_expiration=timezone.now() - timedelta(days=1), ) user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) - fieldset = create_test_fieldset_template( + fieldset = create_test_shared_fieldset( account=account, - template=template, ) data = { 'name': 'Updated Fieldset', @@ -403,7 +369,7 @@ def test_partial_update__expired_sub__permission_denied(api_client, mocker): # act response = api_client.patch( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', data=data, ) @@ -421,13 +387,8 @@ def test_partial_update__billing_plan__permission_denied(api_client, mocker): # arrange account = create_test_account(plan=None) user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) - fieldset = create_test_fieldset_template( + fieldset = create_test_shared_fieldset( account=account, - template=template, ) data = { 'name': 'Updated Fieldset', @@ -444,7 +405,7 @@ def test_partial_update__billing_plan__permission_denied(api_client, mocker): # act response = api_client.patch( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', data=data, ) @@ -471,13 +432,8 @@ def test_partial_update__users_limit__permission_denied(api_client, mocker): ) account.active_users = 2 account.save() - template = create_test_template( - user=user, - tasks_count=1, - ) - fieldset = create_test_fieldset_template( + fieldset = create_test_shared_fieldset( account=account, - template=template, ) data = { 'name': 'Updated Fieldset', @@ -494,7 +450,7 @@ def test_partial_update__users_limit__permission_denied(api_client, mocker): # act response = api_client.patch( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', data=data, ) @@ -511,14 +467,9 @@ def test_partial_update__non_admin__permission_denied(api_client, mocker): # arrange account = create_test_account() - owner = create_test_owner(account=account) - template = create_test_template( - user=owner, - tasks_count=1, - ) - fieldset = create_test_fieldset_template( + create_test_owner(account=account) + fieldset = create_test_shared_fieldset( account=account, - template=template, ) user = create_test_not_admin(account=account) data = { @@ -536,7 +487,7 @@ def test_partial_update__non_admin__permission_denied(api_client, mocker): # act response = api_client.patch( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', data=data, ) @@ -553,13 +504,8 @@ def test_partial_update__invalid_name__validation_error(api_client, mocker): # 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( + fieldset = create_test_shared_fieldset( account=account, - template=template, ) data = { 'name': '', @@ -576,7 +522,7 @@ def test_partial_update__invalid_name__validation_error(api_client, mocker): # act response = api_client.patch( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', data=data, ) @@ -596,13 +542,8 @@ def test_partial_update__invalid_layout__validation_error(api_client, mocker): # 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( + fieldset = create_test_shared_fieldset( account=account, - template=template, ) data = { 'layout': 'invalid_layout', @@ -619,7 +560,7 @@ def test_partial_update__invalid_layout__validation_error(api_client, mocker): # act response = api_client.patch( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', data=data, ) @@ -642,13 +583,8 @@ def test_partial_update__invalid_label_position__validation_error( # 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( + fieldset = create_test_shared_fieldset( account=account, - template=template, ) data = { 'label_position': 'invalid_position', @@ -665,7 +601,7 @@ def test_partial_update__invalid_label_position__validation_error( # act response = api_client.patch( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', data=data, ) @@ -687,13 +623,8 @@ def test_partial_update__service_exception__validation_error( # 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( + fieldset = create_test_shared_fieldset( account=account, - template=template, ) data = { 'name': 'Updated Fieldset', @@ -712,7 +643,7 @@ def test_partial_update__service_exception__validation_error( # act response = api_client.patch( - f'/templates/fieldsets/{fieldset.id}', + f'/fieldsets/{fieldset.id}', data=data, ) @@ -755,7 +686,7 @@ def test_partial_update__not_existing_fieldset__not_found(api_client, mocker): ) # act response = api_client.patch( - f'/templates/fieldsets/{nonexistent_id}', + f'/fieldsets/{nonexistent_id}', data=data, ) 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 index 17a5a2ff4..f826f3768 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py @@ -5,17 +5,12 @@ 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 ( - FieldSetLayout, - LabelPosition, - FieldSetRuleType, -) +from src.processes.enums import FieldSetRuleType from src.processes.tests.fixtures import ( create_test_account, - create_test_fieldset_template, + create_test_shared_fieldset, create_test_not_admin, create_test_owner, - create_test_template, ) pytestmark = pytest.mark.django_db @@ -27,212 +22,56 @@ def test_retrieve__fieldset_all_data__ok(api_client): # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) rule_type = FieldSetRuleType.SUM_EQUAL rule_value = '10' - fieldset = create_test_fieldset_template( + fieldset = create_test_shared_fieldset( account=account, - template=template, - name='My Fieldset', - description='Fieldset description', - layout=FieldSetLayout.HORIZONTAL, - label_position=LabelPosition.LEFT, 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'/templates/fieldsets/{fieldset.id}') + 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'] == 'My Fieldset' - assert response.data['description'] == 'Fieldset description' - assert response.data['template_id'] == template.id - assert response.data['layout'] == FieldSetLayout.HORIZONTAL - assert response.data['label_position'] == LabelPosition.LEFT - assert response.data['kickoff'] is None - assert response.data['tasks'] == [] + 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 - rules_data = response.data['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 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 - fields_data = response.data['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_retrieve__kickoff_fieldset__ok(api_client): - - # arrange - account = create_test_account() - user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) - kickoff = template.kickoff_instance - template_task = template.tasks.get(number=1) - fieldset = create_test_fieldset_template( - account=account, - template=template, - task=template_task, - kickoff=kickoff, - ) - - api_client.token_authenticate(user=user) - - # act - response = api_client.get(f'/templates/fieldsets/{fieldset.id}') - - # assert - assert response.status_code == 200 - assert response.data['id'] == fieldset.id - assert response.data['kickoff'] == kickoff.id - - -def test_retrieve__used_by_kickoff_deleted_record__empty_kickoff(api_client): - - # 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 = create_test_fieldset_template( - account=account, - template=template, - kickoff=kickoff, - ) - fieldset.kickoffs.clear() - - api_client.token_authenticate(user=user) - - # act - response = api_client.get(f'/templates/fieldsets/{fieldset.id}') - - # assert - assert response.status_code == 200 - assert response.data['id'] == fieldset.id - assert response.data['kickoff'] is None - - -def test_retrieve__task_fieldset__ok(api_client): - - # arrange - account = create_test_account() - user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) - template_task = template.tasks.get(number=1) - create_test_fieldset_template( - account=account, - template=template, - task=template_task, - ) - fieldset = create_test_fieldset_template( - account=account, - template=template, - task=template_task, - ) - - api_client.token_authenticate(user=user) - - # act - response = api_client.get(f'/templates/fieldsets/{fieldset.id}') - - # assert - assert response.status_code == 200 - assert response.data['id'] == fieldset.id - assert response.data['tasks'] == [ - { - 'number': template_task.number, - 'name': template_task.name, - 'api_name': template_task.api_name, - }, - ] - - -def test_retrieve__tasks_and_kickoff_fieldset__ok(api_client): - - # arrange - account = create_test_account() - user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=2, - ) - kickoff = template.kickoff_instance - template_task_1 = template.tasks.get(number=1) - template_task_2 = template.tasks.get(number=2) - fieldset = create_test_fieldset_template( - account=account, - template=template, - task=template_task_1, - kickoff=kickoff, - ) - fieldset.tasks.add(template_task_2) - - api_client.token_authenticate(user=user) - - # act - response = api_client.get(f'/templates/fieldsets/{fieldset.id}') - - # assert - assert response.status_code == 200 - assert response.data['id'] == fieldset.id - assert response.data['kickoff'] == kickoff.id - assert response.data['tasks'] == [ - { - 'number': template_task_1.number, - 'name': template_task_1.name, - 'api_name': template_task_1.api_name, - }, - { - 'number': template_task_2.number, - 'name': template_task_2.name, - 'api_name': template_task_2.api_name, - }, - ] + 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): - """Retrieve existing fieldset returning rule mapping fields""" # arrange account_1 = create_test_account(name='Account 1') user_1 = create_test_owner(account=account_1) - template_1 = create_test_template( - user=user_1, - tasks_count=1, - ) - fieldset = create_test_fieldset_template( + fieldset = create_test_shared_fieldset( account=account_1, - template=template_1, rule_type=FieldSetRuleType.SUM_EQUAL, rule_value='10', ) @@ -243,15 +82,16 @@ def test_retrieve__fieldset_rule_with_fields__ok(api_client): api_client.token_authenticate(user=user_1) # act - response = api_client.get(f'/templates/fieldsets/{fieldset.id}') + 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 - rules_data = response.data['rules'] - assert rules_data[0]['fields'] == [field.api_name] + 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): @@ -259,18 +99,13 @@ def test_retrieve__unauthenticated__unauthorized(api_client): # 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( + create_test_owner(account=account) + fieldset = create_test_shared_fieldset( account=account, - template=template, ) # act - response = api_client.get(f'/templates/fieldsets/{fieldset.id}') + response = api_client.get(f'/fieldsets/{fieldset.id}') # assert assert response.status_code == 401 @@ -285,19 +120,14 @@ def test_retrieve__expired_sub__permission_denied(api_client): plan_expiration=timezone.now() - timedelta(days=1), ) user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) - fieldset = create_test_fieldset_template( + fieldset = create_test_shared_fieldset( account=account, - template=template, ) api_client.token_authenticate(user=user) # act - response = api_client.get(f'/templates/fieldsets/{fieldset.id}') + response = api_client.get(f'/fieldsets/{fieldset.id}') # assert assert response.status_code == 403 @@ -310,19 +140,14 @@ def test_retrieve__billing_plan__permission_denied(api_client): # arrange account = create_test_account(plan=None) user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) - fieldset = create_test_fieldset_template( + fieldset = create_test_shared_fieldset( account=account, - template=template, ) api_client.token_authenticate(user=user) # act - response = api_client.get(f'/templates/fieldsets/{fieldset.id}') + response = api_client.get(f'/fieldsets/{fieldset.id}') # assert assert response.status_code == 403 @@ -344,19 +169,14 @@ def test_retrieve__users_overlimit__permission_denied(api_client): ) account.active_users = 2 account.save() - template = create_test_template( - user=user, - tasks_count=1, - ) - fieldset = create_test_fieldset_template( + fieldset = create_test_shared_fieldset( account=account, - template=template, ) api_client.token_authenticate(user=user) # act - response = api_client.get(f'/templates/fieldsets/{fieldset.id}') + response = api_client.get(f'/fieldsets/{fieldset.id}') # assert assert response.status_code == 403 @@ -368,21 +188,16 @@ def test_retrieve__non_admin__permission_denied(api_client): # arrange account = create_test_account() - owner = create_test_owner(account=account) - template = create_test_template( - user=owner, - tasks_count=1, - ) - fieldset = create_test_fieldset_template( + create_test_owner(account=account) + fieldset = create_test_shared_fieldset( account=account, - template=template, ) user = create_test_not_admin(account=account) api_client.token_authenticate(user=user) # act - response = api_client.get(f'/templates/fieldsets/{fieldset.id}') + response = api_client.get(f'/fieldsets/{fieldset.id}') # assert assert response.status_code == 403 @@ -399,7 +214,7 @@ def test_retrieve__not_existing__not_found(api_client): api_client.token_authenticate(user=user) # act - response = api_client.get(f'/templates/fieldsets/{nonexistent_id}') + response = api_client.get(f'/fieldsets/{nonexistent_id}') # assert assert response.status_code == 404 @@ -411,14 +226,9 @@ def test_retrieve__another_account__not_found(api_client): # arrange account_1 = create_test_account(name='Account 1') - owner_1 = create_test_owner(account=account_1) - template_1 = create_test_template( - user=owner_1, - tasks_count=1, - ) - fieldset = create_test_fieldset_template( + create_test_owner(account=account_1) + fieldset = create_test_shared_fieldset( account=account_1, - template=template_1, ) account_2 = create_test_account(name='Account 2') @@ -430,7 +240,7 @@ def test_retrieve__another_account__not_found(api_client): api_client.token_authenticate(user=user_2) # act - response = api_client.get(f'/templates/fieldsets/{fieldset.id}') + response = api_client.get(f'/fieldsets/{fieldset.id}') # assert assert response.status_code == 404 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 index 18ebad721..c3b1351f6 100644 --- 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 @@ -5,122 +5,182 @@ FieldSetRuleType, FieldType, LabelPosition, + OwnerRole, + OwnerType, + PerformerType, ) -from src.processes.models.templates.fieldset import ( - FieldsetTemplate, - FieldsetTemplateKickoff, - FieldsetTemplateRule, - FieldsetTemplateTaskTemplate, +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, ) -from src.processes.tests.fixtures import ( - create_test_account, - create_test_fieldset_template, - create_test_owner, - create_test_template, -) pytestmark = pytest.mark.django_db -def test_clone__fieldset_copied__ok(api_client): - - """Cloning a template copies its FieldsetTemplate - to the new template with correct attributes.""" +def test_clone__kickoff_and_task_fieldsets__ok(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) - fieldset = create_test_fieldset_template( + shared = create_test_shared_fieldset( account=account, - template=template, name='My Fieldset', description='Some description', - label_position=LabelPosition.LEFT, - layout=FieldSetLayout.HORIZONTAL, + 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') + response = api_client.post(f'/templates/{template_id}/clone') # assert assert response.status_code == 200 - new_template_id = response.data['id'] - assert new_template_id != template.id - - field_clones = FieldsetTemplate.objects.filter( - template_id=new_template_id, - ) - assert field_clones.count() == 1 - fieldset_clone = field_clones.first() - assert fieldset_clone.name == fieldset.name - assert fieldset_clone.api_name == fieldset.api_name - assert fieldset_clone.description == fieldset.description - assert fieldset_clone.label_position == fieldset.label_position - assert fieldset_clone.layout == fieldset.layout - assert fieldset_clone.account_id == account.id + 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 FieldTemplate records - belonging to the fieldset.""" + """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) - template = create_test_template(user=user, tasks_count=1) - fieldset = create_test_fieldset_template( - account=account, - template=template, - name='Fieldset with fields', - ) - field_1 = fieldset.fields.first() - # fixture creates one STRING field; add a second one - field_2 = FieldTemplate.objects.create( - template=template, - fieldset=fieldset, - account=account, - name='Second field', - type=FieldType.NUMBER, - order=2, - is_required=False, - is_hidden=True, + + 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') + response = api_client.post(f'/templates/{template_id}/clone') # assert assert response.status_code == 200 - new_template_id = response.data['id'] - fieldset_clone = FieldsetTemplate.objects.get(template_id=new_template_id) - field_clones = FieldTemplate.objects.filter( - fieldset=fieldset_clone, - ).order_by('order') - assert field_clones.count() == 2 - - field_1_clone = field_clones[0] - assert field_1_clone.name == field_1.name - assert field_1_clone.api_name == field_1.api_name - assert field_1_clone.type == field_1.type - assert field_1_clone.order == field_1.order - assert field_1_clone.template_id == new_template_id - assert field_1_clone.kickoff is None - assert field_1_clone.task is None - - field_2_clone = field_clones[1] - assert field_2_clone.name == field_2.name - assert field_2_clone.api_name == field_2.api_name - assert field_2_clone.type == field_2.type - assert field_2_clone.order == field_2.order - assert field_2_clone.is_hidden == field_2.is_hidden + 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): @@ -131,178 +191,223 @@ def test_clone__fieldset_with_selections__ok(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) - fieldset = create_test_fieldset_template( - account=account, - template=template, + shared = create_test_shared_fieldset( + account=user.account, name='Fieldset with dropdown', ) - # Replace the default STRING field with a DROPDOWN + selections - fieldset.fields.all().delete() + shared.fields.all().delete() field = FieldTemplate.objects.create( - template=template, - fieldset=fieldset, - account=account, + fieldset=shared, + account=user.account, name='Dropdown field', type=FieldType.DROPDOWN, order=1, ) - selection_1 = FieldTemplateSelection.objects.create( - template=template, + FieldTemplateSelection.objects.create( field_template=field, value='Option A', ) - selection_2 = FieldTemplateSelection.objects.create( - template=template, + 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') + response = api_client.post(f'/templates/{template_id}/clone') # assert assert response.status_code == 200 - new_template_id = response.data['id'] - fieldset_clone = FieldsetTemplate.objects.get(template_id=new_template_id) - field_clone = FieldTemplate.objects.get(fieldset=fieldset_clone) - selections_clone = FieldTemplateSelection.objects.filter( - field_template=field_clone, - ).order_by('value') - assert selections_clone.count() == 2 - assert selections_clone[0].value == selection_1.value - assert selections_clone[0].template_id == new_template_id - assert selections_clone[0].api_name == selection_1.api_name - - assert selections_clone[1].value == selection_2.value - assert selections_clone[1].template_id == new_template_id - assert selections_clone[1].api_name == selection_2.api_name + 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 FieldsetTemplateRule records - and preserves the rule-field M2M relationships.""" + """Cloning copies rules and preserves the rule-field relationships.""" # 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) - fieldset = create_test_fieldset_template( - account=account, - template=template, + shared = create_test_shared_fieldset( + account=user.account, name='Fieldset with rules', rule_type=FieldSetRuleType.SUM_EQUAL, rule_value='100', ) - # fixture creates a NUMBER field + rule; link them via M2M - field = fieldset.fields.first() - rule = fieldset.rules.first() + 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') + response = api_client.post(f'/templates/{template_id}/clone') # assert assert response.status_code == 200 - new_template_id = response.data['id'] - fieldset_clone = FieldsetTemplate.objects.get(template_id=new_template_id) - - rules_clone = FieldsetTemplateRule.objects.filter(fieldset=fieldset_clone) - assert rules_clone.count() == 1 - rule_clone = rules_clone.first() - assert rule_clone.type == rule.type - assert rule_clone.value == rule.value - assert rule_clone.id != rule.id - assert rule_clone.api_name == rule.api_name + 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 - field_clone = FieldTemplate.objects.get(fieldset=fieldset_clone) - assert list(field_clone.rules.all()) == [rule_clone] +def test_clone__kickoff_multiple_fieldsets__ok(api_client): -def test_clone__multiple_fieldsets__ok(api_client): - - """Cloning a template with multiple fieldsets - copies all of them.""" + """Cloning a template with multiple fieldsets copies all of them.""" # arrange - account = create_test_account() - user = create_test_owner(account=account) + user = create_test_owner() api_client.token_authenticate(user) - template = create_test_template(user=user, tasks_count=1) - fs_1 = create_test_fieldset_template( - account=account, - template=template, + + shared_1 = create_test_shared_fieldset( + account=user.account, name='Fieldset One', ) - fs_2 = create_test_fieldset_template( - account=account, - template=template, + shared_2 = create_test_shared_fieldset( + account=user.account, name='Fieldset Two', ) - # act - response = api_client.post(f'/templates/{template.id}/clone') - - # assert - assert response.status_code == 200 - new_template_id = response.data['id'] - fieldset_clones = FieldsetTemplate.objects.filter( - template_id=new_template_id, - ).order_by('name') - assert fieldset_clones.count() == 2 - assert fieldset_clones[0].name == fs_1.name - assert fieldset_clones[0].api_name == fs_1.api_name - assert fieldset_clones[0].fields.count() == 1 - - assert fieldset_clones[1].name == fs_2.name - assert fieldset_clones[1].api_name == fs_2.api_name - assert fieldset_clones[1].fields.count() == 1 - - -def test_clone__no_kickoff_task_links__ok(api_client): - - """Cloning does NOT create FieldsetTemplateKickoff - or FieldsetTemplateTaskTemplate records.""" - - # 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 - fieldset = create_test_fieldset_template( - account=account, - template=template, - kickoff=kickoff, - task=task, - name='Linked fieldset', + 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, + }, + ], + }, + ], + }, ) - # Verify original has links - assert FieldsetTemplateKickoff.objects.filter( - fieldset=fieldset, - ).exists() - assert FieldsetTemplateTaskTemplate.objects.filter( - fieldset=fieldset, - ).exists() + assert create_response.status_code == 200 + template_id = create_response.data['id'] # act - response = api_client.post(f'/templates/{template.id}/clone') + response = api_client.post(f'/templates/{template_id}/clone') # assert assert response.status_code == 200 - new_template_id = response.data['id'] - fieldset_clone = FieldsetTemplate.objects.get(template_id=new_template_id) - - assert not FieldsetTemplateKickoff.objects.filter( - fieldset=fieldset_clone, - ).exists() - assert not FieldsetTemplateTaskTemplate.objects.filter( - fieldset=fieldset_clone, - ).exists() + 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): @@ -311,68 +416,129 @@ def test_clone__no_fieldsets__ok(api_client): and creates no fieldsets on the clone.""" # arrange - account = create_test_account() - user = create_test_owner(account=account) + user = create_test_owner() api_client.token_authenticate(user) - template = create_test_template(user=user, tasks_count=1) + + 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') + response = api_client.post(f'/templates/{template_id}/clone') # assert assert response.status_code == 200 - new_template_id = response.data['id'] - assert not ( - FieldsetTemplate.objects.filter(template_id=new_template_id).exists() - ) + assert response.data['kickoff']['fieldsets'] == [] def test_clone__fieldset_rule_multi_fields__ok(api_client): - """Cloning preserves a rule linked to multiple fields - via M2M.""" + """Cloning preserves a rule linked to multiple fields.""" # 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) - fieldset = create_test_fieldset_template( + shared = create_test_shared_fieldset( account=account, - template=template, - name='Multi-field rule fieldset', rule_type=FieldSetRuleType.SUM_EQUAL, rule_value='200', ) - field_1 = fieldset.fields.first() - # fixture creates one NUMBER field; add a second one + 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( - template=template, - fieldset=fieldset, - account=account, + fieldset=shared, + account=user.account, name='Amount B', type=FieldType.NUMBER, order=2, ) - rule = fieldset.rules.first() - # Link both fields to the rule - for field in fieldset.fields.all(): - field.rules.add(rule) + 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') + response = api_client.post(f'/templates/{template_id}/clone') # assert assert response.status_code == 200 - new_template_id = response.data['id'] - fieldset_clone = FieldsetTemplate.objects.get(template_id=new_template_id) - - rule_clone = FieldsetTemplateRule.objects.get(fieldset=fieldset_clone) - assert rule_clone.value == rule.value - assert rule_clone.type == rule.type - assert rule_clone.api_name == rule.api_name - - rule_fields = rule_clone.fields.order_by('order') - assert rule_fields.count() == 2 - assert rule_fields[0].api_name == field_1.api_name - assert rule_fields[1].api_name == field_2.api_name + 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_template.py b/backend/src/processes/tests/test_views/test_templates/test_create/test_template.py index 21408dc43..f37a8052a 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 @@ -32,22 +32,20 @@ FieldTemplate, FieldTemplateSelection, ) -from src.processes.models.templates.fieldset import ( - FieldsetTemplateKickoff, - FieldsetTemplateTaskTemplate, -) from src.processes.models.templates.raw_performer import RawPerformerTemplate from src.processes.models.templates.task import TaskTemplate from src.processes.models.templates.template import Template 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, ) @@ -3842,7 +3840,7 @@ def test_create__kickoff_with_one_fieldset__ok( ): """ Creating a template with one fieldset linked to kickoff - calls create_or_update_kickoff_links with correct data. """ + creates FieldsetTemplate linked to the kickoff. """ # arrange account = create_test_account() @@ -3861,11 +3859,7 @@ def test_create__kickoff_with_one_fieldset__ok( 'src.processes.views.template.' 'AnalyticService.templates_kickoff_created', ) - service_mock = mocker.patch( - 'src.processes.serializers.templates.template.' - 'FieldSetTemplateService.create_or_update_kickoff_links', - ) - fieldset_api_name = 'fieldset-test-1' + shared = create_test_shared_fieldset(account=account) request_data = { 'name': 'Template with fieldset', 'owners': [ @@ -3879,7 +3873,7 @@ def test_create__kickoff_with_one_fieldset__ok( 'kickoff': { 'fieldsets': [ { - 'api_name': fieldset_api_name, + 'shared_fieldset_id': shared.id, 'order': 1, }, ], @@ -3909,13 +3903,15 @@ def test_create__kickoff_with_one_fieldset__ok( template = Template.objects.get(id=response.data['id']) kickoff = template.kickoff_instance assert kickoff is not None - service_mock.assert_called_once_with( + fieldset = FieldsetTemplate.objects.get( kickoff=kickoff, - template=template, - fieldsets_links=[ - {'api_name': fieldset_api_name, 'order': 1}, - ], + shared_fieldset=shared, ) + assert fieldset.order == 1 + assert fieldset.name == shared.name + kickoff_data = response.data['kickoff'] + assert len(kickoff_data['fieldsets']) == 1 + assert kickoff_data['fieldsets'][0]['shared_fieldset_id'] == shared.id def test_create__kickoff_with_empty_fieldsets__no_links_created( @@ -3980,7 +3976,7 @@ def test_create__kickoff_with_empty_fieldsets__no_links_created( assert response.status_code == 200 template = Template.objects.get(id=response.data['id']) kickoff = template.kickoff_instance - assert FieldsetTemplateKickoff.objects.filter( + assert FieldsetTemplate.objects.filter( kickoff=kickoff, ).count() == 0 @@ -4045,7 +4041,7 @@ def test_create__kickoff_without_fieldsets_key__no_links_created( assert response.status_code == 200 template = Template.objects.get(id=response.data['id']) kickoff = template.kickoff_instance - assert FieldsetTemplateKickoff.objects.filter( + assert FieldsetTemplate.objects.filter( kickoff=kickoff, ).count() == 0 @@ -4056,7 +4052,7 @@ def test_create__task_with_one_fieldset__ok( ): """ Creating a template with one fieldset linked to a task - calls create_or_update_tasks_links with correct data. """ + creates FieldsetTemplate linked to the task. """ # arrange account = create_test_account() @@ -4071,11 +4067,7 @@ def test_create__task_with_one_fieldset__ok( 'src.processes.views.template.' 'AnalyticService.templates_kickoff_created', ) - service_mock = mocker.patch( - 'src.processes.serializers.templates.task.' - 'FieldSetTemplateService.create_or_update_tasks_links', - ) - fieldset_api_name = 'fieldset-task-1' + shared = create_test_shared_fieldset(account=account) request_data = { 'name': 'Template with task fieldset', 'owners': [ @@ -4099,7 +4091,7 @@ def test_create__task_with_one_fieldset__ok( ], 'fieldsets': [ { - 'api_name': fieldset_api_name, + 'shared_fieldset_id': shared.id, 'order': 1, }, ], @@ -4118,13 +4110,15 @@ def test_create__task_with_one_fieldset__ok( template = Template.objects.get(id=response.data['id']) task = template.tasks.first() assert task is not None - service_mock.assert_called_once_with( + fieldset = FieldsetTemplate.objects.get( task=task, - template=template, - fieldsets_links=[ - {'api_name': fieldset_api_name, 'order': 1}, - ], + shared_fieldset=shared, ) + assert fieldset.order == 1 + assert fieldset.name == shared.name + task_data = response.data['tasks'][0] + assert len(task_data['fieldsets']) == 1 + assert task_data['fieldsets'][0]['shared_fieldset_id'] == shared.id def test_create__task_with_empty_fieldsets__no_links_created( @@ -4132,9 +4126,6 @@ def test_create__task_with_empty_fieldsets__no_links_created( api_client, ): - """ Creating a template with empty fieldsets list in task does not - create any FieldsetTemplateTaskTemplate records. """ - # arrange account = create_test_account() user = create_test_user(account=account) @@ -4184,7 +4175,7 @@ def test_create__task_with_empty_fieldsets__no_links_created( assert response.status_code == 200 template = Template.objects.get(id=response.data['id']) task = template.tasks.first() - assert FieldsetTemplateTaskTemplate.objects.filter( + assert FieldsetTemplate.objects.filter( task=task, ).count() == 0 @@ -4245,6 +4236,6 @@ def test_create__task_without_fieldsets_key__no_links_created( assert response.status_code == 200 template = Template.objects.get(id=response.data['id']) task = template.tasks.first() - assert FieldsetTemplateTaskTemplate.objects.filter( + 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 880aebc4e..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 @@ -217,9 +217,6 @@ def test_export__fieldsets__ok(api_client): account=account, template=template, kickoff=kickoff, - name='Kickoff Fieldset', - description='Kickoff fieldset desc', - api_name='fieldset-kickoff-1', order=0, ) @@ -227,9 +224,6 @@ def test_export__fieldsets__ok(api_client): account=account, template=template, task=task, - name='Task Fieldset', - description='Task fieldset desc', - api_name='fieldset-task-1', order=1, ) 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 0594923df..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 @@ -15,9 +15,6 @@ ) from src.processes.models.templates.fields import FieldTemplate from src.processes.models.templates.fields import FieldTemplateSelection -from src.processes.models.templates.fieldset import ( - FieldsetTemplateKickoff, -) from src.processes.models.templates.owner import TemplateOwner from src.processes.models.templates.template import Template from src.processes.tests.fixtures import ( @@ -27,6 +24,7 @@ create_test_fieldset_template, create_test_group, create_test_owner, + create_test_shared_fieldset, create_test_template, create_test_user, create_test_workflow, @@ -1447,19 +1445,19 @@ def test_list__kickoff_fieldset__ok(api_client): 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, - name='Personal Info', - description='Enter your personal information', + shared_fieldset=shared, api_name='fieldset-personal', order=5, ) - fieldset_link = FieldsetTemplateKickoff.objects.get( - fieldset=fieldset, - kickoff=kickoff, - ) fieldset_field = fieldset.fields.first() api_client.token_authenticate(user) @@ -1471,7 +1469,8 @@ def test_list__kickoff_fieldset__ok(api_client): fieldsets = response.data[0]['kickoff']['fieldsets'] assert len(fieldsets) == 1 fieldset_data = fieldsets[0] - assert fieldset_data['order'] == fieldset_link.order + 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 @@ -1519,30 +1518,30 @@ def test_list__kickoff_multiple_fieldsets_ordered(api_client): 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, - name='Second Fieldset', + shared_fieldset=shared_2, api_name='fieldset-second', order=2, ) - link_2 = FieldsetTemplateKickoff.objects.get( - fieldset=fieldset_2, - kickoff=kickoff, + 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, - name='First Fieldset', + shared_fieldset=shared_1, api_name='fieldset-first', order=1, ) - link_1 = FieldsetTemplateKickoff.objects.get( - fieldset=fieldset_1, - kickoff=kickoff, - ) api_client.token_authenticate(user) # act @@ -1552,7 +1551,7 @@ def test_list__kickoff_multiple_fieldsets_ordered(api_client): assert response.status_code == 200 fieldsets = response.data[0]['kickoff']['fieldsets'] assert len(fieldsets) == 2 - assert fieldsets[0]['order'] == link_1.order + assert fieldsets[0]['order'] == fieldset_1.order assert fieldsets[0]['api_name'] == fieldset_1.api_name - assert fieldsets[1]['order'] == link_2.order + 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 ce3f58a08..7c33945a6 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,19 +5,16 @@ PublicToken, ) from src.processes.enums import ( - FieldType, + FieldType, FieldSetRuleType, ) from src.processes.models.templates.fields import FieldTemplate, \ FieldTemplateSelection -from src.processes.models.templates.fieldset import ( - FieldsetTemplateKickoff, -) from src.processes.tests.fixtures import ( create_test_template, create_test_fieldset_template, create_test_owner, create_test_dataset, - create_test_account, + create_test_account, create_test_shared_fieldset, ) pytestmark = pytest.mark.django_db @@ -445,15 +442,10 @@ def test_retrieve__kickoff_fieldset__ok( account=account, template=template, kickoff=kickoff, - name='Personal Info', description='Enter info', api_name='fieldset-personal', order=5, ) - fieldset_link = FieldsetTemplateKickoff.objects.get( - fieldset=fieldset, - kickoff=kickoff, - ) fieldset_field = fieldset.fields.first() auth_header_value = ( f'Token {template.public_id}' @@ -486,7 +478,7 @@ def test_retrieve__kickoff_fieldset__ok( fieldsets = response.data['kickoff']['fieldsets'] assert len(fieldsets) == 1 fieldset_data = fieldsets[0] - assert fieldset_data['order'] == fieldset_link.order + 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 @@ -573,26 +565,14 @@ def test_retrieve__kickoff_fieldsets_ordered( account=account, template=template, kickoff=kickoff, - name='Second Fieldset', - api_name='fieldset-second', order=2, ) - link_2 = FieldsetTemplateKickoff.objects.get( - fieldset=fieldset_2, - kickoff=kickoff, - ) fieldset_1 = create_test_fieldset_template( account=account, template=template, kickoff=kickoff, - name='First Fieldset', - api_name='fieldset-first', order=1, ) - link_1 = FieldsetTemplateKickoff.objects.get( - fieldset=fieldset_1, - kickoff=kickoff, - ) auth_header_value = ( f'Token {template.public_id}' ) @@ -623,9 +603,9 @@ def test_retrieve__kickoff_fieldsets_ordered( assert response.status_code == 200 fieldsets = response.data['kickoff']['fieldsets'] assert len(fieldsets) == 2 - assert fieldsets[0]['order'] == link_1.order + assert fieldsets[0]['order'] == fieldset_1.order assert fieldsets[0]['api_name'] == fieldset_1.api_name - assert fieldsets[1]['order'] == link_2.order + 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) @@ -866,20 +846,21 @@ def test_retrieve__kickoff_fieldset__ok( 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, - name='Personal Info', - description='Enter info', api_name='fieldset-personal', order=5, ) - fieldset_link = FieldsetTemplateKickoff.objects.get( - fieldset=fieldset, - kickoff=kickoff, - ) fieldset_field = fieldset.fields.first() + rule = fieldset.rules.first() + rule.fields.add(fieldset_field) auth_header_value = ( f'Token {template.embed_id}' ) @@ -911,12 +892,14 @@ def test_retrieve__kickoff_fieldset__ok( fieldsets = response.data['kickoff']['fieldsets'] assert len(fieldsets) == 1 fieldset_data = fieldsets[0] - assert fieldset_data['order'] == fieldset_link.order + 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 @@ -998,26 +981,16 @@ def test_retrieve__kickoff_fieldsets_ordered( account=account, template=template, kickoff=kickoff, - name='Second Fieldset', api_name='fieldset-second', order=2, ) - link_2 = FieldsetTemplateKickoff.objects.get( - fieldset=fieldset_2, - kickoff=kickoff, - ) fieldset_1 = create_test_fieldset_template( account=account, template=template, kickoff=kickoff, - name='First Fieldset', api_name='fieldset-first', order=1, ) - link_1 = FieldsetTemplateKickoff.objects.get( - fieldset=fieldset_1, - kickoff=kickoff, - ) auth_header_value = ( f'Token {template.embed_id}' ) @@ -1048,9 +1021,9 @@ def test_retrieve__kickoff_fieldsets_ordered( assert response.status_code == 200 fieldsets = response.data['kickoff']['fieldsets'] assert len(fieldsets) == 2 - assert fieldsets[0]['order'] == link_1.order + assert fieldsets[0]['order'] == fieldset_1.order assert fieldsets[0]['api_name'] == fieldset_1.api_name - assert fieldsets[1]['order'] == link_2.order + 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 fa77d20e4..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 @@ -783,7 +783,6 @@ def test_retrieve__fieldsets__ok(api_client): account=account, template=template, kickoff=kickoff, - name='Kickoff Fieldset', description='Kickoff fieldset desc', api_name='fieldset-kickoff-1', order=0, @@ -793,7 +792,6 @@ def test_retrieve__fieldsets__ok(api_client): account=account, template=template, task=task, - name='Task Fieldset', description='Task fieldset desc', api_name='fieldset-task-1', 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 dd542d6f6..71f947497 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 @@ -42,10 +42,6 @@ PredicateTemplate, RuleTemplate, ) -from src.processes.models.templates.fieldset import ( - FieldsetTemplateKickoff, - FieldsetTemplateTaskTemplate, -) from src.processes.models.templates.fields import ( FieldTemplate, FieldTemplateSelection, @@ -5210,19 +5206,15 @@ def test_run__kickoff_with_one_fieldset__ok(mocker, api_client): account=user.account, template=template, kickoff=template.kickoff_instance, - name='Personal info', - order=0, + order=11, ) - FieldsetTemplateKickoff.objects.filter( - fieldset=fieldset_template, - ).update(order=11) field_template = fieldset_template.fields.first() field_value = 'test value' - wf_run_mock = mocker.patch( + mocker.patch( 'src.processes.services.workflow_action.' 'WorkflowEventService.workflow_run_event', ) - analytics_mock = mocker.patch( + mocker.patch( 'src.analysis.services.AnalyticService.' 'workflows_started', ) @@ -5239,8 +5231,6 @@ def test_run__kickoff_with_one_fieldset__ok(mocker, api_client): ) # 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 = KickoffValue.objects.get(workflow=workflow) @@ -5290,29 +5280,30 @@ def test_run__kickoff_with_multiple_fieldsets__ok(mocker, api_client): is_active=True, tasks_count=1, ) + kickoff = template.kickoff_instance fieldset_1 = create_test_fieldset_template( account=user.account, template=template, - kickoff=template.kickoff_instance, - name='First fieldset', + kickoff=kickoff, + title='First fieldset', order=0, ) fieldset_2 = create_test_fieldset_template( account=user.account, template=template, - kickoff=template.kickoff_instance, - name='Second fieldset', + kickoff=kickoff, + title='Second fieldset', order=1, ) field_1 = fieldset_1.fields.first() field_2 = fieldset_2.fields.first() field_value_1 = 'value 1' field_value_2 = 'value 2' - wf_run_mock = mocker.patch( + mocker.patch( 'src.processes.services.workflow_action.' 'WorkflowEventService.workflow_run_event', ) - analytics_mock = mocker.patch( + mocker.patch( 'src.analysis.services.AnalyticService.' 'workflows_started', ) @@ -5330,8 +5321,6 @@ def test_run__kickoff_with_multiple_fieldsets__ok(mocker, api_client): ) # 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 = KickoffValue.objects.get( @@ -5340,9 +5329,9 @@ def test_run__kickoff_with_multiple_fieldsets__ok(mocker, api_client): assert kickoff_value.fieldsets.count() == 2 fieldsets_data = response.data['kickoff']['fieldsets'] assert len(fieldsets_data) == 2 - assert fieldsets_data[0]['name'] == fieldset_2.name + assert fieldsets_data[0]['title'] == fieldset_2.title assert fieldsets_data[0]['order'] == 1 - assert fieldsets_data[1]['name'] == fieldset_1.name + assert fieldsets_data[1]['title'] == fieldset_1.title assert fieldsets_data[1]['order'] == 0 @@ -5365,7 +5354,6 @@ def test_run__kickoff_fieldset_and_standalone__ok( account=user.account, template=template, kickoff=template.kickoff_instance, - name='Grouped fields', order=0, ) field_template_1 = fieldset_template.fields.first() @@ -5441,7 +5429,6 @@ def test_run__kickoff_fieldset_sum_equal__ok( account=user.account, template=template, kickoff=template.kickoff_instance, - name='Budget split', order=0, rule_type=FieldSetRuleType.SUM_EQUAL, rule_value='100', @@ -5514,7 +5501,6 @@ def test_run__kickoff_fieldset_sum_equal__validation_error( account=user.account, template=template, kickoff=template.kickoff_instance, - name='Budget split', order=0, rule_type=FieldSetRuleType.SUM_EQUAL, rule_value='100', @@ -5561,228 +5547,3 @@ def test_run__kickoff_fieldset_sum_equal__validation_error( assert response.data['message'] == MSG_FS_0002('100') wf_run_mock.assert_not_called() analytics_mock.assert_not_called() - - -def test_run__kickoff_fieldset_required_empty__validation_error( - mocker, - api_client, -): - - """ Required fieldset field returns 400 when empty. """ - - # 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, - name='Required fieldset', - order=0, - ) - field_template = fieldset_template.fields.first() - field_template.is_required = True - field_template.save(update_fields=['is_required']) - 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': {}, - }, - ) - - # assert - wf_run_mock.assert_not_called() - analytics_mock.assert_not_called() - assert response.status_code == 400 - assert response.data['code'] == ErrorCode.VALIDATION_ERROR - assert response.data['message'] == messages.MSG_PW_0023 - assert response.data['details']['api_name'] == field_template.api_name - - -def test_run__kickoff_soft_deleted_fieldset_through__ok( - mocker, - api_client, -): - - """ Soft-deleted FieldsetTemplateKickoff is skipped. """ - - # 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, - name='Deleted fieldset', - order=0, - ) - FieldsetTemplateKickoff.objects.filter( - fieldset=fieldset_template, - kickoff=template.kickoff_instance, - ).delete() - 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': {}, - }, - ) - - # 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() == 0 - assert response.data['kickoff']['fieldsets'] == [] - - -def test_run__kickoff_deleted_fieldset_among_active__ok( - mocker, - api_client, -): - - """ Only active FieldsetTemplateKickoff records produce FieldSets. """ - - # arrange - account = create_test_account() - user = create_test_owner(account=account) - template = create_test_template( - user=user, - is_active=True, - tasks_count=1, - ) - fieldset_deleted = create_test_fieldset_template( - account=user.account, - template=template, - kickoff=template.kickoff_instance, - name='Deleted fieldset', - order=0, - ) - fieldset_active = create_test_fieldset_template( - account=user.account, - template=template, - kickoff=template.kickoff_instance, - name='Active fieldset', - order=1, - ) - FieldsetTemplateKickoff.objects.filter( - fieldset=fieldset_deleted, - kickoff=template.kickoff_instance, - ).delete() - field_template = fieldset_active.fields.first() - field_value = 'test value' - 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_active.name - assert fieldset.api_name == fieldset_active.api_name - fieldsets_data = response.data['kickoff']['fieldsets'] - assert len(fieldsets_data) == 1 - assert fieldsets_data[0]['name'] == fieldset_active.name - assert fieldsets_data[0]['order'] == 1 - - -def test_run__task_soft_deleted_fieldset_through__ok( - mocker, - api_client, -): - - """ Soft-deleted FieldsetTemplateTaskTemplate is skipped. """ - - # arrange - account = create_test_account() - user = create_test_owner(account=account) - template = create_test_template( - user=user, - is_active=True, - tasks_count=1, - ) - task_template = template.tasks.first() - fieldset_template = create_test_fieldset_template( - account=user.account, - template=template, - task=task_template, - name='Deleted task fieldset', - order=0, - ) - FieldsetTemplateTaskTemplate.objects.filter( - fieldset=fieldset_template, - task=task_template, - ).delete() - 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': {}, - }, - ) - - # assert - assert response.status_code == 200 - workflow = Workflow.objects.get(id=response.data['id']) - task = workflow.tasks.first() - assert task.fieldsets.count() == 0 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 88da68ed6..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,11 +1,11 @@ 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.models.templates.fieldset import ( - FieldsetTemplateKickoff, +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 ( @@ -464,16 +464,16 @@ def test_titles_by_owners__kickoff_fieldset__ok(api_client): account=user.account, template=template, kickoff=kickoff, - name='Personal Info', + title='Personal', description='Enter your personal information', api_name='fieldset-personal', order=5, - ) - fieldset_link = FieldsetTemplateKickoff.objects.get( - fieldset=fieldset, - kickoff=kickoff, + 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 @@ -484,17 +484,30 @@ def test_titles_by_owners__kickoff_fieldset__ok(api_client): fieldsets = response.data[0]['kickoff']['fieldsets'] assert len(fieldsets) == 1 fieldset_data = fieldsets[0] - assert fieldset_data['order'] == fieldset_link.order + 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 @@ -540,26 +553,16 @@ def test_titles_by_owners__kickoff_fieldsets_ordered( account=user.account, template=template, kickoff=kickoff, - name='Second Fieldset', api_name='fieldset-second', order=2, ) - link_2 = FieldsetTemplateKickoff.objects.get( - fieldset=fieldset_2, - kickoff=kickoff, - ) fieldset_1 = create_test_fieldset_template( account=user.account, template=template, kickoff=kickoff, - name='First Fieldset', api_name='fieldset-first', order=1, ) - link_1 = FieldsetTemplateKickoff.objects.get( - fieldset=fieldset_1, - kickoff=kickoff, - ) api_client.token_authenticate(user) # act @@ -569,7 +572,7 @@ def test_titles_by_owners__kickoff_fieldsets_ordered( assert response.status_code == 200 fieldsets = response.data[0]['kickoff']['fieldsets'] assert len(fieldsets) == 2 - assert fieldsets[0]['order'] == link_1.order + assert fieldsets[0]['order'] == fieldset_1.order assert fieldsets[0]['api_name'] == fieldset_1.api_name - assert fieldsets[1]['order'] == link_2.order + 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 index d5aff43b1..8e4a8b0ca 100644 --- 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 @@ -3,16 +3,13 @@ from src.processes.enums import ( OwnerRole, OwnerType, - PerformerType, -) -from src.processes.models.templates.fieldset import ( - FieldsetTemplateKickoff, - FieldsetTemplateTaskTemplate, + PerformerType, FieldSetRuleType, ) +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, ) @@ -21,29 +18,25 @@ # Kickoff fieldsets -def test_update__kickoff_with_one_fieldset__ok( +def test_update__kickoff_create_fieldset__ok( mocker, api_client, ): - """ Updating a template with one fieldset linked to kickoff - creates a FieldsetTemplateKickoff record. """ - # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( - user, - is_active=True, - tasks_count=1, - ) + template = create_test_template(user, is_active=True, tasks_count=1) kickoff = template.kickoff_instance task = template.tasks.first() - fieldset = create_test_fieldset_template( + shared = create_test_shared_fieldset( account=account, - template=template, - api_name='fieldset-update-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', ) + field = shared.fields.first() + rule = shared.rules.first() + field.rules.add(rule) mocker.patch( 'src.processes.services.templates.' 'integrations.TemplateIntegrationsService.' @@ -54,72 +47,88 @@ def test_update__kickoff_with_one_fieldset__ok( 'integrations.TemplateIntegrationsService.template_updated', ) mocker.patch( - 'src.processes.views.template.' - 'AnalyticService.templates_updated', + 'src.processes.views.template.AnalyticService.templates_updated', ) mocker.patch( 'src.processes.views.template.' 'AnalyticService.templates_kickoff_updated', ) - request_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': [ + 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': [ { - 'api_name': fieldset.api_name, - 'order': 3, + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, }, ], - }, - 'tasks': [ - { - 'id': task.id, - 'api_name': task.api_name, - 'number': task.number, - 'name': task.name, - 'raw_performers': [ + 'kickoff': { + 'id': kickoff.id, + 'fieldsets': [ { - 'type': PerformerType.USER, - 'source_id': user.id, + 'shared_fieldset_id': shared.id, + 'order': 3, }, ], }, - ], - } - api_client.token_authenticate(user) - - # act - response = api_client.put( - path=f'/templates/{template.id}', - data=request_data, + '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'] == { - 'fields': [], - 'fieldsets': [ - { - 'api_name': fieldset.api_name, - 'order': 3, - }, - ], - } - assert FieldsetTemplateKickoff.objects.get( - kickoff=kickoff, - fieldset=fieldset, + fieldsets = response.data['kickoff']['fieldsets'] + assert len(fieldsets) == 1 + fieldset_data = fieldsets[0] + assert fieldset_data['shared_fieldset_id'] == shared.id + assert fieldset_data['order'] == 3 + assert fieldset_data['name'] == shared.name + assert fieldset_data['title'] == shared.title + assert fieldset_data['description'] == shared.description + assert fieldset_data['label_position'] == shared.label_position + assert fieldset_data['layout'] == shared.layout + assert fieldset_data['api_name'] + assert fieldset_data['api_name'] != shared.api_name + assert len(fieldset_data['fields']) == 1 + field_data = fieldset_data['fields'][0] + assert field_data['name'] == field.name + assert field_data['description'] == '' + 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['order'] == field.order + assert field_data['default'] == field.default + assert field_data['api_name'] + assert len(fieldset_data['rules']) == 1 + rule_data = fieldset_data['rules'][0] + assert rule_data['type'] == FieldSetRuleType.SUM_EQUAL + assert rule_data['value'] == '100' + assert rule_data['api_name'] + assert rule_data['fields'] == [field_data['api_name']] + assert kickoff.fieldsets.filter( + shared_fieldset_id=shared.id, order=3, - ) + ).exists() def test_update__kickoff_create_two_fieldsets__ok( @@ -128,28 +137,16 @@ def test_update__kickoff_create_two_fieldsets__ok( ): """ Updating a template with multiple fieldsets linked to - kickoff creates multiple FieldsetTemplateKickoff records. """ + 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, - ) + template = create_test_template(user, is_active=True, tasks_count=1) kickoff = template.kickoff_instance task = template.tasks.first() - fieldset_1 = create_test_fieldset_template( - account=account, - template=template, - api_name='fieldset-x', - ) - fieldset_2 = create_test_fieldset_template( - account=account, - template=template, - api_name='fieldset-y', - ) + shared_1 = create_test_shared_fieldset(account=account) + shared_2 = create_test_shared_fieldset(account=account) mocker.patch( 'src.processes.services.templates.' 'integrations.TemplateIntegrationsService.' @@ -160,83 +157,68 @@ def test_update__kickoff_create_two_fieldsets__ok( 'integrations.TemplateIntegrationsService.template_updated', ) mocker.patch( - 'src.processes.views.template.' - 'AnalyticService.templates_updated', + 'src.processes.views.template.AnalyticService.templates_updated', ) mocker.patch( 'src.processes.views.template.' 'AnalyticService.templates_kickoff_updated', ) - request_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': [ - { - 'api_name': fieldset_1.api_name, - 'order': 0, - }, + 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': [ { - 'api_name': fieldset_2.api_name, - 'order': 1, + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, }, ], - }, - 'tasks': [ - { - 'id': task.id, - 'api_name': task.api_name, - 'number': task.number, - 'name': task.name, - 'raw_performers': [ + 'kickoff': { + 'id': kickoff.id, + 'fieldsets': [ + { + 'shared_fieldset_id': shared_1.id, + 'order': 0, + }, { - 'type': PerformerType.USER, - 'source_id': user.id, + 'shared_fieldset_id': shared_2.id, + 'order': 1, }, ], }, - ], - } - api_client.token_authenticate(user) - - # act - response = api_client.put( - path=f'/templates/{template.id}', - data=request_data, + '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'] == { - 'fields': [], - 'fieldsets': [ - { - 'api_name': fieldset_1.api_name, - 'order': 0, - }, - { - 'api_name': fieldset_2.api_name, - 'order': 1, - }, - ], - } - assert FieldsetTemplateKickoff.objects.filter( - fieldset=fieldset_1, - kickoff=kickoff, + fieldsets = response.data['kickoff']['fieldsets'] + assert len(fieldsets) == 2 + assert kickoff.fieldsets.filter( + shared_fieldset_id=shared_1.id, order=0, ).count() == 1 - assert FieldsetTemplateKickoff.objects.filter( - fieldset=fieldset_2, - kickoff=kickoff, + assert kickoff.fieldsets.filter( + shared_fieldset_id=shared_2.id, order=1, ).count() == 1 @@ -249,21 +231,20 @@ def test_update__kickoff_replace_fieldset__ok( # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( - user, - is_active=True, - tasks_count=1, - ) + template = create_test_template(user, is_active=True, tasks_count=1) kickoff = template.kickoff_instance task = template.tasks.first() - fieldset_1 = create_test_fieldset_template( + shared_1 = create_test_shared_fieldset(account=account) + shared_2 = create_test_shared_fieldset(account=account) + # create an existing child fieldset linked to kickoff from shared_1 + existing = FieldsetTemplate.objects.create( account=account, template=template, kickoff=kickoff, - ) - fieldset_2 = create_test_fieldset_template( - account=account, - template=template, + name=shared_1.name, + shared_fieldset_id=shared_1.id, + is_shared=True, + order=0, ) mocker.patch( 'src.processes.services.templates.' @@ -275,74 +256,63 @@ def test_update__kickoff_replace_fieldset__ok( 'integrations.TemplateIntegrationsService.template_updated', ) mocker.patch( - 'src.processes.views.template.' - 'AnalyticService.templates_updated', + 'src.processes.views.template.AnalyticService.templates_updated', ) mocker.patch( 'src.processes.views.template.' 'AnalyticService.templates_kickoff_updated', ) - request_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': [ + 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': [ { - 'api_name': fieldset_2.api_name, - 'order': 2, + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, }, ], - }, - 'tasks': [ - { - 'id': task.id, - 'api_name': task.api_name, - 'number': task.number, - 'name': task.name, - 'raw_performers': [ + 'kickoff': { + 'id': kickoff.id, + 'fieldsets': [ { - 'type': PerformerType.USER, - 'source_id': user.id, + 'shared_fieldset_id': shared_2.id, + 'order': 2, }, ], }, - ], - } - api_client.token_authenticate(user) - - # act - response = api_client.put( - path=f'/templates/{template.id}', - data=request_data, + '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'] == { - 'fields': [], - 'fieldsets': [ - { - 'api_name': fieldset_2.api_name, - 'order': 2, - }, - ], - } - assert FieldsetTemplateKickoff.objects.filter( - fieldset=fieldset_1, - kickoff=kickoff, - ).count() == 0 - assert FieldsetTemplateKickoff.objects.filter( - fieldset=fieldset_2, - kickoff=kickoff, + 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=existing.id).exists() + assert kickoff.fieldsets.filter( + shared_fieldset_id=shared_2.id, order=2, ).count() == 1 @@ -355,17 +325,18 @@ def test_update__kickoff_remove_fieldset__ok( # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( - user, - is_active=True, - tasks_count=1, - ) + template = create_test_template(user, is_active=True, tasks_count=1) kickoff = template.kickoff_instance task = template.tasks.first() - fieldset = create_test_fieldset_template( + shared = create_test_shared_fieldset(account=account) + existing = FieldsetTemplate.objects.create( account=account, template=template, kickoff=kickoff, + name=shared.name, + shared_fieldset_id=shared.id, + is_shared=True, + order=0, ) mocker.patch( 'src.processes.services.templates.' @@ -377,62 +348,53 @@ def test_update__kickoff_remove_fieldset__ok( 'integrations.TemplateIntegrationsService.template_updated', ) mocker.patch( - 'src.processes.views.template.' - 'AnalyticService.templates_updated', + 'src.processes.views.template.AnalyticService.templates_updated', ) mocker.patch( 'src.processes.views.template.' 'AnalyticService.templates_kickoff_updated', ) - request_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, - }, - ], - }, - ], - } api_client.token_authenticate(user) # act response = api_client.put( path=f'/templates/{template.id}', - data=request_data, + 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'] == { - 'fields': [], - 'fieldsets': [], - } - fieldset.refresh_from_db() - assert FieldsetTemplateKickoff.objects.filter( - fieldset=fieldset, - kickoff=kickoff, - ).count() == 0 + assert response.data['kickoff']['fieldsets'] == [] + assert not kickoff.fieldsets.filter(id=existing.id).exists() def test_update__kickoff_skip_fieldsets__no_fieldsets_created( @@ -441,16 +403,12 @@ def test_update__kickoff_skip_fieldsets__no_fieldsets_created( ): """ Updating a template without fieldsets key in kickoff does not - create any FieldsetTemplateKickoff records. """ + create any kickoff fieldset records. """ # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( - user, - is_active=True, - tasks_count=1, - ) + template = create_test_template(user, is_active=True, tasks_count=1) kickoff = template.kickoff_instance task = template.tasks.first() mocker.patch( @@ -463,83 +421,75 @@ def test_update__kickoff_skip_fieldsets__no_fieldsets_created( 'integrations.TemplateIntegrationsService.template_updated', ) mocker.patch( - 'src.processes.views.template.' - 'AnalyticService.templates_updated', + 'src.processes.views.template.AnalyticService.templates_updated', ) mocker.patch( 'src.processes.views.template.' 'AnalyticService.templates_kickoff_updated', ) - request_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, - }, - ], - }, - ], - } api_client.token_authenticate(user) # act response = api_client.put( path=f'/templates/{template.id}', - data=request_data, + 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, + }, + ], + }, + ], + }, ) # assert assert response.status_code == 200 - assert FieldsetTemplateKickoff.objects.filter( - kickoff=kickoff, - ).count() == 0 + assert kickoff.fieldsets.count() == 0 # Task fieldsets -def test_update__task_with_one_fieldset__ok( +def test_update__task_create_fieldset__ok( mocker, api_client, ): - """ Updating a template with one fieldset linked to a task - creates a FieldsetTemplateTaskTemplate record. """ - # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( - user, - is_active=True, - tasks_count=1, - ) + template = create_test_template(user, is_active=True, tasks_count=1) kickoff = template.kickoff_instance task = template.tasks.first() - fieldset = create_test_fieldset_template( + shared = create_test_shared_fieldset( account=account, - template=template, - api_name='fieldset-task-update-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='200', ) + field = shared.fields.first() + rule = shared.rules.first() + field.rules.add(rule) mocker.patch( 'src.processes.services.templates.' 'integrations.TemplateIntegrationsService.' @@ -550,69 +500,81 @@ def test_update__task_with_one_fieldset__ok( 'integrations.TemplateIntegrationsService.template_updated', ) mocker.patch( - 'src.processes.views.template.' - 'AnalyticService.templates_updated', + 'src.processes.views.template.AnalyticService.templates_updated', ) mocker.patch( 'src.processes.views.template.' 'AnalyticService.templates_kickoff_updated', ) - request_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': [ - { - 'api_name': fieldset.api_name, - 'order': 2, - }, - ], - }, - ], - } api_client.token_authenticate(user) # act response = api_client.put( path=f'/templates/{template.id}', - data=request_data, + 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.id, + 'order': 2, + }, + ], + }, + ], + }, ) # assert assert response.status_code == 200 - assert response.data['tasks'][0]['fieldsets'] == [ - { - 'api_name': fieldset.api_name, - 'order': 2, - }, - ] - assert FieldsetTemplateTaskTemplate.objects.get( - task=task, - fieldset=fieldset, + fieldsets = response.data['tasks'][0]['fieldsets'] + assert len(fieldsets) == 1 + fieldset_data = fieldsets[0] + assert fieldset_data['shared_fieldset_id'] == shared.id + assert fieldset_data['order'] == 2 + assert len(fieldset_data['fields']) == 1 + field_data = fieldset_data['fields'][0] + assert field_data['name'] == field.name + assert field_data['description'] == '' + 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['order'] == field.order + assert field_data['default'] == field.default + assert field_data['api_name'] + assert len(fieldset_data['rules']) == 1 + rule_data = fieldset_data['rules'][0] + assert rule_data['type'] == FieldSetRuleType.SUM_EQUAL + assert rule_data['value'] == '200' + assert rule_data['api_name'] + assert rule_data['fields'] == [field_data['api_name']] + assert task.fieldsets.filter( + shared_fieldset_id=shared.id, order=2, - ) + ).exists() def test_update__task_create_two_fieldsets__ok( @@ -621,28 +583,16 @@ def test_update__task_create_two_fieldsets__ok( ): """ Updating a template with multiple fieldsets linked to a task - creates multiple FieldsetTemplateTaskTemplate records. """ + 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, - ) + template = create_test_template(user, is_active=True, tasks_count=1) task = template.tasks.first() kickoff = template.kickoff_instance - fieldset_1 = create_test_fieldset_template( - account=account, - template=template, - api_name='fieldset-x', - ) - fieldset_2 = create_test_fieldset_template( - account=account, - template=template, - api_name='fieldset-y', - ) + shared_1 = create_test_shared_fieldset(account=account) + shared_2 = create_test_shared_fieldset(account=account) mocker.patch( 'src.processes.services.templates.' 'integrations.TemplateIntegrationsService.' @@ -653,82 +603,70 @@ def test_update__task_create_two_fieldsets__ok( 'integrations.TemplateIntegrationsService.template_updated', ) mocker.patch( - 'src.processes.views.template.' - 'AnalyticService.templates_updated', + 'src.processes.views.template.AnalyticService.templates_updated', ) mocker.patch( 'src.processes.views.template.' 'AnalyticService.templates_kickoff_updated', ) - request_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': [ - { - 'api_name': fieldset_1.api_name, - 'order': 1, - }, - { - 'api_name': fieldset_2.api_name, - 'order': 0, - }, - ], - }, - ], - } api_client.token_authenticate(user) # act response = api_client.put( path=f'/templates/{template.id}', - data=request_data, + 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 - assert response.data['tasks'][0]['fieldsets'] == [ - { - 'api_name': fieldset_2.api_name, - 'order': 0, - }, - { - 'api_name': fieldset_1.api_name, - 'order': 1, - }, - ] - assert FieldsetTemplateTaskTemplate.objects.get( - task=task, - fieldset=fieldset_1, + fieldsets = response.data['tasks'][0]['fieldsets'] + assert len(fieldsets) == 2 + assert task.fieldsets.filter( + shared_fieldset_id=shared_1.id, order=1, - ) - assert FieldsetTemplateTaskTemplate.objects.get( - task=task, - fieldset=fieldset_2, + ).count() == 1 + assert task.fieldsets.filter( + shared_fieldset_id=shared_2.id, order=0, - ) + ).count() == 1 def test_update__task_replace_fieldset__ok( @@ -739,21 +677,20 @@ def test_update__task_replace_fieldset__ok( # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( - user, - is_active=True, - tasks_count=1, - ) + template = create_test_template(user, is_active=True, tasks_count=1) kickoff = template.kickoff_instance task = template.tasks.first() - fieldset_1 = create_test_fieldset_template( + shared_1 = create_test_shared_fieldset(account=account) + shared_2 = create_test_shared_fieldset(account=account) + # create an existing child fieldset linked to task from shared_1 + existing = FieldsetTemplate.objects.create( account=account, template=template, task=task, - ) - fieldset_2 = create_test_fieldset_template( - account=account, - template=template, + name=shared_1.name, + shared_fieldset_id=shared_1.id, + is_shared=True, + order=0, ) mocker.patch( 'src.processes.services.templates.' @@ -765,72 +702,63 @@ def test_update__task_replace_fieldset__ok( 'integrations.TemplateIntegrationsService.template_updated', ) mocker.patch( - 'src.processes.views.template.' - 'AnalyticService.templates_updated', + 'src.processes.views.template.AnalyticService.templates_updated', ) mocker.patch( 'src.processes.views.template.' 'AnalyticService.templates_kickoff_updated', ) - request_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': [ - { - 'api_name': fieldset_2.api_name, - 'order': 2, - }, - ], - }, - ], - } api_client.token_authenticate(user) # act response = api_client.put( path=f'/templates/{template.id}', - data=request_data, + 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 - assert response.data['tasks'][0]['fieldsets'] == [ - { - 'api_name': fieldset_2.api_name, - 'order': 2, - }, - ] - fieldset_1.refresh_from_db() - assert FieldsetTemplateTaskTemplate.objects.filter( - task=task, - fieldset=fieldset_1, - ).count() == 0 - assert FieldsetTemplateTaskTemplate.objects.filter( - task=task, - fieldset=fieldset_2, + 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=existing.id).exists() + assert task.fieldsets.filter( + shared_fieldset_id=shared_2.id, order=2, ).count() == 1 @@ -843,17 +771,18 @@ def test_update__tasks_remove_fieldset__ok( # arrange account = create_test_account() user = create_test_owner(account=account) - template = create_test_template( - user, - is_active=True, - tasks_count=1, - ) + template = create_test_template(user, is_active=True, tasks_count=1) kickoff = template.kickoff_instance task = template.tasks.first() - fieldset = create_test_fieldset_template( + shared = create_test_shared_fieldset(account=account) + existing = FieldsetTemplate.objects.create( account=account, template=template, task=task, + name=shared.name, + shared_fieldset_id=shared.id, + is_shared=True, + order=0, ) mocker.patch( 'src.processes.services.templates.' @@ -865,58 +794,53 @@ def test_update__tasks_remove_fieldset__ok( 'integrations.TemplateIntegrationsService.template_updated', ) mocker.patch( - 'src.processes.views.template.' - 'AnalyticService.templates_updated', + 'src.processes.views.template.AnalyticService.templates_updated', ) mocker.patch( 'src.processes.views.template.' 'AnalyticService.templates_kickoff_updated', ) - request_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': [], - }, - ], - } api_client.token_authenticate(user) # act response = api_client.put( path=f'/templates/{template.id}', - data=request_data, + 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 FieldsetTemplateTaskTemplate.objects.filter( - task=task, - fieldset=fieldset, - ).count() == 0 + assert not task.fieldsets.filter(id=existing.id).exists() def test_update__task_with_empty_fieldsets__no_create_fieldsets( @@ -925,16 +849,12 @@ def test_update__task_with_empty_fieldsets__no_create_fieldsets( ): """ Updating a template with empty fieldsets list in task does not - create any FieldsetTemplateTaskTemplate records. """ + 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, - ) + template = create_test_template(user, is_active=True, tasks_count=1) kickoff = template.kickoff_instance task = template.tasks.first() mocker.patch( @@ -947,51 +867,49 @@ def test_update__task_with_empty_fieldsets__no_create_fieldsets( 'integrations.TemplateIntegrationsService.template_updated', ) mocker.patch( - 'src.processes.views.template.' - 'AnalyticService.templates_updated', + 'src.processes.views.template.AnalyticService.templates_updated', ) mocker.patch( 'src.processes.views.template.' 'AnalyticService.templates_kickoff_updated', ) - request_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': [], - }, - ], - } api_client.token_authenticate(user) # act response = api_client.put( path=f'/templates/{template.id}', - data=request_data, + 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 FieldsetTemplateTaskTemplate.objects.filter(task=task).count() == 0 + assert task.fieldsets.count() == 0 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 53b861efa..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,7 +40,7 @@ create_test_template, create_test_user, create_test_workflow, - create_test_fieldset_template, + create_test_fieldset_template, create_test_shared_fieldset, ) pytestmark = pytest.mark.django_db @@ -2843,9 +2843,10 @@ def test_update__wf_name_template_with_fieldset_field__ok( 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, - template=template, + shared_fieldset=shared_fieldset, ) field = fieldset.fields.first() mocker.patch( @@ -2870,7 +2871,7 @@ def test_update__wf_name_template_with_fieldset_field__ok( 'id': kickoff.id, 'fieldsets': [ { - 'api_name': fieldset.api_name, + 'shared_fieldset_id': shared_fieldset.id, 'order': 1, }, ], diff --git a/backend/src/processes/urls/templates.py b/backend/src/processes/urls/templates.py index 4e5c1ce45..1f4b4e667 100644 --- a/backend/src/processes/urls/templates.py +++ b/backend/src/processes/urls/templates.py @@ -1,9 +1,6 @@ from django.urls import path from rest_framework.routers import DefaultRouter -from src.processes.views.fieldset import ( - FieldsetTemplateViewSet, -) from src.processes.views.public.template import ( PublicTemplateViewSet, ) @@ -38,11 +35,6 @@ viewset=TemplatePresetViewSet, basename='presets', ) -router.register( - prefix='fieldsets', - viewset=FieldsetTemplateViewSet, - basename='fieldsets', -) urlpatterns = [ path('public', PublicTemplateViewSet.as_view({'get': 'retrieve'})), path('public/run', PublicTemplateViewSet.as_view({'post': 'run'})), diff --git a/backend/src/processes/views/fieldset.py b/backend/src/processes/views/fieldset.py index 6e4cf0688..12dc80fba 100644 --- a/backend/src/processes/views/fieldset.py +++ b/backend/src/processes/views/fieldset.py @@ -7,13 +7,18 @@ 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 ( - FieldsetTemplateSerializer, + SharedFieldsetTemplateSerializer, +) +from src.processes.serializers.templates.template import ( + FieldsetTemplateFilterSerializer, ) from src.processes.services.templates.fieldsets.fieldset import ( FieldSetTemplateService, @@ -21,11 +26,16 @@ from src.utils.validation import raise_validation_error -class FieldsetTemplateViewSet( +class SharedFieldsetTemplateViewSet( CustomViewSetMixin, GenericViewSet, ): - serializer_class = FieldsetTemplateSerializer + serializer_class = SharedFieldsetTemplateSerializer + filter_backends = (PneumaticFilterBackend,) + + action_filterset_classes = { + 'list': FieldSetFilter, + } def get_serializer_context(self, **kwargs): context = super().get_serializer_context(**kwargs) @@ -52,6 +62,27 @@ def get_queryset(self): .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(**serializer.validated_data) + except BaseServiceException as ex: + raise_validation_error(message=ex.message) + 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) @@ -79,7 +110,7 @@ def partial_update(self, request, *args, **kwargs): except BaseServiceException as ex: raise_validation_error(message=ex.message) fieldset.refresh_from_db() - response_serializer = FieldsetTemplateSerializer(fieldset) + response_serializer = SharedFieldsetTemplateSerializer(fieldset) return self.response_ok(response_serializer.data) def destroy(self, request, *args, **kwargs): diff --git a/backend/src/processes/views/template.py b/backend/src/processes/views/template.py index 295c72102..54428dc75 100644 --- a/backend/src/processes/views/template.py +++ b/backend/src/processes/views/template.py @@ -17,7 +17,6 @@ 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, ) @@ -69,7 +68,6 @@ TemplateTitlesByTasksSerializer, TemplateTitlesByWorkflowsSerializer, TemplateTitlesSerializer, - FieldsetTemplateFilterSerializer, ) from src.processes.serializers.workflows.workflow import ( WorkflowCreateSerializer, @@ -84,14 +82,6 @@ TemplateServiceException, WorkflowServiceException, ) -from src.generics.exceptions import BaseServiceException -from src.processes.serializers.templates.fieldset import ( - FieldsetTemplateSerializer, -) -from src.processes.models.templates.fieldset import FieldsetTemplate -from src.processes.services.templates.fieldsets.fieldset import ( - FieldSetTemplateService, -) from src.processes.services.templates.ai import ( OpenAiService, ) @@ -139,8 +129,6 @@ class TemplateViewSet( 'fields': TemplateOnlyFieldsSerializer, 'presets': TemplatePresetSerializer, 'preset': TemplatePresetSerializer, - 'list_fieldsets': FieldsetTemplateSerializer, - 'create_fieldset': FieldsetTemplateSerializer, } action_filterset_classes = { 'list_fieldsets': FieldSetFilter, @@ -153,7 +141,6 @@ def get_permissions(self): 'destroy', 'discard_changes', 'preset', - 'create_fieldset', ): return ( UserIsAuthenticated(), @@ -171,7 +158,7 @@ def get_permissions(self): UsersOverlimitedPermission(), TemplateAccessPermission(), ) - if self.action in ('retrieve', 'list_fieldsets'): + if self.action == 'retrieve': return ( UserIsAuthenticated(), ExpiredSubscriptionPermission(), @@ -442,68 +429,6 @@ def clone(self, request, *args, **kwargs): serializer = self.get_serializer(data=template_data_clone) with transaction.atomic(): serializer.save_as_draft() - - # TODO Temporary: copy FieldsetTemplate entities --- - # Remove after creating global fieldsets - new_template = serializer.instance - original_fieldsets = FieldsetTemplate.objects.filter( - template=template, - ).prefetch_related( - 'rules', 'rules__fields', - 'fields', 'fields__selections', - ) - - for original_fs in original_fieldsets: - fields_data = [ - { - 'name': f.name, - 'type': f.type, - 'description': f.description or '', - 'is_required': f.is_required, - 'order': f.order, - 'is_hidden': f.is_hidden, - 'default': f.default, - 'api_name': f.api_name, - 'dataset': f.dataset, - 'selections': [ - { - 'value': sel.value, - 'api_name': sel.api_name, - } - for sel in f.selections.all() - ], - } - for f in original_fs.fields.all() - ] - rules_data = [ - { - 'type': r.type, - 'value': r.value, - 'api_name': r.api_name, - 'fields': [ - f.api_name - for f in r.fields.all() - ], - } - for r in original_fs.rules.all() - ] - service = FieldSetTemplateService( - user=request.user, - is_superuser=request.is_superuser, - auth_type=request.token_type, - ) - service.create( - template_id=new_template.id, - name=original_fs.name, - api_name=original_fs.api_name, - description=original_fs.description, - label_position=original_fs.label_position, - layout=original_fs.layout, - fields=fields_data, - rules=rules_data, - ) - # TODO --- End temporary code --- - return self.response_ok(serializer.get_response_data()) def list(self, request, *args, **kwargs): @@ -838,46 +763,6 @@ def preset(self, request, *args, **kwargs): return self.response_ok(self.get_serializer(preset).data) - @action(methods=['GET'], detail=True, url_path='fieldsets') - def list_fieldsets(self, request, *args, **kwargs): - template = self.get_object() - filter_slz = FieldsetTemplateFilterSerializer(data=request.GET) - filter_slz.is_valid(raise_exception=True) - queryset = ( - FieldsetTemplate.objects - .on_account(request.user.account_id) - .filter(template_id=template.id) - ) - queryset = PneumaticFilterBackend().filter_queryset( - queryset=queryset, - request=request, - view=self, - ) - return self.paginated_response(queryset) - - @list_fieldsets.mapping.post - def create_fieldset(self, request, *args, **kwargs): - template = self.get_object() - serializer = self.get_serializer( - data=request.data, - extra_fields={'template': template}, - ) - 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( - template_id=template.id, - **serializer.validated_data, - ) - except BaseServiceException as ex: - raise_validation_error(message=ex.message) - response_serializer = FieldsetTemplateSerializer(fieldset) - return self.response_created(response_serializer.data) - class TemplateIntegrationsViewSet( CustomViewSetMixin, 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'), From b023b11119422b60addff384878e96f045333518 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 16 Jun 2026 23:55:24 +0500 Subject: [PATCH 32/46] 45773 fix(fieldsets): update fieldsets tests --- .../serializers/templates/kickoff.py | 2 + .../processes/serializers/templates/mixins.py | 8 +++- .../processes/serializers/templates/task.py | 2 + .../services/templates/fieldsets/fieldset.py | 5 +- .../test_tasks/test_task_service.py | 11 ++--- .../test_fieldset_template_service.py | 22 +++++---- .../test_workflows/test_fieldset_service.py | 46 ++++++++++--------- 7 files changed, 55 insertions(+), 41 deletions(-) diff --git a/backend/src/processes/serializers/templates/kickoff.py b/backend/src/processes/serializers/templates/kickoff.py index f5befc87d..36ffa3115 100644 --- a/backend/src/processes/serializers/templates/kickoff.py +++ b/backend/src/processes/serializers/templates/kickoff.py @@ -72,6 +72,7 @@ def create(self, validated_data: Dict[str, Any]): fieldsets_data=validated_data.pop('fieldsets', []), template=template, kickoff=instance, + user=self.context['user'], ) self.create_or_update_related( data=validated_data.get('fields'), @@ -106,6 +107,7 @@ def update( fieldsets_data=validated_data.pop('fieldsets', []), template=template, kickoff=instance, + user=self.context['user'], ) self.create_or_update_related( data=validated_data.get('fields'), diff --git a/backend/src/processes/serializers/templates/mixins.py b/backend/src/processes/serializers/templates/mixins.py index a0e4d3b62..d8ec5d677 100644 --- a/backend/src/processes/serializers/templates/mixins.py +++ b/backend/src/processes/serializers/templates/mixins.py @@ -256,6 +256,7 @@ class FieldsetMixin: def create_or_update_fieldsets( fieldsets_data: List[Dict], template: Template, + user: UserModel, task: Optional[TaskTemplate] = None, kickoff: Optional[Kickoff] = None, ): @@ -275,7 +276,10 @@ def create_or_update_fieldsets( update_kwargs['description'] = fieldset_data['description'] if update_kwargs: - service = FieldSetTemplateService(instance=fieldset) + service = FieldSetTemplateService( + instance=fieldset, + user=user, + ) service.partial_update_instance( order=fieldset_data['order'], title=fieldset_data.get('title'), @@ -284,7 +288,7 @@ def create_or_update_fieldsets( fieldsets_api_names.add(fieldset.api_name) else: shared_fieldset = fieldset_data['shared_fieldset_id'] - service = FieldSetTemplateService(account=template.account) + service = FieldSetTemplateService(user=user) fieldset = service.create_from_shared( shared_fieldset_data=FieldSetTemplateService.to_json( shared_fieldset, diff --git a/backend/src/processes/serializers/templates/task.py b/backend/src/processes/serializers/templates/task.py index fe598f85b..f54014632 100644 --- a/backend/src/processes/serializers/templates/task.py +++ b/backend/src/processes/serializers/templates/task.py @@ -430,6 +430,7 @@ def create(self, validated_data: Dict[str, Any]): fieldsets_data=validated_data.pop('fieldsets', []), template=template, task=instance, + user=self.context['user'], ) self.create_or_update_related( data=validated_data.get('fields'), @@ -545,6 +546,7 @@ def update( 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( diff --git a/backend/src/processes/services/templates/fieldsets/fieldset.py b/backend/src/processes/services/templates/fieldsets/fieldset.py index d61300ab7..a22fe2f1c 100644 --- a/backend/src/processes/services/templates/fieldsets/fieldset.py +++ b/backend/src/processes/services/templates/fieldsets/fieldset.py @@ -203,6 +203,8 @@ def partial_update_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() def _replace_api_names(self, shared_fieldset_data: dict) -> dict: @@ -291,7 +293,6 @@ def create_rules( user=self.user, is_superuser=self.is_superuser, auth_type=self.auth_type, - account=self.account, ) for rule_data in rules_data: service.create( @@ -314,7 +315,6 @@ def update_rules( user=self.user, is_superuser=self.is_superuser, auth_type=self.auth_type, - account=self.account, instance=existing_rules[rule_id], ) service.partial_update(**rule_data) @@ -324,7 +324,6 @@ def update_rules( user=self.user, is_superuser=self.is_superuser, auth_type=self.auth_type, - account=self.account, ) rule = service.create( fieldset_id=self.instance.id, 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 62f0121f2..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 @@ -2092,9 +2092,7 @@ def test_set_due_date_directly__default__ok(mocker): ) -def test_create_fields_from_template__deleted_fieldsets__skip( - mocker, -): +def test_create_fields_from_template__deleted_fieldsets__skip(mocker): """ Field inside an active fieldset is excluded, @@ -2105,13 +2103,12 @@ def test_create_fields_from_template__deleted_fieldsets__skip( user = create_test_owner() template = create_test_template(user=user, tasks_count=1) template_task = template.tasks.get(number=1) - create_test_fieldset_template( + fieldset = create_test_fieldset_template( account=user.account, template=template, task=template_task, - name='Deleted fieldset', - order=0, - ).delete() + ) + fieldset.delete() workflow = create_test_workflow(user=user, template=template) task = workflow.tasks.get(number=1) task_field_service_init_mock = mocker.patch.object( diff --git a/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py index e055efe88..d67dd114d 100644 --- a/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py +++ b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py @@ -54,6 +54,7 @@ def test__create_instance__default_params__ok(): service._create_instance( name=name, template_id=template.id, + is_shared=False, ) # assert @@ -92,6 +93,7 @@ def test__create_instance__all_params__ok(): service._create_instance( name=name, template_id=template.id, + is_shared=False, description=description, label_position=label_position, layout=layout, @@ -942,7 +944,7 @@ def test_delete__not_in_use__ok(): def test_delete__used_by_kickoff_deleted_record__ok(): """ - Not in use → deleted + Fieldset previously linked to kickoff but now cleared → deleted """ # arrange @@ -954,9 +956,11 @@ def test_delete__used_by_kickoff_deleted_record__ok(): template=template, account=account, name='Fieldset', + kickoff=kickoff, ) - fieldset.kickoffs.add(kickoff) - fieldset.kickoffs.clear() + fieldset.kickoff = None + fieldset.save(update_fields=['kickoff']) + fieldset.refresh_from_db() service = FieldSetTemplateService( user=user, is_superuser=False, @@ -974,7 +978,7 @@ def test_delete__used_by_kickoff_deleted_record__ok(): def test_delete__used_by_task_deleted_record__ok(): """ - Not in use → deleted + Fieldset previously linked to task but now cleared → deleted """ # arrange @@ -986,9 +990,11 @@ def test_delete__used_by_task_deleted_record__ok(): template=template, account=account, name='Fieldset', + task=task, ) - fieldset.tasks.add(task) - fieldset.tasks.clear() + fieldset.task = None + fieldset.save(update_fields=['task']) + fieldset.refresh_from_db() service = FieldSetTemplateService( user=user, is_superuser=False, @@ -1006,7 +1012,7 @@ def test_delete__used_by_task_deleted_record__ok(): def test_delete__used_by_kickoff__raise_exception(): """ - In use by kickoff → exception + In use by kickoff (kickoff_id is set) → exception """ # arrange @@ -1018,8 +1024,8 @@ def test_delete__used_by_kickoff__raise_exception(): template=template, account=account, name='Fieldset', + kickoff=kickoff, ) - fieldset.kickoffs.add(kickoff) service = FieldSetTemplateService( user=user, is_superuser=False, 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 index 40459b3ee..83fbb876c 100644 --- 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 @@ -26,13 +26,13 @@ create_test_account, create_test_owner, create_test_template, - create_test_workflow, + create_test_workflow, create_test_fieldset_template, ) pytestmark = pytest.mark.django_db -def test__create_instance__with_kickoff__ok(mocker): +def test__create_instance__with_kickoff__ok(): """ Call with kickoff @@ -44,18 +44,18 @@ def test__create_instance__with_kickoff__ok(mocker): template = create_test_template(user=user, tasks_count=1) workflow = create_test_workflow(user=user, template=template) kickoff = workflow.kickoff_instance + workflow.tasks.first() + order = 11 fieldset_template = FieldsetTemplate.objects.create( template=template, account=account, - name='Fieldset', - description='Description', + order=order, ) service = FieldSetService( user=user, is_superuser=False, auth_type=AuthTokenType.USER, ) - order = 11 # act service._create_instance( @@ -66,54 +66,58 @@ def test__create_instance__with_kickoff__ok(mocker): ) # assert - assert service.instance is not None + assert service.instance.account == account assert service.instance.workflow_id == workflow.id - assert service.instance.kickoff_id == kickoff.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' - assert service.instance.description == 'Description' + 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() - fieldset_template = FieldsetTemplate.objects.create( + order = 11 + fieldset_template = create_test_fieldset_template( template=template, account=account, - name='Fieldset', + order=order, ) service = FieldSetService( user=user, is_superuser=False, auth_type=AuthTokenType.USER, ) - order = 11 # act service._create_instance( instance_template=fieldset_template, workflow=workflow, task=task, - order=order, ) # assert - assert service.instance is not None - assert service.instance.workflow_id == workflow.id - assert service.instance.task_id == task.id - assert service.instance.order == order + 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(): From 92fbd869d87b5cb9c5eb7a69dc46889fa69c7512 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Thu, 18 Jun 2026 22:39:43 +0500 Subject: [PATCH 33/46] 45773 fix(fieldsets): fix creation shared fiedlset --- API_PLAN_templates.md | 204 ++++ backend/src/processes/messages/fieldset.py | 2 + backend/src/processes/services/exceptions.py | 10 + .../services/templates/fieldsets/fieldset.py | 11 +- .../test_services/test_fieldsets/__init__.py | 0 .../test_fieldset_template_rule_service.py | 0 .../test_fieldset_template_service.py | 957 ++++++++++++++++++ 7 files changed, 1183 insertions(+), 1 deletion(-) create mode 100644 API_PLAN_templates.md create mode 100644 backend/src/processes/tests/test_services/test_fieldsets/__init__.py rename backend/src/processes/tests/test_services/{test_templates => test_fieldsets}/test_fieldset_template_rule_service.py (100%) rename backend/src/processes/tests/test_services/{test_templates => test_fieldsets}/test_fieldset_template_service.py (53%) diff --git a/API_PLAN_templates.md b/API_PLAN_templates.md new file mode 100644 index 000000000..b2c54561f --- /dev/null +++ b/API_PLAN_templates.md @@ -0,0 +1,204 @@ +# План изменений API `/templates` (ветка `backend/fieldsets/45773__fieldsets`) + +Относительно `master`. + +--- + +## 1. Общая концепция + +Главное изменение — **fieldsets** в kickoff и задачах шаблона. Shared fieldset создаётся через `/fieldsets`, в шаблоне — **привязка** по `shared_fieldset_id` (копия с новыми `api_name` для полей и правил). + +**Модель привязки:** +- Запись: `shared_fieldset_id`, `order`, `title`, `description`, `api_name` +- Read-only из shared: `name`, `label_position`, `layout`, `rules`, `fields` +- Один shared fieldset можно подключить к разным kickoff/task в одном шаблоне + +--- + +## 2. Изменения по эндпоинтам + +### 2.1. `GET /templates`, `GET /templates/:id`, `GET /templates/titles-by-owners` + +**API (ответ):** в `kickoff.fieldsets[]` (и в `tasks[].fieldsets[]` для `GET /templates/:id`) — полная структура привязки fieldset, не `{ api_name, order }`. + +**Wiki — обновлено:** + +| Файл | Что изменилось | +|------|----------------| +| `GET _templates.md` | `kickoff.fieldsets[]`: добавлены `shared_fieldset_id`, `title`, `label_position`, `layout`, `rules`, полный `fields[]` | +| `GET _templates__id.md` | `kickoff.fieldsets[]` и `tasks[].fieldsets[]`: заменена урезанная схема на полную (как выше) | +| `GET _templates_titles-by-owners.md` | `kickoff.fieldsets[]`: то же, что в list | + +**Было в wiki:** `{ name, order, description, api_name, fields[] }` или `{ api_name, order }`. +**Стало:** полный объект привязки с `shared_fieldset_id`, layout, rules (`sum_equal`) и вложенными полями. + +--- + +### 2.2. `POST /templates`, `PUT /templates/:id` + +**API (запрос):** в `kickoff.fieldsets[]` и `tasks[].fieldsets[]` — привязки по `shared_fieldset_id`, не `[int]`. + +**Wiki — обновлено:** + +| Файл | Что изменилось | +|------|----------------| +| `POST _templates.md` | Описание: привязки через `shared_fieldset_id`, ссылка на `/fieldsets`; схема запроса в kickoff/tasks; блок **Fieldsets (запрос)** | +| `PUT _templates__id.md` | `fieldsets: [int]` → объекты привязки в kickoff и tasks (редактирование и создание задачи); блок **Fieldsets (запрос)** с логикой полной замены списка | + +**Было в wiki:** `fieldsets: [int]` или «список id FieldsetTemplate». +**Стало:** `{ shared_fieldset_id, order, title?, description?, api_name? }`; в PUT — полная синхронизация списка привязок. + +--- + +### 2.3. `GET /templates/:id/fields` + +**API (ответ):** укороченная схема полей + `fieldsets[]` с `title`, `order`, `label_position`, `layout`, `is_hidden`. + +**Wiki — обновлено:** + +| Файл | Что изменилось | +|------|----------------| +| `GET _templates__id_fields.md` | Полная схема `kickoff`/`tasks` с `fieldsets[]`; добавлены `title`, `is_hidden`; комментарии к `label_position`/`layout`; примечания об укороченной схеме и сортировке | + +**Было в wiki:** неполная схема fieldsets (без `title`, `is_hidden`). +**Стало:** соответствует `FieldsetTemplateOnlyFieldsSerializer` / `FieldTemplateOnlyFieldsSerializer`. + +--- + +### 2.4. `POST /templates/:id/clone` + +**API:** копируются fieldsets kickoff/tasks; `shared_fieldset_id`, `order`, `title`, `description` сохраняются; `api_name` fieldsets/fields/rules пересоздаются. + +**Wiki — обновлено:** + +| Файл | Что изменилось | +|------|----------------| +| `POST _templates__id_clone.md` | Блок **Fieldsets при клонировании**: что сохраняется, что пересоздаётся; ссылка на ответ `GET /templates/:id` | + +--- + +### 2.5. `POST /templates/:id/run` + +**Запрос** — `kickoff` по-прежнему плоский `api_name → value`, но ключи включают **поля из fieldsets**. + +**Ответ** — в `kickoff` появляется `fieldsets[]` (runtime `FieldSet`). + +**Валидация:** правила `sum_equal` при старте workflow. + +**Документация:** `POST _templates__id_run.md` — заменить `sum_max` на `sum_equal`, описать поля из fieldsets. + +--- + +### 2.6. `GET /templates/export` + +**Ответ** — `kickoff.fieldsets` и `tasks[].fieldsets` с `order`. + +**Документация:** `GET _templates_export.md`. + +--- + +### 2.7. `GET /templates/public` + +**Ответ** — `kickoff.fieldsets[]` (полная схема через `FieldsetTemplateSerializer`). + +**Рефакторинг:** `kickoff` через `SerializerMethodField` + `kickoff_instance`. + +**Документация:** `Public/GET _templates_public.md`, `Public/POST _templates_public_run.md`. + +--- + +### 2.8. Conditions (в теле `POST/PUT /templates`) + +**Новые операторы предикатов** (`predicate_type: task`): +- `skipped` +- `completed_or_skipped` + +**`start_task`:** разрешены `completed`, `skipped`, `completed_or_skipped`. + +**Миграция черновиков:** `completed` → `completed_or_skipped`. + +**Документация:** `POST _templates.md`, `PUT _templates__id.md` — операторы в `conditions`; при необходимости отдельная памятка. + +--- + +### 2.9. Workflow name template + +**Изменение:** в шаблон имени workflow можно подставлять поля из **fieldsets** kickoff (не только плоские `fields`). + +**Документация:** памятка по шаблону имени workflow. + +--- + +### 2.10. Без изменений URL + +Эндпоинты без изменений контракта (кроме косвенных effects через fieldsets в run): +- `DELETE /templates/:id` +- `GET /templates/:id/steps` +- `GET /templates/:id/integrations`, `GET /templates/integrations` +- `POST /templates/by-steps`, `POST /templates/by-name`, `POST /templates/ai` +- `POST /templates/:id/discard-changes` +- `GET /templates/titles-by-*` +- Presets + +**Удалённый эндпоинт:** `GET /templates/:id/fieldsets` — заменён на `/fieldsets` (уже вынесен в wiki). + +--- + +## 3. Изменения схем полей + +| Объект | Изменение | +|--------|-----------| +| `FieldTemplateListSerializer` | добавлен `default` | +| `FieldTemplateShortViewSerializer` | удалён | +| `FieldTemplateSerializer` | порядок полей в ответе | +| `KickoffListSerializer` | + `fieldsets` | +| `TaskTemplateSerializer` | + `fieldsets` | + +--- + +## 4. Технические изменения (не для публичной wiki) + +- `TemplateViewSet`: убраны `filter_backends` / `TemplateFilter` (фильтрация list через `TemplateListFilterSerializer` как раньше) +- `action_filterset_classes.list_fieldsets` — мёртвый код (action удалён) +- `FieldsetMixin` в `mixins.py`: `create_or_update_fieldsets`, `get_draft_fieldsets` + +--- + +## 5. План обновления wiki + +| Приоритет | Файл | Что сделать | +|-----------|------|-------------| +| P0 | `POST _templates.md` | ✅ схема `fieldsets` в kickoff/tasks + блок Fieldsets (запрос) | +| P0 | `PUT _templates__id.md` | ✅ схема fieldsets + полная замена списка; операторы `skipped` / `completed_or_skipped` уже в conditions | +| P0 | `GET _templates__id.md` | ✅ `fieldsets[]` в kickoff/tasks — полная схема | +| P0 | `GET _templates.md` | ✅ `fieldsets[]` в list response — полная схема | +| P0 | `GET _templates__id_fields.md` | ✅ `fieldsets[]` с `title`, `order`, `layout`, `label_position`, `is_hidden` | +| P1 | `POST _templates__id_run.md` | поля fieldsets в kickoff, `sum_equal` | +| P1 | `Public/GET _templates_public.md` | `kickoff.fieldsets` | +| P1 | `Public/POST _templates_public_run.md` | валидация fieldsets | +| P1 | `POST _templates__id_clone.md` | ✅ клонирование fieldsets | +| P1 | `GET _templates_export.md` | fieldsets в export | +| P2 | `GET _templates_titles-by-owners.md` | ✅ fieldsets в nested kickoff — полная схема | +| P2 | Памятка workflow name | поля из fieldsets | + +**Уже сделано:** `/fieldsets` вынесен в `API/Public API/fieldsets/`. + +--- + +## 6. Breaking changes для клиентов + +1. **`fieldsets` в шаблоне** — не `[int]`, а объекты с `shared_fieldset_id`. +2. **Ответ fieldsets** — полная структура (поля, rules), не только id. +3. **Условия** — для task-предикатов предпочтителен `completed_or_skipped` вместо `completed`. +4. **`GET /templates/:id/fieldsets`** — удалён, использовать `/fieldsets`. +5. **Run/complete** — в kickoff нужно передавать значения полей из fieldsets по их `api_name`. + +--- + +## 7. Порядок работ + +1. Обновить схемы запроса/ответа в P0-файлах wiki. +2. Добавить cross-link: Templates → Fieldsets (`shared_fieldset_id`). +3. Обновить P1 (run, public, clone, export). +4. Пройтись по примерам JSON в тестах (`test_update/test_fieldsets.py`, `test_fields.py`, `test_clone/test_fieldsets.py`) как эталонам. +5. Проверить фронтенд-типы и API-клиенты на старую схему `fieldsets: [int]`. diff --git a/backend/src/processes/messages/fieldset.py b/backend/src/processes/messages/fieldset.py index b165e01ef..f603ffa2c 100644 --- a/backend/src/processes/messages/fieldset.py +++ b/backend/src/processes/messages/fieldset.py @@ -29,3 +29,5 @@ 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.') diff --git a/backend/src/processes/services/exceptions.py b/backend/src/processes/services/exceptions.py index b00ccedc9..b495b9980 100644 --- a/backend/src/processes/services/exceptions.py +++ b/backend/src/processes/services/exceptions.py @@ -223,6 +223,16 @@ 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 diff --git a/backend/src/processes/services/templates/fieldsets/fieldset.py b/backend/src/processes/services/templates/fieldsets/fieldset.py index a22fe2f1c..4e31eccb6 100644 --- a/backend/src/processes/services/templates/fieldsets/fieldset.py +++ b/backend/src/processes/services/templates/fieldsets/fieldset.py @@ -14,6 +14,8 @@ from src.processes.services.exceptions import ( FieldsetTemplateInUseException, FieldsetTemplateInUseException2, + FieldsetTemplateSharedIdMissing, + FieldsetTemplateTemplateIdMissing, ) from src.processes.services.templates.field_template import ( FieldTemplateService, @@ -31,8 +33,8 @@ class FieldSetTemplateService(BaseModelService): def _create_instance( self, name: str, - template_id: int, is_shared: bool, + template_id: Optional[int] = None, order: int = 0, title: str = '', description: str = '', @@ -44,6 +46,13 @@ def _create_instance( 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, 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_templates/test_fieldset_template_rule_service.py b/backend/src/processes/tests/test_services/test_fieldsets/test_fieldset_template_rule_service.py similarity index 100% rename from backend/src/processes/tests/test_services/test_templates/test_fieldset_template_rule_service.py rename to backend/src/processes/tests/test_services/test_fieldsets/test_fieldset_template_rule_service.py diff --git a/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py b/backend/src/processes/tests/test_services/test_fieldsets/test_fieldset_template_service.py similarity index 53% rename from backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py rename to backend/src/processes/tests/test_services/test_fieldsets/test_fieldset_template_service.py index d67dd114d..7caf4efd1 100644 --- a/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py +++ b/backend/src/processes/tests/test_services/test_fieldsets/test_fieldset_template_service.py @@ -13,6 +13,8 @@ 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, @@ -43,6 +45,11 @@ def test__create_instance__default_params__ok(): 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, @@ -55,6 +62,7 @@ def test__create_instance__default_params__ok(): name=name, template_id=template.id, is_shared=False, + shared_fieldset_id=shared_fieldset.id, ) # assert @@ -62,6 +70,7 @@ def test__create_instance__default_params__ok(): 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 @@ -78,6 +87,11 @@ def test__create_instance__all_params__ok(): 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, @@ -94,6 +108,7 @@ def test__create_instance__all_params__ok(): name=name, template_id=template.id, is_shared=False, + shared_fieldset_id=shared_fieldset.id, description=description, label_position=label_position, layout=layout, @@ -103,12 +118,141 @@ def test__create_instance__all_params__ok(): # 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): """ @@ -1073,3 +1217,816 @@ def test_delete__used_by_task__raise_exception(): # 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.templates.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.templates.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.templates.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.templates.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.templates.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.templates.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.templates.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.templates.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.templates.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.templates.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.templates.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.templates.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.templates.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.templates.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.templates.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.templates.fieldsets.fieldset.' + 'FieldSetTemplateService.get_new_fieldset_data', + return_value=fieldset_data_from_mock, + ) + create_mock = mocker.patch( + 'src.processes.services.templates.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=True, + 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.templates.fieldsets.fieldset.' + 'FieldSetTemplateService.get_new_fieldset_data', + return_value=fieldset_data_from_mock, + ) + create_mock = mocker.patch( + 'src.processes.services.templates.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=True, + 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) From 899c9cf1ce8c46b046c96cb87ffdd086b09d1efc Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Thu, 18 Jun 2026 22:43:49 +0500 Subject: [PATCH 34/46] 45773 fix(fieldsets): remove trash file --- API_PLAN_templates.md | 204 ------------------------------------------ 1 file changed, 204 deletions(-) delete mode 100644 API_PLAN_templates.md diff --git a/API_PLAN_templates.md b/API_PLAN_templates.md deleted file mode 100644 index b2c54561f..000000000 --- a/API_PLAN_templates.md +++ /dev/null @@ -1,204 +0,0 @@ -# План изменений API `/templates` (ветка `backend/fieldsets/45773__fieldsets`) - -Относительно `master`. - ---- - -## 1. Общая концепция - -Главное изменение — **fieldsets** в kickoff и задачах шаблона. Shared fieldset создаётся через `/fieldsets`, в шаблоне — **привязка** по `shared_fieldset_id` (копия с новыми `api_name` для полей и правил). - -**Модель привязки:** -- Запись: `shared_fieldset_id`, `order`, `title`, `description`, `api_name` -- Read-only из shared: `name`, `label_position`, `layout`, `rules`, `fields` -- Один shared fieldset можно подключить к разным kickoff/task в одном шаблоне - ---- - -## 2. Изменения по эндпоинтам - -### 2.1. `GET /templates`, `GET /templates/:id`, `GET /templates/titles-by-owners` - -**API (ответ):** в `kickoff.fieldsets[]` (и в `tasks[].fieldsets[]` для `GET /templates/:id`) — полная структура привязки fieldset, не `{ api_name, order }`. - -**Wiki — обновлено:** - -| Файл | Что изменилось | -|------|----------------| -| `GET _templates.md` | `kickoff.fieldsets[]`: добавлены `shared_fieldset_id`, `title`, `label_position`, `layout`, `rules`, полный `fields[]` | -| `GET _templates__id.md` | `kickoff.fieldsets[]` и `tasks[].fieldsets[]`: заменена урезанная схема на полную (как выше) | -| `GET _templates_titles-by-owners.md` | `kickoff.fieldsets[]`: то же, что в list | - -**Было в wiki:** `{ name, order, description, api_name, fields[] }` или `{ api_name, order }`. -**Стало:** полный объект привязки с `shared_fieldset_id`, layout, rules (`sum_equal`) и вложенными полями. - ---- - -### 2.2. `POST /templates`, `PUT /templates/:id` - -**API (запрос):** в `kickoff.fieldsets[]` и `tasks[].fieldsets[]` — привязки по `shared_fieldset_id`, не `[int]`. - -**Wiki — обновлено:** - -| Файл | Что изменилось | -|------|----------------| -| `POST _templates.md` | Описание: привязки через `shared_fieldset_id`, ссылка на `/fieldsets`; схема запроса в kickoff/tasks; блок **Fieldsets (запрос)** | -| `PUT _templates__id.md` | `fieldsets: [int]` → объекты привязки в kickoff и tasks (редактирование и создание задачи); блок **Fieldsets (запрос)** с логикой полной замены списка | - -**Было в wiki:** `fieldsets: [int]` или «список id FieldsetTemplate». -**Стало:** `{ shared_fieldset_id, order, title?, description?, api_name? }`; в PUT — полная синхронизация списка привязок. - ---- - -### 2.3. `GET /templates/:id/fields` - -**API (ответ):** укороченная схема полей + `fieldsets[]` с `title`, `order`, `label_position`, `layout`, `is_hidden`. - -**Wiki — обновлено:** - -| Файл | Что изменилось | -|------|----------------| -| `GET _templates__id_fields.md` | Полная схема `kickoff`/`tasks` с `fieldsets[]`; добавлены `title`, `is_hidden`; комментарии к `label_position`/`layout`; примечания об укороченной схеме и сортировке | - -**Было в wiki:** неполная схема fieldsets (без `title`, `is_hidden`). -**Стало:** соответствует `FieldsetTemplateOnlyFieldsSerializer` / `FieldTemplateOnlyFieldsSerializer`. - ---- - -### 2.4. `POST /templates/:id/clone` - -**API:** копируются fieldsets kickoff/tasks; `shared_fieldset_id`, `order`, `title`, `description` сохраняются; `api_name` fieldsets/fields/rules пересоздаются. - -**Wiki — обновлено:** - -| Файл | Что изменилось | -|------|----------------| -| `POST _templates__id_clone.md` | Блок **Fieldsets при клонировании**: что сохраняется, что пересоздаётся; ссылка на ответ `GET /templates/:id` | - ---- - -### 2.5. `POST /templates/:id/run` - -**Запрос** — `kickoff` по-прежнему плоский `api_name → value`, но ключи включают **поля из fieldsets**. - -**Ответ** — в `kickoff` появляется `fieldsets[]` (runtime `FieldSet`). - -**Валидация:** правила `sum_equal` при старте workflow. - -**Документация:** `POST _templates__id_run.md` — заменить `sum_max` на `sum_equal`, описать поля из fieldsets. - ---- - -### 2.6. `GET /templates/export` - -**Ответ** — `kickoff.fieldsets` и `tasks[].fieldsets` с `order`. - -**Документация:** `GET _templates_export.md`. - ---- - -### 2.7. `GET /templates/public` - -**Ответ** — `kickoff.fieldsets[]` (полная схема через `FieldsetTemplateSerializer`). - -**Рефакторинг:** `kickoff` через `SerializerMethodField` + `kickoff_instance`. - -**Документация:** `Public/GET _templates_public.md`, `Public/POST _templates_public_run.md`. - ---- - -### 2.8. Conditions (в теле `POST/PUT /templates`) - -**Новые операторы предикатов** (`predicate_type: task`): -- `skipped` -- `completed_or_skipped` - -**`start_task`:** разрешены `completed`, `skipped`, `completed_or_skipped`. - -**Миграция черновиков:** `completed` → `completed_or_skipped`. - -**Документация:** `POST _templates.md`, `PUT _templates__id.md` — операторы в `conditions`; при необходимости отдельная памятка. - ---- - -### 2.9. Workflow name template - -**Изменение:** в шаблон имени workflow можно подставлять поля из **fieldsets** kickoff (не только плоские `fields`). - -**Документация:** памятка по шаблону имени workflow. - ---- - -### 2.10. Без изменений URL - -Эндпоинты без изменений контракта (кроме косвенных effects через fieldsets в run): -- `DELETE /templates/:id` -- `GET /templates/:id/steps` -- `GET /templates/:id/integrations`, `GET /templates/integrations` -- `POST /templates/by-steps`, `POST /templates/by-name`, `POST /templates/ai` -- `POST /templates/:id/discard-changes` -- `GET /templates/titles-by-*` -- Presets - -**Удалённый эндпоинт:** `GET /templates/:id/fieldsets` — заменён на `/fieldsets` (уже вынесен в wiki). - ---- - -## 3. Изменения схем полей - -| Объект | Изменение | -|--------|-----------| -| `FieldTemplateListSerializer` | добавлен `default` | -| `FieldTemplateShortViewSerializer` | удалён | -| `FieldTemplateSerializer` | порядок полей в ответе | -| `KickoffListSerializer` | + `fieldsets` | -| `TaskTemplateSerializer` | + `fieldsets` | - ---- - -## 4. Технические изменения (не для публичной wiki) - -- `TemplateViewSet`: убраны `filter_backends` / `TemplateFilter` (фильтрация list через `TemplateListFilterSerializer` как раньше) -- `action_filterset_classes.list_fieldsets` — мёртвый код (action удалён) -- `FieldsetMixin` в `mixins.py`: `create_or_update_fieldsets`, `get_draft_fieldsets` - ---- - -## 5. План обновления wiki - -| Приоритет | Файл | Что сделать | -|-----------|------|-------------| -| P0 | `POST _templates.md` | ✅ схема `fieldsets` в kickoff/tasks + блок Fieldsets (запрос) | -| P0 | `PUT _templates__id.md` | ✅ схема fieldsets + полная замена списка; операторы `skipped` / `completed_or_skipped` уже в conditions | -| P0 | `GET _templates__id.md` | ✅ `fieldsets[]` в kickoff/tasks — полная схема | -| P0 | `GET _templates.md` | ✅ `fieldsets[]` в list response — полная схема | -| P0 | `GET _templates__id_fields.md` | ✅ `fieldsets[]` с `title`, `order`, `layout`, `label_position`, `is_hidden` | -| P1 | `POST _templates__id_run.md` | поля fieldsets в kickoff, `sum_equal` | -| P1 | `Public/GET _templates_public.md` | `kickoff.fieldsets` | -| P1 | `Public/POST _templates_public_run.md` | валидация fieldsets | -| P1 | `POST _templates__id_clone.md` | ✅ клонирование fieldsets | -| P1 | `GET _templates_export.md` | fieldsets в export | -| P2 | `GET _templates_titles-by-owners.md` | ✅ fieldsets в nested kickoff — полная схема | -| P2 | Памятка workflow name | поля из fieldsets | - -**Уже сделано:** `/fieldsets` вынесен в `API/Public API/fieldsets/`. - ---- - -## 6. Breaking changes для клиентов - -1. **`fieldsets` в шаблоне** — не `[int]`, а объекты с `shared_fieldset_id`. -2. **Ответ fieldsets** — полная структура (поля, rules), не только id. -3. **Условия** — для task-предикатов предпочтителен `completed_or_skipped` вместо `completed`. -4. **`GET /templates/:id/fieldsets`** — удалён, использовать `/fieldsets`. -5. **Run/complete** — в kickoff нужно передавать значения полей из fieldsets по их `api_name`. - ---- - -## 7. Порядок работ - -1. Обновить схемы запроса/ответа в P0-файлах wiki. -2. Добавить cross-link: Templates → Fieldsets (`shared_fieldset_id`). -3. Обновить P1 (run, public, clone, export). -4. Пройтись по примерам JSON в тестах (`test_update/test_fieldsets.py`, `test_fields.py`, `test_clone/test_fieldsets.py`) как эталонам. -5. Проверить фронтенд-типы и API-клиенты на старую схему `fieldsets: [int]`. From 48bd6c82311839488d7c92b892bdce6cfb4be78c Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Sat, 20 Jun 2026 03:00:32 +0500 Subject: [PATCH 35/46] fix(fieldsets): fix creation shared fiedlset. part 2 --- backend/src/processes/querysets.py | 3 +- .../processes/serializers/templates/mixins.py | 2 +- .../{templates => }/fieldsets/__init__.py | 0 .../{templates => }/fieldsets/fieldset.py | 65 +++++------ .../fieldsets/fieldset_rule.py | 0 .../test_fieldset_template_rule_service.py | 32 +++--- .../test_fieldset_template_service.py | 90 ++++++++-------- .../test_views/test_fieldsets/test_create.py | 51 +++++---- .../test_views/test_fieldsets/test_destroy.py | 102 ++++++++---------- .../test_views/test_fieldsets/test_list.py | 21 ++++ .../test_fieldsets/test_partial_update.py | 47 +++++++- .../test_fieldsets/test_retrieve.py | 22 ++++ backend/src/processes/views/fieldset.py | 12 ++- 13 files changed, 270 insertions(+), 177 deletions(-) rename backend/src/processes/services/{templates => }/fieldsets/__init__.py (100%) rename backend/src/processes/services/{templates => }/fieldsets/fieldset.py (99%) rename backend/src/processes/services/{templates => }/fieldsets/fieldset_rule.py (100%) diff --git a/backend/src/processes/querysets.py b/backend/src/processes/querysets.py index e05c08033..19ac77abe 100644 --- a/backend/src/processes/querysets.py +++ b/backend/src/processes/querysets.py @@ -1288,7 +1288,8 @@ class SearchContentQuerySet(AccountBaseQuerySet): class FieldsetTemplateQuerySet(AccountBaseQuerySet): - pass + def shared(self): + return self.filter(is_shared=True) class FieldsetTemplateRuleQuerySet(AccountBaseQuerySet): diff --git a/backend/src/processes/serializers/templates/mixins.py b/backend/src/processes/serializers/templates/mixins.py index d8ec5d677..a1addf09c 100644 --- a/backend/src/processes/serializers/templates/mixins.py +++ b/backend/src/processes/serializers/templates/mixins.py @@ -11,7 +11,7 @@ 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.templates.fieldsets.fieldset import ( +from src.processes.services.fieldsets.fieldset import ( FieldSetTemplateService, ) from src.utils.validation import raise_validation_error diff --git a/backend/src/processes/services/templates/fieldsets/__init__.py b/backend/src/processes/services/fieldsets/__init__.py similarity index 100% rename from backend/src/processes/services/templates/fieldsets/__init__.py rename to backend/src/processes/services/fieldsets/__init__.py diff --git a/backend/src/processes/services/templates/fieldsets/fieldset.py b/backend/src/processes/services/fieldsets/fieldset.py similarity index 99% rename from backend/src/processes/services/templates/fieldsets/fieldset.py rename to backend/src/processes/services/fieldsets/fieldset.py index 4e31eccb6..a22865674 100644 --- a/backend/src/processes/services/templates/fieldsets/fieldset.py +++ b/backend/src/processes/services/fieldsets/fieldset.py @@ -20,8 +20,9 @@ from src.processes.services.templates.field_template import ( FieldTemplateService, ) -from src.processes.services.templates.fieldsets.fieldset_rule import \ - FieldsetTemplateRuleService +from src.processes.services.fieldsets.fieldset_rule import ( + FieldsetTemplateRuleService, +) from src.processes.utils.common import create_api_name @@ -97,6 +98,36 @@ def create_shared_fieldset( **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=True, + 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, @@ -264,36 +295,6 @@ def get_new_fieldset_data( fieldset_data.pop('order', None) return fieldset_data - 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=True, - shared_fieldset_id=shared_fieldset_id, - order=order, - kickoff_id=kickoff_id, - task_id=task_id, - template_id=template_id, - ) - def create_rules( self, rules_data: List[Dict], diff --git a/backend/src/processes/services/templates/fieldsets/fieldset_rule.py b/backend/src/processes/services/fieldsets/fieldset_rule.py similarity index 100% rename from backend/src/processes/services/templates/fieldsets/fieldset_rule.py rename to backend/src/processes/services/fieldsets/fieldset_rule.py 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 index b0a17287c..7f60472f4 100644 --- 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 @@ -15,7 +15,7 @@ FieldsetTemplateRuleSumMaxFieldsNotNumber, FieldsetTemplateRuleSumMaxInvalidValue, ) -from src.processes.services.templates.fieldsets.fieldset_rule import ( +from src.processes.services.fieldsets.fieldset_rule import ( FieldsetTemplateRuleService, ) from src.processes.tests.fixtures import ( @@ -304,7 +304,7 @@ def test__validate__call_method_by_type__ok(mocker): instance=rule, ) validate_sum_equal_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset_rule.' + 'src.processes.services.fieldsets.fieldset_rule.' 'FieldsetTemplateRuleService._validate_sum_equal', ) kwargs = {'type': FieldSetRuleType.SUM_EQUAL} @@ -581,7 +581,7 @@ def test_set_fields__fields_provided__set_fields(mocker): ) service.instance = rule get_valid_fields_mock = mocker.patch( - 'src.processes.services.templates.fieldsets' + 'src.processes.services.fieldsets' '.fieldset_rule.FieldsetTemplateRuleService' '._get_valid_fields', return_value=[field], @@ -631,7 +631,7 @@ def test_set_fields__fields_not_provided__clear_fields(mocker): ) service.instance = rule get_valid_fields_mock = mocker.patch( - 'src.processes.services.templates.fieldsets' + 'src.processes.services.fieldsets' '.fieldset_rule.FieldsetTemplateRuleService' '._get_valid_fields', ) @@ -659,7 +659,7 @@ def test_create_related__fields_provided__ok(mocker): auth_type=AuthTokenType.USER, ) set_fields_mock = mocker.patch( - 'src.processes.services.templates.fieldsets' + 'src.processes.services.fieldsets' '.fieldset_rule.FieldsetTemplateRuleService' '._set_fields', ) @@ -687,7 +687,7 @@ def test_create_related__fields_provided_empty_list__ok(mocker): auth_type=AuthTokenType.USER, ) set_fields_mock = mocker.patch( - 'src.processes.services.templates.fieldsets' + 'src.processes.services.fieldsets' '.fieldset_rule.FieldsetTemplateRuleService' '._set_fields', ) @@ -714,7 +714,7 @@ def test_create_related__fields_not_provided__skip(mocker): auth_type=AuthTokenType.USER, ) set_fields_mock = mocker.patch( - 'src.processes.services.templates.fieldsets' + 'src.processes.services.fieldsets' '.fieldset_rule.FieldsetTemplateRuleService' '._set_fields', ) @@ -754,19 +754,19 @@ def test_create__valid_data__ok(mocker): ) service.instance = rule create_instance_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset_rule.' + 'src.processes.services.fieldsets.fieldset_rule.' 'FieldsetTemplateRuleService._create_instance', ) create_related_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset_rule.' + 'src.processes.services.fieldsets.fieldset_rule.' 'FieldsetTemplateRuleService._create_related', ) create_actions_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset_rule.' + 'src.processes.services.fieldsets.fieldset_rule.' 'FieldsetTemplateRuleService._create_actions', ) validate_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset_rule.' + 'src.processes.services.fieldsets.fieldset_rule.' 'FieldsetTemplateRuleService._validate', ) @@ -816,12 +816,12 @@ def test_partial_update__with_fields__ok(mocker): instance=rule, ) set_fields_mock = mocker.patch( - 'src.processes.services.templates.fieldsets' + 'src.processes.services.fieldsets' '.fieldset_rule.FieldsetTemplateRuleService' '._set_fields', ) validate_mock = mocker.patch( - 'src.processes.services.templates.fieldsets' + 'src.processes.services.fieldsets' '.fieldset_rule.FieldsetTemplateRuleService' '._validate', ) @@ -867,17 +867,17 @@ def test_partial_update__without_fields__ok(mocker): instance=rule, ) set_fields_mock = mocker.patch( - 'src.processes.services.templates.fieldsets' + 'src.processes.services.fieldsets' '.fieldset_rule.FieldsetTemplateRuleService' '._set_fields', ) validate_mock = mocker.patch( - 'src.processes.services.templates.fieldsets' + 'src.processes.services.fieldsets' '.fieldset_rule.FieldsetTemplateRuleService' '._validate', ) super_partial_mock = mocker.patch( - 'src.processes.services.templates.fieldsets' + 'src.processes.services.fieldsets' '.fieldset_rule.BaseModelService' '.partial_update', return_value=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 index 7caf4efd1..73e0c48e7 100644 --- 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 @@ -19,10 +19,10 @@ from src.processes.services.templates.field_template import ( FieldTemplateService, ) -from src.processes.services.templates.fieldsets.fieldset import ( +from src.processes.services.fieldsets.fieldset import ( FieldSetTemplateService, ) -from src.processes.services.templates.fieldsets.fieldset_rule import ( +from src.processes.services.fieldsets.fieldset_rule import ( FieldsetTemplateRuleService, ) from src.processes.tests.fixtures import ( @@ -349,7 +349,7 @@ def test_create_rules__with_data__ok(mocker): return_value=None, ) fieldset_template_rule_service_create_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset_rule.' + 'src.processes.services.fieldsets.fieldset_rule.' 'FieldsetTemplateRuleService.create', ) @@ -386,11 +386,11 @@ def test__create_related__default_params__ok(mocker): # mock create_rules_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService.create_rules', ) create_fields_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._create_fields', ) @@ -420,11 +420,11 @@ def test__create_related__rules_provided__ok(mocker): # mock create_rules_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService.create_rules', ) create_fields_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._create_fields', ) @@ -454,11 +454,11 @@ def test__create_related__fields_provided__ok(mocker): # mock create_rules_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService.create_rules', ) create_fields_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._create_fields', ) @@ -489,11 +489,11 @@ def test__create_related__both_provided__ok(mocker): # mock create_rules_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService.create_rules', ) create_fields_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._create_fields', ) @@ -725,7 +725,7 @@ def test__validate_rules__with_rules__ok(mocker): return_value=None, ) fieldset_template_rule_service_validate_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset_rule.' + 'src.processes.services.fieldsets.fieldset_rule.' 'FieldsetTemplateRuleService._validate', ) @@ -778,11 +778,11 @@ def test_update_rules__existing_rule__ok(mocker): return_value=None, ) fieldset_template_rule_service_partial_update_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset_rule.' + 'src.processes.services.fieldsets.fieldset_rule.' 'FieldsetTemplateRuleService.partial_update', ) fieldset_template_rule_service_create_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset_rule.' + 'src.processes.services.fieldsets.fieldset_rule.' 'FieldsetTemplateRuleService.create', ) @@ -839,12 +839,12 @@ def test_update_rules__new_rule__ok(mocker): return_value=None, ) fs_rule_create_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset_rule.' + 'src.processes.services.fieldsets.fieldset_rule.' 'FieldsetTemplateRuleService.create', return_value=create_return, ) fs_rule_update_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset_rule.' + 'src.processes.services.fieldsets.fieldset_rule.' 'FieldsetTemplateRuleService.partial_update', ) @@ -907,7 +907,7 @@ def test_update_rules__orphan_rules__deleted(mocker): return_value=None, ) fs_rule_update_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset_rule.' + 'src.processes.services.fieldsets.fieldset_rule.' 'FieldsetTemplateRuleService.partial_update', ) @@ -939,15 +939,15 @@ def test_partial_update_name__ok(mocker): template=template, ) mock_update_fields = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._update_fields', ) mock_update_rules = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService.update_rules', ) mock_validate_rules = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._validate_rules', ) service = FieldSetTemplateService(instance=fieldset, user=owner) @@ -978,15 +978,15 @@ def test_partial_update_fields_ok(mocker): service = FieldSetTemplateService(user=owner, instance=fieldset) mock_update_fields = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._update_fields', ) mock_update_rules = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService.update_rules', ) mock_validate_rules = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._validate_rules', ) mock_super_partial_update = mocker.patch( @@ -1027,15 +1027,15 @@ def test_partial_update__rules__ok(mocker): 'BaseModelService.partial_update', ) mock_update_fields = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._update_fields', ) mock_update_rules = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService.update_rules', ) mock_validate_rules = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._validate_rules', ) service = FieldSetTemplateService(user=owner, instance=fieldset) @@ -1314,7 +1314,7 @@ def test__replace_api_names__fields_and_rules__ok(mocker): new_field_api = 'new-field-1' new_rule_api = 'new-rule-1' create_api_name_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.create_api_name', + 'src.processes.services.fieldsets.fieldset.create_api_name', side_effect=[new_fs_api, new_field_api, new_rule_api], ) @@ -1355,7 +1355,7 @@ def test__replace_api_names__no_fields_key__ok(mocker): ) shared_fieldset_data = {'api_name': 'old-fs'} create_api_name_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.create_api_name', + 'src.processes.services.fieldsets.fieldset.create_api_name', return_value='new-fs-1', ) @@ -1387,7 +1387,7 @@ def test__replace_api_names__empty_fields__ok(mocker): ) shared_fieldset_data = {'api_name': 'old-fs', 'fields': []} create_api_name_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.create_api_name', + 'src.processes.services.fieldsets.fieldset.create_api_name', return_value='new-fs-1', ) @@ -1419,7 +1419,7 @@ def test__replace_api_names__no_rules_key__ok(mocker): ) shared_fieldset_data = {'api_name': 'old-fs', 'fields': []} create_api_name_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.create_api_name', + 'src.processes.services.fieldsets.fieldset.create_api_name', return_value='new-fs-1', ) @@ -1451,7 +1451,7 @@ def test__replace_api_names__empty_rules__ok(mocker): ) shared_fieldset_data = {'api_name': 'old-fs', 'rules': []} create_api_name_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.create_api_name', + 'src.processes.services.fieldsets.fieldset.create_api_name', return_value='new-fs-1', ) @@ -1488,7 +1488,7 @@ def test__replace_api_names__original_not_mutated__ok(mocker): 'rules': [], } create_api_name_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.create_api_name', + 'src.processes.services.fieldsets.fieldset.create_api_name', side_effect=['new-fs', 'new-field'], ) @@ -1517,7 +1517,7 @@ def test__get_new_fieldset_data__default_params__ok(mocker): ) shared_fieldset_data = {'api_name': 'old-fs'} replace_api_names_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._replace_api_names', return_value={'api_name': 'mocked-api', 'order': 3}, ) @@ -1549,7 +1549,7 @@ def test__get_new_fieldset_data__api_name_provided__ok(mocker): shared_fieldset_data = {'api_name': 'old-fs'} override_api_name = 'custom-api' replace_api_names_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._replace_api_names', return_value={'api_name': 'mocked-api'}, ) @@ -1582,7 +1582,7 @@ def test__get_new_fieldset_data__api_name_omitted__ok(mocker): shared_fieldset_data = {'api_name': 'old-fs'} mocked_api_name = 'mocked-api' replace_api_names_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._replace_api_names', return_value={'api_name': mocked_api_name}, ) @@ -1614,7 +1614,7 @@ def test__get_new_fieldset_data__title_provided__ok(mocker): shared_fieldset_data = {'api_name': 'old-fs'} override_title = 'Custom Title' replace_api_names_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._replace_api_names', return_value={'api_name': 'api', 'title': 'Original'}, ) @@ -1647,7 +1647,7 @@ def test__get_new_fieldset_data__title_omitted__ok(mocker): shared_fieldset_data = {'api_name': 'old-fs'} original_title = 'Original Title' replace_api_names_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._replace_api_names', return_value={'api_name': 'api', 'title': original_title}, ) @@ -1679,7 +1679,7 @@ def test__get_new_fieldset_data__description_provided__ok(mocker): shared_fieldset_data = {'api_name': 'old-fs'} override_description = 'New description' replace_api_names_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._replace_api_names', return_value={'api_name': 'api', 'description': 'Old desc'}, ) @@ -1712,7 +1712,7 @@ def test__get_new_fieldset_data__description_omitted__ok(mocker): shared_fieldset_data = {'api_name': 'old-fs'} original_description = 'Original description' replace_api_names_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._replace_api_names', return_value={'api_name': 'api', 'description': original_description}, ) @@ -1743,7 +1743,7 @@ def test__get_new_fieldset_data__order_present__removed(mocker): ) shared_fieldset_data = {'api_name': 'old-fs', 'order': 5} replace_api_names_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._replace_api_names', return_value={'api_name': 'api', 'order': 5}, ) @@ -1774,7 +1774,7 @@ def test__get_new_fieldset_data__no_order__ok(mocker): ) shared_fieldset_data = {'api_name': 'old-fs'} replace_api_names_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService._replace_api_names', return_value={'api_name': 'api'}, ) @@ -1808,12 +1808,12 @@ def test__create_from_shared__default_params__ok(mocker): shared_fieldset_id = 42 fieldset_data_from_mock = {'api_name': 'new-fs', 'name': 'Fieldset'} get_new_fieldset_data_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService.get_new_fieldset_data', return_value=fieldset_data_from_mock, ) create_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService.create', return_value=mocker.Mock(), ) @@ -1870,12 +1870,12 @@ def test__create_from_shared__all_params__ok(mocker): order = 3 fieldset_data_from_mock = {'api_name': api_name, 'name': 'Fieldset'} get_new_fieldset_data_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService.get_new_fieldset_data', return_value=fieldset_data_from_mock, ) create_mock = mocker.patch( - 'src.processes.services.templates.fieldsets.fieldset.' + 'src.processes.services.fieldsets.fieldset.' 'FieldSetTemplateService.create', return_value=mocker.Mock(), ) 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 index b73cd2b44..37ed01f5e 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_create.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_create.py @@ -18,7 +18,7 @@ ) from src.processes.models.templates.fields import FieldTemplate -from src.processes.services.templates.fieldsets.fieldset import ( +from src.processes.services.fieldsets.fieldset import ( FieldSetTemplateService, ) from src.processes.tests.fixtures import ( @@ -66,7 +66,7 @@ def test_create_fieldset__all_fields__ok(api_client, mocker): 'rules': [ { 'type': FieldSetRuleType.SUM_EQUAL, - 'value': 'val', + 'value': '10', 'api_name': 'r1', 'fields': [], }, @@ -97,7 +97,7 @@ def test_create_fieldset__all_fields__ok(api_client, mocker): account=account, fieldset=fieldset, type=FieldSetRuleType.SUM_EQUAL, - value='val', + value='10', api_name='r1', ) rule.fields.add(field) @@ -108,7 +108,8 @@ def test_create_fieldset__all_fields__ok(api_client, mocker): ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.fieldset.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', return_value=fieldset, ) @@ -178,7 +179,8 @@ def test_create_fieldset__min_data__ok(api_client, mocker): name='Minimal Fieldset', ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.fieldset.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', return_value=fieldset, ) @@ -226,7 +228,8 @@ def test_create_fieldset__set_api_name__ok(api_client, mocker): name='Minimal Fieldset', ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.fieldset.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', return_value=fieldset, ) @@ -317,7 +320,8 @@ def test_create_fieldset__rule_with_one_field__ok(api_client, mocker): rule.fields.add(field) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.fieldset.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', return_value=fieldset, ) @@ -416,7 +420,8 @@ def test_create_fieldset__rule_with_two_fields__ok(api_client, mocker): rule.fields.set([field_1, field_2]) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.fieldset.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', return_value=fieldset, ) @@ -461,7 +466,8 @@ def test_create_fieldset__unauthenticated__unauthorized(api_client, mocker): return_value=None, ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.fieldset.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', ) # act @@ -493,7 +499,8 @@ def test_create_fieldset__expired_sub__permission_denied(api_client, mocker): return_value=None, ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.fieldset.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', ) api_client.token_authenticate(user=user) @@ -524,7 +531,8 @@ def test_create_fieldset__billing_plan__permission_denied(api_client, mocker): return_value=None, ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.fieldset.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', ) api_client.token_authenticate(user=user) @@ -565,7 +573,8 @@ def test_create_fieldset__users_limit__permission_denied(api_client, mocker): ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.fieldset.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', ) api_client.token_authenticate(user=user) @@ -597,7 +606,8 @@ def test_create_fieldset__non_admin__permission_denied(api_client, mocker): ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.fieldset.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', ) api_client.token_authenticate(user=user) @@ -636,7 +646,8 @@ def test_create_fieldset__admin__ok(api_client, mocker): return_value=None, ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.fieldset.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', return_value=fieldset, ) @@ -676,7 +687,8 @@ def test_create_fieldset__blank_name__validation_error(api_client, mocker): ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.fieldset.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', ) api_client.token_authenticate(user=user) @@ -710,7 +722,8 @@ def test_create_fieldset__invalid_layout__validation_error(api_client, mocker): ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.fieldset.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', ) api_client.token_authenticate(user=user) @@ -747,7 +760,8 @@ def test_create_fieldset__invalid_label_position__validation_error( ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.fieldset.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', ) api_client.token_authenticate(user=user) @@ -783,7 +797,8 @@ def test_create_fieldset__service_exception__validation_error( return_value=None, ) fieldset_service_create_mock = mocker.patch( - 'src.processes.views.fieldset.FieldSetTemplateService.create', + 'src.processes.views.fieldset.FieldSetTemplateService.' + 'create_shared_fieldset', side_effect=BaseServiceException(message=error_message), ) 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 index f87059dc1..4ac7b306e 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_destroy.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_destroy.py @@ -5,16 +5,16 @@ 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.templates.fieldsets.fieldset import ( +from src.processes.services.fieldsets.fieldset import ( FieldSetTemplateService, ) from src.processes.tests.fixtures import ( create_test_account, - create_test_fieldset_template, create_test_not_admin, create_test_owner, - create_test_template, + create_test_shared_fieldset, ) pytestmark = pytest.mark.django_db @@ -26,14 +26,8 @@ def test_destroy__ok(api_client, mocker): # 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( + fieldset = create_test_shared_fieldset( account=account, - template=template, - kickoff=template.kickoff_instance, ) # mock FieldSetTemplateService @@ -59,7 +53,7 @@ def test_destroy__ok(api_client, mocker): user=user, instance=fieldset, is_superuser=False, - auth_type=mocker.ANY, + auth_type=AuthTokenType.USER, ) field_set_template_service_delete_mock.assert_called_once_with() @@ -69,15 +63,9 @@ def test_destroy__unauthenticated__unauthorized(api_client, mocker): # 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( + create_test_owner(account=account) + fieldset = create_test_shared_fieldset( account=account, - template=template, - kickoff=template.kickoff_instance, ) field_set_template_service_init_mock = mocker.patch.object( @@ -108,14 +96,8 @@ def test_destroy__expired_sub__permission_denied(api_client, mocker): plan_expiration=timezone.now() - timedelta(days=1), ) user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) - fieldset = create_test_fieldset_template( + fieldset = create_test_shared_fieldset( account=account, - template=template, - kickoff=template.kickoff_instance, ) api_client.token_authenticate(user=user) @@ -146,14 +128,8 @@ def test_destroy__billing_plan__permission_denied(api_client, mocker): # arrange account = create_test_account(plan=None) user = create_test_owner(account=account) - template = create_test_template( - user=user, - tasks_count=1, - ) - fieldset = create_test_fieldset_template( + fieldset = create_test_shared_fieldset( account=account, - template=template, - kickoff=template.kickoff_instance, ) api_client.token_authenticate(user=user) @@ -193,14 +169,8 @@ def test_destroy__users_overlimit__permission_denied(api_client, mocker): ) account.active_users = 2 account.save() - template = create_test_template( - user=user, - tasks_count=1, - ) - fieldset = create_test_fieldset_template( + fieldset = create_test_shared_fieldset( account=account, - template=template, - kickoff=template.kickoff_instance, ) api_client.token_authenticate(user=user) @@ -230,15 +200,9 @@ def test_destroy__non_admin__permission_denied(api_client, mocker): # arrange account = create_test_account() - owner = create_test_owner(account=account) - template = create_test_template( - user=owner, - tasks_count=1, - ) - fieldset = create_test_fieldset_template( + create_test_owner(account=account) + fieldset = create_test_shared_fieldset( account=account, - template=template, - kickoff=template.kickoff_instance, ) user = create_test_not_admin(account=account) @@ -269,14 +233,8 @@ def test_destroy__service_exception__validation_error(api_client, mocker): # 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( + fieldset = create_test_shared_fieldset( account=account, - template=template, - kickoff=template.kickoff_instance, ) error_message = 'Service error occurred' @@ -305,7 +263,7 @@ def test_destroy__service_exception__validation_error(api_client, mocker): user=user, instance=fieldset, is_superuser=False, - auth_type=mocker.ANY, + auth_type=AuthTokenType.USER, ) field_set_template_service_delete_mock.assert_called_once_with() @@ -339,3 +297,35 @@ def test_destroy__not_existing__not_found(api_client, mocker): 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 index 3dddc1506..ee7b470e4 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_list.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_list.py @@ -714,3 +714,24 @@ def test_list_fieldsets__soft_deleted__ok(api_client): # 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 index b1f0d2ef9..51a34bf9b 100644 --- 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 @@ -14,7 +14,7 @@ FieldType, ) from src.processes.models.templates.fieldset import FieldsetTemplateRule -from src.processes.services.templates.fieldsets.fieldset import ( +from src.processes.services.fieldsets.fieldset import ( FieldSetTemplateService, ) from src.processes.tests.fixtures import ( @@ -37,6 +37,7 @@ def test_partial_update__all_fields__ok(api_client, mocker): 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', @@ -46,7 +47,7 @@ def test_partial_update__all_fields__ok(api_client, mocker): 'fields': [ { 'name': 'Field 1', - 'type': FieldType.TEXT, + 'type': FieldType.NUMBER, 'order': 1, 'api_name': field_api_name, }, @@ -55,7 +56,7 @@ def test_partial_update__all_fields__ok(api_client, mocker): { 'type': FieldSetRuleType.SUM_EQUAL, 'value': '10', - 'api_name': 'r1', + 'api_name': rule_api_name, 'fields': [field_api_name], }, ], @@ -116,7 +117,7 @@ def test_partial_update__all_fields__ok(api_client, mocker): ) fieldset_partial_update_mock.assert_called_once_with( name='Full Updated Fieldset', - api_name=fieldset_api_name, + api_name=fieldset.api_name, description='Updated description', layout=FieldSetLayout.HORIZONTAL, label_position=LabelPosition.LEFT, @@ -694,3 +695,41 @@ def test_partial_update__not_existing_fieldset__not_found(api_client, mocker): 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() 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 index f826f3768..4e2dc2d57 100644 --- a/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py @@ -244,3 +244,25 @@ def test_retrieve__another_account__not_found(api_client): # 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/views/fieldset.py b/backend/src/processes/views/fieldset.py index 12dc80fba..da757e102 100644 --- a/backend/src/processes/views/fieldset.py +++ b/backend/src/processes/views/fieldset.py @@ -20,7 +20,7 @@ from src.processes.serializers.templates.template import ( FieldsetTemplateFilterSerializer, ) -from src.processes.services.templates.fieldsets.fieldset import ( +from src.processes.services.fieldsets.fieldset import ( FieldSetTemplateService, ) from src.utils.validation import raise_validation_error @@ -59,6 +59,7 @@ def get_queryset(self): return ( FieldsetTemplate.objects .select_related('template') + .shared() .on_account(user.account_id) ) @@ -77,11 +78,14 @@ def create(self, request, *args, **kwargs): auth_type=request.token_type, ) try: - fieldset = service.create(**serializer.validated_data) + fieldset = service.create_shared_fieldset( + **serializer.validated_data, + ) except BaseServiceException as ex: raise_validation_error(message=ex.message) - response_serializer = SharedFieldsetTemplateSerializer(fieldset) - return self.response_created(response_serializer.data) + else: + response_serializer = SharedFieldsetTemplateSerializer(fieldset) + return self.response_created(response_serializer.data) def retrieve(self, request, *args, **kwargs): fieldset = self.get_object() From 314ba8a722646d5f4e178fbd8486a07fdd262979 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 23 Jun 2026 21:33:14 +0500 Subject: [PATCH 36/46] 45773 fix(migrations): reorder fieldset migrations --- ...20260609_1910.py => 0254_add_fieldsets.py} | 103 +++++++++--------- 1 file changed, 50 insertions(+), 53 deletions(-) rename backend/src/processes/migrations/{0254_auto_20260609_1910.py => 0254_add_fieldsets.py} (67%) diff --git a/backend/src/processes/migrations/0254_auto_20260609_1910.py b/backend/src/processes/migrations/0254_add_fieldsets.py similarity index 67% rename from backend/src/processes/migrations/0254_auto_20260609_1910.py rename to backend/src/processes/migrations/0254_add_fieldsets.py index 81dcbf3ca..e865e2436 100644 --- a/backend/src/processes/migrations/0254_auto_20260609_1910.py +++ b/backend/src/processes/migrations/0254_add_fieldsets.py @@ -1,6 +1,3 @@ -# Generated by Django 2.2 on 2026-06-09 19:10 - -import django.contrib.postgres.fields from django.db import migrations, models import django.db.models.deletion import src.generics.mixins.models @@ -9,7 +6,6 @@ class Migration(migrations.Migration): dependencies = [ - ('accounts', '0144_auto_20260609_1910'), ('processes', '0253_add_completed_or_skipped_predicate'), ] @@ -22,12 +18,13 @@ class Migration(migrations.Migration): ('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)), - ('title', models.TextField(blank=True, default='')), - ('order', models.IntegerField(default=0)), ('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'], @@ -58,17 +55,11 @@ class Migration(migrations.Migration): ('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)), - ('title', models.TextField(blank=True, default='')), - ('order', models.IntegerField(default=0)), ('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)), - ('is_shared', models.BooleanField(default=True)), ('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.Kickoff')), - ('shared_fieldset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_fieldsets', to='processes.FieldsetTemplate')), - ('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.TaskTemplate')), - ('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Template')), + ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Template')), ], options={ 'ordering': ['-id'], @@ -91,40 +82,58 @@ class Migration(migrations.Migration): }, bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model), ), - 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.AlterField( - model_name='rawperformer', - name='type', - field=models.CharField(choices=[('user', 'user'), ('group', 'group'), ('workflow_starter', 'workflow_starter'), ('field', 'field'), ('manager', 'manager')], max_length=100), + 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.AlterField( - model_name='rawperformertemplate', - name='type', - field=models.CharField(choices=[('user', 'user'), ('group', 'group'), ('workflow_starter', 'workflow_starter'), ('field', 'field'), ('manager', 'manager')], max_length=100), + 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.AlterField( - model_name='task', - name='parents', - field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=200), default=list, help_text='Api names of task parents', size=None), + migrations.AddField( + model_name='fieldsettemplate', + name='kickoffs', + field=models.ManyToManyField(blank=True, related_name='fieldsets', through='processes.FieldsetTemplateKickoff', to='processes.Kickoff'), ), - migrations.AlterField( - model_name='taskperformer', - name='type', - field=models.CharField(choices=[('user', 'user'), ('group', 'group'), ('workflow_starter', 'workflow_starter'), ('field', 'field'), ('manager', 'manager')], default='user', max_length=100), + 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='fieldset', - name='task', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Task'), + model_name='fieldsettemplate', + name='tasks', + field=models.ManyToManyField(blank=True, related_name='fieldsets', through='processes.FieldsetTemplateTaskTemplate', to='processes.TaskTemplate'), ), - migrations.AddField( - model_name='fieldset', - name='workflow', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Workflow'), + 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', @@ -146,16 +155,4 @@ class Migration(migrations.Migration): name='rules', field=models.ManyToManyField(blank=True, related_name='fields', to='processes.FieldSetRule'), ), - migrations.AddConstraint( - model_name='fieldsettemplate', - constraint=models.UniqueConstraint(condition=models.Q(is_deleted=False), fields=('api_name', 'template'), name='fieldsettemplate_api_name_template_unique'), - ), - 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'), - ), ] From 8cb04546b3bd3ce6454997f3a4b75bf5171cb898 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 23 Jun 2026 21:54:23 +0500 Subject: [PATCH 37/46] 45773 fix(models): add 0255 migration for "shared fieldsets" --- .../migrations/0255_add_shared_fieldsets.py | 85 +++++++++++++++++++ .../processes/models/templates/fieldset.py | 75 ++++++++++++++++ .../processes/services/fieldsets/fieldset.py | 2 +- .../test_fieldset_template_service.py | 4 +- 4 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 backend/src/processes/migrations/0255_add_shared_fieldsets.py 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..9ba48a31c --- /dev/null +++ b/backend/src/processes/migrations/0255_add_shared_fieldsets.py @@ -0,0 +1,85 @@ +# Generated by Django 2.2 on 2026-06-23 16:49 + +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.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'), name='fieldsettemplate_api_name_template_unique'), + ), + migrations.AddConstraint( + model_name='fieldsettemplaterule', + constraint=models.UniqueConstraint( + condition=models.Q(is_deleted=False), + fields=('api_name', 'fieldset'), + name='fieldsettemplate_rule_api_name_template_unique', + ), + ), + ] diff --git a/backend/src/processes/models/templates/fieldset.py b/backend/src/processes/models/templates/fieldset.py index 3e6e41068..bc733b288 100644 --- a/backend/src/processes/models/templates/fieldset.py +++ b/backend/src/processes/models/templates/fieldset.py @@ -3,6 +3,7 @@ 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, @@ -68,6 +69,21 @@ class Meta: 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, )() @@ -84,6 +100,13 @@ class FieldsetTemplateRule( 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' @@ -99,3 +122,55 @@ class Meta: 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/services/fieldsets/fieldset.py b/backend/src/processes/services/fieldsets/fieldset.py index a22865674..d09092250 100644 --- a/backend/src/processes/services/fieldsets/fieldset.py +++ b/backend/src/processes/services/fieldsets/fieldset.py @@ -120,7 +120,7 @@ def create_from_shared( return self.create( **fieldset_data, - is_shared=True, + is_shared=False, shared_fieldset_id=shared_fieldset_id, order=order, kickoff_id=kickoff_id, 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 index 73e0c48e7..ae2b889e0 100644 --- 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 @@ -1836,7 +1836,7 @@ def test__create_from_shared__default_params__ok(mocker): create_mock.assert_called_once_with( api_name='new-fs', name='Fieldset', - is_shared=True, + is_shared=False, shared_fieldset_id=shared_fieldset_id, order=0, kickoff_id=None, @@ -1904,7 +1904,7 @@ def test__create_from_shared__all_params__ok(mocker): create_mock.assert_called_once_with( api_name=api_name, name='Fieldset', - is_shared=True, + is_shared=False, shared_fieldset_id=shared_fieldset_id, order=order, kickoff_id=kickoff.id, From a05e37e2f54b1a79e0c5d6a2bde7e52420b55411 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Wed, 24 Jun 2026 04:20:54 +0500 Subject: [PATCH 38/46] 45773 feat(fieldsets): migrate firldsets command --- .../management/commands/migrate_fieldsets.py | 195 +++++++++++++++++ .../management/commands/migrate_presets.py | 42 ---- .../migrations/0255_add_shared_fieldsets.py | 198 +++++++++++++++++- .../processes/models/templates/fieldset.py | 2 +- .../processes/services/fieldsets/fieldset.py | 3 +- 5 files changed, 394 insertions(+), 46 deletions(-) create mode 100644 backend/src/processes/management/commands/migrate_fieldsets.py delete mode 100644 backend/src/processes/management/commands/migrate_presets.py 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..0fccc2840 --- /dev/null +++ b/backend/src/processes/management/commands/migrate_fieldsets.py @@ -0,0 +1,195 @@ +# ruff: noqa: T201 BLE001 +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 get_drafts_by_fieldsets(self) -> dict: + """ + Returns a dict where the key is fieldset api_name + and the value is a list of template IDs that contain that fieldset. + """ + result = {} + + for draft_obj in TemplateDraft.objects.all(): + draft = draft_obj.draft + if not isinstance(draft, dict): + continue + + # Process tasks -> fieldsets + for task in draft.get('tasks') or []: + for fieldset in task.get('fieldsets') or []: + if isinstance(fieldset, dict): + api_name = fieldset.get('api_name') + if api_name: + result.setdefault(api_name, set()) + result[api_name].add(draft_obj.id) + + # Process kickoff -> fieldsets + kickoff = draft.get('kickoff') + if isinstance(kickoff, dict): + for fieldset in kickoff.get('fieldsets') or []: + if isinstance(fieldset, dict): + api_name = fieldset.get('api_name') + if api_name: + result.setdefault(api_name, set()) + result[api_name].add(draft_obj.id) + + return result + + def update_draft_fieldset( + self, + draft_id: int, + fieldset_data: dict, + fieldset_api_name: str, + shared_fieldset_id: int, + ): + draft_obj = TemplateDraft.objects.get(id=draft_id) + draft = draft_obj.draft + if not isinstance(draft, dict): + return + + updated = False + + # Update matching fieldsets in tasks + for task in draft.get('tasks') or []: + for fieldset in task.get('fieldsets') or []: + if fieldset.get('api_name') == fieldset_api_name: + fieldset.update(fieldset_data) + fieldset['shared_fieldset_id'] = shared_fieldset_id + updated = True + print( + f'*** Draft {draft_id} ' + f'(template {draft_obj.draft["name"]})' + f' - task "{task.get("name", "?")}"' + f' - fieldset "{fieldset_data["name"]}" updated', + ) + + # Update matching fieldsets in kickoff + kickoff = draft.get('kickoff') + if isinstance(kickoff, dict): + for fieldset in kickoff.get('fieldsets') or []: + if fieldset.get('api_name') == fieldset_api_name: + fieldset.update(fieldset_data) + fieldset['shared_fieldset_id'] = shared_fieldset_id + updated = True + print( + f'*** Draft {draft_id} ' + f'(template {draft_obj.draft["name"]})' + f' - kickoff' + f' - fieldset "{fieldset_data["name"]}" updated', + ) + + if updated: + draft_obj.save(update_fields=('draft',)) + print(f' Draft {draft_id} saved') + + def handle(self, *args, **options): + drafts_by_old_fieldsets = self.get_drafts_by_fieldsets() + old_fieldsets = ( + FieldsetTemplate.objects + .filter(is_shared=True) + .order_by('account_id') + ) + with transaction.atomic(): + for old_fieldset in old_fieldsets: + # Ensure the original is marked as shared + old_fieldset.template_id = None + old_fieldset.is_shared = True + + # Build the serialized representation of the shared fieldset + fieldset_data = FieldSetTemplateService.to_json(old_fieldset) + fieldset_data.pop('id') + fieldset_data.pop('api_name') + fieldset_data.pop('order') + shared_fieldset_data = ( + FieldSetTemplateService._replace_api_names(fieldset_data) + ) + old_fieldset.fields.delete() + old_fieldset.rules.delete() + user = old_fieldset.account.get_owner() + + shared_service = FieldSetTemplateService(user=user) + shared_fieldset = shared_service.create( + is_shared=True, + **shared_fieldset_data, + ) + print( + f'Shared - {shared_fieldset.name} : {shared_fieldset.id}', + ) + + # update drafts + for draft_id in drafts_by_old_fieldsets.get( + old_fieldset.api_name, [], + ): + self.update_draft_fieldset( + draft_id=draft_id, + fieldset_data=fieldset_data, + fieldset_api_name=old_fieldset.api_name, + shared_fieldset_id=shared_fieldset.id, + ) + + kickoff_links = old_fieldset.kickoffs.through.objects.filter( + fieldset=old_fieldset, + is_deleted=False, + ) + for link in kickoff_links: + if not FieldsetTemplate.objects.filter( + shared_fieldset_id=shared_fieldset.id, + is_shared=False, + kickoff_id=link.kickoff_id, + ).exists(): + service = FieldSetTemplateService(user=user) + new_fs = service.create( + **fieldset_data, + is_shared=False, + shared_fieldset_id=shared_fieldset.id, + order=link.order, + kickoff_id=link.kickoff_id, + template_id=link.kickoff.template_id, + ) + print( + f'+++ {link.kickoff.template.name} : ' + f'{link.kickoff.template_id} - ' + f'{new_fs.name} : {new_fs.id}', + ) + link.delete() + + task_links = old_fieldset.tasks.through.objects.filter( + fieldset=old_fieldset, + is_deleted=False, + ) + + for link in task_links: + if not FieldsetTemplate.objects.filter( + shared_fieldset_id=shared_fieldset.id, + is_shared=False, + task_id=link.task_id, + ).exists(): + service = FieldSetTemplateService(user=user) + try: + new_fs = service.create( + **fieldset_data, + is_shared=False, + shared_fieldset_id=shared_fieldset.id, + order=link.order, + task_id=link.task_id, + template_id=link.task.template_id, + ) + print( + f'+++ {link.task.template.name} : ' + f'{link.task.template_id} - ' + f'{new_fs.name} : {new_fs.id}', + ) + except Exception: + print( + f'--- Duplicate ' + f'{link.task.template.name} : ' + f'{link.task.template_id}', + ) + link.delete() + old_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/migrations/0255_add_shared_fieldsets.py b/backend/src/processes/migrations/0255_add_shared_fieldsets.py index 9ba48a31c..b6036c949 100644 --- a/backend/src/processes/migrations/0255_add_shared_fieldsets.py +++ b/backend/src/processes/migrations/0255_add_shared_fieldsets.py @@ -1,9 +1,199 @@ # Generated by Django 2.2 on 2026-06-23 16:49 +# ruff: noqa: T201 BLE001 -from django.db import migrations, models +from django.db import migrations, models, transaction import django.db.models.deletion +def get_drafts_by_fieldsets(TemplateDraft): + """ + Returns a dict: {fieldset_api_name: set of TemplateDraft IDs} + """ + result = {} + for draft_obj in TemplateDraft.objects.all(): + draft = draft_obj.draft + if not isinstance(draft, dict): + continue + + # Process tasks -> fieldsets + for task in draft.get('tasks') or []: + for fieldset in task.get('fieldsets') or []: + if isinstance(fieldset, dict): + api_name = fieldset.get('api_name') + if api_name: + result.setdefault(api_name, set()) + result[api_name].add(draft_obj.id) + + # Process kickoff -> fieldsets + kickoff = draft.get('kickoff') + if isinstance(kickoff, dict): + for fieldset in kickoff.get('fieldsets') or []: + if isinstance(fieldset, dict): + api_name = fieldset.get('api_name') + if api_name: + result.setdefault(api_name, set()) + result[api_name].add(draft_obj.id) + + return result + + +def update_draft_fieldset(TemplateDraft, draft_id, fieldset_data, + fieldset_api_name, shared_fieldset_id): + draft_obj = TemplateDraft.objects.get(id=draft_id) + draft = draft_obj.draft + if not isinstance(draft, dict): + return + + updated = False + + # Update matching fieldsets in tasks + for task in draft.get('tasks') or []: + for fieldset in task.get('fieldsets') or []: + if fieldset.get('api_name') == fieldset_api_name: + fieldset.update(fieldset_data) + fieldset['shared_fieldset_id'] = shared_fieldset_id + updated = True + print( + f'*** Draft {draft_id} ' + f'(template {draft_obj.draft["name"]})' + f' - task "{task.get("name", "?")}"' + f' - fieldset "{fieldset_data["name"]}" updated', + ) + + # Update matching fieldsets in kickoff + kickoff = draft.get('kickoff') + if isinstance(kickoff, dict): + for fieldset in kickoff.get('fieldsets') or []: + if fieldset.get('api_name') == fieldset_api_name: + fieldset.update(fieldset_data) + fieldset['shared_fieldset_id'] = shared_fieldset_id + updated = True + print( + f'*** Draft {draft_id} ' + f'(template {draft_obj.draft["name"]})' + f' - kickoff' + f' - fieldset "{fieldset_data["name"]}" updated', + ) + + if updated: + draft_obj.save(update_fields=('draft',)) + print(f' Draft {draft_id} saved') + + +def migrate_shared_fieldsets(apps, schema_editor): + FieldsetTemplate = apps.get_model('processes', 'FieldsetTemplate') + TemplateDraft = apps.get_model('processes', 'TemplateDraft') + FieldSetTemplateService = __import__( + 'src.processes.services.fieldsets.fieldset', + fromlist=['FieldSetTemplateService'], + ).FieldSetTemplateService + + drafts_by_old_fieldsets = get_drafts_by_fieldsets(TemplateDraft) + old_fieldsets = ( + FieldsetTemplate.objects + .filter(is_shared=True) + .order_by('account_id') + ) + with transaction.atomic(): + for old_fieldset in old_fieldsets: + # Ensure the original is marked as shared + old_fieldset.template_id = None + old_fieldset.is_shared = True + + # Build the serialized representation of the shared fieldset + fieldset_data = FieldSetTemplateService.to_json(old_fieldset) + fieldset_data.pop('id') + fieldset_data.pop('api_name') + fieldset_data.pop('order') + shared_fieldset_data = ( + FieldSetTemplateService._replace_api_names(fieldset_data) + ) + old_fieldset.fields.delete() + old_fieldset.rules.delete() + user = old_fieldset.account.get_owner() + + shared_service = FieldSetTemplateService(user=user) + shared_fieldset = shared_service.create( + is_shared=True, + **shared_fieldset_data, + ) + print( + f'Shared - {shared_fieldset.name} : {shared_fieldset.id}', + ) + + # Update drafts + for draft_id in drafts_by_old_fieldsets.get( + old_fieldset.api_name, [], + ): + update_draft_fieldset( + TemplateDraft=TemplateDraft, + draft_id=draft_id, + fieldset_data=fieldset_data, + fieldset_api_name=old_fieldset.api_name, + shared_fieldset_id=shared_fieldset.id, + ) + + kickoff_links = old_fieldset.kickoffs.through.objects.filter( + fieldset=old_fieldset, + is_deleted=False, + ) + for link in kickoff_links: + if not FieldsetTemplate.objects.filter( + shared_fieldset_id=shared_fieldset.id, + is_shared=False, + kickoff_id=link.kickoff_id, + ).exists(): + service = FieldSetTemplateService(user=user) + new_fs = service.create( + **fieldset_data, + is_shared=False, + shared_fieldset_id=shared_fieldset.id, + order=link.order, + kickoff_id=link.kickoff_id, + template_id=link.kickoff.template_id, + ) + print( + f'+++ {link.kickoff.template.name} : ' + f'{link.kickoff.template_id} - ' + f'{new_fs.name} : {new_fs.id}', + ) + link.delete() + + task_links = old_fieldset.tasks.through.objects.filter( + fieldset=old_fieldset, + is_deleted=False, + ) + for link in task_links: + if not FieldsetTemplate.objects.filter( + shared_fieldset_id=shared_fieldset.id, + is_shared=False, + task_id=link.task_id, + ).exists(): + service = FieldSetTemplateService(user=user) + try: + new_fs = service.create( + **fieldset_data, + is_shared=False, + shared_fieldset_id=shared_fieldset.id, + order=link.order, + task_id=link.task_id, + template_id=link.task.template_id, + ) + print( + f'+++ {link.task.template.name} : ' + f'{link.task.template_id} - ' + f'{new_fs.name} : {new_fs.id}', + ) + except Exception: + print( + f'--- Duplicate ' + f'{link.task.template.name} : ' + f'{link.task.template_id}', + ) + link.delete() + old_fieldset.delete() + + class Migration(migrations.Migration): dependencies = [ @@ -72,7 +262,7 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='fieldsettemplate', - constraint=models.UniqueConstraint(condition=models.Q(is_deleted=False), fields=('api_name', 'template'), name='fieldsettemplate_api_name_template_unique'), + constraint=models.UniqueConstraint(condition=models.Q(is_deleted=False), fields=('api_name', 'template', 'is_shared'), name='fieldsettemplate_api_name_template_unique'), ), migrations.AddConstraint( model_name='fieldsettemplaterule', @@ -82,4 +272,8 @@ class Migration(migrations.Migration): name='fieldsettemplate_rule_api_name_template_unique', ), ), + migrations.RunPython( + migrate_shared_fieldsets, + migrations.RunPython.noop, + ), ] diff --git a/backend/src/processes/models/templates/fieldset.py b/backend/src/processes/models/templates/fieldset.py index bc733b288..fe8f7a5e7 100644 --- a/backend/src/processes/models/templates/fieldset.py +++ b/backend/src/processes/models/templates/fieldset.py @@ -28,7 +28,7 @@ class Meta: ordering = ['-id'] constraints = [ UniqueConstraint( - fields=['api_name', 'template'], + fields=['api_name', 'template', 'is_shared'], condition=Q(is_deleted=False), name='fieldsettemplate_api_name_template_unique', ), diff --git a/backend/src/processes/services/fieldsets/fieldset.py b/backend/src/processes/services/fieldsets/fieldset.py index d09092250..00004363f 100644 --- a/backend/src/processes/services/fieldsets/fieldset.py +++ b/backend/src/processes/services/fieldsets/fieldset.py @@ -247,7 +247,8 @@ def delete(self) -> None: raise FieldsetTemplateInUseException self.instance.delete() - def _replace_api_names(self, shared_fieldset_data: dict) -> dict: + @staticmethod + def _replace_api_names(shared_fieldset_data: dict) -> dict: fieldset_data = deepcopy(shared_fieldset_data) fieldset_data['api_name'] = create_api_name( From 3605870a86fc1d5dc129f9065bcdac3feb286f66 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 30 Jun 2026 03:42:47 +0500 Subject: [PATCH 39/46] 45773 fix(templates): create fieldset api_name from the request data --- .../serializers/templates/fieldset.py | 8 +- .../processes/serializers/templates/mixins.py | 2 +- .../test_create/test_template.py | 253 ++++++++++++++---- .../test_public/test_retrieve.py | 3 +- 4 files changed, 216 insertions(+), 50 deletions(-) diff --git a/backend/src/processes/serializers/templates/fieldset.py b/backend/src/processes/serializers/templates/fieldset.py index 3f7cd3992..b50a57c81 100644 --- a/backend/src/processes/serializers/templates/fieldset.py +++ b/backend/src/processes/serializers/templates/fieldset.py @@ -1,4 +1,5 @@ -from rest_framework.fields import CharField +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, @@ -78,6 +79,11 @@ class Meta: required=False, default=list, ) + order = IntegerField( + required=False, + default=0, + validators=[MinValueValidator(0)], + ) class SharedFieldsetTemplateSerializer( diff --git a/backend/src/processes/serializers/templates/mixins.py b/backend/src/processes/serializers/templates/mixins.py index a1addf09c..494abe626 100644 --- a/backend/src/processes/serializers/templates/mixins.py +++ b/backend/src/processes/serializers/templates/mixins.py @@ -264,7 +264,7 @@ def create_or_update_fieldsets( 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.pop('api_name', None) + 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 = {} 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 f37a8052a..26b9b1cd9 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 @@ -20,7 +20,7 @@ OwnerRole, OwnerType, PerformerType, - PredicateOperator, + PredicateOperator, FieldSetRuleType, LabelPosition, FieldSetLayout, ) from src.processes.messages import template as messages from src.processes.models.templates.conditions import ( @@ -3834,7 +3834,7 @@ def test_create__invalid_wf_name_template__validation_error( templates_created_mock.assert_not_called() -def test_create__kickoff_with_one_fieldset__ok( +def test_create__kickoff_only_required_data__ok( mocker, api_client, ): @@ -3859,7 +3859,30 @@ def test_create__kickoff_with_one_fieldset__ok( 'src.processes.views.template.' 'AnalyticService.templates_kickoff_created', ) - shared = create_test_shared_fieldset(account=account) + 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': [ @@ -3873,8 +3896,7 @@ def test_create__kickoff_with_one_fieldset__ok( 'kickoff': { 'fieldsets': [ { - 'shared_fieldset_id': shared.id, - 'order': 1, + 'shared_fieldset_id': shared_fieldset.id, }, ], }, @@ -3902,26 +3924,51 @@ def test_create__kickoff_with_one_fieldset__ok( assert response.status_code == 200 template = Template.objects.get(id=response.data['id']) kickoff = template.kickoff_instance - assert kickoff is not None fieldset = FieldsetTemplate.objects.get( kickoff=kickoff, - shared_fieldset=shared, + shared_fieldset=shared_fieldset, + is_shared=False, ) - assert fieldset.order == 1 - assert fieldset.name == shared.name + field = fieldset.fields.first() + rule = fieldset.rules.first() + kickoff_data = response.data['kickoff'] assert len(kickoff_data['fieldsets']) == 1 - assert kickoff_data['fieldsets'][0]['shared_fieldset_id'] == shared.id - - -def test_create__kickoff_with_empty_fieldsets__no_links_created( + 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_all_fieldset_data__ok( 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) @@ -3939,8 +3986,14 @@ def test_create__kickoff_with_empty_fieldsets__no_links_created( '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 no fieldsets', + 'name': 'Template with fieldset', 'owners': [ { 'type': OwnerType.USER, @@ -3950,7 +4003,15 @@ def test_create__kickoff_with_empty_fieldsets__no_links_created( ], 'is_active': True, 'kickoff': { - 'fieldsets': [], + 'fieldsets': [ + { + 'shared_fieldset_id': shared_fieldset.id, + 'order': fs_order, + 'title': fs_title, + 'description': fs_description, + 'api_name': fs_api_name, + }, + ], }, 'tasks': [ { @@ -3976,17 +4037,34 @@ def test_create__kickoff_with_empty_fieldsets__no_links_created( assert response.status_code == 200 template = Template.objects.get(id=response.data['id']) kickoff = template.kickoff_instance - assert FieldsetTemplate.objects.filter( + assert FieldsetTemplate.objects.get( kickoff=kickoff, - ).count() == 0 - + shared_fieldset=shared_fieldset, + is_shared=False, + api_name=fs_api_name, + ) -def test_create__kickoff_without_fieldsets_key__no_links_created( + 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 without fieldsets key in kickoff does not + """ Creating a template with empty fieldsets list does not create any FieldsetTemplateKickoff records. """ # arrange @@ -4007,7 +4085,7 @@ def test_create__kickoff_without_fieldsets_key__no_links_created( 'AnalyticService.templates_kickoff_created', ) request_data = { - 'name': 'Template no fieldsets key', + 'name': 'Template no fieldsets', 'owners': [ { 'type': OwnerType.USER, @@ -4016,7 +4094,9 @@ def test_create__kickoff_without_fieldsets_key__no_links_created( }, ], 'is_active': True, - 'kickoff': {}, + 'kickoff': { + 'fieldsets': [], + }, 'tasks': [ { 'number': 1, @@ -4046,7 +4126,7 @@ def test_create__kickoff_without_fieldsets_key__no_links_created( ).count() == 0 -def test_create__task_with_one_fieldset__ok( +def test_create__task_fieldset_only_required_data__ok( mocker, api_client, ): @@ -4067,7 +4147,30 @@ def test_create__task_with_one_fieldset__ok( 'src.processes.views.template.' 'AnalyticService.templates_kickoff_created', ) - shared = create_test_shared_fieldset(account=account) + 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': [ @@ -4091,8 +4194,7 @@ def test_create__task_with_one_fieldset__ok( ], 'fieldsets': [ { - 'shared_fieldset_id': shared.id, - 'order': 1, + 'shared_fieldset_id': shared_fieldset.id, }, ], }, @@ -4109,19 +4211,47 @@ def test_create__task_with_one_fieldset__ok( assert response.status_code == 200 template = Template.objects.get(id=response.data['id']) task = template.tasks.first() - assert task is not None fieldset = FieldsetTemplate.objects.get( task=task, - shared_fieldset=shared, + shared_fieldset=shared_fieldset, + is_shared=False, ) - assert fieldset.order == 1 - assert fieldset.name == shared.name + field = fieldset.fields.first() + rule = fieldset.rules.first() + task_data = response.data['tasks'][0] assert len(task_data['fieldsets']) == 1 - assert task_data['fieldsets'][0]['shared_fieldset_id'] == shared.id - - -def test_create__task_with_empty_fieldsets__no_links_created( + 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_all_fieldset_data__ok( mocker, api_client, ): @@ -4139,8 +4269,14 @@ def test_create__task_with_empty_fieldsets__no_links_created( '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 without task fieldsets', + 'name': 'Template with task fieldset', 'owners': [ { 'type': OwnerType.USER, @@ -4160,7 +4296,15 @@ def test_create__task_with_empty_fieldsets__no_links_created( 'source_id': user.id, }, ], - 'fieldsets': [], + 'fieldsets': [ + { + 'shared_fieldset_id': shared_fieldset.id, + 'order': fs_order, + 'title': fs_title, + 'description': fs_description, + 'api_name': fs_api_name, + }, + ], }, ], } @@ -4175,19 +4319,33 @@ def test_create__task_with_empty_fieldsets__no_links_created( assert response.status_code == 200 template = Template.objects.get(id=response.data['id']) task = template.tasks.first() - assert FieldsetTemplate.objects.filter( + assert FieldsetTemplate.objects.get( task=task, - ).count() == 0 - + shared_fieldset=shared_fieldset, + is_shared=False, + api_name=fs_api_name, + ) -def test_create__task_without_fieldsets_key__no_links_created( + 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, ): - """ Creating a template without fieldsets key in task does not - create any FieldsetTemplateTaskTemplate records. """ - # arrange account = create_test_account() user = create_test_user(account=account) @@ -4202,7 +4360,7 @@ def test_create__task_without_fieldsets_key__no_links_created( 'AnalyticService.templates_kickoff_created', ) request_data = { - 'name': 'Template no fieldsets key', + 'name': 'Template without task fieldsets', 'owners': [ { 'type': OwnerType.USER, @@ -4222,6 +4380,7 @@ def test_create__task_without_fieldsets_key__no_links_created( 'source_id': user.id, }, ], + 'fieldsets': [], }, ], } 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 7c33945a6..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 @@ -14,7 +14,8 @@ create_test_fieldset_template, create_test_owner, create_test_dataset, - create_test_account, create_test_shared_fieldset, + create_test_account, + create_test_shared_fieldset, ) pytestmark = pytest.mark.django_db From 4d063f84d6ef2bd7bf92f1312515e6a7b1c163c3 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 30 Jun 2026 04:59:49 +0500 Subject: [PATCH 40/46] 45773 fix(templates): update tests for update fieldset template --- .../test_create/test_template.py | 6 +- .../test_update/test_fieldsets.py | 370 +++++++++++++----- 2 files changed, 272 insertions(+), 104 deletions(-) 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 26b9b1cd9..b18305bf9 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 @@ -3834,7 +3834,7 @@ def test_create__invalid_wf_name_template__validation_error( templates_created_mock.assert_not_called() -def test_create__kickoff_only_required_data__ok( +def test_create__kickoff_fieldset_only_required_data__ok( mocker, api_client, ): @@ -3964,7 +3964,7 @@ def test_create__kickoff_only_required_data__ok( assert field_data['api_name'] == field.api_name -def test_create__kickoff_all_fieldset_data__ok( +def test_create__kickoff_fieldset_all_fieldset_data__ok( mocker, api_client, ): @@ -4251,7 +4251,7 @@ def test_create__task_fieldset_only_required_data__ok( assert field_data['api_name'] == field.api_name -def test_create__task_all_fieldset_data__ok( +def test_create__task_fieldset_all_fieldset_data__ok( mocker, api_client, ): 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 index 8e4a8b0ca..0b10f49fd 100644 --- 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 @@ -1,13 +1,17 @@ import pytest from src.processes.enums import ( + FieldSetLayout, + FieldSetRuleType, + LabelPosition, OwnerRole, OwnerType, - PerformerType, FieldSetRuleType, + 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, @@ -18,7 +22,7 @@ # Kickoff fieldsets -def test_update__kickoff_create_fieldset__ok( +def test_update__create_kickoff_fieldset_only_required_data__ok( mocker, api_client, ): @@ -29,14 +33,28 @@ def test_update__kickoff_create_fieldset__ok( template = create_test_template(user, is_active=True, tasks_count=1) kickoff = template.kickoff_instance task = template.tasks.first() - shared = create_test_shared_fieldset( + 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, - rule_type=FieldSetRuleType.SUM_EQUAL, - rule_value='100', + 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, ) - field = shared.fields.first() - rule = shared.rules.first() - field.rules.add(rule) + 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.' @@ -73,8 +91,7 @@ def test_update__kickoff_create_fieldset__ok( 'id': kickoff.id, 'fieldsets': [ { - 'shared_fieldset_id': shared.id, - 'order': 3, + 'shared_fieldset_id': shared_fieldset.id, }, ], }, @@ -97,38 +114,143 @@ def test_update__kickoff_create_fieldset__ok( # 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.id - assert fieldset_data['order'] == 3 - assert fieldset_data['name'] == shared.name - assert fieldset_data['title'] == shared.title - assert fieldset_data['description'] == shared.description - assert fieldset_data['label_position'] == shared.label_position - assert fieldset_data['layout'] == shared.layout - assert fieldset_data['api_name'] - assert fieldset_data['api_name'] != shared.api_name + 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'] == field.name + assert field_data['name'] == shared_field.name assert field_data['description'] == '' - 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['order'] == field.order - assert field_data['default'] == field.default - assert field_data['api_name'] + 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'] == FieldSetRuleType.SUM_EQUAL - assert rule_data['value'] == '100' - assert rule_data['api_name'] - assert rule_data['fields'] == [field_data['api_name']] - assert kickoff.fieldsets.filter( - shared_fieldset_id=shared.id, - order=3, - ).exists() + 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( @@ -236,14 +358,12 @@ def test_update__kickoff_replace_fieldset__ok( task = template.tasks.first() shared_1 = create_test_shared_fieldset(account=account) shared_2 = create_test_shared_fieldset(account=account) - # create an existing child fieldset linked to kickoff from shared_1 - existing = FieldsetTemplate.objects.create( + # create an fieldset child fieldset linked to kickoff from shared_1 + fieldset = create_test_fieldset_template( account=account, template=template, kickoff=kickoff, - name=shared_1.name, - shared_fieldset_id=shared_1.id, - is_shared=True, + shared_fieldset=shared_1, order=0, ) mocker.patch( @@ -310,7 +430,7 @@ def test_update__kickoff_replace_fieldset__ok( assert len(fieldsets) == 1 assert fieldsets[0]['shared_fieldset_id'] == shared_2.id assert fieldsets[0]['order'] == 2 - assert not kickoff.fieldsets.filter(id=existing.id).exists() + assert not kickoff.fieldsets.filter(id=fieldset.id).exists() assert kickoff.fieldsets.filter( shared_fieldset_id=shared_2.id, order=2, @@ -328,14 +448,12 @@ def test_update__kickoff_remove_fieldset__ok( template = create_test_template(user, is_active=True, tasks_count=1) kickoff = template.kickoff_instance task = template.tasks.first() - shared = create_test_shared_fieldset(account=account) - existing = FieldsetTemplate.objects.create( + shared_fieldset = create_test_shared_fieldset(account=account) + fieldset = create_test_fieldset_template( account=account, template=template, kickoff=kickoff, - name=shared.name, - shared_fieldset_id=shared.id, - is_shared=True, + shared_fieldset=shared_fieldset, order=0, ) mocker.patch( @@ -394,23 +512,45 @@ def test_update__kickoff_remove_fieldset__ok( # assert assert response.status_code == 200 assert response.data['kickoff']['fieldsets'] == [] - assert not kickoff.fieldsets.filter(id=existing.id).exists() + assert not kickoff.fieldsets.filter(id=fieldset.id).exists() -def test_update__kickoff_skip_fieldsets__no_fieldsets_created( +# Task fieldsets + + +def test_update__create_task_fieldset_only_required_data__ok( mocker, api_client, ): - """ Updating a template without fieldsets key in kickoff does not - create any kickoff 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() + 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.' @@ -458,6 +598,11 @@ def test_update__kickoff_skip_fieldsets__no_fieldsets_created( 'source_id': user.id, }, ], + 'fieldsets': [ + { + 'shared_fieldset_id': shared_fieldset.id, + }, + ], }, ], }, @@ -465,13 +610,44 @@ def test_update__kickoff_skip_fieldsets__no_fieldsets_created( # assert assert response.status_code == 200 - assert kickoff.fieldsets.count() == 0 - + fieldset = FieldsetTemplate.objects.get( + task=task, + shared_fieldset=shared_fieldset, + is_shared=False, + ) + field = fieldset.fields.first() + rule = fieldset.rules.first() -# Task fieldsets + 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_create_fieldset__ok( +def test_update__task_fieldset_all_fieldset_data__ok( mocker, api_client, ): @@ -482,14 +658,12 @@ def test_update__task_create_fieldset__ok( template = create_test_template(user, is_active=True, tasks_count=1) kickoff = template.kickoff_instance task = template.tasks.first() - shared = create_test_shared_fieldset( - account=account, - rule_type=FieldSetRuleType.SUM_EQUAL, - rule_value='200', - ) - field = shared.fields.first() - rule = shared.rules.first() - field.rules.add(rule) + 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.' @@ -539,8 +713,11 @@ def test_update__task_create_fieldset__ok( ], 'fieldsets': [ { - 'shared_fieldset_id': shared.id, - 'order': 2, + 'shared_fieldset_id': shared_fieldset.id, + 'order': fs_order, + 'title': fs_title, + 'description': fs_description, + 'api_name': fs_api_name, }, ], }, @@ -550,31 +727,26 @@ def test_update__task_create_fieldset__ok( # assert assert response.status_code == 200 - fieldsets = response.data['tasks'][0]['fieldsets'] - assert len(fieldsets) == 1 - fieldset_data = fieldsets[0] - assert fieldset_data['shared_fieldset_id'] == shared.id - assert fieldset_data['order'] == 2 - assert len(fieldset_data['fields']) == 1 - field_data = fieldset_data['fields'][0] - assert field_data['name'] == field.name - assert field_data['description'] == '' - 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['order'] == field.order - assert field_data['default'] == field.default - assert field_data['api_name'] - assert len(fieldset_data['rules']) == 1 - rule_data = fieldset_data['rules'][0] - assert rule_data['type'] == FieldSetRuleType.SUM_EQUAL - assert rule_data['value'] == '200' - assert rule_data['api_name'] - assert rule_data['fields'] == [field_data['api_name']] - assert task.fieldsets.filter( - shared_fieldset_id=shared.id, - order=2, - ).exists() + 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( @@ -682,14 +854,12 @@ def test_update__task_replace_fieldset__ok( task = template.tasks.first() shared_1 = create_test_shared_fieldset(account=account) shared_2 = create_test_shared_fieldset(account=account) - # create an existing child fieldset linked to task from shared_1 - existing = FieldsetTemplate.objects.create( + # create an fieldset child fieldset linked to task from shared_1 + fieldset = create_test_fieldset_template( account=account, template=template, task=task, - name=shared_1.name, - shared_fieldset_id=shared_1.id, - is_shared=True, + shared_fieldset=shared_1, order=0, ) mocker.patch( @@ -756,7 +926,7 @@ def test_update__task_replace_fieldset__ok( assert len(fieldsets) == 1 assert fieldsets[0]['shared_fieldset_id'] == shared_2.id assert fieldsets[0]['order'] == 2 - assert not task.fieldsets.filter(id=existing.id).exists() + assert not task.fieldsets.filter(id=fieldset.id).exists() assert task.fieldsets.filter( shared_fieldset_id=shared_2.id, order=2, @@ -774,14 +944,12 @@ def test_update__tasks_remove_fieldset__ok( template = create_test_template(user, is_active=True, tasks_count=1) kickoff = template.kickoff_instance task = template.tasks.first() - shared = create_test_shared_fieldset(account=account) - existing = FieldsetTemplate.objects.create( + shared_fieldset = create_test_shared_fieldset(account=account) + fieldset = create_test_fieldset_template( account=account, template=template, task=task, - name=shared.name, - shared_fieldset_id=shared.id, - is_shared=True, + shared_fieldset=shared_fieldset, order=0, ) mocker.patch( @@ -840,7 +1008,7 @@ def test_update__tasks_remove_fieldset__ok( # assert assert response.status_code == 200 assert response.data['tasks'][0]['fieldsets'] == [] - assert not task.fieldsets.filter(id=existing.id).exists() + assert not task.fieldsets.filter(id=fieldset.id).exists() def test_update__task_with_empty_fieldsets__no_create_fieldsets( From 8f6d63a78233e66f931432d06447eaa6419a4201 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 30 Jun 2026 15:58:11 +0500 Subject: [PATCH 41/46] 45773 fix(migrations): split fieldset migrations --- .../migrations/0255_add_shared_fieldsets.py | 195 +--------------- .../0256_migrate_shared_fieldsets.py | 215 ++++++++++++++++++ .../test_create/test_template.py | 5 +- .../test_views/test_templates/test_run.py | 45 +++- 4 files changed, 257 insertions(+), 203 deletions(-) create mode 100644 backend/src/processes/migrations/0256_migrate_shared_fieldsets.py diff --git a/backend/src/processes/migrations/0255_add_shared_fieldsets.py b/backend/src/processes/migrations/0255_add_shared_fieldsets.py index b6036c949..8ff80fd3e 100644 --- a/backend/src/processes/migrations/0255_add_shared_fieldsets.py +++ b/backend/src/processes/migrations/0255_add_shared_fieldsets.py @@ -1,198 +1,9 @@ # Generated by Django 2.2 on 2026-06-23 16:49 -# ruff: noqa: T201 BLE001 -from django.db import migrations, models, transaction +from django.db import migrations, models import django.db.models.deletion -def get_drafts_by_fieldsets(TemplateDraft): - """ - Returns a dict: {fieldset_api_name: set of TemplateDraft IDs} - """ - result = {} - for draft_obj in TemplateDraft.objects.all(): - draft = draft_obj.draft - if not isinstance(draft, dict): - continue - - # Process tasks -> fieldsets - for task in draft.get('tasks') or []: - for fieldset in task.get('fieldsets') or []: - if isinstance(fieldset, dict): - api_name = fieldset.get('api_name') - if api_name: - result.setdefault(api_name, set()) - result[api_name].add(draft_obj.id) - - # Process kickoff -> fieldsets - kickoff = draft.get('kickoff') - if isinstance(kickoff, dict): - for fieldset in kickoff.get('fieldsets') or []: - if isinstance(fieldset, dict): - api_name = fieldset.get('api_name') - if api_name: - result.setdefault(api_name, set()) - result[api_name].add(draft_obj.id) - - return result - - -def update_draft_fieldset(TemplateDraft, draft_id, fieldset_data, - fieldset_api_name, shared_fieldset_id): - draft_obj = TemplateDraft.objects.get(id=draft_id) - draft = draft_obj.draft - if not isinstance(draft, dict): - return - - updated = False - - # Update matching fieldsets in tasks - for task in draft.get('tasks') or []: - for fieldset in task.get('fieldsets') or []: - if fieldset.get('api_name') == fieldset_api_name: - fieldset.update(fieldset_data) - fieldset['shared_fieldset_id'] = shared_fieldset_id - updated = True - print( - f'*** Draft {draft_id} ' - f'(template {draft_obj.draft["name"]})' - f' - task "{task.get("name", "?")}"' - f' - fieldset "{fieldset_data["name"]}" updated', - ) - - # Update matching fieldsets in kickoff - kickoff = draft.get('kickoff') - if isinstance(kickoff, dict): - for fieldset in kickoff.get('fieldsets') or []: - if fieldset.get('api_name') == fieldset_api_name: - fieldset.update(fieldset_data) - fieldset['shared_fieldset_id'] = shared_fieldset_id - updated = True - print( - f'*** Draft {draft_id} ' - f'(template {draft_obj.draft["name"]})' - f' - kickoff' - f' - fieldset "{fieldset_data["name"]}" updated', - ) - - if updated: - draft_obj.save(update_fields=('draft',)) - print(f' Draft {draft_id} saved') - - -def migrate_shared_fieldsets(apps, schema_editor): - FieldsetTemplate = apps.get_model('processes', 'FieldsetTemplate') - TemplateDraft = apps.get_model('processes', 'TemplateDraft') - FieldSetTemplateService = __import__( - 'src.processes.services.fieldsets.fieldset', - fromlist=['FieldSetTemplateService'], - ).FieldSetTemplateService - - drafts_by_old_fieldsets = get_drafts_by_fieldsets(TemplateDraft) - old_fieldsets = ( - FieldsetTemplate.objects - .filter(is_shared=True) - .order_by('account_id') - ) - with transaction.atomic(): - for old_fieldset in old_fieldsets: - # Ensure the original is marked as shared - old_fieldset.template_id = None - old_fieldset.is_shared = True - - # Build the serialized representation of the shared fieldset - fieldset_data = FieldSetTemplateService.to_json(old_fieldset) - fieldset_data.pop('id') - fieldset_data.pop('api_name') - fieldset_data.pop('order') - shared_fieldset_data = ( - FieldSetTemplateService._replace_api_names(fieldset_data) - ) - old_fieldset.fields.delete() - old_fieldset.rules.delete() - user = old_fieldset.account.get_owner() - - shared_service = FieldSetTemplateService(user=user) - shared_fieldset = shared_service.create( - is_shared=True, - **shared_fieldset_data, - ) - print( - f'Shared - {shared_fieldset.name} : {shared_fieldset.id}', - ) - - # Update drafts - for draft_id in drafts_by_old_fieldsets.get( - old_fieldset.api_name, [], - ): - update_draft_fieldset( - TemplateDraft=TemplateDraft, - draft_id=draft_id, - fieldset_data=fieldset_data, - fieldset_api_name=old_fieldset.api_name, - shared_fieldset_id=shared_fieldset.id, - ) - - kickoff_links = old_fieldset.kickoffs.through.objects.filter( - fieldset=old_fieldset, - is_deleted=False, - ) - for link in kickoff_links: - if not FieldsetTemplate.objects.filter( - shared_fieldset_id=shared_fieldset.id, - is_shared=False, - kickoff_id=link.kickoff_id, - ).exists(): - service = FieldSetTemplateService(user=user) - new_fs = service.create( - **fieldset_data, - is_shared=False, - shared_fieldset_id=shared_fieldset.id, - order=link.order, - kickoff_id=link.kickoff_id, - template_id=link.kickoff.template_id, - ) - print( - f'+++ {link.kickoff.template.name} : ' - f'{link.kickoff.template_id} - ' - f'{new_fs.name} : {new_fs.id}', - ) - link.delete() - - task_links = old_fieldset.tasks.through.objects.filter( - fieldset=old_fieldset, - is_deleted=False, - ) - for link in task_links: - if not FieldsetTemplate.objects.filter( - shared_fieldset_id=shared_fieldset.id, - is_shared=False, - task_id=link.task_id, - ).exists(): - service = FieldSetTemplateService(user=user) - try: - new_fs = service.create( - **fieldset_data, - is_shared=False, - shared_fieldset_id=shared_fieldset.id, - order=link.order, - task_id=link.task_id, - template_id=link.task.template_id, - ) - print( - f'+++ {link.task.template.name} : ' - f'{link.task.template_id} - ' - f'{new_fs.name} : {new_fs.id}', - ) - except Exception: - print( - f'--- Duplicate ' - f'{link.task.template.name} : ' - f'{link.task.template_id}', - ) - link.delete() - old_fieldset.delete() - class Migration(migrations.Migration): @@ -272,8 +83,4 @@ class Migration(migrations.Migration): name='fieldsettemplate_rule_api_name_template_unique', ), ), - migrations.RunPython( - migrate_shared_fieldsets, - migrations.RunPython.noop, - ), ] diff --git a/backend/src/processes/migrations/0256_migrate_shared_fieldsets.py b/backend/src/processes/migrations/0256_migrate_shared_fieldsets.py new file mode 100644 index 000000000..31ce73b6c --- /dev/null +++ b/backend/src/processes/migrations/0256_migrate_shared_fieldsets.py @@ -0,0 +1,215 @@ +# Generated by Django 2.2 on 2026-06-23 16:49 +# ruff: noqa: T201 BLE001 +# Split from 0255 to avoid PostgreSQL error: +# "cannot CREATE INDEX because it has pending trigger events" +# Partial indexes (UniqueConstraint with condition) must be committed in their +# own transaction before the data migration runs. + +from django.contrib.auth import get_user_model +from django.db import migrations, transaction + + +def get_drafts_by_fieldsets(TemplateDraft): + """ + Returns a dict: {fieldset_api_name: set of TemplateDraft IDs} + """ + result = {} + for draft_obj in TemplateDraft.objects.all(): + draft = draft_obj.draft + if not isinstance(draft, dict): + continue + + # Process tasks -> fieldsets + for task in draft.get('tasks') or []: + for fieldset in task.get('fieldsets') or []: + if isinstance(fieldset, dict): + api_name = fieldset.get('api_name') + if api_name: + result.setdefault(api_name, set()) + result[api_name].add(draft_obj.id) + + # Process kickoff -> fieldsets + kickoff = draft.get('kickoff') + if isinstance(kickoff, dict): + for fieldset in kickoff.get('fieldsets') or []: + if isinstance(fieldset, dict): + api_name = fieldset.get('api_name') + if api_name: + result.setdefault(api_name, set()) + result[api_name].add(draft_obj.id) + + return result + + +def update_draft_fieldset(TemplateDraft, draft_id, fieldset_data, + fieldset_api_name, shared_fieldset_id): + draft_obj = TemplateDraft.objects.get(id=draft_id) + draft = draft_obj.draft + if not isinstance(draft, dict): + return + + updated = False + + # Update matching fieldsets in tasks + for task in draft.get('tasks') or []: + for fieldset in task.get('fieldsets') or []: + if fieldset.get('api_name') == fieldset_api_name: + fieldset.update(fieldset_data) + fieldset['shared_fieldset_id'] = shared_fieldset_id + updated = True + print( + f'*** Draft {draft_id} ' + f'(template {draft_obj.draft["name"]})' + f' - task "{task.get("name", "?")}"' + f' - fieldset "{fieldset_data["name"]}" updated', + ) + + # Update matching fieldsets in kickoff + kickoff = draft.get('kickoff') + if isinstance(kickoff, dict): + for fieldset in kickoff.get('fieldsets') or []: + if fieldset.get('api_name') == fieldset_api_name: + fieldset.update(fieldset_data) + fieldset['shared_fieldset_id'] = shared_fieldset_id + updated = True + print( + f'*** Draft {draft_id} ' + f'(template {draft_obj.draft["name"]})' + f' - kickoff' + f' - fieldset "{fieldset_data["name"]}" updated', + ) + + if updated: + draft_obj.save(update_fields=('draft',)) + print(f' Draft {draft_id} saved') + + +def migrate_shared_fieldsets(apps, schema_editor): + FieldsetTemplate = apps.get_model('processes', 'FieldsetTemplate') + TemplateDraft = apps.get_model('processes', 'TemplateDraft') + FieldSetTemplateService = __import__( + 'src.processes.services.fieldsets.fieldset', + fromlist=['FieldSetTemplateService'], + ).FieldSetTemplateService + + drafts_by_old_fieldsets = get_drafts_by_fieldsets(TemplateDraft) + old_fieldsets = ( + FieldsetTemplate.objects + .filter(is_shared=True) + .order_by('account_id') + ) + with transaction.atomic(): + for old_fieldset in old_fieldsets: + # Ensure the original is marked as shared + old_fieldset.template_id = None + old_fieldset.is_shared = True + + # Build the serialized representation of the shared fieldset + fieldset_data = FieldSetTemplateService.to_json(old_fieldset) + fieldset_data.pop('id') + fieldset_data.pop('api_name') + fieldset_data.pop('order') + shared_fieldset_data = ( + FieldSetTemplateService._replace_api_names(fieldset_data) + ) + old_fieldset.fields.all().delete() + old_fieldset.rules.all().delete() + UserModel = get_user_model() + user = UserModel.objects.get( + account_id=old_fieldset.account_id, + is_account_owner=True, + ) + shared_service = FieldSetTemplateService(user=user) + shared_fieldset = shared_service.create( + is_shared=True, + **shared_fieldset_data, + ) + print( + f'Shared - {shared_fieldset.name} : {shared_fieldset.id}', + ) + + # Update drafts + for draft_id in drafts_by_old_fieldsets.get( + old_fieldset.api_name, [], + ): + update_draft_fieldset( + TemplateDraft=TemplateDraft, + draft_id=draft_id, + fieldset_data=fieldset_data, + fieldset_api_name=old_fieldset.api_name, + shared_fieldset_id=shared_fieldset.id, + ) + + kickoff_links = old_fieldset.kickoffs.through.objects.filter( + fieldset=old_fieldset, + is_deleted=False, + ) + for link in kickoff_links: + if not FieldsetTemplate.objects.filter( + shared_fieldset_id=shared_fieldset.id, + is_shared=False, + kickoff_id=link.kickoff_id, + ).exists(): + service = FieldSetTemplateService(user=user) + new_fs = service.create( + **fieldset_data, + is_shared=False, + shared_fieldset_id=shared_fieldset.id, + order=link.order, + kickoff_id=link.kickoff_id, + template_id=link.kickoff.template_id, + ) + print( + f'+++ {link.kickoff.template.name} : ' + f'{link.kickoff.template_id} - ' + f'{new_fs.name} : {new_fs.id}', + ) + link.delete() + + task_links = old_fieldset.tasks.through.objects.filter( + fieldset=old_fieldset, + is_deleted=False, + ) + for link in task_links: + if not FieldsetTemplate.objects.filter( + shared_fieldset_id=shared_fieldset.id, + is_shared=False, + task_id=link.task_id, + ).exists(): + service = FieldSetTemplateService(user=user) + try: + new_fs = service.create( + **fieldset_data, + is_shared=False, + shared_fieldset_id=shared_fieldset.id, + order=link.order, + task_id=link.task_id, + template_id=link.task.template_id, + ) + print( + f'+++ {link.task.template.name} : ' + f'{link.task.template_id} - ' + f'{new_fs.name} : {new_fs.id}', + ) + except Exception: + print( + f'--- Duplicate ' + f'{link.task.template.name} : ' + f'{link.task.template_id}', + ) + link.delete() + old_fieldset.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('processes', '0255_add_shared_fieldsets'), + ] + + operations = [ + migrations.RunPython( + migrate_shared_fieldsets, + migrations.RunPython.noop, + ), + ] 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 b18305bf9..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 @@ -20,7 +20,10 @@ OwnerRole, OwnerType, PerformerType, - PredicateOperator, FieldSetRuleType, LabelPosition, FieldSetLayout, + PredicateOperator, + FieldSetRuleType, + LabelPosition, + FieldSetLayout, ) from src.processes.messages import template as messages from src.processes.models.templates.conditions import ( 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 71f947497..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 @@ -29,6 +29,8 @@ TaskStatus, WorkflowEventType, WorkflowStatus, + LabelPosition, + FieldSetLayout, ) from src.processes.messages import workflow as messages from src.processes.messages.fieldset import MSG_FS_0002 @@ -79,7 +81,7 @@ create_test_user, create_test_workflow, create_wf_completed_webhook, - create_wf_created_webhook, + create_wf_created_webhook, create_test_shared_fieldset, ) from src.utils.dates import date_format from src.utils.validation import ErrorCode @@ -5202,14 +5204,33 @@ def test_run__kickoff_with_one_fieldset__ok(mocker, api_client): 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=11, + order=fs_order, + shared_fieldset=shared_fieldset, ) field_template = fieldset_template.fields.first() - field_value = 'test value' + field_value = '100' mocker.patch( 'src.processes.services.workflow_action.' 'WorkflowEventService.workflow_run_event', @@ -5243,9 +5264,9 @@ def test_run__kickoff_with_one_fieldset__ok(mocker, api_client): fieldset_data = fieldsets_data[0] assert fieldset_data['id'] == fieldset.id assert fieldset_data['api_name'] == fieldset_template.api_name - assert fieldset_data['name'] == fieldset_template.name - assert fieldset_data['description'] == fieldset_template.description - assert fieldset_data['order'] == 11 + 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 @@ -5281,19 +5302,27 @@ def test_run__kickoff_with_multiple_fieldsets__ok(mocker, api_client): 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, - title='First fieldset', 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, - title='Second fieldset', order=1, + shared_fieldset=shared_fieldset_2, ) field_1 = fieldset_1.fields.first() field_2 = fieldset_2.fields.first() From 566d75a8ef9f8afa46b0df143d851a47ca222db0 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 30 Jun 2026 17:57:31 +0500 Subject: [PATCH 42/46] 46129 fix(fieldsets): correct second update fieldset rule --- .../processes/services/fieldsets/fieldset.py | 18 ++++++++++-------- .../test_fieldset_template_service.py | 4 ++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/backend/src/processes/services/fieldsets/fieldset.py b/backend/src/processes/services/fieldsets/fieldset.py index 00004363f..d5ab79b12 100644 --- a/backend/src/processes/services/fieldsets/fieldset.py +++ b/backend/src/processes/services/fieldsets/fieldset.py @@ -317,19 +317,21 @@ def update_rules( ): """ All dataset items will be updated """ - existing_rules = {rule.id: rule for rule in self.instance.rules.all()} - rules_ids = set() + existing_rules = { + rule.api_name: rule for rule in self.instance.rules.all() + } + rule_api_names = set() for rule_data in rules_data: - rule_id = rule_data.pop('id', None) - if rule_id and rule_id in existing_rules: + 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_id], + instance=existing_rules[rule_api_name], ) service.partial_update(**rule_data) - rules_ids.add(rule_id) + rule_api_names.add(rule_api_name) else: service = FieldsetTemplateRuleService( user=self.user, @@ -340,9 +342,9 @@ def update_rules( fieldset_id=self.instance.id, **rule_data, ) - rules_ids.add(rule.id) + rule_api_names.add(rule.id) - self.instance.rules.exclude(id__in=rules_ids).delete() + self.instance.rules.exclude(api_name__in=rule_api_names).delete() @staticmethod def to_json(fieldset: FieldsetTemplate) -> dict: 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 index ae2b889e0..84debbc70 100644 --- 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 @@ -769,7 +769,7 @@ def test_update_rules__existing_rule__ok(mocker): auth_type=AuthTokenType.USER, instance=fieldset, ) - rules_data = [{'id': rule_1.id, 'value': '200'}] + rules_data = [{'api_name': rule_1.api_name, 'value': '200'}] # mock fieldset_template_rule_service_init_mock = mocker.patch.object( @@ -898,7 +898,7 @@ def test_update_rules__orphan_rules__deleted(mocker): auth_type=AuthTokenType.USER, instance=fieldset, ) - rules_data = [{'id': rule_1.id, 'value': '150'}] + rules_data = [{'api_name': rule_1.api_name, 'value': '150'}] # mock fs_rule_init_mock = mocker.patch.object( From 9b3c826c61974eb786ceb8ef4a7761649a775e8d Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Tue, 30 Jun 2026 23:23:41 +0500 Subject: [PATCH 43/46] 45773 feat(fieldset): combine similar rules using "or" --- backend/src/processes/models/mixins.py | 3 +- .../services/workflows/fieldsets/fieldset.py | 33 ++- .../test_workflows/test_fieldset_service.py | 223 ++++++++++++++---- 3 files changed, 209 insertions(+), 50 deletions(-) diff --git a/backend/src/processes/models/mixins.py b/backend/src/processes/models/mixins.py index 632ff711b..f993b8220 100644 --- a/backend/src/processes/models/mixins.py +++ b/backend/src/processes/models/mixins.py @@ -15,7 +15,8 @@ FieldSetLayout, PerformerType, PredicateOperator, - PredicateType, FieldSetRuleType, + PredicateType, + FieldSetRuleType, ) from src.datasets.models import Dataset diff --git a/backend/src/processes/services/workflows/fieldsets/fieldset.py b/backend/src/processes/services/workflows/fieldsets/fieldset.py index e2ae407e0..feaac54ed 100644 --- a/backend/src/processes/services/workflows/fieldsets/fieldset.py +++ b/backend/src/processes/services/workflows/fieldsets/fieldset.py @@ -1,3 +1,4 @@ +from itertools import groupby from typing import List, Optional, Dict from django.contrib.auth import get_user_model @@ -77,7 +78,31 @@ def _create_related(self, instance_template, **kwargs): self._create_rules(instance_template, **kwargs) self._create_fields(instance_template, **kwargs) - def validate_rules(self): - for rule in self.instance.rules.all(): - service = FieldSetRuleService(user=self.user, instance=rule) - service.validate() + 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 + exception = None + for rule in group_rules: + try: + service = FieldSetRuleService( + user=self.user, + instance=rule, + ) + service.validate() + except FieldsetServiceException as ex: + ex_counter += 1 + exception = ex + if len(group_rules) == ex_counter: + raise exception 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 index 83fbb876c..e881837fa 100644 --- 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 @@ -4,14 +4,16 @@ FieldSetRuleType, FieldType, ) -from src.processes.messages.fieldset import MSG_FS_0007 +from src.processes.messages.fieldset import ( + MSG_FS_0002, + MSG_FS_0007, +) from src.processes.models.templates.fieldset import ( FieldsetTemplate, FieldsetTemplateRule, ) from src.processes.models.templates.fields import FieldTemplate from src.processes.models.workflows.fieldset import ( - FieldSet, FieldSetRule, ) from src.processes.services.exceptions import FieldsetServiceException @@ -24,9 +26,11 @@ ) 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, + create_test_workflow, + create_test_fieldset_template, ) pytestmark = pytest.mark.django_db @@ -44,7 +48,6 @@ def test__create_instance__with_kickoff__ok(): template = create_test_template(user=user, tasks_count=1) workflow = create_test_workflow(user=user, template=template) kickoff = workflow.kickoff_instance - workflow.tasks.first() order = 11 fieldset_template = FieldsetTemplate.objects.create( template=template, @@ -81,6 +84,10 @@ def test__create_instance__with_kickoff__ok(): def test__create_instance__with_task__ok(): + """ + Call with task + """ + # arrange account = create_test_account() user = create_test_owner(account=account) @@ -178,11 +185,8 @@ def test__create_fields__default_params__ok(mocker): type=FieldType.NUMBER, order=1, ) - fieldset = FieldSet.objects.create( - account=account, + fieldset = create_test_fieldset( workflow=workflow, - name='Fieldset', - order=1, ) service = FieldSetService( user=user, @@ -190,8 +194,6 @@ def test__create_fields__default_params__ok(mocker): auth_type=AuthTokenType.USER, instance=fieldset, ) - - # mock task_field_service_init_mock = mocker.patch.object( TaskFieldService, attribute='__init__', @@ -245,11 +247,8 @@ def test__create_fields__with_fields_data__ok(mocker): type=FieldType.NUMBER, order=1, ) - fieldset = FieldSet.objects.create( - account=account, + fieldset = create_test_fieldset( workflow=workflow, - name='Fieldset', - order=1, ) service = FieldSetService( user=user, @@ -258,8 +257,6 @@ def test__create_fields__with_fields_data__ok(mocker): instance=fieldset, ) fields_data = {field_template_1.api_name: '42'} - - # mock task_field_service_init_mock = mocker.patch.object( TaskFieldService, attribute='__init__', @@ -314,11 +311,8 @@ def test__create_fields__skip_value_true__ok(mocker): type=FieldType.NUMBER, order=1, ) - fieldset = FieldSet.objects.create( - account=account, + fieldset = create_test_fieldset( workflow=workflow, - name='Fieldset', - order=1, ) service = FieldSetService( user=user, @@ -326,8 +320,6 @@ def test__create_fields__skip_value_true__ok(mocker): auth_type=AuthTokenType.USER, instance=fieldset, ) - - # mock task_field_service_init_mock = mocker.patch.object( TaskFieldService, attribute='__init__', @@ -381,11 +373,8 @@ def test__create_rules__with_template__ok(mocker): type=FieldSetRuleType.SUM_EQUAL, value='100', ) - fieldset = FieldSet.objects.create( - account=account, + fieldset = create_test_fieldset( workflow=workflow, - name='Fieldset', - order=1, ) service = FieldSetService( user=user, @@ -393,8 +382,6 @@ def test__create_rules__with_template__ok(mocker): auth_type=AuthTokenType.USER, instance=fieldset, ) - - # mock field_set_rule_service_init_mock = mocker.patch.object( FieldSetRuleService, attribute='__init__', @@ -439,8 +426,6 @@ def test__create_related__with_template__ok(mocker): is_superuser=False, auth_type=AuthTokenType.USER, ) - - # mock create_fields_mock = mocker.patch( 'src.processes.services.workflows.fieldsets.fieldset.' 'FieldSetService._create_fields', @@ -454,15 +439,15 @@ def test__create_related__with_template__ok(mocker): service._create_related(instance_template=fieldset_template) # assert - create_fields_mock.assert_called_once_with( + create_rules_mock.assert_called_once_with( fieldset_template, ) - create_rules_mock.assert_called_once_with( + create_fields_mock.assert_called_once_with( fieldset_template, ) -def test_validate_rules__with_rules__ok(mocker): +def test_validate_rules__one_rule__ok(mocker): """ Call with rules @@ -473,26 +458,18 @@ def test_validate_rules__with_rules__ok(mocker): user = create_test_owner(account=account) template = create_test_template(user=user, tasks_count=1) workflow = create_test_workflow(user=user, template=template) - fieldset = FieldSet.objects.create( - account=account, + fieldset = create_test_fieldset( workflow=workflow, - name='Fieldset', - order=1, - ) - rule = FieldSetRule.objects.create( - account=account, - fieldset=fieldset, - type=FieldSetRuleType.SUM_EQUAL, - value='100', + 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, ) - - # mock field_set_rule_service_init_mock = mocker.patch.object( FieldSetRuleService, attribute='__init__', @@ -512,3 +489,159 @@ def test_validate_rules__with_rules__ok(mocker): 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. + """ + + # 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_0002('0') From ccd39c088853c1bb8e4677981a9facc1a097ce0e Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Wed, 1 Jul 2026 01:07:11 +0500 Subject: [PATCH 44/46] 45773 fix(fieldsets): fix create second rule with the same type --- .../processes/services/fieldsets/fieldset.py | 2 +- .../test_fieldset_template_service.py | 74 +++++++++++++++++- .../test_fieldsets/test_partial_update.py | 75 +++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/backend/src/processes/services/fieldsets/fieldset.py b/backend/src/processes/services/fieldsets/fieldset.py index d5ab79b12..ec0306e05 100644 --- a/backend/src/processes/services/fieldsets/fieldset.py +++ b/backend/src/processes/services/fieldsets/fieldset.py @@ -342,7 +342,7 @@ def update_rules( fieldset_id=self.instance.id, **rule_data, ) - rule_api_names.add(rule.id) + rule_api_names.add(rule.api_name) self.instance.rules.exclude(api_name__in=rule_api_names).delete() 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 index 84debbc70..bcb4dee7a 100644 --- 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 @@ -3,6 +3,7 @@ from src.processes.enums import ( FieldSetLayout, FieldSetRuleType, + FieldType, LabelPosition, ) from src.processes.messages import fieldset as fs_messages @@ -30,6 +31,7 @@ create_test_owner, create_test_template, create_test_fieldset_template, + create_test_shared_fieldset, ) pytestmark = pytest.mark.django_db @@ -832,7 +834,7 @@ def test_update_rules__new_rule__ok(mocker): # mock create_return = mocker.Mock() - create_return.id = 999 + create_return.api_name = 'new-rule-api' fs_rule_init_mock = mocker.patch.object( FieldsetTemplateRuleService, attribute='__init__', @@ -925,6 +927,76 @@ def test_update_rules__orphan_rules__deleted(mocker): ).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""" 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 index 51a34bf9b..39846c015 100644 --- 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 @@ -14,6 +14,7 @@ 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, ) @@ -733,3 +734,77 @@ def test_partial_update__not_shared__not_found(api_client, mocker): 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'], + ) From 16d69c865508dc90c41f63fa050e1881b0e8322a Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Wed, 1 Jul 2026 01:46:05 +0500 Subject: [PATCH 45/46] 45773 fix(fieldsets): new validation message for grouped rules --- backend/src/processes/messages/fieldset.py | 7 +++++++ .../services/workflows/fieldsets/fieldset.py | 13 ++++++++----- .../test_workflows/test_fieldset_service.py | 6 ++++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/backend/src/processes/messages/fieldset.py b/backend/src/processes/messages/fieldset.py index f603ffa2c..04280a8b2 100644 --- a/backend/src/processes/messages/fieldset.py +++ b/backend/src/processes/messages/fieldset.py @@ -31,3 +31,10 @@ ) 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/services/workflows/fieldsets/fieldset.py b/backend/src/processes/services/workflows/fieldsets/fieldset.py index feaac54ed..39943f1c5 100644 --- a/backend/src/processes/services/workflows/fieldsets/fieldset.py +++ b/backend/src/processes/services/workflows/fieldsets/fieldset.py @@ -3,7 +3,7 @@ from django.contrib.auth import get_user_model from src.generics.base.service import BaseModelService -from src.processes.messages.fieldset import MSG_FS_0007 +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 @@ -93,7 +93,6 @@ def validate_rules(self) -> bool: # Multiple rules of the same type — OR logic: # validation passes if at least one rule succeeds ex_counter = 0 - exception = None for rule in group_rules: try: service = FieldSetRuleService( @@ -101,8 +100,12 @@ def validate_rules(self) -> bool: instance=rule, ) service.validate() - except FieldsetServiceException as ex: + except FieldsetServiceException: ex_counter += 1 - exception = ex if len(group_rules) == ex_counter: - raise exception + values = ', '.join( + str(rule.value) for rule in group_rules + ) + raise FieldsetServiceException( + message=MSG_FS_0012(values), + ) 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 index e881837fa..affcf9333 100644 --- 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 @@ -7,6 +7,7 @@ from src.processes.messages.fieldset import ( MSG_FS_0002, MSG_FS_0007, + MSG_FS_0012, ) from src.processes.models.templates.fieldset import ( FieldsetTemplate, @@ -610,7 +611,8 @@ 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. + raises FieldsetServiceException with MSG_FS_0012 + listing all values from the group. """ # arrange @@ -644,4 +646,4 @@ def test_validate_rules__two_same_type_rules__none_matches__raise(): service.validate_rules() # assert - assert ex.value.message == MSG_FS_0002('0') + assert ex.value.message == MSG_FS_0012('100, 0') From 0b969af2d5dca243c16dd3e811abcd5eac4a47e8 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Wed, 1 Jul 2026 16:09:45 +0500 Subject: [PATCH 46/46] 45773 fix(migrations): move fieldsets migration to managenment command --- .../management/commands/migrate_fieldsets.py | 257 +++++++++--------- .../migrations/0255_add_shared_fieldsets.py | 19 +- .../0256_migrate_shared_fieldsets.py | 215 --------------- .../processes/models/templates/fieldset.py | 2 +- 4 files changed, 135 insertions(+), 358 deletions(-) delete mode 100644 backend/src/processes/migrations/0256_migrate_shared_fieldsets.py diff --git a/backend/src/processes/management/commands/migrate_fieldsets.py b/backend/src/processes/management/commands/migrate_fieldsets.py index 0fccc2840..0e5d1617c 100644 --- a/backend/src/processes/management/commands/migrate_fieldsets.py +++ b/backend/src/processes/management/commands/migrate_fieldsets.py @@ -1,4 +1,6 @@ -# ruff: noqa: T201 BLE001 +# 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 @@ -8,188 +10,175 @@ class Command(BaseCommand): - def get_drafts_by_fieldsets(self) -> dict: - """ - Returns a dict where the key is fieldset api_name - and the value is a list of template IDs that contain that fieldset. - """ - result = {} - - for draft_obj in TemplateDraft.objects.all(): - draft = draft_obj.draft - if not isinstance(draft, dict): - continue - - # Process tasks -> fieldsets - for task in draft.get('tasks') or []: - for fieldset in task.get('fieldsets') or []: - if isinstance(fieldset, dict): - api_name = fieldset.get('api_name') - if api_name: - result.setdefault(api_name, set()) - result[api_name].add(draft_obj.id) - - # Process kickoff -> fieldsets - kickoff = draft.get('kickoff') - if isinstance(kickoff, dict): - for fieldset in kickoff.get('fieldsets') or []: - if isinstance(fieldset, dict): - api_name = fieldset.get('api_name') - if api_name: - result.setdefault(api_name, set()) - result[api_name].add(draft_obj.id) - - return result - def update_draft_fieldset( self, - draft_id: int, + template_draft: TemplateDraft, fieldset_data: dict, fieldset_api_name: str, - shared_fieldset_id: int, ): - draft_obj = TemplateDraft.objects.get(id=draft_id) - draft = draft_obj.draft + draft = template_draft.draft if not isinstance(draft, dict): return + print('--- update template draft') updated = False - - # Update matching fieldsets in tasks - for task in draft.get('tasks') or []: - for fieldset in task.get('fieldsets') or []: - if fieldset.get('api_name') == fieldset_api_name: - fieldset.update(fieldset_data) - fieldset['shared_fieldset_id'] = shared_fieldset_id - updated = True - print( - f'*** Draft {draft_id} ' - f'(template {draft_obj.draft["name"]})' - f' - task "{task.get("name", "?")}"' - f' - fieldset "{fieldset_data["name"]}" updated', - ) - # Update matching fieldsets in kickoff kickoff = draft.get('kickoff') + new_kickoff_fieldsets = [] if isinstance(kickoff, dict): - for fieldset in kickoff.get('fieldsets') or []: - if fieldset.get('api_name') == fieldset_api_name: - fieldset.update(fieldset_data) - fieldset['shared_fieldset_id'] = shared_fieldset_id - updated = True - print( - f'*** Draft {draft_id} ' - f'(template {draft_obj.draft["name"]})' - f' - kickoff' - f' - fieldset "{fieldset_data["name"]}" updated', - ) + 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 - if updated: - draft_obj.save(update_fields=('draft',)) - print(f' Draft {draft_id} saved') + # 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): - drafts_by_old_fieldsets = self.get_drafts_by_fieldsets() old_fieldsets = ( FieldsetTemplate.objects .filter(is_shared=True) + .exclude(template_id=None) .order_by('account_id') ) with transaction.atomic(): - for old_fieldset in old_fieldsets: + 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_fieldset.template_id = None - old_fieldset.is_shared = True + old_shared_fieldset.template_id = None # Build the serialized representation of the shared fieldset - fieldset_data = FieldSetTemplateService.to_json(old_fieldset) - fieldset_data.pop('id') - fieldset_data.pop('api_name') - fieldset_data.pop('order') - shared_fieldset_data = ( - FieldSetTemplateService._replace_api_names(fieldset_data) + old_shared_fieldset_data = FieldSetTemplateService.to_json( + old_shared_fieldset, + ) + template_fieldset_data = copy.deepcopy( + old_shared_fieldset_data, ) - old_fieldset.fields.delete() - old_fieldset.rules.delete() - user = old_fieldset.account.get_owner() + + 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) - shared_fieldset = shared_service.create( - is_shared=True, - **shared_fieldset_data, + new_shared_fieldset = shared_service.create_shared_fieldset( + **new_shared_fieldset_data, ) - print( - f'Shared - {shared_fieldset.name} : {shared_fieldset.id}', + 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 - for draft_id in drafts_by_old_fieldsets.get( - old_fieldset.api_name, [], - ): + if template_draft: self.update_draft_fieldset( - draft_id=draft_id, - fieldset_data=fieldset_data, - fieldset_api_name=old_fieldset.api_name, - shared_fieldset_id=shared_fieldset.id, + template_draft=template_draft, + fieldset_data=template_fieldset_data, + fieldset_api_name=old_shared_fieldset.api_name, ) - kickoff_links = old_fieldset.kickoffs.through.objects.filter( - fieldset=old_fieldset, - is_deleted=False, + 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 FieldsetTemplate.objects.filter( - shared_fieldset_id=shared_fieldset.id, - is_shared=False, - kickoff_id=link.kickoff_id, - ).exists(): + if not updated: service = FieldSetTemplateService(user=user) - new_fs = service.create( - **fieldset_data, + new_template_fieldset = service.create( + **template_fieldset_data, is_shared=False, - shared_fieldset_id=shared_fieldset.id, order=link.order, kickoff_id=link.kickoff_id, template_id=link.kickoff.template_id, ) + updated = True print( - f'+++ {link.kickoff.template.name} : ' - f'{link.kickoff.template_id} - ' - f'{new_fs.name} : {new_fs.id}', + 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() - task_links = old_fieldset.tasks.through.objects.filter( - fieldset=old_fieldset, + # 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 FieldsetTemplate.objects.filter( - shared_fieldset_id=shared_fieldset.id, - is_shared=False, - task_id=link.task_id, - ).exists(): + if not updated: service = FieldSetTemplateService(user=user) - try: - new_fs = service.create( - **fieldset_data, - is_shared=False, - shared_fieldset_id=shared_fieldset.id, - order=link.order, - task_id=link.task_id, - template_id=link.task.template_id, - ) - print( - f'+++ {link.task.template.name} : ' - f'{link.task.template_id} - ' - f'{new_fs.name} : {new_fs.id}', - ) - except Exception: - print( - f'--- Duplicate ' - f'{link.task.template.name} : ' - f'{link.task.template_id}', - ) + 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_fieldset.delete() + old_shared_fieldset.delete() diff --git a/backend/src/processes/migrations/0255_add_shared_fieldsets.py b/backend/src/processes/migrations/0255_add_shared_fieldsets.py index 8ff80fd3e..becdeef08 100644 --- a/backend/src/processes/migrations/0255_add_shared_fieldsets.py +++ b/backend/src/processes/migrations/0255_add_shared_fieldsets.py @@ -1,10 +1,10 @@ -# Generated by Django 2.2 on 2026-06-23 16:49 +# 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 = [ @@ -16,6 +16,13 @@ class Migration(migrations.Migration): 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', @@ -73,14 +80,10 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='fieldsettemplate', - constraint=models.UniqueConstraint(condition=models.Q(is_deleted=False), fields=('api_name', 'template', 'is_shared'), name='fieldsettemplate_api_name_template_unique'), + 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_rule_api_name_template_unique', - ), + 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/migrations/0256_migrate_shared_fieldsets.py b/backend/src/processes/migrations/0256_migrate_shared_fieldsets.py deleted file mode 100644 index 31ce73b6c..000000000 --- a/backend/src/processes/migrations/0256_migrate_shared_fieldsets.py +++ /dev/null @@ -1,215 +0,0 @@ -# Generated by Django 2.2 on 2026-06-23 16:49 -# ruff: noqa: T201 BLE001 -# Split from 0255 to avoid PostgreSQL error: -# "cannot CREATE INDEX because it has pending trigger events" -# Partial indexes (UniqueConstraint with condition) must be committed in their -# own transaction before the data migration runs. - -from django.contrib.auth import get_user_model -from django.db import migrations, transaction - - -def get_drafts_by_fieldsets(TemplateDraft): - """ - Returns a dict: {fieldset_api_name: set of TemplateDraft IDs} - """ - result = {} - for draft_obj in TemplateDraft.objects.all(): - draft = draft_obj.draft - if not isinstance(draft, dict): - continue - - # Process tasks -> fieldsets - for task in draft.get('tasks') or []: - for fieldset in task.get('fieldsets') or []: - if isinstance(fieldset, dict): - api_name = fieldset.get('api_name') - if api_name: - result.setdefault(api_name, set()) - result[api_name].add(draft_obj.id) - - # Process kickoff -> fieldsets - kickoff = draft.get('kickoff') - if isinstance(kickoff, dict): - for fieldset in kickoff.get('fieldsets') or []: - if isinstance(fieldset, dict): - api_name = fieldset.get('api_name') - if api_name: - result.setdefault(api_name, set()) - result[api_name].add(draft_obj.id) - - return result - - -def update_draft_fieldset(TemplateDraft, draft_id, fieldset_data, - fieldset_api_name, shared_fieldset_id): - draft_obj = TemplateDraft.objects.get(id=draft_id) - draft = draft_obj.draft - if not isinstance(draft, dict): - return - - updated = False - - # Update matching fieldsets in tasks - for task in draft.get('tasks') or []: - for fieldset in task.get('fieldsets') or []: - if fieldset.get('api_name') == fieldset_api_name: - fieldset.update(fieldset_data) - fieldset['shared_fieldset_id'] = shared_fieldset_id - updated = True - print( - f'*** Draft {draft_id} ' - f'(template {draft_obj.draft["name"]})' - f' - task "{task.get("name", "?")}"' - f' - fieldset "{fieldset_data["name"]}" updated', - ) - - # Update matching fieldsets in kickoff - kickoff = draft.get('kickoff') - if isinstance(kickoff, dict): - for fieldset in kickoff.get('fieldsets') or []: - if fieldset.get('api_name') == fieldset_api_name: - fieldset.update(fieldset_data) - fieldset['shared_fieldset_id'] = shared_fieldset_id - updated = True - print( - f'*** Draft {draft_id} ' - f'(template {draft_obj.draft["name"]})' - f' - kickoff' - f' - fieldset "{fieldset_data["name"]}" updated', - ) - - if updated: - draft_obj.save(update_fields=('draft',)) - print(f' Draft {draft_id} saved') - - -def migrate_shared_fieldsets(apps, schema_editor): - FieldsetTemplate = apps.get_model('processes', 'FieldsetTemplate') - TemplateDraft = apps.get_model('processes', 'TemplateDraft') - FieldSetTemplateService = __import__( - 'src.processes.services.fieldsets.fieldset', - fromlist=['FieldSetTemplateService'], - ).FieldSetTemplateService - - drafts_by_old_fieldsets = get_drafts_by_fieldsets(TemplateDraft) - old_fieldsets = ( - FieldsetTemplate.objects - .filter(is_shared=True) - .order_by('account_id') - ) - with transaction.atomic(): - for old_fieldset in old_fieldsets: - # Ensure the original is marked as shared - old_fieldset.template_id = None - old_fieldset.is_shared = True - - # Build the serialized representation of the shared fieldset - fieldset_data = FieldSetTemplateService.to_json(old_fieldset) - fieldset_data.pop('id') - fieldset_data.pop('api_name') - fieldset_data.pop('order') - shared_fieldset_data = ( - FieldSetTemplateService._replace_api_names(fieldset_data) - ) - old_fieldset.fields.all().delete() - old_fieldset.rules.all().delete() - UserModel = get_user_model() - user = UserModel.objects.get( - account_id=old_fieldset.account_id, - is_account_owner=True, - ) - shared_service = FieldSetTemplateService(user=user) - shared_fieldset = shared_service.create( - is_shared=True, - **shared_fieldset_data, - ) - print( - f'Shared - {shared_fieldset.name} : {shared_fieldset.id}', - ) - - # Update drafts - for draft_id in drafts_by_old_fieldsets.get( - old_fieldset.api_name, [], - ): - update_draft_fieldset( - TemplateDraft=TemplateDraft, - draft_id=draft_id, - fieldset_data=fieldset_data, - fieldset_api_name=old_fieldset.api_name, - shared_fieldset_id=shared_fieldset.id, - ) - - kickoff_links = old_fieldset.kickoffs.through.objects.filter( - fieldset=old_fieldset, - is_deleted=False, - ) - for link in kickoff_links: - if not FieldsetTemplate.objects.filter( - shared_fieldset_id=shared_fieldset.id, - is_shared=False, - kickoff_id=link.kickoff_id, - ).exists(): - service = FieldSetTemplateService(user=user) - new_fs = service.create( - **fieldset_data, - is_shared=False, - shared_fieldset_id=shared_fieldset.id, - order=link.order, - kickoff_id=link.kickoff_id, - template_id=link.kickoff.template_id, - ) - print( - f'+++ {link.kickoff.template.name} : ' - f'{link.kickoff.template_id} - ' - f'{new_fs.name} : {new_fs.id}', - ) - link.delete() - - task_links = old_fieldset.tasks.through.objects.filter( - fieldset=old_fieldset, - is_deleted=False, - ) - for link in task_links: - if not FieldsetTemplate.objects.filter( - shared_fieldset_id=shared_fieldset.id, - is_shared=False, - task_id=link.task_id, - ).exists(): - service = FieldSetTemplateService(user=user) - try: - new_fs = service.create( - **fieldset_data, - is_shared=False, - shared_fieldset_id=shared_fieldset.id, - order=link.order, - task_id=link.task_id, - template_id=link.task.template_id, - ) - print( - f'+++ {link.task.template.name} : ' - f'{link.task.template_id} - ' - f'{new_fs.name} : {new_fs.id}', - ) - except Exception: - print( - f'--- Duplicate ' - f'{link.task.template.name} : ' - f'{link.task.template_id}', - ) - link.delete() - old_fieldset.delete() - - -class Migration(migrations.Migration): - - dependencies = [ - ('processes', '0255_add_shared_fieldsets'), - ] - - operations = [ - migrations.RunPython( - migrate_shared_fieldsets, - migrations.RunPython.noop, - ), - ] diff --git a/backend/src/processes/models/templates/fieldset.py b/backend/src/processes/models/templates/fieldset.py index fe8f7a5e7..8149881db 100644 --- a/backend/src/processes/models/templates/fieldset.py +++ b/backend/src/processes/models/templates/fieldset.py @@ -30,7 +30,7 @@ class Meta: UniqueConstraint( fields=['api_name', 'template', 'is_shared'], condition=Q(is_deleted=False), - name='fieldsettemplate_api_name_template_unique', + name='fieldsettemplate_template_api_name_is_shared_unique', ), ]