diff --git a/README.md b/README.md index 398492270..1bb905abf 100644 --- a/README.md +++ b/README.md @@ -94,11 +94,10 @@ If you want to be accessing Pneumatic over the Internet and the machine you plan
   # Without SSL
+  SERVER_ADDRESS=your-address
   BACKEND_URL=http://your-address:8001
   FRONTEND_URL=http://your-address
   FORMS_URL=http://form.your-address
-  FRONTEND_DOMAIN=your-address
-  BACKEND_DOMAIN=your-address
   FORM_DOMAIN=form.your-address
   WSS_URL=ws://your-address:8001
 
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/docker-compose.yml b/backend/docker-compose.yml index 2d61d8827..b6a94924b 100755 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -75,11 +75,25 @@ services: RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru CAPTCHA: ${CAPTCHA:-no} + RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY:-} + RECAPTCHA_SECRET_KEY: ${RECAPTCHA_SECRET_KEY:-} + RECAPTCHA_TESTING: ${RECAPTCHA_TESTING:-no} ANALYTICS: ${ANALYTICS:-no} + ANALYTICS_DEBUG: ${ANALYTICS_DEBUG:-no} + ANALYTICS_WRITE_KEY: ${ANALYTICS_WRITE_KEY:-} BILLING: ${BILLING:-no} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_WEBHOOK_IP_WHITELIST: ${STRIPE_WEBHOOK_IP_WHITELIST:-} SIGNUP: ${SIGNUP:-yes} MS_AUTH: ${MS_AUTH:-no} + MS_CLIENT_ID: ${MS_CLIENT_ID:-} + MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-} + MS_AUTHORITY: ${MS_AUTHORITY:-} GOOGLE_AUTH: ${GOOGLE_AUTH:-no} + GOOGLE_OAUTH2_CLIENT_ID: ${GOOGLE_OAUTH2_CLIENT_ID:-} + GOOGLE_OAUTH2_CLIENT_SECRET: ${GOOGLE_OAUTH2_CLIENT_SECRET:-} + GOOGLE_OAUTH2_REDIRECT_URI: ${GOOGLE_OAUTH2_REDIRECT_URI:-} SSO_AUTH: ${SSO_AUTH:-no} SSO_PROVIDER: ${SSO_PROVIDER:-} # Allowed values: okta, auth0 OKTA_DOMAIN: ${OKTA_DOMAIN:-} @@ -119,11 +133,15 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} + FIREBASE_PUSH_APPLICATION_CREDENTIALS: ${FIREBASE_PUSH_APPLICATION_CREDENTIALS:-/pneumatic_backend/firebase-push.json} STORAGE: ${STORAGE:-no} STORAGE_PROVIDER: ${STORAGE_PROVIDER:-} - GOOGLE_APPLICATION_CREDENTIALS: /pneumatic_backend/google_api_credentials.json + GCLOUD_BUCKET_NAME: ${GCLOUD_BUCKET_NAME:-pneumatic} + GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-/pneumatic_backend/google_api_credentials.json} SENTRY_DSN: ${SENTRY_DSN:-} POSTGRES_HOST: ${POSTGRES_HOST:-postgres} POSTGRES_USER: ${POSTGRES_USER:-postgres_user} @@ -142,7 +160,7 @@ services: CHANNELS_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/2" SESSION_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/3" CELERY_BROKER_URL: "amqp://${RABBITMQ_USER:-rabbitmq_user}:${RABBITMQ_PASSWORD:-rabbitmq_password}@rabbitmq:5672" - ALLOWED_HOSTS: "pneumatic-nginx ${FRONTEND_DOMAIN:-localhost}" + ALLOWED_HOSTS: "pneumatic-nginx ${SERVER_ADDRESS:-localhost}" VERIFICATION_CHECK: no CORS_ORIGIN_ALLOW_ALL: no CORS_ALLOW_CREDENTIALS: yes @@ -172,11 +190,25 @@ services: RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru CAPTCHA: ${CAPTCHA:-no} + RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY:-} + RECAPTCHA_SECRET_KEY: ${RECAPTCHA_SECRET_KEY:-} + RECAPTCHA_TESTING: ${RECAPTCHA_TESTING:-no} ANALYTICS: ${ANALYTICS:-no} + ANALYTICS_DEBUG: ${ANALYTICS_DEBUG:-no} + ANALYTICS_WRITE_KEY: ${ANALYTICS_WRITE_KEY:-} BILLING: ${BILLING:-no} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_WEBHOOK_IP_WHITELIST: ${STRIPE_WEBHOOK_IP_WHITELIST:-} SIGNUP: ${SIGNUP:-yes} MS_AUTH: ${MS_AUTH:-no} + MS_CLIENT_ID: ${MS_CLIENT_ID:-} + MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-} + MS_AUTHORITY: ${MS_AUTHORITY:-} GOOGLE_AUTH: ${GOOGLE_AUTH:-no} + GOOGLE_OAUTH2_CLIENT_ID: ${GOOGLE_OAUTH2_CLIENT_ID:-} + GOOGLE_OAUTH2_CLIENT_SECRET: ${GOOGLE_OAUTH2_CLIENT_SECRET:-} + GOOGLE_OAUTH2_REDIRECT_URI: ${GOOGLE_OAUTH2_REDIRECT_URI:-} SSO_AUTH: ${SSO_AUTH:-no} SSO_PROVIDER: ${SSO_PROVIDER:-} # Allowed values: okta, auth0 OKTA_DOMAIN: ${OKTA_DOMAIN:-} @@ -216,11 +248,15 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} + FIREBASE_PUSH_APPLICATION_CREDENTIALS: ${FIREBASE_PUSH_APPLICATION_CREDENTIALS:-/pneumatic_backend/firebase-push.json} STORAGE: ${STORAGE:-no} STORAGE_PROVIDER: ${STORAGE_PROVIDER:-} - GOOGLE_APPLICATION_CREDENTIALS: /pneumatic_backend/google_api_credentials.json + GCLOUD_BUCKET_NAME: ${GCLOUD_BUCKET_NAME:-pneumatic} + GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-/pneumatic_backend/google_api_credentials.json} SENTRY_DSN: ${SENTRY_DSN:-} POSTGRES_HOST: ${POSTGRES_HOST:-postgres} POSTGRES_USER: ${POSTGRES_USER:-postgres_user} @@ -239,7 +275,7 @@ services: CHANNELS_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/2" SESSION_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/3" CELERY_BROKER_URL: "amqp://${RABBITMQ_USER:-rabbitmq_user}:${RABBITMQ_PASSWORD:-rabbitmq_password}@rabbitmq:5672" - ALLOWED_HOSTS: "pneumatic-nginx ${FRONTEND_DOMAIN:-localhost}" + ALLOWED_HOSTS: "pneumatic-nginx ${SERVER_ADDRESS:-localhost}" VERIFICATION_CHECK: no CORS_ORIGIN_ALLOW_ALL: no CORS_ALLOW_CREDENTIALS: yes diff --git a/backend/src/accounts/locale/de/LC_MESSAGES/django.po b/backend/src/accounts/locale/de/LC_MESSAGES/django.po index 9fb728ee2..0413c5321 100644 --- a/backend/src/accounts/locale/de/LC_MESSAGES/django.po +++ b/backend/src/accounts/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/accounts/locale/django.pot b/backend/src/accounts/locale/django.pot index 4657a4b74..921217f3c 100644 --- a/backend/src/accounts/locale/django.pot +++ b/backend/src/accounts/locale/django.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/accounts/locale/es/LC_MESSAGES/django.po b/backend/src/accounts/locale/es/LC_MESSAGES/django.po index e640ce431..447b7de73 100644 --- a/backend/src/accounts/locale/es/LC_MESSAGES/django.po +++ b/backend/src/accounts/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/accounts/locale/fr/LC_MESSAGES/django.po b/backend/src/accounts/locale/fr/LC_MESSAGES/django.po index 556491adf..73f62c3e6 100644 --- a/backend/src/accounts/locale/fr/LC_MESSAGES/django.po +++ b/backend/src/accounts/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/accounts/locale/ru/LC_MESSAGES/django.po b/backend/src/accounts/locale/ru/LC_MESSAGES/django.po index 35bd71ce7..35eeee381 100644 --- a/backend/src/accounts/locale/ru/LC_MESSAGES/django.po +++ b/backend/src/accounts/locale/ru/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/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 c48f3d542..dda698eb3 100644 --- a/backend/src/processes/enums.py +++ b/backend/src/processes/enums.py @@ -248,6 +248,8 @@ class PredicateOperator: MORE_THAN = 'more_than' LESS_THAN = 'less_than' COMPLETED = 'completed' + COMPLETED_OR_SKIPPED = 'completed_or_skipped' + SKIPPED = 'skipped' CHOICES = ( (EQUAL, 'Equal'), (NOT_EQUAL, 'Not equal'), @@ -258,10 +260,12 @@ class PredicateOperator: (MORE_THAN, 'More than'), (LESS_THAN, 'Less than'), (COMPLETED, COMPLETED), + (SKIPPED, SKIPPED), + (COMPLETED_OR_SKIPPED, COMPLETED_OR_SKIPPED), ) ALLOWED_OPERATORS = { PredicateType.KICKOFF: {COMPLETED}, - PredicateType.TASK: {COMPLETED}, + PredicateType.TASK: {COMPLETED, SKIPPED, COMPLETED_OR_SKIPPED}, PredicateType.USER: {EQUAL, NOT_EQUAL, EXIST, NOT_EXIST}, PredicateType.GROUP: {EQUAL, NOT_EQUAL, EXIST, NOT_EXIST}, PredicateType.FILE: {EXIST, NOT_EXIST}, @@ -316,7 +320,13 @@ class PredicateOperator: NOT_EXIST, }, } - UNARY_OPERATORS = {EXIST, NOT_EXIST, COMPLETED} + UNARY_OPERATORS = { + EXIST, + NOT_EXIST, + COMPLETED, + SKIPPED, + COMPLETED_OR_SKIPPED, + } class ConditionAction: @@ -717,4 +727,42 @@ class SystemVariable: WORKFLOW_NAME_VARS = {DATE, TEMPLATE_NAME, WORKFLOW_ID, WORKFLOW_STARTER} - TASK_VARS = {WORKFLOW_STARTER} + # Allowed in task name / description without matching a FieldTemplate + # (same as workflow name template — date, template-name, + # workflow-id, starter). + TASK_VARS = WORKFLOW_NAME_VARS + + +class LabelPosition: + + TOP = 'top' + LEFT = 'left' + + CHOICES = ( + (TOP, 'Top'), + (LEFT, 'Left'), + ) + LITERALS = Literal[TOP, LEFT] + + +class FieldSetLayout: + + HORIZONTAL = 'horizontal' + VERTICAL = 'vertical' + + CHOICES = ( + (HORIZONTAL, 'Horizontal'), + (VERTICAL, 'Vertical'), + ) + LITERALS = Literal[HORIZONTAL, VERTICAL] + + +class FieldSetRuleType: + + SUM_EQUAL = 'sum_equal' + + CHOICES = ( + (SUM_EQUAL, 'The sum is equal'), + ) + + LITERALS = Literal[SUM_EQUAL] diff --git a/backend/src/processes/filters.py b/backend/src/processes/filters.py index c03dce6bb..f7695d3d8 100644 --- a/backend/src/processes/filters.py +++ b/backend/src/processes/filters.py @@ -14,8 +14,8 @@ TsQuerySearchFilter, ) from src.processes.enums import TaskStatus, WorkflowStatus +from src.processes.models.templates.fieldset import FieldsetTemplate from src.processes.models.templates.system_template import SystemTemplate -from src.processes.models.templates.template import Template from src.processes.models.workflows.event import WorkflowEvent @@ -28,22 +28,6 @@ def filter(self, qs, value): return super().filter(qs, ordering) -class TemplateFilter(FilterSet): - ordering = TemplateOrderingFilter( - fields=( - ('name', 'name'), - ('date_created', 'date'), - ), - ) - - class Meta: - model = Template - fields = ( - 'ordering', - 'is_active', - ) - - class WorkflowDurationFilter(FilterSet): date_from = IsoDateTimeFilter(method='filter_date_from') date_to = IsoDateTimeFilter(method='filter_date_to') @@ -150,3 +134,20 @@ class Meta: search = TsQuerySearchFilter( field_name='search_content', ) + + +class FieldSetFilter(FilterSet): + + ordering = DefaultOrderingFilter( + fields=( + ('name', 'name'), + ('date_created', 'date'), + ), + default=('-date_created',), + ) + + class Meta: + model = FieldsetTemplate + fields = ( + 'ordering', + ) diff --git a/backend/src/processes/locale/de/LC_MESSAGES/django.po b/backend/src/processes/locale/de/LC_MESSAGES/django.po index eb72e42a7..ddcad0cd6 100644 --- a/backend/src/processes/locale/de/LC_MESSAGES/django.po +++ b/backend/src/processes/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,6 +17,21 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +msgid "Cannot delete a fieldset template that is used in templates." +msgstr "" + +#, python-brace-format +msgid "The sum of fields in this fieldset exceeds the allowed maximum: \"{value}\"." +msgstr "" + +msgid "Rule \"sum_equal\" requires all fieldset fields to be of type \"number\"." +msgstr "" + +#, fuzzy +#| msgid "The value must be a number." +msgid "Rule \"sum_equal\" value must be a number." +msgstr "Der Wert muss eine Zahl sein." + msgid "You can't pass \"snooze\" for the first task." msgstr "Sie können \"snooze\" nicht für die erste Aufgabe übergeben." diff --git a/backend/src/processes/locale/django.pot b/backend/src/processes/locale/django.pot index b9d0b1505..aa9169865 100644 --- a/backend/src/processes/locale/django.pot +++ b/backend/src/processes/locale/django.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,6 +17,19 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +msgid "Cannot delete a fieldset template that is used in templates." +msgstr "" + +#, python-brace-format +msgid "The sum of fields in this fieldset exceeds the allowed maximum: \"{value}\"." +msgstr "" + +msgid "Rule \"sum_equal\" requires all fieldset fields to be of type \"number\"." +msgstr "" + +msgid "Rule \"sum_equal\" value must be a number." +msgstr "" + msgid "You can't pass \"snooze\" for the first task." msgstr "" diff --git a/backend/src/processes/locale/es/LC_MESSAGES/django.po b/backend/src/processes/locale/es/LC_MESSAGES/django.po index 726c47fcd..f53992a8c 100644 --- a/backend/src/processes/locale/es/LC_MESSAGES/django.po +++ b/backend/src/processes/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,6 +17,21 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +msgid "Cannot delete a fieldset template that is used in templates." +msgstr "" + +#, python-brace-format +msgid "The sum of fields in this fieldset exceeds the allowed maximum: \"{value}\"." +msgstr "" + +msgid "Rule \"sum_equal\" requires all fieldset fields to be of type \"number\"." +msgstr "" + +#, fuzzy +#| msgid "The value must be a number." +msgid "Rule \"sum_equal\" value must be a number." +msgstr "El valor debe ser un número." + msgid "You can't pass \"snooze\" for the first task." msgstr "No puedes pasar \"snooze\" para la primera tarea." diff --git a/backend/src/processes/locale/fr/LC_MESSAGES/django.po b/backend/src/processes/locale/fr/LC_MESSAGES/django.po index 7e4886d43..ec0e9249f 100644 --- a/backend/src/processes/locale/fr/LC_MESSAGES/django.po +++ b/backend/src/processes/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,6 +17,21 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +msgid "Cannot delete a fieldset template that is used in templates." +msgstr "" + +#, python-brace-format +msgid "The sum of fields in this fieldset exceeds the allowed maximum: \"{value}\"." +msgstr "" + +msgid "Rule \"sum_equal\" requires all fieldset fields to be of type \"number\"." +msgstr "" + +#, fuzzy +#| msgid "The value must be a number." +msgid "Rule \"sum_equal\" value must be a number." +msgstr "La valeur doit être un nombre." + msgid "You can't pass \"snooze\" for the first task." msgstr "Vous ne pouvez pas passer \"snooze\" pour la première tâche." diff --git a/backend/src/processes/locale/ru/LC_MESSAGES/django.po b/backend/src/processes/locale/ru/LC_MESSAGES/django.po index 82f5b7df0..21e752c90 100644 --- a/backend/src/processes/locale/ru/LC_MESSAGES/django.po +++ b/backend/src/processes/locale/ru/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,6 +17,21 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +msgid "Cannot delete a fieldset template that is used in templates." +msgstr "" + +#, python-brace-format +msgid "The sum of fields in this fieldset exceeds the allowed maximum: \"{value}\"." +msgstr "" + +msgid "Rule \"sum_equal\" requires all fieldset fields to be of type \"number\"." +msgstr "" + +#, fuzzy +#| msgid "The value must be a number." +msgid "Rule \"sum_equal\" value must be a number." +msgstr "Значение должно быть числом." + msgid "You can't pass \"snooze\" for the first task." msgstr "Нельзя передать «snooze» для первой задачи." diff --git a/backend/src/processes/messages/fieldset.py b/backend/src/processes/messages/fieldset.py new file mode 100644 index 000000000..d13c9c79e --- /dev/null +++ b/backend/src/processes/messages/fieldset.py @@ -0,0 +1,25 @@ +from django.utils.text import format_lazy +from django.utils.translation import ugettext_lazy as _ + +MSG_FS_0001 = _( + 'Cannot delete a fieldset template that is used in templates.', +) +MSG_FS_0002 = lambda value: format_lazy( + _('The sum of the fields in this field set must equal "{value}".'), + value=value, +) +MSG_FS_0003 = _( + 'Rule "Sum equal" requires all fields to be of type "number".', +) +MSG_FS_0004 = _('Rule "Sum equal" value must be a number.') +MSG_FS_0005 = lambda rule, field: format_lazy( + _('field "{field}" not found in rule "{rule}".'), + field=field, + rule=rule, +) +MSG_FS_0006 = _( + 'The task with the specified "api_name" was not found in the template', +) +MSG_FS_0007 = _( + 'Either "task" or "kickoff" must be provided to create a fieldset.', +) diff --git a/backend/src/processes/messages/template.py b/backend/src/processes/messages/template.py index d2e415a7d..940ff780b 100644 --- a/backend/src/processes/messages/template.py +++ b/backend/src/processes/messages/template.py @@ -245,7 +245,8 @@ MSG_PT_0064 = lambda name: format_lazy( _( 'Task condition "{name}": ' - 'Only the "completed" operator is allowed for the "start_task" action', + 'Only the "completed", "skipped" or "completed_or_skipped" ' + 'operators is allowed for the "start_task" action', ), name=name, ) diff --git a/backend/src/processes/migrations/0252_add_fieldsets.py b/backend/src/processes/migrations/0252_add_fieldsets.py new file mode 100644 index 000000000..d7a90ca02 --- /dev/null +++ b/backend/src/processes/migrations/0252_add_fieldsets.py @@ -0,0 +1,205 @@ +# Generated by Django 2.2 on 2026-04-28 09:42 + +from django.db import migrations, models +import django.db.models.deletion +import src.generics.mixins.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0142_vacation_fields'), + ('processes', '0251_add_skip_for_starter'), + ] + + operations = [ + migrations.CreateModel( + name='FieldSet', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('api_name', models.CharField(max_length=200)), + ('label_position', models.CharField(choices=[('top', 'Top'), ('left', 'Left')], default='top', max_length=20)), + ('name', models.TextField(max_length=1000)), + ('description', models.TextField(blank=True, default='')), + ('layout', models.CharField(choices=[('horizontal', 'Horizontal'), ('vertical', 'Vertical')], default='vertical', max_length=200)), + ('order', models.IntegerField(default=0)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.Account')), + ('kickoff', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.KickoffValue')), + ('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Task')), + ('workflow', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Workflow')), + ], + options={ + 'ordering': ['-id'], + }, + bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model), + ), + migrations.CreateModel( + name='FieldSetRule', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('api_name', models.CharField(max_length=200)), + ('type', models.CharField(choices=[('sum_equal', 'The sum is equal')], max_length=50)), + ('value', models.TextField(blank=True, null=True)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.Account')), + ('fieldset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rules', to='processes.FieldSet')), + ], + options={ + 'ordering': ['-id'], + }, + bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model), + ), + migrations.CreateModel( + name='FieldsetTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('api_name', models.CharField(max_length=200)), + ('label_position', models.CharField(choices=[('top', 'Top'), ('left', 'Left')], default='top', max_length=20)), + ('name', models.TextField(max_length=1000)), + ('description', models.TextField(blank=True, default='')), + ('layout', models.CharField(choices=[('horizontal', 'Horizontal'), ('vertical', 'Vertical')], default='vertical', max_length=200)), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.Account')), + ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fieldsets', to='processes.Template')), + ], + options={ + 'ordering': ['-id'], + }, + bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model), + ), + migrations.CreateModel( + name='FieldsetTemplateRule', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('api_name', models.CharField(max_length=200)), + ('type', models.CharField(choices=[('sum_equal', 'The sum is equal')], max_length=50)), + ('value', models.TextField(blank=True, null=True)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.Account')), + ('fieldset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rules', to='processes.FieldsetTemplate')), + ], + options={ + 'ordering': ['-id'], + }, + bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model), + ), + migrations.CreateModel( + name='FieldsetTemplateTaskTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('order', models.IntegerField(default=0)), + ('fieldset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='processes.FieldsetTemplate')), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='processes.TaskTemplate')), + ], + options={ + 'db_table': 'processes_fieldsettemplate_tasktemplate', + 'ordering': ['order'], + }, + bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model), + ), + migrations.CreateModel( + name='FieldsetTemplateKickoff', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, + serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('order', models.IntegerField(default=0)), + ('fieldset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='processes.FieldsetTemplate')), + ('kickoff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='processes.Kickoff')), + ], + options={ + 'db_table': 'processes_fieldsettemplate_kickoff', + 'ordering': ['order'], + }, + bases=(src.generics.mixins.models.SoftDeleteMixin, models.Model), + ), + migrations.AddField( + model_name='fieldsettemplate', + name='kickoffs', + field=models.ManyToManyField(blank=True, related_name='fieldsets', through='processes.FieldsetTemplateKickoff', to='processes.Kickoff'), + ), + migrations.AddConstraint( + model_name='fieldsettemplate', + constraint=models.UniqueConstraint( + condition=models.Q(is_deleted=False), + fields=('template', 'api_name'), + name='fieldsettemplate_api_name_template_unique'), + ), + migrations.AddField( + model_name='fieldsettemplate', + name='tasks', + field=models.ManyToManyField(blank=True, related_name='fieldsets', through='processes.FieldsetTemplateTaskTemplate', to='processes.TaskTemplate'), + ), + migrations.AlterField( + model_name='fieldtemplate', + name='template', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='processes.Template'), + ), + migrations.AddField( + model_name='fieldtemplate', + name='fieldset', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='processes.FieldsetTemplate'), + ), + migrations.AddField( + model_name='fieldtemplate', + name='rules', + field=models.ManyToManyField(blank=True, related_name='fields', to='processes.FieldsetTemplateRule'), + ), + migrations.AddField( + model_name='taskfield', + name='fieldset', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='processes.FieldSet'), + ), + migrations.AddField( + model_name='taskfield', + name='rules', + field=models.ManyToManyField(blank=True, related_name='fields', to='processes.FieldSetRule'), + ), + migrations.AlterField( + model_name='predicate', + name='operator', + field=models.CharField( + choices=[('equals', 'Equal'), ('not_equals', 'Not equal'), + ('exists', 'Exists'), ('not_exists', 'Not exists'), + ('contains', 'Contains'), + ('not_contains', 'Not contains'), + ('more_than', 'More than'), + ('less_than', 'Less than'), + ('completed', 'completed'), ('skipped', 'skipped'), + ('completed_or_skipped', 'completed_or_skipped')], + max_length=30), + ), + migrations.AlterField( + model_name='predicatetemplate', + name='operator', + field=models.CharField( + choices=[('equals', 'Equal'), ('not_equals', 'Not equal'), + ('exists', 'Exists'), ('not_exists', 'Not exists'), + ('contains', 'Contains'), + ('not_contains', 'Not contains'), + ('more_than', 'More than'), + ('less_than', 'Less than'), + ('completed', 'completed'), ('skipped', 'skipped'), + ('completed_or_skipped', 'completed_or_skipped')], + max_length=30), + ), + migrations.RunSQL(""" + UPDATE processes_predicate p + SET operator = 'completed_or_skipped' + FROM processes_rule r + WHERE p.rule_id = r.id + AND p.operator = 'completed' + AND p.field_type = 'task' + """), + migrations.RunSQL(""" + UPDATE processes_predicatetemplate pt + SET operator = 'completed_or_skipped' + FROM processes_ruletemplate rt + WHERE pt.rule_id = rt.id + AND pt.operator = 'completed' + AND pt.field_type = 'task' + """) + ] diff --git a/backend/src/processes/models/mixins.py b/backend/src/processes/models/mixins.py index c3cf193f9..4a83b82ca 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 @@ -176,7 +178,7 @@ class Meta: ) -class FieldMixin(AccountBaseMixin): +class FieldMixin(models.Model): class Meta: abstract = True @@ -316,3 +318,41 @@ 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='') + 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..85035556a --- /dev/null +++ b/backend/src/processes/models/templates/fieldset.py @@ -0,0 +1,157 @@ +from django.db import models +from django.db.models import Q, UniqueConstraint + +from src.accounts.models import AccountBaseMixin +from src.generics.managers import BaseSoftDeleteManager +from src.generics.models import SoftDeleteModel +from src.processes.models.base import BaseApiNameModel +from src.processes.models.mixins import ( + BaseFieldSetMixin, + BaseFieldSetRuleMixin, +) +from src.processes.models.templates.kickoff import Kickoff +from src.processes.models.templates.template import Template +from src.processes.models.templates.task import TaskTemplate +from src.processes.querysets import ( + FieldsetTemplateQuerySet, + FieldsetTemplateRuleQuerySet, + FieldsetTemplateTaskTemplateQuerySet, FieldsetTemplateKickoffQuerySet, +) + + +class FieldsetTemplate( + BaseApiNameModel, + BaseFieldSetMixin, + AccountBaseMixin, +): + + class Meta: + ordering = ['-id'] + constraints = [ + UniqueConstraint( + fields=['api_name', 'template'], + condition=Q(is_deleted=False), + name='fieldsettemplate_api_name_template_unique', + ), + ] + + api_name_prefix = 'fieldset' + + date_created = models.DateTimeField( + auto_now_add=True, + ) + template = models.ForeignKey( + Template, + on_delete=models.CASCADE, + related_name='fieldsets', + ) + tasks = models.ManyToManyField( + TaskTemplate, + through='FieldsetTemplateTaskTemplate', + related_name='fieldsets', + blank=True, + ) + kickoffs = models.ManyToManyField( + Kickoff, + through='FieldsetTemplateKickoff', + related_name='fieldsets', + blank=True, + ) + + objects = BaseSoftDeleteManager.from_queryset( + FieldsetTemplateQuerySet, + )() + + def __str__(self): + return self.name + + +class FieldsetTemplateTaskTemplate(SoftDeleteModel): + + """ + Model for the relationship between + "TaskTemplate" <- m2m -> "FieldsetTemplate" + """ + + class Meta: + ordering = ['order'] + db_table = 'processes_fieldsettemplate_tasktemplate' + + fieldset = models.ForeignKey( + 'FieldsetTemplate', + on_delete=models.CASCADE, + ) + task = models.ForeignKey( + TaskTemplate, + on_delete=models.CASCADE, + ) + order = models.IntegerField(default=0) + + objects = BaseSoftDeleteManager.from_queryset( + FieldsetTemplateTaskTemplateQuerySet, + )() + + def __str__(self): + return ( + f'Fieldset: {self.fieldset_id} - ' + f'Task: {self.task_template_id} - ' + f'Order: {self.order}' + ) + + +class FieldsetTemplateKickoff(SoftDeleteModel): + + """ + Model for the relationship + "Kickoff" <- m2m -> "FieldsetTemplate" + """ + + class Meta: + ordering = ['order'] + db_table = 'processes_fieldsettemplate_kickoff' + + fieldset = models.ForeignKey( + 'FieldsetTemplate', + on_delete=models.CASCADE, + ) + kickoff = models.ForeignKey( + Kickoff, + on_delete=models.CASCADE, + ) + order = models.IntegerField(default=0) + + objects = BaseSoftDeleteManager.from_queryset( + FieldsetTemplateKickoffQuerySet, + )() + + def __str__(self): + return ( + f'Fieldset: {self.fieldset_id} - ' + f'Kickoff: {self.kickoff_id} - ' + f'Order: {self.order}' + ) + + +class FieldsetTemplateRule( + BaseApiNameModel, + BaseFieldSetRuleMixin, + 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..57584467b 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__kickoffs__id=kickoff.id), + ), + ) + if fields_filter_kwargs: + qst = qst.filter(**fields_filter_kwargs) + return qst def get_tasks_output_fields( self, @@ -164,26 +166,41 @@ def get_tasks_output_fields( fields_filter_kwargs: Optional[Dict] = None, ) -> FieldTemplateQuerySet: - """ Return the output fields from tasks """ + """Return output FieldTemplates for this template's tasks, + with optional task/field-level filtering and exclusion. - from src.processes.models.templates \ - .fields import FieldTemplate + Fields are collected from two sources: + - directly attached to tasks (task FK) + - indirectly via fieldsets linked to tasks through M2M + """ - if tasks_filter_kwargs is None: - tasks_filter_kwargs = { - 'task__template_id': self.id, - 'task__account_id': self.account_id, - } - else: - tasks_filter_kwargs['task__template_id'] = self.id - tasks_filter_kwargs['task__account_id'] = self.account_id - qst = FieldTemplate.objects.filter(**tasks_filter_kwargs) + from src.processes.models.templates.fields import FieldTemplate + + tasks_filter_kwargs = tasks_filter_kwargs or {} + tasks_exclude_kwargs = tasks_exclude_kwargs or {} + fields_filter_kwargs = fields_filter_kwargs or {} + + tasks_filter = {'task__template_id': self.id, **tasks_filter_kwargs} + fieldset_filter = {'fieldset__tasks__template_id': self.id} + for key, value in tasks_filter_kwargs.items(): + _key = key.replace('task', 'tasks') + fieldset_filter[f'fieldset__{_key}'] = value + tasks_q = Q(**tasks_filter) + fieldset_q = Q(**fieldset_filter) + + if tasks_exclude_kwargs: + fieldset_exclude_kwargs = {} + for key, value in tasks_exclude_kwargs.items(): + _key = key.replace('task', 'tasks') + fieldset_exclude_kwargs[f'fieldset__{_key}'] = value + tasks_q = Q(tasks_q, ~Q(**tasks_exclude_kwargs)) + fieldset_q = Q(fieldset_q, ~Q(**fieldset_exclude_kwargs)) + + 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..eeaaf2687 --- /dev/null +++ b/backend/src/processes/models/workflows/fieldset.py @@ -0,0 +1,64 @@ +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', + ) + order = models.IntegerField(default=0) + + objects = BaseSoftDeleteManager.from_queryset(FieldSetQuerySet)() + + +class FieldSetRule( + BaseApiNameModel, + BaseFieldSetRuleMixin, + AccountBaseMixin, +): + + class Meta: + ordering = ['-id'] + + fieldset = models.ForeignKey( + FieldSet, + on_delete=models.CASCADE, + related_name='rules', + ) + + objects = BaseSoftDeleteManager.from_queryset(FieldSetRuleQuerySet)() diff --git a/backend/src/processes/models/workflows/workflow.py b/backend/src/processes/models/workflows/workflow.py index b961c774f..80eaf046b 100644 --- a/backend/src/processes/models/workflows/workflow.py +++ b/backend/src/processes/models/workflows/workflow.py @@ -2,8 +2,8 @@ from typing import Dict, Optional from django.contrib.auth import get_user_model -from django.core.exceptions import ObjectDoesNotExist from django.db import models +from django.db.models import Q from django.utils import timezone from src.accounts.models import AccountBaseMixin @@ -126,15 +126,17 @@ def get_kickoff_output_fields( """ Return the output fields from kickoff """ - try: - result = self.kickoff.get().output.all() - if fields_filter_kwargs: - result = result.filter(**fields_filter_kwargs) - except ObjectDoesNotExist: - from src.processes.models.workflows \ - .fields import TaskField - result = TaskField.objects.none() - return result + from src.processes.models.workflows.fields import TaskField + kickoff = self.kickoff.get() + qst = TaskField.objects.filter( + Q( + Q(kickoff_id=kickoff.id) | + Q(fieldset__kickoff_id=kickoff.id), + ), + ) + if fields_filter_kwargs: + qst = qst.filter(**fields_filter_kwargs) + return qst def get_tasks_output_fields( self, @@ -145,24 +147,33 @@ def get_tasks_output_fields( """ Return the output fields from tasks """ - from src.processes.models.workflows \ - .fields import TaskField + from src.processes.models.workflows.fields import TaskField + + tasks_filter_kwargs = tasks_filter_kwargs or {} + tasks_exclude_kwargs = tasks_exclude_kwargs or {} + fields_filter_kwargs = fields_filter_kwargs or {} - if tasks_filter_kwargs is None: - tasks_filter_kwargs = { - 'task__workflow_id': self.id, - 'task__account_id': self.account_id, + tasks_filter = {'task__workflow_id': self.id, **tasks_filter_kwargs} + fieldset_filter = { + f'fieldset__{key}': value + for key, value in tasks_filter.items() + } + tasks_q = Q(**tasks_filter) + fieldset_q = Q(**fieldset_filter) + + if tasks_exclude_kwargs: + fieldset_exclude_kwargs = { + f'fieldset__{key}': value + for key, value in tasks_exclude_kwargs.items() } - else: - tasks_filter_kwargs['task__workflow_id'] = self.id - tasks_filter_kwargs['task__account_id'] = self.account_id - qst = TaskField.objects.filter(**tasks_filter_kwargs) + tasks_q = Q(tasks_q, ~Q(**tasks_exclude_kwargs)) + fieldset_q = Q(fieldset_q, ~Q(**fieldset_exclude_kwargs)) + + qst = TaskField.objects.filter(tasks_q | fieldset_q) if fields_filter_kwargs: qst = qst.filter(**fields_filter_kwargs) - if tasks_exclude_kwargs: - qst = qst.exclude(**tasks_exclude_kwargs) return qst @property diff --git a/backend/src/processes/querysets.py b/backend/src/processes/querysets.py index f3aaa3013..f895daf49 100644 --- a/backend/src/processes/querysets.py +++ b/backend/src/processes/querysets.py @@ -293,6 +293,7 @@ def raw_list_query( lookup='kickoff', queryset=( Kickoff.objects.all().prefetch_related( + 'fieldsets', Prefetch( lookup='fields', queryset=( @@ -364,6 +365,7 @@ def raw_list_by_owners_query( lookup='kickoff', queryset=( Kickoff.objects.all().prefetch_related( + 'fieldsets', Prefetch( lookup='fields', queryset=( @@ -443,9 +445,11 @@ def raw_export_query( 'tasks__conditions__rules', 'tasks__conditions__rules__predicates', 'tasks__raw_performers', + 'tasks__fieldsets', 'kickoff', 'kickoff__fields', 'kickoff__fields__selections', + 'kickoff__fieldsets', ) ) @@ -686,7 +690,12 @@ def raw_list_query( queryset=( TaskField.objects .filter(api_name__in=fields) - .order_by('kickoff_id', 'task__number', '-order') + .order_by( + 'kickoff_id', + 'task__number', + 'fieldset_id', + '-order', + ) ), ), ) @@ -1275,3 +1284,33 @@ def by_user(self, user, template_id): class SearchContentQuerySet(AccountBaseQuerySet): pass + + +class FieldsetTemplateQuerySet(AccountBaseQuerySet): + + pass + + +class FieldsetTemplateTaskTemplateQuerySet(BaseQuerySet): + + pass + + +class FieldsetTemplateKickoffQuerySet(BaseQuerySet): + + pass + + +class FieldsetTemplateRuleQuerySet(AccountBaseQuerySet): + + pass + + +class FieldSetQuerySet(AccountBaseQuerySet): + + pass + + +class FieldSetRuleQuerySet(AccountBaseQuerySet): + + pass diff --git a/backend/src/processes/serializers/templates/field.py b/backend/src/processes/serializers/templates/field.py index 7b92a8321..48ea10dff 100644 --- a/backend/src/processes/serializers/templates/field.py +++ b/backend/src/processes/serializers/templates/field.py @@ -73,16 +73,16 @@ class Meta: model = FieldTemplate api_primary_field = 'api_name' fields = ( - 'type', 'name', 'description', + 'type', 'is_required', 'is_hidden', - 'selections', - 'order', 'api_name', + 'selections', 'default', 'dataset', + 'order', ) create_or_update_fields = { 'type', @@ -215,31 +215,19 @@ def update( return instance -class FieldTemplateShortViewSerializer(ModelSerializer): - class Meta: - model = FieldTemplate - fields = ( - 'name', - 'type', - 'order', - 'description', - 'is_hidden', - 'api_name', - ) - - class FieldTemplateListSerializer(ModelSerializer): class Meta: model = FieldTemplate fields = ( 'name', + 'description', 'type', 'is_required', 'is_hidden', - 'description', 'api_name', 'selections', + 'default', 'dataset', 'order', ) diff --git a/backend/src/processes/serializers/templates/fieldset.py b/backend/src/processes/serializers/templates/fieldset.py new file mode 100644 index 000000000..bfa99da42 --- /dev/null +++ b/backend/src/processes/serializers/templates/fieldset.py @@ -0,0 +1,96 @@ +# ruff: noqa: PLC0415 +from rest_framework.fields import CharField, SerializerMethodField +from rest_framework.serializers import ( + IntegerField, + ModelSerializer, +) + +from src.generics.fields import ( + RelatedApiNameListField, +) +from src.generics.mixins.serializers import CustomValidationErrorMixin +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateRule, FieldsetTemplateKickoff, +) +from src.processes.serializers.templates.field import ( + FieldTemplateSerializer, +) + + +class FieldsetTemplateRuleSerializer( + CustomValidationErrorMixin, + ModelSerializer, +): + + class Meta: + model = FieldsetTemplateRule + fields = ( + 'type', + 'value', + 'api_name', + 'fields', + ) + + api_name = CharField(required=False, max_length=200) + fields = RelatedApiNameListField( + required=False, + allow_empty=True, + default=list, + ) + + +class FieldsetTemplateSerializer( + CustomValidationErrorMixin, + ModelSerializer, +): + + class Meta: + model = FieldsetTemplate + fields = ( + 'id', + 'name', + 'description', + 'label_position', + 'layout', + 'rules', + 'fields', + 'api_name', + 'tasks', + 'kickoff', + 'template_id', + ) + + id = IntegerField(required=False) + api_name = CharField(required=False, max_length=200) + rules = FieldsetTemplateRuleSerializer( + many=True, + required=False, + default=list, + ) + fields = FieldTemplateSerializer( + many=True, + required=False, + default=list, + ) + tasks = SerializerMethodField() + kickoff = SerializerMethodField() + + def get_kickoff(self, instance: FieldsetTemplate): + through = FieldsetTemplateKickoff.objects.filter( + fieldset=instance, + ).first() + if through: + return through.kickoff_id + return None + + def get_tasks(self, instance): + # Resolve cyclic imports with TemplateTaskOnlyFieldsSerializer + from src.processes.serializers.templates.task import ( + TemplateStepNameSerializer, + ) + return TemplateStepNameSerializer( + instance=instance.tasks.all(), + many=True, + default=list, + ).data diff --git a/backend/src/processes/serializers/templates/fieldset_link.py b/backend/src/processes/serializers/templates/fieldset_link.py new file mode 100644 index 000000000..2027953a3 --- /dev/null +++ b/backend/src/processes/serializers/templates/fieldset_link.py @@ -0,0 +1,91 @@ +from rest_framework.fields import CharField +from rest_framework.serializers import ( + ModelSerializer, +) +from src.processes.serializers.templates.field import FieldTemplateSerializer +from src.generics.mixins.serializers import CustomValidationErrorMixin +from src.processes.models.templates.fieldset import ( + FieldsetTemplateTaskTemplate, + FieldsetTemplateKickoff, +) + + +class FieldsetTemplateTaskTemplateSerializer( + CustomValidationErrorMixin, + ModelSerializer, +): + class Meta: + model = FieldsetTemplateTaskTemplate + fields = ( + 'order', + 'api_name', + ) + + api_name = CharField(source='fieldset.api_name') + + +class FieldsetTemplateKickoffSerializer( + CustomValidationErrorMixin, + ModelSerializer, +): + class Meta: + model = FieldsetTemplateKickoff + fields = ( + 'order', + 'api_name', + ) + + api_name = CharField(source='fieldset.api_name') + + +class FieldsetTemplateKickoffListSerializer(ModelSerializer): + class Meta: + model = FieldsetTemplateKickoff + fields = ( + 'order', + 'name', + 'description', + 'fields', + 'api_name', + 'label_position', + 'layout', + ) + + name = CharField(source='fieldset.name') + description = CharField(source='fieldset.description') + api_name = CharField(source='fieldset.api_name') + label_position = CharField( + source='fieldset.label_position', + ) + layout = CharField(source='fieldset.layout') + fields = FieldTemplateSerializer( + source='fieldset.fields', + many=True, + ) + + +class FieldsetTemplateTaskListSerializer(ModelSerializer): + + class Meta: + model = FieldsetTemplateTaskTemplate + fields = ( + 'order', + 'name', + 'description', + 'fields', + 'api_name', + 'label_position', + 'layout', + ) + + name = CharField(source='fieldset.name') + description = CharField(source='fieldset.description') + api_name = CharField(source='fieldset.api_name') + label_position = CharField( + source='fieldset.label_position', + ) + layout = CharField(source='fieldset.layout') + fields = FieldTemplateSerializer( + source='fieldset.fields', + many=True, + ) diff --git a/backend/src/processes/serializers/templates/kickoff.py b/backend/src/processes/serializers/templates/kickoff.py index 668e10b0f..27f458fff 100644 --- a/backend/src/processes/serializers/templates/kickoff.py +++ b/backend/src/processes/serializers/templates/kickoff.py @@ -3,7 +3,6 @@ from rest_framework.serializers import ( ModelSerializer, ) - from src.generics.mixins.serializers import ( AdditionalValidationMixin, CustomValidationErrorMixin, @@ -12,7 +11,10 @@ from src.processes.serializers.templates.field import ( FieldTemplateListSerializer, FieldTemplateSerializer, - FieldTemplateShortViewSerializer, +) +from src.processes.serializers.templates.fieldset_link import ( + FieldsetTemplateKickoffSerializer, + FieldsetTemplateKickoffListSerializer, ) from src.processes.serializers.templates.mixins import ( CreateOrUpdateInstanceMixin, @@ -32,22 +34,33 @@ 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 = FieldsetTemplateKickoffSerializer( + source='fieldsettemplatekickoff_set', + many=True, + required=False, + allow_empty=True, + ) - def to_representation(self, data: Dict[str, Any]): - data = super().to_representation(data) - if data.get('fields') is None: - data['fields'] = [] - return data + def to_representation(self, instance): + # TODO Delete when the Template <-> Kickoff relation becomes o2o + from django.db import models # noqa : PLC0415 + if isinstance(instance, models.Manager): + instance = instance.first() + if instance is None: + return {'fields': [], 'fieldsets': []} + return super().to_representation(instance) def create(self, validated_data: Dict[str, Any]): self.additional_validate(validated_data) + validated_data.pop('fieldsettemplatekickoff_set', None) instance = self.create_or_update_instance( validated_data={ 'template': self.context['template'], @@ -75,6 +88,7 @@ def update( validated_data: Dict[str, Any], ): self.additional_validate(validated_data) + validated_data.pop('fieldsettemplatekickoff_set', None) instance = self.create_or_update_instance( instance=instance, validated_data={ @@ -83,6 +97,7 @@ def update( **validated_data, }, ) + self.create_or_update_related( data=validated_data.get('fields'), ancestors_data={ @@ -98,26 +113,24 @@ def update( return instance -class KickoffOnlyFieldsSerializer(ModelSerializer): - class Meta: - model = Kickoff - fields = ( - 'fields', - ) - - fields = FieldTemplateShortViewSerializer( - many=True, - required=False, - read_only=True, - ) - - class KickoffListSerializer(ModelSerializer): class Meta: model = Kickoff fields = ( 'fields', + 'fieldsets', ) fields = FieldTemplateListSerializer(many=True) + fieldsets = FieldsetTemplateKickoffListSerializer( + source='fieldsettemplatekickoff_set', + many=True, + ) + + def to_representation(self, instance): + # TODO Delete when the Template <-> Kickoff relation becomes o2o + from django.db import models # noqa : PLC0415 + if isinstance(instance, models.Manager): + instance = instance.first() + return super().to_representation(instance) diff --git a/backend/src/processes/serializers/templates/predicate.py b/backend/src/processes/serializers/templates/predicate.py index fdf0a336e..20fa99caf 100644 --- a/backend/src/processes/serializers/templates/predicate.py +++ b/backend/src/processes/serializers/templates/predicate.py @@ -85,7 +85,8 @@ def _validate_allowed_operators( condition = self.context['condition'] if ( condition.action == ConditionAction.START_TASK - and operator != PredicateOperator.COMPLETED + and operator not in + PredicateOperator.ALLOWED_OPERATORS[PredicateType.TASK] ): raise_validation_error( message=MSG_PT_0064(name=task.name), diff --git a/backend/src/processes/serializers/templates/public/kickoff.py b/backend/src/processes/serializers/templates/public/kickoff.py index d7fd10d6f..9f8890281 100644 --- a/backend/src/processes/serializers/templates/public/kickoff.py +++ b/backend/src/processes/serializers/templates/public/kickoff.py @@ -4,11 +4,12 @@ CharField, ModelSerializer, ) - from src.processes.models.templates.kickoff import Kickoff from src.processes.serializers.templates.field import ( PublicFieldTemplateSerializer, ) +from src.processes.serializers.templates.fieldset_link import \ + FieldsetTemplateKickoffListSerializer class PublicKickoffSerializer(ModelSerializer): @@ -18,10 +19,15 @@ class Meta: fields = ( 'description', 'fields', + 'fieldsets', ) description = CharField(allow_blank=True, default='') fields = PublicFieldTemplateSerializer(many=True, required=False) + fieldsets = FieldsetTemplateKickoffListSerializer( + source='fieldsettemplatekickoff_set', + many=True, + ) def to_representation(self, data: Dict[str, Any]): data = super().to_representation(data) @@ -29,4 +35,6 @@ def to_representation(self, data: Dict[str, Any]): data['description'] = '' if data.get('fields') is None: data['fields'] = [] + if data.get('fieldsets') is None: + data['fieldsets'] = [] return data diff --git a/backend/src/processes/serializers/templates/public/template.py b/backend/src/processes/serializers/templates/public/template.py index 732a06a82..78b1dece0 100644 --- a/backend/src/processes/serializers/templates/public/template.py +++ b/backend/src/processes/serializers/templates/public/template.py @@ -4,6 +4,7 @@ from rest_framework.serializers import ( CharField, ModelSerializer, + SerializerMethodField, ) from src.processes.models.templates.template import Template @@ -25,19 +26,21 @@ class Meta: ) description = CharField(allow_blank=True, default='') - kickoff = PublicKickoffSerializer(required=False) + kickoff = SerializerMethodField() + + def get_kickoff(self, instance: Template): + # PublicTemplateSerializer cannot return a single Kickoff + # object because the Template related with Kickoff by + # foreign key instead of one to one relation. + # Getting the object manually: + kickoff_slz = PublicKickoffSerializer( + instance=instance.kickoff_instance, + ) + return kickoff_slz.data def to_representation(self, data: Dict[str, Any]): data = super().to_representation(data) if data.get('description') is None: data['description'] = '' - - # PublicTemplateSerializer cannot return a single Kickoff object - # because the Template related with Kickoff by foreign key - # instead of one to one relation. Getting the object manually: - kickoff_slz = PublicKickoffSerializer( - instance=self.instance.kickoff_instance, - ) - data['kickoff'] = kickoff_slz.data return data diff --git a/backend/src/processes/serializers/templates/task.py b/backend/src/processes/serializers/templates/task.py index 01a7e54bf..b053bf1ef 100644 --- a/backend/src/processes/serializers/templates/task.py +++ b/backend/src/processes/serializers/templates/task.py @@ -32,7 +32,9 @@ ) from src.processes.serializers.templates.field import ( FieldTemplateSerializer, - FieldTemplateShortViewSerializer, +) +from src.processes.serializers.templates.fieldset_link import ( + FieldsetTemplateTaskTemplateSerializer, ) from src.processes.serializers.templates.mixins import ( CreateOrUpdateInstanceMixin, @@ -45,6 +47,9 @@ from src.processes.serializers.templates.raw_performer import ( RawPerformerSerializer, ) +from src.processes.services.templates.fieldsets.fieldset import ( + FieldSetTemplateService, +) from src.processes.utils.common import ( VAR_PATTERN, create_api_name, @@ -74,6 +79,7 @@ class Meta: 'skip_for_starter', 'delay', 'fields', + 'fieldsets', 'conditions', 'api_name', 'raw_performers', @@ -101,6 +107,12 @@ class Meta: number = IntegerField() api_name = CharField(max_length=200, required=False) fields = FieldTemplateSerializer(many=True, required=False) + fieldsets = FieldsetTemplateTaskTemplateSerializer( + source='fieldsettemplatetasktemplate_set', + many=True, + required=False, + allow_empty=True, + ) checklists = ChecklistTemplateSerializer(many=True, required=False) conditions = ConditionTemplateSerializer(many=True, required=False) raw_performers = RawPerformerSerializer( @@ -380,6 +392,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]) + validated_data.pop('fieldsettemplatetasktemplate_set', None) instance = self.create_or_update_instance( validated_data={ 'template': self.context['template'], @@ -389,6 +402,15 @@ def create(self, validated_data: Dict[str, Any]): **validated_data, }, ) + fieldsets_links = self.context.get( + 'tasks_fieldsets', {}, + ).get(api_name) + if fieldsets_links is not None: + FieldSetTemplateService.create_or_update_tasks_links( + task=instance, + template=self.context['template'], + fieldsets_links=fieldsets_links, + ) template = self.context['template'] if template.is_active and validated_data.get('raw_due_date'): AnalyticService.templates_task_due_date_created( @@ -489,6 +511,7 @@ def update( and not hasattr(self.instance, 'raw_due_date') and validated_data.get('raw_due_date') ) + validated_data.pop('fieldsettemplatetasktemplate_set', None) instance = self.create_or_update_instance( instance=instance, validated_data={ @@ -499,6 +522,15 @@ def update( **validated_data, }, ) + fieldsets_links = self.context.get( + 'tasks_fieldsets', {}, + ).get(api_name) + if fieldsets_links is not None: + FieldSetTemplateService.create_or_update_tasks_links( + task=instance, + template=self.context['template'], + fieldsets_links=fieldsets_links, + ) if raw_due_date_created: AnalyticService.templates_task_due_date_created( user=self.context['user'], @@ -587,31 +619,12 @@ class TemplateStepNameSerializer(ModelSerializer): class Meta: model = TaskTemplate fields = ( - 'id', # Deprecated 'name', 'number', 'api_name', ) -class TemplateTaskOnlyFieldsSerializer(ModelSerializer): - class Meta: - model = TaskTemplate - fields = ( - 'id', # Deprecated - 'name', - 'number', - 'api_name', - 'fields', - ) - - fields = FieldTemplateShortViewSerializer( - many=True, - required=False, - read_only=True, - ) - - class TaskTemplatePrivilegesSerializer(ModelSerializer): class Meta: model = TaskTemplate diff --git a/backend/src/processes/serializers/templates/template.py b/backend/src/processes/serializers/templates/template.py index d766f4047..50b1e94b9 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 ( @@ -48,7 +49,6 @@ ) from src.processes.serializers.templates.kickoff import ( KickoffListSerializer, - KickoffOnlyFieldsSerializer, KickoffSerializer, ) from src.processes.serializers.templates.mixins import ( @@ -62,7 +62,9 @@ ShortTaskSerializer, TaskTemplatePrivilegesSerializer, TaskTemplateSerializer, - TemplateTaskOnlyFieldsSerializer, +) +from src.processes.services.templates.fieldsets.fieldset import ( + FieldSetTemplateService, ) from src.processes.services.templates.integrations import ( TemplateIntegrationsService, @@ -170,26 +172,45 @@ 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: + api_name = field.get('api_name') + name = field.get('name') + is_required = field.get('is_required', False) + if api_name and name: + result.append({ + 'name': name, + 'api_name': api_name, + 'is_required': is_required, + }) + except (TypeError, AttributeError): + continue + fieldset_link_data = ( + kickoff_data.get('fieldsettemplatekickoff_set') or [] + ) + fieldsets_api_names = [] + for elem in fieldset_link_data: try: - 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 + fieldsets_api_names.append(elem['fieldset']['api_name']) + except (TypeError, ValueError): + continue + if fieldsets_api_names: + account = self.context.get('account') + fieldset_fields = FieldTemplate.objects.filter( + fieldset__api_name__in=fieldsets_api_names, + account_id=account.id, + ) + for field_template in fieldset_fields: + result.append({ + 'name': field_template.name, + 'api_name': field_template.api_name, + 'is_required': field_template.is_required, + }) return result def _get_template_performers_ids(self, data: Dict[str, Any]) -> Set[int]: @@ -454,8 +475,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: @@ -608,6 +633,9 @@ def create(self, validated_data: Dict[str, Any]) -> Template: 'template': instance, }, ) + fieldsets_links_raw = validated_data['kickoff'].pop( + 'fieldsettemplatekickoff_set', None, + ) self.create_or_update_related_one( slz_cls=KickoffSerializer, data=validated_data['kickoff'], @@ -620,9 +648,35 @@ def create(self, validated_data: Dict[str, Any]) -> Template: 'template': instance, }, ) + if fieldsets_links_raw is not None: + fieldsets_links = [ + { + 'api_name': link['fieldset']['api_name'], + 'order': link['order'], + } + for link in fieldsets_links_raw + ] + FieldSetTemplateService.create_or_update_kickoff_links( + kickoff=instance.kickoff_instance, + template=instance, + fieldsets_links=fieldsets_links, + ) parents_by_tasks = get_tasks_parents(validated_data['tasks']) tasks_api_names = set(parents_by_tasks.keys()) ancestors_by_tasks = get_tasks_ancestors(parents_by_tasks) + tasks_fieldsets = {} + for task_data in validated_data['tasks']: + fieldsets_raw = task_data.pop( + 'fieldsettemplatetasktemplate_set', None, + ) + if fieldsets_raw is not None: + tasks_fieldsets[task_data['api_name']] = [ + { + 'api_name': link['fieldset']['api_name'], + 'order': link['order'], + } + for link in fieldsets_raw + ] self.create_or_update_related( data=validated_data['tasks'], ancestors_data={ @@ -636,6 +690,7 @@ def create(self, validated_data: Dict[str, Any]) -> Template: 'tasks_api_names': tasks_api_names, 'parents_by_tasks': parents_by_tasks, 'ancestors_by_tasks': ancestors_by_tasks, + 'tasks_fieldsets': tasks_fieldsets, }, ) @@ -679,6 +734,9 @@ def update( 'template': instance, }, ) + fieldsets_links_raw = validated_data['kickoff'].pop( + 'fieldsettemplatekickoff_set', None, + ) self.create_or_update_related_one( slz_cls=KickoffSerializer, data=validated_data['kickoff'], @@ -691,9 +749,35 @@ def update( 'template': instance, }, ) + if fieldsets_links_raw is not None: + fieldsets_links = [ + { + 'api_name': link['fieldset']['api_name'], + 'order': link['order'], + } + for link in fieldsets_links_raw + ] + FieldSetTemplateService.create_or_update_kickoff_links( + kickoff=instance.kickoff_instance, + template=instance, + fieldsets_links=fieldsets_links, + ) parents_by_tasks = get_tasks_parents(validated_data['tasks']) tasks_api_names = set(parents_by_tasks.keys()) ancestors_by_tasks = get_tasks_ancestors(parents_by_tasks) + tasks_fieldsets = {} + for task_data in validated_data['tasks']: + fieldsets_raw = task_data.pop( + 'fieldsettemplatetasktemplate_set', None, + ) + if fieldsets_raw is not None: + tasks_fieldsets[task_data['api_name']] = [ + { + 'api_name': link['fieldset']['api_name'], + 'order': link['order'], + } + for link in fieldsets_raw + ] self.create_or_update_related( data=validated_data['tasks'], ancestors_data={ @@ -707,6 +791,7 @@ def update( 'tasks_api_names': tasks_api_names, 'parents_by_tasks': parents_by_tasks, 'ancestors_by_tasks': ancestors_by_tasks, + 'tasks_fieldsets': tasks_fieldsets, }, ) @@ -845,43 +930,6 @@ def get_is_editable(self, instance: Template) -> bool: ).exists() -class TemplateOnlyFieldsSerializer(ModelSerializer): - class Meta: - model = Template - fields = ( - 'id', - 'kickoff', - 'tasks', - ) - - kickoff = KickoffOnlyFieldsSerializer(required=False, read_only=True) - tasks = TemplateTaskOnlyFieldsSerializer( - many=True, - required=False, - read_only=True, - ) - - def to_representation(self, data: Dict[str, Any]): - - data = super().to_representation(data) - if data.get('tasks') is None: - data['tasks'] = [] - - # TemplateSerializer cannot return a single Kickoff object - # because the Template related with Kickoff by foreign key - # instead of one to one relation. Getting the object manually: - kickoff_slz = KickoffOnlyFieldsSerializer( - instance=self.instance.kickoff_instance, - ) - data['kickoff'] = kickoff_slz.data - return data - - def get_response_data(self) -> Dict[str, Any]: - if self.instance.is_active: - return self.data - return self.instance.get_draft() - - class TemplateTitlesByWorkflowsSerializer( CustomValidationErrorMixin, Serializer, @@ -1038,3 +1086,22 @@ class TemplateTitlesSerializer(Serializer): id = IntegerField(read_only=True) name = CharField(read_only=True) count = IntegerField(read_only=True) + + +class FieldsetTemplateFilterSerializer( + CustomValidationErrorMixin, + AdditionalValidationMixin, + Serializer, +): + + ordering = ChoiceField( + required=False, + choices=( + ('name', 'name'), + ('-name', '-name'), + ('date', 'date'), + ('-date', '-date'), + ), + ) + limit = IntegerField(min_value=0, required=False) + offset = IntegerField(min_value=0, required=False) diff --git a/backend/src/processes/serializers/templates/template_fields.py b/backend/src/processes/serializers/templates/template_fields.py new file mode 100644 index 000000000..009e5444c --- /dev/null +++ b/backend/src/processes/serializers/templates/template_fields.py @@ -0,0 +1,177 @@ +from typing import Any, Dict + +from rest_framework.fields import CharField +from rest_framework.serializers import ( + ModelSerializer, +) +from src.processes.models.templates.fields import FieldTemplate +from src.processes.models.templates.fieldset import ( + FieldsetTemplateTaskTemplate, + FieldsetTemplateKickoff, +) +from src.processes.models.templates.kickoff import Kickoff +from src.processes.models.templates.template import Template +from src.processes.models.templates.task import TaskTemplate + + +# All serializers for the GET /templates/id/fields API + + +class FieldTemplateOnlyFieldsSerializer(ModelSerializer): + + class Meta: + model = FieldTemplate + fields = ( + 'name', + 'type', + 'order', + 'description', + 'is_hidden', + 'api_name', + ) + + +class FieldsetTemplateKickoffListSerializer(ModelSerializer): + + class Meta: + model = FieldsetTemplateKickoff + fields = ( + 'order', + 'name', + 'description', + 'fields', + 'api_name', + 'label_position', + 'layout', + ) + + name = CharField(source='fieldset.name') + description = CharField(source='fieldset.description') + api_name = CharField(source='fieldset.api_name') + label_position = CharField( + source='fieldset.label_position', + ) + layout = CharField(source='fieldset.layout') + fields = FieldTemplateOnlyFieldsSerializer( + source='fieldset.fields', + many=True, + ) + + +class FieldsetTaskTemplateOnlyFieldsSerializer(ModelSerializer): + + class Meta: + model = FieldsetTemplateTaskTemplate + fields = ( + 'order', + 'name', + 'description', + 'fields', + 'api_name', + 'label_position', + 'layout', + ) + + name = CharField(source='fieldset.name') + description = CharField(source='fieldset.description') + api_name = CharField(source='fieldset.api_name') + label_position = CharField( + source='fieldset.label_position', + ) + layout = CharField(source='fieldset.layout') + fields = FieldTemplateOnlyFieldsSerializer( + source='fieldset.fields', + many=True, + ) + + +class KickoffOnlyFieldsSerializer(ModelSerializer): + + class Meta: + model = Kickoff + fields = ( + 'fields', + 'fieldsets', + ) + + fields = FieldTemplateOnlyFieldsSerializer( + many=True, + default=[], + read_only=True, + ) + fieldsets = FieldsetTemplateKickoffListSerializer( + source='fieldsettemplatekickoff_set', + many=True, + default=[], + read_only=True, + ) + + def to_representation(self, instance): + # TODO Delete when the Template <-> Kickoff relation becomes o2o + from django.db import models # noqa : PLC0415 + if isinstance(instance, models.Manager): + instance = instance.first() + return super().to_representation(instance) + + +class TemplateTaskOnlyFieldsSerializer(ModelSerializer): + + class Meta: + model = TaskTemplate + fields = ( + 'name', + 'number', + 'api_name', + 'fields', + 'fieldsets', + ) + + fields = FieldTemplateOnlyFieldsSerializer( + many=True, + default=[], + read_only=True, + ) + fieldsets = FieldsetTaskTemplateOnlyFieldsSerializer( + source='fieldsettemplatetasktemplate_set', + many=True, + default=[], + read_only=True, + ) + + +class TemplateOnlyFieldsSerializer(ModelSerializer): + + class Meta: + model = Template + fields = ( + 'id', + 'kickoff', + 'tasks', + ) + + kickoff = KickoffOnlyFieldsSerializer(required=False, read_only=True) + tasks = TemplateTaskOnlyFieldsSerializer( + many=True, + required=False, + read_only=True, + ) + + def to_representation(self, data: Dict[str, Any]): + + data = super().to_representation(data) + if data.get('tasks') is None: + data['tasks'] = [] + + # TemplateSerializer cannot return a single Kickoff object + # because the Template related with Kickoff by foreign key + # instead of one to one relation. Getting the object manually: + kickoff_slz = KickoffOnlyFieldsSerializer( + instance=self.instance.kickoff_instance, + ) + data['kickoff'] = kickoff_slz.data + return data + + def get_response_data(self) -> Dict[str, Any]: + if self.instance.is_active: + return self.data + return self.instance.get_draft() diff --git a/backend/src/processes/serializers/workflows/events.py b/backend/src/processes/serializers/workflows/events.py index 6ef99cd2f..fe568358e 100644 --- a/backend/src/processes/serializers/workflows/events.py +++ b/backend/src/processes/serializers/workflows/events.py @@ -11,6 +11,7 @@ from src.processes.serializers.workflows.field import ( TaskFieldEventSerializer, ) +from src.processes.serializers.workflows.fieldset import FieldSetSerializer from src.processes.serializers.workflows.task_performer import ( TaskUserGroupPerformerSerializer, ) @@ -68,6 +69,7 @@ class Meta: 'performers', 'due_date_tsp', 'output', + 'fieldsets', 'sub_workflow', ) @@ -76,6 +78,7 @@ class Meta: source='exclude_directly_deleted_taskperformer_set', ) output = serializers.SerializerMethodField() + fieldsets = serializers.SerializerMethodField() due_date_tsp = TimeStampField(source='due_date') sub_workflow = serializers.SerializerMethodField() @@ -87,6 +90,17 @@ def get_output(self, instance): ).data return None + def get_fieldsets(self, instance): + if ( + self.context['event_type'] == WorkflowEventType.TASK_COMPLETE + and instance.fieldsets.exists() + ): + return FieldSetSerializer( + instance=instance.fieldsets.all(), + many=True, + ).data + return None + def get_sub_workflow(self, instance): if self.context['event_type'] == WorkflowEventType.SUB_WORKFLOW_RUN: return SubWorkflowEventSerializer( diff --git a/backend/src/processes/serializers/workflows/field.py b/backend/src/processes/serializers/workflows/field.py index ccc87865d..5a841ed18 100644 --- a/backend/src/processes/serializers/workflows/field.py +++ b/backend/src/processes/serializers/workflows/field.py @@ -83,6 +83,7 @@ class Meta: 'clear_value', 'user_id', 'group_id', + 'fieldset_id', ) diff --git a/backend/src/processes/serializers/workflows/fieldset.py b/backend/src/processes/serializers/workflows/fieldset.py new file mode 100644 index 000000000..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..d796f551e 100644 --- a/backend/src/processes/serializers/workflows/kickoff_value.py +++ b/backend/src/processes/serializers/workflows/kickoff_value.py @@ -1,31 +1,44 @@ from typing import Any, Dict - +from django.db.models import Q from rest_framework import serializers from src.generics.serializers import CustomValidationErrorMixin +from src.processes.models.templates.fieldset import FieldsetTemplateKickoff from src.processes.models.templates.kickoff import Kickoff +from src.processes.models.workflows.fieldset import FieldSet +from src.processes.models.workflows.fields import TaskField from src.processes.models.workflows.kickoff import KickoffValue from src.processes.models.workflows.workflow import Workflow from src.processes.serializers.workflows.field import ( TaskFieldSerializer, ) +from src.processes.serializers.workflows.fieldset import ( + FieldSetSerializer, +) +from src.processes.services.exceptions import FieldsetServiceException from src.processes.services.tasks.exceptions import ( TaskFieldException, ) from src.processes.services.tasks.task import ( TaskFieldService, ) +from src.processes.services.workflows.fieldsets.fieldset import ( + FieldSetService, +) + from src.services.markdown import MarkdownService class KickoffValueInfoSerializer(serializers.ModelSerializer): output = TaskFieldSerializer(many=True) + fieldsets = FieldSetSerializer(many=True) class Meta: model = KickoffValue fields = ( 'id', 'output', + 'fieldsets', ) @@ -69,20 +82,47 @@ 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_through_records = ( + FieldsetTemplateKickoff.objects + .filter(kickoff=kickoff) + .select_related('fieldset') + .prefetch_related('fieldset__rules', 'fieldset__fields') + .order_by('order') + ) + try: + for fieldset_through in fieldset_through_records: + fieldset_template = fieldset_through.fieldset + service = FieldSetService(user=self.context['user']) + service.create( + instance_template=fieldset_template, + account_id=workflow.account_id, + workflow=workflow, + kickoff=instance, + order=fieldset_through.order, + fields_data=fields_data, + ) + try: + service.validate_rules() + except FieldsetServiceException as ex: + self.raise_validation_error(message=ex.message) + for field_template in kickoff.fields.filter(fieldset__isnull=True): + service = TaskFieldService(user=self.context['user']) service.create( instance_template=field_template, - workflow_id=validated_data['workflow'].id, + workflow_id=workflow.id, kickoff_id=instance.id, value=fields_data.get(field_template.api_name), ) - except TaskFieldException as ex: - self.raise_validation_error( - message=ex.message, - api_name=field_template.api_name, - ) + except TaskFieldException as ex: + self.raise_validation_error( + message=ex.message, + api_name=ex.api_name, + ) + except FieldsetServiceException as ex: + self.raise_validation_error( + message=ex.message, + ) return instance def update( @@ -90,24 +130,43 @@ def update( instance: KickoffValue, validated_data: Dict[str, Any], ): - fields_data: dict = validated_data.pop('fields_data', {}) + fields_values: dict = validated_data.pop('fields_data', {}) instance = super().update(instance, validated_data) - if fields_data: - for task_field in self.instance.output.filter( - api_name__in=fields_data.keys(), - ): + if fields_values: + fields = ( + TaskField.objects + .filter( + Q(fieldset__kickoff=instance) | Q(kickoff=instance), + api_name__in=fields_values, + ) + ) + fieldsets_ids = set() + for field in fields: + if field.fieldset_id: + fieldsets_ids.add(field.fieldset_id) service = TaskFieldService( user=self.context['user'], - instance=task_field, + instance=field, ) try: service.partial_update( - value=fields_data[task_field.api_name], + value=fields_values[field.api_name], force_save=True, ) except TaskFieldException as ex: self.raise_validation_error( message=ex.message, - api_name=task_field.api_name, + api_name=field.api_name, ) + if fieldsets_ids: + fieldsets = FieldSet.objects.filter(id__in=fieldsets_ids) + try: + for fieldset in fieldsets: + service = FieldSetService( + user=self.context['user'], + instance=fieldset, + ) + service.validate_rules() + except FieldsetServiceException as ex: + self.raise_validation_error(message=ex.message) return instance diff --git a/backend/src/processes/serializers/workflows/task.py b/backend/src/processes/serializers/workflows/task.py index 7aa6333d4..1e5299aad 100644 --- a/backend/src/processes/serializers/workflows/task.py +++ b/backend/src/processes/serializers/workflows/task.py @@ -26,6 +26,9 @@ from src.processes.serializers.workflows.field import ( TaskFieldSerializer, ) +from src.processes.serializers.workflows.fieldset import ( + FieldSetSerializer, +) from src.processes.serializers.workflows.task_performer import ( get_performers_for_task, ) @@ -113,6 +116,7 @@ class Meta: 'status', 'revert_tasks', 'is_read_only_viewer', + 'fieldsets', ) date_started_tsp = TimeStampField(source='date_started') @@ -131,6 +135,7 @@ class Meta: sub_workflows = serializers.SerializerMethodField() revert_tasks = TaskShortSerializer(many=True, source='get_revert_tasks') is_read_only_viewer = serializers.SerializerMethodField() + fieldsets = FieldSetSerializer(many=True) def get_performers(self, instance) -> List[Dict[str, Any]]: return get_performers_for_task(instance) diff --git a/backend/src/processes/serializers/workflows/workflow.py b/backend/src/processes/serializers/workflows/workflow.py index 218d96724..5a4ba5948 100644 --- a/backend/src/processes/serializers/workflows/workflow.py +++ b/backend/src/processes/serializers/workflows/workflow.py @@ -399,23 +399,32 @@ class Meta: is_read_only_viewer = serializers.SerializerMethodField() def get_kickoff(self, instance: Workflow): + field_prefetches = [ + Prefetch( + lookup='selections', + queryset=FieldSelection.objects.order_by('id'), + to_attr='selections_values', + ), + Prefetch( + 'dataset__items', + queryset=DatasetItem.objects.order_by('order'), + to_attr='dataset_values', + ), + ] kickoff = ( KickoffValue.objects .filter(workflow=self.instance) .prefetch_related( Prefetch( lookup='output', - queryset=TaskField.objects.all().prefetch_related( - Prefetch( - lookup='selections', - queryset=FieldSelection.objects.order_by('id'), - to_attr='selections_values', - ), - Prefetch( - 'dataset__items', - queryset=DatasetItem.objects.order_by('order'), - to_attr='dataset_values', - ), + queryset=TaskField.objects.filter( + fieldset__isnull=True, + ).prefetch_related(*field_prefetches), + ), + Prefetch( + lookup='fieldsets__fields', + queryset=TaskField.objects.prefetch_related( + *field_prefetches, ), ), ).first() diff --git a/backend/src/processes/services/condition_check/comparator.py b/backend/src/processes/services/condition_check/comparator.py index 6c4bf9cc0..9fdcad14f 100644 --- a/backend/src/processes/services/condition_check/comparator.py +++ b/backend/src/processes/services/condition_check/comparator.py @@ -57,3 +57,11 @@ def less_than(cls, a, b): @classmethod def completed(cls, a: bool): return a + + @classmethod + def skipped(cls, a: bool): + return a + + @classmethod + def completed_or_skipped(cls, a: bool): + return a diff --git a/backend/src/processes/services/condition_check/resolvers/task.py b/backend/src/processes/services/condition_check/resolvers/task.py index 923f048d0..c722a3697 100644 --- a/backend/src/processes/services/condition_check/resolvers/task.py +++ b/backend/src/processes/services/condition_check/resolvers/task.py @@ -1,3 +1,4 @@ +from src.processes.enums import PredicateOperator from src.processes.models.workflows.task import Task from .base import Resolver @@ -10,4 +11,10 @@ def _prepare_args(self): api_name=self._predicate.field, workflow_id=self._workflow_id, ) - self.field_value = (task.is_completed or task.is_skipped) + operator = self._predicate.operator + if operator == PredicateOperator.SKIPPED: + self.field_value = task.is_skipped + elif operator == PredicateOperator.COMPLETED: + self.field_value = task.is_completed + else: + self.field_value = (task.is_completed or task.is_skipped) diff --git a/backend/src/processes/services/events.py b/backend/src/processes/services/events.py index b46f0955e..a127048d1 100644 --- a/backend/src/processes/services/events.py +++ b/backend/src/processes/services/events.py @@ -603,9 +603,6 @@ def task_delegation_event( class CommentService(BaseModelService): - def _create_related(self, **kwargs): - pass - def _create_instance(self, **kwargs): pass diff --git a/backend/src/processes/services/exceptions.py b/backend/src/processes/services/exceptions.py index e8d0000a8..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 74f09b732..00f88c9a8 100644 --- a/backend/src/processes/services/tasks/task.py +++ b/backend/src/processes/services/tasks/task.py @@ -13,6 +13,8 @@ from src.processes.models.templates.checklist import ( ChecklistTemplateSelection, ) +from src.processes.models.templates.fieldset import \ + FieldsetTemplateTaskTemplate from src.processes.models.templates.task import TaskTemplate from src.processes.models.workflows.conditions import ( Condition, @@ -42,6 +44,9 @@ from src.processes.services.tasks.mixins import ( ConditionMixin, ) +from src.processes.services.workflows.fieldsets.fieldset import ( + FieldSetService, +) from src.processes.utils.common import ( insert_fields_values_to_text, ) @@ -101,6 +106,7 @@ def _create_related( redefined_performer=kwargs.get('redefined_performer'), ) self.create_fields_from_template(instance_template) + self.create_fieldsets_from_template(instance_template) self.create_conditions_from_template(instance_template) self.create_checklists_from_template(instance_template) self.create_raw_due_date_from_template(instance_template) @@ -200,8 +206,14 @@ def create_conditions_from_template( self.create_rules(conditions, conditions_tree) def create_fields_from_template(self, instance_template: TaskTemplate): - - for field_template in instance_template.fields.all(): + active_fieldset_ids = ( + FieldsetTemplateTaskTemplate.objects + .filter(task=instance_template) + .values_list('fieldset_id', flat=True) + ) + for field_template in instance_template.fields.exclude( + fieldset__in=active_fieldset_ids, + ): service = TaskFieldService(user=self.user) service.create( instance_template=field_template, @@ -210,6 +222,28 @@ def create_fields_from_template(self, instance_template: TaskTemplate): skip_value=True, ) + def create_fieldsets_from_template( + self, + instance_template: TaskTemplate, + ): + fieldset_through_records = ( + FieldsetTemplateTaskTemplate.objects + .filter(task=instance_template) + .select_related('fieldset') + .prefetch_related('fieldset__rules', 'fieldset__fields') + .order_by('order') + ) + for fieldset_through in fieldset_through_records: + service = FieldSetService(user=self.user) + service.create( + instance_template=fieldset_through.fieldset, + account_id=self.instance.workflow.account_id, + workflow=self.instance.workflow, + task=self.instance, + order=fieldset_through.order, + skip_value=True, + ) + def create_checklists_from_template(self, instance_template: TaskTemplate): for checklist_template in instance_template.checklists.all(): checklist_service = ChecklistService( diff --git a/backend/src/processes/services/tasks/task_version.py b/backend/src/processes/services/tasks/task_version.py index 791de8c7c..fbfb05a44 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,120 @@ 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 []: + task_link = next( + link for link in fieldset_data['task_links'] + if link['task_api_name'] == self.instance.api_name + ) + order = task_link['order'] + fieldset, _ = FieldSet.objects.update_or_create( + workflow=self.instance.workflow, + task=self.instance, + api_name=fieldset_data['api_name'], + defaults={ + 'account_id': self.instance.account_id, + 'name': fieldset_data['name'], + 'description': fieldset_data['description'], + 'order': 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, @@ -405,6 +511,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, @@ -423,6 +530,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..6aa0b6700 --- /dev/null +++ b/backend/src/processes/services/templates/fieldsets/fieldset.py @@ -0,0 +1,259 @@ +from typing import Dict, List, Optional +from django.contrib.auth import get_user_model +from django.db import transaction + +from src.generics.base.service import BaseModelService +from src.processes.enums import LabelPosition, FieldSetLayout +from src.processes.models.templates.fieldset import FieldsetTemplate, \ + FieldsetTemplateKickoff, FieldsetTemplateTaskTemplate +from src.processes.models.templates.kickoff import Kickoff +from src.processes.models.templates.template import Template +from src.processes.models.templates.task import TaskTemplate +from src.processes.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, + description: str = '', + api_name: Optional[str] = None, + label_position: LabelPosition.LITERALS = LabelPosition.TOP, + layout: FieldSetLayout.LITERALS = FieldSetLayout.VERTICAL, + **kwargs, + ): + create_kwargs = { + 'template_id': template_id, + 'account': self.account, + 'name': name, + 'description': description, + 'label_position': label_position, + 'layout': layout, + } + if api_name: + create_kwargs['api_name'] = api_name + self.instance = FieldsetTemplate.objects.create(**create_kwargs) + return self.instance + + def _create_related( + self, + rules: Optional[List[Dict]] = None, + fields: Optional[List[Dict]] = None, + **kwargs, + ): + if fields: + self._create_fields(fields_data=fields) + if rules: + self.create_rules(rules_data=rules) + + def _create_fields( + self, + fields_data: List[Dict], + ): + for field_data in fields_data: + service = FieldTemplateService( + user=self.user, + is_superuser=self.is_superuser, + auth_type=self.auth_type, + ) + service.create( + fieldset_id=self.instance.id, + template_id=self.instance.template_id, + **field_data, + ) + + def _update_fields( + self, + fields_data: List[Dict], + ): + """ All fieldset fields will be updated """ + + existing_fields = { + field.api_name: field + for field in self.instance.fields.all() + } + fields_api_names = set() + for field_data in fields_data: + field_api_name = field_data.pop('api_name', None) + if field_api_name and field_api_name in existing_fields: + service = FieldTemplateService( + user=self.user, + is_superuser=self.is_superuser, + auth_type=self.auth_type, + instance=existing_fields[field_api_name], + ) + service.partial_update(force_save=True, **field_data) + fields_api_names.add(field_api_name) + else: + service = FieldTemplateService( + user=self.user, + is_superuser=self.is_superuser, + auth_type=self.auth_type, + ) + field = service.create( + fieldset_id=self.instance.id, + template_id=self.instance.template_id, + **field_data, + ) + fields_api_names.add(field.api_name) + + self.instance.fields.exclude(api_name__in=fields_api_names).delete() + + def _validate_rules(self): + for rule in self.instance.rules.all(): + service = FieldsetTemplateRuleService( + user=self.user, + is_superuser=self.is_superuser, + auth_type=self.auth_type, + instance=rule, + ) + service._validate() + + def partial_update( + self, + **update_kwargs, + ) -> FieldsetTemplate: + + rules_data = update_kwargs.pop('rules', None) + fields_data = update_kwargs.pop('fields', None) + with transaction.atomic(): + if update_kwargs: + self.instance = super().partial_update( + force_save=True, + **update_kwargs, + ) + + if fields_data is not None: + self._update_fields(fields_data=fields_data) + if rules_data is not None: + self.update_rules(rules_data=rules_data) + self._validate_rules() + return self.instance + + def delete(self) -> None: + kickoffs_exists = ( + FieldsetTemplateKickoff.objects + .filter(fieldset=self.instance) + .exists() + ) + tasks_exists = ( + FieldsetTemplateTaskTemplate.objects + .filter(fieldset=self.instance) + .exists() + ) + if kickoffs_exists or tasks_exists: + 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() + + @classmethod + def create_or_update_kickoff_links( + cls, + fieldsets_links: List[dict], + template: Template, + kickoff: Optional[Kickoff] = None, + ): + api_names = {e['api_name'] for e in fieldsets_links} + for fieldset_link in fieldsets_links: + fieldset_template = FieldsetTemplate.objects.get( + template=template, + api_name=fieldset_link['api_name'], + ) + FieldsetTemplateKickoff.objects.update_or_create( + fieldset=fieldset_template, + kickoff=kickoff, + defaults={ + 'order': fieldset_link['order'], + }, + ) + ( + FieldsetTemplateKickoff.objects + .filter(kickoff=kickoff) + .exclude(fieldset__api_name__in=api_names) + .delete() + ) + + @classmethod + def create_or_update_tasks_links( + cls, + fieldsets_links: List[dict], + template: Template, + task: Optional[TaskTemplate] = None, + ): + api_names = {e['api_name'] for e in fieldsets_links} + for fieldset_link in fieldsets_links: + fieldset_template = FieldsetTemplate.objects.get( + template=template, + api_name=fieldset_link['api_name'], + ) + FieldsetTemplateTaskTemplate.objects.update_or_create( + fieldset=fieldset_template, + task=task, + defaults={ + 'order': fieldset_link['order'], + }, + ) + ( + FieldsetTemplateTaskTemplate.objects + .filter(task=task) + .exclude(fieldset__api_name__in=api_names) + .delete() + ) 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..3c5975af0 --- /dev/null +++ b/backend/src/processes/services/templates/fieldsets/fieldset_rule.py @@ -0,0 +1,123 @@ +from typing import List, Optional +from django.contrib.auth import get_user_model +from django.db import transaction +from src.generics.base.service import BaseModelService +from src.processes.enums import FieldSetRuleType, FieldType +from src.processes.models.templates.fields import FieldTemplate +from src.processes.models.templates.fieldset import ( + FieldsetTemplateRule, +) +from src.processes.services.exceptions import ( + FieldsetTemplateRuleSumMaxFieldsNotNumber, + FieldsetTemplateRuleSumMaxInvalidValue, + FieldsetTemplateRuleServiceException, +) +from src.processes.messages.fieldset import MSG_FS_0005 + + +UserModel = get_user_model() + + +class FieldsetTemplateRuleService(BaseModelService): + + def _validate_sum_equal(self, **kwargs) -> float: + + value = self.instance.value + if not value: + raise FieldsetTemplateRuleSumMaxInvalidValue + try: + result = float(value) + except (ValueError, TypeError) as ex: + raise FieldsetTemplateRuleSumMaxInvalidValue from ex + + if self.instance.fields.exclude(type=FieldType.NUMBER).exists(): + raise FieldsetTemplateRuleSumMaxFieldsNotNumber + return result + + def _validate(self, **kwargs): + + """ Call after objects save """ + + validator = getattr(self, f'_validate_{self.instance.type}', None) + validator(**kwargs) + + def _create_instance( + self, + type: FieldSetRuleType.LITERALS, # noqa: A002 + value: Optional[str] = None, + fieldset_id: Optional[int] = None, + api_name: Optional[str] = None, + **kwargs, + ): + create_kwargs = { + 'account': self.account, + 'type': type, + 'value': value, + 'fieldset_id': fieldset_id, + } + if api_name: + create_kwargs['api_name'] = api_name + self.instance = FieldsetTemplateRule.objects.create(**create_kwargs) + return self.instance + + def _create_related( + self, + **kwargs, + ): + fields = kwargs.pop('fields', None) + if fields is not None: + self._set_fields(fields) + + def _get_valid_fields( + self, + fields_api_names: List[str], + **kwargs, + ) -> List[FieldTemplate]: + + rule_type = kwargs.get('type') or self.instance.type + available_fields = list( + FieldTemplate.objects + .filter( + fieldset_id=self.instance.fieldset_id, + api_name__in=fields_api_names, + ), + ) + fields_api_names = set(fields_api_names) + available_api_names = {field.api_name for field in available_fields} + failed_api_names = fields_api_names - available_api_names + if failed_api_names: + raise FieldsetTemplateRuleServiceException( + message=MSG_FS_0005( + rule=rule_type, + field=failed_api_names.pop(), + ), + ) + return available_fields + + def _set_fields(self, fields_api_names: List[str], **kwargs): + if fields_api_names: + fields = self._get_valid_fields(fields_api_names, **kwargs) + self.instance.fields.set(fields) + else: + self.instance.fields.clear() + + def create( + self, + **kwargs, + ) -> FieldsetTemplateRule: + + with transaction.atomic(): + self._create_instance(**kwargs) + self._create_related(**kwargs) + self._create_actions(**kwargs) + self._validate(**kwargs) + return self.instance + + def partial_update(self, **update_kwargs) -> FieldsetTemplateRule: + fields = update_kwargs.pop('fields', None) + with transaction.atomic(): + result = super().partial_update(**update_kwargs, force_save=True) + if fields is not None: + self._set_fields(fields) + self._validate(**update_kwargs) + return result diff --git a/backend/src/processes/services/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 c4291af29..a0fdbce4e 100644 --- a/backend/src/processes/services/versioning/schemas.py +++ b/backend/src/processes/services/versioning/schemas.py @@ -9,6 +9,12 @@ PredicateTemplate, RuleTemplate, ) +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateKickoff, + FieldsetTemplateRule, + FieldsetTemplateTaskTemplate, +) from src.processes.models.templates.fields import ( FieldTemplate, FieldTemplateSelection, @@ -32,6 +38,17 @@ class Meta: ) +class FieldsetTemplateRuleSchemaV1(serializers.ModelSerializer): + + class Meta: + model = FieldsetTemplateRule + fields = ( + 'api_name', + 'type', + 'value', + ) + + class FieldSchemaV1(serializers.ModelSerializer): class Meta: @@ -48,6 +65,7 @@ class Meta: 'default', 'selections', 'dataset_id', + 'rules', ) selections = SelectionSchemaV1( @@ -56,6 +74,64 @@ class Meta: allow_empty=True, required=False, ) + rules = FieldsetTemplateRuleSchemaV1( + many=True, + allow_null=True, + allow_empty=True, + ) + + +class FieldsetTemplateTaskTemplateSchemaV1(serializers.ModelSerializer): + + class Meta: + model = FieldsetTemplateTaskTemplate + fields = ( + 'task_api_name', + 'order', + ) + + task_api_name = serializers.CharField(source='task.api_name') + + +class FieldsetTemplateKickoffSchemaV1(serializers.ModelSerializer): + + class Meta: + model = FieldsetTemplateKickoff + fields = ('order',) + + +class FieldSetSchemaV1(serializers.ModelSerializer): + + class Meta: + model = FieldsetTemplate + fields = ( + 'api_name', + 'name', + 'description', + 'label_position', + 'layout', + 'fields', + 'rules', + 'task_links', + 'kickoff_links', + ) + + fields = FieldSchemaV1(many=True, allow_null=True, allow_empty=True) + rules = FieldsetTemplateRuleSchemaV1( + many=True, + allow_null=True, + allow_empty=True, + ) + task_links = FieldsetTemplateTaskTemplateSchemaV1( + source='fieldsettemplatetasktemplate_set', + many=True, + required=False, + ) + kickoff_links = FieldsetTemplateKickoffSchemaV1( + source='fieldsettemplatekickoff_set', + many=True, + required=False, + ) class KickoffSchemaV1(serializers.ModelSerializer): @@ -64,9 +140,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): @@ -193,6 +276,7 @@ class Meta: 'require_completion_by_all', 'skip_for_starter', 'fields', + 'fieldsets', 'delay', 'conditions', 'raw_performers', @@ -203,6 +287,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 13208401c..4ff3fca2f 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, @@ -854,15 +857,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..3c2d6b0db --- /dev/null +++ b/backend/src/processes/services/workflows/fieldsets/fieldset.py @@ -0,0 +1,82 @@ +from typing import List, Optional, Dict +from django.contrib.auth import get_user_model + +from src.generics.base.service import BaseModelService +from src.processes.messages.fieldset import MSG_FS_0007 +from src.processes.models.templates.fieldset import FieldsetTemplate +from src.processes.models.workflows.fieldset import FieldSet +from src.processes.services.exceptions import FieldsetServiceException +from src.processes.services.tasks.field import TaskFieldService +from src.processes.services.workflows.fieldsets.fieldset_rule import ( + FieldSetRuleService, +) + +UserModel = get_user_model() + + +class FieldSetService(BaseModelService): + + def _create_instance( + self, + instance_template: FieldsetTemplate, + **kwargs, + ): + task = kwargs.get('task') + kickoff = kwargs.get('kickoff') + if not (task or kickoff): + raise FieldsetServiceException( + message=MSG_FS_0007, + ) + + self.instance = FieldSet.objects.create( + account=self.account, + workflow=kwargs['workflow'], + kickoff=kickoff, + task=task, + api_name=instance_template.api_name, + name=instance_template.name, + description=instance_template.description, + order=kwargs['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..4540da22f --- /dev/null +++ b/backend/src/processes/services/workflows/fieldsets/fieldset_rule.py @@ -0,0 +1,62 @@ +from django.contrib.auth import get_user_model +from django.db import transaction +from src.processes.messages.fieldset import MSG_FS_0002 +from src.processes.models.workflows.fieldset import FieldSetRule +from src.processes.services.base import BaseModelService +from src.processes.services.exceptions import FieldsetServiceException + + +UserModel = get_user_model() + + +class FieldSetRuleService(BaseModelService): + + NULL_VALUES = (None, '', []) + + def _validate_sum_equal(self, **kwargs): + + total = 0 + values_exists = False + for field in self.instance.fields.all(): + if field.value in self.NULL_VALUES: + if field.is_required: + values_exists = True + else: + total += float(field.value) + values_exists = True + if values_exists and total != float(self.instance.value): + raise FieldsetServiceException( + message=MSG_FS_0002(self.instance.value), + ) + return True + + def validate(self, **kwargs): + + """ Call after objects save """ + + validator = getattr(self, f'_validate_{self.instance.type}') + validator(**kwargs) + + def _create_instance(self, instance_template, **kwargs): + self.instance = FieldSetRule.objects.create( + account=self.account, + fieldset=kwargs['fieldset'], + api_name=instance_template.api_name, + type=instance_template.type, + value=instance_template.value, + ) + + def create(self, **kwargs) -> FieldSetRule: + with transaction.atomic(): + self._create_instance(**kwargs) + self._create_related(**kwargs) + self._create_actions(**kwargs) + if kwargs.get('skip_validation') is False: + self.validate(**kwargs) + return self.instance + + def partial_update(self, **update_kwargs) -> FieldSetRule: + with transaction.atomic(): + result = super().partial_update(**update_kwargs) + self.validate(**update_kwargs) + return result diff --git a/backend/src/processes/services/workflows/kickoff_version.py b/backend/src/processes/services/workflows/kickoff_version.py index 9fa54b4ff..6bb093882 100644 --- a/backend/src/processes/services/workflows/kickoff_version.py +++ b/backend/src/processes/services/workflows/kickoff_version.py @@ -1,5 +1,9 @@ -from typing import Dict, List +from typing import Dict, List, Optional +from src.processes.models.workflows.fieldset import ( + FieldSet, + FieldSetRule, +) from src.processes.models.workflows.fields import ( FieldSelection, TaskField, @@ -11,13 +15,19 @@ class KickoffUpdateVersionService(BaseUpdateVersionService): - def _update_field(self, template: dict): + def _update_field( + self, + template: dict, + *, + fieldset: Optional[FieldSet] = None, + ): # TODO Move to TaskFieldService return TaskField.objects.update_or_create( kickoff=self.instance, api_name=template['api_name'], + fieldset=fieldset, defaults={ 'name': template['name'], 'description': template['description'], @@ -31,6 +41,25 @@ def _update_field(self, template: dict): }, ) + def _update_field_selections( + self, + field: TaskField, + field_data: Dict, + ) -> None: + + if field_data.get('selections'): + selection_ids = set() + for selection_data in field_data['selections']: + selection, __ = FieldSelection.objects.update_or_create( + field=field, + api_name=selection_data['api_name'], + defaults={ + 'value': selection_data['value'], + }, + ) + selection_ids.add(selection.id) + field.selections.exclude(id__in=selection_ids).delete() + def _update_fields( self, data: List[Dict], @@ -40,21 +69,101 @@ def _update_fields( field_ids = [] for field_data in data: - field, _ = self._update_field(field_data) + field, _ = self._update_field(field_data, fieldset=None) field_ids.append(field.id) - if field_data.get('selections'): - selection_ids = set() - for selection_data in field_data['selections']: - selection, __ = FieldSelection.objects.update_or_create( - field=field, - api_name=selection_data['api_name'], - defaults={ - 'value': selection_data['value'], - }, - ) - selection_ids.add(selection.id) - field.selections.exclude(id__in=selection_ids).delete() - self.instance.output.exclude(id__in=field_ids).delete() + self._update_field_selections(field, field_data) + self.instance.output.filter( + fieldset__isnull=True, + ).exclude(id__in=field_ids).delete() + + def _update_fieldset_rules( + self, + fieldset: FieldSet, + rules_data: Optional[List[Dict]] = None, + ) -> None: + + rule_ids = [] + rules_data = rules_data or [] + for rule_data in rules_data: + rule, _ = FieldSetRule.objects.update_or_create( + fieldset=fieldset, + api_name=rule_data['api_name'], + defaults={ + 'account_id': fieldset.account_id, + 'type': rule_data['type'], + 'value': rule_data.get('value'), + }, + ) + rule_ids.append(rule.id) + fieldset.rules.exclude(id__in=rule_ids).delete() + + def _update_field_rules( + self, + field: TaskField, + field_data: Dict, + fieldset: FieldSet, + ) -> None: + + rules = field_data.get('rules', []) + if rules: + rules_api_names = [e['api_name'] for e in rules] + rules = FieldSetRule.objects.filter( + fieldset=fieldset, + api_name__in=rules_api_names, + ) + field.rules.set(rules) + else: + field.rules.clear() + + def _update_fieldset_fields( + self, + fieldset: FieldSet, + fields_data: Optional[List[Dict]], + ) -> None: + + field_ids = [] + fields_data = fields_data or [] + for field_data in fields_data: + field, _ = self._update_field(field_data, fieldset=fieldset) + field_ids.append(field.id) + self._update_field_selections(field, field_data) + self._update_field_rules(field, field_data, fieldset) + TaskField.objects.filter( + kickoff=self.instance, + fieldset=fieldset, + ).exclude(id__in=field_ids).delete() + + def _update_fieldsets(self, data: Optional[List]) -> None: + + fs_api_names = set() + for fs_data in data or []: + order = fs_data['kickoff_links'][0]['order'] + fieldset, _ = FieldSet.objects.update_or_create( + workflow=self.instance.workflow, + kickoff=self.instance, + api_name=fs_data['api_name'], + defaults={ + 'account_id': self.instance.account_id, + 'name': fs_data['name'], + 'description': fs_data['description'], + 'order': order, + 'label_position': fs_data['label_position'], + 'layout': fs_data['layout'], + }, + ) + self._update_fieldset_rules( + fieldset=fieldset, + rules_data=fs_data.get('rules'), + ) + self._update_fieldset_fields( + fieldset=fieldset, + fields_data=fs_data.get('fields'), + ) + fs_api_names.add(fs_data['api_name']) + FieldSet.objects.filter( + kickoff=self.instance, + is_deleted=False, + ).exclude(api_name__in=fs_api_names).delete() def update_from_version( self, @@ -64,9 +173,12 @@ def update_from_version( """ data = { 'description': str, - 'fields': list + 'fields': list, + 'fieldsets': list, } """ if data.get('fields'): self._update_fields(data=data['fields']) + if data.get('fieldsets') is not None: + self._update_fieldsets(data=data['fieldsets']) diff --git a/backend/src/processes/tests/fixtures.py b/backend/src/processes/tests/fixtures.py index 36a4e14d7..2c03d558b 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,12 @@ PredicateTemplate, RuleTemplate, ) +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateRule, + FieldsetTemplateTaskTemplate, FieldsetTemplateKickoff, +) +from src.processes.models.templates.fields import FieldTemplate from src.processes.models.templates.kickoff import Kickoff from src.processes.models.templates.owner import TemplateOwner from src.processes.models.templates.preset import ( @@ -64,6 +72,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 @@ -374,7 +383,7 @@ def create_test_template( ) PredicateTemplate.objects.create( rule=rule, - operator=PredicateOperator.COMPLETED, + operator=PredicateOperator.COMPLETED_OR_SKIPPED, field_type=PredicateType.TASK, field=parents[0], value=None, @@ -819,3 +828,122 @@ 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, + name=name, + description=description, + label_position=label_position, + layout=layout, + api_name=api_name, + ) + if task: + FieldsetTemplateTaskTemplate.objects.create( + fieldset=fieldset, + task=task, + order=order, + ) + if kickoff: + FieldsetTemplateKickoff.objects.create( + fieldset=fieldset, + kickoff=kickoff, + order=order, + ) + if rule_type: + FieldsetTemplateRule.objects.create( + fieldset=fieldset, + 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_condition_check/test_service.py b/backend/src/processes/tests/test_services/test_condition_check/test_service.py index 1ce2636ff..d0f556173 100644 --- a/backend/src/processes/tests/test_services/test_condition_check/test_service.py +++ b/backend/src/processes/tests/test_services/test_condition_check/test_service.py @@ -3928,7 +3928,7 @@ def test_check__group__not_exist__group_set__fail(): # region task / kickoff -def test_check__task_completed__return_true(): +def test_check_task_completed__completed__return_true(): """Check returns True when the referenced task has completed status.""" @@ -3963,7 +3963,7 @@ def test_check__task_completed__return_true(): assert result is True -def test_check__task_not_completed__return_false(): +def test_check_task_completed__not_completed__return_false(): """Check returns False when the referenced task has not completed.""" @@ -3996,7 +3996,215 @@ def test_check__task_not_completed__return_false(): assert result is False -def test_check__kickoff_completed__return_true(): +def test_check_task_completed__skipped__return_false(): + + """Check returns False when the referenced task has skipped status.""" + + # arrange + owner = create_test_owner() + workflow = create_test_workflow(user=owner, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + task_1.status = TaskStatus.SKIPPED + task_1.save() + task_2 = workflow.tasks.get(number=2) + condition = Condition.objects.create( + task=task_2, + action=ConditionAction.SKIP_TASK, + order=1, + ) + rule = Rule.objects.create(condition=condition) + Predicate.objects.create( + rule=rule, + operator=PredicateOperator.COMPLETED, + field_type=PredicateType.TASK, + field=task_1.api_name, + value=None, + ) + + # act + result = ConditionCheckService.check( + condition=condition, + workflow_id=workflow.id, + ) + + # assert + assert result is False + + +def test_check_task_skipped__skipped__return_true(): + + """Check returns True when the referenced task has skipped status.""" + + # arrange + owner = create_test_owner() + workflow = create_test_workflow(user=owner, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + task_1.status = TaskStatus.SKIPPED + task_1.save() + task_2 = workflow.tasks.get(number=2) + condition = Condition.objects.create( + task=task_2, + action=ConditionAction.SKIP_TASK, + order=1, + ) + rule = Rule.objects.create(condition=condition) + Predicate.objects.create( + rule=rule, + operator=PredicateOperator.SKIPPED, + field_type=PredicateType.TASK, + field=task_1.api_name, + value=None, + ) + + # act + result = ConditionCheckService.check( + condition=condition, + workflow_id=workflow.id, + ) + + # assert + assert result is True + + +def test_check_task_skipped__not_skipped__return_false(): + + """Check returns True when the referenced task has skipped status.""" + + # arrange + owner = create_test_owner() + workflow = create_test_workflow(user=owner, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + task_1.status = TaskStatus.COMPLETED + task_1.save() + task_2 = workflow.tasks.get(number=2) + condition = Condition.objects.create( + task=task_2, + action=ConditionAction.SKIP_TASK, + order=1, + ) + rule = Rule.objects.create(condition=condition) + Predicate.objects.create( + rule=rule, + operator=PredicateOperator.SKIPPED, + field_type=PredicateType.TASK, + field=task_1.api_name, + value=None, + ) + + # act + result = ConditionCheckService.check( + condition=condition, + workflow_id=workflow.id, + ) + + # assert + assert result is False + + +def test_check_task_completed_or_skipped__skipped__return_true(): + + """Check returns True when the referenced task has skipped status.""" + + # arrange + owner = create_test_owner() + workflow = create_test_workflow(user=owner, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + task_1.status = TaskStatus.SKIPPED + task_1.save() + task_2 = workflow.tasks.get(number=2) + condition = Condition.objects.create( + task=task_2, + action=ConditionAction.SKIP_TASK, + order=1, + ) + rule = Rule.objects.create(condition=condition) + Predicate.objects.create( + rule=rule, + operator=PredicateOperator.COMPLETED_OR_SKIPPED, + field_type=PredicateType.TASK, + field=task_1.api_name, + value=None, + ) + + # act + result = ConditionCheckService.check( + condition=condition, + workflow_id=workflow.id, + ) + + # assert + assert result is True + + +def test_check_task_completed_or_skipped__completed__return_true(): + + """Check returns True when the referenced task has completed status.""" + + # arrange + owner = create_test_owner() + workflow = create_test_workflow(user=owner, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + task_1.status = TaskStatus.COMPLETED + task_1.save() + task_2 = workflow.tasks.get(number=2) + condition = Condition.objects.create( + task=task_2, + action=ConditionAction.SKIP_TASK, + order=1, + ) + rule = Rule.objects.create(condition=condition) + Predicate.objects.create( + rule=rule, + operator=PredicateOperator.COMPLETED_OR_SKIPPED, + field_type=PredicateType.TASK, + field=task_1.api_name, + value=None, + ) + + # act + result = ConditionCheckService.check( + condition=condition, + workflow_id=workflow.id, + ) + + # assert + assert result is True + + +def test_check_task_completed_or_skipped__pending__return_false(): + + """Check returns False when the referenced task is still pending.""" + + # arrange + owner = create_test_owner() + workflow = create_test_workflow(user=owner, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + task_2 = workflow.tasks.get(number=2) + condition = Condition.objects.create( + task=task_2, + action=ConditionAction.SKIP_TASK, + order=1, + ) + rule = Rule.objects.create(condition=condition) + Predicate.objects.create( + rule=rule, + operator=PredicateOperator.COMPLETED_OR_SKIPPED, + field_type=PredicateType.TASK, + field=task_1.api_name, + value=None, + ) + + # act + result = ConditionCheckService.check( + condition=condition, + workflow_id=workflow.id, + ) + + # assert + assert result is False + + +def test_check_kickoff_completed__return_true(): """Check returns True when predicate type is kickoff (always completed).""" @@ -4026,5 +4234,3 @@ def test_check__kickoff_completed__return_true(): # assert assert result is True - -# endregion diff --git a/backend/src/processes/tests/test_services/test_tasks/test_task_service.py b/backend/src/processes/tests/test_services/test_tasks/test_task_service.py index 206a35ecf..1fd2daab0 100644 --- a/backend/src/processes/tests/test_services/test_tasks/test_task_service.py +++ b/backend/src/processes/tests/test_services/test_tasks/test_task_service.py @@ -13,11 +13,25 @@ ChecklistTemplate, ChecklistTemplateSelection, ) +from src.processes.models.templates.fieldset import ( + FieldsetTemplateTaskTemplate, +) +from src.processes.models.templates.fields import FieldTemplate +from src.processes.models.templates.raw_due_date import RawDueDateTemplate from src.processes.models.workflows.fields import TaskField from src.processes.models.workflows.raw_due_date import RawDueDate +from src.processes.models.workflows.task import Delay from src.authentication.enums import AuthTokenType +from src.processes.services.tasks.checklist import ChecklistService +from src.processes.services.tasks.field import TaskFieldService from src.processes.services.tasks.task import TaskService +from src.processes.services.workflows.fieldsets.fieldset import FieldSetService from src.processes.tests.fixtures import ( + create_checklist_template, + create_test_account, + create_test_admin, + create_test_fieldset_template, + create_test_owner, create_test_template, create_test_user, create_test_workflow, @@ -1242,3 +1256,884 @@ def test_insert_fields_values__no_checklists__skip(mocker): markdown_clear_mock.assert_called_once_with(new_description) checklist_service_init_mock.assert_not_called() save_mock.assert_called_once_with() + + +def test_partial_update__default__ok(mocker): + + """ + Default call + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + markdown_clear_mock = mocker.patch( + 'src.processes.services.tasks.task.MarkdownService.clear', + ) + save_mock = mocker.patch( + 'src.processes.services.tasks.task.TaskService.save', + ) + service = TaskService(user=user, instance=task) + + # act + service.partial_update() + + # assert + markdown_clear_mock.assert_not_called() + save_mock.assert_not_called() + + +def test_partial_update__with_desc__clear_desc_set(mocker): + + """ + With description + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + description = 'New description' + clear_description = 'New clear description' + markdown_clear_mock = mocker.patch( + 'src.processes.services.tasks.task.MarkdownService.clear', + return_value=clear_description, + ) + save_mock = mocker.patch( + 'src.processes.services.tasks.task.TaskService.save', + ) + service = TaskService(user=user, instance=task) + + # act + service.partial_update(description=description) + + # assert + assert task.description == description + assert task.clear_description == clear_description + markdown_clear_mock.assert_called_once_with(description) + save_mock.assert_not_called() + + +def test_partial_update__no_desc__clear_desc_skip(mocker): + + """ + Without description + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + original_clear_description = task.clear_description + markdown_clear_mock = mocker.patch( + 'src.processes.services.tasks.task.MarkdownService.clear', + ) + save_mock = mocker.patch( + 'src.processes.services.tasks.task.TaskService.save', + ) + service = TaskService(user=user, instance=task) + + # act + service.partial_update(name='New name') + + # assert + assert task.name == 'New name' + assert task.clear_description == original_clear_description + markdown_clear_mock.assert_not_called() + save_mock.assert_not_called() + + +def test_partial_update__date_started_first__set(mocker): + + """ + date_started, first start + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + task.date_first_started = None + task.save(update_fields=['date_first_started']) + date_started = timezone.now() + markdown_clear_mock = mocker.patch( + 'src.processes.services.tasks.task.MarkdownService.clear', + ) + save_mock = mocker.patch( + 'src.processes.services.tasks.task.TaskService.save', + ) + service = TaskService(user=user, instance=task) + + # act + service.partial_update(date_started=date_started) + + # assert + assert task.date_first_started == date_started + assert 'date_first_started' in service.update_fields + markdown_clear_mock.assert_not_called() + save_mock.assert_not_called() + + +def test_partial_update__date_started_already__skip(mocker): + + """ + date_started, already started + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + original_date_first_started = timezone.now() - timedelta(hours=1) + task.date_first_started = original_date_first_started + task.save(update_fields=['date_first_started']) + date_started = timezone.now() + markdown_clear_mock = mocker.patch( + 'src.processes.services.tasks.task.MarkdownService.clear', + ) + save_mock = mocker.patch( + 'src.processes.services.tasks.task.TaskService.save', + ) + service = TaskService(user=user, instance=task) + + # act + service.partial_update(date_started=date_started) + + # assert + assert task.date_first_started == original_date_first_started + assert 'date_first_started' not in service.update_fields + markdown_clear_mock.assert_not_called() + save_mock.assert_not_called() + + +def test_partial_update__force_save__saved(mocker): + + """ + force_save True + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + markdown_clear_mock = mocker.patch( + 'src.processes.services.tasks.task.MarkdownService.clear', + ) + save_mock = mocker.patch( + 'src.processes.services.tasks.task.TaskService.save', + ) + service = TaskService(user=user, instance=task) + + # act + service.partial_update(force_save=True, name='Updated') + + # assert + assert task.name == 'Updated' + markdown_clear_mock.assert_not_called() + save_mock.assert_called_once_with() + + +def test_create_raw_performers_from_template__default__ok(mocker): + + """ + Default call + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + add_raw_performer_mock = mocker.patch( + 'src.processes.models.workflows.task.Task.add_raw_performer', + ) + update_raw_performers_mock = mocker.patch( + 'src.processes.models.workflows.task.Task' + '.update_raw_performers_from_task_template', + ) + service = TaskService(user=user, instance=task) + + # act + service.create_raw_performers_from_template( + instance_template=template_task, + ) + + # assert + add_raw_performer_mock.assert_not_called() + update_raw_performers_mock.assert_called_once_with(template_task) + + +def test_create_raw_performers_from_template__redefined__ok(mocker): + + """ + With redefined_performer + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + redefined_performer = create_test_admin(account=account) + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + add_raw_performer_mock = mocker.patch( + 'src.processes.models.workflows.task.Task.add_raw_performer', + ) + update_raw_performers_mock = mocker.patch( + 'src.processes.models.workflows.task.Task' + '.update_raw_performers_from_task_template', + ) + service = TaskService(user=user, instance=task) + + # act + service.create_raw_performers_from_template( + instance_template=template_task, + redefined_performer=redefined_performer, + ) + + # assert + add_raw_performer_mock.assert_called_once_with( + user=redefined_performer, + ) + update_raw_performers_mock.assert_not_called() + + +def test_create_fields_from_template__no_fields__ok(mocker): + + """ + Default call, no fields + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + task_field_service_init_mock = mocker.patch.object( + TaskFieldService, + '__init__', + return_value=None, + ) + service = TaskService(user=user, instance=task) + + # act + service.create_fields_from_template(instance_template=template_task) + + # assert + task_field_service_init_mock.assert_not_called() + + +def test_create_fields_from_template__outside_fs__ok(mocker): + + """ + Fields outside fieldsets + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + field_template_1 = FieldTemplate.objects.create( + name='Field 1', + type=FieldType.STRING, + task=template_task, + template=template, + order=1, + api_name='field-1', + account=user.account, + ) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + task_field_service_init_mock = mocker.patch.object( + TaskFieldService, + '__init__', + return_value=None, + ) + task_field_service_create_mock = mocker.patch( + 'src.processes.services.tasks.field.TaskFieldService.create', + ) + service = TaskService(user=user, instance=task) + + # act + service.create_fields_from_template(instance_template=template_task) + + # assert + task_field_service_init_mock.assert_called_once_with(user=user) + task_field_service_create_mock.assert_called_once_with( + instance_template=field_template_1, + workflow_id=task.workflow_id, + task_id=task.id, + skip_value=True, + ) + + +def test_create_fields_from_template__in_fieldsets__skip(mocker): + + """ + Fields inside fieldsets + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + create_test_fieldset_template( + account=user.account, + template=template, + task=template_task, + ) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + task_field_service_init_mock = mocker.patch.object( + TaskFieldService, + '__init__', + return_value=None, + ) + service = TaskService(user=user, instance=task) + + # act + service.create_fields_from_template(instance_template=template_task) + + # assert + task_field_service_init_mock.assert_not_called() + + +def test_create_fieldsets_from_template__no_fieldsets__ok(mocker): + + """ + Default call, no fieldsets + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + field_set_service_init_mock = mocker.patch.object( + FieldSetService, + '__init__', + return_value=None, + ) + service = TaskService(user=user, instance=task) + + # act + service.create_fieldsets_from_template(instance_template=template_task) + + # assert + field_set_service_init_mock.assert_not_called() + + +def test_create_fieldsets_from_template__with_fieldsets__ok(mocker): + + """ + With fieldsets + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + fieldset_template = create_test_fieldset_template( + account=user.account, + template=template, + task=template_task, + order=5, + ) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + field_set_service_init_mock = mocker.patch.object( + FieldSetService, + '__init__', + return_value=None, + ) + field_set_service_create_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset' + '.FieldSetService.create', + ) + service = TaskService(user=user, instance=task) + + # act + service.create_fieldsets_from_template(instance_template=template_task) + + # assert + field_set_service_init_mock.assert_called_once_with(user=user) + field_set_service_create_mock.assert_called_once_with( + instance_template=fieldset_template, + account_id=task.workflow.account_id, + workflow=task.workflow, + task=task, + order=5, + skip_value=True, + ) + + +def test_create_conditions_from_template__no_conditions__ok(mocker): + + """ + Default call, no conditions + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + + # remove default conditions + template_task.conditions.all().delete() + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + create_rules_mock = mocker.patch( + 'src.processes.services.tasks.mixins.ConditionMixin.create_rules', + ) + service = TaskService(user=user, instance=task) + + # act + service.create_conditions_from_template( + instance_template=template_task, + ) + + # assert + create_rules_mock.assert_not_called() + + +def test_create_conditions_from_template__with_conditions__ok(mocker): + + """ + With conditions + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=2) + template_task = template.tasks.get(number=2) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=2) + create_rules_mock = mocker.patch( + 'src.processes.services.tasks.mixins.ConditionMixin.create_rules', + ) + service = TaskService(user=user, instance=task) + + # act + service.create_conditions_from_template( + instance_template=template_task, + ) + + # assert + create_rules_mock.assert_called_once_with( + mocker.ANY, + mocker.ANY, + ) + + +def test_create_checklists_from_template__no_checklists__ok(mocker): + + """ + Default call, no checklists + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + checklist_service_init_mock = mocker.patch.object( + ChecklistService, + '__init__', + return_value=None, + ) + service = TaskService(user=user, instance=task) + + # act + service.create_checklists_from_template( + instance_template=template_task, + ) + + # assert + checklist_service_init_mock.assert_not_called() + + +def test_create_checklists_from_template__with_checklists__ok(mocker): + + """ + With checklists + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + checklist_template_1 = create_checklist_template( + task_template=template_task, + selections_count=1, + ) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + checklist_service_init_mock = mocker.patch.object( + ChecklistService, + '__init__', + return_value=None, + ) + checklist_service_create_mock = mocker.patch( + 'src.processes.services.tasks.checklist.ChecklistService.create', + ) + service = TaskService(user=user, instance=task) + + # act + service.create_checklists_from_template( + instance_template=template_task, + ) + + # assert + checklist_service_init_mock.assert_called_once_with( + user=user, + is_superuser=service.is_superuser, + auth_type=service.auth_type, + ) + checklist_service_create_mock.assert_called_once_with( + instance_template=checklist_template_1, + task=task, + ) + + +def test_create_raw_due_date_from_template__no_raw__ok(): + + """ + Default call, no raw_due_date + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + service = TaskService(user=user, instance=task) + + # act + service.create_raw_due_date_from_template( + instance_template=template_task, + ) + + # assert + assert not RawDueDate.objects.filter(task=task).exists() + + +def test_create_raw_due_date_from_template__with_raw__ok(): + + """ + With raw_due_date + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + duration = timedelta(hours=2) + duration_months = 1 + RawDueDateTemplate.objects.create( + task=template_task, + template=template, + duration=duration, + duration_months=duration_months, + rule=DueDateRule.AFTER_WORKFLOW_STARTED, + source_id=None, + ) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + + # remove any raw_due_date created by workflow fixture + RawDueDate.objects.filter(task=task).delete() + service = TaskService(user=user, instance=task) + + # act + service.create_raw_due_date_from_template( + instance_template=template_task, + ) + + # assert + raw_due_date = RawDueDate.objects.get(task=task) + assert raw_due_date.duration == duration + assert raw_due_date.duration_months == duration_months + assert raw_due_date.rule == DueDateRule.AFTER_WORKFLOW_STARTED + assert raw_due_date.source_id is None + + +def test_get_task_due_date__after_started_same__none(): + + """ + After task started, same, no date + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + task.date_first_started = None + task.save(update_fields=['date_first_started']) + RawDueDate.objects.create( + task=task, + duration=timedelta(days=1), + rule=DueDateRule.AFTER_TASK_STARTED, + source_id=task.api_name, + ) + service = TaskService(user=user, instance=task) + + # act + due_date = service.get_task_due_date() + + # assert + assert due_date is None + + +def test_get_task_due_date__field_no_value__none(): + + """ + Field rule, no value + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + task_2 = workflow.tasks.get(number=2) + field = TaskField.objects.create( + task=task_1, + name='date', + api_name='date-1', + type=FieldType.DATE, + value='', + workflow=workflow, + account=user.account, + ) + RawDueDate.objects.create( + task=task_2, + duration=timedelta(days=1), + rule=DueDateRule.AFTER_FIELD, + source_id=field.api_name, + ) + service = TaskService(user=user, instance=task_2) + + # act + due_date = service.get_task_due_date() + + # assert + assert due_date is None + + +def test_create_related__no_delay__ok(mocker): + + """ + Default call, no delay + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + template_task.delay = None + template_task.save(update_fields=['delay']) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + create_raw_performers_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_raw_performers_from_template', + ) + create_fields_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_fields_from_template', + ) + create_fieldsets_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_fieldsets_from_template', + ) + create_conditions_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_conditions_from_template', + ) + create_checklists_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_checklists_from_template', + ) + create_raw_due_date_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_raw_due_date_from_template', + ) + service = TaskService(user=user, instance=task) + + # act + service._create_related( + instance_template=template_task, + ) + + # assert + assert not Delay.objects.filter(task=task).exists() + create_raw_performers_mock.assert_called_once_with( + instance_template=template_task, + redefined_performer=None, + ) + create_fields_mock.assert_called_once_with(template_task) + create_fieldsets_mock.assert_called_once_with(template_task) + create_conditions_mock.assert_called_once_with(template_task) + create_checklists_mock.assert_called_once_with(template_task) + create_raw_due_date_mock.assert_called_once_with(template_task) + + +def test_create_related__with_delay__ok(mocker): + + """ + With delay + """ + + # arrange + user = create_test_owner() + template = create_test_template( + user=user, + tasks_count=1, + ) + template_task = template.tasks.get(number=1) + delay_value = timedelta(seconds=30) + template_task.delay = delay_value + template_task.save(update_fields=['delay']) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + + # remove delays created by workflow fixture + Delay.objects.filter(task=task).delete() + create_raw_performers_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_raw_performers_from_template', + ) + create_fields_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_fields_from_template', + ) + create_fieldsets_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_fieldsets_from_template', + ) + create_conditions_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_conditions_from_template', + ) + create_checklists_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_checklists_from_template', + ) + create_raw_due_date_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.create_raw_due_date_from_template', + ) + service = TaskService(user=user, instance=task) + + # act + service._create_related( + instance_template=template_task, + ) + + # assert + delay = Delay.objects.get(task=task) + assert delay.duration == delay_value + assert delay.workflow == workflow + create_raw_performers_mock.assert_called_once_with( + instance_template=template_task, + redefined_performer=None, + ) + create_fields_mock.assert_called_once_with(template_task) + create_fieldsets_mock.assert_called_once_with(template_task) + create_conditions_mock.assert_called_once_with(template_task) + create_checklists_mock.assert_called_once_with(template_task) + create_raw_due_date_mock.assert_called_once_with(template_task) + + +def test_set_due_date_directly__default__ok(mocker): + + """ + Default call + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + partial_update_mock = mocker.patch( + 'src.processes.services.tasks.task' + '.TaskService.partial_update', + ) + send_notifications_mock = mocker.patch( + 'src.notifications.tasks.send_due_date_changed.delay', + ) + due_date_changed_event_mock = mocker.patch( + 'src.processes.services.events.WorkflowEventService' + '.due_date_changed_event', + ) + service = TaskService(user=user, instance=task) + + # act + service.set_due_date_directly() + + # assert + partial_update_mock.assert_called_once_with( + due_date=None, + force_save=True, + ) + send_notifications_mock.assert_called_once_with( + logging=user.account.log_api_requests, + author_id=user.id, + task_id=task.id, + account_id=task.account_id, + logo_lg=user.account.logo_lg, + ) + due_date_changed_event_mock.assert_called_once_with( + task=task, + user=user, + ) + + +def test_create_fields_from_template__deleted_fieldsets__skip( + mocker, +): + + """ + Field inside an active fieldset is excluded, + field inside a soft-deleted fieldset is created as standalone. + """ + + # arrange + user = create_test_owner() + template = create_test_template(user=user, tasks_count=1) + template_task = template.tasks.get(number=1) + fieldset_deleted = create_test_fieldset_template( + account=user.account, + template=template, + task=template_task, + name='Deleted fieldset', + order=0, + ) + FieldsetTemplateTaskTemplate.objects.filter( + fieldset=fieldset_deleted, + task=template_task, + ).delete() + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + task_field_service_init_mock = mocker.patch.object( + TaskFieldService, + '__init__', + return_value=None, + ) + task_field_service_create_mock = mocker.patch( + 'src.processes.services.tasks.field.TaskFieldService.create', + ) + service = TaskService(user=user, instance=task) + + # act + service.create_fields_from_template(instance_template=template_task) + + # assert + task_field_service_init_mock.assert_not_called() + task_field_service_create_mock.assert_not_called() diff --git a/backend/src/processes/tests/test_services/test_tasks/test_task_version_service.py b/backend/src/processes/tests/test_services/test_tasks/test_task_version_service.py index b82d97b32..db1ff225d 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 @@ -2097,3 +2105,925 @@ 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=2) + task_1 = workflow.tasks.get(number=1) + task_2 = workflow.tasks.get(number=2) + old_fieldset = create_test_fieldset( + workflow=workflow, + task=task_1, + ) + service = TaskUpdateVersionService( + user=user, + instance=task_1, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + data = [ + { + 'api_name': 'fieldset-1', + 'name': 'New Fieldset', + 'description': 'Test description', + 'task_links': [ + { + 'task_api_name': task_1.api_name, + 'order': 2, + }, + { + 'task_api_name': task_2.api_name, + 'order': 1, + }, + ], + 'label_position': LabelPosition.TOP, + 'layout': FieldSetLayout.VERTICAL, + 'rules': [ + { + 'api_name': 'rule-1', + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '100', + }, + ], + 'fields': [ + { + 'api_name': 'field-1', + 'name': 'Test Field', + 'description': '', + 'type': FieldType.STRING, + 'is_required': False, + 'is_hidden': False, + 'order': 1, + 'dataset_id': None, + }, + ], + }, + ] + _update_fieldset_rules_mock = mocker.patch( + 'src.processes.services.tasks.task_version.' + 'TaskUpdateVersionService._update_fieldset_rules', + ) + _update_fieldset_fields_mock = mocker.patch( + 'src.processes.services.tasks.task_version.' + 'TaskUpdateVersionService._update_fieldset_fields', + ) + + # act + service._update_fieldsets(data=data) + + # assert + assert not FieldSet.objects.filter(id=old_fieldset.id).exists() + new_fieldset = FieldSet.objects.get( + api_name='fieldset-1', + task=task_1, + ) + assert new_fieldset.name == 'New Fieldset' + assert new_fieldset.description == 'Test description' + assert new_fieldset.order == 2 + _update_fieldset_rules_mock.assert_called_once_with( + fieldset=new_fieldset, + rules_data=data[0]['rules'], + ) + _update_fieldset_fields_mock.assert_called_once_with( + fieldset=new_fieldset, + fields_data=data[0]['fields'], + ) + + +def test__update_field_rules__rules_provided__ok(): + + """ + `field_data` contains a non-empty `rules` list — + matching FieldSetRule is linked to the field via M2M. + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fs-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.first() + field = fieldset.fields.first() + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + field_data = { + 'rules': [ + {'api_name': 'fs-1-rule-1'}, + ], + } + + # act + service._update_field_rules(field, field_data, fieldset) + + # assert + assert field.rules.count() == 1 + assert field.rules.filter(id=rule.id).exists() + + +def test__update_field_rules__rules_empty_list__clear(): + + """ + `field_data['rules']` is an empty list — + existing M2M relations are cleared. + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fs-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.get(api_name='fs-1-rule-1') + field = fieldset.fields.get(api_name='fs-1-field-1') + field.rules.add(rule) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + field_data = {'rules': []} + + # act + service._update_field_rules(field, field_data, fieldset) + + # assert + assert field.rules.count() == 0 + + +def test__update_field_rules__rules_key_missing__clear(): + + """ + `field_data` does not contain `rules` key — + `.get('rules', [])` returns `[]`, M2M is cleared. + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fs-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.get(api_name='fs-1-rule-1') + field = fieldset.fields.get(api_name='fs-1-field-1') + field.rules.add(rule) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + field_data = {} + + # act + service._update_field_rules(field, field_data, fieldset) + + # assert + assert field.rules.count() == 0 + + +def test__update_field_rules__multiple_rules__ok(): + + """ + `field_data` contains multiple rules — + all matching FieldSetRule instances are linked. + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fs-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule_1 = fieldset.rules.get(api_name='fs-1-rule-1') + rule_2 = FieldSetRule.objects.create( + fieldset=fieldset, + account_id=user.account_id, + type=FieldSetRuleType.SUM_EQUAL, + value='200', + api_name='fs-1-rule-2', + ) + field = fieldset.fields.get(api_name='fs-1-field-1') + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + field_data = { + 'rules': [ + {'api_name': 'fs-1-rule-1'}, + {'api_name': 'fs-1-rule-2'}, + ], + } + + # act + service._update_field_rules(field, field_data, fieldset) + + # assert + assert field.rules.count() == 2 + assert field.rules.filter(id=rule_1.id).exists() + assert field.rules.filter(id=rule_2.id).exists() + + +def test__update_field_rules__replaces_existing_rules__ok(): + + """ + Field already has a linked rule — it is replaced by the new one. + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fs-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='50', + ) + old_rule = fieldset.rules.get(api_name='fs-1-rule-1') + new_rule = FieldSetRule.objects.create( + fieldset=fieldset, + account_id=user.account_id, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + api_name='new-rule', + ) + field = fieldset.fields.get(api_name='fs-1-field-1') + field.rules.add(old_rule) + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + field_data = {'rules': [{'api_name': 'new-rule'}]} + + # act + service._update_field_rules(field, field_data, fieldset) + + # assert + assert field.rules.count() == 1 + assert field.rules.filter(id=new_rule.id).exists() + assert not field.rules.filter(id=old_rule.id).exists() + + +def test__update_field_rules__nonexistent_api_name__skip(): + + """ + `api_name` in `field_data` does not match any FieldSetRule — + no rules are found, M2M is set to empty. + """ + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user=user, tasks_count=1) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fs-1', + ) + field = fieldset.fields.get(api_name='fs-1-field-1') + service = TaskUpdateVersionService( + user=user, + instance=task, + auth_type=AuthTokenType.USER, + is_superuser=False, + ) + field_data = {'rules': [{'api_name': 'nonexistent-rule'}]} + + # act + service._update_field_rules(field, field_data, fieldset) + + # assert + assert field.rules.count() == 0 diff --git a/backend/src/processes/tests/test_services/test_tasks/test_taskfield.py b/backend/src/processes/tests/test_services/test_tasks/test_taskfield.py index e07d7162f..84a6c45ba 100644 --- a/backend/src/processes/tests/test_services/test_tasks/test_taskfield.py +++ b/backend/src/processes/tests/test_services/test_tasks/test_taskfield.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model from src.processes.enums import ( + FieldSetRuleType, FieldType, WorkflowEventType, ) @@ -10,8 +11,14 @@ FieldTemplate, FieldTemplateSelection, ) +from src.processes.models.templates.fieldset import ( + FieldsetTemplateRule, +) from src.processes.models.workflows.attachment import FileAttachment from src.processes.models.workflows.event import WorkflowEvent +from src.processes.models.workflows.fieldset import ( + FieldSetRule, +) from src.processes.models.workflows.fields import ( TaskField, FieldSelection, @@ -34,7 +41,10 @@ create_test_owner, create_test_template, create_test_user, - create_test_workflow, create_test_dataset, + create_test_workflow, + create_test_dataset, + create_test_fieldset_template, + create_test_fieldset, ) UserModel = get_user_model() @@ -869,6 +879,10 @@ def test__create_related__file_type_not_skip__ok(mocker): 'src.processes.services.tasks.field.' 'TaskFieldService._create_selections', ) + link_rules_mock = mocker.patch( + 'src.processes.services.tasks.field.' + 'TaskFieldService._link_rules', + ) service = TaskFieldService(instance=task_field, user=user) raw_value = ['123'] @@ -882,6 +896,7 @@ def test__create_related__file_type_not_skip__ok(mocker): # assert link_new_attachments_mock.assert_called_once_with(raw_value) create_selections_mock.assert_not_called() + link_rules_mock.assert_not_called() def test__create_related__file_type_skip__skip(mocker): @@ -917,6 +932,10 @@ def test__create_related__file_type_skip__skip(mocker): 'src.processes.services.tasks.field.' 'TaskFieldService._create_selections', ) + link_rules_mock = mocker.patch( + 'src.processes.services.tasks.field.' + 'TaskFieldService._link_rules', + ) service = TaskFieldService(instance=task_field, user=user) # act @@ -929,6 +948,7 @@ def test__create_related__file_type_skip__skip(mocker): # assert link_new_attachments_mock.assert_not_called() create_selections_mock.assert_not_called() + link_rules_mock.assert_not_called() def test__create_related__selection_type__ok(mocker): @@ -964,6 +984,10 @@ def test__create_related__selection_type__ok(mocker): 'src.processes.services.tasks.field.' 'TaskFieldService._create_selections', ) + link_rules_mock = mocker.patch( + 'src.processes.services.tasks.field.' + 'TaskFieldService._link_rules', + ) service = TaskFieldService(instance=task_field, user=user) # act @@ -974,6 +998,7 @@ def test__create_related__selection_type__ok(mocker): # assert create_selections_mock.assert_called_once_with(field_template) link_new_attachments_mock.assert_not_called() + link_rules_mock.assert_not_called() def test__create_related__other_type__skip(mocker): @@ -1009,6 +1034,10 @@ def test__create_related__other_type__skip(mocker): 'src.processes.services.tasks.field.' 'TaskFieldService._create_selections', ) + link_rules_mock = mocker.patch( + 'src.processes.services.tasks.field.' + 'TaskFieldService._link_rules', + ) service = TaskFieldService(instance=task_field, user=user) # act @@ -1019,6 +1048,59 @@ def test__create_related__other_type__skip(mocker): # assert link_new_attachments_mock.assert_not_called() create_selections_mock.assert_not_called() + link_rules_mock.assert_not_called() + + +def test__create_related__with_rules__ok(mocker): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + task_template = template.tasks.first() + fieldset_template = create_test_fieldset_template( + account=account, + template=template, + task=task_template, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + field_template = fieldset_template.fields.first() + rule_template = fieldset_template.rules.first() + rule_template.fields.add(field_template) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.get(number=1) + task_field = TaskField.objects.create( + task=task, + api_name='string-field-1', + type=FieldType.STRING, + workflow=workflow, + account=account, + ) + link_new_attachments_mock = mocker.patch( + 'src.processes.services.tasks.field.' + 'TaskFieldService._link_new_attachments', + ) + create_selections_mock = mocker.patch( + 'src.processes.services.tasks.field.' + 'TaskFieldService._create_selections', + ) + link_rules_mock = mocker.patch( + 'src.processes.services.tasks.field.' + 'TaskFieldService._link_rules', + ) + service = TaskFieldService(instance=task_field, user=user) + kwargs = {'some': 'data'} + + # act + service._create_related( + instance_template=field_template, + **kwargs, + ) + + # assert + link_new_attachments_mock.assert_not_called() + create_selections_mock.assert_not_called() + link_rules_mock.assert_called_once_with(field_template, **kwargs) def test_partial_update__ok(mocker): @@ -2391,3 +2473,315 @@ def test__get_valid_value__not_required_and_null_value__ok( # assert assert result == FieldData() get_valid_string_value_mock.assert_not_called() + + +def test__link_rules__one_rule__ok(): + + """One template rule → one FieldSetRule linked""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + task_template = template.tasks.first() + fieldset_api_name = 'fs1' + fieldset_template = create_test_fieldset_template( + account=account, + template=template, + task=task_template, + api_name=fieldset_api_name, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + field_template = fieldset_template.fields.first() + rule_template = fieldset_template.rules.first() + rule_template.fields.add(field_template) + + workflow = create_test_workflow( + user=user, + template=template, + ) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name=fieldset_api_name, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + rule = fieldset.rules.first() + task_field = fieldset.fields.first() + + service = TaskFieldService( + instance=task_field, + user=user, + ) + + # act + service._link_rules( + instance_template=field_template, + fieldset_id=fieldset.id, + ) + + # assert + assert task_field.rules.count() == 1 + assert task_field.rules.first() == rule + + +def test__link_rules__multiple_rules__ok(): + + """Two template rules → two FieldSetRules linked""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + task_template = template.tasks.first() + fieldset_api_name = 'fs1' + fieldset_template = create_test_fieldset_template( + account=account, + template=template, + task=task_template, + api_name=fieldset_api_name, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + rule_tmpl_2 = FieldsetTemplateRule.objects.create( + fieldset=fieldset_template, + account=account, + api_name=f'{fieldset_api_name}-rule-2', + type=FieldSetRuleType.SUM_EQUAL, + value='200', + ) + field_template = fieldset_template.fields.first() + rule_tmpl_1 = fieldset_template.rules.get( + api_name=f'{fieldset_api_name}-rule-1', + ) + field_template.rules.set( + [rule_tmpl_1, rule_tmpl_2], + ) + + workflow = create_test_workflow( + user=user, + template=template, + ) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name=fieldset_api_name, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + rule_2 = FieldSetRule.objects.create( + fieldset=fieldset, + account=account, + api_name=f'{fieldset_api_name}-rule-2', + type=FieldSetRuleType.SUM_EQUAL, + value='200', + ) + rule_1 = fieldset.rules.exclude(id=rule_2.id).first() + task_field = fieldset.fields.first() + + service = TaskFieldService( + instance=task_field, + user=user, + ) + + # act + service._link_rules( + instance_template=field_template, + fieldset_id=fieldset.id, + ) + + # assert + assert task_field.rules.count() == 2 + linked_ids = set( + task_field.rules.values_list('id', flat=True), + ) + assert linked_ids == {rule_1.id, rule_2.id} + + +def test__link_rules__partial_match__ok(): + + """Two template rules, only one FieldSetRule exists + — only matched one linked""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + task_template = template.tasks.first() + fieldset_api_name = 'fs1' + fieldset_template = create_test_fieldset_template( + account=account, + template=template, + task=task_template, + api_name=fieldset_api_name, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + rule_tmpl_2 = FieldsetTemplateRule.objects.create( + fieldset=fieldset_template, + account=account, + api_name=f'{fieldset_api_name}-rule-2', + type=FieldSetRuleType.SUM_EQUAL, + value='200', + ) + field_template = fieldset_template.fields.first() + rule_tmpl_1 = fieldset_template.rules.get( + api_name=f'{fieldset_api_name}-rule-1', + ) + field_template.rules.set( + [rule_tmpl_1, rule_tmpl_2], + ) + + workflow = create_test_workflow( + user=user, + template=template, + ) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name=fieldset_api_name, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + rule = fieldset.rules.first() + task_field = fieldset.fields.first() + + service = TaskFieldService( + instance=task_field, + user=user, + ) + + # act + service._link_rules( + instance_template=field_template, + fieldset_id=fieldset.id, + ) + + # assert + assert task_field.rules.count() == 1 + assert task_field.rules.first() == rule + + +def test__link_rules__no_matching_rules__empty(): + + """Template has rule, but no FieldSetRule + with that api_name — M2M stays empty""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + task_template = template.tasks.first() + fieldset_api_name = 'fs1' + fieldset_template = create_test_fieldset_template( + account=account, + template=template, + task=task_template, + api_name=fieldset_api_name, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + field_template = fieldset_template.fields.first() + rule_template = fieldset_template.rules.first() + rule_template.fields.add(field_template) + + workflow = create_test_workflow( + user=user, + template=template, + ) + task = workflow.tasks.get(number=1) + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name=fieldset_api_name, + ) + FieldSetRule.objects.create( + fieldset=fieldset, + account=account, + api_name='different-rule', + type=FieldSetRuleType.SUM_EQUAL, + value='999', + ) + task_field = fieldset.fields.first() + + service = TaskFieldService( + instance=task_field, + user=user, + ) + + # act + service._link_rules( + instance_template=field_template, + fieldset_id=fieldset.id, + ) + + # assert + assert task_field.rules.count() == 0 + + +def test__link_rules__another_fieldset_rule__not_linked(): + + """FieldSetRule has matching api_name + but belongs to another fieldset — not linked""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + task_template = template.tasks.first() + fieldset_api_name = 'fs1' + fieldset_template = create_test_fieldset_template( + account=account, + template=template, + task=task_template, + api_name=fieldset_api_name, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + field_template = fieldset_template.fields.first() + rule_template = fieldset_template.rules.first() + rule_template.fields.add(field_template) + + workflow = create_test_workflow( + user=user, + template=template, + ) + task = workflow.tasks.get(number=1) + fieldset_1 = create_test_fieldset( + workflow=workflow, + task=task, + api_name=fieldset_api_name, + ) + task_field = fieldset_1.fields.first() + create_test_fieldset( + workflow=workflow, + task=task, + api_name='fs2', + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + + service = TaskFieldService( + instance=task_field, + user=user, + ) + + # act + service._link_rules( + instance_template=field_template, + fieldset_id=fieldset_1.id, + ) + + # assert + assert task_field.rules.count() == 0 diff --git a/backend/src/processes/tests/test_services/test_templates/test_ai/test_open_ai_service.py b/backend/src/processes/tests/test_services/test_templates/test_ai/test_open_ai_service.py index b9972fded..18a8c64f5 100644 --- a/backend/src/processes/tests/test_services/test_templates/test_ai/test_open_ai_service.py +++ b/backend/src/processes/tests/test_services/test_templates/test_ai/test_open_ai_service.py @@ -502,6 +502,7 @@ def test_get_template_data__ok(mocker): assert template_data['kickoff'] == { 'description': '', 'fields': [], + 'fieldsets': [], } task_1_data = template_data['tasks'][0] assert task_1_data['number'] == 1 diff --git a/backend/src/processes/tests/test_services/test_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..b0a17287c --- /dev/null +++ b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_rule_service.py @@ -0,0 +1,894 @@ +import pytest +from src.authentication.enums import AuthTokenType +from src.processes.enums import ( + FieldSetRuleType, + FieldType, +) +from src.processes.messages import fieldset as fs_messages +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateRule, +) +from src.processes.models.templates.fields import FieldTemplate +from src.processes.services.exceptions import ( + FieldsetTemplateRuleServiceException, + FieldsetTemplateRuleSumMaxFieldsNotNumber, + FieldsetTemplateRuleSumMaxInvalidValue, +) +from src.processes.services.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', + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + # act + result = service._create_instance( + type=FieldSetRuleType.SUM_EQUAL, + fieldset_id=fieldset.id, + ) + + # assert + assert service.instance is not None + assert result is service.instance + assert service.instance.type == FieldSetRuleType.SUM_EQUAL + assert service.instance.value is None + assert service.instance.fieldset_id == fieldset.id + assert service.instance.account_id == account.id + assert service.instance.api_name + + +def test__create_instance__all_params__ok(): + + """ + Call with all parameters + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + value = '100' + api_name = 'rule-1' + rule_type = FieldSetRuleType.SUM_EQUAL + + # act + service._create_instance( + type=rule_type, + value=value, + api_name=api_name, + fieldset_id=fieldset.id, + ) + + # assert + assert service.instance is not None + assert service.instance.type == rule_type + assert service.instance.value == value + assert service.instance.fieldset_id == fieldset.id + assert service.instance.account_id == account.id + assert service.instance.api_name == api_name + + +def test__validate_sum_equal__valid__ok(): + + """ + Value from kwargs, valid number, all NUMBER fields → ok + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + field = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Number field', + type=FieldType.NUMBER, + api_name='num', + order=1, + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='100.5', + ) + rule.fields.add(field) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + result = service._validate_sum_equal() + + # assert + assert result == 100.5 + + +def test__validate_sum_equal__empty_value__raise_exception(): + + """ + Value is empty → raises SumMaxInvalidValue + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value=None, + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + with pytest.raises(FieldsetTemplateRuleSumMaxInvalidValue) as ex: + service._validate_sum_equal() + + # assert + assert ex.value.message == fs_messages.MSG_FS_0004 + + +def test__validate_sum_equal__non_numeric__raise_exception(): + + """ + Value is non-numeric → raises SumMaxInvalidValue + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='abc', + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + with pytest.raises(FieldsetTemplateRuleSumMaxInvalidValue) as ex: + service._validate_sum_equal() + + # assert + assert ex.value.message == fs_messages.MSG_FS_0004 + + +@pytest.mark.parametrize( + 'field_type', + ( + FieldType.STRING, + FieldType.TEXT, + FieldType.RADIO, + FieldType.CHECKBOX, + FieldType.DATE, + FieldType.URL, + FieldType.DROPDOWN, + FieldType.FILE, + FieldType.USER, + ), +) +def test__validate_sum_equal__non_number_type__raise_exception(field_type): + + """ + Non-NUMBER field exists → raises SumMaxFieldsNotNumber + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + field = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='String field', + type=field_type, + api_name='str_field', + order=1, + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ) + rule.fields.add(field) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + with pytest.raises(FieldsetTemplateRuleSumMaxFieldsNotNumber) as ex: + service._validate_sum_equal() + + # assert + assert ex.value.message == fs_messages.MSG_FS_0003 + + +def test__validate__call_method_by_type__ok(mocker): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + validate_sum_equal_mock = mocker.patch( + 'src.processes.services.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', + ) + field_1_api_name = 'field_1' + field1 = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Field 1', + type=FieldType.STRING, + api_name=field_1_api_name, + order=1, + ) + field_2_api_name = 'field_2' + field2 = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + api_name=field_2_api_name, + order=2, + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + service.instance = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + ) + + # act + result = service._get_valid_fields([field_1_api_name, field_2_api_name]) + + # assert + assert len(result) == 2 + assert field1 in result + assert field2 in result + + +def test_get_valid_fields__type_from_kwargs__ok(): + + """ + rule_type from kwargs + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + field_api_name = 'field_1' + field = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Number field', + type=FieldType.NUMBER, + api_name=field_api_name, + order=1, + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + service.instance = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + ) + + # act + result = service._get_valid_fields( + fields_api_names=[field_api_name], + type=FieldSetRuleType.SUM_EQUAL, + ) + + # assert + assert result == [field] + + +def test_get_valid_fields__type_from_instance__ok(): + + """ + rule_type from instance + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + field_api_name = 'field_1' + field = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Number field', + type=FieldType.NUMBER, + api_name=field_api_name, + order=1, + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + service.instance = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + ) + + # act + result = service._get_valid_fields( + fields_api_names=[field_api_name], + ) + + # assert + assert result == [field] + + +def test_get_valid_fields__one_failed__raise_exception(): + + """ + failed_api_names has 1 element → raises exception + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Number field', + type=FieldType.NUMBER, + api_name='num', + order=1, + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + service.instance = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + ) + + # act + with pytest.raises(FieldsetTemplateRuleServiceException) as ex: + service._get_valid_fields(['missing']) + + # assert + assert ex.value.message == fs_messages.MSG_FS_0005( + rule=FieldSetRuleType.SUM_EQUAL, + field='missing', + ) + + +def test_get_valid_fields__two_failed__raise_exception(): + + """ + failed_api_names has 2 elements → raises exception + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + service.instance = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + ) + + # act + with pytest.raises(FieldsetTemplateRuleServiceException) as ex: + service._get_valid_fields(['missing1', 'missing2']) + + # assert + assert ex.value.message in { + fs_messages.MSG_FS_0005( + rule=FieldSetRuleType.SUM_EQUAL, + field='missing1', + ), fs_messages.MSG_FS_0005( + rule=FieldSetRuleType.SUM_EQUAL, + field='missing2', + ), + } + + +def test_set_fields__fields_provided__set_fields(mocker): + + """ + Non-empty list → fields are set + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + field_api_name = 'num' + field = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Number field', + type=FieldType.NUMBER, + api_name=field_api_name, + order=1, + ) + service = FieldsetTemplateRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + ) + service.instance = rule + get_valid_fields_mock = mocker.patch( + 'src.processes.services.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', + ) + 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', + ) + 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', + ) + 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', + ) + 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..b10ca13e8 --- /dev/null +++ b/backend/src/processes/tests/test_services/test_templates/test_fieldset_template_service.py @@ -0,0 +1,1069 @@ +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' + + # act + service._create_instance( + name=name, + template_id=template.id, + ) + + # assert + assert service.instance is not None + assert service.instance.name == name + assert service.instance.api_name + assert service.instance.template_id == template.id + assert service.instance.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' + description = 'Test description' + label_position = LabelPosition.LEFT + layout = FieldSetLayout.HORIZONTAL + api_name = 'fs-1' + + # act + service._create_instance( + name=name, + template_id=template.id, + description=description, + label_position=label_position, + layout=layout, + api_name=api_name, + ) + + # assert + assert service.instance.name == name + assert service.instance.template_id == template.id + assert service.instance.description == description + assert service.instance.label_position == label_position + assert service.instance.layout == layout + assert service.instance.api_name == api_name + + +def test__create_fields__with_data__ok(mocker): + + """ + Call with fields data + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + fields_data = [ + {'name': 'Field 1', 'type': 'string', 'order': 1}, + {'name': 'Field 2', 'type': 'number', 'order': 2}, + ] + + # mock + field_template_service_init_mock = mocker.patch.object( + FieldTemplateService, + attribute='__init__', + return_value=None, + ) + field_template_service_create_mock = mocker.patch( + 'src.processes.services.templates.field_template.' + 'FieldTemplateService.create', + ) + + # act + service._create_fields(fields_data=fields_data) + + # assert + assert field_template_service_init_mock.call_count == 2 + assert field_template_service_create_mock.call_count == 2 + field_template_service_create_mock.assert_has_calls( + [ + mocker.call( + fieldset_id=fieldset.id, + template_id=template.id, + name='Field 1', + type='string', + order=1, + ), + mocker.call( + fieldset_id=fieldset.id, + template_id=template.id, + name='Field 2', + type='number', + order=2, + ), + ], + any_order=True, + ) + + +def test_create_rules__with_data__ok(mocker): + + """ + Call with rules data + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + rules_data = [ + {'type': FieldSetRuleType.SUM_EQUAL, 'value': '100'}, + ] + + # mock + fieldset_template_rule_service_init_mock = mocker.patch.object( + FieldsetTemplateRuleService, + attribute='__init__', + return_value=None, + ) + fieldset_template_rule_service_create_mock = mocker.patch( + 'src.processes.services.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', + ) + field_1 = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Field 1', + type='string', + order=1, + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + fields_data = [{'api_name': field_1.api_name, 'name': 'Updated Field 1'}] + + # mock + field_template_service_init_mock = mocker.patch.object( + FieldTemplateService, + attribute='__init__', + return_value=None, + ) + field_template_service_partial_update_mock = mocker.patch( + 'src.processes.services.templates.field_template.' + 'FieldTemplateService.partial_update', + ) + field_template_service_create_mock = mocker.patch( + 'src.processes.services.templates.field_template.' + 'FieldTemplateService.create', + ) + + # act + service._update_fields(fields_data=fields_data) + + # assert + field_template_service_init_mock.assert_called_once_with( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=field_1, + ) + field_template_service_partial_update_mock.assert_called_once_with( + name='Updated Field 1', + force_save=True, + ) + field_template_service_create_mock.assert_not_called() + + +def test__update_fields__new_field__ok(mocker): + + """ + Create new field + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=template.kickoff_instance, + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + fields_data = [ + {'name': 'New Field', 'type': 'number', 'order': 1}, + ] + + # mock + create_return = mocker.Mock() + create_return.api_name = 'field_api_name' + field_template_service_init_mock = mocker.patch.object( + FieldTemplateService, + attribute='__init__', + return_value=None, + ) + field_template_service_create_mock = mocker.patch( + 'src.processes.services.templates.field_template.' + 'FieldTemplateService.create', + return_value=create_return, + ) + field_template_service_partial_update_mock = mocker.patch( + 'src.processes.services.templates.field_template.' + 'FieldTemplateService.partial_update', + ) + + # act + service._update_fields(fields_data=fields_data) + + # assert + field_template_service_init_mock.assert_called_once_with( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + field_template_service_create_mock.assert_called_once_with( + fieldset_id=fieldset.id, + template_id=template.id, + name='New Field', + type='number', + order=1, + ) + field_template_service_partial_update_mock.assert_not_called() + + +def test__update_fields__orphan_fields__deleted(mocker): + + """ + Orphan fields deleted + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + field_1 = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Field 1', + type='string', + order=1, + ) + field_2 = FieldTemplate.objects.create( + account=account, + fieldset=fieldset, + name='Field 2', + type='string', + order=2, + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + fields_data = [{'api_name': field_1.api_name, 'name': 'Updated Field 1'}] + + # mock + field_template_service_init_mock = mocker.patch.object( + FieldTemplateService, + attribute='__init__', + return_value=None, + ) + field_template_service_update_mock = mocker.patch( + 'src.processes.services.templates.field_template.' + 'FieldTemplateService.partial_update', + ) + + # act + service._update_fields(fields_data=fields_data) + + # assert + field_template_service_init_mock.assert_called_once() + field_template_service_update_mock.assert_called_once() + assert not FieldTemplate.objects.filter( + id=field_2.id, + ).exists() + assert FieldTemplate.objects.filter(id=field_1.id).exists() + + +def test__validate_rules__with_rules__ok(mocker): + + """ + Call with rules + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + rule_1 = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # mock + fieldset_template_rule_service_init_mock = mocker.patch.object( + FieldsetTemplateRuleService, + attribute='__init__', + return_value=None, + ) + fieldset_template_rule_service_validate_mock = mocker.patch( + 'src.processes.services.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', + ) + 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', + ) + fieldset_template_rule_service_create_mock.assert_not_called() + + +def test_update_rules__new_rule__ok(mocker): + + """ + Create new rule + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + rules_data = [ + {'type': FieldSetRuleType.SUM_EQUAL, 'value': '100'}, + ] + + # mock + create_return = mocker.Mock() + create_return.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', + ) + 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_partial_update_name__ok(mocker): + + """Call `partial_update` with default parameters""" + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + template = create_test_template(user=owner, tasks_count=1) + template.tasks.get(number=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + ) + mock_update_fields = mocker.patch( + 'src.processes.services.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_fields_ok(mocker): + """Verify fields update logic for existing and new fields""" + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + template = create_test_template(user=owner, tasks_count=1) + template.tasks.get(number=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + ) + service = FieldSetTemplateService(user=owner, instance=fieldset) + + mock_update_fields = mocker.patch( + 'src.processes.services.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) + template.tasks.get(number=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + ) + + mock_super_partial_update = mocker.patch( + 'src.generics.base.service.' + 'BaseModelService.partial_update', + ) + mock_update_fields = mocker.patch( + 'src.processes.services.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', + ) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # act + service.delete() + + # assert + assert not FieldsetTemplate.objects.filter(id=fieldset.id).exists() + + +def test_delete__used_by_kickoff_deleted_record__ok(): + + """ + Not in use → deleted + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + kickoff = template.kickoff_instance + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + fieldset.kickoffs.add(kickoff) + fieldset.kickoffs.clear() + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # act + service.delete() + + # assert + assert not FieldsetTemplate.objects.filter(id=fieldset.id).exists() + + +def test_delete__used_by_task_deleted_record__ok(): + + """ + Not in use → deleted + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + task = template.tasks.get(number=1) + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + fieldset.tasks.add(task) + fieldset.tasks.clear() + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # act + service.delete() + + # assert + assert not FieldsetTemplate.objects.filter(id=fieldset.id).exists() + + +def test_delete__used_by_kickoff__raise_exception(): + + """ + 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', + ) + fieldset.kickoffs.add(kickoff) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # act + with pytest.raises(FieldsetTemplateInUseException) as ex: + service.delete() + + # assert + assert ex.value.message == fs_messages.MSG_FS_0001 + assert FieldsetTemplate.objects.filter(id=fieldset.id).exists() + + +def test_delete__used_by_task__raise_exception(): + + """ + In use by task → exception + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + task_template = template.tasks.first() + fieldset = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + fieldset.tasks.add(task_template) + service = FieldSetTemplateService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=fieldset, + ) + + # act + with pytest.raises(FieldsetTemplateInUseException) as ex: + service.delete() + + # assert + assert ex.value.message == fs_messages.MSG_FS_0001 + assert FieldsetTemplate.objects.filter(id=fieldset.id).exists() diff --git a/backend/src/processes/tests/test_services/test_workflows/test_fieldset_rule_service.py b/backend/src/processes/tests/test_services/test_workflows/test_fieldset_rule_service.py new file mode 100644 index 000000000..809e13b41 --- /dev/null +++ b/backend/src/processes/tests/test_services/test_workflows/test_fieldset_rule_service.py @@ -0,0 +1,619 @@ +import pytest + +from src.authentication.enums import AuthTokenType +from src.processes.enums import ( + FieldSetRuleType, + FieldType, +) +from src.processes.messages import fieldset as fs_messages +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateRule, +) +from src.processes.models.workflows.fields import TaskField +from src.processes.services.exceptions import FieldsetServiceException +from src.processes.services.workflows.fieldsets.fieldset_rule import ( + FieldSetRuleService, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_owner, + create_test_template, + create_test_workflow, + create_test_fieldset, +) + +pytestmark = pytest.mark.django_db + + +def test__create_instance__with_template__ok(): + + """ + Call with instance_template and fieldset + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset = create_test_fieldset( + workflow=workflow, + name='Fieldset', + order=1, + ) + fieldset_template = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset tmpl', + ) + rule_template = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset_template, + type=FieldSetRuleType.SUM_EQUAL, + value='100', + ) + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + # act + service._create_instance( + instance_template=rule_template, + fieldset=fieldset, + ) + + # assert + assert service.instance is not None + assert service.instance.fieldset_id == fieldset.id + assert service.instance.type == FieldSetRuleType.SUM_EQUAL + assert service.instance.value == '100' + assert service.instance.api_name == rule_template.api_name + assert service.instance.account_id == account.id + + +def test__validate_sum_equal__within_threshold__ok(): + + """ + Total within threshold + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset = create_test_fieldset( + workflow=workflow, + name='Fieldset', + order=1, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.first() + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 1', + type=FieldType.NUMBER, + value='30', + order=1, + ) + field_1.rules.add(rule) + field_2 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + value='70', + order=2, + ) + field_2.rules.add(rule) + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + result = service._validate_sum_equal( + fieldset=fieldset, + value='100', + ) + + # assert + assert result is True + + +def test__validate_sum_equal__one_not_required_field_blank__not_count(): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset = create_test_fieldset( + workflow=workflow, + name='Fieldset', + order=1, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.first() + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 1', + type=FieldType.NUMBER, + value='', + order=1, + is_required=False, + ) + field_1.rules.add(rule) + field_2 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + value='100', + order=2, + is_required=True, + ) + field_2.rules.add(rule) + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + result = service._validate_sum_equal( + fieldset=fieldset, + value='100', + ) + + # assert + assert result is True + + +def test__validate_sum_equal__all_not_required_fields_blank__not_count(): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset = create_test_fieldset( + workflow=workflow, + name='Fieldset', + order=1, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.first() + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 1', + type=FieldType.NUMBER, + value='', + order=1, + is_required=False, + ) + field_1.rules.add(rule) + field_2 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + value='', + order=2, + is_required=False, + ) + field_2.rules.add(rule) + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + result = service._validate_sum_equal( + fieldset=fieldset, + value='100', + ) + + # assert + assert result is True + + +def test__validate_sum_equal__required_field_blank__raise_exception(): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset = create_test_fieldset( + workflow=workflow, + name='Fieldset', + order=1, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.first() + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 1', + type=FieldType.NUMBER, + value='', + order=1, + is_required=True, + ) + field_1.rules.add(rule) + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + with pytest.raises(FieldsetServiceException) as ex: + service._validate_sum_equal( + fieldset=fieldset, + value='100', + ) + + # assert + assert ex.value.message == fs_messages.MSG_FS_0002(100) + + +def test__validate_sum_equal__negative_value__ok(): + + """ + Total within threshold + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset = create_test_fieldset( + workflow=workflow, + name='Fieldset', + order=1, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='0', + ) + rule = fieldset.rules.first() + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 1', + type=FieldType.NUMBER, + value='30', + order=1, + ) + field_1.rules.add(rule) + field_2 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + value='-30', + order=2, + ) + field_2.rules.add(rule) + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + result = service._validate_sum_equal( + fieldset=fieldset, + value='0', + ) + + # assert + assert result is True + + +def test__validate_sum_equal__exceeds__raise_exception(): + + """ + Total exceeds threshold → exception + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset = create_test_fieldset( + workflow=workflow, + name='Fieldset', + order=1, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.first() + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 1', + type=FieldType.NUMBER, + value='60', + order=1, + ) + field_1.rules.add(rule) + field_2 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + value='50', + order=2, + ) + field_2.rules.add(rule) + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + with pytest.raises(FieldsetServiceException) as ex: + service._validate_sum_equal( + fieldset=fieldset, + value='100', + ) + + # assert + assert ex.value.message == fs_messages.MSG_FS_0002(100) + + +def test__validate_sum_equal__null_values__skip(): + + """ + Fields with null values skipped + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset = create_test_fieldset( + workflow=workflow, + name='Fieldset', + order=1, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule = fieldset.rules.first() + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 1', + type=FieldType.NUMBER, + value='100', + order=1, + ) + field_1.rules.add(rule) + field_2 = TaskField.objects.create( + account=account, + workflow=workflow, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + value='', + order=2, + ) + field_2.rules.add(rule) + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=rule, + ) + + # act + result = service._validate_sum_equal( + fieldset=fieldset, + value='100', + ) + + # assert + assert result is True + + +def test_validate__ok(mocker): + + """ + Known type, validator exists + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user, tasks_count=1) + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=workflow.kickoff_instance, + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + rule = fieldset.rules.first() + service = FieldSetRuleService( + instance=rule, + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + validate_sum_equal_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService._validate_sum_equal', + ) + kwargs = {'some': 'value'} + + # act + service.validate(**kwargs) + + # assert + validate_sum_equal_mock.assert_called_once_with(**kwargs) + + +def test_create__default_params__ok(mocker): + + """ + Call with default params + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + create_instance_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService._create_instance', + ) + create_related_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService._create_related', + ) + create_actions_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService._create_actions', + ) + validate_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService.validate', + ) + instance = mocker.Mock() + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=instance, + ) + kwargs = { + 'some': 'value', + } + + # act + result = service.create(**kwargs) + + # assert + create_instance_mock.assert_called_once_with(**kwargs) + create_related_mock.assert_called_once_with(**kwargs) + create_actions_mock.assert_called_once_with(**kwargs) + validate_mock.assert_not_called() + assert result is instance + + +def test_create__skip_validation_false__ok(mocker): + + """ + Call with skip_validation=False + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + create_instance_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService._create_instance', + ) + create_related_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService._create_related', + ) + create_actions_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService._create_actions', + ) + validate_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService.validate', + ) + instance = mocker.Mock() + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + instance=instance, + ) + kwargs = { + 'skip_validation': False, + } + + # act + result = service.create(**kwargs) + + # assert + create_instance_mock.assert_called_once_with(**kwargs) + create_related_mock.assert_called_once_with(**kwargs) + create_actions_mock.assert_called_once_with(**kwargs) + validate_mock.assert_called_once_with(**kwargs) + assert result is instance + + +def test_partial_update__default_params__ok(mocker): + + """ + Call with default params + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = FieldSetRuleService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + + result_mock = mocker.Mock() + mock_super_partial_update = mocker.patch( + 'src.generics.base.service.' + 'BaseModelService.partial_update', + return_value=result_mock, + ) + validate_mock = mocker.patch( + 'src.processes.services.workflows.fieldsets.fieldset_rule.' + 'FieldSetRuleService.validate', + ) + kwargs = { + 'value': 200, + } + + # act + result = service.partial_update(**kwargs) + + # assert + assert result is result_mock + mock_super_partial_update.assert_called_once_with(**kwargs) + validate_mock.assert_called_once_with(**kwargs) diff --git a/backend/src/processes/tests/test_services/test_workflows/test_fieldset_service.py b/backend/src/processes/tests/test_services/test_workflows/test_fieldset_service.py new file mode 100644 index 000000000..40459b3ee --- /dev/null +++ b/backend/src/processes/tests/test_services/test_workflows/test_fieldset_service.py @@ -0,0 +1,510 @@ +import pytest +from src.authentication.enums import AuthTokenType +from src.processes.enums import ( + FieldSetRuleType, + FieldType, +) +from src.processes.messages.fieldset import MSG_FS_0007 +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateRule, +) +from src.processes.models.templates.fields import FieldTemplate +from src.processes.models.workflows.fieldset import ( + FieldSet, + FieldSetRule, +) +from src.processes.services.exceptions import FieldsetServiceException +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', + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + order = 11 + + # act + service._create_instance( + instance_template=fieldset_template, + workflow=workflow, + kickoff=kickoff, + order=order, + ) + + # assert + 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 == order + + +def test__create_instance__with_task__ok(): + + """ + Call with task + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + task = workflow.tasks.first() + fieldset_template = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + order = 11 + + # act + service._create_instance( + instance_template=fieldset_template, + workflow=workflow, + task=task, + order=order, + ) + + # assert + assert service.instance is not None + assert service.instance.workflow_id == workflow.id + assert service.instance.task_id == task.id + assert service.instance.order == order + assert service.instance.kickoff is None + + +def test__create_instance__no_kickoff_no_task__raise_exception(): + + """ + Call without kickoff and task + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset_template = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + service = FieldSetService( + user=user, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + order = 11 + + # act + with pytest.raises(FieldsetServiceException) as ex: + service._create_instance( + instance_template=fieldset_template, + workflow=workflow, + order=order, + ) + + # assert + assert ex.value.message == MSG_FS_0007 + + +def test__create_fields__default_params__ok(mocker): + + """ + Call with default parameters + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user=user, tasks_count=1) + workflow = create_test_workflow(user=user, template=template) + fieldset_template = FieldsetTemplate.objects.create( + template=template, + account=account, + name='Fieldset', + ) + FieldTemplate.objects.create( + account=account, + fieldset=fieldset_template, + name='Field 1', + type=FieldType.NUMBER, + order=1, + ) + fieldset = 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', + ) + 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', + ) + 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', + ) + 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', + ) + 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..e002caf02 --- /dev/null +++ b/backend/src/processes/tests/test_services/test_workflows/test_kickoff_version_service.py @@ -0,0 +1,1107 @@ +import pytest + +from src.authentication.enums import AuthTokenType +from src.processes.enums import ( + FieldSetLayout, + FieldSetRuleType, + FieldType, + LabelPosition, +) +from src.processes.models.workflows.fields import ( + FieldSelection, + TaskField, +) +from src.processes.models.workflows.fieldset import ( + FieldSet, + FieldSetRule, +) +from src.processes.services.workflows.kickoff_version import ( + KickoffUpdateVersionService, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_fieldset, + create_test_owner, + create_test_template, + create_test_workflow, +) + + +pytestmark = pytest.mark.django_db + + +def test__update_field__no_fieldset__ok(): + + """ + fieldset is None (default) + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + field_data = { + 'api_name': 'field-1', + 'name': 'Test field', + 'description': 'Desc', + 'type': FieldType.STRING, + 'is_required': True, + 'is_hidden': False, + 'order': 1, + 'dataset_id': None, + } + + # act + field, created = service._update_field( + template=field_data, + fieldset=None, + ) + + # assert + assert created is True + assert field.kickoff == kickoff + assert field.api_name == 'field-1' + assert field.name == 'Test field' + assert field.description == 'Desc' + assert field.type == FieldType.STRING + assert field.is_required is True + assert field.is_hidden is False + assert field.order == 1 + assert field.workflow == kickoff.workflow + assert field.account == kickoff.account + assert field.dataset_id is None + assert field.fieldset is None + + +def test__update_field__fieldset__ok(): + + """ + fieldset provided + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + name='Test fieldset', + api_name='fs-1', + ) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + field_data = { + 'api_name': 'field-1', + 'name': 'Number field', + 'description': '', + 'type': FieldType.NUMBER, + 'is_required': False, + 'is_hidden': True, + 'order': 2, + 'dataset_id': None, + } + + # act + field, created = service._update_field( + template=field_data, + fieldset=fieldset, + ) + + # assert + assert created is True + assert field.kickoff == kickoff + assert field.fieldset == fieldset + assert field.api_name == 'field-1' + assert field.name == 'Number field' + assert field.type == FieldType.NUMBER + assert field.is_required is False + assert field.is_hidden is True + assert field.order == 2 + + +def test__update_field_selections__provided__ok(): + + """ + selections provided — creates and deletes stale + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + field = TaskField.objects.create( + kickoff=kickoff, + workflow=workflow, + account=account, + api_name='field-1', + name='Checkbox', + type=FieldType.CHECKBOX, + order=1, + ) + + # stale selection to be deleted + stale_selection = FieldSelection.objects.create( + field=field, + api_name='sel-old', + value='Old value', + ) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + field_data = { + 'selections': [ + { + 'api_name': 'sel-1', + 'value': 'First', + }, + { + 'api_name': 'sel-2', + 'value': 'Second', + }, + ], + } + + # act + service._update_field_selections( + field=field, + field_data=field_data, + ) + + # assert + selections = field.selections.order_by('api_name') + assert selections.count() == 2 + assert selections[0].api_name == 'sel-1' + assert selections[0].value == 'First' + assert selections[1].api_name == 'sel-2' + assert selections[1].value == 'Second' + assert FieldSelection.objects.filter( + id=stale_selection.id, + ).exists() is False + + +def test__update_field_selections__empty__skip(): + + """ + no selections — skips + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + field = TaskField.objects.create( + kickoff=kickoff, + workflow=workflow, + account=account, + api_name='field-1', + name='Text field', + type=FieldType.STRING, + order=1, + ) + existing_selection = FieldSelection.objects.create( + field=field, + api_name='sel-existing', + value='Existing', + ) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + field_data = {} + + # act + service._update_field_selections( + field=field, + field_data=field_data, + ) + + # assert + assert field.selections.count() == 1 + assert field.selections.filter( + id=existing_selection.id, + ).exists() is True + + +def test__update_fieldset_rules__rules_data_is_none__delete_all(): + + """ + rules_data is None — defaults to empty, deletes all + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + name='FS', + api_name='fs-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + existing_rule = fieldset.rules.first() + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + + # act + service._update_fieldset_rules( + fieldset=fieldset, + rules_data=None, + ) + + # assert + assert fieldset.rules.count() == 0 + assert FieldSetRule.objects.filter( + id=existing_rule.id, + ).exists() is False + + +def test__update_fieldset_rules__provided__ok(): + + """ + rules_data provided — creates and deletes stale + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + name='FS', + api_name='fs-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='50', + ) + + # stale rule to be deleted + stale_rule = fieldset.rules.first() + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + rules_data = [ + { + 'api_name': 'rule-1', + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '100', + }, + ] + + # act + service._update_fieldset_rules( + fieldset=fieldset, + rules_data=rules_data, + ) + + # assert + rules = fieldset.rules.all() + assert rules.count() == 1 + assert rules[0].api_name == 'rule-1' + assert rules[0].type == FieldSetRuleType.SUM_EQUAL + assert rules[0].value == '100' + assert rules[0].account_id == account.id + assert FieldSetRule.objects.filter( + id=stale_rule.id, + ).exists() is False + + +def test__update_field_rules__provided__ok(): + + """ + rules provided — links rules + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + name='FS', + api_name='fs-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule_1 = fieldset.rules.first() + field = TaskField.objects.create( + kickoff=kickoff, + workflow=workflow, + account=account, + fieldset=fieldset, + api_name='field-1', + name='Number field', + type=FieldType.NUMBER, + order=1, + ) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + field_data = { + 'rules': [ + {'api_name': rule_1.api_name}, + ], + } + + # act + service._update_field_rules( + field=field, + field_data=field_data, + fieldset=fieldset, + ) + + # assert + assert field.rules.count() == 1 + assert field.rules.filter(id=rule_1.id).exists() is True + + +def test__update_field_rules__empty__clear(): + + """ + no rules — clears rules + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + name='FS', + api_name='fs-1', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + rule_1 = fieldset.rules.first() + field = TaskField.objects.create( + kickoff=kickoff, + workflow=workflow, + account=account, + fieldset=fieldset, + api_name='field-1', + name='Number field', + type=FieldType.NUMBER, + order=1, + ) + field.rules.add(rule_1) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + field_data = {} + + # act + service._update_field_rules( + field=field, + field_data=field_data, + fieldset=fieldset, + ) + + # assert + assert field.rules.count() == 0 + + +def test__update_fields__provided__ok(mocker): + + """ + fields data provided — deletes stale fields + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + field_1 = TaskField.objects.create( + kickoff=kickoff, + workflow=workflow, + account=account, + api_name='field-1', + name='Field 1', + type=FieldType.STRING, + order=1, + ) + + # stale field to be deleted + stale_field = TaskField.objects.create( + kickoff=kickoff, + workflow=workflow, + account=account, + api_name='field-stale', + name='Stale', + type=FieldType.STRING, + order=2, + ) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + field_data_1 = { + 'api_name': 'field-1', + 'name': 'Field 1', + 'description': '', + 'type': FieldType.STRING, + 'is_required': False, + 'is_hidden': False, + 'order': 1, + 'dataset_id': None, + } + data = [field_data_1] + + update_field_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_field', + return_value=(field_1, True), + ) + update_field_selections_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_field_selections', + ) + + # act + service._update_fields(data=data) + + # assert + update_field_mock.assert_called_once_with( + field_data_1, fieldset=None, + ) + update_field_selections_mock.assert_called_once_with( + field_1, field_data_1, + ) + assert TaskField.objects.filter( + id=field_1.id, + ).exists() is True + assert TaskField.objects.filter( + id=stale_field.id, + ).exists() is False + + +def test__update_fs_fields__none__delete_all(mocker): + + """ + fields_data is None — defaults to empty, deletes all + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + name='FS', + api_name='fs-1', + ) + + # existing field to be deleted + existing_field = TaskField.objects.create( + kickoff=kickoff, + workflow=workflow, + account=account, + fieldset=fieldset, + api_name='field-old', + name='Old field', + type=FieldType.STRING, + order=1, + ) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + + update_field_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_field', + ) + update_field_selections_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_field_selections', + ) + update_field_rules_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_field_rules', + ) + + # act + service._update_fieldset_fields( + fieldset=fieldset, + fields_data=None, + ) + + # assert + update_field_mock.assert_not_called() + update_field_selections_mock.assert_not_called() + update_field_rules_mock.assert_not_called() + assert TaskField.objects.filter( + id=existing_field.id, + ).exists() is False + + +def test__update_fs_fields__provided__ok(mocker): + + """ + fields_data provided — deletes stale fields + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + name='FS', + api_name='fs-1', + ) + field_1 = TaskField.objects.create( + kickoff=kickoff, + workflow=workflow, + account=account, + fieldset=fieldset, + api_name='field-1', + name='Field 1', + type=FieldType.STRING, + order=1, + ) + + # stale field to be deleted + stale_field = TaskField.objects.create( + kickoff=kickoff, + workflow=workflow, + account=account, + fieldset=fieldset, + api_name='field-stale', + name='Stale', + type=FieldType.STRING, + order=2, + ) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + field_data_1 = {'api_name': 'field-1'} + fields_data = [field_data_1] + + update_field_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_field', + return_value=(field_1, True), + ) + update_field_selections_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_field_selections', + ) + update_field_rules_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_field_rules', + ) + + # act + service._update_fieldset_fields( + fieldset=fieldset, + fields_data=fields_data, + ) + + # assert + update_field_mock.assert_called_once_with( + field_data_1, fieldset=fieldset, + ) + update_field_selections_mock.assert_called_once_with( + field_1, field_data_1, + ) + update_field_rules_mock.assert_called_once_with( + field_1, field_data_1, fieldset, + ) + assert TaskField.objects.filter( + id=field_1.id, + ).exists() is True + assert TaskField.objects.filter( + id=stale_field.id, + ).exists() is False + + +def test__update_fieldsets__none__delete_all(mocker): + + """ + data is None — defaults to empty, deletes all + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + + # existing fieldset to be deleted + existing_fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + name='FS', + api_name='fs-old', + ) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + + update_fieldset_rules_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fieldset_rules', + ) + update_fieldset_fields_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fieldset_fields', + ) + + # act + service._update_fieldsets(data=None) + + # assert + update_fieldset_rules_mock.assert_not_called() + update_fieldset_fields_mock.assert_not_called() + assert FieldSet.objects.filter( + id=existing_fieldset.id, + ).exists() is False + + +def test__update_fieldsets__provided__ok(mocker): + + """ + data provided — creates and deletes stale + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + + # stale fieldset to be deleted + stale_fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + name='Stale FS', + api_name='fs-stale', + order=1, + ) + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + rules_data_1 = [ + { + 'api_name': 'rule-1', + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '100', + }, + ] + fields_data_1 = [ + {'api_name': 'field-1'}, + ] + data = [ + { + 'api_name': 'fs-1', + 'name': 'Fieldset 1', + 'description': 'Desc', + 'kickoff_links': [ + { + 'order': 11, + }, + ], + 'label_position': LabelPosition.TOP, + 'layout': FieldSetLayout.VERTICAL, + 'rules': rules_data_1, + 'fields': fields_data_1, + }, + ] + + update_fieldset_rules_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fieldset_rules', + ) + update_fieldset_fields_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fieldset_fields', + ) + + # act + service._update_fieldsets(data=data) + + # assert + fieldset = FieldSet.objects.get( + kickoff=kickoff, + api_name='fs-1', + ) + assert fieldset.name == 'Fieldset 1' + assert fieldset.description == 'Desc' + assert fieldset.order == 11 + assert fieldset.label_position == LabelPosition.TOP + assert fieldset.layout == FieldSetLayout.VERTICAL + assert fieldset.account_id == account.id + update_fieldset_rules_mock.assert_called_once_with( + fieldset=fieldset, + rules_data=rules_data_1, + ) + update_fieldset_fields_mock.assert_called_once_with( + fieldset=fieldset, + fields_data=fields_data_1, + ) + assert FieldSet.objects.filter( + id=stale_fieldset.id, + ).exists() is False + + +def test__update_from_version__fields__ok(mocker): + + """ + fields provided + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + fields_data = [{'api_name': 'field-1'}] + data = {'fields': fields_data} + version = 1 + + update_fields_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fields', + ) + update_fieldsets_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fieldsets', + ) + + # act + service.update_from_version( + data=data, + version=version, + ) + + # assert + update_fields_mock.assert_called_once_with( + data=fields_data, + ) + update_fieldsets_mock.assert_not_called() + + +def test__update_from_version__no_fields__skip(mocker): + + """ + fields not provided + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + fieldsets_data = [] + data = {'fieldsets': fieldsets_data} + version = 1 + + update_fields_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fields', + ) + update_fieldsets_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fieldsets', + ) + + # act + service.update_from_version( + data=data, + version=version, + ) + + # assert + update_fields_mock.assert_not_called() + update_fieldsets_mock.assert_called_once_with( + data=fieldsets_data, + ) + + +def test__update_from_version__fieldsets__ok(mocker): + + """ + fieldsets provided (not None) + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + fieldsets_data = [{'api_name': 'fs-1'}] + data = {'fieldsets': fieldsets_data} + version = 1 + + update_fields_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fields', + ) + update_fieldsets_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fieldsets', + ) + + # act + service.update_from_version( + data=data, + version=version, + ) + + # assert + update_fields_mock.assert_not_called() + update_fieldsets_mock.assert_called_once_with( + data=fieldsets_data, + ) + + +def test__update_from_version__no_fieldsets__skip(mocker): + + """ + fieldsets key missing + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template, + ) + kickoff = workflow.kickoff_instance + service = KickoffUpdateVersionService( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + instance=kickoff, + ) + data = {} + version = 1 + + update_fields_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fields', + ) + update_fieldsets_mock = mocker.patch( + 'src.processes.services.workflows.kickoff_version.' + 'KickoffUpdateVersionService._update_fieldsets', + ) + + # act + service.update_from_version( + data=data, + version=version, + ) + + # assert + update_fields_mock.assert_not_called() + update_fieldsets_mock.assert_not_called() diff --git a/backend/src/processes/tests/test_services/test_workflows/test_workflow_version_service.py b/backend/src/processes/tests/test_services/test_workflows/test_workflow_version_service.py index adf846ebf..0e128d168 100644 --- a/backend/src/processes/tests/test_services/test_workflows/test_workflow_version_service.py +++ b/backend/src/processes/tests/test_services/test_workflows/test_workflow_version_service.py @@ -160,7 +160,7 @@ def test_update_from_version__ok(self, mocker): instance=workflow.kickoff_instance, ) kickoff_service_update_mock.assert_called_once_with( - data={'fields': []}, + data={'fields': [], 'fieldsets': []}, version=template_version.version, ) workflow_service_init_mock.assert_called_once_with( diff --git a/backend/src/processes/tests/test_utils/test_get_tasks_parents.py b/backend/src/processes/tests/test_utils/test_get_tasks_parents.py index 59a17d661..355ad9187 100644 --- a/backend/src/processes/tests/test_utils/test_get_tasks_parents.py +++ b/backend/src/processes/tests/test_utils/test_get_tasks_parents.py @@ -10,281 +10,830 @@ ) -def test_get_parents__task_without_conditions__ok(): - - # arrange - task_1_api_name = 'task-1' - task_2_api_name = 'task-2' - tasks_data = [ - { - 'number': 1, - 'name': 'Task 1', - 'api_name': task_1_api_name, - }, - { - 'number': 2, - 'name': 'Task 2', - 'api_name': task_2_api_name, - 'conditions': [ +class TestCompletedTaskOperator: + + def test_get_parents__task_without_conditions__ok(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + condition = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ { - 'order': 1, - 'action': ConditionAction.START_TASK, - 'rules': [ + 'predicates': [ { - 'predicates': [ - { - 'field_type': PredicateType.TASK, - 'operator': PredicateOperator.COMPLETED, - 'api_name': 'predicate-1', - 'field': task_1_api_name, - 'value': None, - }, - ], + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, }, ], }, ], - }, - ] + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [condition], + }, + ] - # act - ancestors = get_tasks_parents(tasks_data) + # act + ancestors = get_tasks_parents(tasks_data) - # assert - assert ancestors[task_1_api_name] == [] - assert ancestors[task_2_api_name] == [task_1_api_name] + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [task_1_api_name] + @pytest.mark.parametrize( + 'cond_action', + (ConditionAction.SKIP_TASK, ConditionAction.END_WORKFLOW), + ) + def test_get_parents__task_with_not_start_conditions__ok( + self, + cond_action, + ): -@pytest.mark.parametrize( - 'cond_action', - (ConditionAction.SKIP_TASK, ConditionAction.END_WORKFLOW), -) -def test_get_parents__task_with_not_start_conditions__ok(cond_action): - - # arrange - task_1_api_name = 'task-1' - task_2_api_name = 'task-2' - tasks_data = [ - { - 'number': 1, - 'name': 'Task 1', - 'api_name': task_1_api_name, - }, - { - 'number': 2, - 'name': 'Task 2', - 'api_name': task_2_api_name, - 'conditions': [ + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + condition = { + 'order': 1, + 'action': cond_action, + 'rules': [ { - 'order': 1, - 'action': cond_action, - 'rules': [ + 'predicates': [ { - 'predicates': [ - { - 'field_type': PredicateType.TASK, - 'operator': PredicateOperator.COMPLETED, - 'api_name': 'predicate-1', - 'field': task_1_api_name, - 'value': None, - }, - ], + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, }, ], }, ], - }, - ] + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [condition], + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) - # act - ancestors = get_tasks_parents(tasks_data) + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [] + + def test_get_parents__two_start_predicates__ok(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + task_3_api_name = 'task-3' + condition = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'field': task_2_api_name, + 'value': None, + }, + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'field': task_3_api_name, + 'value': None, + }, + ], + }, + ], + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + 'conditions': [condition], + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + }, + { + 'number': 3, + 'name': 'Task 3', + 'api_name': task_3_api_name, + }, + ] - # assert - assert ancestors[task_1_api_name] == [] - assert ancestors[task_2_api_name] == [] + # act + ancestors = get_tasks_parents(tasks_data) + # assert + assert ancestors[task_1_api_name] == [task_2_api_name, task_3_api_name] + assert ancestors[task_2_api_name] == [] + assert ancestors[task_3_api_name] == [] -def test_get_parents__two_start_predicates__ok(): + def test_get_parents__linear_template__ok(self): - # arrange - task_1_api_name = 'task-1' - task_2_api_name = 'task-2' - task_3_api_name = 'task-3' - tasks_data = [ - { - 'number': 1, - 'name': 'Task 1', - 'api_name': task_1_api_name, - 'conditions': [ + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + condition_1 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ { - 'order': 1, - 'action': ConditionAction.START_TASK, - 'rules': [ + 'predicates': [ { - 'predicates': [ - { - 'field_type': PredicateType.TASK, - 'operator': PredicateOperator.COMPLETED, - 'field': task_2_api_name, - 'value': None, - }, - { - 'field_type': PredicateType.TASK, - 'operator': PredicateOperator.COMPLETED, - 'field': task_3_api_name, - 'value': None, - }, - ], + 'field_type': PredicateType.KICKOFF, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-1', + 'field': None, + 'value': None, }, ], }, ], - }, - { - 'number': 2, - 'name': 'Task 2', - 'api_name': task_2_api_name, - }, - { - 'number': 3, - 'name': 'Task 3', - 'api_name': task_3_api_name, - }, - ] - - # act - ancestors = get_tasks_parents(tasks_data) - - # assert - assert ancestors[task_1_api_name] == [task_2_api_name, task_3_api_name] - assert ancestors[task_2_api_name] == [] - assert ancestors[task_3_api_name] == [] - - -def test_get_parents__linear_template__ok(): - - # arrange - task_1_api_name = 'task-1' - task_2_api_name = 'task-2' - tasks_data = [ - { - 'number': 1, - 'name': 'Task 1', - 'api_name': task_1_api_name, - 'conditions': [ + } + condition_2 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + 'conditions': [condition_1], + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [condition_2], + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [task_1_api_name] + + def test_get_parents__deleted_task_in_conditions__skip(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + deleted_task_api_name = 'task-3' + condition_1 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-1', + 'field': deleted_task_api_name, + 'value': None, + }, + ], + }, + ], + } + + condition_2 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + 'conditions': [condition_1], + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [condition_2], + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [task_1_api_name] + + +class TestSkippedTaskOperator: + + def test_get_parents__task_without_conditions__ok(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [ + { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.SKIPPED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + }, + ], + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [task_1_api_name] + + @pytest.mark.parametrize( + 'cond_action', + (ConditionAction.SKIP_TASK, ConditionAction.END_WORKFLOW), + ) + def test_get_parents__task_with_not_start_conditions__ok( + self, + cond_action, + ): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [ + { + 'order': 1, + 'action': cond_action, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.SKIPPED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + }, + ], + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [] + + def test_get_parents__two_start_predicates__ok(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + task_3_api_name = 'task-3' + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + 'conditions': [ + { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.SKIPPED, + 'field': task_2_api_name, + 'value': None, + }, + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.SKIPPED, + 'field': task_3_api_name, + 'value': None, + }, + ], + }, + ], + }, + ], + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + }, + { + 'number': 3, + 'name': 'Task 3', + 'api_name': task_3_api_name, + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [task_2_api_name, task_3_api_name] + assert ancestors[task_2_api_name] == [] + assert ancestors[task_3_api_name] == [] + + def test_get_parents__linear_template__ok(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + condition_1 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ { - 'order': 1, - 'action': ConditionAction.START_TASK, - 'rules': [ + 'predicates': [ { - 'predicates': [ - { - 'field_type': PredicateType.KICKOFF, - 'operator': PredicateOperator.COMPLETED, - 'api_name': 'predicate-1', - 'field': None, - 'value': None, - }, - ], + 'field_type': PredicateType.KICKOFF, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-1', + 'field': None, + 'value': None, }, ], }, ], - }, - { - 'number': 2, - 'name': 'Task 2', - 'api_name': task_2_api_name, - 'conditions': [ + } + condition_2 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ { - 'order': 1, - 'action': ConditionAction.START_TASK, - 'rules': [ + 'predicates': [ { - 'predicates': [ - { - 'field_type': PredicateType.TASK, - 'operator': PredicateOperator.COMPLETED, - 'api_name': 'predicate-1', - 'field': task_1_api_name, - 'value': None, - }, - ], + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.SKIPPED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, }, ], }, ], - }, - ] + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + 'conditions': [condition_1], + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [condition_2], + }, + ] - # act - ancestors = get_tasks_parents(tasks_data) + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [task_1_api_name] + + def test_get_parents__deleted_task_in_conditions__skip(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + deleted_task_api_name = 'task-3' + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + 'conditions': [ + { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.SKIPPED, + 'api_name': 'predicate-1', + 'field': deleted_task_api_name, + 'value': None, + }, + ], + }, + ], + }, + ], + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [ + { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.SKIPPED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + }, + ], + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [task_1_api_name] + + +class TestCompletedOrSkippedTaskOperator: + + def test_get_parents__task_without_conditions__ok(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + conditions_1 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED_OR_SKIPPED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [conditions_1], + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [task_1_api_name] + + @pytest.mark.parametrize( + 'cond_action', + (ConditionAction.SKIP_TASK, ConditionAction.END_WORKFLOW), + ) + def test_get_parents__task_with_not_start_conditions__ok( + self, + cond_action, + ): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + condition_1 = { + 'order': 1, + 'action': cond_action, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED_OR_SKIPPED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [condition_1], + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [] + + def test_get_parents__two_start_predicates__ok(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + task_3_api_name = 'task-3' + condition_1 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED_OR_SKIPPED, + 'field': task_2_api_name, + 'value': None, + }, + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED_OR_SKIPPED, + 'field': task_3_api_name, + 'value': None, + }, + ], + }, + ], + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + 'conditions': [condition_1], + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + }, + { + 'number': 3, + 'name': 'Task 3', + 'api_name': task_3_api_name, + }, + ] + + # act + ancestors = get_tasks_parents(tasks_data) + + # assert + assert ancestors[task_1_api_name] == [task_2_api_name, task_3_api_name] + assert ancestors[task_2_api_name] == [] + assert ancestors[task_3_api_name] == [] + + def test_get_parents__linear_template__ok(self): + + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + condition_1 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.KICKOFF, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-1', + 'field': None, + 'value': None, + }, + ], + }, + ], + } + condition_2 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED_OR_SKIPPED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + 'conditions': [condition_1], + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [condition_2], + }, + ] - # assert - assert ancestors[task_1_api_name] == [] - assert ancestors[task_2_api_name] == [task_1_api_name] + # act + ancestors = get_tasks_parents(tasks_data) + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [task_1_api_name] -def test_get_parents__deleted_task_in_conditions__skip(): + def test_get_parents__deleted_task_in_conditions__skip(self): - # arrange - task_1_api_name = 'task-1' - task_2_api_name = 'task-2' - deleted_task_api_name = 'task-3' - tasks_data = [ - { - 'number': 1, - 'name': 'Task 1', - 'api_name': task_1_api_name, - 'conditions': [ + # arrange + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + deleted_task_api_name = 'task-3' + condition_1 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ { - 'order': 1, - 'action': ConditionAction.START_TASK, - 'rules': [ + 'predicates': [ { - 'predicates': [ - { - 'field_type': PredicateType.TASK, - 'operator': PredicateOperator.COMPLETED, - 'api_name': 'predicate-1', - 'field': deleted_task_api_name, - 'value': None, - }, - ], + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED_OR_SKIPPED, + 'api_name': 'predicate-1', + 'field': deleted_task_api_name, + 'value': None, }, ], }, ], - }, - { - 'number': 2, - 'name': 'Task 2', - 'api_name': task_2_api_name, - 'conditions': [ + } + condition_2 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ { - 'order': 1, - 'action': ConditionAction.START_TASK, - 'rules': [ + 'predicates': [ { - 'predicates': [ - { - 'field_type': PredicateType.TASK, - 'operator': PredicateOperator.COMPLETED, - 'api_name': 'predicate-1', - 'field': task_1_api_name, - 'value': None, - }, - ], + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED_OR_SKIPPED, + 'api_name': 'predicate-1', + 'field': task_1_api_name, + 'value': None, }, ], }, ], - }, - ] + } + tasks_data = [ + { + 'number': 1, + 'name': 'Task 1', + 'api_name': task_1_api_name, + 'conditions': [condition_1], + }, + { + 'number': 2, + 'name': 'Task 2', + 'api_name': task_2_api_name, + 'conditions': [condition_2], + }, + ] - # act - ancestors = get_tasks_parents(tasks_data) + # act + ancestors = get_tasks_parents(tasks_data) - # assert - assert ancestors[task_1_api_name] == [] - assert ancestors[task_2_api_name] == [task_1_api_name] + # assert + assert ancestors[task_1_api_name] == [] + assert ancestors[task_2_api_name] == [task_1_api_name] diff --git a/backend/src/processes/tests/test_views/test_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..b5c82af58 --- /dev/null +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_create.py @@ -0,0 +1,923 @@ +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', + '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, + name=data['name'], + description=data['description'], + label_position=data['label_position'], + layout=data['layout'], + api_name=data['api_name'], + ) + field = FieldTemplate.objects.create( + account=account, + template=template, + task=task, + name='Field 1', + type='text', + order=1, + api_name='f1', + fieldset=fieldset, + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='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['template_id'] == template.id + assert response.data['tasks'] == [] + assert response.data['label_position'] == data['label_position'] + assert response.data['layout'] == data['layout'] + assert response.data['api_name'] == data['api_name'] + + 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'], + description=data['description'], + layout=data['layout'], + label_position=data['label_position'], + api_name=data['api_name'], + rules=data['rules'], + fields=data['fields'], + ) + + +def test_create_fieldset__min_data__ok(api_client, mocker): + + """Create fieldset with minimal request data""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + data = { + 'name': 'Minimal Fieldset', + } + + # 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', + ) + 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', + rules=[], + fields=[], + ) + + +def test_create_fieldset__set_api_name__ok(api_client, mocker): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + data = { + 'name': 'Minimal Fieldset', + 'api_name': 'fs1', + } + + # 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', + ) + 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'], + rules=[], + fields=[], + ) + + +def test_create_fieldset__rule_with_one_field__ok(api_client, mocker): + + """ + Create fieldset with fields in rule request and check fields in response + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + template.tasks.first() + field_api_name = 'f1' + data = { + 'name': 'All Fields Fieldset', + 'fields': [ + { + 'name': 'Field 1', + 'type': FieldType.STRING, + 'order': 2, + 'api_name': field_api_name, + }, + { + 'name': 'Field 2', + 'type': FieldType.URL, + 'order': 1, + 'api_name': 'f2', + }, + ], + 'rules': [ + { + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '10', + 'fields': [field_api_name], + }, + ], + } + + # mock FieldSetTemplateService + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + fieldset = FieldsetTemplate.objects.create( + account=account, + template=template, + name=data['name'], + ) + field = FieldTemplate.objects.create( + account=account, + template=template, + api_name=field_api_name, + fieldset=fieldset, + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='10', + ) + rule.fields.add(field) + + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.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, + name=data['name'], + rules=data['rules'], + fields=data['fields'], + ) + + +def test_create_fieldset__rule_with_two_fields__ok(api_client, mocker): + + """ + Create fieldset with fields in rule request and check fields in response + """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + field_1_api_name = 'f1' + field_2_api_name = 'f2' + data = { + 'name': 'All Fields Fieldset', + 'fields': [ + { + 'name': 'Field 1', + 'type': FieldType.STRING, + 'order': 2, + 'api_name': field_1_api_name, + }, + { + 'name': 'Field 2', + 'type': FieldType.URL, + 'order': 1, + 'api_name': field_2_api_name, + }, + ], + 'rules': [ + { + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '10', + 'fields': [field_2_api_name, field_1_api_name], + }, + ], + } + + # mock FieldSetTemplateService + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + fieldset = FieldsetTemplate.objects.create( + account=account, + template=template, + name=data['name'], + ) + field_1 = FieldTemplate.objects.create( + account=account, + template=template, + api_name=field_1_api_name, + fieldset=fieldset, + ) + field_2 = FieldTemplate.objects.create( + account=account, + template=template, + api_name=field_2_api_name, + fieldset=fieldset, + ) + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='10', + ) + rule.fields.set([field_1, field_2]) + + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.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'], + rules=data['rules'], + fields=data['fields'], + ) + + +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', + } + + 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', + } + + 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', + } + 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', + } + + 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', + } + 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', + } + 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__blank_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 = { + } + + 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', + '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', + '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', + } + 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', + rules=[], + fields=[], + ) + + +def test_create_fieldset__not_existing_tpl__not_found(api_client, mocker): + + """ Non-existent template returns 404 """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + nonexistent_id = 999999 + data = { + 'name': 'New Fieldset', + } + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + fieldset_service_create_mock = mocker.patch( + 'src.processes.views.template.FieldSetTemplateService.create', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.post( + f'/templates/{nonexistent_id}/fieldsets', + data=data, + ) + + # assert + assert response.status_code == 404 + fieldset_service_init_mock.assert_not_called() + fieldset_service_create_mock.assert_not_called() diff --git a/backend/src/processes/tests/test_views/test_fieldsets/test_destroy.py b/backend/src/processes/tests/test_views/test_fieldsets/test_destroy.py 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..aea9958b1 --- /dev/null +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_list.py @@ -0,0 +1,861 @@ + +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, +) +from src.processes.models.templates.fieldset import FieldsetTemplate +from src.utils.validation import ErrorCode + +pytestmark = pytest.mark.django_db + + +def test_list_fieldsets__all_data__ok(api_client): + """List fieldsets for existing template""" + + # 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, + name='Kickoff Fieldset', + 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['template_id'] == template.id + assert item_1['layout'] == fieldset.layout + assert item_1['label_position'] == fieldset.label_position + assert item_1['tasks'] == [] + + assert len(item_1['rules']) == 1 + rules_data = item_1['rules'] + assert rules_data[0]['type'] == rule_type + assert rules_data[0]['value'] == rule_value + assert rules_data[0]['api_name'] == rule.api_name + + assert len(item_1['fields']) == 1 + fields_data = item_1['fields'] + assert fields_data[0]['name'] == field.name + assert fields_data[0]['type'] == field.type + assert fields_data[0]['api_name'] == field.api_name + assert fields_data[0]['description'] == '' + assert fields_data[0]['is_required'] is False + assert fields_data[0]['is_hidden'] is False + assert fields_data[0]['default'] == '' + assert 'dataset' not in fields_data[0] + assert 'selections' not in fields_data[0] + + +def test_list_fieldsets__tasks_and_kickoff_fieldset__ok(api_client): + + """List fieldsets for existing template""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=2, + ) + kickoff = template.kickoff_instance + template_task_1 = template.tasks.get(number=1) + template_task_2 = template.tasks.get(number=2) + rule_type = FieldSetRuleType.SUM_EQUAL + rule_value = '10' + fieldset = create_test_fieldset_template( + account=account, + template=template, + name='Kickoff Fieldset', + task=template_task_1, + kickoff=kickoff, + rule_type=rule_type, + rule_value=rule_value, + ) + fieldset.tasks.add(template_task_2) + fieldset.fields.get() + fieldset.rules.get() + + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/fieldsets', + ) + + # assert + assert response.status_code == 200 + data = response.data[0] + assert data['id'] == fieldset.id + assert data['kickoff'] == kickoff.id + assert data['tasks'] == [ + { + 'number': template_task_1.number, + 'name': template_task_1.name, + 'api_name': template_task_1.api_name, + }, + { + 'number': template_task_2.number, + 'name': template_task_2.name, + 'api_name': template_task_2.api_name, + }, + ] + + +def test_list_fieldsets__pagination__ok(api_client): + """List fieldsets for existing template""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + template.tasks.first() + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + ) + create_test_fieldset_template( + account=account, + template=template, + ) + + 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 + + item_2 = response.data['results'][1] + assert item_2['id'] == fieldset_1.id + + +def test_list_fieldsets__different_accounts__ok(api_client): + """List fieldsets filtered by account""" + + # 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, + 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, + ) + + api_client.token_authenticate(user=user_1) + + # act + response = api_client.get( + f'/templates/{template_1.id}/fieldsets', + ) + + # assert + assert response.status_code == 200 + assert len(response.data) == 1 + assert response.data[0]['id'] == fieldset_1.id + + +def test_list_fieldsets__different_templates__ok(api_client): + """List fieldsets filtered by template_id""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template_1 = create_test_template( + user=user, + tasks_count=1, + ) + fieldset_1 = create_test_fieldset_template( + account=account, + template=template_1, + ) + template_2 = create_test_template( + user=user, + tasks_count=1, + ) + create_test_fieldset_template( + account=account, + template=template_2, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template_1.id}/fieldsets', + ) + + # 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, + ) + rule_type = FieldSetRuleType.SUM_EQUAL + rule_value = '10' + fieldset = create_test_fieldset_template( + account=account, + template=template, + name='Kickoff Fieldset', + 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]['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 + + +def test_list_fieldsets__no_ordering__ok(api_client): + + """ No ordering param — default -date_created """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + now = timezone.now() + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + name='Oldest', + ) + FieldsetTemplate.objects.filter(id=fieldset_1.id).update( + date_created=now - timedelta(days=2), + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + name='Middle', + ) + FieldsetTemplate.objects.filter(id=fieldset_2.id).update( + date_created=now - timedelta(days=1), + ) + fieldset_3 = create_test_fieldset_template( + account=account, + template=template, + name='Newest', + ) + FieldsetTemplate.objects.filter(id=fieldset_3.id).update( + date_created=now, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/fieldsets', + ) + + # assert + assert response.status_code == 200 + assert len(response.data) == 3 + item_1 = response.data[0] + assert item_1['id'] == fieldset_3.id + item_2 = response.data[1] + assert item_2['id'] == fieldset_2.id + item_3 = response.data[2] + assert item_3['id'] == fieldset_1.id + + +def test_list_fieldsets__ordering_name_asc__ok(api_client): + + """ ordering=name — ascending by name """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + name='Alpha', + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + name='Beta', + ) + fieldset_3 = create_test_fieldset_template( + account=account, + template=template, + name='Gamma', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/fieldsets', + data={'ordering': 'name'}, + ) + + # assert + assert response.status_code == 200 + assert len(response.data) == 3 + item_1 = response.data[0] + assert item_1['id'] == fieldset_1.id + assert item_1['name'] == 'Alpha' + item_2 = response.data[1] + assert item_2['id'] == fieldset_2.id + assert item_2['name'] == 'Beta' + item_3 = response.data[2] + assert item_3['id'] == fieldset_3.id + assert item_3['name'] == 'Gamma' + + +def test_list_fieldsets__ordering_name_desc__ok(api_client): + + """ ordering=-name — descending by name """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + name='Alpha', + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + name='Beta', + ) + fieldset_3 = create_test_fieldset_template( + account=account, + template=template, + name='Gamma', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/fieldsets', + data={'ordering': '-name'}, + ) + + # assert + assert response.status_code == 200 + assert len(response.data) == 3 + item_1 = response.data[0] + assert item_1['id'] == fieldset_3.id + assert item_1['name'] == 'Gamma' + item_2 = response.data[1] + assert item_2['id'] == fieldset_2.id + assert item_2['name'] == 'Beta' + item_3 = response.data[2] + assert item_3['id'] == fieldset_1.id + assert item_3['name'] == 'Alpha' + + +def test_list_fieldsets__ordering_date_asc__ok(api_client): + + """ ordering=date — ascending by date_created """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + now = timezone.now() + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + name='Oldest', + ) + FieldsetTemplate.objects.filter(id=fieldset_1.id).update( + date_created=now - timedelta(days=2), + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + name='Middle', + ) + FieldsetTemplate.objects.filter(id=fieldset_2.id).update( + date_created=now - timedelta(days=1), + ) + fieldset_3 = create_test_fieldset_template( + account=account, + template=template, + name='Newest', + ) + FieldsetTemplate.objects.filter(id=fieldset_3.id).update( + date_created=now, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/fieldsets', + data={'ordering': 'date'}, + ) + + # assert + assert response.status_code == 200 + assert len(response.data) == 3 + item_1 = response.data[0] + assert item_1['id'] == fieldset_1.id + item_2 = response.data[1] + assert item_2['id'] == fieldset_2.id + item_3 = response.data[2] + assert item_3['id'] == fieldset_3.id + + +def test_list_fieldsets__ordering_date_desc__ok(api_client): + + """ ordering=-date — descending by date_created """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + now = timezone.now() + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + name='Oldest', + ) + FieldsetTemplate.objects.filter(id=fieldset_1.id).update( + date_created=now - timedelta(days=2), + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + name='Middle', + ) + FieldsetTemplate.objects.filter(id=fieldset_2.id).update( + date_created=now - timedelta(days=1), + ) + fieldset_3 = create_test_fieldset_template( + account=account, + template=template, + name='Newest', + ) + FieldsetTemplate.objects.filter(id=fieldset_3.id).update( + date_created=now, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/fieldsets', + data={'ordering': '-date'}, + ) + + # assert + assert response.status_code == 200 + assert len(response.data) == 3 + item_1 = response.data[0] + assert item_1['id'] == fieldset_3.id + item_2 = response.data[1] + assert item_2['id'] == fieldset_2.id + item_3 = response.data[2] + assert item_3['id'] == fieldset_1.id + + +def test_list_fieldsets__no_pagination__ok(api_client): + + """ No pagination params — flat list response """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + create_test_fieldset_template( + account=account, + template=template, + name='First', + ) + create_test_fieldset_template( + account=account, + template=template, + name='Second', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/fieldsets', + ) + + # assert + assert response.status_code == 200 + assert isinstance(response.data, list) + assert len(response.data) == 2 + + +def test_list_fieldsets__ordering_invalid__validation_error( + api_client, +): + + """ Invalid ordering value returns validation error """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + create_test_fieldset_template( + account=account, + template=template, + name='First', + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/fieldsets', + data={'ordering': 'foobar'}, + ) + + # assert + assert response.status_code == 400 + message = '"foobar" is not a valid choice.' + assert response.data['message'] == message + assert response.data['code'] == ErrorCode.VALIDATION_ERROR + + +def test_list_fieldsets__ordering_empty__ok(api_client): + + """ Empty ordering value falls back to default """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + now = timezone.now() + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + name='First', + ) + FieldsetTemplate.objects.filter(id=fieldset_1.id).update( + date_created=now - timedelta(days=1), + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + name='Second', + ) + FieldsetTemplate.objects.filter(id=fieldset_2.id).update( + date_created=now, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/fieldsets', + data={'ordering': ''}, + ) + + # assert + assert response.status_code == 200 + assert len(response.data) == 2 + + # default ordering is -date_created (newest first) + item_1 = response.data[0] + assert item_1['id'] == fieldset_2.id + item_2 = response.data[1] + assert item_2['id'] == fieldset_1.id + + +def test_list_fieldsets__soft_deleted__ok(api_client): + + """ Soft-deleted fieldsets are excluded """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + name='Deleted Fieldset', + ) + FieldsetTemplate.objects.filter(id=fieldset.id).update( + is_deleted=True, + ) + api_client.token_authenticate(user=user) + + # act + response = api_client.get( + f'/templates/{template.id}/fieldsets', + ) + + # assert + assert response.status_code == 200 + assert len(response.data) == 0 diff --git a/backend/src/processes/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..147b207a4 --- /dev/null +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_partial_update.py @@ -0,0 +1,765 @@ +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, + 'layout': FieldSetLayout.HORIZONTAL, + 'label_position': LabelPosition.LEFT, + 'fields': [ + { + 'name': 'Field 1', + 'type': FieldType.TEXT, + 'order': 1, + 'api_name': field_api_name, + }, + ], + 'rules': [ + { + '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'], + label_position=data['label_position'], + layout=data['layout'], + api_name=data['api_name'], + rule_type=FieldSetRuleType.SUM_EQUAL, + ) + field = fieldset.fields.first() + rule = fieldset.rules.first() + rule.fields.add(field) + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + return_value=fieldset, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/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['template_id'] == template.id + assert response.data['tasks'] == [] + assert response.data['label_position'] == data['label_position'] + assert response.data['layout'] == data['layout'] + assert response.data['api_name'] == data['api_name'] + + assert len(response.data['fields']) == 1 + assert response.data['fields'][0]['name'] == field.name + assert response.data['fields'][0]['api_name'] == field.api_name + + assert len(response.data['rules']) == 1 + assert response.data['rules'][0]['type'] == rule.type + assert response.data['rules'][0]['value'] == rule.value + assert response.data['rules'][0]['api_name'] == rule.api_name + assert response.data['rules'][0]['fields'] == [field.api_name] + fieldset_service_init_mock.assert_called_once_with( + user=user, + instance=fieldset, + is_superuser=False, + auth_type=AuthTokenType.USER, + ) + fieldset_partial_update_mock.assert_called_once_with( + name='Full Updated Fieldset', + api_name=fieldset_api_name, + description='Updated description', + layout=FieldSetLayout.HORIZONTAL, + label_position=LabelPosition.LEFT, + rules=data['rules'], + fields=data['fields'], + ) + + +def test_partial_update__name__ok(api_client, mocker): + + """ Partial update with minimal request data """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + ) + 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__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, + ) + field_api_name = 'f1' + data = { + 'name': 'Updated Fieldset', + 'fields': [ + { + 'name': 'Field 1', + 'type': FieldType.STRING, + 'order': 1, + 'api_name': field_api_name, + }, + ], + 'rules': [ + { + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '10', + 'api_name': 'r1', + 'fields': [field_api_name], + }, + ], + } + + # mock FieldSetTemplateService + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + + # Pre-add the field to the fieldset for the mock response verification + field = fieldset.fields.first() + rule = FieldsetTemplateRule.objects.create( + account=account, + fieldset=fieldset, + type=FieldSetRuleType.SUM_EQUAL, + value='val', + api_name='r1', + ) + rule.fields.add(field) + + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + return_value=fieldset, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/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, + ) + data = { + 'name': 'Updated Fieldset', + 'rules': [ + { + 'type': FieldSetRuleType.SUM_EQUAL, + 'value': '10', + 'api_name': 'r1', + 'fields': [], + }, + ], + } + + # mock FieldSetTemplateService + fieldset_service_init_mock = mocker.patch.object( + FieldSetTemplateService, + attribute='__init__', + return_value=None, + ) + fieldset_partial_update_mock = mocker.patch( + 'src.processes.views.fieldset.FieldSetTemplateService.partial_update', + return_value=fieldset, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.patch( + f'/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__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, + ) + 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, + ) + 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, + ) + 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, + ) + 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, + ) + 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, + ) + 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, + ) + 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, + ) + 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, + ) + 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..17a5a2ff4 --- /dev/null +++ b/backend/src/processes/tests/test_views/test_fieldsets/test_retrieve.py @@ -0,0 +1,436 @@ +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, + name='My Fieldset', + description='Fieldset description', + layout=FieldSetLayout.HORIZONTAL, + label_position=LabelPosition.LEFT, + rule_type=rule_type, + rule_value=rule_value, + ) + field = fieldset.fields.get() + rule = fieldset.rules.get() + + 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['template_id'] == template.id + assert response.data['layout'] == FieldSetLayout.HORIZONTAL + assert response.data['label_position'] == LabelPosition.LEFT + assert response.data['kickoff'] is None + assert response.data['tasks'] == [] + + assert len(response.data['rules']) == 1 + rules_data = response.data['rules'] + assert rules_data[0]['type'] == rule_type + assert rules_data[0]['value'] == rule_value + assert rules_data[0]['api_name'] == rule.api_name + + assert len(response.data['fields']) == 1 + fields_data = response.data['fields'] + assert fields_data[0]['name'] == field.name + assert fields_data[0]['type'] == field.type + assert fields_data[0]['api_name'] == field.api_name + assert fields_data[0]['description'] == '' + assert fields_data[0]['is_required'] is False + assert fields_data[0]['is_hidden'] is False + assert fields_data[0]['default'] == '' + assert 'dataset' not in fields_data[0] + assert 'selections' not in fields_data[0] + + +def test_retrieve__kickoff_fieldset__ok(api_client): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + kickoff = template.kickoff_instance + template_task = template.tasks.get(number=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=template_task, + kickoff=kickoff, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/templates/fieldsets/{fieldset.id}') + + # assert + assert response.status_code == 200 + assert response.data['id'] == fieldset.id + assert response.data['kickoff'] == kickoff.id + + +def test_retrieve__used_by_kickoff_deleted_record__empty_kickoff(api_client): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + kickoff = template.kickoff_instance + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + ) + fieldset.kickoffs.clear() + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/templates/fieldsets/{fieldset.id}') + + # assert + assert response.status_code == 200 + assert response.data['id'] == fieldset.id + assert response.data['kickoff'] is None + + +def test_retrieve__task_fieldset__ok(api_client): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=1, + ) + template_task = template.tasks.get(number=1) + create_test_fieldset_template( + account=account, + template=template, + task=template_task, + ) + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=template_task, + ) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/templates/fieldsets/{fieldset.id}') + + # assert + assert response.status_code == 200 + assert response.data['id'] == fieldset.id + assert response.data['tasks'] == [ + { + 'number': template_task.number, + 'name': template_task.name, + 'api_name': template_task.api_name, + }, + ] + + +def test_retrieve__tasks_and_kickoff_fieldset__ok(api_client): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + tasks_count=2, + ) + kickoff = template.kickoff_instance + template_task_1 = template.tasks.get(number=1) + template_task_2 = template.tasks.get(number=2) + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=template_task_1, + kickoff=kickoff, + ) + fieldset.tasks.add(template_task_2) + + api_client.token_authenticate(user=user) + + # act + response = api_client.get(f'/templates/fieldsets/{fieldset.id}') + + # assert + assert response.status_code == 200 + assert response.data['id'] == fieldset.id + assert response.data['kickoff'] == kickoff.id + assert response.data['tasks'] == [ + { + 'number': template_task_1.number, + 'name': template_task_1.name, + 'api_name': template_task_1.api_name, + }, + { + 'number': template_task_2.number, + 'name': template_task_2.name, + 'api_name': template_task_2.api_name, + }, + ] + + +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, + 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]['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, + ) + + # 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, + ) + + 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, + ) + + 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, + ) + + 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, + ) + 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, + ) + + 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_clone/test_fieldsets.py b/backend/src/processes/tests/test_views/test_templates/test_clone/test_fieldsets.py new file mode 100644 index 000000000..18ebad721 --- /dev/null +++ b/backend/src/processes/tests/test_views/test_templates/test_clone/test_fieldsets.py @@ -0,0 +1,378 @@ +import pytest + +from src.processes.enums import ( + FieldSetLayout, + FieldSetRuleType, + FieldType, + LabelPosition, +) +from src.processes.models.templates.fieldset import ( + FieldsetTemplate, + FieldsetTemplateKickoff, + FieldsetTemplateRule, + FieldsetTemplateTaskTemplate, +) +from src.processes.models.templates.fields import ( + FieldTemplate, + FieldTemplateSelection, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_fieldset_template, + create_test_owner, + create_test_template, +) + +pytestmark = pytest.mark.django_db + + +def test_clone__fieldset_copied__ok(api_client): + + """Cloning a template copies its FieldsetTemplate + to the new template with correct attributes.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + api_client.token_authenticate(user) + template = create_test_template(user=user, tasks_count=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + name='My Fieldset', + description='Some description', + label_position=LabelPosition.LEFT, + layout=FieldSetLayout.HORIZONTAL, + ) + + # act + response = api_client.post(f'/templates/{template.id}/clone') + + # assert + assert response.status_code == 200 + new_template_id = response.data['id'] + assert new_template_id != template.id + + field_clones = FieldsetTemplate.objects.filter( + template_id=new_template_id, + ) + assert field_clones.count() == 1 + fieldset_clone = field_clones.first() + assert fieldset_clone.name == fieldset.name + assert fieldset_clone.api_name == fieldset.api_name + assert fieldset_clone.description == fieldset.description + assert fieldset_clone.label_position == fieldset.label_position + assert fieldset_clone.layout == fieldset.layout + assert fieldset_clone.account_id == account.id + + +def test_clone__fieldset_with_fields__ok(api_client): + + """Cloning copies FieldTemplate records + belonging to the fieldset.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + api_client.token_authenticate(user) + template = create_test_template(user=user, tasks_count=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + name='Fieldset with fields', + ) + field_1 = fieldset.fields.first() + # fixture creates one STRING field; add a second one + field_2 = FieldTemplate.objects.create( + template=template, + fieldset=fieldset, + account=account, + name='Second field', + type=FieldType.NUMBER, + order=2, + is_required=False, + is_hidden=True, + ) + + # act + response = api_client.post(f'/templates/{template.id}/clone') + + # assert + assert response.status_code == 200 + new_template_id = response.data['id'] + fieldset_clone = FieldsetTemplate.objects.get(template_id=new_template_id) + field_clones = FieldTemplate.objects.filter( + fieldset=fieldset_clone, + ).order_by('order') + assert field_clones.count() == 2 + + field_1_clone = field_clones[0] + assert field_1_clone.name == field_1.name + assert field_1_clone.api_name == field_1.api_name + assert field_1_clone.type == field_1.type + assert field_1_clone.order == field_1.order + assert field_1_clone.template_id == new_template_id + assert field_1_clone.kickoff is None + assert field_1_clone.task is None + + field_2_clone = field_clones[1] + assert field_2_clone.name == field_2.name + assert field_2_clone.api_name == field_2.api_name + assert field_2_clone.type == field_2.type + assert field_2_clone.order == field_2.order + assert field_2_clone.is_hidden == field_2.is_hidden + + +def test_clone__fieldset_with_selections__ok(api_client): + + """Cloning copies FieldTemplateSelection records + for dropdown fields in a fieldset.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + api_client.token_authenticate(user) + template = create_test_template(user=user, tasks_count=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + name='Fieldset with dropdown', + ) + # Replace the default STRING field with a DROPDOWN + selections + fieldset.fields.all().delete() + field = FieldTemplate.objects.create( + template=template, + fieldset=fieldset, + account=account, + name='Dropdown field', + type=FieldType.DROPDOWN, + order=1, + ) + selection_1 = FieldTemplateSelection.objects.create( + template=template, + field_template=field, + value='Option A', + ) + selection_2 = FieldTemplateSelection.objects.create( + template=template, + field_template=field, + value='Option B', + ) + + # act + response = api_client.post(f'/templates/{template.id}/clone') + + # assert + assert response.status_code == 200 + new_template_id = response.data['id'] + fieldset_clone = FieldsetTemplate.objects.get(template_id=new_template_id) + field_clone = FieldTemplate.objects.get(fieldset=fieldset_clone) + selections_clone = FieldTemplateSelection.objects.filter( + field_template=field_clone, + ).order_by('value') + assert selections_clone.count() == 2 + assert selections_clone[0].value == selection_1.value + assert selections_clone[0].template_id == new_template_id + assert selections_clone[0].api_name == selection_1.api_name + + assert selections_clone[1].value == selection_2.value + assert selections_clone[1].template_id == new_template_id + assert selections_clone[1].api_name == selection_2.api_name + + +def test_clone__fieldset_with_rules__ok(api_client): + + """Cloning copies FieldsetTemplateRule records + and preserves the rule-field M2M relationships.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + api_client.token_authenticate(user) + template = create_test_template(user=user, tasks_count=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + name='Fieldset with rules', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + # fixture creates a NUMBER field + rule; link them via M2M + field = fieldset.fields.first() + rule = fieldset.rules.first() + field.rules.add(rule) + + # act + response = api_client.post(f'/templates/{template.id}/clone') + + # assert + assert response.status_code == 200 + new_template_id = response.data['id'] + fieldset_clone = FieldsetTemplate.objects.get(template_id=new_template_id) + + rules_clone = FieldsetTemplateRule.objects.filter(fieldset=fieldset_clone) + assert rules_clone.count() == 1 + rule_clone = rules_clone.first() + assert rule_clone.type == rule.type + assert rule_clone.value == rule.value + assert rule_clone.id != rule.id + assert rule_clone.api_name == rule.api_name + + field_clone = FieldTemplate.objects.get(fieldset=fieldset_clone) + assert list(field_clone.rules.all()) == [rule_clone] + + +def test_clone__multiple_fieldsets__ok(api_client): + + """Cloning a template with multiple fieldsets + copies all of them.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + api_client.token_authenticate(user) + template = create_test_template(user=user, tasks_count=1) + fs_1 = create_test_fieldset_template( + account=account, + template=template, + name='Fieldset One', + ) + fs_2 = create_test_fieldset_template( + account=account, + template=template, + name='Fieldset Two', + ) + + # act + response = api_client.post(f'/templates/{template.id}/clone') + + # assert + assert response.status_code == 200 + new_template_id = response.data['id'] + fieldset_clones = FieldsetTemplate.objects.filter( + template_id=new_template_id, + ).order_by('name') + assert fieldset_clones.count() == 2 + assert fieldset_clones[0].name == fs_1.name + assert fieldset_clones[0].api_name == fs_1.api_name + assert fieldset_clones[0].fields.count() == 1 + + assert fieldset_clones[1].name == fs_2.name + assert fieldset_clones[1].api_name == fs_2.api_name + assert fieldset_clones[1].fields.count() == 1 + + +def test_clone__no_kickoff_task_links__ok(api_client): + + """Cloning does NOT create FieldsetTemplateKickoff + or FieldsetTemplateTaskTemplate records.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + api_client.token_authenticate(user) + template = create_test_template(user=user, tasks_count=1) + task = template.tasks.first() + kickoff = template.kickoff_instance + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + task=task, + name='Linked fieldset', + ) + # Verify original has links + assert FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset, + ).exists() + assert FieldsetTemplateTaskTemplate.objects.filter( + fieldset=fieldset, + ).exists() + + # act + response = api_client.post(f'/templates/{template.id}/clone') + + # assert + assert response.status_code == 200 + new_template_id = response.data['id'] + fieldset_clone = FieldsetTemplate.objects.get(template_id=new_template_id) + + assert not FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset_clone, + ).exists() + assert not FieldsetTemplateTaskTemplate.objects.filter( + fieldset=fieldset_clone, + ).exists() + + +def test_clone__no_fieldsets__ok(api_client): + + """Cloning a template without fieldsets still works + and creates no fieldsets on the clone.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + api_client.token_authenticate(user) + template = create_test_template(user=user, tasks_count=1) + + # act + response = api_client.post(f'/templates/{template.id}/clone') + + # assert + assert response.status_code == 200 + new_template_id = response.data['id'] + assert not ( + FieldsetTemplate.objects.filter(template_id=new_template_id).exists() + ) + + +def test_clone__fieldset_rule_multi_fields__ok(api_client): + + """Cloning preserves a rule linked to multiple fields + via M2M.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + api_client.token_authenticate(user) + template = create_test_template(user=user, tasks_count=1) + fieldset = create_test_fieldset_template( + account=account, + template=template, + name='Multi-field rule fieldset', + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='200', + ) + field_1 = fieldset.fields.first() + # fixture creates one NUMBER field; add a second one + field_2 = FieldTemplate.objects.create( + template=template, + fieldset=fieldset, + account=account, + name='Amount B', + type=FieldType.NUMBER, + order=2, + ) + rule = fieldset.rules.first() + # Link both fields to the rule + for field in fieldset.fields.all(): + field.rules.add(rule) + + # act + response = api_client.post(f'/templates/{template.id}/clone') + + # assert + assert response.status_code == 200 + new_template_id = response.data['id'] + fieldset_clone = FieldsetTemplate.objects.get(template_id=new_template_id) + + rule_clone = FieldsetTemplateRule.objects.get(fieldset=fieldset_clone) + assert rule_clone.value == rule.value + assert rule_clone.type == rule.type + assert rule_clone.api_name == rule.api_name + + rule_fields = rule_clone.fields.order_by('order') + assert rule_fields.count() == 2 + assert rule_fields[0].api_name == field_1.api_name + assert rule_fields[1].api_name == field_2.api_name diff --git a/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py b/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py index 36124c467..2d73226db 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py +++ b/backend/src/processes/tests/test_views/test_templates/test_create/test_condition_template.py @@ -1944,7 +1944,7 @@ def test_create__predicates_with_equal_api_names__validation_error( assert response.data['details']['api_name'] == predicate_api_name -def test_create__predicate_type_kickoff_completed__ok( +def test_create__start_task_predicate_kickoff_completed__ok( mocker, api_client, ): @@ -2026,7 +2026,107 @@ def test_create__predicate_type_kickoff_completed__ok( assert predicate['field'] is None -def test_create__predicate_type_task__completed__ok( +@pytest.mark.parametrize( + 'operator', + ( + PredicateOperator.SKIPPED, + PredicateOperator.COMPLETED, + PredicateOperator.COMPLETED_OR_SKIPPED, + ), +) +def test_create__start_task_allowed_predicates__ok( + mocker, + operator, + api_client, +): + + # arrange + account = create_test_account() + user = create_test_user(account=account) + mocker.patch( + 'src.processes.serializers.templates.' + 'condition.AnalyticService.templates_task_condition_created', + ) + predicate_api_name = 'predicate-skip' + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + # START_TASK condition with COMPLETED makes task-1 an ancestor of task-2 + start_condition_data = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': operator, + 'api_name': predicate_api_name, + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + + api_client.token_authenticate(user) + + # act + response = api_client.post( + path='/templates', + data={ + 'name': 'Template', + 'is_active': True, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': {}, + 'tasks': [ + { + 'number': 1, + 'name': 'Step 1', + 'api_name': task_1_api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + { + 'number': 2, + 'name': 'Step 2', + 'api_name': task_2_api_name, + 'conditions': [ + start_condition_data, + ], + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + condition = response.data['tasks'][1]['conditions'][0] + predicate = condition['rules'][0]['predicates'][0] + assert predicate['field_type'] == PredicateType.TASK + assert predicate['api_name'] == predicate_api_name + assert predicate['operator'] == operator + assert predicate['value'] is None + assert predicate['field'] == task_1_api_name + + +def test_create__skip_task_predicate_task_skipped__ok( mocker, api_client, ): @@ -2037,10 +2137,12 @@ def test_create__predicate_type_task__completed__ok( 'src.processes.serializers.templates.' 'condition.AnalyticService.templates_task_condition_created', ) - predicate_api_name = 'predicate-1' + predicate_api_name = 'predicate-skip' task_1_api_name = 'task-1' task_2_api_name = 'task-2' - condition_data = { + task_3_api_name = 'task-3' + # START_TASK condition with COMPLETED makes task-1 an ancestor of task-3 + start_condition_data = { 'order': 1, 'action': ConditionAction.START_TASK, 'rules': [ @@ -2049,6 +2151,24 @@ def test_create__predicate_type_task__completed__ok( { 'field_type': PredicateType.TASK, 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-start', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + # SKIP_TASK condition with SKIPPED checks if task-1 was skipped + skip_condition_data = { + 'order': 2, + 'action': ConditionAction.SKIP_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.SKIPPED, 'api_name': predicate_api_name, 'field': task_1_api_name, 'value': None, @@ -2099,7 +2219,21 @@ def test_create__predicate_type_task__completed__ok( 'number': 2, 'name': 'Step 2', 'api_name': task_2_api_name, - 'conditions': [condition_data], + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + { + 'number': 3, + 'name': 'Step 3', + 'api_name': task_3_api_name, + 'conditions': [ + start_condition_data, + skip_condition_data, + ], 'raw_performers': [ { 'type': PerformerType.USER, @@ -2113,15 +2247,248 @@ def test_create__predicate_type_task__completed__ok( # assert assert response.status_code == 200 - condition = response.data['tasks'][1]['conditions'][0] + condition = response.data['tasks'][2]['conditions'][1] predicate = condition['rules'][0]['predicates'][0] assert predicate['field_type'] == PredicateType.TASK assert predicate['api_name'] == predicate_api_name - assert predicate['operator'] == PredicateOperator.COMPLETED + assert predicate['operator'] == PredicateOperator.SKIPPED + assert predicate['value'] is None + assert predicate['field'] == task_1_api_name + + +def test_create__skip_task_predicate_type_task_completed_or_skipped__ok( + mocker, + api_client, +): + # arrange + account = create_test_account(plan=BillingPlanType.UNLIMITED) + user = create_test_user(account=account) + mocker.patch( + 'src.processes.serializers.templates.' + 'condition.AnalyticService.templates_task_condition_created', + ) + predicate_api_name = 'predicate-completed-or-skipped' + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + task_3_api_name = 'task-3' + # START_TASK condition with COMPLETED makes task-1 an ancestor of task-3 + start_condition_data = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'api_name': 'predicate-start', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + # SKIP_TASK condition with COMPLETED_OR_SKIPPED + skip_condition_data = { + 'order': 2, + 'action': ConditionAction.SKIP_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED_OR_SKIPPED, + 'api_name': predicate_api_name, + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.post( + path='/templates', + data={ + 'name': 'Template', + 'is_active': True, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'fields': [ + { + 'order': 1, + 'name': 'First step performer', + 'type': FieldType.USER, + 'api_name': 'user-field-1', + 'is_required': True, + }, + ], + }, + 'tasks': [ + { + 'number': 1, + 'name': 'Step 1', + 'api_name': task_1_api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + { + 'number': 2, + 'name': 'Step 2', + 'api_name': task_2_api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + { + 'number': 3, + 'name': 'Step 3', + 'api_name': task_3_api_name, + 'conditions': [ + start_condition_data, + skip_condition_data, + ], + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + condition = response.data['tasks'][2]['conditions'][1] + predicate = condition['rules'][0]['predicates'][0] + assert predicate['field_type'] == PredicateType.TASK + assert predicate['api_name'] == predicate_api_name + assert predicate['operator'] == PredicateOperator.COMPLETED_OR_SKIPPED assert predicate['value'] is None assert predicate['field'] == task_1_api_name +@pytest.mark.parametrize( + 'operator', + (PredicateOperator.SKIPPED, PredicateOperator.COMPLETED_OR_SKIPPED), +) +def test_create__start_and_skip_condition_on_the_same_task__allowed( + mocker, + operator, + api_client, +): + # arrange + account = create_test_account() + user = create_test_owner(account=account) + mocker.patch( + 'src.processes.serializers.templates.' + 'condition.AnalyticService.templates_task_condition_created', + ) + task_1_api_name = 'task-1' + task_2_api_name = 'task-2' + condition_1 = { + 'order': 1, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': operator, + 'api_name': 'predicate-start', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + condition_2 = { + 'order': 2, + 'action': ConditionAction.SKIP_TASK, + 'rules': [ + { + 'predicates': [ + { + 'field_type': PredicateType.TASK, + 'operator': operator, + 'api_name': 'predicate-skip', + 'field': task_1_api_name, + 'value': None, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.post( + path='/templates', + data={ + 'name': 'Template', + 'is_active': True, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': {}, + 'tasks': [ + { + 'number': 1, + 'name': 'Step 1', + 'api_name': task_1_api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + { + 'number': 2, + 'name': 'Step 2', + 'api_name': task_2_api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'conditions': [ + condition_1, + condition_2, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + + @pytest.mark.parametrize( 'case', ( (PredicateOperator.EQUAL, FieldType.STRING, 'yes'), diff --git a/backend/src/processes/tests/test_views/test_templates/test_create/test_kickoff.py b/backend/src/processes/tests/test_views/test_templates/test_create/test_kickoff.py index 53791313f..fbc832625 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_create/test_kickoff.py +++ b/backend/src/processes/tests/test_views/test_templates/test_create/test_kickoff.py @@ -47,6 +47,7 @@ def test_create__only_required_fields__defaults_ok( 'is_active': True, 'kickoff': { 'fields': [], + 'fieldsets': [], }, 'tasks': [ { @@ -86,6 +87,7 @@ def test_create__all_fields__ok( api_client.token_authenticate(user) request_data = { 'fields': [], + 'fieldsets': [], } # act @@ -217,6 +219,7 @@ def test_create_draft__not_kickoff__ok( assert response.status_code == 200 assert response.data['kickoff'] == { 'fields': [], + 'fieldsets': [], } assert response.data['is_active'] is False kickoff_create_mock.assert_called_once() @@ -262,6 +265,7 @@ def test_create_draft__kickoff_is_null__ok( assert response.status_code == 200 assert response.data['kickoff'] == { 'fields': [], + 'fieldsets': [], } assert response.data['is_active'] is False kickoff_create_mock.assert_called_once() diff --git a/backend/src/processes/tests/test_views/test_templates/test_create/test_template.py b/backend/src/processes/tests/test_views/test_templates/test_create/test_template.py index ccb575808..21408dc43 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_create/test_template.py +++ b/backend/src/processes/tests/test_views/test_templates/test_create/test_template.py @@ -32,6 +32,10 @@ FieldTemplate, FieldTemplateSelection, ) +from src.processes.models.templates.fieldset import ( + FieldsetTemplateKickoff, + FieldsetTemplateTaskTemplate, +) from src.processes.models.templates.raw_performer import RawPerformerTemplate from src.processes.models.templates.task import TaskTemplate from src.processes.models.templates.template import Template @@ -3830,3 +3834,417 @@ def test_create__invalid_wf_name_template__validation_error( assert response.data['message'] == str(messages.MSG_PT_0008) templates_kickoff_created_mock.assert_not_called() templates_created_mock.assert_not_called() + + +def test_create__kickoff_with_one_fieldset__ok( + mocker, + api_client, +): + + """ Creating a template with one fieldset linked to kickoff + calls create_or_update_kickoff_links with correct data. """ + + # arrange + account = create_test_account() + user = create_test_user(account=account) + api_client.token_authenticate(user) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_created', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_created', + ) + service_mock = mocker.patch( + 'src.processes.serializers.templates.template.' + 'FieldSetTemplateService.create_or_update_kickoff_links', + ) + fieldset_api_name = 'fieldset-test-1' + request_data = { + 'name': 'Template with fieldset', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'is_active': True, + 'kickoff': { + 'fieldsets': [ + { + 'api_name': fieldset_api_name, + 'order': 1, + }, + ], + }, + 'tasks': [ + { + 'number': 1, + 'name': 'First step', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + } + + # act + response = api_client.post( + path='/templates', + data=request_data, + ) + + # assert + assert response.status_code == 200 + template = Template.objects.get(id=response.data['id']) + kickoff = template.kickoff_instance + assert kickoff is not None + service_mock.assert_called_once_with( + kickoff=kickoff, + template=template, + fieldsets_links=[ + {'api_name': fieldset_api_name, 'order': 1}, + ], + ) + + +def test_create__kickoff_with_empty_fieldsets__no_links_created( + mocker, + api_client, +): + + """ Creating a template with empty fieldsets list does not + create any FieldsetTemplateKickoff records. """ + + # arrange + account = create_test_account() + user = create_test_user(account=account) + api_client.token_authenticate(user) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_created', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_created', + ) + request_data = { + 'name': 'Template no fieldsets', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'is_active': True, + 'kickoff': { + 'fieldsets': [], + }, + 'tasks': [ + { + 'number': 1, + 'name': 'First step', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + } + + # act + response = api_client.post( + path='/templates', + data=request_data, + ) + + # assert + assert response.status_code == 200 + template = Template.objects.get(id=response.data['id']) + kickoff = template.kickoff_instance + assert FieldsetTemplateKickoff.objects.filter( + kickoff=kickoff, + ).count() == 0 + + +def test_create__kickoff_without_fieldsets_key__no_links_created( + mocker, + api_client, +): + + """ Creating a template without fieldsets key in kickoff does not + create any FieldsetTemplateKickoff records. """ + + # arrange + account = create_test_account() + user = create_test_user(account=account) + api_client.token_authenticate(user) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_created', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_created', + ) + request_data = { + 'name': 'Template no fieldsets key', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'is_active': True, + 'kickoff': {}, + 'tasks': [ + { + 'number': 1, + 'name': 'First step', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + } + + # act + response = api_client.post( + path='/templates', + data=request_data, + ) + + # assert + assert response.status_code == 200 + template = Template.objects.get(id=response.data['id']) + kickoff = template.kickoff_instance + assert FieldsetTemplateKickoff.objects.filter( + kickoff=kickoff, + ).count() == 0 + + +def test_create__task_with_one_fieldset__ok( + mocker, + api_client, +): + + """ Creating a template with one fieldset linked to a task + calls create_or_update_tasks_links with correct data. """ + + # arrange + account = create_test_account() + user = create_test_user(account=account) + api_client.token_authenticate(user) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_created', + ) + service_mock = mocker.patch( + 'src.processes.serializers.templates.task.' + 'FieldSetTemplateService.create_or_update_tasks_links', + ) + fieldset_api_name = 'fieldset-task-1' + request_data = { + 'name': 'Template with task fieldset', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'is_active': True, + 'kickoff': {}, + 'tasks': [ + { + 'number': 1, + 'name': 'First step', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'fieldsets': [ + { + 'api_name': fieldset_api_name, + 'order': 1, + }, + ], + }, + ], + } + + # act + response = api_client.post( + path='/templates', + data=request_data, + ) + + # assert + assert response.status_code == 200 + template = Template.objects.get(id=response.data['id']) + task = template.tasks.first() + assert task is not None + service_mock.assert_called_once_with( + task=task, + template=template, + fieldsets_links=[ + {'api_name': fieldset_api_name, 'order': 1}, + ], + ) + + +def test_create__task_with_empty_fieldsets__no_links_created( + mocker, + api_client, +): + + """ Creating a template with empty fieldsets list in task does not + create any FieldsetTemplateTaskTemplate records. """ + + # arrange + account = create_test_account() + user = create_test_user(account=account) + api_client.token_authenticate(user) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_created', + ) + request_data = { + 'name': 'Template without task fieldsets', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'is_active': True, + 'kickoff': {}, + 'tasks': [ + { + 'number': 1, + 'name': 'First step', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'fieldsets': [], + }, + ], + } + + # act + response = api_client.post( + path='/templates', + data=request_data, + ) + + # assert + assert response.status_code == 200 + template = Template.objects.get(id=response.data['id']) + task = template.tasks.first() + assert FieldsetTemplateTaskTemplate.objects.filter( + task=task, + ).count() == 0 + + +def test_create__task_without_fieldsets_key__no_links_created( + mocker, + api_client, +): + + """ Creating a template without fieldsets key in task does not + create any FieldsetTemplateTaskTemplate records. """ + + # arrange + account = create_test_account() + user = create_test_user(account=account) + api_client.token_authenticate(user) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_created', + ) + request_data = { + 'name': 'Template no fieldsets key', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'is_active': True, + 'kickoff': {}, + 'tasks': [ + { + 'number': 1, + 'name': 'First step', + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + } + + # act + response = api_client.post( + path='/templates', + data=request_data, + ) + + # assert + assert response.status_code == 200 + template = Template.objects.get(id=response.data['id']) + task = template.tasks.first() + assert FieldsetTemplateTaskTemplate.objects.filter( + task=task, + ).count() == 0 diff --git a/backend/src/processes/tests/test_views/test_templates/test_export.py b/backend/src/processes/tests/test_views/test_templates/test_export.py index e5d8d2fa7..880aebc4e 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_export.py +++ b/backend/src/processes/tests/test_views/test_templates/test_export.py @@ -19,6 +19,7 @@ FieldTemplate, FieldTemplateSelection, ) + from src.processes.models.templates.kickoff import Kickoff from src.processes.models.templates.owner import TemplateOwner from src.processes.models.templates.raw_due_date import RawDueDateTemplate @@ -27,6 +28,7 @@ from src.processes.tests.fixtures import ( create_test_account, create_test_admin, + create_test_fieldset_template, create_test_group, create_test_guest, create_test_not_admin, @@ -199,6 +201,57 @@ def test_export__response_format__ok(api_client): assert predicates_template[0]['operator'] == predicate.operator +def test_export__fieldsets__ok(api_client): + # arrange + account = create_test_account() + account_owner = create_test_owner(account=account) + api_client.token_authenticate(account_owner) + template = create_test_template( + user=account_owner, + tasks_count=1, + ) + kickoff = template.kickoff_instance + task = template.tasks.first() + + kickoff_fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Kickoff Fieldset', + description='Kickoff fieldset desc', + api_name='fieldset-kickoff-1', + order=0, + ) + + task_fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + name='Task Fieldset', + description='Task fieldset desc', + api_name='fieldset-task-1', + order=1, + ) + + # act + response = api_client.get('/templates/export') + + # assert + assert response.status_code == 200 + assert len(response.data) == 1 + response_data = response.data[0] + + kickoff_fieldsets = response_data['kickoff']['fieldsets'] + assert len(kickoff_fieldsets) == 1 + assert kickoff_fieldsets[0]['api_name'] == kickoff_fieldset.api_name + assert kickoff_fieldsets[0]['order'] == 0 + + task_fieldsets = response_data['tasks'][0]['fieldsets'] + assert len(task_fieldsets) == 1 + assert task_fieldsets[0]['api_name'] == task_fieldset.api_name + assert task_fieldsets[0]['order'] == 1 + + def test_export__not_auth__permission_denied(api_client): # act response = api_client.get('/templates/export') diff --git a/backend/src/processes/tests/test_views/test_templates/test_fields.py b/backend/src/processes/tests/test_views/test_templates/test_fields.py index fd6322286..9a760958e 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_fields.py +++ b/backend/src/processes/tests/test_views/test_templates/test_fields.py @@ -9,7 +9,10 @@ OwnerType, PerformerType, ) -from src.processes.models.templates.fields import FieldTemplate +from src.processes.models.templates.fields import ( + FieldTemplate, + FieldTemplateSelection, +) from src.processes.models.templates.owner import TemplateOwner from src.processes.models.templates.template import Template from src.processes.models.workflows.task import TaskPerformer @@ -21,6 +24,7 @@ create_test_owner, create_test_template, create_test_workflow, + create_test_fieldset_template, create_test_dataset, ) pytestmark = pytest.mark.django_db @@ -63,6 +67,7 @@ def test_fields__active_template__ok(api_client): data = response.data assert data['id'] == template.id assert len(data['kickoff']['fields']) == 1 + assert data['kickoff']['fieldsets'] == [] field_1_data = data['kickoff']['fields'][0] assert field_1_data['name'] == kickoff_field.name @@ -73,11 +78,11 @@ def test_fields__active_template__ok(api_client): assert len(data['tasks']) == 1 task_data = data['tasks'][0] - assert task_data['id'] == task.id assert task_data['name'] == task.name assert task_data['number'] == task.number assert task_data['api_name'] == task.api_name assert len(task_data['fields']) == 1 + assert task_data['fieldsets'] == [] field_2_data = task_data['fields'][0] assert field_2_data['name'] == task_field.name @@ -156,7 +161,7 @@ def test_fields__draft_template__return_from_db(api_client): # assert assert response.status_code == 200 - assert response.data['kickoff'] == {'fields': []} + assert response.data['kickoff'] == {'fields': [], 'fieldsets': []} assert response.data['tasks'] == [] @@ -520,3 +525,125 @@ def test_fields__ordering__ok(api_client): assert data['tasks'][1]['fields'][0]['order'] == 2 assert data['tasks'][1]['fields'][1]['api_name'] == task_2_field_1.api_name assert data['tasks'][1]['fields'][1]['order'] == 1 + + +def test_fields__kickoff_fieldset__ok(api_client): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user, tasks_count=1, is_active=True) + kickoff = template.kickoff_instance + fieldset_order = 999 + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + order=fieldset_order, + ) + fieldset.fields.delete() + dataset = create_test_dataset(account=account) + field = FieldTemplate.objects.create( + name='First task performer', + description='Some description', + type=FieldType.DROPDOWN, + fieldset=fieldset, + template=template, + order=1, + api_name='field-1', + account=account, + dataset=dataset, + ) + FieldTemplateSelection.objects.create( + field_template=field, + value='Value 1', + template=template, + ) + api_client.token_authenticate(user) + + # act + response = api_client.get(f'/templates/{template.id}/fields') + + # assert + assert response.status_code == 200 + data = response.data + assert data['id'] == template.id + assert len(data['kickoff']['fieldsets']) == 1 + fieldset_data = data['kickoff']['fieldsets'][0] + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + assert fieldset_data['api_name'] == fieldset.api_name + assert fieldset_data['order'] == fieldset_order + assert len(fieldset_data['fields']) == 1 + + field_data = fieldset_data['fields'][0] + assert field_data['name'] == field.name + assert field_data['type'] == field.type + assert field_data['order'] == field.order + assert field_data['description'] == field.description + assert field_data['is_hidden'] == field.is_hidden + assert field_data['api_name'] == field.api_name + assert 'selections' not in field_data + assert 'dataset' not in field_data + + +def test_fields__task_fieldset__ok(api_client): + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template(user, tasks_count=1, is_active=True) + task = template.tasks.get(number=1) + fieldset_order = 888 + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + order=fieldset_order, + ) + fieldset.fields.delete() + dataset = create_test_dataset(account=account) + field = FieldTemplate.objects.create( + name='First task performer', + description='Some description', + type=FieldType.DROPDOWN, + fieldset=fieldset, + template=template, + order=1, + api_name='field-1', + account=account, + dataset=dataset, + ) + FieldTemplateSelection.objects.create( + field_template=field, + value='Value 1', + template=template, + ) + api_client.token_authenticate(user) + + # act + response = api_client.get(f'/templates/{template.id}/fields') + + # assert + assert response.status_code == 200 + data = response.data + assert data['id'] == template.id + assert len(data['tasks'][0]['fieldsets']) == 1 + fieldset_data = data['tasks'][0]['fieldsets'][0] + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + assert fieldset_data['order'] == fieldset_order + assert fieldset_data['api_name'] == fieldset.api_name + assert len(fieldset_data['fields']) == 1 + field_data = fieldset_data['fields'][0] + assert field_data['name'] == field.name + assert field_data['type'] == field.type + assert field_data['order'] == field.order + assert field_data['description'] == field.description + assert field_data['is_hidden'] == field.is_hidden + assert field_data['api_name'] == field.api_name + assert 'selections' not in field_data + assert 'dataset' not in field_data diff --git a/backend/src/processes/tests/test_views/test_templates/test_list.py b/backend/src/processes/tests/test_views/test_templates/test_list.py index a268ed044..0594923df 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_list.py +++ b/backend/src/processes/tests/test_views/test_templates/test_list.py @@ -15,12 +15,16 @@ ) from src.processes.models.templates.fields import FieldTemplate from src.processes.models.templates.fields import FieldTemplateSelection +from src.processes.models.templates.fieldset import ( + FieldsetTemplateKickoff, +) from src.processes.models.templates.owner import TemplateOwner from src.processes.models.templates.template import Template from src.processes.tests.fixtures import ( create_invited_user, create_test_account, create_test_dataset, + create_test_fieldset_template, create_test_group, create_test_owner, create_test_template, @@ -1429,3 +1433,126 @@ def test_list__kickoff_field_is_hidden_true(api_client): assert field_data['api_name'] == field.api_name assert field_data['is_hidden'] is True assert field_data['is_required'] is False + + +def test_list__kickoff_fieldset__ok(api_client): + + """ GET /templates returns kickoff fieldset with all fields. """ + + # arrange + user = create_test_owner() + template = create_test_template( + user=user, + tasks_count=1, + is_active=True, + ) + kickoff = template.kickoff_instance + fieldset = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=kickoff, + name='Personal Info', + description='Enter your personal information', + api_name='fieldset-personal', + order=5, + ) + fieldset_link = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset, + kickoff=kickoff, + ) + fieldset_field = fieldset.fields.first() + api_client.token_authenticate(user) + + # act + response = api_client.get('/templates') + + # assert + assert response.status_code == 200 + fieldsets = response.data[0]['kickoff']['fieldsets'] + assert len(fieldsets) == 1 + fieldset_data = fieldsets[0] + assert fieldset_data['order'] == fieldset_link.order + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['api_name'] == fieldset.api_name + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + assert len(fieldset_data['fields']) == 1 + field_data = fieldset_data['fields'][0] + assert field_data['api_name'] == fieldset_field.api_name + assert field_data['name'] == fieldset_field.name + assert field_data['type'] == fieldset_field.type + assert field_data['order'] == fieldset_field.order + + +def test_list__kickoff_no_fieldsets__empty_list(api_client): + + """ GET /templates returns empty fieldsets list when none exist. """ + + # arrange + user = create_test_owner() + create_test_template( + user=user, + tasks_count=1, + is_active=True, + ) + api_client.token_authenticate(user) + + # act + response = api_client.get('/templates') + + # assert + assert response.status_code == 200 + fieldsets = response.data[0]['kickoff']['fieldsets'] + assert fieldsets == [] + + +def test_list__kickoff_multiple_fieldsets_ordered(api_client): + + """ GET /templates returns kickoff fieldsets ordered by order. """ + + # arrange + user = create_test_owner() + template = create_test_template( + user=user, + tasks_count=1, + is_active=True, + ) + kickoff = template.kickoff_instance + fieldset_2 = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=kickoff, + name='Second Fieldset', + api_name='fieldset-second', + order=2, + ) + link_2 = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset_2, + kickoff=kickoff, + ) + fieldset_1 = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=kickoff, + name='First Fieldset', + api_name='fieldset-first', + order=1, + ) + link_1 = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset_1, + kickoff=kickoff, + ) + api_client.token_authenticate(user) + + # act + response = api_client.get('/templates') + + # assert + assert response.status_code == 200 + fieldsets = response.data[0]['kickoff']['fieldsets'] + assert len(fieldsets) == 2 + assert fieldsets[0]['order'] == link_1.order + assert fieldsets[0]['api_name'] == fieldset_1.api_name + assert fieldsets[1]['order'] == link_2.order + assert fieldsets[1]['api_name'] == fieldset_2.api_name diff --git a/backend/src/processes/tests/test_views/test_templates/test_public/test_retrieve.py b/backend/src/processes/tests/test_views/test_templates/test_public/test_retrieve.py index ae26a7921..ce3f58a08 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_public/test_retrieve.py +++ b/backend/src/processes/tests/test_views/test_templates/test_public/test_retrieve.py @@ -9,8 +9,12 @@ ) from src.processes.models.templates.fields import FieldTemplate, \ FieldTemplateSelection +from src.processes.models.templates.fieldset import ( + FieldsetTemplateKickoff, +) from src.processes.tests.fixtures import ( create_test_template, + create_test_fieldset_template, create_test_owner, create_test_dataset, create_test_account, @@ -419,6 +423,213 @@ def test_retrieve__disable_captcha__ok(self, api_client, mocker): get_template_mock.assert_called_once_with(token) anonymous_user_workflow_exists_mock.assert_not_called() + def test_retrieve__kickoff_fieldset__ok( + self, + api_client, + mocker, + ): + + """ GET /templates/public returns kickoff fieldset. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + is_public=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Personal Info', + description='Enter info', + api_name='fieldset-personal', + order=5, + ) + fieldset_link = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset, + kickoff=kickoff, + ) + fieldset_field = fieldset.fields.first() + auth_header_value = ( + f'Token {template.public_id}' + ) + token = PublicToken(template.public_id) + get_token_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_token', + return_value=token, + ) + get_template_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_template', + return_value=template, + ) + settings_mock = mocker.patch( + 'src.processes.views.public.' + 'template.settings', + ) + settings_mock.PROJECT_CONF = {'CAPTCHA': True} + + # act + response = api_client.get( + path='/templates/public', + **{'X-Public-Authorization': auth_header_value}, + ) + + # assert + assert response.status_code == 200 + fieldsets = response.data['kickoff']['fieldsets'] + assert len(fieldsets) == 1 + fieldset_data = fieldsets[0] + assert fieldset_data['order'] == fieldset_link.order + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['api_name'] == fieldset.api_name + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + assert len(fieldset_data['fields']) == 1 + field_data = fieldset_data['fields'][0] + assert field_data['api_name'] == fieldset_field.api_name + assert field_data['name'] == fieldset_field.name + assert field_data['type'] == fieldset_field.type + assert field_data['order'] == fieldset_field.order + get_token_mock.assert_called_once() + get_template_mock.assert_called_once_with(token) + + def test_retrieve__kickoff_no_fieldsets__ok( + self, + api_client, + mocker, + ): + + """ GET /templates/public returns empty fieldsets. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + is_public=True, + tasks_count=1, + ) + auth_header_value = ( + f'Token {template.public_id}' + ) + token = PublicToken(template.public_id) + get_token_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_token', + return_value=token, + ) + get_template_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_template', + return_value=template, + ) + settings_mock = mocker.patch( + 'src.processes.views.public.' + 'template.settings', + ) + settings_mock.PROJECT_CONF = {'CAPTCHA': True} + + # act + response = api_client.get( + path='/templates/public', + **{'X-Public-Authorization': auth_header_value}, + ) + + # assert + assert response.status_code == 200 + fieldsets = response.data['kickoff']['fieldsets'] + assert fieldsets == [] + get_token_mock.assert_called_once() + get_template_mock.assert_called_once_with(token) + + def test_retrieve__kickoff_fieldsets_ordered( + self, + api_client, + mocker, + ): + + """ GET /templates/public returns ordered fieldsets. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + is_public=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Second Fieldset', + api_name='fieldset-second', + order=2, + ) + link_2 = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset_2, + kickoff=kickoff, + ) + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='First Fieldset', + api_name='fieldset-first', + order=1, + ) + link_1 = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset_1, + kickoff=kickoff, + ) + auth_header_value = ( + f'Token {template.public_id}' + ) + token = PublicToken(template.public_id) + get_token_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_token', + return_value=token, + ) + get_template_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_template', + return_value=template, + ) + settings_mock = mocker.patch( + 'src.processes.views.public.' + 'template.settings', + ) + settings_mock.PROJECT_CONF = {'CAPTCHA': True} + + # act + response = api_client.get( + path='/templates/public', + **{'X-Public-Authorization': auth_header_value}, + ) + + # assert + assert response.status_code == 200 + fieldsets = response.data['kickoff']['fieldsets'] + assert len(fieldsets) == 2 + assert fieldsets[0]['order'] == link_1.order + assert fieldsets[0]['api_name'] == fieldset_1.api_name + assert fieldsets[1]['order'] == link_2.order + assert fieldsets[1]['api_name'] == fieldset_2.api_name + get_token_mock.assert_called_once() + get_template_mock.assert_called_once_with(token) + class TestRetrieveEmbedTemplate: @@ -636,3 +847,210 @@ def test_retrieve__disable_captcha__ok(self, api_client, mocker): get_token_mock.assert_called_once() get_template_mock.assert_called_once_with(token) anonymous_user_workflow_exists_mock.assert_not_called() + + def test_retrieve__kickoff_fieldset__ok( + self, + api_client, + mocker, + ): + + """ GET /templates/public returns embed fieldset. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + is_embedded=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Personal Info', + description='Enter info', + api_name='fieldset-personal', + order=5, + ) + fieldset_link = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset, + kickoff=kickoff, + ) + fieldset_field = fieldset.fields.first() + auth_header_value = ( + f'Token {template.embed_id}' + ) + token = EmbedToken(template.embed_id) + get_token_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_token', + return_value=token, + ) + get_template_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_template', + return_value=template, + ) + settings_mock = mocker.patch( + 'src.processes.views.public.' + 'template.settings', + ) + settings_mock.PROJECT_CONF = {'CAPTCHA': True} + + # act + response = api_client.get( + path='/templates/public', + **{'X-Public-Authorization': auth_header_value}, + ) + + # assert + assert response.status_code == 200 + fieldsets = response.data['kickoff']['fieldsets'] + assert len(fieldsets) == 1 + fieldset_data = fieldsets[0] + assert fieldset_data['order'] == fieldset_link.order + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['api_name'] == fieldset.api_name + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + assert len(fieldset_data['fields']) == 1 + field_data = fieldset_data['fields'][0] + assert field_data['api_name'] == fieldset_field.api_name + assert field_data['name'] == fieldset_field.name + assert field_data['type'] == fieldset_field.type + assert field_data['order'] == fieldset_field.order + get_token_mock.assert_called_once() + get_template_mock.assert_called_once_with(token) + + def test_retrieve__kickoff_no_fieldsets__ok( + self, + api_client, + mocker, + ): + + """ GET /templates/public returns empty embed fieldsets. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + is_embedded=True, + tasks_count=1, + ) + auth_header_value = ( + f'Token {template.embed_id}' + ) + token = EmbedToken(template.embed_id) + get_token_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_token', + return_value=token, + ) + get_template_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_template', + return_value=template, + ) + settings_mock = mocker.patch( + 'src.processes.views.public.' + 'template.settings', + ) + settings_mock.PROJECT_CONF = {'CAPTCHA': True} + + # act + response = api_client.get( + path='/templates/public', + **{'X-Public-Authorization': auth_header_value}, + ) + + # assert + assert response.status_code == 200 + fieldsets = response.data['kickoff']['fieldsets'] + assert fieldsets == [] + get_token_mock.assert_called_once() + get_template_mock.assert_called_once_with(token) + + def test_retrieve__kickoff_fieldsets_ordered( + self, + api_client, + mocker, + ): + + """ GET /templates/public returns ordered embed fieldsets. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + is_embedded=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Second Fieldset', + api_name='fieldset-second', + order=2, + ) + link_2 = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset_2, + kickoff=kickoff, + ) + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='First Fieldset', + api_name='fieldset-first', + order=1, + ) + link_1 = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset_1, + kickoff=kickoff, + ) + auth_header_value = ( + f'Token {template.embed_id}' + ) + token = EmbedToken(template.embed_id) + get_token_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_token', + return_value=token, + ) + get_template_mock = mocker.patch( + 'src.authentication.services.public_auth.' + 'PublicAuthService.get_template', + return_value=template, + ) + settings_mock = mocker.patch( + 'src.processes.views.public.' + 'template.settings', + ) + settings_mock.PROJECT_CONF = {'CAPTCHA': True} + + # act + response = api_client.get( + path='/templates/public', + **{'X-Public-Authorization': auth_header_value}, + ) + + # assert + assert response.status_code == 200 + fieldsets = response.data['kickoff']['fieldsets'] + assert len(fieldsets) == 2 + assert fieldsets[0]['order'] == link_1.order + assert fieldsets[0]['api_name'] == fieldset_1.api_name + assert fieldsets[1]['order'] == link_2.order + assert fieldsets[1]['api_name'] == fieldset_2.api_name + get_token_mock.assert_called_once() + get_template_mock.assert_called_once_with(token) diff --git a/backend/src/processes/tests/test_views/test_templates/test_retrieve.py b/backend/src/processes/tests/test_views/test_templates/test_retrieve.py index 9197bb208..fa77d20e4 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_retrieve.py +++ b/backend/src/processes/tests/test_views/test_templates/test_retrieve.py @@ -27,6 +27,7 @@ from src.processes.tests.fixtures import ( create_invited_user, create_test_account, + create_test_fieldset_template, create_test_group, create_test_not_admin, create_test_owner, @@ -763,3 +764,54 @@ def test_retrieve__not_found__not_found(api_client): # assert assert response.status_code == 404 + + +def test_retrieve__fieldsets__ok(api_client): + # arrange + account = create_test_account() + account_owner = create_test_owner(account=account) + api_client.token_authenticate(account_owner) + template = create_test_template( + user=account_owner, + is_active=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + task = template.tasks.first() + + kickoff_fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + name='Kickoff Fieldset', + description='Kickoff fieldset desc', + api_name='fieldset-kickoff-1', + order=0, + ) + + task_fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + name='Task Fieldset', + description='Task fieldset desc', + api_name='fieldset-task-1', + order=1, + ) + + # act + response = api_client.get(f'/templates/{template.id}') + + # assert + assert response.status_code == 200 + response_data = response.data + + kickoff_fieldsets = response_data['kickoff']['fieldsets'] + assert len(kickoff_fieldsets) == 1 + assert kickoff_fieldsets[0]['api_name'] == kickoff_fieldset.api_name + assert kickoff_fieldsets[0]['order'] == 0 + + task_fieldsets = response_data['tasks'][0]['fieldsets'] + assert len(task_fieldsets) == 1 + assert task_fieldsets[0]['api_name'] == task_fieldset.api_name + assert task_fieldsets[0]['order'] == 1 diff --git a/backend/src/processes/tests/test_views/test_templates/test_run.py b/backend/src/processes/tests/test_views/test_templates/test_run.py index c5fa5484b..dd542d6f6 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_run.py +++ b/backend/src/processes/tests/test_views/test_templates/test_run.py @@ -20,6 +20,7 @@ ConditionAction, DirectlyStatus, DueDateRule, + FieldSetRuleType, FieldType, OwnerRole, OwnerType, @@ -30,6 +31,7 @@ WorkflowStatus, ) from src.processes.messages import workflow as messages +from src.processes.messages.fieldset import MSG_FS_0002 from src.processes.messages.workflow import ( MSG_PW_0028, MSG_PW_0030, @@ -40,6 +42,10 @@ PredicateTemplate, RuleTemplate, ) +from src.processes.models.templates.fieldset import ( + FieldsetTemplateKickoff, + FieldsetTemplateTaskTemplate, +) from src.processes.models.templates.fields import ( FieldTemplate, FieldTemplateSelection, @@ -67,18 +73,22 @@ from src.processes.tests.fixtures import ( create_test_account, create_test_admin, + create_test_dataset, + create_test_fieldset_template, create_test_group, create_test_guest, + create_test_not_admin, create_test_owner, create_test_template, create_test_user, create_test_workflow, create_wf_completed_webhook, - create_wf_created_webhook, create_test_not_admin, create_test_dataset, + create_wf_created_webhook, ) from src.utils.dates import date_format from src.utils.validation import ErrorCode + pytestmark = pytest.mark.django_db @@ -5182,3 +5192,597 @@ def test_run__template_starter_not_admin__ok(mocker, api_client): # assert assert response.status_code == 200 assert Workflow.objects.filter(id=response.data['id']).exists() + + +def test_run__kickoff_with_one_fieldset__ok(mocker, api_client): + + """ Kickoff with one fieldset creates FieldSet. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + fieldset_template = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + name='Personal info', + order=0, + ) + FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset_template, + ).update(order=11) + field_template = fieldset_template.fields.first() + field_value = 'test value' + wf_run_mock = mocker.patch( + 'src.processes.services.workflow_action.' + 'WorkflowEventService.workflow_run_event', + ) + analytics_mock = mocker.patch( + 'src.analysis.services.AnalyticService.' + 'workflows_started', + ) + api_client.token_authenticate(user) + + # act + response = api_client.post( + path=f'/templates/{template.id}/run', + data={ + 'kickoff': { + field_template.api_name: field_value, + }, + }, + ) + + # assert + wf_run_mock.assert_called_once() + analytics_mock.assert_called_once() + assert response.status_code == 200 + workflow = Workflow.objects.get(id=response.data['id']) + kickoff_value = KickoffValue.objects.get(workflow=workflow) + assert kickoff_value.fieldsets.count() == 1 + fieldset = kickoff_value.fieldsets.first() + assert fieldset.name == fieldset_template.name + assert fieldset.api_name == fieldset_template.api_name + fieldsets_data = response.data['kickoff']['fieldsets'] + assert len(fieldsets_data) == 1 + fieldset_data = fieldsets_data[0] + assert fieldset_data['id'] == fieldset.id + assert fieldset_data['api_name'] == fieldset_template.api_name + assert fieldset_data['name'] == fieldset_template.name + assert fieldset_data['description'] == fieldset_template.description + assert fieldset_data['order'] == 11 + assert fieldset_data['label_position'] == fieldset_template.label_position + assert fieldset_data['layout'] == fieldset_template.layout + + assert len(fieldset_data['fields']) == 1 + field_data = fieldset_data['fields'][0] + assert field_data['id'] + assert field_data['api_name'] == field_template.api_name + assert field_data['name'] == field_template.name + assert field_data['type'] == field_template.type + assert field_data['order'] == field_template.order + assert field_data['is_required'] == field_template.is_required + assert field_data['is_hidden'] == field_template.is_hidden + assert field_data['description'] == field_template.description + assert field_data['value'] == field_value + assert field_data['markdown_value'] == field_value + assert field_data['clear_value'] == field_value + assert field_data['user_id'] is None + assert field_data['group_id'] is None + assert field_data['selections'] == [] + assert field_data['attachments'] == [] + + +def test_run__kickoff_with_multiple_fieldsets__ok(mocker, api_client): + + """ Multiple fieldsets created with correct order. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + fieldset_1 = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + name='First fieldset', + order=0, + ) + fieldset_2 = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + name='Second fieldset', + order=1, + ) + field_1 = fieldset_1.fields.first() + field_2 = fieldset_2.fields.first() + field_value_1 = 'value 1' + field_value_2 = 'value 2' + wf_run_mock = mocker.patch( + 'src.processes.services.workflow_action.' + 'WorkflowEventService.workflow_run_event', + ) + analytics_mock = mocker.patch( + 'src.analysis.services.AnalyticService.' + 'workflows_started', + ) + api_client.token_authenticate(user) + + # act + response = api_client.post( + path=f'/templates/{template.id}/run', + data={ + 'kickoff': { + field_1.api_name: field_value_1, + field_2.api_name: field_value_2, + }, + }, + ) + + # assert + wf_run_mock.assert_called_once() + analytics_mock.assert_called_once() + assert response.status_code == 200 + workflow = Workflow.objects.get(id=response.data['id']) + kickoff_value = KickoffValue.objects.get( + workflow=workflow, + ) + assert kickoff_value.fieldsets.count() == 2 + fieldsets_data = response.data['kickoff']['fieldsets'] + assert len(fieldsets_data) == 2 + assert fieldsets_data[0]['name'] == fieldset_2.name + assert fieldsets_data[0]['order'] == 1 + assert fieldsets_data[1]['name'] == fieldset_1.name + assert fieldsets_data[1]['order'] == 0 + + +def test_run__kickoff_fieldset_and_standalone__ok( + mocker, + api_client, +): + + """ Fieldset and standalone field both created. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + fieldset_template = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + name='Grouped fields', + order=0, + ) + field_template_1 = fieldset_template.fields.first() + field_template_2 = FieldTemplate.objects.create( + name='Standalone field', + type=FieldType.STRING, + is_required=False, + kickoff=template.kickoff_instance, + template=template, + account=user.account, + ) + field_value_1 = 'fieldset field value' + field_value_2 = 'standalone field value' + wf_run_mock = mocker.patch( + 'src.processes.services.workflow_action.' + 'WorkflowEventService.workflow_run_event', + ) + analytics_mock = mocker.patch( + 'src.analysis.services.AnalyticService.' + 'workflows_started', + ) + api_client.token_authenticate(user) + + # act + response = api_client.post( + path=f'/templates/{template.id}/run', + data={ + 'kickoff': { + field_template_1.api_name: field_value_1, + field_template_2.api_name: field_value_2, + }, + }, + ) + + # assert + wf_run_mock.assert_called_once() + analytics_mock.assert_called_once() + assert response.status_code == 200 + workflow = Workflow.objects.get(id=response.data['id']) + kickoff_value = workflow.kickoff_instance + assert kickoff_value.output.count() == 1 + assert kickoff_value.output.get(api_name=field_template_2.api_name) + + assert kickoff_value.fieldsets.count() == 1 + fieldset = kickoff_value.fieldsets.get(api_name=fieldset_template.api_name) + assert fieldset.fields.get(api_name=field_template_1.api_name) + + fieldset_fields_data = response.data['kickoff']['fieldsets'][0]['fields'] + assert len(fieldset_fields_data) == 1 + assert fieldset_fields_data[0]['api_name'] == field_template_1.api_name + + fields_data = response.data['kickoff']['output'] + assert len(fields_data) == 1 + assert fields_data[0]['api_name'] == field_template_2.api_name + + +def test_run__kickoff_fieldset_sum_equal__ok( + mocker, + api_client, +): + + """ Fieldset sum_equal rule passes on correct sum. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + fieldset_template = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + name='Budget split', + order=0, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + field_1 = fieldset_template.fields.first() + field_2 = FieldTemplate.objects.create( + name='Second number', + type=FieldType.NUMBER, + fieldset=fieldset_template, + template=template, + order=2, + api_name=( + f'{fieldset_template.api_name}-field-2' + ), + account=user.account, + ) + rule_template = fieldset_template.rules.first() + field_1.rules.add(rule_template) + field_2.rules.add(rule_template) + wf_run_mock = mocker.patch( + 'src.processes.services.workflow_action.' + 'WorkflowEventService.workflow_run_event', + ) + analytics_mock = mocker.patch( + 'src.analysis.services.AnalyticService.' + 'workflows_started', + ) + api_client.token_authenticate(user) + + # act + response = api_client.post( + path=f'/templates/{template.id}/run', + data={ + 'kickoff': { + field_1.api_name: 60, + field_2.api_name: 40, + }, + }, + ) + + # assert + assert response.status_code == 200 + workflow = Workflow.objects.get(id=response.data['id']) + kickoff_value = KickoffValue.objects.get( + workflow=workflow, + ) + fieldset = kickoff_value.fieldsets.first() + assert fieldset.fields.count() == 2 + assert fieldset.rules.count() == 1 + wf_run_mock.assert_called_once() + analytics_mock.assert_called_once() + + +def test_run__kickoff_fieldset_sum_equal__validation_error( + mocker, + api_client, +): + + """ Fieldset sum_equal returns 400 on wrong sum. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + fieldset_template = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + name='Budget split', + order=0, + rule_type=FieldSetRuleType.SUM_EQUAL, + rule_value='100', + ) + field_1 = fieldset_template.fields.first() + field_2 = FieldTemplate.objects.create( + name='Second number', + type=FieldType.NUMBER, + fieldset=fieldset_template, + template=template, + order=2, + api_name=( + f'{fieldset_template.api_name}-field-2' + ), + account=user.account, + ) + rule_template = fieldset_template.rules.first() + field_1.rules.add(rule_template) + field_2.rules.add(rule_template) + wf_run_mock = mocker.patch( + 'src.processes.services.workflow_action.' + 'WorkflowEventService.workflow_run_event', + ) + analytics_mock = mocker.patch( + 'src.analysis.services.AnalyticService.' + 'workflows_started', + ) + api_client.token_authenticate(user) + + # act + response = api_client.post( + path=f'/templates/{template.id}/run', + data={ + 'kickoff': { + field_1.api_name: 60, + field_2.api_name: 50, + }, + }, + ) + + # assert + assert response.status_code == 400 + assert response.data['code'] == ErrorCode.VALIDATION_ERROR + assert response.data['message'] == MSG_FS_0002('100') + wf_run_mock.assert_not_called() + analytics_mock.assert_not_called() + + +def test_run__kickoff_fieldset_required_empty__validation_error( + mocker, + api_client, +): + + """ Required fieldset field returns 400 when empty. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + fieldset_template = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + name='Required fieldset', + order=0, + ) + field_template = fieldset_template.fields.first() + field_template.is_required = True + field_template.save(update_fields=['is_required']) + wf_run_mock = mocker.patch( + 'src.processes.services.workflow_action.' + 'WorkflowEventService.workflow_run_event', + ) + analytics_mock = mocker.patch( + 'src.analysis.services.AnalyticService.' + 'workflows_started', + ) + api_client.token_authenticate(user) + + # act + response = api_client.post( + path=f'/templates/{template.id}/run', + data={ + 'kickoff': {}, + }, + ) + + # assert + wf_run_mock.assert_not_called() + analytics_mock.assert_not_called() + assert response.status_code == 400 + assert response.data['code'] == ErrorCode.VALIDATION_ERROR + assert response.data['message'] == messages.MSG_PW_0023 + assert response.data['details']['api_name'] == field_template.api_name + + +def test_run__kickoff_soft_deleted_fieldset_through__ok( + mocker, + api_client, +): + + """ Soft-deleted FieldsetTemplateKickoff is skipped. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + fieldset_template = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + name='Deleted fieldset', + order=0, + ) + FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset_template, + kickoff=template.kickoff_instance, + ).delete() + mocker.patch( + 'src.processes.services.workflow_action.' + 'WorkflowEventService.workflow_run_event', + ) + mocker.patch( + 'src.analysis.services.AnalyticService.' + 'workflows_started', + ) + api_client.token_authenticate(user) + + # act + response = api_client.post( + path=f'/templates/{template.id}/run', + data={ + 'kickoff': {}, + }, + ) + + # assert + assert response.status_code == 200 + workflow = Workflow.objects.get(id=response.data['id']) + kickoff_value = KickoffValue.objects.get(workflow=workflow) + assert kickoff_value.fieldsets.count() == 0 + assert response.data['kickoff']['fieldsets'] == [] + + +def test_run__kickoff_deleted_fieldset_among_active__ok( + mocker, + api_client, +): + + """ Only active FieldsetTemplateKickoff records produce FieldSets. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + fieldset_deleted = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + name='Deleted fieldset', + order=0, + ) + fieldset_active = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=template.kickoff_instance, + name='Active fieldset', + order=1, + ) + FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset_deleted, + kickoff=template.kickoff_instance, + ).delete() + field_template = fieldset_active.fields.first() + field_value = 'test value' + mocker.patch( + 'src.processes.services.workflow_action.' + 'WorkflowEventService.workflow_run_event', + ) + mocker.patch( + 'src.analysis.services.AnalyticService.' + 'workflows_started', + ) + api_client.token_authenticate(user) + + # act + response = api_client.post( + path=f'/templates/{template.id}/run', + data={ + 'kickoff': { + field_template.api_name: field_value, + }, + }, + ) + + # assert + assert response.status_code == 200 + workflow = Workflow.objects.get(id=response.data['id']) + kickoff_value = KickoffValue.objects.get(workflow=workflow) + assert kickoff_value.fieldsets.count() == 1 + fieldset = kickoff_value.fieldsets.first() + assert fieldset.name == fieldset_active.name + assert fieldset.api_name == fieldset_active.api_name + fieldsets_data = response.data['kickoff']['fieldsets'] + assert len(fieldsets_data) == 1 + assert fieldsets_data[0]['name'] == fieldset_active.name + assert fieldsets_data[0]['order'] == 1 + + +def test_run__task_soft_deleted_fieldset_through__ok( + mocker, + api_client, +): + + """ Soft-deleted FieldsetTemplateTaskTemplate is skipped. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + task_template = template.tasks.first() + fieldset_template = create_test_fieldset_template( + account=user.account, + template=template, + task=task_template, + name='Deleted task fieldset', + order=0, + ) + FieldsetTemplateTaskTemplate.objects.filter( + fieldset=fieldset_template, + task=task_template, + ).delete() + mocker.patch( + 'src.processes.services.workflow_action.' + 'WorkflowEventService.workflow_run_event', + ) + mocker.patch( + 'src.analysis.services.AnalyticService.' + 'workflows_started', + ) + api_client.token_authenticate(user) + + # act + response = api_client.post( + path=f'/templates/{template.id}/run', + data={ + 'kickoff': {}, + }, + ) + + # assert + assert response.status_code == 200 + workflow = Workflow.objects.get(id=response.data['id']) + task = workflow.tasks.first() + assert task.fieldsets.count() == 0 diff --git a/backend/src/processes/tests/test_views/test_templates/test_steps.py b/backend/src/processes/tests/test_views/test_templates/test_steps.py index 18bbcd0d7..d43fc5a9b 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_steps.py +++ b/backend/src/processes/tests/test_views/test_templates/test_steps.py @@ -95,7 +95,7 @@ def test_steps__admin__ok(api_client): # assert assert len(response.data) == 1 assert response.data[0]['number'] == 1 - assert response.data[0]['id'] == template_task.id + assert response.data[0]['name'] == template_task.name assert response.data[0]['api_name'] == template_task.api_name @@ -126,7 +126,7 @@ def test_steps__not_admin__ok(api_client): # assert assert len(response.data) == 1 assert response.data[0]['number'] == 1 - assert response.data[0]['id'] == template_task.id + assert response.data[0]['name'] == template_task.name assert response.data[0]['api_name'] == template_task.api_name @@ -161,7 +161,7 @@ def test_steps__group_is_template_owner__ok(api_client): assert response.status_code == 200 assert len(response.data) == 1 assert response.data[0]['number'] == template_task.number - assert response.data[0]['id'] == template_task.id + assert response.data[0]['name'] == template_task.name assert response.data[0]['api_name'] == template_task.api_name @@ -498,7 +498,7 @@ def test_steps__with_tasks_in_progress_true__another_task_completed__ok( assert response.status_code == 200 assert len(response.data) == 1 assert response.data[0]['number'] == template_task_2.number - assert response.data[0]['id'] == template_task_2.id + assert response.data[0]['name'] == template_task_2.name assert response.data[0]['api_name'] == template_task_2.api_name @@ -535,7 +535,7 @@ def test_steps__with_tasks_in_progress_true__not_template_owner__ok( # assert assert response.status_code == 200 assert len(response.data) == 1 - assert response.data[0]['id'] == template_task.id + assert response.data[0]['name'] == template_task.name assert response.data[0]['number'] == template_task.number assert response.data[0]['api_name'] == template_task.api_name @@ -576,7 +576,7 @@ def test_steps__with_tasks_in_progress_true__performer_group__ok( assert response.status_code == 200 assert len(response.data) == 1 assert response.data[0]['number'] == 1 - assert response.data[0]['id'] == template_task_1.id + assert response.data[0]['name'] == template_task_1.name assert response.data[0]['api_name'] == template_task_1.api_name @@ -694,7 +694,7 @@ def test_steps__with_tasks_in_progress_false__completed_task__ok( assert response.status_code == 200 assert len(response.data) == 1 assert response.data[0]['number'] == template_task.number - assert response.data[0]['id'] == template_task.id + assert response.data[0]['name'] == template_task.name assert response.data[0]['api_name'] == template_task.api_name @@ -771,7 +771,7 @@ def test_steps__with_tasks_in_progress_false__performer_group__ok( assert response.status_code == 200 assert len(response.data) == 1 assert response.data[0]['number'] == 1 - assert response.data[0]['id'] == template_task_1.id + assert response.data[0]['name'] == template_task_1.name assert response.data[0]['api_name'] == template_task_1.api_name diff --git a/backend/src/processes/tests/test_views/test_templates/test_titles_by_owners.py b/backend/src/processes/tests/test_views/test_templates/test_titles_by_owners.py index 6231e869d..88da68ed6 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_titles_by_owners.py +++ b/backend/src/processes/tests/test_views/test_templates/test_titles_by_owners.py @@ -4,9 +4,13 @@ from src.processes.enums import OwnerRole, OwnerType, FieldType from src.processes.models.templates.fields import FieldTemplate, \ FieldTemplateSelection +from src.processes.models.templates.fieldset import ( + FieldsetTemplateKickoff, +) from src.processes.models.templates.owner import TemplateOwner from src.processes.tests.fixtures import ( create_test_account, + create_test_fieldset_template, create_test_group, create_test_template, create_test_owner, @@ -442,3 +446,130 @@ def test_titles_by_owners__kickoff_field_with_dataset_and_selections__ok( assert len(field_data['selections']) == 2 assert field_data['selections'][0] == selection.value assert field_data['selections'][1] == dataset_item.value + + +def test_titles_by_owners__kickoff_fieldset__ok(api_client): + + """ GET titles-by-owners returns kickoff fieldset with all fields. """ + + # arrange + user = create_test_owner() + template = create_test_template( + user=user, + tasks_count=1, + is_active=True, + ) + kickoff = template.kickoff_instance + fieldset = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=kickoff, + name='Personal Info', + description='Enter your personal information', + api_name='fieldset-personal', + order=5, + ) + fieldset_link = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset, + kickoff=kickoff, + ) + fieldset_field = fieldset.fields.first() + api_client.token_authenticate(user) + + # act + response = api_client.get('/templates/titles-by-owners') + + # assert + assert response.status_code == 200 + fieldsets = response.data[0]['kickoff']['fieldsets'] + assert len(fieldsets) == 1 + fieldset_data = fieldsets[0] + assert fieldset_data['order'] == fieldset_link.order + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['api_name'] == fieldset.api_name + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + assert len(fieldset_data['fields']) == 1 + field_data = fieldset_data['fields'][0] + assert field_data['api_name'] == fieldset_field.api_name + assert field_data['name'] == fieldset_field.name + assert field_data['type'] == fieldset_field.type + assert field_data['order'] == fieldset_field.order + + +def test_titles_by_owners__kickoff_no_fieldsets__empty_list( + api_client, +): + + """ GET titles-by-owners returns empty fieldsets when none exist. """ + + # arrange + user = create_test_owner() + create_test_template( + user=user, + tasks_count=1, + is_active=True, + ) + api_client.token_authenticate(user) + + # act + response = api_client.get('/templates/titles-by-owners') + + # assert + assert response.status_code == 200 + fieldsets = response.data[0]['kickoff']['fieldsets'] + assert fieldsets == [] + + +def test_titles_by_owners__kickoff_fieldsets_ordered( + api_client, +): + + """ GET titles-by-owners returns fieldsets ordered by order. """ + + # arrange + user = create_test_owner() + template = create_test_template( + user=user, + tasks_count=1, + is_active=True, + ) + kickoff = template.kickoff_instance + fieldset_2 = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=kickoff, + name='Second Fieldset', + api_name='fieldset-second', + order=2, + ) + link_2 = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset_2, + kickoff=kickoff, + ) + fieldset_1 = create_test_fieldset_template( + account=user.account, + template=template, + kickoff=kickoff, + name='First Fieldset', + api_name='fieldset-first', + order=1, + ) + link_1 = FieldsetTemplateKickoff.objects.get( + fieldset=fieldset_1, + kickoff=kickoff, + ) + api_client.token_authenticate(user) + + # act + response = api_client.get('/templates/titles-by-owners') + + # assert + assert response.status_code == 200 + fieldsets = response.data[0]['kickoff']['fieldsets'] + assert len(fieldsets) == 2 + assert fieldsets[0]['order'] == link_1.order + assert fieldsets[0]['api_name'] == fieldset_1.api_name + assert fieldsets[1]['order'] == link_2.order + assert fieldsets[1]['api_name'] == fieldset_2.api_name diff --git a/backend/src/processes/tests/test_views/test_templates/test_update/test_condition_template.py b/backend/src/processes/tests/test_views/test_templates/test_update/test_condition_template.py index 4710953ce..cc80c7dfb 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_update/test_condition_template.py +++ b/backend/src/processes/tests/test_views/test_templates/test_update/test_condition_template.py @@ -476,6 +476,359 @@ def test_update__predicate_to_type_kickoff_completed__ok( assert predicate.operator == PredicateOperator.COMPLETED +def test_update__predicate_to_type_task_skipped__ok( + mocker, + api_client, +): + # arrange + account = create_test_account(plan=BillingPlanType.UNLIMITED) + user = create_test_user(account=account) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + template = create_test_template( + user=user, + tasks_count=3, + is_active=True, + ) + first_task = template.tasks.order_by('number').first() + second_task = template.tasks.get(number=2) + third_task = template.tasks.get(number=3) + + # START_TASK condition on third_task with COMPLETED on first_task + # to establish first_task as an ancestor of third_task + start_condition = ConditionTemplate.objects.create( + action=ConditionAction.START_TASK, + order=1, + task=third_task, + template=template, + ) + start_rule = RuleTemplate.objects.create( + condition=start_condition, + template=template, + ) + PredicateTemplate.objects.create( + rule=start_rule, + operator=PredicateOperator.COMPLETED, + field_type=PredicateType.TASK, + field=first_task.api_name, + value=None, + template=template, + ) + + # SKIP_TASK condition on third_task (will be updated to use SKIPPED) + skip_condition = ConditionTemplate.objects.create( + action=ConditionAction.SKIP_TASK, + order=2, + task=third_task, + template=template, + ) + skip_rule = RuleTemplate.objects.create( + condition=skip_condition, + template=template, + ) + predicate = PredicateTemplate.objects.create( + rule=skip_rule, + operator=PredicateOperator.COMPLETED, + field_type=PredicateType.TASK, + field=first_task.api_name, + value=None, + template=template, + ) + + start_request_data = { + 'api_name': start_condition.api_name, + 'order': start_condition.order, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'api_name': start_rule.api_name, + 'predicates': [ + { + 'api_name': start_rule.predicates.first().api_name, + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'field': first_task.api_name, + 'value': None, + }, + ], + }, + ], + } + skip_request_data = { + 'api_name': skip_condition.api_name, + 'order': skip_condition.order, + 'action': ConditionAction.SKIP_TASK, + 'rules': [ + { + 'api_name': skip_rule.api_name, + 'predicates': [ + { + 'api_name': predicate.api_name, + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.SKIPPED, + 'field': first_task.api_name, + 'value': None, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data={ + 'id': template.id, + 'name': template.name, + 'is_active': True, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': {'id': template.kickoff_instance.id}, + 'tasks': [ + { + 'id': first_task.id, + 'number': first_task.number, + 'name': first_task.name, + 'api_name': first_task.api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + { + 'id': second_task.id, + 'number': second_task.number, + 'name': second_task.name, + 'api_name': second_task.api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + { + 'id': third_task.id, + 'number': third_task.number, + 'name': third_task.name, + 'api_name': third_task.api_name, + 'conditions': [ + start_request_data, + skip_request_data, + ], + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + condition_data = response.data['tasks'][2]['conditions'][1] + predicate_data = condition_data['rules'][0]['predicates'][0] + assert predicate_data['field_type'] == PredicateType.TASK + assert predicate_data['api_name'] == predicate.api_name + assert predicate_data['operator'] == PredicateOperator.SKIPPED + assert predicate_data['value'] is None + assert predicate_data['field'] == first_task.api_name + + predicate.refresh_from_db() + assert predicate.field_type == PredicateType.TASK + assert predicate.operator == PredicateOperator.SKIPPED + + +def test_update__predicate_to_type_task_completed_or_skipped__ok( + mocker, + api_client, +): + # arrange + account = create_test_account(plan=BillingPlanType.UNLIMITED) + user = create_test_user(account=account) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + template = create_test_template( + user=user, + tasks_count=3, + is_active=True, + ) + first_task = template.tasks.order_by('number').first() + second_task = template.tasks.get(number=2) + third_task = template.tasks.get(number=3) + + # START_TASK condition on third_task with COMPLETED on first_task + # to establish first_task as an ancestor of third_task + start_condition = ConditionTemplate.objects.create( + action=ConditionAction.START_TASK, + order=1, + task=third_task, + template=template, + ) + start_rule = RuleTemplate.objects.create( + condition=start_condition, + template=template, + ) + PredicateTemplate.objects.create( + rule=start_rule, + operator=PredicateOperator.COMPLETED, + field_type=PredicateType.TASK, + field=first_task.api_name, + value=None, + template=template, + ) + + # SKIP_TASK condition on third_task + # (will be updated to use COMPLETED_OR_SKIPPED) + skip_condition = ConditionTemplate.objects.create( + action=ConditionAction.SKIP_TASK, + order=2, + task=third_task, + template=template, + ) + skip_rule = RuleTemplate.objects.create( + condition=skip_condition, + template=template, + ) + predicate = PredicateTemplate.objects.create( + rule=skip_rule, + operator=PredicateOperator.COMPLETED, + field_type=PredicateType.TASK, + field=first_task.api_name, + value=None, + template=template, + ) + + start_request_data = { + 'api_name': start_condition.api_name, + 'order': start_condition.order, + 'action': ConditionAction.START_TASK, + 'rules': [ + { + 'api_name': start_rule.api_name, + 'predicates': [ + { + 'api_name': start_rule.predicates.first().api_name, + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED, + 'field': first_task.api_name, + 'value': None, + }, + ], + }, + ], + } + skip_request_data = { + 'api_name': skip_condition.api_name, + 'order': skip_condition.order, + 'action': ConditionAction.SKIP_TASK, + 'rules': [ + { + 'api_name': skip_rule.api_name, + 'predicates': [ + { + 'api_name': predicate.api_name, + 'field_type': PredicateType.TASK, + 'operator': PredicateOperator.COMPLETED_OR_SKIPPED, + 'field': first_task.api_name, + 'value': None, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data={ + 'id': template.id, + 'name': template.name, + 'is_active': True, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': {'id': template.kickoff_instance.id}, + 'tasks': [ + { + 'id': first_task.id, + 'number': first_task.number, + 'name': first_task.name, + 'api_name': first_task.api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + { + 'id': second_task.id, + 'number': second_task.number, + 'name': second_task.name, + 'api_name': second_task.api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + { + 'id': third_task.id, + 'number': third_task.number, + 'name': third_task.name, + 'api_name': third_task.api_name, + 'conditions': [ + start_request_data, + skip_request_data, + ], + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + }, + ) + + # assert + assert response.status_code == 200 + condition_data = response.data['tasks'][2]['conditions'][1] + predicate_data = condition_data['rules'][0]['predicates'][0] + assert predicate_data['field_type'] == PredicateType.TASK + assert predicate_data['api_name'] == predicate.api_name + assert predicate_data['operator'] == PredicateOperator.COMPLETED_OR_SKIPPED + assert predicate_data['value'] is None + assert predicate_data['field'] == first_task.api_name + + predicate.refresh_from_db() + assert predicate.field_type == PredicateType.TASK + assert predicate.operator == PredicateOperator.COMPLETED_OR_SKIPPED + + def test_update__predicate_to_type_number__ok( mocker, api_client, diff --git a/backend/src/processes/tests/test_views/test_templates/test_update/test_fieldsets.py b/backend/src/processes/tests/test_views/test_templates/test_update/test_fieldsets.py new file mode 100644 index 000000000..d5aff43b1 --- /dev/null +++ b/backend/src/processes/tests/test_views/test_templates/test_update/test_fieldsets.py @@ -0,0 +1,997 @@ +import pytest + +from src.processes.enums import ( + OwnerRole, + OwnerType, + PerformerType, +) +from src.processes.models.templates.fieldset import ( + FieldsetTemplateKickoff, + FieldsetTemplateTaskTemplate, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_fieldset_template, + create_test_owner, + create_test_template, +) + +pytestmark = pytest.mark.django_db + +# Kickoff fieldsets + + +def test_update__kickoff_with_one_fieldset__ok( + mocker, + api_client, +): + + """ Updating a template with one fieldset linked to kickoff + creates a FieldsetTemplateKickoff record. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user, + is_active=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + task = template.tasks.first() + fieldset = create_test_fieldset_template( + account=account, + template=template, + api_name='fieldset-update-1', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + request_data = { + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + 'fieldsets': [ + { + 'api_name': fieldset.api_name, + 'order': 3, + }, + ], + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert response.data['kickoff'] == { + 'fields': [], + 'fieldsets': [ + { + 'api_name': fieldset.api_name, + 'order': 3, + }, + ], + } + assert FieldsetTemplateKickoff.objects.get( + kickoff=kickoff, + fieldset=fieldset, + order=3, + ) + + +def test_update__kickoff_create_two_fieldsets__ok( + mocker, + api_client, +): + + """ Updating a template with multiple fieldsets linked to + kickoff creates multiple FieldsetTemplateKickoff records. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user, + is_active=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + task = template.tasks.first() + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + api_name='fieldset-x', + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + api_name='fieldset-y', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + request_data = { + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + 'fieldsets': [ + { + 'api_name': fieldset_1.api_name, + 'order': 0, + }, + { + 'api_name': fieldset_2.api_name, + 'order': 1, + }, + ], + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert response.data['kickoff'] == { + 'fields': [], + 'fieldsets': [ + { + 'api_name': fieldset_1.api_name, + 'order': 0, + }, + { + 'api_name': fieldset_2.api_name, + 'order': 1, + }, + ], + } + assert FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset_1, + kickoff=kickoff, + order=0, + ).count() == 1 + assert FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset_2, + kickoff=kickoff, + order=1, + ).count() == 1 + + +def test_update__kickoff_replace_fieldset__ok( + mocker, + api_client, +): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user, + is_active=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + task = template.tasks.first() + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + request_data = { + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + 'fieldsets': [ + { + 'api_name': fieldset_2.api_name, + 'order': 2, + }, + ], + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert response.data['kickoff'] == { + 'fields': [], + 'fieldsets': [ + { + 'api_name': fieldset_2.api_name, + 'order': 2, + }, + ], + } + assert FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset_1, + kickoff=kickoff, + ).count() == 0 + assert FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset_2, + kickoff=kickoff, + order=2, + ).count() == 1 + + +def test_update__kickoff_remove_fieldset__ok( + mocker, + api_client, +): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user, + is_active=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + task = template.tasks.first() + fieldset = create_test_fieldset_template( + account=account, + template=template, + kickoff=kickoff, + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + request_data = { + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + 'fieldsets': [], + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert response.data['kickoff'] == { + 'fields': [], + 'fieldsets': [], + } + fieldset.refresh_from_db() + assert FieldsetTemplateKickoff.objects.filter( + fieldset=fieldset, + kickoff=kickoff, + ).count() == 0 + + +def test_update__kickoff_skip_fieldsets__no_fieldsets_created( + mocker, + api_client, +): + + """ Updating a template without fieldsets key in kickoff does not + create any FieldsetTemplateKickoff records. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user, + is_active=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + task = template.tasks.first() + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + request_data = { + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert FieldsetTemplateKickoff.objects.filter( + kickoff=kickoff, + ).count() == 0 + + +# Task fieldsets + + +def test_update__task_with_one_fieldset__ok( + mocker, + api_client, +): + + """ Updating a template with one fieldset linked to a task + creates a FieldsetTemplateTaskTemplate record. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user, + is_active=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + task = template.tasks.first() + fieldset = create_test_fieldset_template( + account=account, + template=template, + api_name='fieldset-task-update-1', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + request_data = { + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'fieldsets': [ + { + 'api_name': fieldset.api_name, + 'order': 2, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert response.data['tasks'][0]['fieldsets'] == [ + { + 'api_name': fieldset.api_name, + 'order': 2, + }, + ] + assert FieldsetTemplateTaskTemplate.objects.get( + task=task, + fieldset=fieldset, + order=2, + ) + + +def test_update__task_create_two_fieldsets__ok( + mocker, + api_client, +): + + """ Updating a template with multiple fieldsets linked to a task + creates multiple FieldsetTemplateTaskTemplate records. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user, + is_active=True, + tasks_count=1, + ) + task = template.tasks.first() + kickoff = template.kickoff_instance + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + api_name='fieldset-x', + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + api_name='fieldset-y', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + request_data = { + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'fieldsets': [ + { + 'api_name': fieldset_1.api_name, + 'order': 1, + }, + { + 'api_name': fieldset_2.api_name, + 'order': 0, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert response.data['tasks'][0]['fieldsets'] == [ + { + 'api_name': fieldset_2.api_name, + 'order': 0, + }, + { + 'api_name': fieldset_1.api_name, + 'order': 1, + }, + ] + assert FieldsetTemplateTaskTemplate.objects.get( + task=task, + fieldset=fieldset_1, + order=1, + ) + assert FieldsetTemplateTaskTemplate.objects.get( + task=task, + fieldset=fieldset_2, + order=0, + ) + + +def test_update__task_replace_fieldset__ok( + mocker, + api_client, +): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user, + is_active=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + task = template.tasks.first() + fieldset_1 = create_test_fieldset_template( + account=account, + template=template, + task=task, + ) + fieldset_2 = create_test_fieldset_template( + account=account, + template=template, + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + request_data = { + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'fieldsets': [ + { + 'api_name': fieldset_2.api_name, + 'order': 2, + }, + ], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert response.data['tasks'][0]['fieldsets'] == [ + { + 'api_name': fieldset_2.api_name, + 'order': 2, + }, + ] + fieldset_1.refresh_from_db() + assert FieldsetTemplateTaskTemplate.objects.filter( + task=task, + fieldset=fieldset_1, + ).count() == 0 + assert FieldsetTemplateTaskTemplate.objects.filter( + task=task, + fieldset=fieldset_2, + order=2, + ).count() == 1 + + +def test_update__tasks_remove_fieldset__ok( + mocker, + api_client, +): + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user, + is_active=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + task = template.tasks.first() + fieldset = create_test_fieldset_template( + account=account, + template=template, + task=task, + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + request_data = { + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'fieldsets': [], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert response.data['tasks'][0]['fieldsets'] == [] + assert FieldsetTemplateTaskTemplate.objects.filter( + task=task, + fieldset=fieldset, + ).count() == 0 + + +def test_update__task_with_empty_fieldsets__no_create_fieldsets( + mocker, + api_client, +): + + """ Updating a template with empty fieldsets list in task does not + create any FieldsetTemplateTaskTemplate records. """ + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + template = create_test_template( + user, + is_active=True, + tasks_count=1, + ) + kickoff = template.kickoff_instance + task = template.tasks.first() + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.' + 'create_integrations_for_template', + ) + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_updated', + ) + mocker.patch( + 'src.processes.views.template.' + 'AnalyticService.templates_kickoff_updated', + ) + request_data = { + 'id': template.id, + 'is_active': True, + 'name': 'Updated template', + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + }, + 'tasks': [ + { + 'id': task.id, + 'api_name': task.api_name, + 'number': task.number, + 'name': task.name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + 'fieldsets': [], + }, + ], + } + api_client.token_authenticate(user) + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert FieldsetTemplateTaskTemplate.objects.filter(task=task).count() == 0 diff --git a/backend/src/processes/tests/test_views/test_templates/test_update/test_kickoff.py b/backend/src/processes/tests/test_views/test_templates/test_update/test_kickoff.py index 6e3f5f393..088be71eb 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_update/test_kickoff.py +++ b/backend/src/processes/tests/test_views/test_templates/test_update/test_kickoff.py @@ -211,6 +211,7 @@ def test_update_draft__not_kickoff__ok( assert response.status_code == 200 assert response.data['kickoff'] == { 'fields': [], + 'fieldsets': [], } assert response.data['is_active'] is False kickoff_update_mock.assert_not_called() @@ -267,6 +268,7 @@ def test_update_draft__kickoff_id_null__ok( assert response.status_code == 200 assert response.data['kickoff'] == { 'fields': [], + 'fieldsets': [], } assert response.data['is_active'] is False kickoff_update_mock.assert_not_called() @@ -309,5 +311,6 @@ def test_update_draft__not_send_kickoff_id__ok( assert response.status_code == 200 assert response.data['kickoff'] == { 'fields': [], + 'fieldsets': [], } kickoff_update_mock.assert_not_called() diff --git a/backend/src/processes/tests/test_views/test_templates/test_update/test_template.py b/backend/src/processes/tests/test_views/test_templates/test_update/test_template.py index ab13bff56..53b861efa 100644 --- a/backend/src/processes/tests/test_views/test_templates/test_update/test_template.py +++ b/backend/src/processes/tests/test_views/test_templates/test_update/test_template.py @@ -40,6 +40,7 @@ create_test_template, create_test_user, create_test_workflow, + create_test_fieldset_template, ) pytestmark = pytest.mark.django_db @@ -2829,3 +2830,73 @@ def test_update__inactive_template__analytics_skipped(mocker, api_client): api_request_mock.assert_not_called() templates_kickoff_updated_mock.assert_not_called() templates_updated_mock.assert_not_called() + + +def test_update__wf_name_template_with_fieldset_field__ok( + mocker, + api_client, +): + # arrange + account = create_test_account() + user = create_test_owner(account=account) + api_client.token_authenticate(user) + template = create_test_template(user=user, tasks_count=1) + task = template.tasks.first() + kickoff = template.kickoff_instance + fieldset = create_test_fieldset_template( + account=account, + template=template, + ) + field = fieldset.fields.first() + mocker.patch( + 'src.processes.services.templates.' + 'integrations.TemplateIntegrationsService.template_updated', + ) + wf_name_template = f'Template {{ {field.api_name} }}' + + request_data = { + 'id': template.id, + 'is_active': True, + 'wf_name_template': wf_name_template, + 'name': template.name, + 'owners': [ + { + 'type': OwnerType.USER, + 'source_id': user.id, + 'role': OwnerRole.OWNER, + }, + ], + 'kickoff': { + 'id': kickoff.id, + 'fieldsets': [ + { + 'api_name': fieldset.api_name, + 'order': 1, + }, + ], + }, + 'tasks': [ + { + 'id': task.id, + 'number': task.number, + 'name': task.name, + 'api_name': task.api_name, + 'raw_performers': [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + }, + ], + }, + ], + } + + # act + response = api_client.put( + path=f'/templates/{template.id}', + data=request_data, + ) + + # assert + assert response.status_code == 200 + assert response.data['wf_name_template'] == wf_name_template diff --git a/backend/src/processes/tests/test_views/test_workflow/test_events.py b/backend/src/processes/tests/test_views/test_workflow/test_events.py index 330e614bf..693095712 100644 --- a/backend/src/processes/tests/test_views/test_workflow/test_events.py +++ b/backend/src/processes/tests/test_views/test_workflow/test_events.py @@ -36,6 +36,8 @@ TaskPerformer, ) from src.processes.models.workflows.workflow import Workflow +from src.processes.models.workflows.fields import TaskField +from src.processes.models.workflows.fieldset import FieldSet from src.processes.services.events import ( WorkflowEventService, ) @@ -54,6 +56,7 @@ create_test_template, create_test_user, create_test_workflow, + create_test_event, ) pytestmark = pytest.mark.django_db @@ -1716,3 +1719,166 @@ def test_workflow_events__template_starter_own_workflow__ok(api_client): # assert assert response.status_code == status.HTTP_200_OK + + +def test_events__task_complete_fieldsets_present__ok(api_client): + + """ + GET workflow events: TASK_COMPLETE row includes non-null task.fieldsets + when the task has at least one FieldSet. + """ + + # arrange + + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user, tasks_count=1) + task_1 = workflow.tasks.get(number=1) + fieldset = FieldSet.objects.create( + account=account, + workflow=workflow, + task=task_1, + name='Fieldset 1', + order=1, + ) + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + task=task_1, + fieldset=fieldset, + name='Field 1', + type=FieldType.TEXT, + order=1, + ) + field_2 = TaskField.objects.create( + account=account, + workflow=workflow, + task=task_1, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + order=2, + ) + WorkflowEventService.task_complete_event( + task=task_1, + user=user, + after_create_actions=False, + ) + api_client.token_authenticate(user=user) + + # act + + response = api_client.get( + path=f'/workflows/{workflow.id}/events', + ) + + # assert + + assert response.status_code == 200 + event_data = response.data[0] + assert event_data['type'] == WorkflowEventType.TASK_COMPLETE + fieldsets_data = event_data['task']['fieldsets'] + assert fieldsets_data is not None + assert len(fieldsets_data) == 1 + fieldset.refresh_from_db() + field_1.refresh_from_db() + field_2.refresh_from_db() + fieldset_data = fieldsets_data[0] + assert fieldset_data['id'] == fieldset.id + assert fieldset_data['api_name'] == fieldset.api_name + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['order'] == fieldset.order + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + fields_data = fieldset_data['fields'] + assert len(fields_data) == 2 + field_2_data = fields_data[0] + assert field_2_data['id'] == field_2.id + assert field_2_data['order'] == field_2.order + assert field_2_data['type'] == field_2.type + assert field_2_data['is_required'] == field_2.is_required + assert field_2_data['is_hidden'] == field_2.is_hidden + assert field_2_data['description'] == field_2.description + assert field_2_data['api_name'] == field_2.api_name + assert field_2_data['name'] == field_2.name + assert field_2_data['value'] == field_2.value + assert field_2_data['markdown_value'] == field_2.markdown_value + assert field_2_data['clear_value'] == field_2.clear_value + assert field_2_data['user_id'] == field_2.user_id + assert field_2_data['group_id'] == field_2.group_id + assert field_2_data['selections'] == [] + assert field_2_data['attachments'] == [] + field_1_data = fields_data[1] + assert field_1_data['id'] == field_1.id + + +def test_events__task_complete_fieldsets_absent__ok(api_client): + + """ + GET workflow events: TASK_COMPLETE row has task.fieldsets equal to null + when the task has no FieldSet rows. + """ + + # arrange + + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user, tasks_count=1) + task_1 = workflow.tasks.get(number=1) + WorkflowEventService.task_complete_event( + task=task_1, + user=user, + after_create_actions=False, + ) + api_client.token_authenticate(user=user) + + # act + + response = api_client.get( + path=f'/workflows/{workflow.id}/events', + ) + + # assert + + assert response.status_code == 200 + item_1 = response.data[0] + assert item_1['type'] == WorkflowEventType.TASK_COMPLETE + task_payload = item_1['task'] + assert task_payload['fieldsets'] is None + + +def test_events__non_complete_task_fieldsets_null__ok(api_client): + + """ + GET workflow events: non-TASK_COMPLETE event still exposes task.fieldsets + as null in serialized task payload. + """ + + # arrange + + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user, tasks_count=1) + task_1 = workflow.tasks.get(number=1) + create_test_event( + workflow=workflow, + user=user, + task=task_1, + type_event=WorkflowEventType.COMMENT, + ) + api_client.token_authenticate(user=user) + + # act + + response = api_client.get( + path=f'/workflows/{workflow.id}/events', + ) + + # assert + + assert response.status_code == 200 + item_1 = response.data[0] + assert item_1['type'] == WorkflowEventType.COMMENT + task_payload = item_1['task'] + assert task_payload['fieldsets'] is None diff --git a/backend/src/processes/tests/test_views/test_workflow/test_fields.py b/backend/src/processes/tests/test_views/test_workflow/test_fields.py index 31ff2c1b1..d269793b9 100644 --- a/backend/src/processes/tests/test_views/test_workflow/test_fields.py +++ b/backend/src/processes/tests/test_views/test_workflow/test_fields.py @@ -22,6 +22,7 @@ from src.processes.tests.fixtures import ( create_test_account, create_test_admin, + create_test_fieldset, create_test_guest, create_test_not_admin, create_test_owner, @@ -865,3 +866,195 @@ def test_fields__all_params__ok(api_client): # assert assert response.status_code == 200 + + +def test_fields__filter_by_fields_fieldset__ok(api_client): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=1) + task = workflow.tasks.get(number=1) + + create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-2', + ) + field = fieldset.fields.first() + + api_client.token_authenticate(owner) + + # act + response = api_client.get( + '/workflows/fields', + data={ + 'fields': [field.api_name], + }, + ) + + # assert + assert response.status_code == 200 + data = response.data['results'] + assert len(data) == 1 + assert data[0]['id'] == workflow.id + fields_data = data[0]['fields'] + assert len(fields_data) == 1 + assert fields_data[0]['id'] == field.id + assert fields_data[0]['fieldset_id'] == fieldset.id + + +def test_fields__filter_by_multiple_fields_fieldset__ok(api_client): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + + fieldset_1 = create_test_fieldset( + workflow=workflow, + kickoff=workflow.kickoff_instance, + api_name='fieldset-1', + ) + field_1 = fieldset_1.fields.first() + + fieldset_2 = create_test_fieldset( + workflow=workflow, + task=task_1, + api_name='fieldset-2', + ) + field_2 = fieldset_2.fields.first() + field_2.order = 2 + field_2.save() + + create_test_fieldset( + workflow=workflow, + task=task_1, + api_name='fieldset-non-selected', + ) + + api_client.token_authenticate(owner) + + # act + response = api_client.get( + '/workflows/fields', + data={ + 'fields': ( + f'{field_1.api_name},' + f'{field_2.api_name}' + ), + }, + ) + + # assert + assert response.status_code == 200 + data = response.data['results'] + assert len(data) == 1 + assert data[0]['id'] == workflow.id + + fields_data = data[0]['fields'] + assert len(fields_data) == 2 + assert fields_data[0]['id'] == field_2.id + assert fields_data[0]['task_id'] == task_1.id + assert fields_data[0]['fieldset_id'] == fieldset_2.id + assert fields_data[1]['id'] == field_1.id + assert fields_data[1]['kickoff_id'] is None + assert fields_data[1]['fieldset_id'] == fieldset_1.id + + +def test_fields__multiple_workflows_and_multiple_fields_fieldset__ok( + api_client, +): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=2) + + fieldset_1 = create_test_fieldset( + workflow=workflow, + kickoff=workflow.kickoff_instance, + api_name='fieldset-1', + ) + field_1 = fieldset_1.fields.first() + + workflow_2 = create_test_workflow(owner, tasks_count=2) + + fieldset_2 = create_test_fieldset( + workflow=workflow_2, + kickoff=workflow_2.kickoff_instance, + api_name='fieldset-2', + ) + field_2 = fieldset_2.fields.first() + + api_client.token_authenticate(owner) + + # act + response = api_client.get( + '/workflows/fields', + data={ + 'fields': ( + f'{field_1.api_name},' + f'{field_2.api_name}' + ), + }, + ) + + # assert + assert response.status_code == 200 + data = response.data['results'] + assert len(data) == 2 + assert data[0]['id'] == workflow_2.id + fields_data = data[0]['fields'] + assert len(fields_data) == 1 + assert fields_data[0]['id'] == field_2.id + assert fields_data[0]['fieldset_id'] == fieldset_2.id + + assert data[1]['id'] == workflow.id + fields_data = data[1]['fields'] + assert len(fields_data) == 1 + assert fields_data[0]['id'] == field_1.id + assert fields_data[0]['fieldset_id'] == fieldset_1.id + + +def test_fields__filter_by_invalid_fields_fieldset__ok(api_client): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=1) + task = workflow.tasks.get(number=1) + + create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-2', + ) + api_client.token_authenticate(owner) + + # act + response = api_client.get( + '/workflows/fields', + data={ + 'fields': 'field-non-existing', + }, + ) + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 1 + data = response.data['results'][0] + assert data['id'] == workflow.id + assert len(data['fields']) == 0 diff --git a/backend/src/processes/tests/test_views/test_workflow/test_list.py b/backend/src/processes/tests/test_views/test_workflow/test_list.py index 0b0d91d28..e8ca9ef65 100644 --- a/backend/src/processes/tests/test_views/test_workflow/test_list.py +++ b/backend/src/processes/tests/test_views/test_workflow/test_list.py @@ -46,6 +46,7 @@ create_invited_user, create_test_account, create_test_admin, + create_test_fieldset, create_test_group, create_test_guest, create_test_not_admin, @@ -2181,6 +2182,7 @@ def test_list__filter_fields_kickoff_field__ok(api_client): assert field_data['is_required'] == field.is_required assert field_data['task_id'] is None assert field_data['kickoff_id'] == workflow.kickoff_instance.id + assert field_data['fieldset_id'] is None assert field_data['type'] == field.type assert field_data['api_name'] == field.api_name assert field_data['name'] == field.name @@ -2548,6 +2550,427 @@ def test_list__filter_fields_and_another_template_id__empty_list(api_client): assert len(response.data['results']) == 0 +def test_list__filter_fields_kickoff_fieldset__ok(api_client): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=1) + + create_test_fieldset( + workflow=workflow, + kickoff=workflow.kickoff_instance, + api_name='fieldset-1', + ) + + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=workflow.kickoff_instance, + api_name='fieldset-2', + ) + field = fieldset.fields.first() + field.value = 'raw value' + field.clear_value = 'clear value' + field.markdown_value = '**bold** value' + field.order = 2 + field.description = 'desc' + field.is_required = True + field.save() + + api_client.token_authenticate(owner) + + # act + response = api_client.get(f'/workflows?fields={field.api_name}') + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == workflow.id + assert len(response.data['results'][0]['fields']) == 1 + field_data = response.data['results'][0]['fields'][0] + assert field_data['id'] == field.id + assert field_data['order'] == field.order + assert field_data['is_required'] == field.is_required + assert field_data['fieldset_id'] == fieldset.id + assert field_data['task_id'] is None + assert field_data['kickoff_id'] is None + assert field_data['type'] == field.type + assert field_data['api_name'] == field.api_name + assert field_data['name'] == field.name + assert field_data['description'] == field.description + assert field_data['value'] == field.value + assert field_data['markdown_value'] == field.markdown_value + assert field_data['clear_value'] == field.clear_value + assert field_data['user_id'] is None + assert field_data['group_id'] is None + + +def test_list__filter_fields_task_fieldset__ok(api_client): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=1) + task = workflow.tasks.first() + + create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-2', + ) + field = fieldset.fields.first() + field.value = 'raw value' + field.clear_value = 'clear value' + field.markdown_value = '**bold** value' + field.order = 2 + field.description = 'desc' + field.is_required = True + field.save() + + api_client.token_authenticate(owner) + + # act + response = api_client.get(f'/workflows?fields={field.api_name}') + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == workflow.id + assert len(response.data['results'][0]['fields']) == 1 + field_data = response.data['results'][0]['fields'][0] + assert field_data['id'] == field.id + assert field_data['order'] == field.order + assert field_data['is_required'] == field.is_required + assert field_data['task_id'] == task.id + assert field_data['kickoff_id'] is None + assert field_data['fieldset_id'] == fieldset.id + assert field_data['type'] == field.type + assert field_data['api_name'] == field.api_name + assert field_data['name'] == field.name + assert field_data['description'] == field.description + assert field_data['value'] == field.value + assert field_data['markdown_value'] == field.markdown_value + assert field_data['clear_value'] == field.clear_value + assert field_data['user_id'] is None + assert field_data['group_id'] is None + + +def test_list__filter_fields__fieldset_ordering__ok(api_client): + + # arrange + user = create_test_owner() + workflow = create_test_workflow(user, tasks_count=2) + task_1 = workflow.tasks.get(number=1) + + fieldset_4 = create_test_fieldset( + workflow=workflow, + task=task_1, + api_name='fs-4', + ) + field_4 = fieldset_4.fields.first() + field_4.order = 3 + field_4.save() + + task_2 = workflow.tasks.get(number=2) + + fieldset_3 = create_test_fieldset( + workflow=workflow, + task=task_2, + api_name='fs-3', + ) + field_3 = fieldset_3.fields.first() + field_3.order = 2 + field_3.save() + + fieldset_2 = create_test_fieldset( + workflow=workflow, + task=task_2, + api_name='fs-2', + ) + field_2 = fieldset_2.fields.first() + field_2.order = 1 + field_2.save() + + kickoff = workflow.kickoff_instance + fieldset_1 = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + api_name='fs-1', + ) + field_1 = fieldset_1.fields.first() + field_1.order = 5 + field_1.save() + + task_1.number = 3 + task_1.save() + task_2.number = 1 + task_2.save() + + api_client.token_authenticate(user) + fields_filter = ( + f'{field_4.api_name},' + f'{field_3.api_name},' + f'{field_2.api_name},' + f'{field_1.api_name}' + ) + + # act + response = api_client.get(f'/workflows?fields={fields_filter}') + + # assert + assert response.status_code == 200 + fields_data = response.data['results'][0]['fields'] + assert len(fields_data) == 4 + assert fields_data[0]['id'] == field_3.id + assert fields_data[1]['id'] == field_2.id + assert fields_data[2]['id'] == field_4.id + assert fields_data[3]['id'] == field_1.id + + +def test_list__filter_fields_multiple_values_fieldset__ok(api_client): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=1) + task = workflow.tasks.first() + + fieldset_1 = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + field_1 = fieldset_1.fields.first() + field_1.order = 3 + field_1.save() + + fieldset_2 = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-2', + ) + field_2 = fieldset_2.fields.first() + field_2.type = FieldType.NUMBER + field_2.value = '2.13' + field_2.order = 2 + field_2.save() + + api_client.token_authenticate(owner) + + # act + response = api_client.get( + f'/workflows?fields={field_1.api_name},{field_2.api_name}', + ) + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == workflow.id + assert len(response.data['results'][0]['fields']) == 2 + field_1_data = response.data['results'][0]['fields'][0] + assert field_1_data['id'] == field_1.id + field_2_data = response.data['results'][0]['fields'][1] + assert field_2_data['id'] == field_2.id + + +def test_list__filter_fields_blank_fieldset__empty_list(api_client): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=1) + task = workflow.tasks.first() + + create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + api_client.token_authenticate(owner) + + # act + response = api_client.get('/workflows?fields=') + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == workflow.id + assert len(response.data['results'][0]['fields']) == 0 + + +def test_list__filter_fields_not_existent_fieldset__empty_list(api_client): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=1) + task = workflow.tasks.first() + + create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + api_client.token_authenticate(owner) + + # act + response = api_client.get('/workflows?fields=not-existent') + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == workflow.id + assert len(response.data['results'][0]['fields']) == 0 + + +def test_list__filter_fields__another_account_fieldset__empty_list( + api_client, +): + + # arrange + another_account = create_test_account() + another_owner = create_test_owner(account=another_account) + workflow = create_test_workflow(another_owner, tasks_count=1) + task = workflow.tasks.first() + + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + field = fieldset.fields.first() + + account = create_test_account() + owner = create_test_owner( + account=account, + email='owner2@pneumatic.app', + ) + workflow_2 = create_test_workflow(user=owner, tasks_count=1) + api_client.token_authenticate(owner) + + # act + response = api_client.get(f'/workflows?fields={field.api_name}') + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == workflow_2.id + assert len(response.data['results'][0]['fields']) == 0 + + +def test_list__filter_fields__another_workflow_fieldset__empty_list( + api_client, +): + + # arrange + account = create_test_account() + owner = create_test_owner(account=account) + workflow = create_test_workflow(owner, tasks_count=1) + task = workflow.tasks.first() + + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + field = fieldset.fields.first() + + user = create_test_admin(account=account) + workflow_2 = create_test_workflow(user, tasks_count=1) + api_client.token_authenticate(user) + + # act + response = api_client.get(f'/workflows?fields={field.api_name}') + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == workflow_2.id + assert len(response.data['results'][0]['fields']) == 0 + + +def test_list__filter_fields_and_template_fieldset__ok(api_client): + + # arrange + user = create_test_owner() + template_1 = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template_1, + ) + task = workflow.tasks.first() + + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + field = fieldset.fields.first() + api_client.token_authenticate(user) + + # act + response = api_client.get( + f'/workflows?fields={field.api_name};template_id={template_1.id}', + ) + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 1 + assert response.data['results'][0]['id'] == workflow.id + assert len(response.data['results'][0]['fields']) == 1 + field_data = response.data['results'][0]['fields'][0] + assert field_data['id'] == field.id + assert field_data['fieldset_id'] == fieldset.id + + +def test_list__filter_fields_and_another_template_id_fieldset__empty_list( + api_client, +): + + # arrange + user = create_test_owner() + template_1 = create_test_template( + user=user, + is_active=True, + tasks_count=1, + ) + workflow = create_test_workflow( + user=user, + template=template_1, + ) + task = workflow.tasks.first() + + fieldset = create_test_fieldset( + workflow=workflow, + task=task, + api_name='fieldset-1', + ) + field = fieldset.fields.first() + + another_template = create_test_template(user=user, tasks_count=1) + api_client.token_authenticate(user) + + # act + response = api_client.get( + f'/workflows?fields={field.api_name};' + f'template_id={another_template.id}', + ) + + # assert + assert response.status_code == 200 + assert len(response.data['results']) == 0 + + def test_list__ordering_oldest__ok(api_client): # arrange diff --git a/backend/src/processes/tests/test_views/test_workflow/test_webhook_example.py b/backend/src/processes/tests/test_views/test_workflow/test_webhook_example.py index 177004fd3..c7b0f2ac6 100644 --- a/backend/src/processes/tests/test_views/test_workflow/test_webhook_example.py +++ b/backend/src/processes/tests/test_views/test_workflow/test_webhook_example.py @@ -61,6 +61,7 @@ def test_webhook_example__body__ok(api_client): 'kickoff': { 'id': workflow.kickoff_instance.id, 'output': [], + 'fieldsets': [], }, 'tasks': [ OrderedDict([ diff --git a/backend/src/processes/tests/test_webhooks/test_webhooks.py b/backend/src/processes/tests/test_webhooks/test_webhooks.py index bb3462cb6..d1e15f53c 100644 --- a/backend/src/processes/tests/test_webhooks/test_webhooks.py +++ b/backend/src/processes/tests/test_webhooks/test_webhooks.py @@ -58,6 +58,7 @@ def test_send_task_completed_webhook__ok(api_client, mocker): 'contains_comments': False, 'require_completion_by_all': False, 'output': [], + 'fieldsets': [], 'delay': None, 'date_started_tsp': task_1.date_started.timestamp(), 'date_completed_tsp': task_1.date_completed.timestamp(), @@ -108,6 +109,7 @@ def test_send_task_completed_webhook__ok(api_client, mocker): 'kickoff': { 'id': workflow.kickoff_instance.id, 'output': [], + 'fieldsets': [], }, 'tasks': [ OrderedDict([ @@ -226,6 +228,7 @@ def test_send_task_completed_webhook__sub_workflows__ok(api_client, mocker): 'contains_comments': False, 'require_completion_by_all': False, 'output': [], + 'fieldsets': [], 'delay': None, 'date_started_tsp': task_1.date_started.timestamp(), 'date_completed_tsp': task_1.date_completed.timestamp(), @@ -343,6 +346,7 @@ def test_send_task_completed_webhook__sub_workflows__ok(api_client, mocker): 'kickoff': { 'id': workflow.kickoff_instance.id, 'output': [], + 'fieldsets': [], }, 'tasks': [ OrderedDict([ @@ -446,6 +450,7 @@ def test_send_task_returned_webhook__ok(api_client, mocker): 'contains_comments': False, 'require_completion_by_all': False, 'output': [], + 'fieldsets': [], 'delay': None, 'date_started_tsp': None, 'date_completed_tsp': None, @@ -499,6 +504,7 @@ def test_send_task_returned_webhook__ok(api_client, mocker): 'kickoff': { 'id': workflow.kickoff_instance.id, 'output': [], + 'fieldsets': [], }, 'tasks': [ OrderedDict([ @@ -605,6 +611,7 @@ def test_send_workflow_started_webhook__ok(api_client, mocker): 'kickoff': { 'id': workflow.kickoff_instance.id, 'output': [], + 'fieldsets': [], }, 'tasks': [ OrderedDict([ @@ -708,6 +715,7 @@ def test_send_workflow_completed_webhook__ok(api_client, mocker): 'kickoff': { 'id': workflow.kickoff_instance.id, 'output': [], + 'fieldsets': [], }, 'tasks': [ OrderedDict([ diff --git a/backend/src/processes/urls/templates.py b/backend/src/processes/urls/templates.py index e4ba1202f..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/utils/common.py b/backend/src/processes/utils/common.py index 83644ccc6..998f5d81a 100644 --- a/backend/src/processes/utils/common.py +++ b/backend/src/processes/utils/common.py @@ -166,6 +166,11 @@ def get_tasks_parents(tasks_data: List[Dict]) -> dict: """ Find and return task parents api_names """ + allowed_operators = { + PredicateOperator.COMPLETED, + PredicateOperator.SKIPPED, + PredicateOperator.COMPLETED_OR_SKIPPED, + } parents_by_tasks = {} available_api_names = { e['api_name'] for e in tasks_data if e.get('api_name') @@ -181,7 +186,7 @@ def get_tasks_parents(tasks_data: List[Dict]) -> dict: for p in rule.get('predicates', ()): try: if ( - p['operator'] == PredicateOperator.COMPLETED + p['operator'] in allowed_operators and p['field_type'] == PredicateType.TASK and p['field'] in available_api_names ): 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..295c72102 100644 --- a/backend/src/processes/views/template.py +++ b/backend/src/processes/views/template.py @@ -22,7 +22,9 @@ CustomViewSetMixin, ) from src.generics.permissions import UserIsAuthenticated -from src.processes.filters import TemplateFilter +from src.processes.filters import ( + FieldSetFilter, +) from src.processes.models.templates.fields import FieldTemplate from src.processes.models.templates.kickoff import Kickoff from src.processes.models.templates.preset import TemplatePreset @@ -30,6 +32,7 @@ from src.processes.models.templates.task import TaskTemplate from src.processes.models.templates.template import Template from src.processes.models.templates.owner import TemplateOwner +from src.processes.models.templates.fields import FieldTemplateSelection from src.processes.permissions import ( TemplateAccessPermission, TemplateAdminOwnerPermission, @@ -51,6 +54,9 @@ TemplateStepFilterSerializer, TemplateStepNameSerializer, ) +from src.processes.serializers.templates.template_fields import ( + TemplateOnlyFieldsSerializer, +) from src.processes.serializers.templates.template import ( TemplateAiSerializer, TemplateByNameSerializer, @@ -58,12 +64,12 @@ TemplateExportFilterSerializer, TemplateListFilterSerializer, TemplateListSerializer, - TemplateOnlyFieldsSerializer, TemplateSerializer, TemplateTitlesByEventsSerializer, TemplateTitlesByTasksSerializer, TemplateTitlesByWorkflowsSerializer, TemplateTitlesSerializer, + FieldsetTemplateFilterSerializer, ) from src.processes.serializers.workflows.workflow import ( WorkflowCreateSerializer, @@ -78,6 +84,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, ) @@ -110,8 +124,6 @@ class TemplateViewSet( GenericViewSet, ): pagination_class = LimitOffsetPagination - filter_backends = (PneumaticFilterBackend,) - filterset_class = TemplateFilter serializer_class = TemplateSerializer action_serializer_classes = { 'list': TemplateListSerializer, @@ -127,6 +139,11 @@ class TemplateViewSet( 'fields': TemplateOnlyFieldsSerializer, 'presets': TemplatePresetSerializer, 'preset': TemplatePresetSerializer, + 'list_fieldsets': FieldsetTemplateSerializer, + 'create_fieldset': FieldsetTemplateSerializer, + } + action_filterset_classes = { + 'list_fieldsets': FieldSetFilter, } def get_permissions(self): @@ -136,6 +153,7 @@ def get_permissions(self): 'destroy', 'discard_changes', 'preset', + 'create_fieldset', ): return ( UserIsAuthenticated(), @@ -153,7 +171,7 @@ def get_permissions(self): UsersOverlimitedPermission(), TemplateAccessPermission(), ) - if self.action == 'retrieve': + if self.action in ('retrieve', 'list_fieldsets'): return ( UserIsAuthenticated(), ExpiredSubscriptionPermission(), @@ -242,6 +260,7 @@ def prefetch_queryset( 'kickoff', 'kickoff__fields', 'kickoff__fields__selections', + 'kickoff__fieldsets', Prefetch('owners', queryset=owners_qs), Prefetch( lookup='tasks', @@ -251,6 +270,7 @@ def prefetch_queryset( .prefetch_related( 'fields', 'fields__selections', + 'fieldsets', 'checklists', 'checklists__selections', 'conditions', @@ -271,10 +291,21 @@ def prefetch_queryset( Prefetch( lookup='fields', queryset=( - FieldTemplate.objects.all() + FieldTemplate.objects.prefetch_related( + Prefetch( + 'selections', + queryset=( + FieldTemplateSelection.objects + .order_by('id') + ), + to_attr='selections_values', + ), + ) + .all() .order_by('-order') ), ), + 'fieldsets', ) ), ), @@ -291,6 +322,7 @@ def prefetch_queryset( .order_by('-order') ), ), + 'fieldsets', ) ), ), @@ -410,6 +442,68 @@ def clone(self, request, *args, **kwargs): serializer = self.get_serializer(data=template_data_clone) with transaction.atomic(): serializer.save_as_draft() + + # TODO Temporary: copy FieldsetTemplate entities --- + # Remove after creating global fieldsets + new_template = serializer.instance + original_fieldsets = FieldsetTemplate.objects.filter( + template=template, + ).prefetch_related( + 'rules', 'rules__fields', + 'fields', 'fields__selections', + ) + + for original_fs in original_fieldsets: + fields_data = [ + { + 'name': f.name, + 'type': f.type, + 'description': f.description or '', + 'is_required': f.is_required, + 'order': f.order, + 'is_hidden': f.is_hidden, + 'default': f.default, + 'api_name': f.api_name, + 'dataset': f.dataset, + 'selections': [ + { + 'value': sel.value, + 'api_name': sel.api_name, + } + for sel in f.selections.all() + ], + } + for f in original_fs.fields.all() + ] + rules_data = [ + { + 'type': r.type, + 'value': r.value, + 'api_name': r.api_name, + 'fields': [ + f.api_name + for f in r.fields.all() + ], + } + for r in original_fs.rules.all() + ] + service = FieldSetTemplateService( + user=request.user, + is_superuser=request.is_superuser, + auth_type=request.token_type, + ) + service.create( + template_id=new_template.id, + name=original_fs.name, + api_name=original_fs.api_name, + description=original_fs.description, + label_position=original_fs.label_position, + layout=original_fs.layout, + fields=fields_data, + rules=rules_data, + ) + # TODO --- End temporary code --- + return self.response_ok(serializer.get_response_data()) def list(self, request, *args, **kwargs): @@ -421,6 +515,7 @@ def list(self, request, *args, **kwargs): SQL pagination (by LIMIT, OFFSET) is not possible because it is impossible to calculate response 'count' value """ + filter_slz = TemplateListFilterSerializer(data=request.GET) filter_slz.is_valid(raise_exception=True) @@ -743,6 +838,46 @@ def preset(self, request, *args, **kwargs): return self.response_ok(self.get_serializer(preset).data) + @action(methods=['GET'], detail=True, url_path='fieldsets') + def list_fieldsets(self, request, *args, **kwargs): + template = self.get_object() + filter_slz = FieldsetTemplateFilterSerializer(data=request.GET) + filter_slz.is_valid(raise_exception=True) + queryset = ( + FieldsetTemplate.objects + .on_account(request.user.account_id) + .filter(template_id=template.id) + ) + queryset = PneumaticFilterBackend().filter_queryset( + queryset=queryset, + request=request, + view=self, + ) + return self.paginated_response(queryset) + + @list_fieldsets.mapping.post + def create_fieldset(self, request, *args, **kwargs): + template = self.get_object() + serializer = self.get_serializer( + data=request.data, + extra_fields={'template': template}, + ) + serializer.is_valid(raise_exception=True) + service = FieldSetTemplateService( + user=request.user, + is_superuser=request.is_superuser, + auth_type=request.token_type, + ) + try: + fieldset = service.create( + template_id=template.id, + **serializer.validated_data, + ) + except BaseServiceException as ex: + raise_validation_error(message=ex.message) + response_serializer = FieldsetTemplateSerializer(fieldset) + return self.response_created(response_serializer.data) + class TemplateIntegrationsViewSet( CustomViewSetMixin, diff --git a/backend/src/reports/serializers.py b/backend/src/reports/serializers.py index 30bf3eff2..f62021075 100644 --- a/backend/src/reports/serializers.py +++ b/backend/src/reports/serializers.py @@ -14,6 +14,7 @@ from src.processes.serializers.workflows.field import ( TaskFieldEventSerializer, ) +from src.processes.serializers.workflows.fieldset import FieldSetSerializer UserModel = get_user_model() @@ -45,9 +46,11 @@ class Meta: model = KickoffValue fields = ( 'output', + 'fieldsets', ) output = TaskFieldEventSerializer(many=True) + fieldsets = FieldSetSerializer(many=True) class ActivityWorkflowSerializer(serializers.ModelSerializer): diff --git a/backend/src/reports/tests/test_views/test_highlights.py b/backend/src/reports/tests/test_views/test_highlights.py index de83c5c33..f53ca8f90 100644 --- a/backend/src/reports/tests/test_views/test_highlights.py +++ b/backend/src/reports/tests/test_views/test_highlights.py @@ -21,6 +21,7 @@ from src.processes.models.workflows.fields import TaskField from src.processes.models.workflows.task import Delay from src.processes.models.workflows.workflow import Workflow +from src.processes.models.workflows.fieldset import FieldSet from src.processes.services.events import ( WorkflowEventService, ) @@ -34,7 +35,7 @@ create_test_template, create_test_user, create_test_workflow, create_test_owner, create_test_dataset, - create_test_event, + create_test_event, create_test_admin, create_test_fieldset, ) from src.utils.validation import ErrorCode @@ -1514,7 +1515,6 @@ def test_complete_task_event__task_field_with_dataset__ok(api_client): assert field_data['id'] == field.id assert field_data['type'] == FieldType.DROPDOWN assert field_data['value'] == dataset_item.value - assert 'selections' not in field_data def test_complete_task_event__kickoff_field_with_dataset__ok(api_client): @@ -1553,4 +1553,283 @@ def test_complete_task_event__kickoff_field_with_dataset__ok(api_client): assert field_data['id'] == field.id assert field_data['type'] == FieldType.DROPDOWN assert field_data['value'] == dataset_item.value - assert 'selections' not in field_data + + +def test_highlights__task_complete_fieldsets_present__ok(api_client): + + """ + GET reports highlights: TASK_COMPLETE item includes non-null task.fieldsets + when the task has at least one FieldSet. + """ + + # arrange + + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user, tasks_count=1) + task_1 = workflow.tasks.get(number=1) + fieldset = FieldSet.objects.create( + account=account, + workflow=workflow, + task=task_1, + name='Fieldset 1', + order=1, + ) + field_1 = TaskField.objects.create( + account=account, + workflow=workflow, + task=task_1, + fieldset=fieldset, + name='Field 1', + type=FieldType.TEXT, + order=1, + ) + field_2 = TaskField.objects.create( + account=account, + workflow=workflow, + task=task_1, + fieldset=fieldset, + name='Field 2', + type=FieldType.NUMBER, + order=2, + ) + WorkflowEventService.task_complete_event( + task=task_1, + user=user, + after_create_actions=False, + ) + api_client.token_authenticate(user=user) + + # act + + response = api_client.get( + path='/reports/highlights', + ) + + # assert + assert response.status_code == 200 + event_data = response.data[0] + assert event_data['type'] == WorkflowEventType.TASK_COMPLETE + fieldsets_data = event_data['task']['fieldsets'] + assert len(fieldsets_data) == 1 + + fieldset_data = fieldsets_data[0] + assert fieldset_data['id'] == fieldset.id + assert fieldset_data['api_name'] == fieldset.api_name + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['order'] == fieldset.order + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + fields_data = fieldset_data['fields'] + assert len(fields_data) == 2 + field_2_data = fields_data[0] + assert field_2_data['id'] == field_2.id + assert field_2_data['order'] == field_2.order + assert field_2_data['type'] == field_2.type + assert field_2_data['is_required'] == field_2.is_required + assert field_2_data['is_hidden'] == field_2.is_hidden + assert field_2_data['description'] == field_2.description + assert field_2_data['api_name'] == field_2.api_name + assert field_2_data['name'] == field_2.name + assert field_2_data['value'] == field_2.value + assert field_2_data['markdown_value'] == field_2.markdown_value + assert field_2_data['clear_value'] == field_2.clear_value + assert field_2_data['user_id'] == field_2.user_id + assert field_2_data['group_id'] == field_2.group_id + assert field_2_data['attachments'] == [] + field_1_data = fields_data[1] + assert field_1_data['id'] == field_1.id + + +def test_highlights__task_complete_fieldsets_absent__ok(api_client): + + """ + GET reports highlights: TASK_COMPLETE item has task.fieldsets null when the + task has no FieldSet rows. + """ + + # arrange + + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user, tasks_count=1) + task_1 = workflow.tasks.get(number=1) + WorkflowEventService.task_complete_event( + task=task_1, + user=user, + after_create_actions=False, + ) + api_client.token_authenticate(user=user) + + # act + + response = api_client.get( + path='/reports/highlights', + ) + + # assert + + assert response.status_code == 200 + item_1 = response.data[0] + assert item_1['type'] == WorkflowEventType.TASK_COMPLETE + task_payload = item_1['task'] + assert task_payload['fieldsets'] is None + + +def test_highlights__non_complete_fieldsets_null__ok(api_client): + + """ + GET reports highlights: non-TASK_COMPLETE highlight exposes task.fieldsets + as null. + """ + + # arrange + + account = create_test_account() + user = create_test_owner(account=account) + workflow = create_test_workflow(user=user, tasks_count=1) + task_1 = workflow.tasks.get(number=1) + create_test_event( + workflow=workflow, + user=user, + task=task_1, + type_event=WorkflowEventType.COMMENT, + ) + api_client.token_authenticate(user=user) + + # act + + response = api_client.get( + path='/reports/highlights', + ) + + # assert + + assert response.status_code == 200 + item_1 = response.data[0] + assert item_1['type'] == WorkflowEventType.COMMENT + task_payload = item_1['task'] + assert task_payload['fieldsets'] is None + + +def test_highlights__start_workflow_kickoff_fieldset_present__ok(api_client): + + # arrange + account = create_test_account() + create_test_owner(account=account) + user = create_test_admin(account=account) + workflow = create_test_workflow(user, tasks_count=2, active_task_number=2) + kickoff = workflow.kickoff_instance + fieldset = create_test_fieldset( + workflow=workflow, + kickoff=kickoff, + order=1, + ) + field = fieldset.fields.first() + WorkflowEventService.workflow_run_event(workflow) + api_client.token_authenticate(user) + + # act + response = api_client.get('/reports/highlights') + + # assert + assert response.status_code == 200 + event_data = response.data[0] + assert event_data['type'] == WorkflowEventType.RUN + fieldsets_data = event_data['workflow']['kickoff']['fieldsets'] + assert len(fieldsets_data) == 1 + fieldset_data = fieldsets_data[0] + assert fieldset_data['id'] == fieldset.id + assert fieldset_data['api_name'] == fieldset.api_name + assert fieldset_data['name'] == fieldset.name + assert fieldset_data['description'] == fieldset.description + assert fieldset_data['order'] == fieldset.order + assert fieldset_data['label_position'] == fieldset.label_position + assert fieldset_data['layout'] == fieldset.layout + fields_data = fieldset_data['fields'] + assert len(fields_data) == 1 + field_data = fields_data[0] + assert field_data['id'] == field.id + assert field_data['order'] == field.order + assert field_data['type'] == field.type + assert field_data['is_required'] == field.is_required + assert field_data['is_hidden'] == field.is_hidden + assert field_data['description'] == field.description + assert field_data['api_name'] == field.api_name + assert field_data['name'] == field.name + assert field_data['value'] == field.value + assert field_data['markdown_value'] == field.markdown_value + assert field_data['clear_value'] == field.clear_value + assert field_data['user_id'] == field.user_id + assert field_data['group_id'] == field.group_id + assert field_data['attachments'] == [] + + +def test_highlights__start_workflow_kickoff_field_present__ok(api_client): + + # arrange + account = create_test_account() + create_test_owner(account=account) + user = create_test_admin(account=account) + workflow = create_test_workflow(user, tasks_count=2, active_task_number=2) + kickoff = workflow.kickoff_instance + + field = TaskField.objects.create( + type=FieldType.DROPDOWN, + name='dropdown', + kickoff=kickoff, + value='some value', + workflow=workflow, + account=account, + ) + WorkflowEventService.workflow_run_event(workflow) + api_client.token_authenticate(user) + + # act + response = api_client.get('/reports/highlights') + + # assert + assert response.status_code == 200 + event_data = response.data[0] + assert event_data['type'] == WorkflowEventType.RUN + fields_data = event_data['workflow']['kickoff']['output'] + assert len(fields_data) == 1 + field_data = fields_data[0] + + assert field_data['id'] == field.id + assert field_data['order'] == field.order + assert field_data['type'] == field.type + assert field_data['is_required'] == field.is_required + assert field_data['is_hidden'] == field.is_hidden + assert field_data['description'] == field.description + assert field_data['api_name'] == field.api_name + assert field_data['name'] == field.name + assert field_data['value'] == field.value + assert field_data['markdown_value'] == field.markdown_value + assert field_data['clear_value'] == field.clear_value + assert field_data['user_id'] == field.user_id + assert field_data['group_id'] == field.group_id + assert field_data['attachments'] == [] + + +def test_highlights__start_workflow_fieldset_absent__ok(api_client): + + # arrange + account = create_test_account() + create_test_owner(account=account) + user = create_test_admin(account=account) + workflow = create_test_workflow(user, tasks_count=2, active_task_number=2) + + WorkflowEventService.workflow_run_event(workflow) + api_client.token_authenticate(user) + + # act + response = api_client.get('/reports/highlights') + + # assert + assert response.status_code == 200 + event_data = response.data[0] + assert event_data['type'] == WorkflowEventType.RUN + assert event_data['workflow']['kickoff']['fieldsets'] == [] + assert event_data['workflow']['kickoff']['output'] == [] diff --git a/backend/src/services/locale/de/LC_MESSAGES/django.po b/backend/src/services/locale/de/LC_MESSAGES/django.po index 4c78de97e..14fdd4edb 100644 --- a/backend/src/services/locale/de/LC_MESSAGES/django.po +++ b/backend/src/services/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/services/locale/django.pot b/backend/src/services/locale/django.pot index 4c78de97e..14fdd4edb 100644 --- a/backend/src/services/locale/django.pot +++ b/backend/src/services/locale/django.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/services/locale/es/LC_MESSAGES/django.po b/backend/src/services/locale/es/LC_MESSAGES/django.po index 4c78de97e..14fdd4edb 100644 --- a/backend/src/services/locale/es/LC_MESSAGES/django.po +++ b/backend/src/services/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/services/locale/fr/LC_MESSAGES/django.po b/backend/src/services/locale/fr/LC_MESSAGES/django.po index 4c78de97e..14fdd4edb 100644 --- a/backend/src/services/locale/fr/LC_MESSAGES/django.po +++ b/backend/src/services/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/services/locale/ru/LC_MESSAGES/django.po b/backend/src/services/locale/ru/LC_MESSAGES/django.po index afa6bfaef..77647008d 100644 --- a/backend/src/services/locale/ru/LC_MESSAGES/django.po +++ b/backend/src/services/locale/ru/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-07 11:48+0000\n" +"POT-Creation-Date: 2026-04-13 23:53+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/backend/src/settings.py b/backend/src/settings.py index b7704164d..2645dd4d7 100644 --- a/backend/src/settings.py +++ b/backend/src/settings.py @@ -344,8 +344,8 @@ class Common(Configuration): ] # reCaptcha - DRF_RECAPTCHA_SITE_KEY = env.get('RECAPTCHA_SITE_KEY', 'key') - DRF_RECAPTCHA_SECRET_KEY = env.get('RECAPTCHA_SECRET_KEY', 'key') + DRF_RECAPTCHA_SITE_KEY = env.get('RECAPTCHA_SITE_KEY') or 'key' + DRF_RECAPTCHA_SECRET_KEY = env.get('RECAPTCHA_SECRET_KEY') or 'key' DRF_RECAPTCHA_TESTING = env.get('RECAPTCHA_TESTING', 'yes') == 'yes' # Firebase Credentials @@ -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" diff --git a/default.env b/default.env new file mode 100644 index 000000000..b0f0f56ba --- /dev/null +++ b/default.env @@ -0,0 +1,201 @@ +# Pneumatic Workflow Configuration + +# ============================================================================= +# Server address +# ============================================================================= +# SERVER_ADDRESS=localhost +# BACKEND_URL=http://localhost:8001 +# FRONTEND_URL=http://localhost +# FORMS_URL=http://form.localhost +# FORM_DOMAIN=form.localhost +# WSS_URL=ws://localhost:8001 +# BACKEND_PRIVATE_URL=http://pneumatic-nginx/ + + +# ============================================================================= +# Nginx / SSL +# ============================================================================= + +# --- SSL enabled --- +# CERTBOT_ENABLE=yes +# CERTBOT_EMAIL= +# NGINX_CONF_TEMPLATE=./nginx/ssl_templates/ + +# ============================================================================= +# Database +# ============================================================================= + +# --- PostgreSQL --- +# POSTGRES_PASSWORD=postgres_password +# POSTGRES_REPLICA_PASSWORD=postgres_password +# POSTGRES_HOST=postgres +# POSTGRES_USER=postgres_user +# POSTGRES_DB=postgres_db +# POSTGRES_REPLICA_HOST=postgres +# POSTGRES_REPLICA_USER=postgres_user +# POSTGRES_REPLICA_DB=postgres_db + +# --- Redis --- +# REDIS_PASSWORD=redis_password + +# --- RabbitMQ --- +# RABBITMQ_USER=rabbitmq_user +# RABBITMQ_PASSWORD=rabbitmq_password + +# ============================================================================= +# Application +# ============================================================================= + +# ENVIRONMENT=Production +# LANGUAGE_CODE=en + +# --- Backend --- +# WORKERS_COUNT=2 +# DJANGO_DEBUG=no +# ADMIN_PATH=admin +# DJANGO_SECRET_KEY=django_secret_django_secret_django_secret +# ENABLE_LOGGING=no +# SENTRY_DSN= + +# --- Frontend --- +# NODE_OPTIONS=--max-old-space-size=3072 +# SENTRY_FRONTEND_DSN= +# MCS_RUN_ENV=prod + +# ============================================================================= +# SIGNUP +# ============================================================================= +# SIGNUP=yes + +# ============================================================================= +# Authorization +# ============================================================================= + +# --- Microsoft Entra ID (AD) --- +# MS_AUTH=no +# MS_CLIENT_ID= +# MS_CLIENT_SECRET= +# MS_AUTHORITY= + +# --- Google --- +# GOOGLE_AUTH=no +# GOOGLE_OAUTH2_CLIENT_ID= +# GOOGLE_OAUTH2_CLIENT_SECRET= +# GOOGLE_OAUTH2_REDIRECT_URI= + +# --- SSO --- +# SSO_AUTH=no + +# Okta +# SSO_PROVIDER=okta +# OKTA_DOMAIN= +# OKTA_CLIENT_ID= +# OKTA_CLIENT_SECRET= +# OKTA_REDIRECT_URI= + +# Auth0 +# SSO_PROVIDER=auth0 +# AUTH0_CLIENT_ID= +# AUTH0_CLIENT_SECRET= +# AUTH0_DOMAIN= +# AUTH0_REDIRECT_URI= + +# ============================================================================= +# Email +# ============================================================================= +# EMAIL=no + +# --- Server --- +# EMAIL_PROVIDER=smtp +# EMAIL_HOST= +# EMAIL_PORT= +# EMAIL_HOST_USER= +# EMAIL_HOST_PASSWORD= +# EMAIL_USE_TLS= +# EMAIL_USE_SSL= +# EMAIL_TIMEOUT= + +# --- Customer.io --- +# EMAIL_PROVIDER=customerio +# CIO_WEBHOOK_API_VERSION= +# CIO_WEBHOOK_API_KEY= +# CIO_TRANSACTIONAL_API_KEY= +# CIO_TEMPLATE__RESET_PASSWORD= +# CIO_TEMPLATE__USER_DEACTIVATED= +# CIO_TEMPLATE__NEW_TASK= +# CIO_TEMPLATE__TASK_RETURNED= +# CIO_TEMPLATE__ACCOUNT_VERIFICATION= +# CIO_TEMPLATE__WORKFLOWS_DIGEST= +# CIO_TEMPLATE__TASKS_DIGEST= +# CIO_TEMPLATE__USER_TRANSFER= +# CIO_TEMPLATE__UNREAD_NOTIFICATIONS= +# CIO_TEMPLATE__GUEST_NEW_TASK= +# CIO_TEMPLATE__OVERDUE_TASK= +# CIO_TEMPLATE__MENTION= +# CIO_TEMPLATE__TASK_REMINDER= +# CIO_TEMPLATE__COMPLETE_WORKFLOW= +# CIO_TEMPLATE__VACATION_DELEGATION= + +# ============================================================================= +# Captcha +# ============================================================================= +# CAPTCHA=no +# RECAPTCHA_SITE_KEY= +# RECAPTCHA_SECRET_KEY= +# RECAPTCHA_TESTING=no + +# ============================================================================= +# AI +# ============================================================================= +# AI=no + +# --- OpenAI --- +# AI_PROVIDER=openai +# OPENAI_API_KEY= +# OPENAI_API_ORG= + +# ============================================================================= +# Push notifications +# ============================================================================= +# PUSH=no + +# --- Firebase --- +# PUSH_PROVIDER=firebase +# FIREBASE_VAPID_KEY= +# FIREBASE_API_KEY= +# FIREBASE_AUTH_DOMAIN= +# FIREBASE_PROJECT_ID= +# FIREBASE_STORAGE_BUCKET= +# FIREBASE_SENDER_ID= +# FIREBASE_APP_ID= +# FIREBASE_MEASUREMENT_ID= +# FIREBASE_PUSH_APPLICATION_CREDENTIALS=/pneumatic_backend/firebase-push.json + +# ============================================================================= +# Analytics +# ============================================================================= +# ANALYTICS=no + +# --- Segment --- +# ANALYTICS_WRITE_KEY= +# ANALYTICS_DEBUG=no + +# ============================================================================= +# Storage +# ============================================================================= +# STORAGE=no + +# --- Google Cloud --- +# STORAGE_PROVIDER=gcloud +# GCLOUD_BUCKET_NAME=pneumatic +# GOOGLE_APPLICATION_CREDENTIALS=/pneumatic_backend/google_api_credentials.json + +# ============================================================================= +# Billing +# ============================================================================= +# BILLING=no + +# --- Stripe --- +# STRIPE_SECRET_KEY= +# STRIPE_WEBHOOK_SECRET= +# STRIPE_WEBHOOK_IP_WHITELIST= \ No newline at end of file diff --git a/docker-compose.src.yml b/docker-compose.src.yml index 8a56efeba..0607a500f 100755 --- a/docker-compose.src.yml +++ b/docker-compose.src.yml @@ -76,11 +76,25 @@ services: RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru CAPTCHA: ${CAPTCHA:-no} + RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY:-} + RECAPTCHA_SECRET_KEY: ${RECAPTCHA_SECRET_KEY:-} + RECAPTCHA_TESTING: ${RECAPTCHA_TESTING:-no} ANALYTICS: ${ANALYTICS:-no} + ANALYTICS_DEBUG: ${ANALYTICS_DEBUG:-no} + ANALYTICS_WRITE_KEY: ${ANALYTICS_WRITE_KEY:-} BILLING: ${BILLING:-no} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_WEBHOOK_IP_WHITELIST: ${STRIPE_WEBHOOK_IP_WHITELIST:-} SIGNUP: ${SIGNUP:-yes} MS_AUTH: ${MS_AUTH:-no} + MS_CLIENT_ID: ${MS_CLIENT_ID:-} + MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-} + MS_AUTHORITY: ${MS_AUTHORITY:-} GOOGLE_AUTH: ${GOOGLE_AUTH:-no} + GOOGLE_OAUTH2_CLIENT_ID: ${GOOGLE_OAUTH2_CLIENT_ID:-} + GOOGLE_OAUTH2_CLIENT_SECRET: ${GOOGLE_OAUTH2_CLIENT_SECRET:-} + GOOGLE_OAUTH2_REDIRECT_URI: ${GOOGLE_OAUTH2_REDIRECT_URI:-} SSO_AUTH: ${SSO_AUTH:-no} SSO_PROVIDER: ${SSO_PROVIDER:-} # Allowed values: okta, auth0 OKTA_DOMAIN: ${OKTA_DOMAIN:-} @@ -120,11 +134,15 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} + FIREBASE_PUSH_APPLICATION_CREDENTIALS: ${FIREBASE_PUSH_APPLICATION_CREDENTIALS:-/pneumatic_backend/firebase-push.json} STORAGE: ${STORAGE:-no} STORAGE_PROVIDER: ${STORAGE_PROVIDER:-} - GOOGLE_APPLICATION_CREDENTIALS: /pneumatic_backend/google_api_credentials.json + GCLOUD_BUCKET_NAME: ${GCLOUD_BUCKET_NAME:-pneumatic} + GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-/pneumatic_backend/google_api_credentials.json} SENTRY_DSN: ${SENTRY_DSN:-} POSTGRES_HOST: ${POSTGRES_HOST:-postgres} POSTGRES_USER: ${POSTGRES_USER:-postgres_user} @@ -143,7 +161,7 @@ services: CHANNELS_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/2" SESSION_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/3" CELERY_BROKER_URL: "amqp://${RABBITMQ_USER:-rabbitmq_user}:${RABBITMQ_PASSWORD:-rabbitmq_password}@rabbitmq:5672" - ALLOWED_HOSTS: "pneumatic-nginx ${FRONTEND_DOMAIN:-localhost}" + ALLOWED_HOSTS: "pneumatic-nginx ${SERVER_ADDRESS:-localhost}" VERIFICATION_CHECK: no CORS_ORIGIN_ALLOW_ALL: no CORS_ALLOW_CREDENTIALS: yes @@ -179,11 +197,25 @@ services: RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru CAPTCHA: ${CAPTCHA:-no} + RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY:-} + RECAPTCHA_SECRET_KEY: ${RECAPTCHA_SECRET_KEY:-} + RECAPTCHA_TESTING: ${RECAPTCHA_TESTING:-no} ANALYTICS: ${ANALYTICS:-no} + ANALYTICS_DEBUG: ${ANALYTICS_DEBUG:-no} + ANALYTICS_WRITE_KEY: ${ANALYTICS_WRITE_KEY:-} BILLING: ${BILLING:-no} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_WEBHOOK_IP_WHITELIST: ${STRIPE_WEBHOOK_IP_WHITELIST:-} SIGNUP: ${SIGNUP:-yes} MS_AUTH: ${MS_AUTH:-no} + MS_CLIENT_ID: ${MS_CLIENT_ID:-} + MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-} + MS_AUTHORITY: ${MS_AUTHORITY:-} GOOGLE_AUTH: ${GOOGLE_AUTH:-no} + GOOGLE_OAUTH2_CLIENT_ID: ${GOOGLE_OAUTH2_CLIENT_ID:-} + GOOGLE_OAUTH2_CLIENT_SECRET: ${GOOGLE_OAUTH2_CLIENT_SECRET:-} + GOOGLE_OAUTH2_REDIRECT_URI: ${GOOGLE_OAUTH2_REDIRECT_URI:-} SSO_AUTH: ${SSO_AUTH:-no} SSO_PROVIDER: ${SSO_PROVIDER:-} # Allowed values: okta, auth0 OKTA_DOMAIN: ${OKTA_DOMAIN:-} @@ -223,11 +255,15 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} + FIREBASE_PUSH_APPLICATION_CREDENTIALS: ${FIREBASE_PUSH_APPLICATION_CREDENTIALS:-/pneumatic_backend/firebase-push.json} STORAGE: ${STORAGE:-no} STORAGE_PROVIDER: ${STORAGE_PROVIDER:-} - GOOGLE_APPLICATION_CREDENTIALS: /pneumatic_backend/google_api_credentials.json + GCLOUD_BUCKET_NAME: ${GCLOUD_BUCKET_NAME:-pneumatic} + GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-/pneumatic_backend/google_api_credentials.json} SENTRY_DSN: ${SENTRY_DSN:-} POSTGRES_HOST: ${POSTGRES_HOST:-postgres} POSTGRES_USER: ${POSTGRES_USER:-postgres_user} @@ -246,7 +282,7 @@ services: CHANNELS_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/2" SESSION_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/3" CELERY_BROKER_URL: "amqp://${RABBITMQ_USER:-rabbitmq_user}:${RABBITMQ_PASSWORD:-rabbitmq_password}@rabbitmq:5672" - ALLOWED_HOSTS: "pneumatic-nginx ${FRONTEND_DOMAIN:-localhost}" + ALLOWED_HOSTS: "pneumatic-nginx ${SERVER_ADDRESS:-localhost}" VERIFICATION_CHECK: no CORS_ORIGIN_ALLOW_ALL: no CORS_ALLOW_CREDENTIALS: yes @@ -276,11 +312,25 @@ services: RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru CAPTCHA: ${CAPTCHA:-no} + RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY:-} + RECAPTCHA_SECRET_KEY: ${RECAPTCHA_SECRET_KEY:-} + RECAPTCHA_TESTING: ${RECAPTCHA_TESTING:-no} ANALYTICS: ${ANALYTICS:-no} + ANALYTICS_DEBUG: ${ANALYTICS_DEBUG:-no} + ANALYTICS_WRITE_KEY: ${ANALYTICS_WRITE_KEY:-} BILLING: ${BILLING:-no} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_WEBHOOK_IP_WHITELIST: ${STRIPE_WEBHOOK_IP_WHITELIST:-} SIGNUP: ${SIGNUP:-yes} MS_AUTH: ${MS_AUTH:-no} + MS_CLIENT_ID: ${MS_CLIENT_ID:-} + MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-} + MS_AUTHORITY: ${MS_AUTHORITY:-} GOOGLE_AUTH: ${GOOGLE_AUTH:-no} + GOOGLE_OAUTH2_CLIENT_ID: ${GOOGLE_OAUTH2_CLIENT_ID:-} + GOOGLE_OAUTH2_CLIENT_SECRET: ${GOOGLE_OAUTH2_CLIENT_SECRET:-} + GOOGLE_OAUTH2_REDIRECT_URI: ${GOOGLE_OAUTH2_REDIRECT_URI:-} SSO_AUTH: ${SSO_AUTH:-no} SSO_PROVIDER: ${SSO_PROVIDER:-} # Allowed values: okta, auth0 OKTA_DOMAIN: ${OKTA_DOMAIN:-} @@ -320,11 +370,15 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} + FIREBASE_PUSH_APPLICATION_CREDENTIALS: ${FIREBASE_PUSH_APPLICATION_CREDENTIALS:-/pneumatic_backend/firebase-push.json} STORAGE: ${STORAGE:-no} STORAGE_PROVIDER: ${STORAGE_PROVIDER:-} - GOOGLE_APPLICATION_CREDENTIALS: /pneumatic_backend/google_api_credentials.json + GCLOUD_BUCKET_NAME: ${GCLOUD_BUCKET_NAME:-pneumatic} + GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-/pneumatic_backend/google_api_credentials.json} SENTRY_DSN: ${SENTRY_DSN:-} POSTGRES_HOST: ${POSTGRES_HOST:-postgres} POSTGRES_USER: ${POSTGRES_USER:-postgres_user} @@ -343,7 +397,7 @@ services: CHANNELS_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/2" SESSION_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/3" CELERY_BROKER_URL: "amqp://${RABBITMQ_USER:-rabbitmq_user}:${RABBITMQ_PASSWORD:-rabbitmq_password}@rabbitmq:5672" - ALLOWED_HOSTS: "pneumatic-nginx ${FRONTEND_DOMAIN:-localhost}" + ALLOWED_HOSTS: "pneumatic-nginx ${SERVER_ADDRESS:-localhost}" VERIFICATION_CHECK: no CORS_ORIGIN_ALLOW_ALL: no CORS_ALLOW_CREDENTIALS: yes @@ -375,6 +429,7 @@ services: LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru CAPTCHA: ${CAPTCHA:-no} ANALYTICS: ${ANALYTICS:-no} + ANALYTICS_WRITE_KEY: ${ANALYTICS_WRITE_KEY:-} BILLING: ${BILLING:-no} SIGNUP: ${SIGNUP:-yes} MS_AUTH: ${MS_AUTH:-no} @@ -411,8 +466,7 @@ services: container_name: pneumatic-nginx environment: SSL: ${SSL:-no} - BACKEND_DOMAIN: ${BACKEND_DOMAIN:-localhost} - FRONTEND_DOMAIN: ${FRONTEND_DOMAIN:-localhost} + SERVER_ADDRESS: ${SERVER_ADDRESS:-localhost} FORM_DOMAIN: ${FORM_DOMAIN:-form.localhost} NGINX_ENVSUBST_OUTPUT_DIR: /etc/nginx/ NGINX_ENVSUBST_TEMPLATE_DIR: /etc/nginx/templates @@ -421,6 +475,7 @@ services: - ./nginx/keys/:/etc/keys/:ro - ./nginx/www/:/var/www/:ro - ${NGINX_CONF_TEMPLATE:-./nginx/templates/}:/etc/nginx/templates + - ./nginx/includes/:/etc/nginx/includes/:ro - backend-socket:/tmp/gunicorn/ - backend-staticfiles:/tmp/backend-staticfiles/ ports: diff --git a/docker-compose.yml b/docker-compose.yml index ea914dc76..162cce6f0 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,11 +77,25 @@ services: RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru CAPTCHA: ${CAPTCHA:-no} + RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY:-} + RECAPTCHA_SECRET_KEY: ${RECAPTCHA_SECRET_KEY:-} + RECAPTCHA_TESTING: ${RECAPTCHA_TESTING:-no} ANALYTICS: ${ANALYTICS:-no} + ANALYTICS_DEBUG: ${ANALYTICS_DEBUG:-no} + ANALYTICS_WRITE_KEY: ${ANALYTICS_WRITE_KEY:-} BILLING: ${BILLING:-no} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_WEBHOOK_IP_WHITELIST: ${STRIPE_WEBHOOK_IP_WHITELIST:-} SIGNUP: ${SIGNUP:-yes} MS_AUTH: ${MS_AUTH:-no} + MS_CLIENT_ID: ${MS_CLIENT_ID:-} + MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-} + MS_AUTHORITY: ${MS_AUTHORITY:-} GOOGLE_AUTH: ${GOOGLE_AUTH:-no} + GOOGLE_OAUTH2_CLIENT_ID: ${GOOGLE_OAUTH2_CLIENT_ID:-} + GOOGLE_OAUTH2_CLIENT_SECRET: ${GOOGLE_OAUTH2_CLIENT_SECRET:-} + GOOGLE_OAUTH2_REDIRECT_URI: ${GOOGLE_OAUTH2_REDIRECT_URI:-} SSO_AUTH: ${SSO_AUTH:-no} SSO_PROVIDER: ${SSO_PROVIDER:-} # Allowed values: okta, auth0 OKTA_DOMAIN: ${OKTA_DOMAIN:-} @@ -121,11 +135,15 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} + FIREBASE_PUSH_APPLICATION_CREDENTIALS: ${FIREBASE_PUSH_APPLICATION_CREDENTIALS:-/pneumatic_backend/firebase-push.json} STORAGE: ${STORAGE:-no} STORAGE_PROVIDER: ${STORAGE_PROVIDER:-} - GOOGLE_APPLICATION_CREDENTIALS: /pneumatic_backend/google_api_credentials.json + GCLOUD_BUCKET_NAME: ${GCLOUD_BUCKET_NAME:-pneumatic} + GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-/pneumatic_backend/google_api_credentials.json} SENTRY_DSN: ${SENTRY_DSN:-} POSTGRES_HOST: ${POSTGRES_HOST:-postgres} POSTGRES_USER: ${POSTGRES_USER:-postgres_user} @@ -144,7 +162,7 @@ services: CHANNELS_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/2" SESSION_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/3" CELERY_BROKER_URL: "amqp://${RABBITMQ_USER:-rabbitmq_user}:${RABBITMQ_PASSWORD:-rabbitmq_password}@rabbitmq:5672" - ALLOWED_HOSTS: "pneumatic-nginx ${FRONTEND_DOMAIN:-localhost}" + ALLOWED_HOSTS: "pneumatic-nginx ${SERVER_ADDRESS:-localhost}" VERIFICATION_CHECK: no CORS_ORIGIN_ALLOW_ALL: no CORS_ALLOW_CREDENTIALS: yes @@ -180,11 +198,25 @@ services: RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru CAPTCHA: ${CAPTCHA:-no} + RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY:-} + RECAPTCHA_SECRET_KEY: ${RECAPTCHA_SECRET_KEY:-} + RECAPTCHA_TESTING: ${RECAPTCHA_TESTING:-no} ANALYTICS: ${ANALYTICS:-no} + ANALYTICS_DEBUG: ${ANALYTICS_DEBUG:-no} + ANALYTICS_WRITE_KEY: ${ANALYTICS_WRITE_KEY:-} BILLING: ${BILLING:-no} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_WEBHOOK_IP_WHITELIST: ${STRIPE_WEBHOOK_IP_WHITELIST:-} SIGNUP: ${SIGNUP:-yes} MS_AUTH: ${MS_AUTH:-no} + MS_CLIENT_ID: ${MS_CLIENT_ID:-} + MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-} + MS_AUTHORITY: ${MS_AUTHORITY:-} GOOGLE_AUTH: ${GOOGLE_AUTH:-no} + GOOGLE_OAUTH2_CLIENT_ID: ${GOOGLE_OAUTH2_CLIENT_ID:-} + GOOGLE_OAUTH2_CLIENT_SECRET: ${GOOGLE_OAUTH2_CLIENT_SECRET:-} + GOOGLE_OAUTH2_REDIRECT_URI: ${GOOGLE_OAUTH2_REDIRECT_URI:-} SSO_AUTH: ${SSO_AUTH:-no} SSO_PROVIDER: ${SSO_PROVIDER:-} # Allowed values: okta, auth0 OKTA_DOMAIN: ${OKTA_DOMAIN:-} @@ -224,11 +256,15 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} + FIREBASE_PUSH_APPLICATION_CREDENTIALS: ${FIREBASE_PUSH_APPLICATION_CREDENTIALS:-/pneumatic_backend/firebase-push.json} STORAGE: ${STORAGE:-no} STORAGE_PROVIDER: ${STORAGE_PROVIDER:-} - GOOGLE_APPLICATION_CREDENTIALS: /pneumatic_backend/google_api_credentials.json + GCLOUD_BUCKET_NAME: ${GCLOUD_BUCKET_NAME:-pneumatic} + GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-/pneumatic_backend/google_api_credentials.json} SENTRY_DSN: ${SENTRY_DSN:-} POSTGRES_HOST: ${POSTGRES_HOST:-postgres} POSTGRES_USER: ${POSTGRES_USER:-postgres_user} @@ -247,7 +283,7 @@ services: CHANNELS_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/2" SESSION_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/3" CELERY_BROKER_URL: "amqp://${RABBITMQ_USER:-rabbitmq_user}:${RABBITMQ_PASSWORD:-rabbitmq_password}@rabbitmq:5672" - ALLOWED_HOSTS: "pneumatic-nginx ${FRONTEND_DOMAIN:-localhost}" + ALLOWED_HOSTS: "pneumatic-nginx ${SERVER_ADDRESS:-localhost}" VERIFICATION_CHECK: no CORS_ORIGIN_ALLOW_ALL: no CORS_ALLOW_CREDENTIALS: yes @@ -278,11 +314,25 @@ services: RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru CAPTCHA: ${CAPTCHA:-no} + RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY:-} + RECAPTCHA_SECRET_KEY: ${RECAPTCHA_SECRET_KEY:-} + RECAPTCHA_TESTING: ${RECAPTCHA_TESTING:-no} ANALYTICS: ${ANALYTICS:-no} + ANALYTICS_DEBUG: ${ANALYTICS_DEBUG:-no} + ANALYTICS_WRITE_KEY: ${ANALYTICS_WRITE_KEY:-} BILLING: ${BILLING:-no} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_WEBHOOK_IP_WHITELIST: ${STRIPE_WEBHOOK_IP_WHITELIST:-} SIGNUP: ${SIGNUP:-yes} MS_AUTH: ${MS_AUTH:-no} + MS_CLIENT_ID: ${MS_CLIENT_ID:-} + MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-} + MS_AUTHORITY: ${MS_AUTHORITY:-} GOOGLE_AUTH: ${GOOGLE_AUTH:-no} + GOOGLE_OAUTH2_CLIENT_ID: ${GOOGLE_OAUTH2_CLIENT_ID:-} + GOOGLE_OAUTH2_CLIENT_SECRET: ${GOOGLE_OAUTH2_CLIENT_SECRET:-} + GOOGLE_OAUTH2_REDIRECT_URI: ${GOOGLE_OAUTH2_REDIRECT_URI:-} SSO_AUTH: ${SSO_AUTH:-no} SSO_PROVIDER: ${SSO_PROVIDER:-} # Allowed values: okta, auth0 OKTA_DOMAIN: ${OKTA_DOMAIN:-} @@ -322,11 +372,15 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} + FIREBASE_PUSH_APPLICATION_CREDENTIALS: ${FIREBASE_PUSH_APPLICATION_CREDENTIALS:-/pneumatic_backend/firebase-push.json} STORAGE: ${STORAGE:-no} STORAGE_PROVIDER: ${STORAGE_PROVIDER:-} - GOOGLE_APPLICATION_CREDENTIALS: /pneumatic_backend/google_api_credentials.json + GCLOUD_BUCKET_NAME: ${GCLOUD_BUCKET_NAME:-pneumatic} + GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-/pneumatic_backend/google_api_credentials.json} SENTRY_DSN: ${SENTRY_DSN:-} POSTGRES_HOST: ${POSTGRES_HOST:-postgres} POSTGRES_USER: ${POSTGRES_USER:-postgres_user} @@ -345,7 +399,7 @@ services: CHANNELS_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/2" SESSION_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/3" CELERY_BROKER_URL: "amqp://${RABBITMQ_USER:-rabbitmq_user}:${RABBITMQ_PASSWORD:-rabbitmq_password}@rabbitmq:5672" - ALLOWED_HOSTS: "pneumatic-nginx ${FRONTEND_DOMAIN:-localhost}" + ALLOWED_HOSTS: "pneumatic-nginx ${SERVER_ADDRESS:-localhost}" VERIFICATION_CHECK: no CORS_ORIGIN_ALLOW_ALL: no CORS_ALLOW_CREDENTIALS: yes @@ -378,6 +432,7 @@ services: LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru CAPTCHA: ${CAPTCHA:-no} ANALYTICS: ${ANALYTICS:-no} + ANALYTICS_WRITE_KEY: ${ANALYTICS_WRITE_KEY:-} BILLING: ${BILLING:-no} SIGNUP: ${SIGNUP:-yes} MS_AUTH: ${MS_AUTH:-no} @@ -413,8 +468,7 @@ services: image: nginx:1.25.4 container_name: pneumatic-nginx environment: - BACKEND_DOMAIN: ${BACKEND_DOMAIN:-localhost} - FRONTEND_DOMAIN: ${FRONTEND_DOMAIN:-localhost} + SERVER_ADDRESS: ${SERVER_ADDRESS:-localhost} FORM_DOMAIN: ${FORM_DOMAIN:-form.localhost} NGINX_ENVSUBST_OUTPUT_DIR: /etc/nginx/ NGINX_ENVSUBST_TEMPLATE_DIR: /etc/nginx/templates @@ -423,6 +477,7 @@ services: - ./nginx/keys/:/etc/keys/:ro - ./nginx/www/:/var/www/:ro - ${NGINX_CONF_TEMPLATE:-./nginx/templates/}:/etc/nginx/templates + - ./nginx/includes/:/etc/nginx/includes/:ro - backend-socket:/tmp/gunicorn/ - backend-staticfiles:/tmp/backend-staticfiles/ ports: diff --git a/frontend/config/common.json b/frontend/config/common.json index 968b3a48a..ea4ce3f81 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", "vacationActivate": "/accounts/user/activate-vacation", "vacationDeactivate": "/accounts/user/deactivate-vacation", diff --git a/frontend/docker-compose.yml b/frontend/docker-compose.yml index c76773322..3baddaec0 100755 --- a/frontend/docker-compose.yml +++ b/frontend/docker-compose.yml @@ -77,11 +77,25 @@ services: RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru CAPTCHA: ${CAPTCHA:-no} + RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY:-} + RECAPTCHA_SECRET_KEY: ${RECAPTCHA_SECRET_KEY:-} + RECAPTCHA_TESTING: ${RECAPTCHA_TESTING:-no} ANALYTICS: ${ANALYTICS:-no} + ANALYTICS_DEBUG: ${ANALYTICS_DEBUG:-no} + ANALYTICS_WRITE_KEY: ${ANALYTICS_WRITE_KEY:-} BILLING: ${BILLING:-no} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_WEBHOOK_IP_WHITELIST: ${STRIPE_WEBHOOK_IP_WHITELIST:-} SIGNUP: ${SIGNUP:-yes} MS_AUTH: ${MS_AUTH:-no} + MS_CLIENT_ID: ${MS_CLIENT_ID:-} + MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-} + MS_AUTHORITY: ${MS_AUTHORITY:-} GOOGLE_AUTH: ${GOOGLE_AUTH:-no} + GOOGLE_OAUTH2_CLIENT_ID: ${GOOGLE_OAUTH2_CLIENT_ID:-} + GOOGLE_OAUTH2_CLIENT_SECRET: ${GOOGLE_OAUTH2_CLIENT_SECRET:-} + GOOGLE_OAUTH2_REDIRECT_URI: ${GOOGLE_OAUTH2_REDIRECT_URI:-} SSO_AUTH: ${SSO_AUTH:-no} SSO_PROVIDER: ${SSO_PROVIDER:-} # Allowed values: okta, auth0 OKTA_DOMAIN: ${OKTA_DOMAIN:-} @@ -121,10 +135,15 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} + FIREBASE_PUSH_APPLICATION_CREDENTIALS: ${FIREBASE_PUSH_APPLICATION_CREDENTIALS:-/pneumatic_backend/firebase-push.json} STORAGE: ${STORAGE:-no} STORAGE_PROVIDER: ${STORAGE_PROVIDER:-} + GCLOUD_BUCKET_NAME: ${GCLOUD_BUCKET_NAME:-pneumatic} + GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-/pneumatic_backend/google_api_credentials.json} SENTRY_DSN: ${SENTRY_DSN:-} POSTGRES_HOST: ${POSTGRES_HOST:-postgres} POSTGRES_USER: ${POSTGRES_USER:-postgres_user} @@ -143,7 +162,7 @@ services: CHANNELS_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/2" SESSION_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/3" CELERY_BROKER_URL: "amqp://${RABBITMQ_USER:-rabbitmq_user}:${RABBITMQ_PASSWORD:-rabbitmq_password}@rabbitmq:5672" - ALLOWED_HOSTS: "pneumatic-nginx ${FRONTEND_DOMAIN:-localhost}" + ALLOWED_HOSTS: "pneumatic-nginx ${SERVER_ADDRESS:-localhost}" VERIFICATION_CHECK: no CORS_ORIGIN_ALLOW_ALL: no CORS_ALLOW_CREDENTIALS: yes @@ -176,11 +195,25 @@ services: RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru CAPTCHA: ${CAPTCHA:-no} + RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY:-} + RECAPTCHA_SECRET_KEY: ${RECAPTCHA_SECRET_KEY:-} + RECAPTCHA_TESTING: ${RECAPTCHA_TESTING:-no} ANALYTICS: ${ANALYTICS:-no} + ANALYTICS_DEBUG: ${ANALYTICS_DEBUG:-no} + ANALYTICS_WRITE_KEY: ${ANALYTICS_WRITE_KEY:-} BILLING: ${BILLING:-no} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_WEBHOOK_IP_WHITELIST: ${STRIPE_WEBHOOK_IP_WHITELIST:-} SIGNUP: ${SIGNUP:-yes} MS_AUTH: ${MS_AUTH:-no} + MS_CLIENT_ID: ${MS_CLIENT_ID:-} + MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-} + MS_AUTHORITY: ${MS_AUTHORITY:-} GOOGLE_AUTH: ${GOOGLE_AUTH:-no} + GOOGLE_OAUTH2_CLIENT_ID: ${GOOGLE_OAUTH2_CLIENT_ID:-} + GOOGLE_OAUTH2_CLIENT_SECRET: ${GOOGLE_OAUTH2_CLIENT_SECRET:-} + GOOGLE_OAUTH2_REDIRECT_URI: ${GOOGLE_OAUTH2_REDIRECT_URI:-} SSO_AUTH: ${SSO_AUTH:-no} SSO_PROVIDER: ${SSO_PROVIDER:-} # Allowed values: okta, auth0 OKTA_DOMAIN: ${OKTA_DOMAIN:-} @@ -220,10 +253,15 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} + FIREBASE_PUSH_APPLICATION_CREDENTIALS: ${FIREBASE_PUSH_APPLICATION_CREDENTIALS:-/pneumatic_backend/firebase-push.json} STORAGE: ${STORAGE:-no} STORAGE_PROVIDER: ${STORAGE_PROVIDER:-} + GCLOUD_BUCKET_NAME: ${GCLOUD_BUCKET_NAME:-pneumatic} + GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-/pneumatic_backend/google_api_credentials.json} SENTRY_DSN: ${SENTRY_DSN:-} POSTGRES_HOST: ${POSTGRES_HOST:-postgres} POSTGRES_USER: ${POSTGRES_USER:-postgres_user} @@ -242,7 +280,7 @@ services: CHANNELS_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/2" SESSION_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/3" CELERY_BROKER_URL: "amqp://${RABBITMQ_USER:-rabbitmq_user}:${RABBITMQ_PASSWORD:-rabbitmq_password}@rabbitmq:5672" - ALLOWED_HOSTS: "pneumatic-nginx ${FRONTEND_DOMAIN:-localhost}" + ALLOWED_HOSTS: "pneumatic-nginx ${SERVER_ADDRESS:-localhost}" VERIFICATION_CHECK: no CORS_ORIGIN_ALLOW_ALL: no CORS_ALLOW_CREDENTIALS: yes @@ -272,11 +310,25 @@ services: RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru CAPTCHA: ${CAPTCHA:-no} + RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY:-} + RECAPTCHA_SECRET_KEY: ${RECAPTCHA_SECRET_KEY:-} + RECAPTCHA_TESTING: ${RECAPTCHA_TESTING:-no} ANALYTICS: ${ANALYTICS:-no} + ANALYTICS_DEBUG: ${ANALYTICS_DEBUG:-no} + ANALYTICS_WRITE_KEY: ${ANALYTICS_WRITE_KEY:-} BILLING: ${BILLING:-no} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_WEBHOOK_IP_WHITELIST: ${STRIPE_WEBHOOK_IP_WHITELIST:-} SIGNUP: ${SIGNUP:-yes} MS_AUTH: ${MS_AUTH:-no} + MS_CLIENT_ID: ${MS_CLIENT_ID:-} + MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-} + MS_AUTHORITY: ${MS_AUTHORITY:-} GOOGLE_AUTH: ${GOOGLE_AUTH:-no} + GOOGLE_OAUTH2_CLIENT_ID: ${GOOGLE_OAUTH2_CLIENT_ID:-} + GOOGLE_OAUTH2_CLIENT_SECRET: ${GOOGLE_OAUTH2_CLIENT_SECRET:-} + GOOGLE_OAUTH2_REDIRECT_URI: ${GOOGLE_OAUTH2_REDIRECT_URI:-} SSO_AUTH: ${SSO_AUTH:-no} SSO_PROVIDER: ${SSO_PROVIDER:-} # Allowed values: okta, auth0 OKTA_DOMAIN: ${OKTA_DOMAIN:-} @@ -316,10 +368,15 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} + FIREBASE_PUSH_APPLICATION_CREDENTIALS: ${FIREBASE_PUSH_APPLICATION_CREDENTIALS:-/pneumatic_backend/firebase-push.json} STORAGE: ${STORAGE:-no} STORAGE_PROVIDER: ${STORAGE_PROVIDER:-} + GCLOUD_BUCKET_NAME: ${GCLOUD_BUCKET_NAME:-pneumatic} + GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-/pneumatic_backend/google_api_credentials.json} SENTRY_DSN: ${SENTRY_DSN:-} POSTGRES_HOST: ${POSTGRES_HOST:-postgres} POSTGRES_USER: ${POSTGRES_USER:-postgres_user} @@ -338,7 +395,7 @@ services: CHANNELS_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/2" SESSION_REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_password}@redis:6379/3" CELERY_BROKER_URL: "amqp://${RABBITMQ_USER:-rabbitmq_user}:${RABBITMQ_PASSWORD:-rabbitmq_password}@rabbitmq:5672" - ALLOWED_HOSTS: "pneumatic-nginx ${FRONTEND_DOMAIN:-localhost}" + ALLOWED_HOSTS: "pneumatic-nginx ${SERVER_ADDRESS:-localhost}" VERIFICATION_CHECK: no CORS_ORIGIN_ALLOW_ALL: no CORS_ALLOW_CREDENTIALS: yes diff --git a/frontend/src/public/api/__tests__/getTemplateFields.test.ts b/frontend/src/public/api/__tests__/getTemplateFields.test.ts new file mode 100644 index 000000000..4b5515f88 --- /dev/null +++ b/frontend/src/public/api/__tests__/getTemplateFields.test.ts @@ -0,0 +1,38 @@ +// + +const MOCK_URLS = { + templateFields: '/templates/:id/fields', +}; + +jest.mock('../../utils/getConfig', () => ({ + getBrowserConfigEnv: jest.fn().mockReturnValue({ + api: { urls: MOCK_URLS }, + }), +})); + +jest.mock('../commonRequest'); + +import { commonRequest } from '../commonRequest'; +import { getTemplateFields } from '../getTemplateFields'; + +describe('getTemplateFields', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls commonRequest with GET, correct URL and signal', async () => { + const mockResponse = { tasks: [], kickoff: { fields: [], fieldsets: [] } }; + (commonRequest as jest.Mock).mockResolvedValue(mockResponse); + + const abortController = new AbortController(); + const result = await getTemplateFields('42', abortController.signal); + + expect(commonRequest).toHaveBeenCalledTimes(1); + expect(commonRequest).toHaveBeenCalledWith( + '/templates/42/fields', + { signal: abortController.signal }, + { shouldThrow: true }, + ); + expect(result).toBe(mockResponse); + }); +}); diff --git a/frontend/src/public/api/fieldsets/__tests__/fieldsets-api.test.ts b/frontend/src/public/api/fieldsets/__tests__/fieldsets-api.test.ts new file mode 100644 index 000000000..f7b8d716c --- /dev/null +++ b/frontend/src/public/api/fieldsets/__tests__/fieldsets-api.test.ts @@ -0,0 +1,217 @@ +// + +const MOCK_URLS = { + templateFieldsets: '/templates/:id/fieldsets', + fieldset: '/fieldsets/:id', +}; + +jest.mock('../../../utils/getConfig', () => ({ + getBrowserConfigEnv: jest.fn().mockReturnValue({ + api: { urls: MOCK_URLS }, + }), +})); + +jest.mock('../../commonRequest'); + +jest.mock('../../../utils/mappers', () => ({ + mapRequestBody: jest.fn((obj: object) => JSON.stringify(obj)), +})); + +import { commonRequest } from '../../commonRequest'; +import { mapRequestBody } from '../../../utils/mappers'; +import { createFieldset } from '../createFieldset'; +import { getFieldset } from '../getFieldset'; +import { getFieldsets } from '../getFieldsets'; +import { updateFieldset } from '../updateFieldset'; +import { deleteFieldset } from '../deleteFieldset'; +import { EFieldsetsSorting } from '../../../types/fieldset'; + +describe('fieldsets API clients', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('createFieldset', () => { + it('calls commonRequest with POST, correct URL and body via mapRequestBody', async () => { + const mockResponse = { id: 1, name: 'Test' }; + (commonRequest as jest.Mock).mockResolvedValue(mockResponse); + + const params = { + templateId: 42, + name: 'New Fieldset', + description: 'Desc', + rules: [], + fields: [], + }; + + const result = await createFieldset(params); + + expect(commonRequest).toHaveBeenCalledTimes(1); + expect(commonRequest).toHaveBeenCalledWith( + '/templates/42/fieldsets', + { + method: 'POST', + data: expect.any(String), + }, + { shouldThrow: true }, + ); + + expect(mapRequestBody).toHaveBeenCalledTimes(1); + expect(mapRequestBody).toHaveBeenCalledWith({ + name: 'New Fieldset', + description: 'Desc', + rules: [], + fields: [], + }); + + expect(result).toBe(mockResponse); + }); + }); + + describe('getFieldset', () => { + it('calls commonRequest with GET, correct URL and signal in requestOptions', async () => { + const mockResponse = { id: 5, name: 'My Fieldset' }; + (commonRequest as jest.Mock).mockResolvedValue(mockResponse); + + const abortController = new AbortController(); + const result = await getFieldset({ id: 5, signal: abortController.signal }); + + expect(commonRequest).toHaveBeenCalledTimes(1); + expect(commonRequest).toHaveBeenCalledWith( + '/fieldsets/5', + { + method: 'GET', + signal: abortController.signal, + }, + { shouldThrow: true }, + ); + expect(result).toBe(mockResponse); + }); + }); + + describe('getFieldsets', () => { + it('builds URL without query params when ordering/limit/offset are absent', async () => { + (commonRequest as jest.Mock).mockResolvedValue({ count: 0, results: [] }); + + await getFieldsets({ templateId: 10 }); + + expect(commonRequest).toHaveBeenCalledTimes(1); + expect(commonRequest).toHaveBeenCalledWith( + '/templates/10/fieldsets', + expect.objectContaining({ method: 'GET' }), + { shouldThrow: true }, + ); + }); + + it('adds limit and offset to query string', async () => { + (commonRequest as jest.Mock).mockResolvedValue({ count: 0, results: [] }); + + await getFieldsets({ templateId: 10, limit: 30, offset: 0 }); + + expect(commonRequest).toHaveBeenCalledTimes(1); + const calledUrl = (commonRequest as jest.Mock).mock.calls[0][0] as string; + expect(calledUrl).toContain('limit=30'); + expect(calledUrl).toContain('offset=0'); + }); + + it.each([ + [EFieldsetsSorting.NameAsc, 'ordering=name'], + [EFieldsetsSorting.NameDesc, 'ordering=-name'], + [EFieldsetsSorting.DateAsc, 'ordering=date'], + [EFieldsetsSorting.DateDesc, 'ordering=-date'], + ])('maps %s to backend ordering %s', async (sorting, expected) => { + (commonRequest as jest.Mock).mockResolvedValue({ count: 0, results: [] }); + + await getFieldsets({ templateId: 10, ordering: sorting }); + + expect(commonRequest).toHaveBeenCalledTimes(1); + const calledUrl = (commonRequest as jest.Mock).mock.calls[0][0] as string; + expect(calledUrl).toContain(expected); + }); + + it('passes unknown ordering as fallback', async () => { + (commonRequest as jest.Mock).mockResolvedValue({ count: 0, results: [] }); + + await getFieldsets({ templateId: 10, ordering: 'custom-sort' }); + + expect(commonRequest).toHaveBeenCalledTimes(1); + const calledUrl = (commonRequest as jest.Mock).mock.calls[0][0] as string; + expect(calledUrl).toContain('ordering=custom-sort'); + }); + + it('combines all params into one query string and passes signal', async () => { + (commonRequest as jest.Mock).mockResolvedValue({ count: 0, results: [] }); + const abortController = new AbortController(); + + await getFieldsets({ + templateId: 10, + ordering: EFieldsetsSorting.DateDesc, + limit: 20, + offset: 40, + signal: abortController.signal, + }); + + expect(commonRequest).toHaveBeenCalledTimes(1); + + const calledUrl = (commonRequest as jest.Mock).mock.calls[0][0] as string; + expect(calledUrl).toContain('ordering=-date'); + expect(calledUrl).toContain('limit=20'); + expect(calledUrl).toContain('offset=40'); + + expect(commonRequest).toHaveBeenCalledWith( + calledUrl, + expect.objectContaining({ signal: abortController.signal }), + { shouldThrow: true }, + ); + }); + }); + + describe('updateFieldset', () => { + it('calls commonRequest with PATCH, id in URL, signal in requestOptions, data via mapRequestBody', async () => { + const mockResponse = { id: 7, name: 'Updated' }; + (commonRequest as jest.Mock).mockResolvedValue(mockResponse); + + const abortController = new AbortController(); + const result = await updateFieldset({ + id: 7, + name: 'Updated', + description: 'New desc', + signal: abortController.signal, + }); + + expect(commonRequest).toHaveBeenCalledTimes(1); + expect(commonRequest).toHaveBeenCalledWith( + '/fieldsets/7', + { + method: 'PATCH', + data: expect.any(String), + signal: abortController.signal, + }, + { shouldThrow: true }, + ); + + expect(mapRequestBody).toHaveBeenCalledTimes(1); + expect(mapRequestBody).toHaveBeenCalledWith({ + name: 'Updated', + description: 'New desc', + }); + + expect(result).toBe(mockResponse); + }); + }); + + describe('deleteFieldset', () => { + it('calls commonRequest with DELETE, correct URL and responseType empty', async () => { + (commonRequest as jest.Mock).mockResolvedValue(undefined); + + await deleteFieldset({ id: 99 }); + + expect(commonRequest).toHaveBeenCalledTimes(1); + expect(commonRequest).toHaveBeenCalledWith( + '/fieldsets/99', + { method: 'DELETE' }, + { shouldThrow: true, responseType: 'empty' }, + ); + }); + }); +}); 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..df6ef1bf8 100644 --- a/frontend/src/public/api/getTemplateFields.ts +++ b/frontend/src/public/api/getTemplateFields.ts @@ -1,10 +1,14 @@ import { commonRequest } from './commonRequest'; import { getBrowserConfigEnv } from '../utils/getConfig'; -import { IKickoff, ITemplateTask } from '../types/template'; +import { IKickoff, ITemplateTask, TTemplateFieldFieldset } from '../types/template'; export type TGetTemplateFieldsResponse = { - tasks: Pick[]; - kickoff: Pick; + tasks: (Pick & { + fieldsets: TTemplateFieldFieldset[]; + })[]; + kickoff: Pick & { + fieldsets: TTemplateFieldFieldset[]; + }; }; export function getTemplateFields(id: string, signal?: AbortSignal) { diff --git a/frontend/src/public/components/Datasets/DatasetDetails/DatasetDetails.tsx b/frontend/src/public/components/Datasets/DatasetDetails/DatasetDetails.tsx index 549d38ae3..dd258eaf0 100644 --- a/frontend/src/public/components/Datasets/DatasetDetails/DatasetDetails.tsx +++ b/frontend/src/public/components/Datasets/DatasetDetails/DatasetDetails.tsx @@ -69,10 +69,14 @@ const DatasetDetails = ({ match: { params: { id: matchParamId } } }: TDatasetDet }; }, []); - if (isLoading || !dataset) { + if (isLoading) { return ; } + if (!dataset) { + return null; + } + const handleAddRow = () => { setIsAddingRow(true); setEditingItemId(null); 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/__tests__/FieldsetFieldGroup.test.tsx b/frontend/src/public/components/FieldsetFieldGroup/__tests__/FieldsetFieldGroup.test.tsx new file mode 100644 index 000000000..527e0b737 --- /dev/null +++ b/frontend/src/public/components/FieldsetFieldGroup/__tests__/FieldsetFieldGroup.test.tsx @@ -0,0 +1,146 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { FieldsetFieldGroup } from '../FieldsetFieldGroup'; +import { EExtraFieldType, EExtraFieldMode, IExtraField } from '../../../types/template'; +import { EInputNameBackgroundColor } from '../../../types/workflow'; + +type TExtraFieldIntlMockProps = { + field: IExtraField; + editField: (changedProps: Partial) => void; +}; + +jest.mock('../../TemplateEdit/ExtraFields', () => ({ + ExtraFieldIntl: jest.fn(({ field, editField }: TExtraFieldIntlMockProps) => ( +
+ {field.name} + +
+ )), +})); + +const makeField = (apiName: string, order = 0): IExtraField => ({ + apiName, + name: `Field ${apiName}`, + type: EExtraFieldType.String, + order, + isRequired: false, + isHidden: false, + userId: null, + groupId: null, +}); + +const baseProps = { + fields: [] as IExtraField[], + onEditField: jest.fn(() => jest.fn()), + mode: EExtraFieldMode.Kickoff, + labelBackgroundColor: EInputNameBackgroundColor.White, + accountId: 1, +}; + +describe('FieldsetFieldGroup', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('group title', () => { + it('renders when title is a non-empty string', () => { + render(); + + expect(screen.getByText('Contact details')).toBeInTheDocument(); + }); + + it('does not render when title is omitted', () => { + render(); + + expect(screen.queryByText('Contact details')).not.toBeInTheDocument(); + }); + }); + + describe('group description', () => { + it('renders when description is a non-empty string', () => { + render(); + + expect(screen.getByText('Fill carefully')).toBeInTheDocument(); + }); + + it('renders even when title is omitted (independent of title)', () => { + render(); + + expect(screen.queryByText('Contact details')).not.toBeInTheDocument(); + expect(screen.getByText('Description only')).toBeInTheDocument(); + }); + }); + + describe('validation error message', () => { + const ERROR_TEXT = 'Required field'; + + it('is visible when validationError is a non-empty string', () => { + render(); + + expect(screen.getByText(ERROR_TEXT)).toBeInTheDocument(); + }); + + it.each<'' | null | undefined>(['', null, undefined])( + 'does not render for falsy validationError = %p', + (value) => { + render(); + + expect(screen.queryByText(ERROR_TEXT)).not.toBeInTheDocument(); + }, + ); + }); + + it('renders all provided fields in the original array order, without re-sorting', () => { + const fields = [ + makeField('email', 5), + makeField('phone', 1), + makeField('city', 3), + ]; + + render(); + + const rendered = screen.getAllByTestId(/^extra-field-/); + + expect(rendered).toHaveLength(3); + expect(rendered.map((el) => el.getAttribute('data-testid'))).toEqual([ + 'extra-field-email', + 'extra-field-phone', + 'extra-field-city', + ]); + }); + + it('routes edits to the specific apiName when editing a field', () => { + const collected: Array<{ apiName: string; changedProps: Partial }> = []; + const onEditField = jest.fn( + (apiName: string) => (changedProps: Partial) => { + collected.push({ apiName, changedProps }); + }, + ); + + const fields = [makeField('email'), makeField('phone'), makeField('city')]; + + render( + , + ); + + expect(onEditField).toHaveBeenCalledTimes(3); + expect(onEditField.mock.calls.map(([apiName]) => apiName)).toEqual([ + 'email', + 'phone', + 'city', + ]); + + userEvent.click(screen.getByRole('button', { name: 'edit phone' })); + + expect(collected).toEqual([ + { apiName: 'phone', changedProps: { value: 'value-for-phone' } }, + ]); + }); +}); 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..3aae2417a --- /dev/null +++ b/frontend/src/public/components/Fieldsets/FieldsetCard/FieldsetCard.tsx @@ -0,0 +1,143 @@ +import * as React from 'react'; +import { 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, + templateId, + 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/__tests__/FieldsetCard.test.tsx b/frontend/src/public/components/Fieldsets/FieldsetCard/__tests__/FieldsetCard.test.tsx new file mode 100644 index 000000000..3fc4d7926 --- /dev/null +++ b/frontend/src/public/components/Fieldsets/FieldsetCard/__tests__/FieldsetCard.test.tsx @@ -0,0 +1,236 @@ +// +import * as React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useDispatch } from 'react-redux'; + +import { FieldsetCard } from '../FieldsetCard'; +import { Dropdown } from '../../../UI'; +import { WarningPopup } from '../../../UI/WarningPopup'; +import { + openEditModal, + deleteFieldsetAction, + setCurrentFieldset, +} from '../../../../redux/fieldsets/slice'; +import { history } from '../../../../utils/history'; +import { intlMock } from '../../../../__stubs__/intlMock'; +import { IFieldsetCardProps } from '../types'; +import { IFieldsetField, IFieldsetTemplateRule } from '../../../../types/fieldset'; + +jest.mock('../../../../utils/history', () => ({ + history: { push: jest.fn(), location: { pathname: '/' }, listen: jest.fn() }, +})); + +jest.mock('../../../../utils/strings', () => ({ + sanitizeText: jest.fn((text: string) => text), +})); + +jest.mock('../../../../redux/fieldsets/slice', () => ({ + openEditModal: jest.fn(() => ({ type: 'fieldsets/openEditModal' })), + deleteFieldsetAction: jest.fn((p) => ({ type: 'fieldsets/deleteFieldsetAction', payload: p })), + setCurrentFieldset: jest.fn((p) => ({ type: 'fieldsets/setCurrentFieldset', payload: p })), +})); + +jest.mock('../../../UI', () => ({ + Dropdown: jest.fn(() => null), +})); + +jest.mock('../../../UI/WarningPopup', () => ({ + WarningPopup: jest.fn(() => null), +})); + +jest.mock('../../../icons', () => ({ + MoreIcon: () => null, + PencilIcon: () => null, + TrashIcon: () => null, +})); + +describe('FieldsetCard', () => { + const mockDispatch = jest.fn(); + + const formatMsg = (id: string) => intlMock.formatMessage({ id }); + const EDIT_LABEL = formatMsg('fieldsets.edit'); + const DELETE_LABEL = formatMsg('fieldsets.delete'); + const FIELDS_STATS = (count: number) => intlMock.formatMessage({ id: 'fieldsets.stats.fields' }, { count }); + const RULES_STATS = (count: number) => intlMock.formatMessage({ id: 'fieldsets.stats.rules' }, { count }); + + let fieldCounter = 0; + let ruleCounter = 0; + + const makeField = (overrides: Partial = {}): IFieldsetField => ({ + type: 'string', + name: 'Field', + order: 0, + api_name: `f-${++fieldCounter}`, + ...overrides, + }); + + const makeRule = (overrides: Partial = {}): IFieldsetTemplateRule => ({ + id: ++ruleCounter, + type: 'sum_equal', + value: '100', + fields: [], + ...overrides, + }); + + const makeProps = (overrides: Partial = {}): IFieldsetCardProps => ({ + id: 10, + apiName: 'fs-10', + name: 'Test Fieldset', + description: 'A test fieldset', + labelPosition: 'top', + layout: 'vertical', + order: 0, + kickoffId: null, + taskId: null, + rules: [], + fields: [], + templateId: 5, + ...overrides, + }); + + const getDropdownOptions = () => { + const mock = Dropdown as unknown as jest.Mock; + const lastCall = mock.mock.calls[mock.mock.calls.length - 1]; + return lastCall[0].options; + }; + + const findDropdownOption = (label: string) => { + return getDropdownOptions().find((opt: { label: string }) => opt.label === label); + }; + + const getWarningPopupProps = () => { + const mock = WarningPopup as jest.Mock; + const lastCall = mock.mock.calls[mock.mock.calls.length - 1]; + return lastCall[0]; + }; + + beforeEach(() => { + jest.clearAllMocks(); + fieldCounter = 0; + ruleCounter = 0; + (useDispatch as jest.Mock).mockReturnValue(mockDispatch); + }); + + describe('Navigation', () => { + it('navigates to detail page on title click', () => { + render(React.createElement(FieldsetCard, makeProps({ id: 10, templateId: 5 }))); + + const titleLink = screen.getByRole('link'); + userEvent.click(titleLink); + + expect(history.push).toHaveBeenCalledTimes(1); + expect(history.push).toHaveBeenCalledWith('/templates/5/fieldsets/10/'); + }); + + it('navigates to detail page on Enter key', () => { + render(React.createElement(FieldsetCard, makeProps({ id: 10, templateId: 5 }))); + + const titleLink = screen.getByRole('link'); + titleLink.focus(); + userEvent.keyboard('{Enter}'); + + expect(history.push).toHaveBeenCalledTimes(1); + expect(history.push).toHaveBeenCalledWith('/templates/5/fieldsets/10/'); + }); + }); + + describe('Dropdown — Edit', () => { + it('dispatches setCurrentFieldset and openEditModal on Edit click', () => { + const props = makeProps(); + render(React.createElement(FieldsetCard, props)); + + const editOption = findDropdownOption(EDIT_LABEL); + editOption.onClick(); + + expect(mockDispatch).toHaveBeenCalledWith( + setCurrentFieldset({ + id: props.id, + templateId: props.templateId, + name: props.name, + description: props.description, + labelPosition: props.labelPosition, + layout: props.layout, + order: props.order, + kickoffId: props.kickoffId, + taskId: props.taskId, + rules: props.rules, + fields: props.fields, + }), + ); + expect(mockDispatch).toHaveBeenCalledWith(openEditModal()); + expect(mockDispatch).toHaveBeenCalledTimes(2); + }); + }); + + describe('Dropdown — Delete', () => { + it('opens WarningPopup on Delete click', () => { + render(React.createElement(FieldsetCard, makeProps())); + + expect(getWarningPopupProps().isOpen).toBe(false); + + const deleteOption = findDropdownOption(DELETE_LABEL); + act(() => { deleteOption.onClick(); }); + + expect(getWarningPopupProps().isOpen).toBe(true); + }); + + it('dispatches deleteFieldsetAction on WarningPopup confirm', () => { + render(React.createElement(FieldsetCard, makeProps({ id: 10 }))); + + act(() => { findDropdownOption(DELETE_LABEL).onClick(); }); + act(() => { getWarningPopupProps().onConfirm(); }); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith(deleteFieldsetAction({ id: 10 })); + + expect(getWarningPopupProps().isOpen).toBe(false); + }); + + it('closes WarningPopup without dispatch on cancel', () => { + render(React.createElement(FieldsetCard, makeProps())); + + act(() => { findDropdownOption(DELETE_LABEL).onClick(); }); + expect(getWarningPopupProps().isOpen).toBe(true); + + act(() => { getWarningPopupProps().onReject(); }); + + expect(getWarningPopupProps().isOpen).toBe(false); + expect(deleteFieldsetAction).not.toHaveBeenCalled(); + }); + }); + + describe('Statistics footer', () => { + it('shows field count and rule count when both are > 0', () => { + const props = makeProps({ + fields: [makeField(), makeField()], + rules: [makeRule()], + }); + render(React.createElement(FieldsetCard, props)); + + expect(screen.getByText(FIELDS_STATS(2))).toBeInTheDocument(); + expect(screen.getByText(RULES_STATS(1))).toBeInTheDocument(); + }); + + it('hides footer when fields and rules are both empty', () => { + render(React.createElement(FieldsetCard, makeProps({ fields: [], rules: [] }))); + + expect(screen.queryByText(FIELDS_STATS(0))).not.toBeInTheDocument(); + expect(screen.queryByText(RULES_STATS(0))).not.toBeInTheDocument(); + }); + + it('shows only fields count when rules are empty', () => { + render(React.createElement(FieldsetCard, makeProps({ fields: [makeField()], rules: [] }))); + + expect(screen.getByText(FIELDS_STATS(1))).toBeInTheDocument(); + expect(screen.queryByText(RULES_STATS(0))).not.toBeInTheDocument(); + }); + + it('shows only rules count when fields are empty', () => { + render(React.createElement(FieldsetCard, makeProps({ fields: [], rules: [makeRule()] }))); + + expect(screen.getByText(RULES_STATS(1))).toBeInTheDocument(); + expect(screen.queryByText(FIELDS_STATS(0))).not.toBeInTheDocument(); + }); + }); +}); 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..a6efe6e6f --- /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; + white-space: nowrap; + color: var(--pneumatic-color-black72); + + @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..0986e256a --- /dev/null +++ b/frontend/src/public/components/Fieldsets/FieldsetDetails/FieldsetDetails.tsx @@ -0,0 +1,491 @@ +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 { EExtraFieldMode, EExtraFieldType, IExtraField } from '../../../types/template'; +import { EInputNameBackgroundColor, EMoveDirections } from '../../../types/workflow'; +import { IFieldsetTemplateRule, TFieldLabelPosition } 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 { useDatasetOptions } from '../../TemplateEdit/ExtraFields/utils/useDatasetOptions'; + +import { normalizeFieldsForUI } from './fieldsetFieldMappers'; + + +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 datasetOptions = useDatasetOptions(localFields); + 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); + const templateId = Number(matchTemplateId); + + if (Number.isNaN(templateId)) { + history.push(ERoutes.Templates); + return; + } + if (Number.isNaN(id)) { + history.push(fieldsetListRoute); + return; + } + + dispatch(setTemplateId(templateId)); + if (fieldset?.id === id) return; + dispatch(loadCurrentFieldset({ id })); + }, [matchParamId, matchTemplateId]); + + + + 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) { + return ; + } + + if (!fieldset) { + return null; + } + + 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' })} +

+ +
+
+ +