From 6f7605c6055a1a1e961716e214c1848b2b210f2b Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Fri, 24 Apr 2026 03:46:07 +0500 Subject: [PATCH 001/102] 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 975d3ee3919f196b3e7cd0ff94199e0ee1e3b119 Mon Sep 17 00:00:00 2001 From: "joseph.vreeken" Date: Fri, 24 Apr 2026 03:52:20 +0500 Subject: [PATCH 002/102] 45773 feat(fieldsets): frontend init commit --- frontend/config/common.json | 3 + .../public/api/fieldsets/createFieldset.ts | 23 + .../public/api/fieldsets/deleteFieldset.ts | 22 + .../src/public/api/fieldsets/getFieldset.ts | 22 + .../src/public/api/fieldsets/getFieldsets.ts | 36 ++ .../public/api/fieldsets/updateFieldset.ts | 24 + frontend/src/public/api/getTemplateFields.ts | 4 +- .../FieldsetFieldGroup/FieldsetFieldGroup.css | 29 ++ .../FieldsetFieldGroup/FieldsetFieldGroup.tsx | 54 ++ .../components/FieldsetFieldGroup/index.ts | 1 + .../Fieldsets/FieldsetCard/FieldsetCard.css | 57 +++ .../Fieldsets/FieldsetCard/FieldsetCard.tsx | 141 ++++++ .../Fieldsets/FieldsetCard/index.ts | 1 + .../Fieldsets/FieldsetCard/types.ts | 5 + .../FieldsetDetails/FieldsetDetails.css | 286 +++++++++++ .../FieldsetDetails/FieldsetDetails.tsx | 475 ++++++++++++++++++ .../FieldsetDetailsSkeleton.tsx | 17 + .../FieldsetDetails/fieldsetFieldMappers.ts | 31 ++ .../Fieldsets/FieldsetDetails/index.ts | 2 + .../Fieldsets/FieldsetDetails/types.ts | 8 + .../Fieldsets/FieldsetModal/FieldsetModal.css | 16 + .../Fieldsets/FieldsetModal/FieldsetModal.tsx | 137 +++++ .../Fieldsets/FieldsetModal/types.ts | 9 + .../public/components/Fieldsets/Fieldsets.css | 24 + .../public/components/Fieldsets/Fieldsets.tsx | 78 +++ .../src/public/components/Fieldsets/index.ts | 2 + .../components/KickoffEdit/KickoffEdit.tsx | 23 +- .../KickoffOutputs/KickoffOutputs.css | 23 + .../KickoffOutputs/KickoffOutputs.tsx | 74 ++- .../public/components/PageTitle/PageTitle.tsx | 2 + .../public/components/TaskCard/TaskCard.tsx | 56 ++- .../FieldsetOutputsPreview.css | 18 + .../FieldsetOutputsPreview.tsx | 49 ++ .../FieldsetPicker/FieldsetPicker.css | 162 ++++++ .../FieldsetPicker/FieldsetPicker.tsx | 149 ++++++ .../TemplateEdit/FieldsetPicker/index.ts | 1 + .../KickoffRedux/KickoffRedux.css | 5 + .../KickoffRedux/KickoffRedux.tsx | 29 +- .../TemplateEdit/OutputForm/OutputForm.css | 69 +++ .../TemplateEdit/OutputForm/OutputForm.tsx | 45 +- .../OutputForm/OutputFormTaskMerged.tsx | 277 ++++++++++ .../TemplateEdit/OutputForm/index.ts | 1 + .../TemplateEdit/TaskForm/DueDate/DueDate.tsx | 8 +- .../DueDate/utils/getRuleTargetOptions.ts | 11 +- .../TemplateEdit/TaskForm/TaskForm.tsx | 44 +- .../TemplateEdit/TaskForm/container.ts | 8 +- .../utils/__tests__/getTaskVariables.test.ts | 77 ++- .../TaskForm/utils/getTaskVariables.tsx | 109 +++- .../TemplateEdit/TaskItem/TaskItem.tsx | 18 +- .../TemplateEdit/TaskMenu/TaskMenu.tsx | 2 +- .../FieldsetFlowRowDropdown.tsx | 91 ++++ .../TaskOutputFlow/FieldsetIconPicker.tsx | 160 ++++++ .../__tests__/mergeTaskOutputFlow.test.ts | 62 +++ .../TaskOutputFlow/mergeTaskOutputFlow.ts | 120 +++++ .../TaskRenderExtraFieldsInfo.tsx | 25 +- .../TemplateControlls/TemplateControlls.tsx | 20 +- .../components/TemplateEdit/TemplateEdit.tsx | 79 +-- .../TemplateEditFieldsetsContext.tsx | 40 ++ .../TemplateEditVariablesSync.tsx | 45 ++ .../public/components/TemplateEdit/types.ts | 1 + .../utils/__tests__/getClonedTask.test.ts | 1 + .../__tests__/getRunnableWorkflow.test.ts | 12 + .../TemplateEdit/utils/getRunnableWorkflow.ts | 55 +- .../components/UI/Dropdown/Dropdown.css | 16 +- .../components/UI/Dropdown/Dropdown.tsx | 27 +- .../components/UI/ReturnLink/ReturnLink.tsx | 4 +- .../WorkflowEditPopup/WorkflowEditPopup.tsx | 35 +- .../__tests__/WorkflowEditPopup.test.tsx | 2 + .../components/WorkflowEditPopup/types.ts | 3 +- .../Workflows/WorkflowModal/WorkflowModal.tsx | 41 +- .../utils/__tests__/getClonedKickoff.test.ts | 1 + .../WorkflowsGridPage/WorkflowsGridPage.tsx | 7 +- .../public/components/icons/FieldsetIcon.tsx | 11 + .../src/public/constants/defaultValues.ts | 3 + frontend/src/public/constants/routes.ts | 2 + frontend/src/public/constants/sortings.ts | 10 + frontend/src/public/constants/titles.ts | 1 + .../hooks/useTemplateEditFieldsetsCatalog.ts | 45 ++ frontend/src/public/lang/locales/en_US.ts | 62 +++ frontend/src/public/lang/locales/ru_RU.ts | 59 +++ .../layout/TemplateLayout/TemplateLayout.tsx | 27 + .../TemplatesLayout/TemplatesLayout.tsx | 6 +- frontend/src/public/redux/dashboard/saga.ts | 64 ++- frontend/src/public/redux/fieldsets/saga.ts | 169 +++++++ frontend/src/public/redux/fieldsets/slice.ts | 159 ++++++ frontend/src/public/redux/fieldsets/types.ts | 4 + frontend/src/public/redux/reducers.ts | 2 + .../__tests__/reducer.test.ts | 1 + frontend/src/public/redux/sagas.ts | 2 + .../src/public/redux/selectors/fieldsets.ts | 23 + frontend/src/public/redux/workflows/saga.ts | 7 +- frontend/src/public/redux/workflows/slice.ts | 2 +- frontend/src/public/types/fieldset.ts | 101 ++++ frontend/src/public/types/redux.ts | 24 +- frontend/src/public/types/tasks.ts | 3 +- frontend/src/public/types/template.ts | 53 +- frontend/src/public/types/workflow.ts | 2 + .../public/utils/__tests__/template.test.ts | 4 + frontend/src/public/utils/getErrorMessage.ts | 10 +- .../mapFieldsetTemplateToFieldsetData.ts | 44 ++ frontend/src/public/utils/template.ts | 6 +- frontend/src/public/utils/validators.ts | 12 + frontend/src/public/utils/workflows.ts | 2 +- .../src/public/views/Fieldsets/Fieldsets.tsx | 35 ++ frontend/src/public/views/Fieldsets/index.ts | 1 + .../src/public/views/Template/Template.tsx | 3 + frontend/stylelint/config.js | 2 +- 107 files changed, 4374 insertions(+), 201 deletions(-) create mode 100644 frontend/src/public/api/fieldsets/createFieldset.ts create mode 100644 frontend/src/public/api/fieldsets/deleteFieldset.ts create mode 100644 frontend/src/public/api/fieldsets/getFieldset.ts create mode 100644 frontend/src/public/api/fieldsets/getFieldsets.ts create mode 100644 frontend/src/public/api/fieldsets/updateFieldset.ts create mode 100644 frontend/src/public/components/FieldsetFieldGroup/FieldsetFieldGroup.css create mode 100644 frontend/src/public/components/FieldsetFieldGroup/FieldsetFieldGroup.tsx create mode 100644 frontend/src/public/components/FieldsetFieldGroup/index.ts create mode 100644 frontend/src/public/components/Fieldsets/FieldsetCard/FieldsetCard.css create mode 100644 frontend/src/public/components/Fieldsets/FieldsetCard/FieldsetCard.tsx create mode 100644 frontend/src/public/components/Fieldsets/FieldsetCard/index.ts create mode 100644 frontend/src/public/components/Fieldsets/FieldsetCard/types.ts create mode 100644 frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetails.css create mode 100644 frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetails.tsx create mode 100644 frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetailsSkeleton.tsx create mode 100644 frontend/src/public/components/Fieldsets/FieldsetDetails/fieldsetFieldMappers.ts create mode 100644 frontend/src/public/components/Fieldsets/FieldsetDetails/index.ts create mode 100644 frontend/src/public/components/Fieldsets/FieldsetDetails/types.ts create mode 100644 frontend/src/public/components/Fieldsets/FieldsetModal/FieldsetModal.css create mode 100644 frontend/src/public/components/Fieldsets/FieldsetModal/FieldsetModal.tsx create mode 100644 frontend/src/public/components/Fieldsets/FieldsetModal/types.ts create mode 100644 frontend/src/public/components/Fieldsets/Fieldsets.css create mode 100644 frontend/src/public/components/Fieldsets/Fieldsets.tsx create mode 100644 frontend/src/public/components/Fieldsets/index.ts create mode 100644 frontend/src/public/components/TemplateEdit/FieldsetOutputsPreview/FieldsetOutputsPreview.css create mode 100644 frontend/src/public/components/TemplateEdit/FieldsetOutputsPreview/FieldsetOutputsPreview.tsx create mode 100644 frontend/src/public/components/TemplateEdit/FieldsetPicker/FieldsetPicker.css create mode 100644 frontend/src/public/components/TemplateEdit/FieldsetPicker/FieldsetPicker.tsx create mode 100644 frontend/src/public/components/TemplateEdit/FieldsetPicker/index.ts create mode 100644 frontend/src/public/components/TemplateEdit/OutputForm/OutputFormTaskMerged.tsx create mode 100644 frontend/src/public/components/TemplateEdit/TaskOutputFlow/FieldsetFlowRowDropdown.tsx create mode 100644 frontend/src/public/components/TemplateEdit/TaskOutputFlow/FieldsetIconPicker.tsx create mode 100644 frontend/src/public/components/TemplateEdit/TaskOutputFlow/__tests__/mergeTaskOutputFlow.test.ts create mode 100644 frontend/src/public/components/TemplateEdit/TaskOutputFlow/mergeTaskOutputFlow.ts create mode 100644 frontend/src/public/components/TemplateEdit/TemplateEditFieldsetsContext.tsx create mode 100644 frontend/src/public/components/TemplateEdit/TemplateEditVariablesSync.tsx create mode 100644 frontend/src/public/components/icons/FieldsetIcon.tsx create mode 100644 frontend/src/public/hooks/useTemplateEditFieldsetsCatalog.ts create mode 100644 frontend/src/public/redux/fieldsets/saga.ts create mode 100644 frontend/src/public/redux/fieldsets/slice.ts create mode 100644 frontend/src/public/redux/fieldsets/types.ts create mode 100644 frontend/src/public/redux/selectors/fieldsets.ts create mode 100644 frontend/src/public/types/fieldset.ts create mode 100644 frontend/src/public/utils/mapFieldsetTemplateToFieldsetData.ts create mode 100644 frontend/src/public/views/Fieldsets/Fieldsets.tsx create mode 100644 frontend/src/public/views/Fieldsets/index.ts diff --git a/frontend/config/common.json b/frontend/config/common.json index ea0b41971..08bf0d7ec 100644 --- a/frontend/config/common.json +++ b/frontend/config/common.json @@ -152,6 +152,9 @@ "tenantToken": "/tenants/:id/token", "datasets": "/datasets", "dataset": "/datasets/:id", + "templateFieldsets": "/templates/:id/fieldsets", + "fieldsets": "/templates/fieldsets", + "fieldset": "/templates/fieldsets/:id", "getFaq": "/faq", "wsNotifications": "/ws/notifications", "wsNewTask": "/ws/workflows/new-task", diff --git a/frontend/src/public/api/fieldsets/createFieldset.ts b/frontend/src/public/api/fieldsets/createFieldset.ts new file mode 100644 index 000000000..23f5a9109 --- /dev/null +++ b/frontend/src/public/api/fieldsets/createFieldset.ts @@ -0,0 +1,23 @@ +import { commonRequest } from '../commonRequest'; +import { IFieldsetTemplate, ICreateFieldsetParams } from '../../types/fieldset'; +import { getBrowserConfigEnv } from '../../utils/getConfig'; +import { mapRequestBody } from '../../utils/mappers'; + +export function createFieldset({ templateId, name, description, rules, fields }: ICreateFieldsetParams) { + const { + api: { urls }, + } = getBrowserConfigEnv(); + + const url = urls.templateFieldsets.replace(':id', String(templateId)); + + return commonRequest( + url, + { + method: 'POST', + data: mapRequestBody({ name, description, rules, fields }), + }, + { + shouldThrow: true, + }, + ); +} diff --git a/frontend/src/public/api/fieldsets/deleteFieldset.ts b/frontend/src/public/api/fieldsets/deleteFieldset.ts new file mode 100644 index 000000000..c6613b772 --- /dev/null +++ b/frontend/src/public/api/fieldsets/deleteFieldset.ts @@ -0,0 +1,22 @@ +import { commonRequest } from '../commonRequest'; +import { IDeleteFieldsetParams } from '../../types/fieldset'; +import { getBrowserConfigEnv } from '../../utils/getConfig'; + +export function deleteFieldset({ id }: IDeleteFieldsetParams) { + const { + api: { urls }, + } = getBrowserConfigEnv(); + + const url = urls.fieldset.replace(':id', String(id)); + + return commonRequest( + url, + { + method: 'DELETE', + }, + { + shouldThrow: true, + responseType: 'empty', + }, + ); +} diff --git a/frontend/src/public/api/fieldsets/getFieldset.ts b/frontend/src/public/api/fieldsets/getFieldset.ts new file mode 100644 index 000000000..2e5c806d8 --- /dev/null +++ b/frontend/src/public/api/fieldsets/getFieldset.ts @@ -0,0 +1,22 @@ +import { commonRequest } from '../commonRequest'; +import { IFieldsetTemplate, IGetFieldsetParams } from '../../types/fieldset'; +import { getBrowserConfigEnv } from '../../utils/getConfig'; + +export function getFieldset({ id, signal }: IGetFieldsetParams) { + const { + api: { urls }, + } = getBrowserConfigEnv(); + + const url = urls.fieldset.replace(':id', String(id)); + + return commonRequest( + url, + { + method: 'GET', + signal, + }, + { + shouldThrow: true, + }, + ); +} diff --git a/frontend/src/public/api/fieldsets/getFieldsets.ts b/frontend/src/public/api/fieldsets/getFieldsets.ts new file mode 100644 index 000000000..14c0c6da1 --- /dev/null +++ b/frontend/src/public/api/fieldsets/getFieldsets.ts @@ -0,0 +1,36 @@ +import { commonRequest } from '../commonRequest'; +import { IGetFieldsetsResponse, IGetFieldsetsParams } from '../../types/fieldset'; +import { fieldsetsOrderingMap } from '../../constants/sortings'; +import { getBrowserConfigEnv } from '../../utils/getConfig'; + +export function getFieldsets(config: IGetFieldsetsParams) { + const { + api: { urls }, + } = getBrowserConfigEnv(); + + const { signal, templateId } = config; + const queryString = getFieldsetsQueryString(config); + const baseUrl = urls.templateFieldsets.replace(':id', String(templateId)); + const url = queryString ? `${baseUrl}?${queryString}` : baseUrl; + + return commonRequest( + url, + { + method: 'GET', + signal, + }, + { + shouldThrow: true, + }, + ); +} + +function getFieldsetsQueryString({ ordering, limit, offset }: IGetFieldsetsParams): string { + const backendOrdering = ordering ? fieldsetsOrderingMap[ordering] || ordering : undefined; + + return [ + backendOrdering && `ordering=${backendOrdering}`, + limit !== undefined && `limit=${limit}`, + offset !== undefined && `offset=${offset}`, + ].filter(Boolean).join('&'); +} diff --git a/frontend/src/public/api/fieldsets/updateFieldset.ts b/frontend/src/public/api/fieldsets/updateFieldset.ts new file mode 100644 index 000000000..c587fe997 --- /dev/null +++ b/frontend/src/public/api/fieldsets/updateFieldset.ts @@ -0,0 +1,24 @@ +import { commonRequest } from '../commonRequest'; +import { IFieldsetTemplate, IUpdateFieldsetParams } from '../../types/fieldset'; +import { getBrowserConfigEnv } from '../../utils/getConfig'; +import { mapRequestBody } from '../../utils/mappers'; + +export function updateFieldset({ id, signal, ...data }: IUpdateFieldsetParams) { + const { + api: { urls }, + } = getBrowserConfigEnv(); + + const url = urls.fieldset.replace(':id', String(id)); + + return commonRequest( + url, + { + method: 'PATCH', + data: mapRequestBody(data), + signal, + }, + { + shouldThrow: true, + }, + ); +} diff --git a/frontend/src/public/api/getTemplateFields.ts b/frontend/src/public/api/getTemplateFields.ts index c1369e0cb..9dc2dfb7a 100644 --- a/frontend/src/public/api/getTemplateFields.ts +++ b/frontend/src/public/api/getTemplateFields.ts @@ -3,8 +3,8 @@ import { getBrowserConfigEnv } from '../utils/getConfig'; import { IKickoff, ITemplateTask } from '../types/template'; export type TGetTemplateFieldsResponse = { - tasks: Pick[]; - kickoff: Pick; + tasks: Pick[]; + kickoff: Pick; }; export function getTemplateFields(id: string, signal?: AbortSignal) { diff --git a/frontend/src/public/components/FieldsetFieldGroup/FieldsetFieldGroup.css b/frontend/src/public/components/FieldsetFieldGroup/FieldsetFieldGroup.css new file mode 100644 index 000000000..c8bdc4abe --- /dev/null +++ b/frontend/src/public/components/FieldsetFieldGroup/FieldsetFieldGroup.css @@ -0,0 +1,29 @@ +.fieldset-group { + margin-top: 16px; + margin-bottom: 16px; + padding-left: 16px; + border-left: 3px solid var(--pneumatic-color-link); +} + +.fieldset-group__title { + margin: 0 0 4px; + font-family: Nunito, sans-serif; + font-size: 14px; + font-weight: bold; + line-height: 20px; + color: #262522; +} + +.fieldset-group__description { + margin: 0 0 8px; + font-family: Nunito, sans-serif; + font-size: 13px; + line-height: 18px; + color: #79756d; +} + +.fieldset-group__error { + margin-top: 4px; + font-size: 13px; + color: var(--color-error, #e74c3c); +} diff --git a/frontend/src/public/components/FieldsetFieldGroup/FieldsetFieldGroup.tsx b/frontend/src/public/components/FieldsetFieldGroup/FieldsetFieldGroup.tsx new file mode 100644 index 000000000..2ff7a471e --- /dev/null +++ b/frontend/src/public/components/FieldsetFieldGroup/FieldsetFieldGroup.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { IExtraField, EExtraFieldMode } from '../../types/template'; +import { EInputNameBackgroundColor } from '../../types/workflow'; +import { ExtraFieldIntl } from '../TemplateEdit/ExtraFields'; + +import styles from './FieldsetFieldGroup.css'; + +export interface IFieldsetFieldGroupProps { + fields: IExtraField[]; + title?: string; + description?: string; + onEditField: (apiName: string) => (changedProps: Partial) => void; + mode: EExtraFieldMode; + labelBackgroundColor: EInputNameBackgroundColor; + accountId: number; + fieldClassName?: string; + validationError?: string | null; +} + +export function FieldsetFieldGroup({ + fields, + title, + description, + onEditField, + mode, + labelBackgroundColor, + accountId, + fieldClassName, + validationError, +}: IFieldsetFieldGroupProps) { + return ( +
+ {title &&

{title}

} + {description &&

{description}

} + {fields.map((field) => ( + + ))} + {validationError && ( +

{validationError}

+ )} +
+ ); +} diff --git a/frontend/src/public/components/FieldsetFieldGroup/index.ts b/frontend/src/public/components/FieldsetFieldGroup/index.ts new file mode 100644 index 000000000..61e098002 --- /dev/null +++ b/frontend/src/public/components/FieldsetFieldGroup/index.ts @@ -0,0 +1 @@ +export { FieldsetFieldGroup } from './FieldsetFieldGroup'; diff --git a/frontend/src/public/components/Fieldsets/FieldsetCard/FieldsetCard.css b/frontend/src/public/components/Fieldsets/FieldsetCard/FieldsetCard.css new file mode 100644 index 000000000..0978e1c6b --- /dev/null +++ b/frontend/src/public/components/Fieldsets/FieldsetCard/FieldsetCard.css @@ -0,0 +1,57 @@ +.card { + @mixin page-card; +} + +.card__content { + @mixin page-card-content; +} + +.card__header { + @mixin page-card-header; +} + +.card__title { + text-decoration: none; + cursor: pointer; + color: inherit; + + &:hover { + color: var(--pneumatic-color-link-hover); + } + + @mixin page-card-title; +} + +.card__more { + @mixin page-card-more; +} + +.card__footer { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 0.4rem; + + .card-stats { + font-style: normal; + color: var(--pneumatic-color-black48); + + @mixin text-small; + + > *:not(:last-child) { + margin-right: 0.8rem; + } + } + + .card-stats--items { + color: var(--pneumatic-color-black72); + + @mixin text-base 700; + } + + .card-stats--rules { + color: var(--pneumatic-color-black48); + + @mixin text-small; + } +} diff --git a/frontend/src/public/components/Fieldsets/FieldsetCard/FieldsetCard.tsx b/frontend/src/public/components/Fieldsets/FieldsetCard/FieldsetCard.tsx new file mode 100644 index 000000000..16425ba86 --- /dev/null +++ b/frontend/src/public/components/Fieldsets/FieldsetCard/FieldsetCard.tsx @@ -0,0 +1,141 @@ +import React, { useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { useIntl } from 'react-intl'; +import classnames from 'classnames'; + +import { Dropdown, TDropdownOption } from '../../UI'; +import { MoreIcon, PencilIcon, TrashIcon } from '../../icons'; +import { WarningPopup } from '../../UI/WarningPopup'; +import { openEditModal, deleteFieldsetAction, setCurrentFieldset } from '../../../redux/fieldsets/slice'; +import { history } from '../../../utils/history'; +import { ERoutes } from '../../../constants/routes'; +import { sanitizeText } from '../../../utils/strings'; +import { IFieldsetCardProps } from './types'; + +import styles from './FieldsetCard.css'; + +export function FieldsetCard({ + id, + name, + description, + labelPosition, + layout, + order, + kickoffId, + taskId, + rules, + fields, + templateId, +}: IFieldsetCardProps) { + const { formatMessage } = useIntl(); + const dispatch = useDispatch(); + + const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + const handleOpenDeleteModal = () => setIsDeleteModalVisible(true); + const handleCloseDeleteModal = () => setIsDeleteModalVisible(false); + + const handleConfirmDelete = () => { + dispatch(deleteFieldsetAction({ id })); + handleCloseDeleteModal(); + }; + + const handleEditName = () => { + dispatch(setCurrentFieldset({ + id, + name, + description, + labelPosition, + layout, + order, + kickoffId, + taskId, + rules, + fields, + })); + dispatch(openEditModal()); + }; + + const handleCardClick = () => { + history.push( + ERoutes.TemplateFieldsetDetail + .replace(':templateId', templateId.toString()) + .replace(':id', id.toString()), + ); + }; + + const dropdownOptions: TDropdownOption[] = [ + { + label: formatMessage({ id: 'fieldsets.edit' }), + onClick: handleEditName, + Icon: PencilIcon, + size: 'sm', + }, + { + label: formatMessage({ id: 'fieldsets.delete' }), + onClick: handleOpenDeleteModal, + Icon: TrashIcon, + color: 'red', + withUpperline: true, + size: 'sm', + }, + ]; + + const hasContent = fields.length > 0 || rules.length > 0; + + return ( +
+ {name} })} + closeModal={handleCloseDeleteModal} + isOpen={isDeleteModalVisible} + onConfirm={handleConfirmDelete} + onReject={handleCloseDeleteModal} + /> + +
+
+
e.key === 'Enter' && handleCardClick()} + role="link" + tabIndex={0} + > + {sanitizeText(name)} +
+ + ( + + )} + options={dropdownOptions} + /> +
+ + {hasContent && ( +
+ {fields.length > 0 && ( +
+ {formatMessage( + { id: 'fieldsets.stats.fields' }, + { count: fields.length }, + )} +
+ )} + {rules.length > 0 && ( +
+ {formatMessage( + { id: 'fieldsets.stats.rules' }, + { count: rules.length }, + )} +
+ )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/public/components/Fieldsets/FieldsetCard/index.ts b/frontend/src/public/components/Fieldsets/FieldsetCard/index.ts new file mode 100644 index 000000000..050e95ea0 --- /dev/null +++ b/frontend/src/public/components/Fieldsets/FieldsetCard/index.ts @@ -0,0 +1 @@ +export * from './FieldsetCard'; diff --git a/frontend/src/public/components/Fieldsets/FieldsetCard/types.ts b/frontend/src/public/components/Fieldsets/FieldsetCard/types.ts new file mode 100644 index 000000000..aefce5d49 --- /dev/null +++ b/frontend/src/public/components/Fieldsets/FieldsetCard/types.ts @@ -0,0 +1,5 @@ +import { IFieldsetListItem } from '../../../types/fieldset'; + +export interface IFieldsetCardProps extends IFieldsetListItem { + templateId: number; +} diff --git a/frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetails.css b/frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetails.css new file mode 100644 index 000000000..ef1246d80 --- /dev/null +++ b/frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetails.css @@ -0,0 +1,286 @@ +.container { + margin: 0 auto; + max-width: 108.8rem; +} + +.header { + @mixin details-header; + + .header__config { + display: flex; + align-items: center; + gap: 0.8rem; + } +} + +.description { + padding: 0 0 2.4rem; + color: var(--pneumatic-color-black72); + + @mixin text-base; +} + +.section-title { + margin-bottom: 1.6rem; + font-size: 1.8rem; + font-weight: bold; + + @mixin text-base; +} + +.list { + margin: 0 -1.6rem; + padding: 3.2rem 1.6rem; + background-color: var(--pneumatic-color-white); + + &:not(:last-child) { + margin-bottom: 2.4rem; + } + + @media (--desktop) { + margin: initial; + padding: 3.2rem; + border-radius: 2.4rem; + + &:not(:last-child) { + margin-bottom: 2.4rem; + } + } +} + +.settings-form { + display: flex; + flex-direction: column; + gap: 2.4rem; +} + +.settings-field { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.settings-label { + font-weight: 600; + color: var(--pneumatic-color-black72); + + @mixin text-base; +} + +.settings-textarea { + padding: 1.2rem 1.6rem; + width: 100%; + min-height: 8rem; + font-family: inherit; + resize: vertical; + border: 1px solid var(--pneumatic-color-black16); + border-radius: 0.8rem; + outline: none; + transition: border-color 0.2s; + + @mixin text-base; + + &:focus { + border-color: var(--pneumatic-color-blue); + } +} + +.settings-select { + padding: 0.8rem 1.2rem; + width: fit-content; + min-width: 20rem; + cursor: pointer; + background-color: var(--pneumatic-color-white); + border: 1px solid var(--pneumatic-color-black16); + border-radius: 0.8rem; + outline: none; + transition: border-color 0.2s; + appearance: auto; + + @mixin text-base; + + &:focus { + border-color: var(--pneumatic-color-blue); + } +} + +.settings-input { + padding: 0.8rem 1.2rem; + width: fit-content; + min-width: 20rem; + border: 1px solid var(--pneumatic-color-black16); + border-radius: 0.8rem; + outline: none; + transition: border-color 0.2s; + + @mixin text-base; + + &:focus { + border-color: var(--pneumatic-color-blue); + } +} + +.empty-text { + color: var(--pneumatic-color-black48); + + @mixin text-base; +} + +.components { + display: flex; + flex-flow: row nowrap; + overflow-x: scroll; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.components::-webkit-scrollbar { + display: none; +} + +.fields { + margin-top: 1.6rem; +} + +.save-bar { + margin-top: 2.4rem; + display: flex; + align-items: center; + gap: 1.6rem; +} + +.save-bar__hint { + color: var(--pneumatic-color-black48); + + @mixin text-small; +} + +.fields-table { + width: 100%; + border-collapse: collapse; +} + +.fields-table th { + padding: 0.8rem 1.6rem; + font-weight: 600; + text-align: left; + color: var(--pneumatic-color-black48); + border-bottom: 2px solid var(--pneumatic-color-black16); + + @mixin text-small; +} + +.fields-table td { + padding: 1.2rem 1.6rem; + border-bottom: 1px solid var(--pneumatic-color-black16); + + @mixin text-base; +} + +.fields-table tbody tr:hover { + background-color: var(--pneumatic-color-black4); +} + +.rule-row { + padding: 1.2rem 0; + display: flex; + border-bottom: 1px solid var(--pneumatic-color-black16); + align-items: center; + gap: 1.2rem; + flex-wrap: wrap; +} + +.rule-row:last-child { + border-bottom: none; +} + +.rule-fields-selector { + flex-basis: 100%; + display: flex; + align-items: center; + gap: 0.8rem; +} + +.rule-fields-label { + font-weight: 600; + color: var(--pneumatic-color-black72); + white-space: nowrap; + + @mixin text-small; +} + +.rule-fields-select { + flex: 1; + min-width: 0; +} + +.rule-type-label { + min-width: 10rem; + font-weight: 600; + + @mixin text-base; +} + +.rule-value-input { + flex: 1; + padding: 0.8rem 1.2rem; + border: 1px solid var(--pneumatic-color-black16); + border-radius: 0.8rem; + outline: none; + transition: border-color 0.2s; + + @mixin text-base; + + &:focus { + border-color: var(--pneumatic-color-blue); + } +} + +.rule-delete-btn { + padding: 0.6rem 1.2rem; + cursor: pointer; + color: var(--pneumatic-color-black48); + background: none; + border: 1px solid var(--pneumatic-color-black16); + border-radius: 0.8rem; + transition: all 0.2s; + + @mixin text-small; + + &:hover { + color: var(--pneumatic-color-red); + border-color: var(--pneumatic-color-red); + } +} + +.add-rule-btn { + margin-top: 1.6rem; + padding: 0.8rem 1.6rem; + font-weight: 600; + cursor: pointer; + color: var(--pneumatic-color-blue); + background: none; + border: 1px dashed var(--pneumatic-color-black16); + border-radius: 0.8rem; + transition: all 0.2s; + + @mixin text-base; + + &:hover { + background-color: var(--pneumatic-color-blue-light); + border-color: var(--pneumatic-color-blue); + } +} + +.header-skeleton { + background-color: var(--pneumatic-color-skeleton-gray-bg); + background-image: + linear-gradient( + 90deg, + var(--pneumatic-color-skeleton-gray-bg), + var(--pneumatic-color-skeleton-gray-bg-highlight), + var(--pneumatic-color-skeleton-gray-bg) + ); + background-repeat: no-repeat; + background-size: 200px 100%; +} diff --git a/frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetails.tsx b/frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetails.tsx new file mode 100644 index 000000000..6af345762 --- /dev/null +++ b/frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetails.tsx @@ -0,0 +1,475 @@ +import * as React from 'react'; +import { useEffect, useState, useMemo, useCallback } from 'react'; +import { useIntl } from 'react-intl'; +import { useDispatch, useSelector } from 'react-redux'; + +import { + openEditModal, + deleteFieldsetAction, + loadCurrentFieldset, + resetCurrentFieldset, + updateFieldsetAction, + setTemplateId, +} from '../../../redux/fieldsets/slice'; + + +import { history } from '../../../utils/history'; +import { ERoutes } from '../../../constants/routes'; + +import { ModifyDropdown, Button, FilterSelect } from '../../UI'; +import { EModifyDropdownToggle } from '../../UI/ModifyDropdown/types'; +import { FieldsetModal } from '../FieldsetModal/FieldsetModal'; +import { EFieldsetModalType } from '../FieldsetModal/types'; +import { FieldsetDetailsSkeleton } from './FieldsetDetailsSkeleton'; + +import { getCurrentFieldset, isCurrentFieldsetLoading } from '../../../redux/selectors/fieldsets'; +import { getAccountId } from '../../../redux/selectors/user'; + + +import { EExtraFieldType, IExtraField } from '../../../types/template'; +import { EInputNameBackgroundColor, EMoveDirections } from '../../../types/workflow'; +import { IFieldsetTemplateRule } from '../../../types/fieldset'; +import { ExtraFieldsMap } from '../../TemplateEdit/ExtraFields/utils/ExtraFieldsMap'; +import { ExtraFieldIcon } from '../../TemplateEdit/ExtraFields/utils/ExtraFieldIcon'; +import { ExtraFieldIntl } from '../../TemplateEdit/ExtraFields'; +import { getEmptyField } from '../../TemplateEdit/KickoffRedux/utils/getEmptyField'; +import { getEditedFields } from '../../TemplateEdit/ExtraFields/utils/getEditedFields'; +import { getNormalizeFieldsOrders, moveWorkflowField } from '../../../utils/workflows'; + +import { normalizeFieldsForUI } from './fieldsetFieldMappers'; + +import { TFieldLabelPosition } from '../../../types/fieldset'; +import { TFieldsetDetailsProps } from './types'; +import styles from './FieldsetDetails.css'; + +const RULE_TYPES = [ + { value: 'sum_equal', labelKey: 'fieldsets.rule-type-sum_equal' }, +] as const; + +const LABEL_POSITION_OPTIONS: { value: TFieldLabelPosition; labelKey: string }[] = [ + { value: 'top', labelKey: 'fieldsets.settings.label-position.top' }, + { value: 'left', labelKey: 'fieldsets.settings.label-position.left' }, +]; + + + +const FieldsetDetails = ({ match: { params: { id: matchParamId, templateId: matchTemplateId } } }: TFieldsetDetailsProps) => { + const { formatMessage } = useIntl(); + const dispatch = useDispatch(); + const fieldset = useSelector(getCurrentFieldset); + const isLoading = useSelector(isCurrentFieldsetLoading); + const accountId = useSelector(getAccountId); + + + const [localFields, setLocalFields] = useState([]); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + const [localRules, setLocalRules] = useState([]); + const [hasUnsavedRuleChanges, setHasUnsavedRuleChanges] = useState(false); + + // Settings local state + const [localDescription, setLocalDescription] = useState(''); + const [localLabelPosition, setLocalLabelPosition] = useState('top'); + + const [hasUnsavedSettingsChanges, setHasUnsavedSettingsChanges] = useState(false); + + const fieldsetListRoute = ERoutes.TemplateFieldsets.replace(':templateId', matchTemplateId); + + + + + useEffect(() => { + const id = Number(matchParamId); + if (Number.isNaN(id)) { + history.push(fieldsetListRoute); + + return; + } + if (fieldset?.id === id) return; + + dispatch(setTemplateId(Number(matchTemplateId))); + dispatch(loadCurrentFieldset({ id })); + }, [matchParamId]); + + + + useEffect(() => { + return () => { + dispatch(resetCurrentFieldset()); + }; + }, []); + + // Sync local fields when fieldset loads/updates from server + useEffect(() => { + if (fieldset?.fields) { + setLocalFields(normalizeFieldsForUI(fieldset.fields as unknown as IExtraField[])); + setHasUnsavedChanges(false); + } + }, [fieldset?.fields]); + + // Sync local rules when fieldset loads/updates from server + useEffect(() => { + if (fieldset?.rules) { + setLocalRules(fieldset.rules); + setHasUnsavedRuleChanges(false); + } + }, [fieldset?.rules]); + + // Sync settings when fieldset loads/updates from server + useEffect(() => { + if (fieldset) { + setLocalDescription(fieldset.description || ''); + setLocalLabelPosition(fieldset.labelPosition || 'top'); + setHasUnsavedSettingsChanges(false); + } + }, [ + fieldset?.id, + fieldset?.description, + fieldset?.labelPosition, + ]); + + const handleSettingsDescriptionChange = (e: React.ChangeEvent) => { + setLocalDescription(e.target.value); + setHasUnsavedSettingsChanges(true); + }; + + const handleSettingsLabelPositionChange = (e: React.ChangeEvent) => { + setLocalLabelPosition(e.target.value as TFieldLabelPosition); + setHasUnsavedSettingsChanges(true); + }; + + + + const handleSaveSettings = () => { + if (!fieldset) return; + dispatch(updateFieldsetAction({ + id: fieldset.id, + description: localDescription, + label_position: localLabelPosition, + })); + setHasUnsavedSettingsChanges(false); + }; + + const getSortedFields = useCallback(() => { + return [...localFields].sort((a, b) => b.order - a.order); + }, [localFields]); + + const sortedFields = useMemo(() => getSortedFields(), [getSortedFields]); + + const handleCreateField = (type: EExtraFieldType) => { + const newFields = getNormalizeFieldsOrders([...localFields, getEmptyField(type, formatMessage)]); + setLocalFields(newFields); + setHasUnsavedChanges(true); + }; + + const handleEditField = (apiName: string) => (changedProps: Partial) => { + const newFields = getEditedFields(getSortedFields(), apiName, changedProps); + setLocalFields(newFields); + setHasUnsavedChanges(true); + }; + + const handleDeleteField = (idx: number) => { + const newFields = getSortedFields().filter((_, index) => index !== idx); + setLocalFields(getNormalizeFieldsOrders(newFields)); + setHasUnsavedChanges(true); + }; + + const handleMoveField = (from: number, direction: EMoveDirections) => { + const to = direction === EMoveDirections.Up ? from - 1 : from + 1; + const newFields = moveWorkflowField(from, to, getSortedFields()); + setLocalFields(newFields); + setHasUnsavedChanges(true); + }; + + const handleSaveFields = () => { + if (!fieldset) return; + const fieldsPayload = localFields.map(({ id: _id, ...rest }) => rest); + dispatch(updateFieldsetAction({ + id: fieldset.id, + fields: fieldsPayload as any, + })); + setHasUnsavedChanges(false); + }; + + // Rules handlers + const handleAddRule = () => { + const newRule: IFieldsetTemplateRule = { + id: -(Date.now()), // temporary negative id for new rules + type: RULE_TYPES[0].value, + value: '', + fields: [], + }; + setLocalRules([...localRules, newRule]); + setHasUnsavedRuleChanges(true); + }; + + const handleEditRuleValue = (index: number, value: string) => { + const updated = localRules.map((rule, i) => + i === index ? { ...rule, value } : rule, + ); + setLocalRules(updated); + setHasUnsavedRuleChanges(true); + }; + + const handleEditRuleType = (index: number, type: string) => { + const updated = localRules.map((rule, i) => + i === index ? { ...rule, type } : rule, + ); + setLocalRules(updated); + setHasUnsavedRuleChanges(true); + }; + + const handleEditRuleFields = (index: number, fieldApiNames: (string | number | null)[]) => { + const updated = localRules.map((rule, i) => + i === index ? { ...rule, fields: fieldApiNames.filter((n): n is string => typeof n === 'string') } : rule, + ); + setLocalRules(updated); + setHasUnsavedRuleChanges(true); + }; + + const handleDeleteRule = (index: number) => { + setLocalRules(localRules.filter((_, i) => i !== index)); + setHasUnsavedRuleChanges(true); + }; + + const handleSaveRules = () => { + if (!fieldset) return; + // Strip temporary negative ids for new rules so the backend creates them + const rulesPayload = localRules.map((rule) => ({ + ...rule, + id: rule.id > 0 ? rule.id : undefined, + })); + dispatch(updateFieldsetAction({ + id: fieldset.id, + rules: rulesPayload as IFieldsetTemplateRule[], + })); + setHasUnsavedRuleChanges(false); + }; + + if (isLoading || !fieldset) { + return ; + } + + return ( +
+
+

{fieldset.name}

+
+ dispatch(openEditModal())} + onDelete={() => { + dispatch(deleteFieldsetAction({ id: fieldset.id })); + history.push(fieldsetListRoute); + }} + editLabel={formatMessage({ id: 'fieldsets.edit' })} + deleteLabel={formatMessage({ id: 'fieldsets.delete' })} + toggleType={EModifyDropdownToggle.Modify} + /> +
+
+ +
+

+ {formatMessage({ id: 'fieldsets.settings-section' })} +

+ +
+
+ +