From 8887139e513e50caa0ca60ef9ec2ad2e708df100 Mon Sep 17 00:00:00 2001 From: shuhaib s <95394061+shuhaib-aot@users.noreply.github.com> Date: Fri, 10 Jan 2025 16:58:37 +0530 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20Release=20v7.0.0=20(#2502)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * FWF-4012:[Feature]-Sonar cloud fix 2 * updated internationalization for multi and normal * FWF-4012:[Feature]-Updated process get call's payload with mapper id * feature/FWF-3694 Updated CD action to Build and push Multiarch images into Docker registry (#2405) * env update for custom theming * FWF-4055: [Bugfix] On duplicate form create process with processType & data from payload * FWF-4012:[Feature] - Migration-UI and API integration * FWF-4012:[Feature] - Sonar qube fix for migration modal. * FWF-4012:[Feature]-Sonar cloud fix 2 * FWF-4012:[Feature]-Updated process get call's payload with mapper id * FWF-4012:[Feature] - resolved comments. * new modal based on task variable selection * ui issue fixes * [Bugfix] Import Fixes (#2406) * FWF-4051: [Bugfix] Webapi Import fixes * Fixed front end issues * FWF-4051: [Bugfix] Webapi Import validation fixes * Fixed sonar in list page * Fixed sonar issue by giving some exctra check in list page * added isArray check for extracted data * added prop validation * fixed sonarcloud issues * FIxed sonar * Fixed sonar cloud issues * Disabled unwanted prop validation * Disabled unwanted prop validation * FWF-4051: Sonar fix --------- Co-authored-by: shuhaib-aot * FWF-3971:[Feature] - Import type issue fix in form listing page. * Fixed authorization for application and submission view * Adding new permission for BPMN and DMN access Fixing dependency issues * Fixed sonar cloud issue and also type of chek * FWF-4084: Update group_or_roles return groups in multitenant * Fixed (web) : tenant key assigned with mapperId in api call of getProcessDetails * FWF-4088: Form Export Import authorization updates in userName field * FWF-4078: Added input validate loader for form title * updated comments * [Bugfix]: Fix documents api build issue * updated package * FWF-4078: prop name updated * FWF-4078: updated name validation prop in form duplicate modal * FWF-4078: prop name updated in list.js * FWF-4077: [Bugfix] Allow hyphen in form title, path & ame * FWF-4077: [Bugfix] Flake & test fix * FWF-4097 [Feature] Updated formiojs and react-formio versions to incorporate hidden fields selection in conditional chaining * fixes for ui bugs * FWF-3701 : version upgrades * Upgrading spring boot to 3.3.5 * FWF-4087: [Bugfix] Fix multitenant subgroup listing (#2426) * updated calling userroles by authenticating (#2423) * Documents-api: Update in prod.txt (#2430) * FWF-4075 [bugfix] added default task variable on form creation (#2431) * Typo fix in permissions * unused fragment import removed * bugfix/fwf-4022:Added tenantkey prefix editable in path * fixed sonar issues * resoved merge conflict * FWF-3933: [BugFix] Added class for wizard page name fix * FWF-4093: [Feature] Webapi permission matrix changes * Moved functions to helper * Incorporating code review comments * FWF-2459 [Bugfix] Task loading fix by avoiding conditions inside updateBPMNTask method so all latest data fetched on each update with a timeout of 2 seconds * Adding async start event (#2438) * FWF-4032:[Feature]-Custom Scrollabr in layout. * FWF-2459 [Bugfix] Updated sonarcloud fixes * ui issues second part fixes * bugfix/fwf-4035-Hid sidebar in preview page * bugfix/fwf-4089-Hide-default-task-var (#2439) * bugfix/fwf-4089-Hide-default-task-var * used set for improved readability * env issue in custom theming solved * FWF-4088: [Bugfix] Edit import designer auth username will existing username & other minor bug fixes (#2445) * FWF-4088: [Bugfix] Edit import designer auth username will existing username * Update in api requirements * Changes to permission labels to match with re-design terminology (#2443) * FWF-4111: [Bugfix] Removed camunda:asyncAfter from default workflow (#2446) * Remove CI action on merge to save CI hours * Add deployment permission to designers * release-7.0.0 changelog & readme draft * bugfix/fwf-4081:fixed showing tenantkey in permission * FWF-4109: [Bugfix] Webapi removed after response translation code (#2451) * FWF-4109: [Bugfix] Removed after response translation * FWF-4093: [Removed] Removed condition on edit import bpmn * updated * analytics version change and Documents CD reverting * tenankey removed from validation * resolved sonar issue * FWF-3971: {Bugfix] Given different name for mapper reponse to exclude mapper from response if None * release-7.0.0 changelog * helper fucntion moved to MF-service * truncate helper added * duplicates removed * added set for future use & added helper fn in MF * FWF-4120: [Bugfix] Added parentFormId to validation query * release-7.0.0 changelog & readme updated * release-7.0.0 changelog & readme updated * changes in edit page and process diagram in submission page * bugfix/fwf-3035:Updated hleper fn * FWF-4114 [Bugfix] Unsaved modal showing during migration is made unavailable. style fixes. button disabling fix in migration * bugfix/fwf-4125:added save btn disabling functionality&&added openbydefault prop for multi-select comp * added props in setting modal * Adding documentation for DB migration * Adding async start task * FWF-4020 [Bugfix] : Unsaved changes modal showing up issue and optimisations * Merge remote-tracking branch 'ff-open/develop' into bugfix/FWF-4114-unsaved-modal-open-issue # Please enter a commit message to explain why this merge is necessary, # especially if it merges an updated upstream into a topic branch. # # Lines starting with '#' will be ignored, and an empty message aborts # the commit. * FWF-4101 [Bugfix] Iframe height set to 90vh to fully cover the page. console eror fix * fixed sonar errs * typofix * updated * FWF-4122 [Bugfix] Submit button position change during default form create * bugfix/fwf-4120:Updated validate api with parentFormId and formId * FWF-4121 [Bugfix] scroll issues fixes * FWF-4141 [Bugfix] taskvarible hidden variable showing issue fixed and modal sizing issue * bugfix/fwf-4129:Fixed ui issuesin opensource * Updated wizard name issue in variables, design update in submissions * FWF-4136 [Bugfix] Sidebar issue fixes with multi env (#2467) * FWF-4136 [Bugfix] Sidebar issue fixes with multi env * FWF-4136 [Bugfix] SOnarcloud fixes * state update on unpublish * Fixed returning task variable proper json format * bugfix/fwf-4055:Fixed flow is not updating when duplicating * Resolved UI issues in dashboard search * Permission matrix fix for filter * FWF-4020 [Bugfix] Unsaved changes issue for new form create without save fixed * [Bugfix] Sonarcloud fix * Added migration link of bpm * bugfix/fwf-4152:Fixed scrolling issues * updated * Added: Added Submitter's Email , firstname, lastname Fixed: cache existing form submission view * updatec mapper call and disable the action modal * sonarcube fix * Fixed: anonymouse form submission * Added Trivy Action File: Container and Repository Vulnerability Scanning * [BugFix]: FWF-4170 Submission status UI fixes * FWF-4168: [Bugfix] Deploy migrated workflow * updated with note * [BugFix]: FWF-4170 Submission status style updated * Added: added some hidden fields to default taskvariables & removed all user roles * added: null check in applicationComponent.js file * [BugFix]: FWF-4170 Submission status style updated * Fixed sonar cloud issues * updated * updated sonar issue * [BugFix]: FWF-4170 Submission status style updated * [BugFix]: FWF-4170 Submission status style updated * [BugFix]: FWF-4170 Sonar issue fixed * updated * Updated changelof for 7.0.0 * Updated changelof for 7.0.0 * Updated changelof for 7.0.0 * bugfix/fwf-4155-Fixed-draft-table-height-issue * Updated changelog and readme for 7.0.0 * Updated changelog and readme for 7.0.0 * Updated changelog and readme for 7.0.0 * Update CHANGELOG.md * Fixed: Enable hidden field section in task varibale * FWF-4171:[Feature]-Updated hidden component's label * Modified currentUserRole label in default task variables (#2485) * Update CHANGELOG.md * Readme updates for 7.0.0 * Resolved long form and flow name issues * Fixed: set initial form as null and fixed wizard form next and previous button clickable in taskvariable * Resolve status name issue in metrics * FWF-4200: [Bugfix] Fix subflow, dmn loading issue in multitenant * Resolved sonar issue * FWF-4200: [Added] Added index to all tenant columns across tables and parent_process_key in process table * Adding DMN authorization for designers and reviewers (#2494) * Adding DMN authorization for designers and reviewers * Update application.yaml * [Bugfix] currentUserRoles data to be fetched from groups variable from 'userDetails' localstorage instead of roles variable * Updated changelog to include Premium Feature changes * ui issue fix for task page and submission page * Fix for Handle flow and layout save while form is published (#2488) * initial commit * updated version change with modal * sonar fix * updated code * Optimized code * task variable modal changed * updated comments --------- Co-authored-by: shuhaib-aot * Updated readme * FWF-4210:[Feature] - Flow history button disabling on published state. * bugfix/fwf-4212:Fixed followup&due date not updating issue and styles inn task page * FWF-4200: [Added] Added index * word-break added for long flow names * Update CHANGELOG.md * FWF-4211 [Bugfix] process id error after unpublish and save fixed * FWF-4211 [Bugfix] sonarcloud fix * CHANGELOG.md release date added * Update python version in data-analysis github flow * Modifeid: version to 7.0.0 * Fixed typo * Updated reference in utils to release branch --------- Co-authored-by: josephalexantony-aot Co-authored-by: abilpraju-aot Co-authored-by: arun s Co-authored-by: Alan Raju <94040192+alanraju-aot@users.noreply.github.com> Co-authored-by: Ajay krishna Co-authored-by: auslin-aot <99173163+auslin-aot@users.noreply.github.com> Co-authored-by: Sumesh Kariyil Co-authored-by: Bonymol-aot Co-authored-by: Abil P Raju <83952803+abilpraju-aot@users.noreply.github.com> Co-authored-by: fahad-aot Co-authored-by: fahad-aot <99656326+fahad-aot@users.noreply.github.com> Co-authored-by: sethu-aot Co-authored-by: Bonymol Baby <88372115+Bonymol-aot@users.noreply.github.com> --- .github/pull_request_template.md | 14 +- .github/workflows/forms-flow-api-ci.yml | 30 +- .github/workflows/forms-flow-bpm-cd.yml | 46 +- .github/workflows/forms-flow-bpm-ci.yml | 10 +- .../forms-flow-data-analysis-api-ci.yml | 14 +- .github/workflows/forms-flow-documents-ci.yml | 30 +- .github/workflows/forms-flow-idm.yml | 112 + .../workflows/forms-flow-root-config-cd.yml | 6 +- .github/workflows/forms-flow-web-cd.yml | 23 +- .github/workflows/forms-flow-web-ci.yml | 8 +- .github/workflows/issue-notification.yml | 96 + .github/workflows/pr-notification.yml | 75 + .github/workflows/trivy-scan.yml | 74 + .github/workflows/zap-scan.yml | 142 + .gitignore | 5 +- .images/export_pdf_bundle_template.pdf | Bin 0 -> 44031 bytes CHANGELOG.md | 279 + VERSION | 2 +- deployment/docker/docker-compose.yml | 23 +- deployment/docker/sample.env | 52 +- deployment/openshift/sample.env | 14 +- forms-flow-analytics/docker-compose.yml | 2 +- forms-flow-api-utils/setup.py | 2 +- .../exceptions/__init__.py | 7 +- .../formsflow_api_utils/services/__init__.py | 3 +- .../services/external/__init__.py | 1 + .../services/external/custom_submission.py | 33 + .../services/external/formio.py | 16 +- .../src/formsflow_api_utils/utils/__init__.py | 24 +- .../src/formsflow_api_utils/utils/auth.py | 7 +- .../src/formsflow_api_utils/utils/caching.py | 10 +- .../formsflow_api_utils/utils/constants.py | 6 +- .../src/formsflow_api_utils/utils/enums.py | 12 + .../src/formsflow_api_utils/utils/pdf.py | 24 + .../formsflow_api_utils/utils/permisions.py | 53 + .../src/formsflow_api_utils/utils/roles.py | 9 - .../formsflow_api_utils/utils/user_context.py | 16 +- .../src/formsflow_api_utils/utils/util.py | 47 +- forms-flow-api/Dockerfile | 2 +- forms-flow-api/README.md | 16 +- forms-flow-api/docker-compose.yml | 14 +- ...086882f6c_added_fields_to_process_table.py | 32 + .../migrations/versions/2dbcca4a4c28_.py | 34 + .../6fc8e5beebe4_title_length_changed.py | 46 + .../versions/77d8b68e6c1f_users_table.py | 69 + .../versions/8feb43e1e408_subflow_table.py | 34 + .../migrations/versions/95387de7067e_theme.py | 41 + ...960477800395_workflow_migration_changes.py | 69 + ..._minor_version_to_existing_form_history.py | 51 + ...ded_major_minor_version_to_form_history.py | 34 + ...cd447cfa5_added_version_to_process_data.py | 45 + ...8ad0e9_alter_audit_data_time_zone_aware.py | 176 + .../versions/c8e7baa093ba_filter_updation.py | 33 + ..._added_submission_id_and_latest_form_id.py | 6 +- .../d4618d0e45ca_added_prompt_new_version.py | 28 + .../versions/e1d88d2efbcb_filter_order.py | 28 + .../f1599a5bd658_capture_process_data.py | 46 + .../versions/f581ec5971eb_added_index.py | 42 + forms-flow-api/requirements.txt | 95 +- forms-flow-api/requirements/prod.txt | 7 +- forms-flow-api/sample.env | 15 +- forms-flow-api/setup.cfg | 2 +- forms-flow-api/src/formsflow_api/app.py | 25 +- forms-flow-api/src/formsflow_api/config.py | 18 +- .../src/formsflow_api/constants/__init__.py | 159 + .../src/formsflow_api/models/__init__.py | 8 + .../src/formsflow_api/models/application.py | 23 +- .../models/application_history.py | 5 + .../src/formsflow_api/models/audit_mixin.py | 15 +- .../src/formsflow_api/models/authorization.py | 50 +- .../src/formsflow_api/models/draft.py | 2 +- .../src/formsflow_api/models/filter.py | 26 +- .../formsflow_api/models/form_history_logs.py | 52 +- .../models/form_process_mapper.py | 180 +- .../src/formsflow_api/models/process.py | 259 + .../src/formsflow_api/models/theme.py | 60 + .../src/formsflow_api/models/user.py | 70 + .../src/formsflow_api/resources/__init__.py | 4 + .../formsflow_api/resources/application.py | 25 +- .../resources/application_history.py | 13 +- .../formsflow_api/resources/authorization.py | 47 +- .../src/formsflow_api/resources/dashboards.py | 12 +- .../src/formsflow_api/resources/draft.py | 13 +- .../src/formsflow_api/resources/filter.py | 26 +- .../resources/form_process_mapper.py | 400 +- .../src/formsflow_api/resources/formio.py | 38 +- .../formsflow_api/resources/import_support.py | 46 + .../src/formsflow_api/resources/metrics.py | 11 +- .../src/formsflow_api/resources/process.py | 454 +- .../src/formsflow_api/resources/roles.py | 49 +- .../src/formsflow_api/resources/theme.py | 92 + .../src/formsflow_api/resources/user.py | 66 +- .../src/formsflow_api/schemas/__init__.py | 20 +- .../src/formsflow_api/schemas/application.py | 6 +- .../schemas/application_history.py | 15 +- .../src/formsflow_api/schemas/base_schema.py | 48 + .../src/formsflow_api/schemas/draft.py | 11 +- .../src/formsflow_api/schemas/filter.py | 7 +- .../schemas/form_history_logs.py | 33 +- .../schemas/form_process_mapper.py | 18 +- .../formsflow_api/schemas/import_support.py | 152 + .../src/formsflow_api/schemas/process.py | 134 +- .../schemas/process_history_logs.py | 32 + .../src/formsflow_api/schemas/roles.py | 1 + .../src/formsflow_api/schemas/theme.py | 21 + .../src/formsflow_api/schemas/user.py | 14 + .../src/formsflow_api/services/__init__.py | 4 + .../src/formsflow_api/services/application.py | 119 +- .../services/application_history.py | 14 +- .../formsflow_api/services/authorization.py | 63 +- .../src/formsflow_api/services/draft.py | 12 +- .../formsflow_api/services/external/admin.py | 29 + .../services/external/base_bpm.py | 16 +- .../formsflow_api/services/external/bpm.py | 46 +- .../services/external/keycloak.py | 57 +- .../services/factory/__init__.py | 1 + .../services/factory/keycloak_admin.py | 32 +- .../factory/keycloak_client_service.py | 174 +- .../factory/keycloak_group_service.py | 219 +- .../src/formsflow_api/services/filter.py | 63 +- .../services/form_history_logs.py | 51 +- .../services/form_process_mapper.py | 743 +- .../formsflow_api/services/import_support.py | 757 + .../src/formsflow_api/services/process.py | 764 +- .../src/formsflow_api/services/theme.py | 48 + .../src/formsflow_api/services/user.py | 45 + forms-flow-api/tests/conftest.py | 58 + .../docker/realms/forms-flow-ai-realm.json | 139 +- .../unit/api/test_anonymous_application.py | 79 +- .../tests/unit/api/test_application.py | 242 +- .../unit/api/test_application_history.py | 29 +- .../tests/unit/api/test_authorization.py | 42 +- .../tests/unit/api/test_checkpoint.py | 1 + .../tests/unit/api/test_dashboards.py | 18 +- forms-flow-api/tests/unit/api/test_draft.py | 104 +- forms-flow-api/tests/unit/api/test_filter.py | 59 +- .../tests/unit/api/test_form_embed.py | 58 +- .../unit/api/test_form_process_mapper.py | 689 +- forms-flow-api/tests/unit/api/test_formio.py | 62 +- .../tests/unit/api/test_import_support.py | 622 + .../tests/unit/api/test_keycloak_groups.py | 1 + forms-flow-api/tests/unit/api/test_metrics.py | 77 +- forms-flow-api/tests/unit/api/test_process.py | 653 + forms-flow-api/tests/unit/api/test_roles.py | 18 +- forms-flow-api/tests/unit/api/test_theme.py | 59 + forms-flow-api/tests/unit/api/test_user.py | 36 +- .../tests/unit/models/test_application.py | 1 + .../unit/models/test_application_audit.py | 1 + .../unit/models/test_form_process_mapper.py | 1 + .../unit/models/test_formprocessmapper.py | 1 + .../tests/unit/services/test_application.py | 1 + .../unit/services/test_application_history.py | 3 +- .../unit/services/test_form_process_mapper.py | 45 +- .../tests/unit/utils/test_logging.py | 1 + forms-flow-api/tests/utilities/base_test.py | 159 +- forms-flow-api/tests/utils/test_logging.py | 1 + forms-flow-bpm/Dockerfile | 13 +- forms-flow-bpm/Dockerfile-ARM64 | 39 - forms-flow-bpm/README.md | 8 +- forms-flow-bpm/docker-compose.yml | 1 + forms-flow-bpm/forms-flow-bpm-camunda/pom.xml | 43 +- .../connector/support/BPMAccessHandler.java | 4 +- .../extension/commons/utils/VaultConfig.java | 66 + .../data/FormProcessMappingData.java | 4 +- .../service/impl/AdminRestServiceImpl.java | 117 +- .../CamundaAuthorizationInitializer.java | 79 + .../CamundaAuthorizationProperties.java | 69 + .../keycloak/plugin/KeycloakGroupService.java | 13 +- .../keycloak/plugin/KeycloakUserService.java | 26 +- .../rest/KeycloakAuthenticationFilter.java | 66 +- .../sso/KeycloakAuthenticationProvider.java | 15 +- .../sso/OAuth2LoginSecurityConfig.java | 24 +- .../camunda/app/admin/scripts/config.js | 2 +- .../camunda/app/admin/styles/user-styles.css | 3 + .../camunda/app/cockpit/scripts/config.js | 2 +- .../app/cockpit/styles/user-styles.css | 6 +- .../camunda/app/tasklist/scripts/config.js | 2 +- .../app/tasklist/styles/user-styles.css | 6 +- .../app/welcome/styles/user-styles.css | 4 + .../src/main/resources/application.yaml | 34 +- ...rmBPMFilteredDataPipelineListenerTest.java | 33 +- .../plugin/KeycloakGroupServiceTest.java | 4 +- .../KeycloakAuthenticationFilterTest.java | 35 +- forms-flow-bpm/forms-flow-bpm-utils/pom.xml | 4 +- forms-flow-bpm/migration/README.md | 10 + forms-flow-bpm/migration/scripts/7.0.0.sql | 27 + forms-flow-bpm/pom-default.xml | 2 +- forms-flow-bpm/pom.xml | 41 +- forms-flow-bpm/sample.env | 11 + forms-flow-data-analysis-api/Dockerfile | 6 +- forms-flow-data-analysis-api/README.md | 6 +- .../docker-compose.yml | 9 +- .../{entrypoint => entrypoint.sh} | 3 +- .../model_training/requirements.txt | 10 +- forms-flow-data-analysis-api/requirements.txt | 137 +- .../requirements/prod.txt | 4 +- forms-flow-data-analysis-api/sample.env | 11 +- .../src/api/__init__.py | 2 +- .../src/api/config.py | 20 +- .../src/api/utils/file_log_handler.py | 2 +- .../tests/conftest.py | 1 + .../tests/unit/api/test_sentiment_analysis.py | 5 +- .../unit/services/test_sentiment_analysis.py | 5 +- .../tests/utilities/base_test.py | 2 +- forms-flow-documents/Dockerfile | 2 +- forms-flow-documents/Dockerfile-ARM64 | 2 +- forms-flow-documents/README.md | 60 +- forms-flow-documents/docker-compose.yml | 13 +- forms-flow-documents/requirements.txt | 94 +- forms-flow-documents/requirements/prod.txt | 10 +- forms-flow-documents/sample.env | 4 + forms-flow-documents/setup.cfg | 2 +- .../src/formsflow_documents/app.py | 2 +- .../src/formsflow_documents/resources/pdf.py | 6 +- .../src/formsflow_documents/services/pdf.py | 53 +- .../static/js/from_io_render.js | 16 +- .../formsflow_documents/utils/constants.py | 1 + .../src/formsflow_documents/utils/util.py | 1 + forms-flow-documents/tests/conftest.py | 3 + .../tests/unit/services/test_pdf.py | 3 +- .../tests/utilities/base_test.py | 3 +- forms-flow-forms/docker-compose.yml | 2 +- forms-flow-idm/README.md | 28 +- forms-flow-idm/keycloak/Dockerfile | 20 +- forms-flow-idm/keycloak/docker-compose.yml | 40 +- .../keycloak/idp-selector/.gitignore | 1 + forms-flow-idm/keycloak/idp-selector/pom.xml | 70 + .../ConfigurableIdpAuthenticator.java | 100 + .../ConfigurableIdpAuthenticatorFactory.java | 87 + ...ycloak.authentication.AuthenticatorFactory | 1 + .../keycloak/imports/formsflow-ai-realm.json | 2619 +- forms-flow-idm/keycloak/start-keycloak.sh | 8 + .../keycloak/themes/formsflow/login/login.ftl | 185 +- .../themes/formsflow/login/register.ftl | 109 + .../formsflow/login/resources/css/login.css | 1426 +- .../formsflow/login/resources/img/logo.png | Bin 0 -> 1499 bytes .../themes/formsflow/login/template.ftl | 73 +- .../themes/formsflow/login/theme.properties | 4 +- forms-flow-idm/migration/README.md | 72 + forms-flow-idm/migration/migrate-7.0.0.py | 335 + forms-flow-idm/migration/requirements.txt | 2 + forms-flow-idm/migration/sample.env | 5 + forms-flow-idm/migration/utils.py | 228 + .../realm-exports/Client based auth.json | 5 + .../realm-exports/Group based auth.json | 2622 +- .../realm-exports/Multi tenant realm.json | 550 +- forms-flow-redis/README.md | 33 + forms-flow-redis/docker-compose.yml | 14 + forms-flow-web-root-config/Dockerfile | 9 +- forms-flow-web-root-config/README.md | 11 +- forms-flow-web-root-config/docker-compose.yml | 10 +- forms-flow-web-root-config/package-lock.json | 17854 ++++- forms-flow-web-root-config/package.json | 17 +- .../public/config/config.js | 2 +- .../public/config/config.sample.js | 23 +- .../public/config/config.template.js | 4 +- forms-flow-web-root-config/public/worker.js | 1 + forms-flow-web-root-config/sample.env | 39 +- forms-flow-web-root-config/src/index.ejs | 25 +- forms-flow-web/.eslintrc | 29 +- forms-flow-web/Dockerfile | 4 +- forms-flow-web/README.md | 2 +- forms-flow-web/craco.config.js | 13 +- forms-flow-web/package-lock.json | 57256 ++++++++++------ forms-flow-web/package.json | 82 +- .../test-components/test-form/index.test.js | 8 +- .../test-redux-states/redux-state-sample.js | 2 +- forms-flow-web/src/actions/actionConstants.js | 11 +- forms-flow-web/src/actions/bpmTaskActions.js | 7 + forms-flow-web/src/actions/formActions.js | 12 + forms-flow-web/src/actions/processActions.js | 46 +- .../src/apiManager/endpoints/index.js | 12 + .../src/apiManager/services/FormServices.js | 54 +- .../services/authorizationService.js | 13 +- .../apiManager/services/bpmFormServices.js | 14 +- .../src/apiManager/services/bpmServices.js | 3 +- .../apiManager/services/bpmTaskServices.js | 11 +- .../apiManager/services/formatterService.js | 2 +- .../apiManager/services/processServices.js | 199 +- .../src/apiManager/services/tenantServices.js | 12 + .../src/apiManager/services/userservices.js | 8 + forms-flow-web/src/assets/styles/base.scss | 32 +- forms-flow-web/src/assets/styles/layouts.scss | 4 +- .../src/components/AccessDenied/index.js | 42 +- forms-flow-web/src/components/App.jsx | 25 +- .../components/Application/Application.scss | 8 +- .../Application/ApplicationFilter.js | 2 +- .../Application/ApplicationHistory.js | 1 - .../Application/ApplicationTable.js | 109 +- .../Application/Details/ApplicationDetails.js | 2 +- .../src/components/Application/List.js | 50 +- .../components/Application/ViewApplication.js | 18 +- .../src/components/BPMN/BpmnViewer.js | 55 + .../src/components/BPMN/ProcessDiagramHook.js | 3 +- forms-flow-web/src/components/BPMN/bpm.scss | 4 +- forms-flow-web/src/components/BaseRouting.jsx | 73 +- .../CustomComponents/Assets/Chevron.svg | 3 + .../CustomComponents/Assets/Loader.svg | 12 + .../CustomComponents/MultiSelect.js | 119 + .../CustomComponents/NavigateBlocker.jsx | 91 + .../CustomComponents/SortableHeader.js | 50 + .../src/components/Dashboard/Dashboard.js | 109 +- .../src/components/Dashboard/StatusChart.js | 126 +- .../src/components/Dashboard/dashboard.scss | 30 +- .../components/Draft/Details/DraftDetails.js | 2 +- .../src/components/Draft/DraftTable.js | 106 +- forms-flow-web/src/components/Draft/Edit.js | 15 +- .../src/components/Draft/EditDraft.js | 2 +- forms-flow-web/src/components/Draft/List.js | 39 +- .../src/components/Draft/ViewDraft.js | 13 +- .../src/components/Footer/Footer.js | 31 +- forms-flow-web/src/components/Form/Create.js | 11 +- .../src/components/Form/EditForm/FlowEdit.js | 404 + .../src/components/Form/EditForm/FormEdit.js | 1378 + .../components/Form/EditForm/FormPreview.js | 60 + .../components/Form/EditForm/FormSettings.js | 426 + .../src/components/Form/EditForm/index.js | 87 + .../components/Form/ExportAsPdf/button.jsx | 71 +- .../Form/ExportAsPdf/downloadPdfButton.jsx | 2 + .../Form/FileUpload/SelectFormForDownload.js | 4 +- .../Form/FileUpload/fileUploadModal.js | 18 +- .../Form/FormOperations/FormOperations.js | 19 +- .../Form/Item/CreateTemplateConfirmModal.js | 6 +- .../Form/Item/{Edit.js => EditOld.js} | 89 +- .../components/Form/Item/FormHistoryModal.js | 6 +- .../src/components/Form/Item/Preview.js | 82 +- .../Item/SaveAsNewVersionConfirmationModal.js | 4 +- .../Form/Item/Submission/Item/Edit.js | 12 +- .../Form/Item/Submission/Item/View.js | 12 +- .../Form/Item/Submission/Item/index.js | 5 +- .../components/Form/Item/Submission/List.js | 14 +- .../components/Form/Item/Submission/index.js | 14 +- .../src/components/Form/Item/View.js | 10 +- .../src/components/Form/Item/index.js | 24 +- forms-flow-web/src/components/Form/List.js | 776 +- forms-flow-web/src/components/Form/List.scss | 40 +- .../src/components/Form/RichText/Toolbar.js | 48 - .../src/components/Form/RichText/index.js | 36 - .../components/Form/RichText/richText.scss | 3 - forms-flow-web/src/components/Form/Stepper.js | 519 - .../components/Form/Steps/PreviewStepper.js | 30 +- .../src/components/Form/Steps/SaveNext.js | 33 +- .../src/components/Form/Steps/WorkFlow.js | 94 +- .../src/components/Form/Steps/steps.scss | 26 +- .../src/components/Form/SubmitList.js | 144 + .../components/Form/constants/ClientTable.js | 253 +- .../components/Form/constants/FormTable.js | 468 +- .../Form/constants/formListConstants.js | 9 +- forms-flow-web/src/components/Form/index.js | 83 +- .../src/components/Form/stepper.scss | 14 +- .../src/components/Insights/Insights.js | 33 +- .../src/components/Insights/Insights.scss | 17 +- .../src/components/Modals/ActionModal.js | 159 + .../src/components/Modals/CreateFormModal.js | 38 + .../src/components/Modals/ExportDiagrams.js | 174 + .../src/components/Modals/ExportModal.js | 141 + .../src/components/Modals/ImportProcess.js | 125 + .../src/components/Modals/NewVersionModal.js | 148 + .../src/components/Modals/ProgressBar.js | 19 + .../src/components/Modals/SettingsModal.js | 69 + .../components/Modals/TaskVariableModal.js | 495 + .../src/components/Modeler/Create.js | 56 - .../src/components/Modeler/DecisionTable.js | 258 + forms-flow-web/src/components/Modeler/Edit.js | 74 - .../Modeler/Editors/BpmnEditor/BpmEditor.js | 155 + .../Modeler/Editors/BpmnEditor/index.js | 404 - .../Modeler/Editors/DmnEditor/DmnEditor.js | 158 + .../Modeler/Editors/DmnEditor/index.js | 359 - .../components/Modeler/Editors/Editor.scss | 45 +- forms-flow-web/src/components/Modeler/Main.js | 137 - .../src/components/Modeler/Modeler.scss | 237 - .../components/Modeler/ProcessCreateEdit.js | 739 + .../src/components/Modeler/SubFlowTable.js | 246 + .../Modeler/constants/bpmnModelerConstants.js | 1 + .../components/Modeler/constants/bpmnTable.js | 229 - .../components/Modeler/constants/dmnTable.js | 234 - .../src/components/Modeler/helpers/deploy.js | 13 - .../src/components/Modeler/helpers/helper.js | 2 +- .../src/components/Modeler/index.js | 51 +- .../src/components/MultiTenant/LandingPage.js | 81 + .../src/components/MultiTenant/index.js | 6 + .../components/MultiTenant/landingPage.css | 117 + .../src/components/NotFound/pagenotfound.scss | 10 +- .../src/components/PrivateRoute.jsx | 239 +- forms-flow-web/src/components/PublicRoute.jsx | 2 +- .../ServiceFlow/NoTaskSelected/index.js | 4 +- .../components/ServiceFlow/ServiceFlow.scss | 96 +- .../ServiceFlow/constants/taskConstants.js | 4 +- .../ServiceFlow/details/ServiceTaskDetails.js | 8 +- .../ServiceFlow/details/TaskDetail.scss | 16 +- .../ServiceFlow/details/TaskHeaderListView.js | 290 +- .../details/UserSelectionDebounce.js | 1 + .../filter/ServiceTaskFilterListDropDown.js | 203 +- .../src/components/ServiceFlow/index.js | 15 +- .../ServiceFlow/list/ServiceTaskList.js | 2 +- .../ServiceFlow/list/ServiceTaskListView.js | 19 +- .../list/ServiceTaskListViewDetails.js | 8 +- .../list/search/TaskFilterViewComponent.js | 17 +- .../list/search/TaskSearchBarListView.scss | 4 +- .../list/search/TaskSearchBarView.js | 107 +- .../ServiceFlow/list/sort/CreateNewFilter.js | 387 +- .../list/sort/TaskAttributeComponent.js | 15 +- .../src/constants/applicationComponent.js | 355 +- forms-flow-web/src/constants/constants.js | 30 + forms-flow-web/src/constants/permissions.js | 23 + forms-flow-web/src/containers/Confirm.js | 5 +- forms-flow-web/src/containers/Head.js | 12 +- forms-flow-web/src/containers/MessageModal.js | 40 + .../src/containers/Stepper/index.js | 8 +- .../src/containers/Stepper/style.scss | 25 +- forms-flow-web/src/containers/TaskHead.js | 149 +- forms-flow-web/src/containers/styles.scss | 64 +- forms-flow-web/src/helper/helper.js | 68 +- forms-flow-web/src/helper/processHelper.js | 167 + forms-flow-web/src/helper/routerHelper.js | 42 + .../src/modules/RestoreFormReducer.js | 6 +- forms-flow-web/src/modules/bpmFormReducer.js | 12 + forms-flow-web/src/modules/bpmTaskReducer.js | 4 +- forms-flow-web/src/modules/formReducer.js | 28 + forms-flow-web/src/modules/index.js | 2 +- forms-flow-web/src/modules/processReducer.js | 65 +- .../src/modules/userDetailReducer.js | 8 +- .../resourceBundles/formio_resourceBundles.js | 5943 -- forms-flow-web/src/resourceBundles/i18n.js | 7 + forms-flow-web/src/services/FileService.js | 70 +- forms-flow-web/src/single-spa-index.js | 7 +- forms-flow-web/src/styles.scss | 603 +- forms-flow-web/src/variables.scss | 29 + jobs/sentiment-analysis/Dockerfile | 2 +- jobs/sentiment-analysis/requirements.txt | 147 +- jobs/sentiment-analysis/requirements/prod.txt | 2 +- jobs/sentiment-analysis/setup.cfg | 2 +- 432 files changed, 76194 insertions(+), 42022 deletions(-) create mode 100644 .github/workflows/forms-flow-idm.yml create mode 100644 .github/workflows/issue-notification.yml create mode 100644 .github/workflows/pr-notification.yml create mode 100644 .github/workflows/trivy-scan.yml create mode 100644 .github/workflows/zap-scan.yml create mode 100644 .images/export_pdf_bundle_template.pdf create mode 100644 forms-flow-api-utils/src/formsflow_api_utils/services/external/custom_submission.py create mode 100644 forms-flow-api-utils/src/formsflow_api_utils/utils/permisions.py delete mode 100644 forms-flow-api-utils/src/formsflow_api_utils/utils/roles.py create mode 100644 forms-flow-api/migrations/versions/069086882f6c_added_fields_to_process_table.py create mode 100644 forms-flow-api/migrations/versions/2dbcca4a4c28_.py create mode 100644 forms-flow-api/migrations/versions/6fc8e5beebe4_title_length_changed.py create mode 100644 forms-flow-api/migrations/versions/77d8b68e6c1f_users_table.py create mode 100644 forms-flow-api/migrations/versions/8feb43e1e408_subflow_table.py create mode 100644 forms-flow-api/migrations/versions/95387de7067e_theme.py create mode 100644 forms-flow-api/migrations/versions/960477800395_workflow_migration_changes.py create mode 100644 forms-flow-api/migrations/versions/9929f234cef0_script_to_add_major_minor_version_to_existing_form_history.py create mode 100644 forms-flow-api/migrations/versions/ae48e890f2e2_added_major_minor_version_to_form_history.py create mode 100644 forms-flow-api/migrations/versions/b0ecd447cfa5_added_version_to_process_data.py create mode 100644 forms-flow-api/migrations/versions/b338018ad0e9_alter_audit_data_time_zone_aware.py create mode 100644 forms-flow-api/migrations/versions/c8e7baa093ba_filter_updation.py create mode 100644 forms-flow-api/migrations/versions/d4618d0e45ca_added_prompt_new_version.py create mode 100644 forms-flow-api/migrations/versions/e1d88d2efbcb_filter_order.py create mode 100644 forms-flow-api/migrations/versions/f1599a5bd658_capture_process_data.py create mode 100644 forms-flow-api/migrations/versions/f581ec5971eb_added_index.py create mode 100644 forms-flow-api/src/formsflow_api/models/process.py create mode 100644 forms-flow-api/src/formsflow_api/models/theme.py create mode 100644 forms-flow-api/src/formsflow_api/models/user.py create mode 100644 forms-flow-api/src/formsflow_api/resources/import_support.py create mode 100644 forms-flow-api/src/formsflow_api/resources/theme.py create mode 100644 forms-flow-api/src/formsflow_api/schemas/base_schema.py create mode 100644 forms-flow-api/src/formsflow_api/schemas/import_support.py create mode 100644 forms-flow-api/src/formsflow_api/schemas/process_history_logs.py create mode 100644 forms-flow-api/src/formsflow_api/schemas/theme.py create mode 100644 forms-flow-api/src/formsflow_api/services/external/admin.py create mode 100644 forms-flow-api/src/formsflow_api/services/import_support.py create mode 100644 forms-flow-api/src/formsflow_api/services/theme.py create mode 100644 forms-flow-api/tests/unit/api/test_import_support.py create mode 100644 forms-flow-api/tests/unit/api/test_process.py create mode 100644 forms-flow-api/tests/unit/api/test_theme.py delete mode 100644 forms-flow-bpm/Dockerfile-ARM64 create mode 100644 forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/commons/utils/VaultConfig.java create mode 100644 forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/plugin/CamundaAuthorizationInitializer.java create mode 100644 forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/plugin/CamundaAuthorizationProperties.java create mode 100644 forms-flow-bpm/migration/README.md create mode 100644 forms-flow-bpm/migration/scripts/7.0.0.sql rename forms-flow-data-analysis-api/{entrypoint => entrypoint.sh} (66%) create mode 100644 forms-flow-idm/keycloak/idp-selector/.gitignore create mode 100644 forms-flow-idm/keycloak/idp-selector/pom.xml create mode 100644 forms-flow-idm/keycloak/idp-selector/src/main/java/com/formsflow/idm/authenticator/ConfigurableIdpAuthenticator.java create mode 100644 forms-flow-idm/keycloak/idp-selector/src/main/java/com/formsflow/idm/authenticator/ConfigurableIdpAuthenticatorFactory.java create mode 100644 forms-flow-idm/keycloak/idp-selector/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory create mode 100644 forms-flow-idm/keycloak/themes/formsflow/login/register.ftl create mode 100644 forms-flow-idm/keycloak/themes/formsflow/login/resources/img/logo.png create mode 100644 forms-flow-idm/migration/README.md create mode 100644 forms-flow-idm/migration/migrate-7.0.0.py create mode 100644 forms-flow-idm/migration/requirements.txt create mode 100644 forms-flow-idm/migration/sample.env create mode 100644 forms-flow-idm/migration/utils.py create mode 100644 forms-flow-redis/README.md create mode 100644 forms-flow-redis/docker-compose.yml create mode 100644 forms-flow-web/src/components/BPMN/BpmnViewer.js create mode 100644 forms-flow-web/src/components/CustomComponents/Assets/Chevron.svg create mode 100644 forms-flow-web/src/components/CustomComponents/Assets/Loader.svg create mode 100644 forms-flow-web/src/components/CustomComponents/MultiSelect.js create mode 100644 forms-flow-web/src/components/CustomComponents/NavigateBlocker.jsx create mode 100644 forms-flow-web/src/components/CustomComponents/SortableHeader.js create mode 100644 forms-flow-web/src/components/Form/EditForm/FlowEdit.js create mode 100644 forms-flow-web/src/components/Form/EditForm/FormEdit.js create mode 100644 forms-flow-web/src/components/Form/EditForm/FormPreview.js create mode 100644 forms-flow-web/src/components/Form/EditForm/FormSettings.js create mode 100644 forms-flow-web/src/components/Form/EditForm/index.js rename forms-flow-web/src/components/Form/Item/{Edit.js => EditOld.js} (92%) delete mode 100644 forms-flow-web/src/components/Form/RichText/Toolbar.js delete mode 100644 forms-flow-web/src/components/Form/RichText/index.js delete mode 100644 forms-flow-web/src/components/Form/RichText/richText.scss delete mode 100644 forms-flow-web/src/components/Form/Stepper.js create mode 100644 forms-flow-web/src/components/Form/SubmitList.js create mode 100644 forms-flow-web/src/components/Modals/ActionModal.js create mode 100644 forms-flow-web/src/components/Modals/CreateFormModal.js create mode 100644 forms-flow-web/src/components/Modals/ExportDiagrams.js create mode 100644 forms-flow-web/src/components/Modals/ExportModal.js create mode 100644 forms-flow-web/src/components/Modals/ImportProcess.js create mode 100644 forms-flow-web/src/components/Modals/NewVersionModal.js create mode 100644 forms-flow-web/src/components/Modals/ProgressBar.js create mode 100644 forms-flow-web/src/components/Modals/SettingsModal.js create mode 100644 forms-flow-web/src/components/Modals/TaskVariableModal.js delete mode 100644 forms-flow-web/src/components/Modeler/Create.js create mode 100644 forms-flow-web/src/components/Modeler/DecisionTable.js delete mode 100644 forms-flow-web/src/components/Modeler/Edit.js create mode 100644 forms-flow-web/src/components/Modeler/Editors/BpmnEditor/BpmEditor.js delete mode 100644 forms-flow-web/src/components/Modeler/Editors/BpmnEditor/index.js create mode 100644 forms-flow-web/src/components/Modeler/Editors/DmnEditor/DmnEditor.js delete mode 100644 forms-flow-web/src/components/Modeler/Editors/DmnEditor/index.js delete mode 100644 forms-flow-web/src/components/Modeler/Main.js delete mode 100644 forms-flow-web/src/components/Modeler/Modeler.scss create mode 100644 forms-flow-web/src/components/Modeler/ProcessCreateEdit.js create mode 100644 forms-flow-web/src/components/Modeler/SubFlowTable.js delete mode 100644 forms-flow-web/src/components/Modeler/constants/bpmnTable.js delete mode 100644 forms-flow-web/src/components/Modeler/constants/dmnTable.js delete mode 100644 forms-flow-web/src/components/Modeler/helpers/deploy.js create mode 100644 forms-flow-web/src/components/MultiTenant/LandingPage.js create mode 100644 forms-flow-web/src/components/MultiTenant/index.js create mode 100644 forms-flow-web/src/components/MultiTenant/landingPage.css create mode 100644 forms-flow-web/src/constants/permissions.js create mode 100644 forms-flow-web/src/containers/MessageModal.js create mode 100644 forms-flow-web/src/helper/processHelper.js create mode 100644 forms-flow-web/src/helper/routerHelper.js delete mode 100644 forms-flow-web/src/resourceBundles/formio_resourceBundles.js create mode 100644 forms-flow-web/src/variables.scss diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2b378363b8..9c963c5d15 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,20 +10,14 @@ Give a high-level description of the changes. #Examples: Added a search feature, Renaming several fields, etc. --> -# How has the change been tested? - - # Screenshots (if applicable) -# Build Success screenshot (Till a CICD pipeline is set up) - - # Notes + +# Checklist +- [ ] Updated changelog +- [ ] Added meaningful title for pull request \ No newline at end of file diff --git a/.github/workflows/forms-flow-api-ci.yml b/.github/workflows/forms-flow-api-ci.yml index 6c1fdc4298..523f84b236 100644 --- a/.github/workflows/forms-flow-api-ci.yml +++ b/.github/workflows/forms-flow-api-ci.yml @@ -2,11 +2,11 @@ name: Forms Flow API CI on: workflow_dispatch: - push: - branches: - - develop - - master - - release/** + # push: + # branches: + # - develop + # - master + # - release/** pull_request: branches: - develop @@ -35,12 +35,12 @@ jobs: strategy: matrix: - python-version: [3.12.3] + python-version: [3.12.7] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -85,6 +85,9 @@ jobs: USE_DOCKER_MOCK: "True" runs-on: ubuntu-20.04 + strategy: + matrix: + python-version: [3.12.7] services: postgres: @@ -101,9 +104,20 @@ jobs: steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + - name: Install Docker Compose + run: | + sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker-compose --version + - name: Check for docker-compose.yml + run: | + if [ ! -f tests/docker/docker-compose.yml ]; then + echo "docker-compose.yml not found!" + exit 1 + fi - name: Install dependencies run: | make build diff --git a/.github/workflows/forms-flow-bpm-cd.yml b/.github/workflows/forms-flow-bpm-cd.yml index dfc0660f43..d418016e22 100644 --- a/.github/workflows/forms-flow-bpm-cd.yml +++ b/.github/workflows/forms-flow-bpm-cd.yml @@ -74,34 +74,35 @@ jobs: key: ${{ runner.os }}-buildx-${{ matrix.name }}-${{ github.sha }} restore-keys: | ${{ runner.os }}-buildx-${{ matrix.name }} - - name: Build and push Docker image - amd64 + - name: Build and push Docker image if: ${{ github.ref != 'refs/heads/master' }} uses: docker/build-push-action@v4 with: context: forms-flow-bpm push: true file: forms-flow-bpm/Dockerfile - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64/v8 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - - name: Build and push Docker image - amd64 + - name: Build and push Docker image if: ${{ github.ref == 'refs/heads/master' }} uses: docker/build-push-action@v2 with: context: forms-flow-bpm push: true + platforms: linux/amd64,linux/arm64/v8 file: forms-flow-bpm/Dockerfile tags: ${{ steps.meta.outputs.tags }}, formsflow/forms-flow-bpm:latest labels: ${{ steps.meta.outputs.labels }} - - name: Build and push Docker image - arm64 - uses: docker/build-push-action@v4 - with: - context: forms-flow-bpm - file: forms-flow-bpm/Dockerfile-ARM64 - push: true - platforms: linux/arm64/v8 - tags: ${{ steps.meta.outputs.tags }}-arm64 - labels: ${{ steps.meta.outputs.labels }} + # - name: Build and push Docker image - arm64 + # uses: docker/build-push-action@v4 + # with: + # context: forms-flow-bpm + # file: forms-flow-bpm/Dockerfile-ARM64 + # push: true + # platforms: linux/arm64/v8 + # tags: ${{ steps.meta.outputs.tags }}-arm64 + # labels: ${{ steps.meta.outputs.labels }} - name: Scan Docker image 🐳 uses: snyk/actions/docker@master continue-on-error: true @@ -136,17 +137,34 @@ jobs: zap_scan: runs-on: ubuntu-latest - name: Scan the webapplication + name: Scan the application steps: - name: Checkout uses: actions/checkout@v2 with: ref: master + - name: ZAP Scan uses: zaproxy/action-full-scan@v0.8.0 with: + working-directory: zap-reports token: ${{ secrets.GITHUB_TOKEN }} docker_name: 'ghcr.io/zaproxy/zaproxy:stable' target: ${{ secrets.BPM_TARGET_URL }} rules_file_name: '.zap/rules.tsv' - cmd_options: '-a' + + - name: Install AWS CLI + run: | + sudo apt-get update + sudo apt-get install -y awscli + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.REGION }} + + - name: Upload ZAP Report to S3 + run: | + aws s3 cp report_html.html s3://zap-report-formsflow/zap-reports/zap-report-$(date +%Y-%m-%d_%H-%M-%S).html diff --git a/.github/workflows/forms-flow-bpm-ci.yml b/.github/workflows/forms-flow-bpm-ci.yml index 6ad57f4b20..f8527735ac 100644 --- a/.github/workflows/forms-flow-bpm-ci.yml +++ b/.github/workflows/forms-flow-bpm-ci.yml @@ -1,11 +1,11 @@ name: Forms Flow BPM CI on: workflow_dispatch: - push: - branches: - - develop - - master - - release/** + # push: + # branches: + # - develop + # - master + # - release/** pull_request: branches: - develop diff --git a/.github/workflows/forms-flow-data-analysis-api-ci.yml b/.github/workflows/forms-flow-data-analysis-api-ci.yml index a96ad5cfa8..d7dbdaace6 100644 --- a/.github/workflows/forms-flow-data-analysis-api-ci.yml +++ b/.github/workflows/forms-flow-data-analysis-api-ci.yml @@ -29,7 +29,7 @@ jobs: strategy: matrix: - python-version: [3.11.9] + python-version: [3.12.8] steps: - uses: actions/checkout@v2 @@ -44,7 +44,7 @@ jobs: id: pylint run: | pylint --rcfile=setup.cfg src/api - + Test: if: always() needs: setup-job @@ -72,6 +72,10 @@ jobs: runs-on: ubuntu-20.04 + strategy: + matrix: + python-version: [3.12.8] + services: postgres: image: postgres:13 @@ -104,7 +108,11 @@ jobs: Build: if: always() runs-on: ubuntu-20.04 - name: Build + + strategy: + matrix: + python-version: [3.12.8] + steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/forms-flow-documents-ci.yml b/.github/workflows/forms-flow-documents-ci.yml index 945095e64d..52101f2f08 100644 --- a/.github/workflows/forms-flow-documents-ci.yml +++ b/.github/workflows/forms-flow-documents-ci.yml @@ -2,11 +2,11 @@ name: Forms Flow Document CI on: workflow_dispatch: - push: - branches: - - develop - - master - - release/** + # push: + # branches: + # - develop + # - master + # - release/** pull_request: branches: - develop @@ -34,12 +34,12 @@ jobs: strategy: matrix: - python-version: [3.12.3] + python-version: [3.12.7] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -77,6 +77,9 @@ jobs: USE_DOCKER_MOCK: "True" runs-on: ubuntu-20.04 + strategy: + matrix: + python-version: [3.12.7] services: postgres: @@ -93,9 +96,20 @@ jobs: steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + - name: Install Docker Compose + run: | + sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker-compose --version + - name: Check for docker-compose.yml + run: | + if [ ! -f tests/docker/docker-compose.yml ]; then + echo "docker-compose.yml not found!" + exit 1 + fi - name: Install dependencies run: | make build diff --git a/.github/workflows/forms-flow-idm.yml b/.github/workflows/forms-flow-idm.yml new file mode 100644 index 0000000000..3f0d799da3 --- /dev/null +++ b/.github/workflows/forms-flow-idm.yml @@ -0,0 +1,112 @@ +name: Push keycloak-customizations to registry + +on: + workflow_dispatch: + push: + branches: [ master, develop, release/* ] + paths: + - "forms-flow-idm/keycloak/**" + - "VERSION" + +defaults: + run: + shell: bash + working-directory: ./forms-flow-idm/keycloak + +jobs: + build-and-push-image-to-dockerhub: + if: github.repository == 'AOT-Technologies/forms-flow-ai' + runs-on: ubuntu-latest + strategy: + matrix: + include: + - image: formsflow/keycloak-customizations + context: forms-flow-idm/keycloak + dockerfile: Dockerfile + name: keycloak-customizations + permissions: + contents: read + packages: write + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + with: + install: true + + - name: Set version for non-master branches + if: ${{ github.ref != 'refs/heads/master' }} + working-directory: . + run: | + VER=$(cat VERSION) + echo "VERSION=$VER" >> $GITHUB_ENV + + - name: Set version for master branch + if: ${{ github.ref == 'refs/heads/master' }} + working-directory: . + run: | + VER=$(cat VERSION) + VER=${VER/-alpha/''} + echo "VERSION=$VER" >> $GITHUB_ENV + + - name: Output version + run: echo ${{ env.VERSION }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v3 + with: + images: ${{ matrix.image }} + tags: ${{ env.VERSION }} + + - name: Log in to the Container registry + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_ACCESS_TOKEN }} + + - name: Cache Docker layers + uses: actions/cache@v2 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ matrix.name }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx-${{ matrix.name }} + + - name: Build and push Docker image for non-master branches + if: ${{ github.ref != 'refs/heads/master' }} + uses: docker/build-push-action@v4 + with: + context: forms-flow-idm/keycloak + platforms: linux/amd64,linux/arm64/v8 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Build and push Docker image for master branch + if: ${{ github.ref == 'refs/heads/master' }} + uses: docker/build-push-action@v4 + with: + context: forms-flow-idm/keycloak + platforms: linux/amd64,linux/arm64/v8 + push: true + tags: ${{ steps.meta.outputs.tags }}, formsflow/keycloak-customizations:latest + labels: ${{ steps.meta.outputs.labels }} + + - name: Scan Docker image 🐳 + uses: snyk/actions/docker@master + continue-on-error: true + with: + image: ${{ steps.meta.outputs.tags }} + args: --severity-threshold=high --sarif-file-output=snyk.sarif + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + + - name: Upload Snyk report as SARIF 📦 + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: snyk.sarif diff --git a/.github/workflows/forms-flow-root-config-cd.yml b/.github/workflows/forms-flow-root-config-cd.yml index 6d88b012b7..f0a3d2c1ac 100644 --- a/.github/workflows/forms-flow-root-config-cd.yml +++ b/.github/workflows/forms-flow-root-config-cd.yml @@ -86,8 +86,8 @@ jobs: MF_FORMSFLOW_WEB_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-web@${{ env.VERSION }}/forms-flow-web.gz.js MF_FORMSFLOW_NAV_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-nav@${{ env.VERSION }}/forms-flow-nav.gz.js MF_FORMSFLOW_SERVICE_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-service@${{ env.VERSION }}/forms-flow-service.gz.js + MF_FORMSFLOW_COMPONENTS_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-components@${{ env.VERSION }}/forms-flow-components.gz.js MF_FORMSFLOW_ADMIN_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-admin@${{ env.VERSION }}/forms-flow-admin.gz.js - MF_FORMSFLOW_THEME_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-theme@${{ env.VERSION }}/forms-flow-theme.gz.js - name: Build and push Docker image if: ${{ github.ref == 'refs/heads/master' }} uses: docker/build-push-action@v4 @@ -101,8 +101,8 @@ jobs: MF_FORMSFLOW_WEB_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-web@${{ env.VERSION }}/forms-flow-web.gz.js MF_FORMSFLOW_NAV_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-nav@${{ env.VERSION }}/forms-flow-nav.gz.js MF_FORMSFLOW_SERVICE_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-service@${{ env.VERSION }}/forms-flow-service.gz.js - MF_FORMSFLOW_ADMIN_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-admin@${{ env.VERSION }}/forms-flow-admin.gz.js - MF_FORMSFLOW_THEME_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-theme@${{ env.VERSION }}/forms-flow-theme.gz.js + MF_FORMSFLOW_COMPONENTS_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-components@${{ env.VERSION }}/forms-flow-components.gz.js + MF_FORMSFLOW_ADMIN_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-admin@${{ env.VERSION }}/forms-flow-admin.gz.js - name: Scan Docker image 🐳 uses: snyk/actions/docker@master continue-on-error: true diff --git a/.github/workflows/forms-flow-web-cd.yml b/.github/workflows/forms-flow-web-cd.yml index b12ac54bd8..a1ef4ec090 100644 --- a/.github/workflows/forms-flow-web-cd.yml +++ b/.github/workflows/forms-flow-web-cd.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: '14.17.6' + node-version: '16.20.0' - run: npm ci working-directory: ./forms-flow-web - run: npm run build @@ -59,17 +59,34 @@ jobs: zap_scan: runs-on: ubuntu-latest - name: Scan the webapplication + name: Scan the web application steps: - name: Checkout uses: actions/checkout@v2 with: ref: master + - name: ZAP Scan uses: zaproxy/action-full-scan@v0.8.0 with: + working-directory: zap-reports token: ${{ secrets.GITHUB_TOKEN }} docker_name: 'ghcr.io/zaproxy/zaproxy:stable' target: ${{ secrets.WEB_TARGET_URL }} rules_file_name: '.zap/rules.tsv' - cmd_options: '-a' + + - name: Install AWS CLI + run: | + sudo apt-get update + sudo apt-get install -y awscli + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.REGION }} + + - name: Upload ZAP Report to S3 + run: | + aws s3 cp report_html.html s3://zap-report-formsflow/zap-reports/zap-report-$(date +%Y-%m-%d_%H-%M-%S).html diff --git a/.github/workflows/forms-flow-web-ci.yml b/.github/workflows/forms-flow-web-ci.yml index a9a5159b7e..17c5a41798 100644 --- a/.github/workflows/forms-flow-web-ci.yml +++ b/.github/workflows/forms-flow-web-ci.yml @@ -34,7 +34,7 @@ jobs: strategy: matrix: - node-version: [14.17.0] + node-version: [16.20.0] steps: - uses: actions/checkout@v2 @@ -55,7 +55,7 @@ jobs: strategy: matrix: - node-version: [14.17.0] + node-version: [16.20.0] steps: - uses: actions/checkout@v2 @@ -77,7 +77,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - node-version: [14.17.0] + node-version: [16.20.0] steps: - uses: actions/checkout@v2 @@ -91,4 +91,4 @@ jobs: - name: build to check strictness id: build run: | - npm run build \ No newline at end of file + npm run build diff --git a/.github/workflows/issue-notification.yml b/.github/workflows/issue-notification.yml new file mode 100644 index 0000000000..3014ef65c9 --- /dev/null +++ b/.github/workflows/issue-notification.yml @@ -0,0 +1,96 @@ +name: Issue Creation Tracker +on: + issues: + types: [opened, closed, reopened] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Determine Issue Status + run: | + if [[ "${{ github.event.action }}" == "opened" ]]; then + echo "status=🟢 Open" >> $GITHUB_ENV + elif [[ "${{ github.event.action }}" == "closed" ]]; then + echo "status=🟣 Closed" >> $GITHUB_ENV + elif [[ "${{ github.event.action }}" == "reopened" ]]; then + echo "status=🔵 Reopened" >> $GITHUB_ENV + else + echo "status_color=⚪ Unknown" >> $GITHUB_ENV + fi + + - name: Git Issue Details + run: | + echo "Issue creator: ${{ github.event.issue.user.login }}" + echo "Issue number: ${{ github.event.issue.number }}" + echo "Issue url: ${{ github.event.issue.html_url }}" + echo "Assigned labels: " ${{ join(github.event.issue.labels.*.name) }} + echo "Issue status: ${{ github.event.action }}" + + - name: Google Chat Notification + run: | + curl --location --request POST '${{ secrets.NOTIFY_GITHUB_ISSUES }}' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "cards": [ + { + "header": { + "title": "Issue Tracker", + "subtitle": "Issue No: #${{ github.event.issue.number }}" + }, + "sections": [ + { + "widgets": [ + { + "keyValue": { + "topLabel": "Repository", + "content": "${{ github.repository }}" + } + }, + { + "keyValue": { + "topLabel": "Creator", + "content": "${{ github.event.issue.user.login }}" + } + }, + { + "keyValue": { + "topLabel": "Assigned Labels", + { + "keyValue": { + "topLabel": "Creator", + "content": "${{ github.event.issue.user.login }}" + }, + }, + { + "keyValue": { + "topLabel": "Assigned Lables", + "content": "- ${{ join(github.event.issue.labels.*.name) }}" + } + }, + { + "textParagraph": { + "text": "Status: ${{ env.status }}" + } + }, + { + "buttons": [ + { + "textButton": { + "text": "OPEN ISSUE", + "onClick": { + "openLink": { + "url": "${{ github.event.issue.html_url }}" + } + } + } + } + ] + } + ] + } + ] + } + ] + }' \ No newline at end of file diff --git a/.github/workflows/pr-notification.yml b/.github/workflows/pr-notification.yml new file mode 100644 index 0000000000..c2fc77b5e6 --- /dev/null +++ b/.github/workflows/pr-notification.yml @@ -0,0 +1,75 @@ +name: PR Notification to Google Chat + +on: + pull_request_target: + types: [opened, synchronize, closed] + branches: + - develop + +jobs: + notify: + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.base.repo.full_name == 'AOT-Technologies/forms-flow-ai' && github.event.pull_request.draft == false }} + + steps: + - name: Determine PR Status + id: pr_status + run: | + if [[ "${{ github.event.action }}" == "opened" ]]; then + echo "status=🟢 Open" >> $GITHUB_ENV + elif [[ "${{ github.event.action }}" == "closed" && "${{ github.event.pull_request.merged }}" == "true" ]]; then + echo "status=🟣 Merged" >> $GITHUB_ENV + elif [[ "${{ github.event.action }}" == "closed" ]]; then + echo "status=🔴 Closed" >> $GITHUB_ENV + else + echo "status=🟢 Open" >> $GITHUB_ENV + fi + + - name: Send notification to Google Chat + uses: fjogeleit/http-request-action@v1.16.0 + with: + url: ${{ secrets.PR_NOTIFICATION }} + method: POST + contentType: application/json + data: | + { + "cards": [ + { + "header": { + "title": "Open source: Pull Request Opened by ${{ github.event.pull_request.user.login }}", + "subtitle": "Pull Request #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}", + "imageUrl": "${{ github.event.pull_request.user.avatar_url }}" + }, + "sections": [ + { + "widgets": [ + { + "textParagraph": { + "text": "Repository: ${{ github.repository }}" + } + }, + { + "textParagraph": { + "text": "Status: ${{ env.status }}" + } + }, + { + "buttons": [ + { + "textButton": { + "text": "View Pull Request", + "onClick": { + "openLink": { + "url": "${{ github.event.pull_request.html_url }}" + } + } + } + } + ] + } + ] + } + ] + } + ] + } \ No newline at end of file diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml new file mode 100644 index 0000000000..058c821e49 --- /dev/null +++ b/.github/workflows/trivy-scan.yml @@ -0,0 +1,74 @@ +name: trivy-scanning + +on: + push: + branches: + - develop + workflow_dispatch: + +permissions: write-all + +jobs: + repo-scan: + name: Trivy Repo Scan + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Run Trivy vulnerability scanner in repo mode + uses: aquasecurity/trivy-action@0.28.0 + with: + scan-type: 'fs' + ignore-unfixed: true + format: 'sarif' + output: 'trivy-results.sarif' + severity: 'HIGH,CRITICAL' + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: 'trivy-results.sarif' + + image-scans: + name: Trivy Image Scans + runs-on: ubuntu-latest + strategy: + matrix: + service: + - { name: "forms-flow-bpm", tag: "latest" } + - { name: "forms-flow-forms", tag: "latest" } + - { name: "forms-flow-webapi", tag: "latest" } + - { name: "forms-flow-web", tag: "latest" } + - { name: "redash", tag: "24.04.0" } + - { name: "forms-flow-data-analysis-api", tag: "latest" } + - { name: "forms-flow-documents-api", tag: "latest" } + steps: + - name: Install Trivy + run: | + sudo apt-get update + sudo apt-get install -y wget apt-transport-https gnupg + wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add - + echo deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -cs) main | sudo tee -a /etc/apt/sources.list.d/trivy.list + sudo apt-get update + sudo apt-get install -y trivy + + - name: Download Trivy HTML Template + run: wget https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/html.tpl -O html.tpl + + - name: Run Trivy Vulnerability Scanner + run: | + trivy image \ + --template @html.tpl \ + --format template \ + --vuln-type os,library \ + --severity CRITICAL,HIGH,MEDIUM \ + --scanners vuln,secret,misconfig,license \ + --output trivy-${{ matrix.service.name }}-results.html \ + docker.io/formsflow/${{ matrix.service.name }}:${{ matrix.service.tag }} + + - name: Upload Trivy Image Scan Results + uses: actions/upload-artifact@v3 + with: + name: trivy-${{ matrix.service.name }}-results + path: trivy-${{ matrix.service.name }}-results.html diff --git a/.github/workflows/zap-scan.yml b/.github/workflows/zap-scan.yml new file mode 100644 index 0000000000..cab01e53c0 --- /dev/null +++ b/.github/workflows/zap-scan.yml @@ -0,0 +1,142 @@ +name: zap-scan + +on: + workflow_dispatch: + schedule: + - cron: '0 0 1,15 * *' + +defaults: + run: + shell: bash + +jobs: + + zap_scan: + runs-on: ubuntu-latest + name: Scan the web application + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + ref: develop + + - name: ZAP Scan + id: zap_scan + uses: zaproxy/action-full-scan@v0.8.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + target: ${{ secrets.WEB_TARGET_URL }} + rules_file_name: '.zap/rules.tsv' + cmd_options: '-a -r report_html.html' + + - name: Upload ZAP Report to S3 + if: always() + run: | + if [ -f report_html.html ]; then + aws s3 cp report_html.html s3://${{ secrets.ZAP_REPORT_BUCKET }}/zap-reports/zap-report-$(TZ='Asia/Kolkata' date +%Y-%m-%d_%H-%M-%S).html + else + echo "ZAP report not found!" >&2 + exit 1 + fi + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.REGION }} + + - name: Check ZAP Scan Result + id: check_zap_result + run: | + if grep -q "High" report_html.html || grep -q "Medium" report_html.html; then + echo "failures=1" >> $GITHUB_ENV + else + echo "failures=0" >> $GITHUB_ENV + fi + - name: Create Jira Issue + id: create_ticket + if: env.failures == '1' + run: | + current_time=$(TZ='Asia/Kolkata' date +"%m/%d/%Y-%I:%M %p") + + response=$(curl -X POST -u ${{ secrets.JIRA_USERNAME }}:${{ secrets.JIRA_API_TOKEN }} \ + -H "Content-Type: application/json" \ + --data '{ + "fields": { + "project": { + "key": "'${{ secrets.JIRA_PROJECT_KEY }}'" + }, + "summary": "ZAP Scan detected issues in '${{ github.repository }}' at '"${current_time}"'", + "description": "ZAP Scan found issues in the scan. Please review the attached report.", + "issuetype": { + "name": "Task" + } + } + }' ${{ secrets.JIRA_BASE_URL }}/rest/api/2/issue/) + issue_id=$(echo $response | jq -r '.id') + if [[ "$issue_id" != "null" ]]; then + curl -X POST -u ${{ secrets.JIRA_USERNAME }}:${{ secrets.JIRA_API_TOKEN }} \ + -H "X-Atlassian-Token: no-check" \ + -H "Content-Type: multipart/form-data" \ + -F "file=@report_html.html" \ + ${{ secrets.JIRA_BASE_URL }}/rest/api/2/issue/$issue_id/attachments + + echo "issue_id=$issue_id" >> $GITHUB_ENV + echo "A new Jira ticket has been created with ID: $issue_id" + else + echo "Failed to create Jira issue" + exit 1 + fi + - name: Notify team about new Jira ticket + if: env.failures == '1' + uses: SimonScholz/google-chat-action@v1.1.0 + with: + webhookUrl: ${{ secrets.GOOGLE_CHAT_WEBHOOK_URL }} + + title: "New Jira ticket created" + subtitle: "ZAP Scan detected issues" + createDefaultSection: true + collapsibleDefaultSection: true + uncollapsibleWidgetsCount: 4 + additionalSections: '[{ + "header": "Repository", + "widgets": [{ + "decoratedText": { + "text": "Repository: ${{ github.repository }}" + } + }, { + "decoratedText": { + "text": "Branch: ${{ github.ref }}" + } + }] + }]' + env: + GOOGLE_CHAT_WEBHOOK_URL: ${{ secrets.GOOGLE_CHAT_WEBHOOK_URL }} + + notify_team: + runs-on: ubuntu-latest + name: Notify relevant team about scan failure + needs: zap_scan + if: failure() + steps: + - name: Send Google Chat Notification + uses: SimonScholz/google-chat-action@v1.1.0 + with: + webhookUrl: ${{ secrets.GOOGLE_CHAT_WEBHOOK_URL }} + title: "ZAP Scan Failure" + subtitle: "The ZAP scan failed in ${{ github.repository }}" + createDefaultSection: true + collapsibleDefaultSection: true + uncollapsibleWidgetsCount: 4 + additionalSections: '[{ + "header": "Details", + "widgets": [{ + "decoratedText": { + "text": "Branch: ${{ github.ref }}" + } + }, { + "decoratedText": { + "text": "Commit: ${{ github.sha }}" + } + }] + }]' + env: + GOOGLE_CHAT_WEBHOOK_URL: ${{ secrets.GOOGLE_CHAT_WEBHOOK_URL }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2080e2690d..3f341ed653 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,7 @@ jobs/sentiment-analysis/dbms/__pycache__ #Database postgres/ mongodb/ -forms-flow-web/scripts/node_modules \ No newline at end of file +forms-flow-web/scripts/node_modules +forms-flow-idm/keycloak/idp-selector/.settings/* +*.pyc +*.pyc diff --git a/.images/export_pdf_bundle_template.pdf b/.images/export_pdf_bundle_template.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ae32334318fc77bf67d09cdc62ecdb331330ccfb GIT binary patch literal 44031 zcmce9by$>J_qK?D5+WrfI5bEOLk-=Cq<|nG-Q6H9As`JZEulywN=i$Mgh)3iB^}Zd z-@^>&bv*ic^pBrj=Q=QJ_Otd{_g?$nwe|yzqPQe8n1utKre|^fBRUww2C~sNN9X5f z6|*g*F^*c(T%JPPi%mEv&BFU1V(2Sl>@QL z+t^#_S$-?T^{tSknWZCO8mpuwV1u}kfsLUNI;*UawTYuC2n+_Z2?~N7908}S(C;Qy z$BvngNnUDM-NJX*3aWy9#cHh8 zt=AU{GiQ!x(tA>7`&Egg5NefL@A#wSGY3n1EjhC~99eUwp*JPJ@+3y)?QhR*uG4Kj z?gT@0gLrq_$MP1L>>*OCe&*iC+uF;7wVuh=DtttmJytxLk>|4FAn1QzEGFY^C^=jn z%f4LU{?O{-C(4(@7asK01hJLYxFns^Vq%$xRTXkh&9v_-^3g!Wcj_KAV~j{!v+7x2-WnyN-GI5C#0%e5rRcT%0ibrXOlSk}h2T!eSYy^|My;7f7R(=iq;`pTzDW@&-!n=Od zQGS!TsN4(|oea(V59Z6V&?P)F6JCC@D)Qjv$ctSXMYOj@#kY1MRY6O-9pUE7l}laMu35LX33rG%V7{NS zy$9h@HYQ~^l)cmNzI;!KPQK|@W$Rv!0z?+c^7e}kB@(sp_WMmbvwT8ysg=!nBI_t6 zX;&7dF*n|$8DfXLL~*)}@|6tfwVgz?4Wq9nL195Nx6albaLvNX?v354Mzx83ZHsGL zZl4|2hhD6Ubq{U3PUyu!G|Hx{rVSQ*_BQz;1 zzT^|*^Q|ozfir;+)pyDlo|u(Cy3Ic`;;<$B{KdFK!56j8{28v-kEx}`xn`)TYCE_5 zWKgG!&GWKpidhF~B5N^_Gh3Kl955eo#vxgb%3xK$spdzXDqE@dmeIL-&TdqwOt}AY zMxyQ~XS$0r+$#@bnnxE5%NZ6Sma&*=qj44m;$Agq7b8fgXYnOz(EZXH8;6Dt6rpdp z6J<8s;+RFh?j|T}b0>^{tsF{UzDTg};v(Bv^0_TaH{zL8j%OKi1rL$5dAi@E;wf)C zbk^3@^xyV-mSOogbJct8j-TIAsj7a+qMRh@l@h+3U(+u59rvobo65!4 zgb%{^P|>h#JIustw}bqiIbVnFt)YIUxro}tJlD7NA^gFVGhW^CJuG%;+{4YfKpo;hD^O#9N^SQy!y@5brv-=tr& zNZwD$9GmNuIlIGB>r|_zawKw=NJ9Y&J$n{qe&R8pI0ZvJtWFpDeiNrdJK2AN&DE`k(uA;@%JbIGn)a z1mMb!&MISO=m63@X;3B5iStTY-zExRgy{qv4LFJtmPS?pq9-#8Yq$ic9t1!SvrGo0 z3A7iyyqp+ds-g+#^eZ_rX>kxRsQ}ss_=VmYm~ODa|3MfKs{J9n{#EVu6L*36$^Ytu z8LIT7R(3?KKQ!&Xw0`f*Y1sUiGXM+ySS>eVwFtAVpJJ#Y4s!+;(m->7G{Nv@KxY+! z0jU6DRngP81iIICBRxYSd!V&|COYX7-@Z|@aZ$F_vj&}T;&)$&*;xMb#gArkA)5I^ zp!^b@(;)mWbl87}4ivFk1iJrCbYLA8aOVFS9Ueq8Pg>`@ZuBp7R8GPi1U_Y`lSYKa z4+snc>PP4~5tOt4SGa!{nC#!8{+GQB@)PL6i2VVk=$Agh0+aoORsrt-pJ0Zb^s@hB z=)qt_bWSk+m6_k80~B2~ib{}nnv1SULw zKVCOm#XaG-t+Ohm?n{1Ib9ev>h|e*!%_B4ff7{WtWG zZ;S~u^e-?bJ0fF3{)jOlzsZ<9KSKwRF=2}S44uEknCys*32U8Sn)&ac93DR)I8Z+X zCOaZyLjH&`A-~C((4Rq%$e1uizw`;ln2>Lb2{ZI>1STS5LjH&`A-~C(C!3T%whAI; z!ZiIEJb#HY*%2ud);zy7^M}ZUPcq?1{fGe~WpexxWpex`WrBaU{DB~HCQQ>Woq~lX z$2ZP|S^5`36N1Q@9Dl@_9KXq#;GeC3Ac&+1)AVQX{3X(aAd)7md46f;ze5uqK_EC% zKZGU(ku*8}h%`BVlQh9UTL3{2Srew|mrlV$^BZfzEd3jyiO8B9f5e&`zss6GTLD23 zSrew|&*1q>tO-G6O^!dFXu=~11V`$}&_rZS&Oc&J&fjItpDlqn5LpwZ>A$h({KlFv zOaDS>av-uM=O3{q=kK!S&(=U3h^z_I^k?w=CD!CXWKCG}{L;*SvnD)(Kyajf2u%(| z)`aaf{Zcu6)A0L!QW%$k;JX$e*cwd<1YfU!;Il&zd?W_Jx|I?L9$`ON!uDNjoG|jQ zEctGShVvVp{&I%~{Ilg42O=@TH2tdHAGV1(VPnS+-f$pxd9IT_^((F4djq4q|K4u` z|7`ihiP)Z;|3&lg4JXcT{0_79Z*+OYQGx4^7?JCD8S!UJ98N?=glYO!v-}9piKTx5 z9?tI=@uYcvX(p`8bHX;PU||DmcO~H9>eSlr&qX*8hdQqR8tT3aO)ePa{dZ`x|7`ul z`CXUzFPevA&-IPpVV3?4>=Aj0>yPG=18Z_34t3mr#F|{c$(rmxTR(9jvL;N^FP(y8|BW?a zmi~>CfQ#ORUL-$eLV#%$o2B0>P2`F*FfbllzZY zllymB^JnWPE=1OZY5H&MxxcX{%+kLQnp}vi$^A#H$^E;m`LiVs7b0uIH2oPoe~C4@ z5Lpw}Jij#a->eCbAP^j>A3~E0ku`b#h&8!?molh^z_I^h>8;p~>@&HDQ+ih0x?iWKEtwVojdkWzC;0gSZh{6Q=3U;Q33e z$&JXGu;%%tng0$=cm#pqNc|9++=#3R{Ug@o`Ayb@{A?M-gUFgNO}}&s9-7}+6K3h( z2+i+U^N%M}aEN{c9gz>9Cmrrr&VRS|gu?jnzt^6SpDnI<5IY)7)354{=;@AjCp|!<^NyOEA+dz{?Bd;V;AVD zQXm} z>6iY%U1Hm)dv;8Y9P2kZL4PuP<`*vWx7B*E?^{K`&o z8v`dR;0g+G%TmwU1VnFS&3sGw?f^E{K9vfibHyDVB_hS&7Th`3V%n^75@m33%0pXI%N#6#VPhQUo zxFGfKV(`l>2&LJ8(x+EyzLP=#DLjM`E8+m8@J2G?IH6!7gdj~G!3_;woWh*H2ec(U<#wE@1Jb_j&BK$7P_$K;4oz_-)nP(&%<+i71y zlmfoNGe{9@V+VXY?Qn=?0pCtf;}NBRZ}5!K?=%3uo%U74vVd>!tibQe0=}JgXoRvr zF4jMX{)ke*x6>RhL@D6gDHkA00pH+>UWl~;d2as@24Y#jx6|ALL@D6gX<81V6!7ho zYY?S?Z}8lm?`i{lJ0&E9vOvbfX$k{kSs;f6bed^_SQhZ@l*t zVp+hqQ?f>s0=}KHBBB)V4bJG_=>>c{9kme40=}Id6C+50?16u17EucL20u$i)C(M? z|HH+IWdYw#2S-FH;M?iRAEFfS?Ucq5rGRhndc?ATZ>M80q7?A$^e_iO z3Y_u%GfW^#0pH*!K8Upe2VS7lu>`R!;M?g?k0=FvJKdj0lmfn;4nv4iz&H3N{&%$j zzMYO$h-CraPG=N|QouL(rahttU?={cVGf}zu(Nx*FZ*3tz_-(}5V0)a+v&swQ408W zx{HP=1$;XlMiHfeZ}6?Q?`i{lI~{8g%L2Zg&W{kKfN$`9BSa0re%U|6G(uTmPvf7d z6`~aI0=^0Iod&>*(-9uAEMV{HtPD{K*bDt(d;@kePA3P5dV%HUKNC6x4ZzYC1Ye(i zSN6o-AI3K*VA(%&K|~b*z^4nX?{okb|1$$YEDacUI+6UYG=Szm^Wkr$Pu5F-_}?C$ z%)9~dzlZRX=>;H$PjbFpQv$=T<(^J+;Fo=Y7kivcH4!H=CsV8Mrb6)hjVDtfB^w)H zwkq?#>$cw}N%FvzZW$xjlUu~!gh08|iLA8v$>YFh*gOo;q94`@VtS5xmNq6QdQ5=H z5e$1^<3!KiLfYEc=HyE2>2=~$3H&=FLlAJO+X&Endb|4{a{(Am97L=VVXFxKdJ|wi z2WIEw0|++4spFTyKO+^}w|43vD<{@NbzZQOZadyhB{+VJrn+a!nzbkq?w zA%cHN9Epq=pTK|sU!S8iYLoVZF0R#ZM(dWvlw1#ondOx*xpp}n>adQ(;ofGiSIcq_ zM+dpeJj(llZ?0lMYedJ>f@sUdc!2w}l#y;^o{WX`JS|4g5cQi=OYZAs(+VnC66v1UChuEw zcGE%{CfoB`sTC!=w(%|o`LEh}eRZVr$3nVG-gme*QY&7McYvEgY(M_MrNBJ2Z+O?``Wd^UO|MO} zBTH}8O_O44vJh#!X+kriF7p)W27C#xYgi8A5^ZvIyh^@wF}|+RA-L(f)wFHs#T#L! zipd=^D^Yffk zDRQsrE@KD=7I1_#1oe-EGHV(|aJjso^@P0ZyEhu6cVI=xKGz7dL{50n zeq`A=>w4T{0a`)YpoiA2-5W>M@rl(%!31G9yP|mIA2X?L(jL^0q%1Y9l5vndo4>Vz zw^3jy;OSE%7XEZKeYz-$@gB}a4aIts9!!m{(xVM%h*I1KuI;Ew+tTednHPt_anZ*Q zs$w1z;(UbLzqQm&-95k2HNUkIH2cG}0?C>iwT^T5j;B3s z6%r>DRzfGFBgQXC9V68QX?--FH|8Z#*q7RmKGM76eO`z!G5RRU);X4<-D$o&J*Cyv z-p%^8oj%o_mB70b9^O|ww{$SH+;>S_Hb^3|QmxsHILL2%8y+?+mOE98cxMq_x2cLH zU-qnx-FL4eaCTnLLMPiOcJN}UFR>FF#gVsJxCkDM>1QT|x2(A)LlbeWV$ zn8s3A7=2`Q>x8(&vvC%1($LHq_t;CNE@@_tX{}E8m!$bhM!qhgEhRk>KTn3|6If)s z*}KrG`*J3aV(3FOTXBKRn>7FK)|$Rs)fW!a&do1q4S1qY)+F<-R>$_vmQY{gt;Ace zN{(qfbM_i?^QB80@(}jpP>Hf{(^1S}xUt1cbG}=@ z@O7SwdF1oP`)=yI7}dEII5lP3D^a))Nvf3mobGGTA@lj1>5L>PL|c4*uJL(*_*8P; zdca+Q4L&3AK&w+?htc!tM?=1;C~1vXTzB!?)zL|W=Qx=7?-N>}YI4;vU>zbq*uFJ} zwKXoBnweJ&)qA*DLZOuTO7`<2E^65G;;;wu7iBVU&^50@86(5iQ=Np#KkH09ncTNq z;Td2P^kVyXo;G-9Uwl1()o`klo9@QUet>wSoz?v78u_*`8j3nbU#76*2b0dzt&Azn zo0(uYiCsKBU8)=xkE)w7BrQ`@hb9jVNR6Z;TNKpury8)b^t#8xiaKeJ_g->;xT~Nb z8iZY6ziCYRp;;@p--#}(n;vVM3iX)MUe||v@^gHe*|BEBIRc*r;U}nPEvEwC=NM8s z;~L~vD0@Dz+)So5ryqQnYRAP6hC~lFNj4ixzEFs0yPLrLWNhlflWu+xcWOBH4qY?X zQ-QI@aM8=Sr9$q=8?SpP`uyEEG%h?hCm*Dx*%ej6=u8kmV!kg&OjsPg{65>bI|Kut zH%Tch)RHI#wVWdPXao`$hM8X2EzS|gyWjVDVC#I=;XK5g=N5TA@u406Q>(4vbsEtP z+j`~!Z6O6()rQheYpWY>8uePSZ}d3@7;((AOc-Ubrq=f#oezzz%J~Y#mDd*J)Y%Xu zYhKFDOvVXuGF7{eH8qB;iF@?g<&1z;NbfQhbuT&pBzd{J12%-CS8?6w4ohl&T&h!U zr6%u8%&qD%jBej_R3g47-D`xF)7Cb@SP6IPvxuHsFU+__Dhm7e2Hm?;NmPr6+7xsZ ziQt7^e0D!eOhuiHan{XH@mIYs%LV0H_EYrVK^$^II&FK3ST?dl+>ix)dX-gg*xkOt z8VU)O&HfNiAzyd>9#60(2Y!zcSCMPB<~Y;NTR$aHt5sungM|;pNvZ)EUpCeAk5p`8 z3Kiw3?=?WkGTOxry7)B9AJFD|SCbxwe4LHfGaIEZa}0;f+9yzmv?k*D$G@z2^Rcfg zWv^&-FhV|*Jb&-YF2gOO8veJV&UdWIiDLOG2`zewL~16hALq|dGj|nMl;+|4sV_4S zU(DxyUZ2)EzA|@?$pbroMNrPCzsbT~o?!C!jsxyE0Z9xylUQMRBX6iOnsX>g^$zrL zB-lzNlcM-2qez$|$qp6GutzE~HwuJa73ZWi@SHZSCEPiq83uz675AAnRLF(Zr9Ggdp@F^xQaluuyr zsPCoYz14b+x5Bf@XK^(=hEu5?5@c>3B!(zB;EUJ?8<-}iDUfd3TCJPu?!Kg&R6H9l zEbur3W3p~uq-Jr7ZqbP_l2O(CKwU7Hxbk6=vP~FC1(stzIr>(ZTTV~qRYqk^!DYk~Qdt34OG|Teq0ytuNoLm8+Y94+)B_>Rd5K3coe(G72oI3!ee4a!>o`LNFUA*q zs^ee2Kd%}3^8Fci=-|6LL3B;}!GRG=pRKtKosP`CrgWl!5DFE{p~a=Dl+3E!RV2Q_ zj*1DC$ylVC0TdoM5 zP``EQ#igP<_8;yS z3oT8N+549ibo-2HknA-C(svp}up{rV&1~aR(oKi3Z?#cCDR)}Y43E+eN@dsgW7=l) z_OjY*S^c!Or#?f~)edt!ZeUl6-Z{JAkSZaoDo#sB#z~>lXV`?=+qW`SHpC&$yCW^*Nh9_@o))EvS zIb<+q{Cvwna&8nn=Lh9oOtKW4AViWxA~}?YYo2)*1hVGIa(8%vkG@1)W1D4rae*1;WGbb8O`SE?TX2M!iy}Vq(gDo zEne|jOwgI<7IUoDT9=q~hFwg1vga^W>v#@7sb@E8YU?NwJkeoi?RLrXp5hL;tT~p9 z*=wM^F%ZyZYG7R^bu-@43L-FDW3S<%3U)9r0H;ru)W zx!bN4zt&s7o3Z)NGOk@rpjY|8`N$fHUOdcRw@)3y^HMX_Z{SO{YRHIriQ?y%Lsr#S zoE)wY1ev1^T}(19C^SgAk16m`_jbM_=Pf-(vv1FDtIXiLZ=-Z6QT(6)nvM)7J~fg8D&9O67;J_FJ(RD zI$l)pLK%5P@WQ%{q?qLeo*4X(uIpHaolo%fDqpAM57CKU3cMtT(YW{4OrSy&&bj zK3Iy#;J9vb&u>WMS(B#}k(HMilhG!rzV~{ZaI?@Gwn#HJ5u!UJ#hz|ENAo>B6C*-e z-jL-(-d){~gt{N5@oY7AauqN~pIQj@dQO{JZ{PXKNq6TfRcs)eWXte0!OFmBh~dR- zYK5Gy!4hmCs7GCAV+6Z++p_LR+8PE#%i8>a-LT;$m_u9(|8+I^ORPl)3Sz>pvc1SK})Z^bOE^KEjp)s$}Yl{v^w z)bu?!XLGP~b**l!{;ba4#lN++G;A91Xz`laP{nI+j!zk*V^1s&o04iqiXOXETjy$lz~K$)_W_! zBnHhV9?PiH^7XY4D@Z6I*+D^PC6|AuR*Y++`(>=aA`-rV*k!&~&pwr0C2ZeH@NTM{ zwPo#?KhE?d6CFFML6L8}p=oU#;5IT;?R_*g8%@BsxZwPPUhmk=xFxLSjgY7zPa19F zMjFGJs;i;8tc_KF^yQ-FxM`gU?fNN9l8Wd+J$UYF7zYF;|6; zHBb3-x>5_}haw!DNE&i)M86tN7Dcg3Gr0csLH$<(VQ`seq=x5{dIob35Q)+~r&0^i zFToWRd?YvT4HMLMXJRs-J$e{iGn;cZc)06Ib=DgwO3=pU&HLAw7Wz=dS1ch zGAIE#ig{-n4Cq5!LPe|z1NfK&=MCy?oR?(K+6I@p;sacY_%jvS-0#>MIlVhtmw%sB z-tz^N#E_|!QZV5%ad71B-l(XaCf5PkD-v%q5OP^b%Wl(ry&v$jBeA9;+jk19qK!}1 zc{Tc|xe_h2acOwi(s<{#sUoX4D>!W?A}4pWZKOl0WAiC-J8G4?NbG%J?~k(Tjm|VQ zd1vpIcV$ZRVawqKp}8HgdZCZCOLL+}LB~NUMRU6jrt+Cjbk7vw)jV#KrE!dszFcf) zaIs^PQC|;pE!KIt8S{2|aqzKP&oQBzKIE8Q7Lt0>;na% z_80d*TXIB$`BMDcaZ3cYvNy!=$0|BeAeE@Xw}Kt0F0T@e#V{euwwJ=#&dZdqwO9Kl z8pS^NDA|dXdDzBR(PsV$>pp2ln<{tTi`%b^D=ZJ)2J5!a?<-{#Y-hw|ubR#gXjD^p zn-8^r054iJxo>kUw20u_m+|gcBVE++;WPCYyYl&^{Bzpwbcc#3DDs`3952rYuh8@A zG)i<5YF&FOk(Hlm5hJdi8`FM4l9f>Jqh|W$rV!d-mdoqdggzSQ%H?v($2>l?#}^GR ziKd2)K^{iM^I{4_Lo=KoW-wy33xKTd&C3L~T=JNDr+xznMeF_T4$IipgJ`q_>L#~m_A~p^tuRMstL^Er}6dvHnHq%mGh^EGpF~D7YG-%w` zB~HyCF8YT3JVPV1Ikpz&hmA-Qb8M`@@Ic(O2a#!UL2_#A*YqKUvZYzvWql?)!TCeu zx|NX>*Onmx_q-FF0(}!`QU$GVzmYXh8@d;_(Oyq^H5RRCG$vch#`b0US#n%PD#qDl zA;OyZsI;6qls8DB3R+`@1T|-n%V%()Ra`G~DqXO4Tq_5R4#xs3Q)-K#4aN+Zmq%Dl z!yPCqo!ySYS)E#+vX#((nR$e5cQ_g$&9zst@D8VrUNjsdlx6b1EK^;m$-~uLVp@yd zyMZ60eMm6Lu}AEX_8mr%Ro1eSov{gbFx|u5aFZ{*mxxFnObpVrRpu^AtvaB$=(Efu zaxy739$8*drORNI#*0&Fh$DBJE}tyFWKa{`jjR07m^bcm6Pxsf?T#!{99=Kp81@kX zmbJcWpU4Ryb<5@yYt$hC3I;Tzc}YN5ayA^=Z88Kdp@x`FRpbIv=!ZA zwy-A@?+=*YHj)rJ-ka<#i|tF;(lRa^3yt1?SUbQxD<65sUgIzjMg0R)g>Yza@d%IU zsKxYUl2}p(6ZTo6kgy~cG+C&e_#A4{$dft7wL0FbpFKV+a6YTy^O*CD^{8;ZN*+@% znz{elS!XnMO>4V)b?Q)%Xq&8M++Trk!>4*a2Wpnl$P||i&E({U^mHc}74d3%_X3 zGbr}v92pPPjH|q3*XLr$Q1`68LHH7vC>qMJFN5h~B$l~%n7MpZ4d(1;?Gl+) z37w2XD^_ejbg;a9UG>6%a;H54pi_I z;ipw`H*NoxMicRU0-TlZoMCXuk-}BU+~VMe!SYdFpn4l|9#(DKc?cyxWj)y^HSr2f zf4BTkGw4m%qH5aj3Q!ADC{@!3Z<=T@#~ZPlA zL*ibE=PkrdjU~gXWGN3s{VOE~sYG85GAUuG&TqcXI-25sni913;=E1J8Wm0r;}LfL zxBz9n7mjmK7ghZY9CLAcygKUHpiL^t{p9N@ms0S&sqI@hUgTM3bm&K1uhzeW;dOC9 zth69xHF(o?oj?-n)8KX*J-a?WhnRoD#;*D_d$@Yl?bgb9HJY@8-mo=IBH_&JOnE34)TpP`9xTKPL!47pph75y*!gk8lleEPqd*vmH0Eb0goRPt)VKc&AsrP|UCIroTNS7V?^)B@CTnR)jfZuy}yBclPOJGjp%}lh>wbZi#XDqEVHO>)?+znZQsv+tvN62D4Nk?<{q>B^TAo|;UQf#Fb$d%UX zbp^#^TE|-^G1BZCikHSfp|qV#h4nZ6nu#wKc}t#0pS$nZ{J?d_{}yH4BjlGOw#}VN zF|iewgReEH3+k3@a~?SED>5Ir?dD%y@wYwPtX5jisS6m@m%t%Jv$B_8j}F##Cg)C9 zp9t*DQ=e9B5zr)j8n*6T)uGR9wvbCbeSF3~SIXkYcdLV8DSO`?#-F?cUEIjQz~0Oj z`0FC@ccP!95rSAnVE-6p&Ge1z^&Ek}C8BI(Z)VIaYGY{#6tmPbaR6}unZOPPK*}kQ zuK{KOgCXoZ=&UE&PDIS?U@jI28xJS+fVS(~+K)5-9cZBQNUNhmUc{stqak(^bQg3D;Vr^n+1Oncoeqx#^?Dgx+U>I-$y#b^%aI^3LhweZUywqX5WU06wYMNJ)#!>DdCWqc;RVF>`c`RVcLmXl0Do!42uRsgum|48ZFZ8x`_E&5P}#}W7I-`Q3AVt6GIUn8 zlN?e_2p1;{JA?z4lE((*EwX8yr1AkDx!D2CxVV8-V;~_-lMBqo!UF-|2eKE~fK)Or z7BDC980^zYCMp~79FRQ=WIJ(iKv;M1i}J<%L4|!1|(1*+Yvw-!VY9C z19v?&|EEanByH&1M?mj4J#0XF;;EMJYW|;vT0pajs{!eBCw~1ehxR1h`;mOZQj5;FphTGaWURU{ErZVrXv$4tt*ACNA?K0XEQfx5FU)qR2Qk z?a;sn53{V3NuCKrjE~W4u2l_4LgSh%AHFHD?Fpb^P}A6dyv7*VSx6r@;<~}ck#SXB z-Jaaje_?v8-Gz=*`=gJ9jl56KY_m&j1=i?z{$T)8=o~or+KW_dY1Sv6%czq$!`or# zPRnziYX=O1CK&t@@>!(NYjKuzbxyOWd7%wCcU>>$>g)E_xTG*_?tFmZC{p6(%wM1OuSrDQE>NK2Ygz5hnxbS6{lhGu;s^T;lZMO`UV;2HKoUD8l1axPR~ zEG}wcTv_CEvgfJOsLzpHPfVv^J(qTYL(XnwbTcYFO>TJnV4k^HZexNgZEc6PoEv-O zIQ}taK>FUw3ul+v_V{>+Nhj2yb1C=?1$8neUbth&lU3&N!j0=-inU9kl`&)80w9k( za|-{X^Ir;BM|h^Q4&|38F5C>GY#)YnxwlPfzBo_8Th%LSvom={nv(L9mw#+;rLpq+ zhWVX~m-KnXuE(3q1h33o^*+8!TRL%n#)uK;l8{Wvl`nm9@9(&_by?Mp8tf-svL>uA zEtuEoKkQ-k`bwN}jMk=8{`_kx($TFg8@59l?eLPZvb>ka6Yt6{;C#9?*DHKH+0{r7 z4Vvdr6@2N{^k!CvY46b`V7*&iD@G*6;g3t@^WcJP(JPk*{%+Y7inH}JpJN z{Ej#s8N*MY_vkiZ+&&6g$eEF2Qmh9)mui#-n|YpXemY7hiW-$UdL#eR7h7Y^BkacJ zrLstO*LQn67MY3^9$CW~rIS#dLo)tjWo5@LeyYojoyYxN2Tqxt{&Nvivn_|B6X{pO z3OKUXEt|hYs~8^SGFXyuvNnIo_P>9@go%7HBfUX7Q2EBX2mj2v3S+oWvt-Jp#Ex_I zc#k-vBMhb=TwZlezxOEehLVggPenP;z?1aY%_t;$#v$?2NWQn=4W=8&Q#UTIM~XH+ zmKL}W1vM1>|A4bNw(^cC}pPdZa|V}>-I!In*o*62sX z?X2~4GEDEWquI2j))ty9E^3b#0IOE-skB!QrS7Bnw=7D3oHL~Hd&P0Awvirv1a-w- ztnn}qKvla1YRRa-9C|4_p26RjbxbeKU!?bJI_);e zb>5*XGRnm<7W!rvX>$2vW-}+xQ)9h1)sTo|t{{GH%AoFJ-zyiuHm1=3)X{0`NmyVV znt>u3gQzt@^nCDWr|>R2la5Rb(GYlZ(@@;8yd>o3e_fN`6H+*O(&l(buhR zl?RtUsuoXL3MANH)!D6m+;TjIQ!+ozvCw#-7)LomYg)ca$boF>${w++hBO(idu`fK z(RB9hg)>m`m800Zxo@=wI}HYpOP*n*B`W%+h`8^1_Qqna=@sM}JYS90@$yt&Gufpn zzq;kYIj+y+Wcr+2gu16CmhdS>5(_4dGDP{dm;2H57yA|x(8s8LF$vQC_FXy z6za>jy-^*Ok6GhvYH=M$_*iYKWwB!Cv{$OCRyQ8B`K}!9bT4}!%5+)pV!ZXfpt0;- zLsVSrx?bPZMhM+|TEKJ{9A|ipeSAMgXs_SOej2zC)?MESK1r1`0biit`yf2-L+c@Ib^l=T>l(Gr>%mW#Or*` zJ{XeZdnk18E%uu=Ll~`6G=QALBn0v#9rcKv%AMw zrBD-baRLqbYrxKj!>_uI6MLSHD*{h;HYXaEG35s%R_-mk5DW#PB5#Xa5jh?<|2nLj zEr1fO5$!3>Y<_j%X+79QPn}LX#7|;dVwu97Pq3pY<1&(KJ)r~h;T59SXQWuU>@QX*8dq443W0g9N)7_iroFxip ziz-KZ$!{(PN5*VRL=?G?O@o&d%h>NtO%QrM0>6+|9 z<>mt=fW)kKCP6YtZRO95ZlI6)-3~SN6Z4BB9u`I&^Als^2@0?~|IzQ|xgI}1O#bt? zkqbmzid`*{NVR>jCavg)Hzz@#u=Nk?(rM^d>^(ItWWF4@%6%+%FrMIF%xG2=ds*|s zc@`6@I92c>CyzcwTUA5lGjrT*Ye#G6J4Yj)W$6o&t(OW0%p}4zXx6S2_9(BSJ3}dl zv~UDR2+-vWT-CVGR%D*3puBhvrJ&^jPX#_l%T^q_{`TlS1}X4NZX5`jBfZ5D@oao1 zsOsjNeUi_1hN|ppzC#I=GWOA?B~6D3Be8&Sl6>CBECmhqxgg=#XM(t+ukF)oaxaeX zW%u+~n5&s)y1tdpzxa?>R>EEjnU10i_0hSG`~rUI2a@kA!fe&I#@eVpX7uk@sALvd zC1iqeS96quujh9gir*r=$tcIWPc&~BF_muOIGqf^RUe)S*IdyUuYS#0?_@BB^TNw; zdL`%sS*t^z#0FPwtUr|w&idQ7en0;7MXb35Bmej?Ewv7F}ko5+E?5IfrE1YZ# z8kJ^LQt@?eZf5hauDnzOBW}?(LR)B?@{{IL>7=#VYqW3A8=`IW&Kn|6I7rgUfboc} z&BJEN49yl8DBZ`baE6<1NYcqLG3OcHEu1#rVj=9D&D+bT!V%21E6*i&t;*zMufm@( zBS=J##eZ4+brAQrG{opn&_JTOe~UQkk?cX{hnD9mOP~=` zdzIHOiTmZNb88s4M_#jEAhnhKA}+e}xtIIdy8%7XmGF+6hpnQl{hwFr85VMtGgv=4 zVTt43ffRJoQRUXyc;zb5s4_<@lGeR}sxp4cFE9|`Tac<)cuM`U^p0G<$pf2DpIULH z7QP5>ZVWFF6_#9{xmM|w?&PY_IR3ce3TJ-HhlGXDw^lrLRxkPqtYo_)#aszr@nr(} z&;49woe8T6eZAuv+BC&tDg0h)c4J5%m=;wsLI9FOn$!&Y)sD| z(Q)gGQ#nJsC>IXhWd&m*2$hJ-g37EU$)kfl%g+Iynp^c-f|@xkj?P<*5?DlnX2Njl z#c#84XIvY8zd1y(BuOI~rn>1moz4W&(r)c_ZtQ#Q(a2d_qERP_VK22{+#ma(-B-N> zlIhwN*TFyUt0hNgG{{+7y_M&mw)kSUUm4%jYM{OJVf^iWw|*as`)8kHzPraXrAyr3 z&EIZ{f6!aRPSJ3Kq2SD;gF=Wfew;Bbs^JhZ$$jaEY{6`T=nn~q_-tdMRVgDrcn$Ey z6z!E)Tg_ZoBYkM6FA+Y8O*xq(vhvzo{_L$LiQ*Ro5GvjQSyXJcyC8Hm@M{tpH zB3JPo7td&pFsRR?rZ&g&E?>Kt1pK6PpYjEt&vM2FE9CeKsiu6Qr)`xE_d!7we6IRH zM>XXv>JIUfW~R&!>QZD=@oQbXH_~RlWG?P<+eR{$tJV2Los&u`ERXclyU254Pcj=> zhC%AAjpMztsjzS-iTjhEJW|Ijpngu;_1*bX`I|VQQe8%onPd5Zl@g|M_f*lF)9)Hw z*X5R`k#!wo%6&W3+do_A$a9H2o%$k!9pfW^ z!$TH=Yf-5sP$uPek=O;wsoz@Bmw~YnQxK+1dEOVKBf>>Poyr%o-k{PA{?8 zFj}h!=NG^Hr{Hw*@R6%hGp}v$Py@fXFoie2db+z*rI?f#D{8ku*Q!Uh9$t;6oo+hj zmg2sIiyLe702lT73#CC?+Ckh>Sw?fuo!0LAMaI0wmMQBji=H@1nGW;!*mEzOyYF<* z{!wIXczvR9^%;5rvPBl3fsvakrYuexm+M+=j|Rbcd$_8#c=NN@eU>IK;$j+Q$<|Yg zvP3hA4@RV2q6rl|OLWck6_z9^?kwY$y7vB1<*L5j-A`2GG*_sNqs36CZ8cSOE@SZ$ z^0+6B`t~NP495({UU$W_*2vldTByd`MoxjB>Z(Y0BGzwi5<;L#l=t!)rd8K!Fhx@SAV(O}( zXOEnM&&<|@$S1s#5{1y?WfukQL2$K4%l%dF9R(M)TVscG(*ro667zICe{)Pd4^c`&kq>T!+|MN6q_|J$KG z3U%bK9I35Wx$-%&jcnd$%vwqh@d2A$3hyuC$Zc2C-nP^*s2FQBlVhMY@5ZLhrr^8b zE-@L6P4`Bn&ai%NaqSvjapx2J3y*W2`Oz2QdG6n^@b4Z>&vG#5Nuo(gy8C$8Y<^2% zZR}Zt17wkxv5nVeBjwP-veSwTdw?bdOu!PO7H;x@{?+_#|4cNXw7BgyXdw z+83ubM?YQ`5wx=!x&#?P47&M1dY(wAmA`b)_x7X`v~8118749~Jtz z{YKi$o}~Htw~g%0%G{6GiCLLjosF_G*kW^-=dYw(8QeUW)OwG7D_E%awT*^D&x?kw z>O>C#8BYtsTg$H(`_VpmrDE8)rdv%ZTX+zs9YL90I@|on#;~{KQeL^aJbUG_-roLX zWX>@rA^dg*tJxiwf!U+o@FvikxDi(2H^|aSn;;F;5k`X_Uz(%&PPbKQl4_%wS6Y z5sg!o@w3V`VLOc7NUa)~CfS=^Jo-7W8y2@;IbCZedDixs2_@x&iO*g64i>tymF!oK z9b4K8!dEkud?g@9sk-vl73+j3*pBedk2X}V&2;S8m!X%o=e>Rv!NHKtV16Y;A8+r5 z(G2rL2$VBH!b?cONU&XJx?n9%uvs}-T|%3u{vnmFuv1)dm>}(uadCK(K85!-#Cj{y z#%phxd!jMsDw(%V_2q&5i-qK>Z}Aets3a@uUN#65%#2QtEk6&Y-8O6}1Tkf;W@lLb_3uZf58P z>5`U^?(R}rP(Zpnqy*_s>F#dnQo2Fly=LFfz1=YTIgaPsi(i=koNKLXE!R4)YYygj z{&yd5=030#k%Yq@w-M`Jz{sPGR~D+6z%p-)*0^v|P+8EJ@&3H?9MLqWMZ)X*OUno+ zl8b^lwK45iXV}E_86v{1H$Ng=M|e7UtWq|deTpy7)(yW`8P3$Nd$|_I+fO~VEDZ+D z_Ekje}`+qD@=@mjFk6%~Y}dkm=>7FlVMd3#esL+`~Dty)jZe&*K^^h)O?y%08v zok6BVcqovL~O)}vYw8bF98#`s(m&T>_EBZ)<@XS-LFY%;WOWK zLT({BmE44qx2rIWO;i20S}ZO9(Ui34)Kv`Ih*&ks9BN36EpjzUi226zHsK%wf-#+J zqL=7FG*ZfWE#&BYuif)9sD&C|Q`=-b@WkZ9%#|Gad4Uxn_xkD;jOUBglCv1IkF~G+ zImgmeH0X&i9c9~_5$1A;)@1iLUgNe-e2T_VulsgY@MVHlrP&-VEg8w^NR)RIE`*W- zqp70ug_#f+*Muwgki$37a$sEuxiq#%Q7Czo1Kdr958H{2o$LS{-_ys{L|}%b$RB5$ zQ^`Nvqv03Q4fO{l*zzBmywB*jNg!D4I=9oE-OzAN;4X4Xc!o9B-%HFW6m#Xs z(1JFJ$6dOv{Y#lSk(;onQ%hb5+0Fi@+}XqZ@h{@hjO}~|GI~dBY0R>wqu$Lt5|b&O zDQ_?DY>OW`-U%pOz>}2jd$Ci>+nOjSXCNTDWTURdEiXZ%*8WvFdv2h>G%O}2cINB) zA(t{md!!JkHiVvAii-hTTNzYz6;T4y>EgH)!qvv`gp5Kp(gD>bK-Y`F%qS3D=Fro+ z@l1@iQ!Cw|HGWCmcqecsrcNEX6|lem}%CsJpj~z|GrI znY3=XqfD#u8{X3Js=#6WGHV5td;_P?U-(ubnQeelnbEkUr%kMdLUYi*tL$)2=Adrn zi}_j1H(oUdWwyB#S2>Ee(Mb6tab6YnVb&UkU$`-!^V=$uF+9UT!Orzl(3R)NqV-D= z=ZqC1rq&Vnj(QSs!Es^cE*^16%fzHI+gunAz1Siv8mFWn=-{Fd7Ua0%;uBq?OP303FeQXKUn$9+l zWxeW>xAV~yfXyL|IeGR|>}2Bv_oQBH3DZBotT1^WgYhUSAtzav_0<^e_*fczusXdK zh!k7=6NVVGn@xf7wqhR{#wz+qYl~HC_#TO3GPf8%zt~v_%H!FNl=S{oYmq!HQK$LB zR||=2&nB&&H8pPUgJQ3EmTJ2myqg`Q2!zx2Nk~HXLhStTsp+X8hQaf12BLnnJ{`}0 zbs~H`Xx}_qy9ql?Qpx4f7RY(TA!_YqS^R~6B%MQAiYv^BEsJlZcO%X)p@kCUx<)rF zs~Kg#JeSU5`C2LfR?#)n2aF+0Ii2)8xit!hEBBp{*pb5nyh)x=f%jTbQYDMJE{~~V zQFOGVw=<2lyxQJIJwS0ze<=k!lIx(QAW;fu(3`tfzn0-w#nd6yUnW)zmm#7S$mF! z*qQohX>~cZf9moJz4MKWxlYGKY?TFnRaW55?}G!+kivSSmu?>5o2|3T6n^v&f(T)H zWYmUW&=2qMk=w;Lu&a!RGrp{^Rbl0nng>{>v>u_Yx7bo{CViEyIx3E|E+eiQ<`td# z;_r*)k|3~kD!EDH%ZChq9QGrnQzZ@2Uj*de?i5THFTWu*6Y#dQ*GblrUCPLOlkJG% zNZ6>b=LtC-#VOh5L`0nw3EdKnRFfu|3-{GgIz6e;nlBCMA?V_KfZ^$Sz-vmME{;vb zjBJzr4-)~d9>a0?D5cz$%wEHzn~I_T9l-O63x zIHQGlvfX&v4R)w9)P2ucTSy6Px)iI;vuenqjhijMjXrU{wsCP<4qcYQwTS=jAQnkm zyuPsi*^=;`wNq_H*zgY^S$_z}yc&^r`IK%Ohq*G00ohQEUXD1j2-Bybt85p+%%?F%L+)grA|JjiBZW6#JbcO}5++hyrv2dQ zW&4*mHflcvp9O7*6l#AcM$~#*yjYJ&`D%($=zFztX2d4CAv4_}CYdJI z5|Mc^GA%LYg%={K-xE#Ia~G2w7UfQ~+|aKt`h1C06b;1&!s2igxT$8S8Wk4>)U27x zf4LCv1Q9AkWKQevVLQuOfeZ9I-PYb<^cDnV55qWW_B(bI7wO4P7)aqD%O>Nv-E^*) z{+7MDW>mO13oXWWSRPyflHU2pYKH37R{>7Lx^ACSa$ux13LpE9J;S*y6)Hr`RaYdq%(aUL_Vam;YIt6xFD<3EIkIBW902~W5vRX3<)g7_sK|$ zGE=bvm>5r%u)a3oiju}y>QIM-hwm7sMJx6lDJRo07^w$Lpca5=vctmr%NZU_TEnT) zi{i*d%T!Sjx`Gi_%88Ve_;aUEi?K;A7W8GDd29B+kI>+%M1v;{@;x%?_jv11Pqa5w z@kdrbW@YZda1u#9D&Jjvfxr32{Ps+AHGgMn+yX*S-Ajp-y~vv(kN)bPq@F6T#oKv3 z(tY4#gE@#|0{%Lh*dv*cPvndcgBo3VphIV6ZLy^+1u83`UMg2KOi!Q5MO*hei%oeAaFk3!^rnJkz_}a~|pEhOgT!}6306qfr zg1eKoMnCwcrjKNS!qwDiPC%XQ(I{J^NAKDDs1*(uR4TtwF9hx1=j-eqhj!{Rs_w1y z!PC>h=02c01MYsjE9KeJsKb^gS`Mlo?arlEEVoAKyCk1BHR-y2g z_PrfC;w{&p@}4Vt4P)O0Ut=2iC=o+f4To-}>1p{aYqNfz=vp{DcOhD#P2haVc*&q` zb2B#)b%Zb|4SynOQgaNk4`EZG^b6u^bFdJ{g4tl}1HN_VtEon+Sv;o8-Tgu-z1c^@ zB3M!KF(UI8rHi{m~G z5Kf4{r0r{0dTc20)N4vsykyy+W4V+rn|zFlGpHb-F#?84ex3gm15PiJ@2jrnSC&DW zy_n8G>cz2@-~7HA0-ZO5<6pm6HPyzUFf#;QMOPREpx zm2c8!QvUPi@oK($UF*XaqMn24tg4OGuI~&k3URL*_`$!vzndvtmn6BVGt{LbX?w@QXlnUx2}CE<9j4iusWN9cXUnm{b3x9Qh3d?`1qwUs zkIc0kWzXJ9cXZ;~#a^goej1B(UL9J`2q)`VjVsLcj;Y4BEpM<>ChuSG;YhIb;i3E# z&>fJ7;Y@i*lQ>>ETshhiw2-EmB#(!tvBKCkdhV~Q9M(>5hgn$d^LpNEsfw+A_!;{U zJE0etgW#0?6~XzwA3UC=R}Bi&oXt0H0)l#!Cv9iD{$owl9ZUUz*xS>|vl`V&-WkO= zQ&*c!{$HeykYo5;z71O8&}N+Iz8mo^O;JQZ3VMU=D%(o?4nHOnebBruSJ*-D!zT`! zkuPLVik+35+CSE2V10Tr9~X|Dkbkb@<6RYYArQBaKCHHpa_lfsW~qwftvx#=DqMDT zyfx~x5^v1+y-U^OfM)g~o}ds%rnFdNSfX`Ku^KGlc(K5*r_O;0A|7a*(p0m!bFZqO z?>Ox|q*u*>8!~geAXo%vfZJjaGa?flg==BCLgr-%tJ4rNLf2h0VvqgYI8|Ka zJw+JvsBp~w z`RRu`SH7A{=hw4xss7GC2&_M7xAI%z|DdVAF=QVPeb-6y+3ty1RL!qgsp>^4@1tKD zZUI;kv>ztJg7D@>kztBWQc19N1n_Wj1&fi710Nu&9#cp6r#-+NUY1{~w~-gXYVx1L z%UIX5V~D2rdUMWir|xongu!NChbUh&cx0>=#>P{NT&xoub3h!QcDoqzebF<{mB=1Q^dG zQ@*#zDPBLjTv8>y{JuZR=U|I=F?i{@gQNj0v}{Ih<1)PmE&eghsT1}V zleu|nD#KYT9si>@Wjgz_*WkeN_V1my<+tSgKZ?deohr7;`Cs@eUOvb|Xgal}(k_;a zo#xQeFK@BCeaQAol;)%Gd7QG#%xz(6t7QK5>kK89^Ymr%M;=QXAs4!zlmkM7gPb}q zTS`0eSYWLWj;4lO`G**8J#LwAb5lDPkgpd{V8i=8h76+o?XwK9)wRl+^NkghHHkhY zXnay1HQ5J|&&7XsDVzx-OjDG!qT8)6Jg-&#t(wom+}IY=Ad$OFrBuv1v;&qUFdu8} zw&r**`96Fzr~xZD=2bXH#A1W1Tl+G8;YNJ35#gICDVo%Adh6-f4szv>vAsuD4V*_~ zFLB(hcJuJRj;imP5X|=W55_5LJB3BBs?Q8&s-+@YW>EvU zhXp%+>n`}e2%^L{5HFlro!UtgHk}=%dN(M=9`BU6*<4MzOhp=tF6OA0C+;YeClF2i zlC?wp3Pq=jh@tO;G&!P(>b?-gOZF+&GzmoWtm9k|(e>e%)`s1DQco`|_cF>7RV?j` z)z;Gs{)yxn7TAUFN9`zUj2nIGimE^ODF;DyMcE!YvV1b6p4Pwj9M9@2LUG{tE_QgG z`6|Jb*3P2@FZB3Koevx>%HPGi9500Fc02N9G zpR#dVDph^mlHGk~XKRM_38coW8D)fsArbL7ix#6OoNOFD*WY{ldVEX^1#_H9-Mu6s z+qDHhVq8ajJj&1SRs?G^Wo}Z>_SwKMRmzL}C*3<7V5VbD@1_=h!qV0{Fr8vB)EXa0k_95d914@B5O#99>+K-ncjYwRraD-TsXqMUj@r1 zGL!9<>f0ZLyK!`7lXey*wSvL=Rt4=Y)=DPKX>0wvJvM%U?ZMQR+pe)-)dacc2# zZ4*ZaEp6lTVH+HBf*Nk!v%{*yz+ zW(AlYsTXhE%((h39BRZ7Sxbhb&k{r9Ul*?NlUNC{Fcf;qX<1(tutS`*9de)32w8EvOMlSD>P_J_D}I^Wvc_!l7YZ;!OvOY&93!w}HIU*w{@J z&cs(#t9c0A_?J|LqQ~=R6@vncGbt3R>Ixm_nZHc(^avB?kd+9`{s*y%ooQVo73lh6QEl}(H0!#iJ(r%))rQPgmW8w~SVrCM) zpNRMSy6HIWAE2wICGNAAtE$yc*!vHaR;OM9pVqm{qHQPmZ2i`eN%Ra$Y^`{X+skmB zGz&>CkAFonTA`uU3y*!A*snSgJ2!SAvA0e4_^Co$(XmyKgoVX=HARaiVS`xOl<&9n zk}0>v_7{k7SSKXjmz^sLw^~~EeWO%BPmV&{{KH)IpIj5vCAQSae%V)ad_lC8C9jT- z)O&8PLw2&Nu9tBe1th~|zKnVbdA^(Ezjd8_)0^Za+AdlQYq%oZh(UisQFHSQfqLM* zaaN>M2wIQUJRdP{j<0*|ms%v^oL+qYMZdn@_$UL(ct4JcdI}Imu=xv4RlChss88Cy zRi~)gf~Qr=$V2cbWZ1r_*!E;#*7L1J$OY^*bKrlM3)rHB#d@LS5jnn2+qo_?W65>C zR>nTFb4|NxJ;gCzP3G>u617R@T3hODi?LX=(o=x?AQ8@{7u`8H`VXvqGJMgn@`UAp19v7 z2Mwy;?SDUcG&vD}oe@AGn-$oH zdyhVw-?%O<Tj#x6T_al{YawGJxMyl@7++dlq=!4VQ{2$jG81pdz@N2r*4VmS&nKV z?bfdXUdKBtlFTQuN1WM^sM3r}Irz5mQLgk@2oll@=l2?P1?s&CpNJEs9eEavltb3@ z0Xu-JZ_C#$&zO^R9m^IWAZwX$g<7)A3r(hmWHC!Qqlu&IboUs-#<*#Erojq-5huk<%Z-w<$I+<3Toi6(5y zB)yauj+*u;HLeR0P^nOg&YJA}QOHxc(E8)rc#pp#cx+!3d`vlQ!X%m6^$z~rvPQxc zneH1r#iKJ4Tva#=Wi|7+TlB+Yh89|vD@CXkB_wU zzs>A&E^_WH!IGr4y>QJH?XPc~bI~`+CXgHpONVz?1@&oUbd0#iX7`v!IvLfrD`XZQ|EdfKN;3}bb+gzxWT-&cGF=J@*OPzcTrhHw) zg5%F%KJDX=feSSQ#Jt>7p~<;>nh#eEHgd-f($=xoKE5*7n?5EGfVFw+&wrk1%nE#s z2&;miY<=wF;75|1JaLhVIXFX9q2Vwxb{r#{m7qe~RyPnG(^=fsHN~Q|v?{1XoJ@j%CwR+gD~#A_ zCcPh3ko)DVpdmu=ynzyWFb!pH()0j}@zC_j+~(ZY`<4rzBjpW*0l8#beq-PpR5$2X z13!d{IJ~h(b|zKjuX`_Pa2sNS3{cFI<7en{o={F#}PY}1GI!){?OQ&@J> zu0?u-hmzjbrxj_;)NbS~iIn@R&t=?Ec_d9-Z?dp#-ZLXEsifn?1Ds+uLBaA;wscj& zYu*VR4R-RNl+EnCUoRMCr(ZoE+s$!LcQ>flcY&eo-0e z0a|b%djAAPPaIv?`;D+RbxvD10V4NBvHeud56 zs%>x8IP?=SwdIR)GaPP$WP*$2=S@l?9BBzJCXYi3zT}!oEW`y;3*fBFc&M)%Sm`*) zQXif7!Z10yzD?qF-mgHV<9NuhJ%42UDrXX6v*Ck)$pz~i_$$pTxGO{Mz4lk%lgno5 zNBIsiDHfTmiO521EbKn4FPAEuN}9QuJ|xtsJapxI*B-o3FXCE&KFBw%U20;LP}H1E zN>|eVR;l5-khpS7hMZhx)Dj;Q+dsv3vsd-a2fJ|{zvuPpy55^cqm#;yry-=@YPf(u zLi#$ITMf*L-tfErMuUgOc$ohxsSgG6{2|`{L%{uq`xAoV{&cPEfq;9&A14#Qow0+! z00a0B;*XhulNrneuzdd^{=lpZ91!FWzykk4{Bf`Y93TgT75v|cKL9WTh(Ff5ydNtQ z0}Ce$K(uiJG#fK38x-&Nm!H5tAfdn9=%MEQFTCIXlm-3+GW{>B@HhGN|DW{xFNgOQ zp!Wc1ijxUgdI;_Jhqe3j8-)4$1MdCN|JHy`0JIPE4?GWWD$w}H+kj~R;7-KM%D@a_ z10X>*HZ}%MP8OiipWpt*`GCRxsQ&~H|3v(wXP~sc#-BIX|2r+96Ue`R>F6yWJ_8E7 z|IHpkDz^jI^pDp7ium^k|ImR&_%9R*;Ea$7{`Vz7FaFIr0$d}&GXCiesB!*J3=$|~ z{%@VXg;Xd1GZEk=7*Z=7veti{fPX_A|8lSRzaWlaz`gMgvi3Hj*UFa(UF7V&U+flH z%X+*xBK7ltwhXlBQ`jaFg-ksZ9 zWkO}A^=7r%DW`9|M*+<2 z7VYeN+~}2UqdvytG|G+!9^=RAs#_Io){ba>o#mEa=RDZ^HDZrGpC_OcKTr!JLkJe5}Ha zx1=3jNpcJ$5r1p=P+jH0#j8V4j@n3y4D_U~#e0AP7vV+KJC6Yj;aK&E;}V`gWASi;=Zz#I?* zg*zG(3mD>@bWdXe4(>e-;Fh7r1u;QvFz&TwgOtL*r-30h2KO`wf_`6P2XfCn4a5#H zo4MN-!~$_Ax~DNi%tG#IK<0tc0BZie2HaJl<^ke_n7iETj|0Nf-qpY?kS%pj`^!_{ zuExp)F(|pKf!Uzf9t?(FdoTy|`Tm?1VU_cT_Bt;#(O z46(?$tFeKguZ5KpI*t{nApq4Da+kZ`9}~pH;qDmh|FTwWtk7|6V2E+Y-L`E1vR1$u zgqkm~)u8qeJERuEz5dw$Wv$qO^Z*qHh8PFky%r88h!M(Njhz!}pE85lA%+ll<2cwM zrbYKO;7$ysF+&V$?!~b}tYPkHV2Cf+U5yi{VsKyM;Dn9?o-m=?vOr%S=U<)`cg6t9 zAwcgxPBw_Q#ho~y^aON1-~^s{q2kyf7A|+%0(Kfu`wuvQ{}RUxm9GHf4yfw`JU$@i zM|a0y20`sVz-kOSj)?5-FX1F1!#>8DsKQEHlXJVg1)~21q7hiiUs&IbbnkB^nPXr zfq)0}d)Eit7oheAh!bLvb$1>hcBpd++>%-E=L6v6L+w*$F!R5}F+t48?v4Q*H>h!e zbP6>FP^klIAF#4OuPH0ASogqYX;@e} zq2|lN27+2=7B=7`%)S1ACku#u+?_GNz`4A?24LVE{iCgeoi0#w#_lmtjY84X4JcRv wJex5>{B3~J6+nuG_`Cj9?geP_mwSx8gRY&!U(a#C{|z(KV{&p4Inl@e57%{9zW@LL literal 0 HcmV?d00001 diff --git a/CHANGELOG.md b/CHANGELOG.md index ce42ae1ce9..f077600ac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,195 @@ Mark items as `Added`, `Changed`, `Fixed`, `Modified`, `Removed`, `Untested Features`, `Upcoming Features`, `Known Issues` + + +## 7.0.0 - 2025-01-10 + +`Added` + +**forms-flow-web** +* Added redesigned form and workflow UI for designer + * Layout and Flow listing + * Layout and Flow Create/ Edit page: + * Import, export, duplicate, delete, history, preview, authorization settings +* Added flow builder to design page +* Added new user settings option in sidebar +* Added new css variables to support dynamic theming of application using customTheme file +* Added advanced logic conditioning in formio component settings to allow chaining of conditions for forms +* Added the displayForRole custom property to the form component to display data for a specific role +* Added certain user data as hidden variables in the form design by default: + * Current User + * Submitter Email + * Submitter First Name + * Submitter Last Name + * Current User Roles + +**forms-flow-api** + * Added new endpoints for: + * Form validation: `/form/validate` + * Layout + Flow import: `/import` + * Layout + Flow export: `/form//export` + * Flow migration - `/process/migrate` + * Layout + Flow publish: `/form//publish` + * Layout + Flow unpublish: `/form//unpublish` + * List permissions: `/roles/permissions` + * Theme customization: + * Create, Get, Update theme: `/themes` + * Subflow and decision table redesign + * Create/List: `/process` + * Get/Update/Delete by id: `/process/` + * Get by key: `/process/` + * Get history: `/process//versions` + * Validate: `/process/validate` + * Publish: `/process//publish` + * Unpublish: `/process//unpublish` + + + * Added Alembic scripts to implement the following changes: + * Created tables for theme customization(Themes), Process, and User + * Updated the filter table to include filter_order, the form history table to add major_version and minor_version, and the form_process_mapper table to include prompt_new_version & is_migrated + * Populated major_version and minor_version columns in existing form history records + * Altered audit datetime fields to be timezone-aware + * Updated the process_name format from process_name(process_key) to process_name + * Increased the length of form_name, process_key, and process_name fields in the form_process_mapper table to 200 + + + + **forms-flow-bpm** +* Added support to fetch secrets from Vault +* Added BPM authorizations dynamically upon startup +

+ +`Modified` + +**formsflow-web** +* Modified Flow and Layout to a one-to-one association, with the combination now referred to as a Form +* Modified Navbar and converted to Sidebar: + * Categorized UI to menus and sub-menus based on functionality + * Menus visibility is controlled based on user permissions + * Moved language selection to the user settings modal, accessible by clicking the username in the bottom-left corner of the sidebar + * Moved client submission from the Forms menu to the Submit menu (Submit → Forms → All Forms) + * Moved form design to Design menu + * Moved Subflows (BPMN) and Decision Tables (DMN) to individual submenus under the Design + * Moved Manage roles, users and dashboards under Manage menu + * Moved Insights and Metrics under Analyze menu + * Moved Tasks under Review menu + +* Modified form history management to include major and minor versions +* Modified RBAC mechanism: + * Users can create new roles with specific permissions for more granular application access control. Refer [here](https://aot-technologies.github.io/forms-flow-ai-doc/#permissions) for more +* Authorization updates: + * Permissions options in settings for Designers are changed : + * 'All Designers' option is removed + * Permissions options in settings for reviewer to view submission are changed to generic view Submission permissions: + * 'All Reviewers' changed to 'Submitter', + * 'Specific Reviewers' changed to 'Submitter and specified roles' + + +**forms-flow-api** +* Modified authorization endpoints for: + * updated permissions + * sub flow and decision table redesign + * Form create, mapper create and authorization create apis combined into form-design + +**forms-flow-bpm** +* Existing users, refer [here](./forms-flow-bpm/migration#migration-tasks-for-bpm) for forms-flow-bpm migration changes + +**forms-flow-documents** +* Modified endpoint authorizations based on updated permission mechanism + +**forms-flow-idm** +* Refer [here](./forms-flow-idm/README.md) for Permission matrix migration changes in IDM +

+ + +`Removed` + +**formsflow-web** +* Removed workflow selection from form edit page + +**forms-flow-api** +* Removed form_mapper create API +

+ +*Upgrade notes:* + +**forms-flow-web** +* Npm package version upgraded to 16.20.0 + +**forms-flow-api** + + * Python version upgraded to 3.12.6 + +**forms-flow-bpm** + + * SpringBoot version upgraded to 3.3.5 + * Camunda version upgarded to 7.21 + * spring-websocket version upgarded to 6.1 + +**forms-flow-idm** + +* Keycloak Version upgraded to 25.0.0 + + +**forms-flow-documents** + + * Python version upgraded to 3.12.6 + +**forms-flow-data-analysis-api** + + * Python version upgraded to 3.12.6 +

+ + +`Generic Changes` +* Designer page redesign +* Workflow selection from edit page is not available now, instead users have to create workflow while form creation itself +* Added new micro-frontend : forms-flow-components +* Fixed security vulnerabilities +* Refer [version documentation](https://aot-technologies.github.io/forms-flow-ai-doc/#version_upgrade) for environment variable changes + +`Known Issues` +* The language translation of the entire UI is not perfect at the moment, so some glitches may be expected. +* forms-flow-web test cases are not fully covered + +#### Premium Features + +`Added` + +**forms-flow-web** +* Added no-code creation +* Added regenerate option for form creation with Flow-E + +**forms-flow-api** +* Added process_type column to templates + +**forms-flow-documents** +* Added export pdf for bundle + +**forms-flow-data-analysis-api** +* Added regenerate support in chat bot form creation + +`Modified` + +**forms-flow-web** +* Modified premium icons +* Moved Bundle as separate sub-menu under Design menu in sidebar +* Moved Build using AI feature (Flow-E) to form creation page from edit page. +* Moved select from template feature to form listing page from edit page. +* Modified design for save as template +* Modified the bundle submission logic to retrieve submission data for the currently viewed form instead of fetching all submission data. + +**forms-flow-api** +* Updated the `bundles/execute-rules` API to expect only the currently edited data instead of the entire data. The API fetch the necessary data from Form.io to execute rule. + +`Removed` + +**forms-flow-web** +* Forms that should be included in the bundle no longer require selecting 'enable bundling' from the edit page. +* Removed short intro from template creation modal +

+ ## 6.0.2 - 2024-06-05 `Added` @@ -109,6 +298,33 @@ Mark items as `Added`, `Changed`, `Fixed`, `Modified`, `Removed`, `Untested Fea * Fixed security vulnerabilities +#### Premium Features + +`Added` + +**froms-flow-web** +* Added IPASS integration +* Added task variables from forms of a bundle to filter creation + +**forms-flow-data-analysis-api** +* Added new env variable `CHAT_BOT_CONTEXT_KEY` to define the context for chat bot + +**forms-flow-bpm** +* Added `iPaasListener` to support IPASS integration + +**forms-flow-api** +* Added new endpoints to support IPASS + +`Modified` + +**forms-flow-data-analysis-api** +* CHAT_URL port number updated + +`Fixed` + +**froms-flow-web** +* Fixed task details view of bundle in list view + ## 5.3.1 - 2024-02-14 @@ -134,6 +350,19 @@ Mark items as `Added`, `Changed`, `Fixed`, `Modified`, `Removed`, `Untested Fea * Changes have been made to the Roles and Groups endpoint to accommodate modifications related to subgroups in Keycloak 23. +#### Premium Features + +`Fixed` + +* Fixed category listing for pre-built templates for multi-tenant environment. + +`Added` + +**forms-flow-bpm** + +* Added new field injection `emailAddress` in Notify Listener to allow email addresses in addition to group names. + + ## 5.3.0 - 2023-11-24 `Added` @@ -210,6 +439,32 @@ Mark items as `Added`, `Changed`, `Fixed`, `Modified`, `Removed`, `Untested Fea * Flask upgraded to 2.3.3 and fixed security vulnerabilities +#### Premium Features + +`Added` + +**forms-flow-web** + +* Added RBAC(Role Based Access Control) support in form bundling, refer [here](https://aot-technologies.github.io/forms-flow-ai-doc/#rbac). +* Added Templates feature, refer [here](https://aot-technologies.github.io/forms-flow-ai-doc/#templates) for more. +* Added AI chat assist support in form creation, refer [here](https://aot-technologies.github.io/forms-flow-ai-doc/#chatbot) for more. +* Added environment variables `ENABLE_CHATBOT`, `CHATBOT_URL` for AI chat assist support. + +**forms-flow-data-analysis-api** + +* Added environment variables `OPENAI_API_KEY`, `CHAT_BOT_MODEL_ID` for AI chat assist support. + + +**forms-flow-api** + +* Added RBAC(Role Based Access Control) support in form bundling. + +`Fixed` + +**forms-flow-api** + +* Fixed task variable updation issue on resubmit in form bundling. + ## 5.2.1 - 2023-09-01 @@ -295,6 +550,30 @@ Mark items as `Added`, `Changed`, `Fixed`, `Modified`, `Removed`, `Untested Fea Refer the [forms-flow-ai-micro-front-ends](https://github.com/AOT-Technologies/forms-flow-ai-micro-front-end) repository for further details. * Dashboard authorization is moved from designer role to admin user. +#### Premium Features + +`Added` + +**forms-flow-web** + +* Added `form bundling` feature as a premium feature, refer [here](https://aot-technologies.github.io/forms-flow-ai-doc/#formBundling) for more details. + +**forms-flow-bpm** + +* Added CombineSubmissionBundleListener to support form bundling feature. +* Added RequestStateListener to support Request status. +* Added skip-sanitize flag to request header for calls from BPM to Form.io. + +`Modified` + +**forms-flow-web** + +* To enable tracking of individual requests within the bundle, the application history has been updated to Application status and Request status. + +`Generic changes` + +* During the process of form bundling, it is necessary to configure task variables while designing each individual form. + ## 5.1.1 - 2023-05-18 diff --git a/VERSION b/VERSION index bd7f7cb3ac..d18077c214 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v6.0.2 \ No newline at end of file +v7.0.0 diff --git a/deployment/docker/docker-compose.yml b/deployment/docker/docker-compose.yml index 2448298597..0c3d0faebc 100644 --- a/deployment/docker/docker-compose.yml +++ b/deployment/docker/docker-compose.yml @@ -23,7 +23,7 @@ services: forms-flow-forms: container_name: forms-flow-forms - image: formsflow/forms-flow-forms:v6.0.0 + image: formsflow/forms-flow-forms:v7.0.0 # The app will restart until Mongo is listening restart: always @@ -120,6 +120,11 @@ services: - REDIS_PORT=${REDIS_PORT:-6379} - REDIS_PASSCODE=${REDIS_PASSCODE:-changeme} - SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-false} + - VAULT_ENABLED=${VAULT_ENABLED:-false} + - VAULT_URL=${VAULT_URL} + - VAULT_TOKEN=${VAULT_TOKEN} + - VAULT_PATH=${VAULT_PATH} + - VAULT_SECRET=${VAULT_SECRET} networks: - forms-flow-network @@ -132,11 +137,11 @@ services: context: ./../../forms-flow-web-root-config/ dockerfile: Dockerfile args: - - MF_FORMSFLOW_WEB_URL=${MF_FORMSFLOW_WEB_URL:-https://forms-flow-microfrontends.aot-technologies.com/forms-flow-web@v6.0.2/forms-flow-web.gz.js} - - MF_FORMSFLOW_NAV_URL=${MF_FORMSFLOW_NAV_URL:-https://forms-flow-microfrontends.aot-technologies.com/forms-flow-nav@v6.0.2/forms-flow-nav.gz.js} - - MF_FORMSFLOW_SERVICE_URL=${MF_FORMSFLOW_SERVICE_URL:-https://forms-flow-microfrontends.aot-technologies.com/forms-flow-service@v6.0.2/forms-flow-service.gz.js} - - MF_FORMSFLOW_ADMIN_URL=${MF_FORMSFLOW_ADMIN_URL:-https://forms-flow-microfrontends.aot-technologies.com/forms-flow-admin@v6.0.2/forms-flow-admin.gz.js} - - MF_FORMSFLOW_THEME_URL=${MF_FORMSFLOW_THEME_URL:-https://forms-flow-microfrontends.aot-technologies.com/forms-flow-theme@v6.0.2/forms-flow-theme.gz.js} + - MF_FORMSFLOW_WEB_URL=${MF_FORMSFLOW_WEB_URL:-https://forms-flow-microfrontends.aot-technologies.com/forms-flow-web@v7.0.0/forms-flow-web.gz.js} + - MF_FORMSFLOW_NAV_URL=${MF_FORMSFLOW_NAV_URL:-https://forms-flow-microfrontends.aot-technologies.com/forms-flow-nav@v7.0.0/forms-flow-nav.gz.js} + - MF_FORMSFLOW_SERVICE_URL=${MF_FORMSFLOW_SERVICE_URL:-https://forms-flow-microfrontends.aot-technologies.com/forms-flow-service@v7.0.0/forms-flow-service.gz.js} + - MF_FORMSFLOW_COMPONENTS_URL=${MF_FORMSFLOW_COMPONENTS_URL:-https://forms-flow-microfrontends.aot-technologies.com/forms-flow-components@v7.0.0/forms-flow-components.gz.js} + - MF_FORMSFLOW_ADMIN_URL=${MF_FORMSFLOW_ADMIN_URL:-https://forms-flow-microfrontends.aot-technologies.com/forms-flow-admin@v7.0.0/forms-flow-admin.gz.js} - NODE_ENV=${NODE_ENV:-production} entrypoint: /bin/sh -c "/usr/share/nginx/html/config/env.sh && nginx -g 'daemon off;'" environment: @@ -222,6 +227,11 @@ services: INSIGHT_API_KEY: ${INSIGHT_API_KEY} INSIGHT_API_URL: ${INSIGHT_API_URL} DATABASE_URL: ${FORMSFLOW_API_DB_URL:-postgresql://postgres:changeme@forms-flow-webapi-db:5432/webapi} + DATABASE_USERNAME: ${FORMSFLOW_API_DB_USER} + DATABASE_PASSWORD: ${FORMSFLOW_API_DB_PASSWORD} + DATABASE_HOST: ${FORMSFLOW_API_DB_HOST} + DATABASE_PORT: ${FORMSFLOW_API_DB_PORT} + DATABASE_NAME: ${FORMSFLOW_API_DB_NAME} BPM_TOKEN_API: ${KEYCLOAK_URL}${KEYCLOAK_URL_HTTP_RELATIVE_PATH:-/auth}/realms/${KEYCLOAK_URL_REALM:-forms-flow-ai}/protocol/openid-connect/token BPM_CLIENT_ID: ${KEYCLOAK_BPM_CLIENT_ID:-forms-flow-bpm} BPM_CLIENT_SECRET: ${KEYCLOAK_BPM_CLIENT_SECRET:-e4bdbd25-1467-4f7f-b993-bc4b1944c943} @@ -249,6 +259,7 @@ services: API_LOG_BACKUP_COUNT: ${API_LOG_BACKUP_COUNT:-7} CONFIGURE_LOGS: ${CONFIGURE_LOGS:-true} REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + FORMSFLOW_ADMIN_URL: ${FORMSFLOW_ADMIN_URL} stdin_open: true # -i tty: true # -t diff --git a/deployment/docker/sample.env b/deployment/docker/sample.env index 1a72481c89..097b282acd 100644 --- a/deployment/docker/sample.env +++ b/deployment/docker/sample.env @@ -63,7 +63,17 @@ INSIGHT_API_KEY={API Key from Redash} #----Environment variables for adaptive tier (Python Webapi) Datastore----# ##JDBC DB Connection URL for formsflow.ai + +## DATABASE URL configuration #FORMSFLOW_API_DB_URL=postgresql://postgres:changeme@forms-flow-webapi-db:5432/webapi +# You can pass the full database URL or split it into the following variables: +FORMSFLOW_API_DB_USER="" +FORMSFLOW_API_DB_PASSWORD="" +FORMSFLOW_API_DB_HOST="" +FORMSFLOW_API_DB_PORT="" +FORMSFLOW_API_DB_NAME="" + + ##formsflow.ai database postgres user #FORMSFLOW_API_DB_USER=postgres ##formsflow.ai database postgres password @@ -186,13 +196,13 @@ CUSTOM_SUBMISSION_URL=http://{your-ip-address}:{port} #The MF Variables below are used to get MicroFrontend Components Created ##For running locally or if have custom changes then change the url to the one forms-flow-web folder content is running -#MF_FORMSFLOW_WEB_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-web@v6.0.2/forms-flow-web.gz.js +#MF_FORMSFLOW_WEB_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-web@v7.0.0/forms-flow-web.gz.js ## Refer Github Repo https://github.com/AOT-Technologies/forms-flow-ai-micro-front-ends and update to your own custom implementation for the Components here -#MF_FORMSFLOW_NAV_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-nav@v6.0.2/forms-flow-nav.gz.js -#MF_FORMSFLOW_SERVICE_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-service@v6.0.2/forms-flow-service.gz.js -#MF_FORMSFLOW_ADMIN_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-admin@v6.0.2/forms-flow-admin.gz.js -#MF_FORMSFLOW_THEME_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-theme@v6.0.2/forms-flow-theme.gz.js +#MF_FORMSFLOW_NAV_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-nav@v7.0.0/forms-flow-nav.gz.js +#MF_FORMSFLOW_SERVICE_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-service@v7.0.0/forms-flow-service.gz.js +#MF_FORMSFLOW_COMPONENTS_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-components@v7.0.0/forms-flow-components.gz.js +#MF_FORMSFLOW_ADMIN_URL=https://forms-flow-microfrontends.aot-technologies.com/forms-flow-admin@v7.0.0/forms-flow-admin.gz.js #++++++++++++++++--- formsflow.ai Web Microfrontend components ENV Variables - STOP ---+++++++++++++++++++++++++# @@ -218,11 +228,26 @@ CUSTOM_SUBMISSION_URL=http://{your-ip-address}:{port} #public/themeConfig/customTheme.json inside forms-flow-web-root-config. #the json data should be below format. -# `{ -# "--navbar-background": "blue", -# "--navbar-items": "grey", -# "--navbar-active": "white" -# }` +# { + # "--navbar-active-submenu-bg-color": "#fbe9d0", + # "--navbar-active-submenu-font-color": "#d79922", + # "--navbar-menu-hover-bg-color": "#192d42", + # "--navbar-main-menu-active-bg-color": "#446c7c", + # "--navbar-main-menu-active-font-color": "#FFFFFF", + # "--navbar-bg-color": "#83b2b7", + # "--custom-logo-path": "https://logos-world.net/wp-content/uploads/2020/06/Amazon-Logo.png", + # "--custom-title": "Amazon.in", + # "--primary-btn-font-color": "black", + # "--primary-btn-bg-color": "yellow", + # "--primary-btn-hover-bg-color": "#FFFFC5", + # "--secondary-btn-font-color": "yellow", + # "--secondary-btn-bg-color": "black", + # "--secondary-btn-hover-bg-color": "#353535", + # "--default-font-color": "red", + # "--default-font-size": "1rem", + # "--ff-primary": "violet", + # "--ff-secondary": "green" +# } #CUSTOM_THEME_URL=/themeConfig/customTheme.json @@ -252,3 +277,10 @@ CUSTOM_SUBMISSION_URL=http://{your-ip-address}:{port} #API_LOG_ROTATION_INTERVAL=1 #API_LOG_BACKUP_COUNT=7 #CONFIGURE_LOGS=true + +# Vault configuration +# VAULT_ENABLED=false +# VAULT_URL=http://{your-ip-address}:8200 +# VAULT_TOKEN= +# VAULT_PATH= +# VAULT_SECRET= \ No newline at end of file diff --git a/deployment/openshift/sample.env b/deployment/openshift/sample.env index a49351f934..68abebde88 100644 --- a/deployment/openshift/sample.env +++ b/deployment/openshift/sample.env @@ -85,13 +85,15 @@ INSIGHT_API_KEY={API Key from Redash} #----Environment variables for adaptive tier (Python Webapi) Datastore----# ##JDBC DB Connection URL for formsflow.ai + +## DATABASE URL configuration #FORMSFLOW_API_DB_URL=postgresql://postgres:changeme@forms-flow-webapi-db:5432/webapi -##formsflow.ai database postgres user -#FORMSFLOW_API_DB_USER=postgres -##formsflow.ai database postgres password -#FORMSFLOW_API_DB_PASSWORD=changeme -##formsflow.ai database name -#FORMSFLOW_API_DB_NAME=webapi +# You can pass the full database URL or split it into the following variables: +FORMSFLOW_API_DB_USER=postgres +FORMSFLOW_API_DB_PASSWORD=changeme +FORMSFLOW_API_DB_HOST=localhost +FORMSFLOW_API_DB_PORT=5432 +FORMSFLOW_API_DB_NAME=webapi #----Integration variable settings----# ##Define project level configuration, possible values development,test,production diff --git a/forms-flow-analytics/docker-compose.yml b/forms-flow-analytics/docker-compose.yml index e6de8e2ebc..a6fc340821 100644 --- a/forms-flow-analytics/docker-compose.yml +++ b/forms-flow-analytics/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.7" x-redash-service: &redash-service - image: formsflow/redash:10.1.5 + image: formsflow/redash:24.04.0 depends_on: - postgres - redis diff --git a/forms-flow-api-utils/setup.py b/forms-flow-api-utils/setup.py index 7d18c5d34b..e38fba6c36 100644 --- a/forms-flow-api-utils/setup.py +++ b/forms-flow-api-utils/setup.py @@ -27,7 +27,7 @@ def read_requirements(filename): setuptools.setup( name='formsflow_api_utils', - version='6.0.2', + version='7.0.0', author='AOT Technologies', description='Formsflow api related libraries.', long_description=read("README.md"), diff --git a/forms-flow-api-utils/src/formsflow_api_utils/exceptions/__init__.py b/forms-flow-api-utils/src/formsflow_api_utils/exceptions/__init__.py index 4e29ceae8c..12238c7e60 100644 --- a/forms-flow-api-utils/src/formsflow_api_utils/exceptions/__init__.py +++ b/forms-flow-api-utils/src/formsflow_api_utils/exceptions/__init__.py @@ -55,9 +55,12 @@ def status_code(self): class BusinessException(Exception): """Exception that adds error code and error.""" - def __init__(self, error_code: ErrorCodeMixin, details=None, detail_message=None): + def __init__(self, error_code: ErrorCodeMixin, details=None, detail_message=None, include_details=False): super().__init__(error_code.message) - self.message = error_code.message + + # Include the detailed message in the main message if include_details is True + self.message = detail_message if include_details and detail_message else error_code.message + self.code = error_code.code self.status_code = error_code.status_code if detail_message: diff --git a/forms-flow-api-utils/src/formsflow_api_utils/services/__init__.py b/forms-flow-api-utils/src/formsflow_api_utils/services/__init__.py index 2802cf5096..178c287cb5 100644 --- a/forms-flow-api-utils/src/formsflow_api_utils/services/__init__.py +++ b/forms-flow-api-utils/src/formsflow_api_utils/services/__init__.py @@ -1,7 +1,8 @@ """This exports all of the services used by the application.""" from formsflow_api_utils.services.external.formio import FormioService - +from formsflow_api_utils.services.external.custom_submission import CustomSubmissionService __all__ = [ "FormioService", + "CustomSubmissionService" ] diff --git a/forms-flow-api-utils/src/formsflow_api_utils/services/external/__init__.py b/forms-flow-api-utils/src/formsflow_api_utils/services/external/__init__.py index 0a568cdf99..b0725ca811 100644 --- a/forms-flow-api-utils/src/formsflow_api_utils/services/external/__init__.py +++ b/forms-flow-api-utils/src/formsflow_api_utils/services/external/__init__.py @@ -1,3 +1,4 @@ """This exports all of the external communication services used by the application.""" from .formio import FormioService +from .custom_submission import CustomSubmissionService diff --git a/forms-flow-api-utils/src/formsflow_api_utils/services/external/custom_submission.py b/forms-flow-api-utils/src/formsflow_api_utils/services/external/custom_submission.py new file mode 100644 index 0000000000..41f7cb0d12 --- /dev/null +++ b/forms-flow-api-utils/src/formsflow_api_utils/services/external/custom_submission.py @@ -0,0 +1,33 @@ +import requests +from flask import current_app + +from formsflow_api_utils.exceptions import BusinessException, ExternalError +from formsflow_api_utils.utils import HTTP_TIMEOUT +from typing import Any + +class CustomSubmissionService: + + def __init__(self) -> None: + self.base_url = current_app.config.get("CUSTOM_SUBMISSION_URL") + + def __get_headers(self, token: str) -> dict: + """Returns the headers for the request.""" + return {"Authorization": token, "Content-Type": "application/json"} + + def fetch_submission_data(self, token: str, form_id: str, submission_id: str) -> Any: + """Returns the submission data from the form adapter.""" + if not self.base_url: + raise BusinessException("Base URL for custom submission is not configured.") + + submission_url = f"{self.base_url}/form/{form_id}/submission/{submission_id}" + current_app.logger.debug(f"Fetching custom submission data: {submission_url}") + headers = self.__get_headers(token) + + try: + response = requests.get(submission_url, headers=headers, timeout=HTTP_TIMEOUT) + current_app.logger.debug(f"Custom submission response code: {response.status_code}") + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + current_app.logger.error(f"Error fetching custom submission data: {str(e)}") + raise ExternalError("Failed to fetch custom submission data.") from e diff --git a/forms-flow-api-utils/src/formsflow_api_utils/services/external/formio.py b/forms-flow-api-utils/src/formsflow_api_utils/services/external/formio.py index fc1691e844..0979863d6c 100644 --- a/forms-flow-api-utils/src/formsflow_api_utils/services/external/formio.py +++ b/forms-flow-api-utils/src/formsflow_api_utils/services/external/formio.py @@ -105,9 +105,13 @@ def get_user_resource_ids(self): current_app.logger.info("Fetching user resource ids...") return self._invoke_service(url, headers={}, method='GET') - def get_form(self, data, formio_token): + def get_form(self, query_params, formio_token): """Get request to formio API to get form details.""" - return self.get_form_by_id(data["form_id"], formio_token) + headers = {"Content-Type": "application/json", "x-jwt-token": formio_token} + url = f"{self.base_url}/form" + if query_params: + url = f"{url}?{query_params}" + return self._invoke_service(url, headers, method='GET') def get_form_by_id(self, form_id: str, formio_token): """Get request to formio API to get form details.""" @@ -154,3 +158,11 @@ def get_form_by_path(self, path_name: str, formio_token: str) -> dict: headers = {"Content-Type": "application/json", "x-jwt-token": formio_token} url = f"{self.base_url}/{path_name}" return self._invoke_service(url, headers, method='GET') + + def get_form_search(self, query_params, formio_token): + """Get request to formio API to get form details by search.""" + headers = {"Content-Type": "application/json", "x-jwt-token": formio_token} + url = f"{self.base_url}/forms/search" + if query_params: + url = f"{url}?{query_params}" + return self._invoke_service(url, headers, method='GET') diff --git a/forms-flow-api-utils/src/formsflow_api_utils/utils/__init__.py b/forms-flow-api-utils/src/formsflow_api_utils/utils/__init__.py index 2ec07a6d43..ecc14ee278 100644 --- a/forms-flow-api-utils/src/formsflow_api_utils/utils/__init__.py +++ b/forms-flow-api-utils/src/formsflow_api_utils/utils/__init__.py @@ -19,9 +19,30 @@ KEYCLOAK_DASHBOARD_BASE_GROUP, NEW_APPLICATION_STATUS, REVIEWER_GROUP, - HTTP_TIMEOUT + HTTP_TIMEOUT, ) from .enums import ApplicationSortingParameters +from .permisions import ( + PERMISSION_DETAILS , + CREATE_DESIGNS, + VIEW_DESIGNS, + CREATE_SUBMISSIONS, + VIEW_SUBMISSIONS, + VIEW_DASHBOARDS, + VIEW_TASKS, + MANAGE_TASKS, + MANAGE_ALL_FILTERS, + CREATE_FILTERS, + VIEW_FILTERS, + MANAGE_INTEGRATIONS, + MANAGE_DASHBOARD_AUTHORIZATIONS, + MANAGE_USERS, + MANAGE_ROLES, + ADMIN, + CREATE_BPMN_FLOWS, + MANAGE_DECISION_TABLES, + MANAGE_SUBFLOWS, +) from .file_log_handler import CustomTimedRotatingFileHandler, register_log_handlers from .format import CustomFormatter from .logging import setup_logging, log_bpm_error @@ -33,6 +54,7 @@ get_role_ids_from_user_groups, translate, validate_sort_order_and_order_by, + add_sort_filter, ) from .caching import Cache from .sentry import init_sentry diff --git a/forms-flow-api-utils/src/formsflow_api_utils/utils/auth.py b/forms-flow-api-utils/src/formsflow_api_utils/utils/auth.py index dcd37a7319..cecc57025b 100644 --- a/forms-flow-api-utils/src/formsflow_api_utils/utils/auth.py +++ b/forms-flow-api-utils/src/formsflow_api_utils/utils/auth.py @@ -56,6 +56,11 @@ def wrapper(*args, **kwargs): def has_role(cls, role): """Method to validate the role.""" return jwt.validate_roles(role) + + @classmethod + def has_any_role(cls, role): + """Method to validate the role.""" + return jwt.contains_role(role) @classmethod def require_custom(cls, f): @@ -80,4 +85,4 @@ def decorated(*args, **kwargs): auth = ( Auth() -) # pylint: disable=invalid-name; lower case name as used by convention in most Flask apps +) # pylint: disable=invalid-name; lower case name as used by convention in most Flask apps \ No newline at end of file diff --git a/forms-flow-api-utils/src/formsflow_api_utils/utils/caching.py b/forms-flow-api-utils/src/formsflow_api_utils/utils/caching.py index 898face689..7bbab23887 100644 --- a/forms-flow-api-utils/src/formsflow_api_utils/utils/caching.py +++ b/forms-flow-api-utils/src/formsflow_api_utils/utils/caching.py @@ -4,7 +4,8 @@ from flask import current_app from redis.client import Redis import json - +import os +from redis import RedisCluster as rc class RedisManager: """ @@ -28,11 +29,15 @@ def get_client(cls, app=None) -> Redis: Raises: Exception: If the Redis client has not been initialized and no app context is provided. """ + redis_cluster = os.getenv('REDIS_CLUSTER', 'false').lower() == 'true' if cls._redis_client is None: if app is None: app = current_app redis_url = app.config.get("REDIS_URL") - cls._redis_client = redis.StrictRedis.from_url(redis_url) + if redis_cluster: + cls._redis_client = rc.from_url(redis_url) + else: + cls._redis_client = redis.StrictRedis.from_url(redis_url) app.logger.info("Redis client initiated successfully") return cls._redis_client @@ -75,4 +80,3 @@ def get(cls, key): return json.loads(val_json) # Deserialize the JSON string back into Python object else: return None - diff --git a/forms-flow-api-utils/src/formsflow_api_utils/utils/constants.py b/forms-flow-api-utils/src/formsflow_api_utils/utils/constants.py index 2b6c1f21e0..08b2396b6a 100644 --- a/forms-flow-api-utils/src/formsflow_api_utils/utils/constants.py +++ b/forms-flow-api-utils/src/formsflow_api_utils/utils/constants.py @@ -1,6 +1,5 @@ """All constants for project.""" import os - from dotenv import find_dotenv, load_dotenv # this will load all the envars from a .env file located in the project root (api) @@ -40,8 +39,11 @@ "is_bundle": {"field": "is_bundle", "operator": "eq"}, "title":{"field": "title", "operator": "ilike"}, "category":{"field": "category", "operator": "ilike"}, + "process_name": {"field": "name", "operator": "ilike"}, + "process_status": {"field": "status", "operator": "eq"}, + "process_type": {"field": "process_type", "operator": "eq"}, } DEFAULT_PROCESS_KEY = "Defaultflow" DEFAULT_PROCESS_NAME = "Default Flow" -HTTP_TIMEOUT = 30 +HTTP_TIMEOUT = 30 \ No newline at end of file diff --git a/forms-flow-api-utils/src/formsflow_api_utils/utils/enums.py b/forms-flow-api-utils/src/formsflow_api_utils/utils/enums.py index b3aa9ab2ed..c7d8c06a2b 100644 --- a/forms-flow-api-utils/src/formsflow_api_utils/utils/enums.py +++ b/forms-flow-api-utils/src/formsflow_api_utils/utils/enums.py @@ -23,8 +23,11 @@ class ApplicationSortingParameters: # pylint: disable=too-few-public-methods Created = "created" Name = "applicationName" Status = "applicationStatus" + FormStatus = "status" Modified = "modified" FormName = "formName" + visibility= "visibility" + is_anonymous= "is_anonymous" @unique @@ -68,3 +71,12 @@ class FilterStatus(Enum): ACTIVE = "active" INACTIVE = "inactive" + + +class ProcessSortingParameters: # pylint: disable=too-few-public-methods + """This enum provides the list of Sorting Parameters.""" + + Name = "name" + Created = "created" + Modified= "modified" + ProcessKey = "processKey" diff --git a/forms-flow-api-utils/src/formsflow_api_utils/utils/pdf.py b/forms-flow-api-utils/src/formsflow_api_utils/utils/pdf.py index f25dc42653..4895ee3cfa 100644 --- a/forms-flow-api-utils/src/formsflow_api_utils/utils/pdf.py +++ b/forms-flow-api-utils/src/formsflow_api_utils/utils/pdf.py @@ -11,6 +11,7 @@ from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait from seleniumwire import webdriver +from .user_context import _get_token_info def send_devtools(driver, cmd, params=None): @@ -64,6 +65,29 @@ def interceptor(request): driver.get(path) + # Set user details to local storage + token_info = _get_token_info() + user_details = { + "sub": token_info.get("sub", None), + "email_verified": token_info.get("email_verified", False), + "role": token_info.get("roles", None) or token_info.get( + "role", None + ), + "roles": token_info.get("roles", None) or token_info.get( + "role", None + ), + "name": token_info.get("name", None), + "groups": token_info.get("groups", None), + "preferred_username": token_info.get("preferred_username", None), + "given_name": token_info.get("given_name", None), + "family_name": token_info.get("family_name", None), + "email": token_info.get("email", None), + "tenantKey": token_info.get("tenantKey", None), + } + + user_details_json = json.dumps(user_details) + driver.execute_script(f"window.localStorage.setItem('UserDetails', '{user_details_json}')") + try: if "wait" in args: delay = 30 # seconds diff --git a/forms-flow-api-utils/src/formsflow_api_utils/utils/permisions.py b/forms-flow-api-utils/src/formsflow_api_utils/utils/permisions.py new file mode 100644 index 0000000000..3341433cea --- /dev/null +++ b/forms-flow-api-utils/src/formsflow_api_utils/utils/permisions.py @@ -0,0 +1,53 @@ +"""Permission definitions.""" + +CREATE_DESIGNS = "create_designs" +VIEW_DESIGNS = "view_designs" +CREATE_SUBMISSIONS = "create_submissions" +VIEW_SUBMISSIONS = "view_submissions" +VIEW_DASHBOARDS = "view_dashboards" +VIEW_TASKS = "view_tasks" +MANAGE_TASKS = "manage_tasks" +MANAGE_ALL_FILTERS = "manage_all_filters" +CREATE_FILTERS = "create_filters" +VIEW_FILTERS = "view_filters" +MANAGE_INTEGRATIONS = "manage_integrations" +MANAGE_DASHBOARD_AUTHORIZATIONS = "manage_dashboard_authorizations" +MANAGE_USERS = "manage_users" +MANAGE_ROLES = "manage_roles" +ADMIN= "admin" +CREATE_BPMN_FLOWS = "create_bpmn_flows" +MANAGE_SUBFLOWS = "manage_subflows" +MANAGE_DECISION_TABLES = "manage_decision_tables" + + +PERMISSION_DETAILS = [ + {"name": CREATE_DESIGNS , "description": "Design layout and flow", "depends_on": [ VIEW_DESIGNS ]}, + {"name": VIEW_DESIGNS , "description": "Access to designs", "depends_on": []}, + {"name": CREATE_BPMN_FLOWS , "description": "Access to BPMN workflows", "depends_on": [CREATE_DESIGNS]}, + {"name": MANAGE_SUBFLOWS , "description": "Access to Subflows", "depends_on": [CREATE_DESIGNS]}, + {"name": MANAGE_DECISION_TABLES , "description": "Access to Decision Tables", "depends_on": [CREATE_DESIGNS]}, + {"name": CREATE_SUBMISSIONS , "description": "Create submissions", "depends_on": []}, + {"name": VIEW_SUBMISSIONS , "description": "Access to submissions", "depends_on": []}, + {"name": VIEW_DASHBOARDS , "description": "Access to dashboards", "depends_on": []}, + {"name": VIEW_TASKS , "description": "Access to tasks", "depends_on": [ VIEW_FILTERS ]}, + {"name": MANAGE_TASKS , "description": "Can assign, re-assign and work on tasks", "depends_on": [ VIEW_TASKS , VIEW_FILTERS ]}, + {"name": MANAGE_ALL_FILTERS , "description": "Manage all filters", "depends_on": [ VIEW_FILTERS , CREATE_FILTERS ]}, + {"name": CREATE_FILTERS , "description": "Access to create filters", "depends_on": [ VIEW_FILTERS ]}, + {"name": VIEW_FILTERS , "description": "Access to view filters", "depends_on": []}, + {"name": MANAGE_INTEGRATIONS , "description": "Access to Integrations", "depends_on": []}, + {"name": MANAGE_DASHBOARD_AUTHORIZATIONS , "description": "Manage Dashboard Authorization", "depends_on": [ VIEW_DASHBOARDS ]}, + {"name": MANAGE_USERS , "description": "Manage Users", "depends_on": []}, + {"name": MANAGE_ROLES , "description": "Manage Roles", "depends_on": [ MANAGE_USERS ]}, + {"name": ADMIN , "description": "Administrator Role", "depends_on": [ MANAGE_ROLES , MANAGE_USERS ]}, +] + + +def build_permission_dict(): + """ + Builds a dictionary of permissions where the key is the permission name and + the value is the permission detail. + + Returns: + dict: A dictionary of permission details. + """ + return {permission["name"]: permission for permission in PERMISSION_DETAILS} diff --git a/forms-flow-api-utils/src/formsflow_api_utils/utils/roles.py b/forms-flow-api-utils/src/formsflow_api_utils/utils/roles.py deleted file mode 100644 index 53c32928e1..0000000000 --- a/forms-flow-api-utils/src/formsflow_api_utils/utils/roles.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Role definitions.""" - - -# class Role: # pylint: disable=too-few-public-methods -# """User Role.""" - -# rpas_designer = "rpas-designer" -# rpas_reviewer = "rpas-reviewer" -# rpas_client = "rpas-client" diff --git a/forms-flow-api-utils/src/formsflow_api_utils/utils/user_context.py b/forms-flow-api-utils/src/formsflow_api_utils/utils/user_context.py index 36aa7d6316..062f2b81d7 100644 --- a/forms-flow-api-utils/src/formsflow_api_utils/utils/user_context.py +++ b/forms-flow-api-utils/src/formsflow_api_utils/utils/user_context.py @@ -51,15 +51,21 @@ def email(self) -> str: def roles(self) -> List[str]: """Return the roles.""" return self._roles + + @property + def groups(self) -> List[str]: + """Return the roles.""" + return self._groups @property def group_or_roles(self) -> List[str]: """Return groups is env is using groups, else roles.""" - return ( - self._roles - if current_app.config.get("KEYCLOAK_ENABLE_CLIENT_AUTH") - else self._groups - ) + if current_app.config.get( + "KEYCLOAK_ENABLE_CLIENT_AUTH" + ) and not current_app.config.get("MULTI_TENANCY_ENABLED"): + return self._roles + else: + return self._groups def user_context(function): diff --git a/forms-flow-api-utils/src/formsflow_api_utils/utils/util.py b/forms-flow-api-utils/src/formsflow_api_utils/utils/util.py index e90920da4a..9208851e72 100644 --- a/forms-flow-api-utils/src/formsflow_api_utils/utils/util.py +++ b/forms-flow-api-utils/src/formsflow_api_utils/utils/util.py @@ -12,17 +12,23 @@ from .constants import ( ALLOW_ALL_ORIGINS, - CLIENT_GROUP, - DESIGNER_GROUP, - REVIEWER_GROUP, ) from .enums import ( ApplicationSortingParameters, DraftSortingParameters, FormioRoles, + ProcessSortingParameters, ) from .translations.translations import translations - +from .permisions import ( + CREATE_DESIGNS, +VIEW_DESIGNS, +MANAGE_TASKS, +VIEW_TASKS, +CREATE_SUBMISSIONS, +VIEW_SUBMISSIONS, +) +from sqlalchemy.sql.expression import text def cors_preflight(methods: str = "GET"): """Render an option method on the class.""" @@ -58,13 +64,21 @@ def validate_sort_order_and_order_by(order_by: str, sort_order: str) -> bool: ApplicationSortingParameters.Name, ApplicationSortingParameters.Status, ApplicationSortingParameters.Modified, + ApplicationSortingParameters.FormStatus, ApplicationSortingParameters.FormName, + ApplicationSortingParameters.visibility, DraftSortingParameters.Name, + ProcessSortingParameters.Name, + ProcessSortingParameters.Created, + ProcessSortingParameters.Modified, + ProcessSortingParameters.ProcessKey, ]: order_by = None else: if order_by in [ApplicationSortingParameters.Name, DraftSortingParameters.Name]: order_by = ApplicationSortingParameters.FormName + if order_by == ApplicationSortingParameters.visibility: + order_by = ApplicationSortingParameters.is_anonymous order_by = camel_to_snake(order_by) if sort_order not in ["asc", "desc"]: sort_order = None @@ -104,11 +118,11 @@ def get_role_ids_from_user_groups(role_ids, user_role): if role_ids is None or user_role is None: return None - if DESIGNER_GROUP in user_role: + if any(permission in user_role for permission in [ CREATE_DESIGNS, VIEW_DESIGNS]): return role_ids - if REVIEWER_GROUP in user_role: + if any(permission in user_role for permission in [ MANAGE_TASKS, VIEW_TASKS]): return filter_list_by_user_role(FormioRoles.REVIEWER.name, role_ids) - if CLIENT_GROUP in user_role: + if any(permission in user_role for permission in [ CREATE_SUBMISSIONS, VIEW_SUBMISSIONS]): return filter_list_by_user_role(FormioRoles.CLIENT.name, role_ids) return None @@ -123,3 +137,22 @@ def get_form_and_submission_id_from_form_url(form_url: str) -> Tuple: form_id = form_url[form_url.find("/form/") + 6 : form_url.find("/submission/")] submission_id = form_url[form_url.find("/submission/") + 12 : len(form_url)] return (form_id, submission_id) + + +def add_sort_filter(query, sort_by, sort_order, model_name): + """Adding sortBy and sortOrder.""" + order = [] + if sort_by and sort_order: + for sort_by_att, sort_order_attr in zip(sort_by, sort_order): + name, value = validate_sort_order_and_order_by( + sort_order=sort_order_attr, order_by=sort_by_att + ) + if name and value: + # Handle null values in is_anonymous to false + if name == "is_anonymous": + order.append(text(f"COALESCE({model_name}.{name}, false) {value}")) + else: + order.append(text(f"{model_name}.{name} {value}")) + + query = query.order_by(*order) + return query diff --git a/forms-flow-api/Dockerfile b/forms-flow-api/Dockerfile index a1517fe0e8..48a547a0c9 100644 --- a/forms-flow-api/Dockerfile +++ b/forms-flow-api/Dockerfile @@ -1,5 +1,5 @@ #Author: Kurian Benoy -FROM python:3.12.1-slim-bullseye +FROM python:3.12.6-slim # set label for image LABEL Name="formsflow" diff --git a/forms-flow-api/README.md b/forms-flow-api/README.md index d5d53b27e7..5e97d4e131 100644 --- a/forms-flow-api/README.md +++ b/forms-flow-api/README.md @@ -1,7 +1,7 @@ # formsflow.ai API [![FormsFlow API CI](https://github.com/AOT-Technologies/forms-flow-ai/actions/workflows/forms-flow-api-ci.yml/badge.svg)](https://github.com/AOT-Technologies/forms-flow-ai/actions) -![Python](https://img.shields.io/badge/python-3.12.1-blue) ![Flask](https://img.shields.io/badge/Flask-2.3.3-blue) ![postgres](https://img.shields.io/badge/postgres-11.0-blue) +![Python](https://img.shields.io/badge/python-3.12.6-blue) ![Flask](https://img.shields.io/badge/Flask-2.3.3-blue) ![postgres](https://img.shields.io/badge/postgres-11.0-blue) [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![linting: pylint](https://img.shields.io/badge/linting-pylint-yellowgreen)](https://github.com/PyCQA/pylint) **formsflow.ai** has built this adaptive tier for correlating form management, BPM and analytics together. @@ -25,6 +25,8 @@ the system. It is built using Python :snake: . * For docker based installation [Docker](https://docker.com) need to be installed. * Admin access to [Keycloak](../forms-flow-idm/keycloak) server and ensure audience(camunda-rest-api) is setup in Keycloak-bpm server. +* Ensure that the `forms-flow-redis` service is running and accessible on port `6379`. For more details, refer to the [forms-flow-redis README](../forms-flow-redis/README.md). + ## Solution Setup @@ -59,6 +61,8 @@ Variable name | Meaning | Possible values | Default value | `FORMSFLOW_API_DB_USER`|formsflow database postgres user|Used on installation to create the database.Choose your own|`postgres` `FORMSFLOW_API_DB_PASSWORD`|formsflow database postgres password|Used on installation to create the database.Choose your own|`changeme` `FORMSFLOW_API_DB_NAME`|formsflow database name|Used on installation to create the database.Choose your own|`FORMSFLOW_API_DB` +`FORMSFLOW_API_DB_HOST`|formsflow database host|Used on installation to create the database.Choose your own||`localhost` +`FORMSFLOW_API_DB_PORT`|formsflow database port|Used on installation to create the database.Choose your own||`5432` `FORMSFLOW_API_DB_URL`|JDBC DB Connection URL for formsflow||`postgresql://postgres:changeme@forms-flow-webapi-db:5432/webapi` `KEYCLOAK_URL`:triangular_flag_on_post:| URL to your Keycloak server || `http://{your-ip-address}:8080` `KEYCLOAK_URL_REALM`|The Keycloak realm to use|eg. forms-flow-ai | `forms-flow-ai` @@ -68,6 +72,16 @@ Variable name | Meaning | Possible values | Default value | `BPM_API_URL`:triangular_flag_on_post:|Camunda Rest API URL||`http://{your-ip-address}:8000/camunda` `FORMSFLOW_API_URL`:triangular_flag_on_post:|formsflow.ai Rest API URL||`http://{your-ip-address}:5000` `FORMSFLOW_API_CORS_ORIGINS`| formsflow.ai Rest API allowed origins, for allowing multiple origins you can separate host address using a comma seperated string or use * to allow all origins |eg:`host1, host2, host3`| `*` +`FORMSFLOW_ADMIN_URL`|To fetch formio roles in multi tenancy||`http://{your-ip-address}:5010/api/v1` +`REDIS_URL`| Redis url||`redis://{your-ip-address}:6379/0` +`REDIS_CLUSTER`|To support single/cluster node|`true`/`false`|`false` +`DATABASE_URL`|Database Connection URL||postgresql://postgres:changeme@forms-flow-webapi-db:5432 +`DATABASE_USERNAME`|Database username(This is not needed if we are having DATABASE_URL)| `postgres` +`DATABASE_PASSWORD`|Database password(This is not needed if we are having DATABASE_URL)|`changeme` +`DATABASE_HOST`|Database host(This is not needed if we are having DATABASE_URL)|`forms-flow-webapi-db` +`DATABASE_PORT`|Database port(This is not needed if we are having DATABASE_URL)|`5432` +`DATABASE_NAME`|Database name(This is not needed if we are having DATABASE_URL)|`webapi` + **NOTE : Default realm is `forms-flow-ai`** diff --git a/forms-flow-api/docker-compose.yml b/forms-flow-api/docker-compose.yml index 8422b394de..cfb4b2c4e2 100644 --- a/forms-flow-api/docker-compose.yml +++ b/forms-flow-api/docker-compose.yml @@ -22,7 +22,6 @@ services: restart: unless-stopped depends_on: - forms-flow-webapi-db - - redis entrypoint: "/wait-for-service.sh forms-flow-webapi-db:5432 -s -- ./entrypoint.sh" ports: - '5000:5000' @@ -32,6 +31,11 @@ services: INSIGHT_API_KEY: ${INSIGHT_API_KEY} INSIGHT_API_URL: ${INSIGHT_API_URL} DATABASE_URL: ${FORMSFLOW_API_DB_URL:-postgresql://postgres:changeme@forms-flow-webapi-db:5432/webapi} + DATABASE_USERNAME: ${FORMSFLOW_API_DB_USER:-postgres} + DATABASE_PASSWORD: ${FORMSFLOW_API_DB_PASSWORD:-changeme} + DATABASE_HOST: ${FORMSFLOW_API_DB_HOST:-forms-flow-webapi-db} + DATABASE_PORT: ${FORMSFLOW_API_DB_PORT:-5432} + DATABASE_NAME: ${FORMSFLOW_API_DB_NAME:-webapi} BPM_TOKEN_API: ${KEYCLOAK_URL}${KEYCLOAK_URL_HTTP_RELATIVE_PATH:-/auth}/realms/${KEYCLOAK_URL_REALM:-forms-flow-ai}/protocol/openid-connect/token BPM_CLIENT_ID: ${KEYCLOAK_BPM_CLIENT_ID:-forms-flow-bpm} BPM_CLIENT_SECRET: ${KEYCLOAK_BPM_CLIENT_SECRET:-e4bdbd25-1467-4f7f-b993-bc4b1944c943} @@ -58,18 +62,12 @@ services: API_LOG_BACKUP_COUNT: ${API_LOG_BACKUP_COUNT:-7} CONFIGURE_LOGS: ${CONFIGURE_LOGS:-true} REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + FORMSFLOW_ADMIN_URL: ${FORMSFLOW_ADMIN_URL} stdin_open: true # -i tty: true # -t networks: - forms-flow-webapi-network - redis: - image: "redis:alpine" - ports: - - "6379:6379" - networks: - - forms-flow-webapi-network - networks: forms-flow-webapi-network: diff --git a/forms-flow-api/migrations/versions/069086882f6c_added_fields_to_process_table.py b/forms-flow-api/migrations/versions/069086882f6c_added_fields_to_process_table.py new file mode 100644 index 0000000000..22cb897d6a --- /dev/null +++ b/forms-flow-api/migrations/versions/069086882f6c_added_fields_to_process_table.py @@ -0,0 +1,32 @@ +"""Added fields to process-table + +Revision ID: 069086882f6c +Revises: d4618d0e45ca +Create Date: 2024-09-26 16:53:01.101934 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '069086882f6c' +down_revision = 'd4618d0e45ca' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('process', sa.Column('process_key', sa.String(), nullable=True)) + op.add_column('process', sa.Column('parent_process_key', sa.String(), nullable=True)) + op.add_column('process', sa.Column('is_subflow', sa.Boolean(), nullable=True, server_default='false')) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('process', 'is_subflow') + op.drop_column('process', 'parent_process_key') + op.drop_column('process', 'process_key') + # ### end Alembic commands ### diff --git a/forms-flow-api/migrations/versions/2dbcca4a4c28_.py b/forms-flow-api/migrations/versions/2dbcca4a4c28_.py new file mode 100644 index 0000000000..96c5138c65 --- /dev/null +++ b/forms-flow-api/migrations/versions/2dbcca4a4c28_.py @@ -0,0 +1,34 @@ +"""Form title length updated + +Revision ID: 2dbcca4a4c28 +Revises: 960477800395 +Create Date: 2024-12-02 11:50:19.461596 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2dbcca4a4c28' +down_revision = '960477800395' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('form_process_mapper', 'form_name', + existing_type=sa.VARCHAR(length=100), + type_=sa.String(length=200), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('form_process_mapper', 'form_name', + existing_type=sa.String(length=200), + type_=sa.VARCHAR(length=100), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/forms-flow-api/migrations/versions/6fc8e5beebe4_title_length_changed.py b/forms-flow-api/migrations/versions/6fc8e5beebe4_title_length_changed.py new file mode 100644 index 0000000000..d5c6e1ef4a --- /dev/null +++ b/forms-flow-api/migrations/versions/6fc8e5beebe4_title_length_changed.py @@ -0,0 +1,46 @@ +"""title length changed + +Revision ID: 6fc8e5beebe4 +Revises: 2dbcca4a4c28 +Create Date: 2024-12-03 15:06:02.156816 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6fc8e5beebe4' +down_revision = '2dbcca4a4c28' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('form_process_mapper', 'process_key', + existing_type=sa.VARCHAR(length=50), + type_=sa.String(length=200), + existing_nullable=True, + existing_server_default=sa.text("'Defaultflow'::character varying")) + op.alter_column('form_process_mapper', 'process_name', + existing_type=sa.VARCHAR(length=100), + type_=sa.String(length=200), + existing_nullable=True, + existing_server_default=sa.text("'Default Flow'::character varying")) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('form_process_mapper', 'process_name', + existing_type=sa.String(length=200), + type_=sa.VARCHAR(length=100), + existing_nullable=True, + existing_server_default=sa.text("'Default Flow'::character varying")) + op.alter_column('form_process_mapper', 'process_key', + existing_type=sa.String(length=200), + type_=sa.VARCHAR(length=50), + existing_nullable=True, + existing_server_default=sa.text("'Defaultflow'::character varying")) + # ### end Alembic commands ### diff --git a/forms-flow-api/migrations/versions/77d8b68e6c1f_users_table.py b/forms-flow-api/migrations/versions/77d8b68e6c1f_users_table.py new file mode 100644 index 0000000000..76e8cd7a82 --- /dev/null +++ b/forms-flow-api/migrations/versions/77d8b68e6c1f_users_table.py @@ -0,0 +1,69 @@ +"""Users table + +Revision ID: 77d8b68e6c1f +Revises: f1599a5bd658 +Create Date: 2024-05-30 16:01:37.273907 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '77d8b68e6c1f' +down_revision = 'f1599a5bd658' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_name', sa.String(length=50), nullable=False), + sa.Column('default_filter', sa.Integer(), nullable=True), + sa.Column('locale', sa.String(), nullable=True, comment='language code'), + sa.Column('tenant', sa.String(), nullable=True, comment='tenant key'), + sa.Column('created', sa.DateTime(), nullable=False), + sa.Column('modified', sa.DateTime(), nullable=True), + sa.Column('created_by', sa.String(), nullable=False), + sa.Column('modified_by', sa.String(), nullable=True), + sa.ForeignKeyConstraint(['default_filter'], ['filter.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_name', 'tenant', name='uq_tenant_user_name') + ) + op.alter_column('themes', 'logo_type', + existing_type=sa.VARCHAR(length=100), + type_=sa.String(length=50), + existing_nullable=False) + op.alter_column('themes', 'logo_data', + existing_type=sa.VARCHAR(), + comment='logo_data contain a base64 or a URL.', + existing_nullable=False) + op.alter_column('themes', 'theme', + existing_type=postgresql.JSON(astext_type=sa.Text()), + comment='Json data', + existing_nullable=False) + op.create_unique_constraint('uq_tenant', 'themes', ['tenant']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('uq_tenant', 'themes', type_='unique') + op.alter_column('themes', 'theme', + existing_type=postgresql.JSON(astext_type=sa.Text()), + comment=None, + existing_comment='Json data', + existing_nullable=False) + op.alter_column('themes', 'logo_data', + existing_type=sa.VARCHAR(), + comment=None, + existing_comment='logo_data contain a base64 or a URL.', + existing_nullable=False) + op.alter_column('themes', 'logo_type', + existing_type=sa.String(length=50), + type_=sa.VARCHAR(length=100), + existing_nullable=False) + op.drop_table('user') + # ### end Alembic commands ### diff --git a/forms-flow-api/migrations/versions/8feb43e1e408_subflow_table.py b/forms-flow-api/migrations/versions/8feb43e1e408_subflow_table.py new file mode 100644 index 0000000000..55b533aa35 --- /dev/null +++ b/forms-flow-api/migrations/versions/8feb43e1e408_subflow_table.py @@ -0,0 +1,34 @@ +"""subflow table + +Revision ID: 8feb43e1e408 +Revises: 069086882f6c +Create Date: 2024-10-08 13:22:27.369462 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8feb43e1e408' +down_revision = '069086882f6c' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('process', sa.Column('status_changed', sa.Boolean(), nullable=True)) + op.drop_index('ix_process_process_type', table_name='process') + op.drop_constraint('process_form_process_mapper_id_fkey', 'process', type_='foreignkey') + op.drop_column('process', 'form_process_mapper_id') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('process', sa.Column('form_process_mapper_id', sa.INTEGER(), autoincrement=False, nullable=True)) + op.create_foreign_key('process_form_process_mapper_id_fkey', 'process', 'form_process_mapper', ['form_process_mapper_id'], ['id']) + op.create_index('ix_process_process_type', 'process', ['process_type'], unique=False) + op.drop_column('process', 'status_changed') + # ### end Alembic commands ### diff --git a/forms-flow-api/migrations/versions/95387de7067e_theme.py b/forms-flow-api/migrations/versions/95387de7067e_theme.py new file mode 100644 index 0000000000..5d45dff357 --- /dev/null +++ b/forms-flow-api/migrations/versions/95387de7067e_theme.py @@ -0,0 +1,41 @@ +"""theme customization + +Revision ID: 95387de7067e +Revises: fdfe787a197c +Create Date: 2024-04-30 03:34:31.937798 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '95387de7067e' +down_revision = 'fdfe787a197c' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('themes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('logo_name', sa.String(length=50), nullable=False), + sa.Column('logo_type', sa.String(length=100), nullable=False), + sa.Column('logo_data', sa.String(), nullable=False), + sa.Column('application_title', sa.String(length=50), nullable=False), + sa.Column('theme', sa.JSON(), nullable=False), + sa.Column('created', sa.DateTime(), nullable=False), + sa.Column('modified', sa.DateTime(), nullable=True), + sa.Column('created_by', sa.String(), nullable=False), + sa.Column('modified_by', sa.String(), nullable=True), + sa.Column('tenant', sa.String(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('themes') + # ### end Alembic commands ### diff --git a/forms-flow-api/migrations/versions/960477800395_workflow_migration_changes.py b/forms-flow-api/migrations/versions/960477800395_workflow_migration_changes.py new file mode 100644 index 0000000000..3ec886cd7c --- /dev/null +++ b/forms-flow-api/migrations/versions/960477800395_workflow_migration_changes.py @@ -0,0 +1,69 @@ +"""Workflow Migration changes + +Revision ID: 960477800395 +Revises: b338018ad0e9 +Create Date: 2024-10-29 11:24:12.569841 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import text +from collections import Counter + +# revision identifiers, used by Alembic. +revision = '960477800395' +down_revision = 'b338018ad0e9' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('form_process_mapper', sa.Column('is_migrated', sa.Boolean(), nullable=True, comment="Is workflow migrated", server_default='false')) + # Update process_name of format process_name(process_key) to process_name + # Ex: "Two Step Approval (two-step-approval)" to "Two Step Approval" + update_query = text("""UPDATE public.form_process_mapper + SET process_name = regexp_replace(process_name, '\\s*\\([a-zA-Z0-9_-]+\\)$', '') + WHERE process_name ~ '\\([a-zA-Z0-9_-]+\\)$';""") + # Execute the SQL statement + op.execute(update_query) + + # code to set the is_migrated field to TRUE for workflow(process) with only one form. + conn = op.get_bind() + # Subquery to get the latest non-deleted row per `parent_form_id` + latest_rows_sql = """ + SELECT process_key, id + FROM ( + SELECT process_key, + parent_form_id, + id, + ROW_NUMBER() OVER (PARTITION BY parent_form_id ORDER BY id DESC) AS row_num + FROM public.form_process_mapper + WHERE deleted = false + ) AS latest_rows + WHERE row_num = 1 + """ + latest_rows = conn.execute(sa.text(latest_rows_sql)).mappings().all() + #Count occurrences of each process_key within the latest rows + process_key_counts = Counter(row["process_key"] for row in latest_rows) + + #Update is_migrated for each latest row based on the process_key count + for row in latest_rows: + process_key = row["process_key"] + row_id = row["id"] + + # Update is_migrated to true if process_key appears in a single parent_form_id group + if process_key_counts[process_key] == 1: + update_sql = """ + UPDATE public.form_process_mapper + SET is_migrated = true + WHERE id = :row_id + """ + conn.execute(sa.text(update_sql), {"row_id": row_id}) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('form_process_mapper', 'is_migrated') + # ### end Alembic commands ### diff --git a/forms-flow-api/migrations/versions/9929f234cef0_script_to_add_major_minor_version_to_existing_form_history.py b/forms-flow-api/migrations/versions/9929f234cef0_script_to_add_major_minor_version_to_existing_form_history.py new file mode 100644 index 0000000000..aa044d4110 --- /dev/null +++ b/forms-flow-api/migrations/versions/9929f234cef0_script_to_add_major_minor_version_to_existing_form_history.py @@ -0,0 +1,51 @@ +"""Script to update existing form history records by inserting values + into the major_version and minor_version columns. + +Revision ID: 9929f234cef0 +Revises: ae48e890f2e2 +Create Date: 2024-08-28 16:49:12.699755 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9929f234cef0' +down_revision = 'ae48e890f2e2' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + # Get distinct parent_form_ids + distinct_parent_ids = conn.execute(sa.text( + "SELECT DISTINCT parent_form_id FROM form_history")).fetchall() + + for parent_id in distinct_parent_ids: + # Get all rows for this parent_form_id in ascending order by id + stmt = "SELECT id, change_log FROM form_history WHERE parent_form_id = :parent_id ORDER BY id ASC" + rows = conn.execute(sa.text(stmt), {"parent_id": parent_id[0]}).mappings().all() + + previous_version = None + + for row in rows: + change_log = row['change_log'] + current_version = change_log.get('version') + + if current_version: + major_version = int(current_version.strip('v')) + previous_version = major_version + else: + major_version = previous_version + + if major_version is not None: + update_stmt ="UPDATE form_history SET minor_version=0, major_version = :major_version WHERE id = :id" + conn.execute(sa.text(update_stmt), {"major_version": major_version, "id": row['id']}) + + +def downgrade(): + # Add downgrade logic if necessary + pass + diff --git a/forms-flow-api/migrations/versions/ae48e890f2e2_added_major_minor_version_to_form_history.py b/forms-flow-api/migrations/versions/ae48e890f2e2_added_major_minor_version_to_form_history.py new file mode 100644 index 0000000000..d199fd68a7 --- /dev/null +++ b/forms-flow-api/migrations/versions/ae48e890f2e2_added_major_minor_version_to_form_history.py @@ -0,0 +1,34 @@ +"""Added major & minor version columns to form history table. + +Revision ID: ae48e890f2e2 +Revises: b0ecd447cfa5 +Create Date: 2024-08-28 11:29:41.480868 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ae48e890f2e2' +down_revision = 'b0ecd447cfa5' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('form_history', sa.Column('major_version', sa.Integer(), nullable=True)) + op.add_column('form_history', sa.Column('minor_version', sa.Integer(), nullable=True)) + op.create_index(op.f('ix_form_history_major_version'), 'form_history', ['major_version'], unique=False) + op.create_index(op.f('ix_form_history_minor_version'), 'form_history', ['minor_version'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_form_history_minor_version'), table_name='form_history') + op.drop_index(op.f('ix_form_history_major_version'), table_name='form_history') + op.drop_column('form_history', 'minor_version') + op.drop_column('form_history', 'major_version') + # ### end Alembic commands ### diff --git a/forms-flow-api/migrations/versions/b0ecd447cfa5_added_version_to_process_data.py b/forms-flow-api/migrations/versions/b0ecd447cfa5_added_version_to_process_data.py new file mode 100644 index 0000000000..cbccb44dd6 --- /dev/null +++ b/forms-flow-api/migrations/versions/b0ecd447cfa5_added_version_to_process_data.py @@ -0,0 +1,45 @@ +"""Added version to process data + +Revision ID: b0ecd447cfa5 +Revises: e1d88d2efbcb +Create Date: 2024-08-22 12:04:13.443862 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision = 'b0ecd447cfa5' +down_revision = 'e1d88d2efbcb' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('process', sa.Column('major_version', sa.Integer(), nullable=False)) + op.add_column('process', sa.Column('minor_version', sa.Integer(), nullable=False)) + op.alter_column('process', 'process_data', + existing_type=sa.VARCHAR(), + type_=sa.LargeBinary(), + existing_nullable=False, + postgresql_using="process_data::bytea") + op.create_index(op.f('ix_process_major_version'), 'process', ['major_version'], unique=False) + op.create_index(op.f('ix_process_minor_version'), 'process', ['minor_version'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_process_minor_version'), table_name='process') + op.drop_index(op.f('ix_process_major_version'), table_name='process') + op.alter_column('process', 'process_data', + existing_type=sa.LargeBinary(), + type_=sa.VARCHAR(), + existing_nullable=False, + postgresql_using="process_data::bytea") + op.drop_column('process', 'minor_version') + op.drop_column('process', 'major_version') + # ### end Alembic commands ### diff --git a/forms-flow-api/migrations/versions/b338018ad0e9_alter_audit_data_time_zone_aware.py b/forms-flow-api/migrations/versions/b338018ad0e9_alter_audit_data_time_zone_aware.py new file mode 100644 index 0000000000..4e0b9dce38 --- /dev/null +++ b/forms-flow-api/migrations/versions/b338018ad0e9_alter_audit_data_time_zone_aware.py @@ -0,0 +1,176 @@ +"""Alter audit date time data as time zone aware + +Revision ID: b338018ad0e9 +Revises: 8feb43e1e408 +Create Date: 2024-11-18 16:26:45.562596 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'b338018ad0e9' +down_revision = '8feb43e1e408' +branch_labels = None +depends_on = None + +default_value = "timezone('UTC'::text, CURRENT_TIMESTAMP)" + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + op.alter_column('application', 'created', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=False, + existing_server_default=sa.text(default_value)) + op.alter_column('application', 'modified', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + op.alter_column('application_audit', 'created', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=False) + op.alter_column('authorization', 'created', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=False, + existing_server_default=sa.text(default_value)) + op.alter_column('authorization', 'modified', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + op.alter_column('draft', 'created', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=False) + op.alter_column('draft', 'modified', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + op.alter_column('filter', 'created', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=False) + op.alter_column('filter', 'modified', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + op.alter_column('form_history', 'created', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=False) + op.alter_column('form_process_mapper', 'created', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=False) + op.alter_column('form_process_mapper', 'modified', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + op.alter_column('process', 'created', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=False) + op.alter_column('process', 'modified', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + op.alter_column('themes', 'created', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=False) + op.alter_column('themes', 'modified', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + op.alter_column('user', 'created', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=False) + op.alter_column('user', 'modified', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('user', 'modified', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=True) + op.alter_column('user', 'created', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=False) + op.alter_column('themes', 'modified', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=True) + op.alter_column('themes', 'created', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=False) + op.alter_column('process', 'modified', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=True) + op.alter_column('process', 'created', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=False) + op.alter_column('form_process_mapper', 'modified', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=True) + op.alter_column('form_process_mapper', 'created', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=False) + op.alter_column('form_history', 'created', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=False) + op.alter_column('filter', 'modified', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=True) + op.alter_column('filter', 'created', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=False) + op.alter_column('draft', 'modified', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=True) + op.alter_column('draft', 'created', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=False) + op.alter_column('authorization', 'modified', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=True) + op.alter_column('authorization', 'created', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=False, + existing_server_default=sa.text(default_value)) + op.alter_column('application_audit', 'created', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=False) + op.alter_column('application', 'modified', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=True) + op.alter_column('application', 'created', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=False, + existing_server_default=sa.text(default_value)) + # ### end Alembic commands ### diff --git a/forms-flow-api/migrations/versions/c8e7baa093ba_filter_updation.py b/forms-flow-api/migrations/versions/c8e7baa093ba_filter_updation.py new file mode 100644 index 0000000000..e4c0fe0c4b --- /dev/null +++ b/forms-flow-api/migrations/versions/c8e7baa093ba_filter_updation.py @@ -0,0 +1,33 @@ +"""empty message + +Revision ID: c8e7baa093ba +Revises: 77d8b68e6c1f +Create Date: 2024-06-12 15:16:54.882030 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c8e7baa093ba' +down_revision = '77d8b68e6c1f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + conn = op.get_bind() + conn.execute(sa.text(''' + UPDATE public."filter" f SET criteria = (f.criteria::jsonb - 'processDefinitionNameLike') || + jsonb_build_object('processDefinitionKey', fpm.process_key) FROM public.form_process_mapper fpm + WHERE f.criteria IS NOT NULL AND f.criteria::jsonb->>'processDefinitionNameLike' LIKE '%' || SPLIT_PART(fpm.process_name, ' (', 1) || '%'; + ''')) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/forms-flow-api/migrations/versions/d457f753885f_added_submission_id_and_latest_form_id.py b/forms-flow-api/migrations/versions/d457f753885f_added_submission_id_and_latest_form_id.py index 5039bafdb3..d8fe1ac478 100644 --- a/forms-flow-api/migrations/versions/d457f753885f_added_submission_id_and_latest_form_id.py +++ b/forms-flow-api/migrations/versions/d457f753885f_added_submission_id_and_latest_form_id.py @@ -15,11 +15,11 @@ branch_labels = None depends_on = None -conn = op.get_bind() -form_url_exists = conn.execute(sa.text("SELECT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='application' AND column_name='form_url');")) -form_url_exists = form_url_exists.fetchone()[0] def upgrade(): # ### commands auto generated by Alembic - please adjust! ### + conn = op.get_bind() + form_url_exists = conn.execute(sa.text("SELECT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='application' AND column_name='form_url');")) + form_url_exists = form_url_exists.fetchone()[0] op.add_column('application', sa.Column('submission_id', sa.String(length=100), nullable=True)) op.add_column('application', sa.Column('latest_form_id', sa.String(length=100), nullable=True)) if(form_url_exists): diff --git a/forms-flow-api/migrations/versions/d4618d0e45ca_added_prompt_new_version.py b/forms-flow-api/migrations/versions/d4618d0e45ca_added_prompt_new_version.py new file mode 100644 index 0000000000..7eaa02bc11 --- /dev/null +++ b/forms-flow-api/migrations/versions/d4618d0e45ca_added_prompt_new_version.py @@ -0,0 +1,28 @@ +"""Added prompt_new_version column in form_process_mapper + +Revision ID: d4618d0e45ca +Revises: 9929f234cef0 +Create Date: 2024-09-12 15:57:52.395411 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd4618d0e45ca' +down_revision = '9929f234cef0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('form_process_mapper', sa.Column('prompt_new_version', sa.Boolean(), nullable=True, server_default='false')) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('form_process_mapper', 'prompt_new_version') + # ### end Alembic commands ### diff --git a/forms-flow-api/migrations/versions/e1d88d2efbcb_filter_order.py b/forms-flow-api/migrations/versions/e1d88d2efbcb_filter_order.py new file mode 100644 index 0000000000..9392c1cc9a --- /dev/null +++ b/forms-flow-api/migrations/versions/e1d88d2efbcb_filter_order.py @@ -0,0 +1,28 @@ +"""filter_order + +Revision ID: e1d88d2efbcb +Revises: c8e7baa093ba +Create Date: 2024-06-20 13:55:11.434204 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e1d88d2efbcb' +down_revision = 'c8e7baa093ba' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### Added order for filter to display based on this order! ### + op.add_column('filter', sa.Column('order', sa.Integer(), nullable=True, comment='Display order')) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('filter', 'order') + # ### end Alembic commands ### diff --git a/forms-flow-api/migrations/versions/f1599a5bd658_capture_process_data.py b/forms-flow-api/migrations/versions/f1599a5bd658_capture_process_data.py new file mode 100644 index 0000000000..5b3aa24f9e --- /dev/null +++ b/forms-flow-api/migrations/versions/f1599a5bd658_capture_process_data.py @@ -0,0 +1,46 @@ +"""capture_process_data + +Revision ID: f1599a5bd658 +Revises: 95387de7067e +Create Date: 2024-04-26 12:30:03.513372 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'f1599a5bd658' +down_revision = '95387de7067e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('process', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('process_type', postgresql.ENUM('BPMN', 'LOWCODE', 'DMN', name='ProcessType'), nullable=False), + sa.Column('process_data', sa.String(), nullable=False), + sa.Column('status', postgresql.ENUM('DRAFT', 'PUBLISHED', name='ProcessStatus'), nullable=False), + sa.Column('form_process_mapper_id', sa.Integer(), nullable=True), + sa.Column('tenant', sa.String(length=100), nullable=True), + sa.Column('created', sa.DateTime(), nullable=False), + sa.Column('modified', sa.DateTime(), nullable=True), + sa.Column('created_by', sa.String(), nullable=False), + sa.Column('modified_by', sa.String(), nullable=True), + sa.ForeignKeyConstraint(['form_process_mapper_id'], ['form_process_mapper.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_process_process_type'), 'process', ['process_type'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_process_process_type'), table_name='process') + op.drop_table('process') + sa.Enum(name='ProcessType').drop(op.get_bind(), checkfirst=False) + sa.Enum(name='ProcessStatus').drop(op.get_bind(), checkfirst=False) + # ### end Alembic commands ### diff --git a/forms-flow-api/migrations/versions/f581ec5971eb_added_index.py b/forms-flow-api/migrations/versions/f581ec5971eb_added_index.py new file mode 100644 index 0000000000..c59d6bdb97 --- /dev/null +++ b/forms-flow-api/migrations/versions/f581ec5971eb_added_index.py @@ -0,0 +1,42 @@ +"""Added index for tenant columns and columns in process table + +Revision ID: f581ec5971eb +Revises: 6fc8e5beebe4 +Create Date: 2025-01-10 09:51:38.407463 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f581ec5971eb' +down_revision = '6fc8e5beebe4' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f('ix_authorization_tenant'), 'authorization', ['tenant'], unique=False) + op.create_index(op.f('ix_form_process_mapper_tenant'), 'form_process_mapper', ['tenant'], unique=False) + op.create_index('idx_tenant_is_subflow', 'process', ['tenant', 'is_subflow'], unique=False) + op.create_index('idx_tenant_parent_process_key', 'process', ['tenant', 'parent_process_key'], unique=False) + op.create_index(op.f('ix_process_name'), 'process', ['name'], unique=False) + op.create_index(op.f('ix_process_parent_process_key'), 'process', ['parent_process_key'], unique=False) + op.create_index(op.f('ix_process_tenant'), 'process', ['tenant'], unique=False) + op.create_index(op.f('ix_themes_tenant'), 'themes', ['tenant'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_themes_tenant'), table_name='themes') + op.drop_index(op.f('ix_process_tenant'), table_name='process') + op.drop_index(op.f('ix_process_parent_process_key'), table_name='process') + op.drop_index(op.f('ix_process_name'), table_name='process') + op.drop_index('idx_tenant_parent_process_key', table_name='process') + op.drop_index('idx_tenant_is_subflow', table_name='process') + op.drop_index(op.f('ix_form_process_mapper_tenant'), table_name='form_process_mapper') + op.drop_index(op.f('ix_authorization_tenant'), table_name='authorization') + # ### end Alembic commands ### diff --git a/forms-flow-api/requirements.txt b/forms-flow-api/requirements.txt index 1644ad1cba..7c37c7a9c2 100644 --- a/forms-flow-api/requirements.txt +++ b/forms-flow-api/requirements.txt @@ -1,70 +1,71 @@ Brotli==1.1.0 -Flask-Caching==2.1.0 +Flask-Caching==2.0.1 Flask-Migrate==4.0.7 -Flask-Moment==1.0.5 +Flask-Moment==1.0.6 Flask-SQLAlchemy==3.1.1 Flask==2.3.3 -Jinja2==3.1.3 -Mako==1.3.2 -MarkupSafe==2.1.5 -PyJWT==2.8.0 +Jinja2==3.1.4 +Mako==1.3.8 +MarkupSafe==3.0.2 +PyJWT==2.10.1 PySocks==1.7.1 -SQLAlchemy-Utils==0.41.1 -SQLAlchemy==2.0.28 -Werkzeug==3.0.1 -alembic==1.13.1 +SQLAlchemy-Utils==0.41.2 +SQLAlchemy==2.0.36 +Werkzeug==3.1.3 +alembic==1.14.0 aniso8601==9.0.1 -async-timeout==4.0.3 -attrs==23.2.0 -blinker==1.7.0 -cachelib==0.9.0 -certifi==2024.2.2 -cffi==1.16.0 -charset-normalizer==3.3.2 +attrs==24.2.0 +blinker==1.9.0 +cachelib==0.13.0 +certifi==2024.8.30 +cffi==1.17.1 +charset-normalizer==3.4.0 click==8.1.7 -cryptography==42.0.5 -ecdsa==0.18.0 -flask-jwt-oidc==0.3.0 +cryptography==44.0.0 +ecdsa==0.19.0 +flask-jwt-oidc==0.7.0 flask-marshmallow==1.2.1 flask-restx==1.3.0 -formsflow_api_utils @ git+https://github.com/AOT-Technologies/forms-flow-ai.git@release/6.0.2#subdirectory=forms-flow-api-utils -gunicorn==21.2.0 +formsflow_api_utils @ git+https://github.com/AOT-Technologies/forms-flow-ai.git@release/7.0.0#subdirectory=forms-flow-api-utils +gunicorn==23.0.0 h11==0.14.0 h2==4.1.0 hpack==4.0.0 hyperframe==6.0.1 -idna==3.6 -importlib_resources==6.3.2 -itsdangerous==2.1.2 -jsonschema-specifications==2023.12.1 -jsonschema==4.21.1 +idna==3.10 +importlib_resources==6.4.5 +itsdangerous==2.2.0 +jsonschema-specifications==2024.10.1 +jsonschema==4.23.0 kaitaistruct==0.10 -marshmallow-sqlalchemy==1.0.0 -marshmallow==3.21.1 +lxml==5.3.0 +marshmallow-sqlalchemy==1.1.0 +marshmallow==3.23.1 outcome==1.3.0.post0 -packaging==24.0 -psycopg2-binary==2.9.9 -pyOpenSSL==24.1.0 -pyasn1==0.5.1 -pycparser==2.21 -pyparsing==3.1.2 +packaging==24.2 +psycopg2-binary==2.9.10 +pyOpenSSL==24.3.0 +pyasn1==0.6.1 +pycparser==2.22 +pyparsing==3.2.0 python-dotenv==1.0.1 python-jose==3.3.0 -pytz==2024.1 -redis==5.0.3 -referencing==0.34.0 -requests==2.31.0 -rpds-py==0.18.0 +pytz==2024.2 +redis==5.2.1 +referencing==0.35.1 +requests==2.32.3 +rpds-py==0.22.3 rsa==4.9 selenium-wire==5.1.0 -selenium==4.19.0 -sentry-sdk==1.43.0 -six==1.16.0 +selenium==4.27.1 +sentry-sdk==2.19.2 +six==1.17.0 sniffio==1.3.1 sortedcontainers==2.4.0 trio-websocket==0.11.1 -trio==0.25.0 -typing_extensions==4.10.0 -urllib3==2.2.1 +trio==0.27.0 +typing_extensions==4.12.2 +urllib3==2.2.3 +websocket-client==1.8.0 wsproto==1.2.0 -zstandard==0.22.0 +zstandard==0.23.0 diff --git a/forms-flow-api/requirements/prod.txt b/forms-flow-api/requirements/prod.txt index 504a0ae9a1..d385b8c521 100644 --- a/forms-flow-api/requirements/prod.txt +++ b/forms-flow-api/requirements/prod.txt @@ -1,12 +1,12 @@ gunicorn -Flask<3 +Flask Flask-Caching Flask-Migrate Flask-Moment Flask-SQLAlchemy flask-restx flask-marshmallow -flask-jwt-oidc +flask-jwt-oidc==0.7.0 python-dotenv psycopg2-binary marshmallow-sqlalchemy @@ -16,4 +16,5 @@ sqlalchemy_utils markupsafe PyJWT redis -git+https://github.com/AOT-Technologies/forms-flow-ai.git@release/6.0.2#egg=formsflow_api_utils&subdirectory=forms-flow-api-utils \ No newline at end of file +lxml +git+https://github.com/AOT-Technologies/forms-flow-ai.git@release/7.0.0#subdirectory=forms-flow-api-utils \ No newline at end of file diff --git a/forms-flow-api/sample.env b/forms-flow-api/sample.env index 2b24a5a510..3b97aa1acc 100644 --- a/forms-flow-api/sample.env +++ b/forms-flow-api/sample.env @@ -16,6 +16,8 @@ #FORMSFLOW_API_DB_PASSWORD=changeme ##formsflow.ai database name #FORMSFLOW_API_DB_NAME=webapi +#FORMSFLOW_API_DB_HOST +#FORMSFLOW_API_DB_PORT ##URL to your Keycloak server KEYCLOAK_URL=http://{your-ip-address}:8080 @@ -53,6 +55,7 @@ FORMIO_ROOT_PASSWORD=changeme ##Multitenancy ENV Variables #MULTI_TENANCY_ENABLED=false #KEYCLOAK_ENABLE_CLIENT_AUTH=false +#FORMSFLOW_ADMIN_URL=http://{your-ip-address}:5010/api/v1 ## Form embedding #FORM_EMBED_JWT_SECRET=f6a69a42-7f8a-11ed-a1eb-0242ac120002 @@ -69,4 +72,14 @@ FORMIO_ROOT_PASSWORD=changeme #CONFIGURE_LOGS=true #Redis configuration -REDIS_URL=redis://{your-ip-address}:6379/0 \ No newline at end of file +REDIS_URL=redis://{your-ip-address}:6379/0 +REDIS_CLUSTER=false + +#For Local setup +DATABASE_URL +#instead of DATABASE URL you can use these variables +DATABASE_USERNAME +DATABASE_PASSWORD +DATABASE_HOST +DATABASE_PORT +DATABASE_NAME diff --git a/forms-flow-api/setup.cfg b/forms-flow-api/setup.cfg index 2f72bbf943..9c2627b08a 100644 --- a/forms-flow-api/setup.cfg +++ b/forms-flow-api/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = formsflow_api -version = 6.0.2 +version = 7.0.0 author = aot-technologies classifiers = Development Status :: Production diff --git a/forms-flow-api/src/formsflow_api/app.py b/forms-flow-api/src/formsflow_api/app.py index 050099318b..7d0c2681c3 100644 --- a/forms-flow-api/src/formsflow_api/app.py +++ b/forms-flow-api/src/formsflow_api/app.py @@ -3,12 +3,10 @@ Initialize app and the dependencies. """ -import json import logging import os -from http import HTTPStatus -from flask import Flask, g, request +from flask import Flask, request from flask.logging import default_handler from formsflow_api_utils.exceptions import ( register_db_error_handlers, @@ -21,7 +19,6 @@ jwt, register_log_handlers, setup_logging, - translate, ) from formsflow_api_utils.utils.startup import ( collect_role_ids, @@ -59,7 +56,7 @@ def create_app( when=os.getenv("API_LOG_ROTATION_WHEN", "d"), interval=int(os.getenv("API_LOG_ROTATION_INTERVAL", "1")), backupCount=int(os.getenv("API_LOG_BACKUP_COUNT", "7")), - configure_log_file=app.config["CONFIGURE_LOGS"] + configure_log_file=app.config["CONFIGURE_LOGS"], ) app.logger.propagate = False @@ -96,24 +93,6 @@ def add_additional_headers(response): # pylint: disable=unused-variable response.headers["X-Frame-Options"] = "DENY" return response - @app.after_request - def translate_response(response): # pylint: disable=unused-variable - """Select the client specific language from the token locale attribute.""" - if response.status_code in [ - HTTPStatus.BAD_REQUEST, - HTTPStatus.UNAUTHORIZED, - HTTPStatus.FORBIDDEN, - HTTPStatus.NOT_FOUND, - ]: - lang = g.token_info.get("locale", "en") if "token_info" in g else "en" - if lang == "en": - return response - json_response = response.get_json() - translated_response = translate(lang, json_response) - str_response = json.dumps(translated_response) - response.set_data(str_response) - return response - register_shellcontext(app) if not app.config["MULTI_TENANCY_ENABLED"]: with app.app_context(): diff --git a/forms-flow-api/src/formsflow_api/config.py b/forms-flow-api/src/formsflow_api/config.py index 84b50af790..323e826e30 100644 --- a/forms-flow-api/src/formsflow_api/config.py +++ b/forms-flow-api/src/formsflow_api/config.py @@ -53,7 +53,16 @@ class _Config: # pylint: disable=too-few-public-methods ALEMBIC_INI = "migrations/alembic.ini" # POSTGRESQL - SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "") + # PostgreSQL configuration + DB_USER = os.getenv("DATABASE_USERNAME", "postgres") + DB_PASSWORD = os.getenv("DATABASE_PASSWORD", "changeme") + DB_HOST = os.getenv("DATABASE_HOST", "localhost") + DB_PORT = os.getenv("DATABASE_PORT", "6432") + DB_NAME = os.getenv("DATABASE_NAME", "webapi") + SQLALCHEMY_DATABASE_URI = os.getenv( + "DATABASE_URL", + f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}", + ) TESTING = False DEBUG = False @@ -85,7 +94,9 @@ class _Config: # pylint: disable=too-few-public-methods # Keycloak Admin Service KEYCLOAK_URL = os.getenv("KEYCLOAK_URL") KEYCLOAK_URL_REALM = os.getenv("KEYCLOAK_URL_REALM") - KEYCLOAK_URL_HTTP_RELATIVE_PATH = os.getenv("KEYCLOAK_URL_HTTP_RELATIVE_PATH", "/auth") + KEYCLOAK_URL_HTTP_RELATIVE_PATH = os.getenv( + "KEYCLOAK_URL_HTTP_RELATIVE_PATH", "/auth" + ) # Web url WEB_BASE_URL = os.getenv("WEB_BASE_URL") @@ -117,6 +128,9 @@ class _Config: # pylint: disable=too-few-public-methods # Configure LOG CONFIGURE_LOGS = str(os.getenv("CONFIGURE_LOGS", default="true")).lower() == "true" + # Admin url + ADMIN_URL = os.getenv("FORMSFLOW_ADMIN_URL") + class DevConfig(_Config): # pylint: disable=too-few-public-methods """Development environment configuration.""" diff --git a/forms-flow-api/src/formsflow_api/constants/__init__.py b/forms-flow-api/src/formsflow_api/constants/__init__.py index 36ad45e63b..ab5e78c25f 100644 --- a/forms-flow-api/src/formsflow_api/constants/__init__.py +++ b/forms-flow-api/src/formsflow_api/constants/__init__.py @@ -20,6 +20,18 @@ class BusinessErrorCode(ErrorCodeMixin, Enum): HTTPStatus.BAD_REQUEST, ) PROCESS_DEF_NOT_FOUND = "Process definition does not exist", HTTPStatus.BAD_REQUEST + PROCESS_NOT_LATEST_VERSION = ( + "Passed process id is not latest version", + HTTPStatus.BAD_REQUEST, + ) + MAPPER_NOT_LATEST_VERSION = ( + "The provided mapper ID is not the latest version.", + HTTPStatus.BAD_REQUEST, + ) + DECISION_DEF_NOT_FOUND = ( + "Decision definition does not exist", + HTTPStatus.BAD_REQUEST, + ) INVALID_AUTH_RESOURCE_ID = ( "Invalid authorization resource ID", HTTPStatus.BAD_REQUEST, @@ -55,6 +67,66 @@ class BusinessErrorCode(ErrorCodeMixin, Enum): FILTER_NOT_FOUND = "The specified filter does not exist", HTTPStatus.BAD_REQUEST PROCESS_START_ERROR = "Cannot start process instance", HTTPStatus.BAD_REQUEST USER_NOT_FOUND = "User not found", HTTPStatus.BAD_REQUEST + INVALID_PROCESS_DATA = ( + "Invalid process data passed; both data and process type are required", + HTTPStatus.BAD_REQUEST, + ) + PROCESS_ID_NOT_FOUND = ( + "The specified process ID does not exist", + HTTPStatus.BAD_REQUEST, + ) + THEME_NOT_FOUND = "The specified theme not exist", HTTPStatus.BAD_REQUEST + THEME_EXIST = "The specified theme already exist", HTTPStatus.BAD_REQUEST + ROLE_MAPPING_FAILED = "Role mapping failed", HTTPStatus.BAD_REQUEST + INVALID_FILE_TYPE = "File format not supported", HTTPStatus.BAD_REQUEST + FILE_NOT_FOUND = "The file not found", HTTPStatus.BAD_REQUEST + FORM_EXISTS = ( + "Form validation failed: The Name or Path already exists. They must be unique.", + HTTPStatus.BAD_REQUEST, + ) + INVALID_INPUT = "Invalid input parameter", HTTPStatus.BAD_REQUEST + INVALID_FORM_VALIDATION_INPUT = ( + "At least one query parameter (title, name, path) must be provided.", + HTTPStatus.BAD_REQUEST, + ) + INVALID_FORM_TITLE_LENGTH = ( + "The form title should not exceed 200 characters.", + HTTPStatus.BAD_REQUEST, + ) + KEYCLOAK_REQUEST_FAIL = ( + "Request to Keycloak Admin APIs failed.", + HTTPStatus.BAD_REQUEST, + ) + PROCESS_EXISTS = ( + "The Process name or ID already exists. It must be unique.", + HTTPStatus.BAD_REQUEST, + ) + INVALID_PROCESS_VALIDATION_INPUT = ( + "At least one query parameter (name, key) must be provided.", + HTTPStatus.BAD_REQUEST, + ) + PROCESS_INVALID_OPERATION = ( + "Cannot update a published process", + HTTPStatus.BAD_REQUEST, + ) + FORM_INVALID_OPERATION = ( + "Cannot update a published form", + HTTPStatus.BAD_REQUEST, + ) + FORM_VALIDATION_FAILED = "FORM_VALIDATION_FAILED.", HTTPStatus.BAD_REQUEST + INVALID_PROCESS = "Invalid process.", HTTPStatus.BAD_REQUEST + RESTRICT_FORM_DELETE = ( + "Can't delete the form that has submissions associated with it.", + HTTPStatus.BAD_REQUEST, + ) + ADMIN_SERVICE_UNAVAILABLE = ( + "Admin service is not available", + HTTPStatus.SERVICE_UNAVAILABLE, + ) + INVALID_ADMIN_RESPONSE = ( + "Invalid response received from admin service", + HTTPStatus.BAD_REQUEST, + ) def __new__(cls, message, status_code): """Constructor.""" @@ -72,3 +144,90 @@ def message(self): def status_code(self): """Return status code.""" return self._value + + +def default_flow_xml_data(name="Defaultflow"): + """Xml data for default flow.""" + return f""" + + + + Flow_09rbji4 + + + + + execution.setVariable('applicationStatus', 'Completed'); + + + + ["applicationId","applicationStatus"] + + + + + Flow_09rbji4 + Flow_0klorcg + + + + + execution.setVariable('applicationStatus', 'New'); + + + + + + Flow_0klorcg + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """ + + +default_task_variables = [ + {"key": "applicationId", "label": "Submission Id", "type": "hidden"}, + {"key": "applicationStatus", "label": "Submission Status", "type": "hidden"}, + {"key": "submitterLastName", "label": "Submitter Last Name", "type": "hidden"}, + {"key": "submitterFirstName", "label": "Submitter First Name", "type": "hidden"}, + {"key": "submitterEmail", "label": "Submitter Email", "type": "hidden"}, + {"key": "currentUser", "label": "Current User", "type": "hidden"}, + {"key": "currentUserRole", "label": "Current User Roles", "type": "hidden"}, +] diff --git a/forms-flow-api/src/formsflow_api/models/__init__.py b/forms-flow-api/src/formsflow_api/models/__init__.py index 1d355c8921..7989f4948a 100644 --- a/forms-flow-api/src/formsflow_api/models/__init__.py +++ b/forms-flow-api/src/formsflow_api/models/__init__.py @@ -9,6 +9,9 @@ from .filter import Filter from .form_history_logs import FormHistory from .form_process_mapper import FormProcessMapper +from .process import Process, ProcessStatus, ProcessType +from .theme import Themes +from .user import User __all__ = [ "db", @@ -22,4 +25,9 @@ "Authorization", "Filter", "FormHistory", + "Process", + "ProcessType", + "ProcessStatus", + "Themes", + "User", ] diff --git a/forms-flow-api/src/formsflow_api/models/application.py b/forms-flow-api/src/formsflow_api/models/application.py index a9674e44ab..15a317aaed 100644 --- a/forms-flow-api/src/formsflow_api/models/application.py +++ b/forms-flow-api/src/formsflow_api/models/application.py @@ -193,7 +193,7 @@ def filter_conditions(cls, **filters): return query @classmethod - def find_all_by_user( # pylint: disable=too-many-arguments + def find_all_by_user( # pylint: disable=too-many-arguments, too-many-positional-arguments cls, user_id: str, page_no: int, @@ -273,7 +273,7 @@ def find_by_form_id(cls, form_id, page_no: int, limit: int): return result @classmethod - def find_by_form_names( # pylint: disable=too-many-arguments + def find_by_form_names( # pylint: disable=too-many-arguments, too-many-positional-arguments cls, form_names: list(str), page_no: int, @@ -296,7 +296,7 @@ def find_by_form_names( # pylint: disable=too-many-arguments return pagination.items, total_count @classmethod - def find_applications_by_auth_formids_user( # pylint: disable=too-many-arguments + def find_applications_by_auth_formids_user( # pylint: disable=too-many-arguments, too-many-positional-arguments cls, page_no: int, limit: int, @@ -466,7 +466,7 @@ def find_all_by_form_id_user_count(cls, form_id, user_id: str): @classmethod @user_context def find_aggregated_applications( - # pylint: disable-msg=too-many-arguments, too-many-locals + # pylint: disable-msg=too-many-arguments, too-many-locals, too-many-positional-arguments cls, from_date: str, to_date: str, @@ -792,3 +792,18 @@ def get_auth_application_count_by_form_id_user(cls, form_ids, user_name): ) query = cls.filter_draft_applications(query=query) return query.count() + + @classmethod + def get_application_by_formid_and_user_name( + cls, formid: str, user_name: str + ) -> Application: + """Get application by checking formid and created by.""" + query = FormProcessMapper.tenant_authorization( + query=cls.query.join( + FormProcessMapper, cls.form_process_mapper_id == FormProcessMapper.id + ) + ) + query = query.filter( + and_(cls.latest_form_id == formid, cls.created_by == user_name) + ) + return query.first() diff --git a/forms-flow-api/src/formsflow_api/models/application_history.py b/forms-flow-api/src/formsflow_api/models/application_history.py index b2a7166055..7f2ed9c5cb 100644 --- a/forms-flow-api/src/formsflow_api/models/application_history.py +++ b/forms-flow-api/src/formsflow_api/models/application_history.py @@ -55,3 +55,8 @@ def get_application_history(cls, application_id: int): cls.submitted_by, ) ) + + @classmethod + def get_application_history_by_id(cls, application_id: int): + """Find application history by id.""" + return cls.query.filter(cls.application_id == application_id).first() diff --git a/forms-flow-api/src/formsflow_api/models/audit_mixin.py b/forms-flow-api/src/formsflow_api/models/audit_mixin.py index 496054a5bc..3cb28db5af 100644 --- a/forms-flow-api/src/formsflow_api/models/audit_mixin.py +++ b/forms-flow-api/src/formsflow_api/models/audit_mixin.py @@ -5,21 +5,26 @@ from formsflow_api.models.db import db +def iso_utcnow(): + """Return the current UTC datetime in ISO format with timezone awareness.""" + return datetime.datetime.now(datetime.timezone.utc).isoformat() + + class AuditDateTimeMixin: # pylint: disable=too-few-public-methods """Inherit this class to extend the model with created and modified column.""" - created = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) + created = db.Column(db.DateTime(timezone=True), nullable=False, default=iso_utcnow) modified = db.Column( - db.DateTime, - default=datetime.datetime.utcnow, - onupdate=datetime.datetime.utcnow, + db.DateTime(timezone=True), + default=iso_utcnow, + onupdate=iso_utcnow, ) class ApplicationAuditDateTimeMixin: # pylint: disable=too-few-public-methods """Inherit this class to extend the model with created and modified column.""" - created = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) + created = db.Column(db.DateTime(timezone=True), nullable=False, default=iso_utcnow) class AuditUserMixin: # pylint: disable=too-few-public-methods diff --git a/forms-flow-api/src/formsflow_api/models/authorization.py b/forms-flow-api/src/formsflow_api/models/authorization.py index e4a4ea7c74..86da8111ce 100644 --- a/forms-flow-api/src/formsflow_api/models/authorization.py +++ b/forms-flow-api/src/formsflow_api/models/authorization.py @@ -32,7 +32,7 @@ class Authorization(AuditDateTimeMixin, AuditUserMixin, BaseModel, db.Model): """This class manages authorization.""" id = db.Column(db.Integer, primary_key=True, comment="Authorization ID") - tenant = db.Column(db.String, nullable=True, comment="Tenant key") + tenant = db.Column(db.String, nullable=True, comment="Tenant key", index=True) auth_type = db.Column( ENUM(AuthType, name="AuthType"), nullable=False, index=True, comment="Auth Type" ) @@ -69,26 +69,37 @@ def find_all_authorizations( @classmethod def _auth_query( cls, auth_type, roles, tenant, user_name, include_created_by=False - ): # pylint: disable=too-many-arguments - role_condition = [Authorization.roles.contains([role]) for role in roles] - query = cls.query.filter(Authorization.auth_type == auth_type).filter( - or_( - *role_condition, - include_created_by and Authorization.created_by == user_name, - Authorization.user_name == user_name, - and_( - Authorization.user_name.is_(None), - or_(Authorization.roles == {}, Authorization.roles.is_(None)), - ), + ): # pylint: disable=too-many-arguments,too-many-positional-arguments + role_condition = [] + if roles: + role_condition = [Authorization.roles.contains([role]) for role in roles] + query = cls.query.filter(Authorization.auth_type == auth_type) + if auth_type == AuthType.APPLICATION: + # if the authtype is application then need to fetch the resource id associated with roles + query = query.filter( + or_( + *role_condition, + ) + ) + else: + query = query.filter( + or_( + *role_condition, + include_created_by and Authorization.created_by == user_name, + Authorization.user_name == user_name, + and_( + Authorization.user_name.is_(None), + or_(Authorization.roles == {}, Authorization.roles.is_(None)), + ), + ) ) - ) if tenant: query = query.filter(Authorization.tenant == tenant) return query @classmethod - def find_resource_authorization( # pylint: disable=too-many-arguments + def find_resource_authorization( # pylint: disable=too-many-arguments,too-many-positional-arguments cls, auth_type: AuthType, resource_id: str, @@ -102,7 +113,7 @@ def find_resource_authorization( # pylint: disable=too-many-arguments return query.all() @classmethod - # pylint: disable=too-many-arguments + # pylint: disable=too-many-arguments,too-many-positional-arguments def find_resource_by_id( cls, auth_type: AuthType, @@ -112,11 +123,14 @@ def find_resource_by_id( user_name: str = None, tenant: str = None, include_created_by: bool = False, + ignore_role_check: bool = False, ) -> Optional[Authorization]: """Find resource authorization by id.""" if ( - is_designer and auth_type != AuthType.DESIGNER - ) or auth_type == AuthType.DASHBOARD: + (is_designer and auth_type != AuthType.DESIGNER) + or auth_type == AuthType.DASHBOARD + or ignore_role_check + ): query = cls.query.filter(Authorization.auth_type == auth_type) else: query = cls._auth_query( @@ -130,7 +144,7 @@ def find_resource_by_id( @classmethod def find_all_resources_authorized( cls, auth_type, roles, tenant, user_name, include_created_by=False - ): # pylint: disable=too-many-arguments + ): # pylint: disable=too-many-arguments,too-many-positional-arguments """Find all resources authorized to specific user/role or Accessible by all users/roles.""" query = cls._auth_query(auth_type, roles, tenant, user_name, include_created_by) return query.all() diff --git a/forms-flow-api/src/formsflow_api/models/draft.py b/forms-flow-api/src/formsflow_api/models/draft.py index 63171a532e..38ee44723a 100644 --- a/forms-flow-api/src/formsflow_api/models/draft.py +++ b/forms-flow-api/src/formsflow_api/models/draft.py @@ -114,7 +114,7 @@ def find_by_id(cls, draft_id: int, user_id: str) -> Draft: return FormProcessMapper.tenant_authorization(result).first() @classmethod - def find_all_active( # pylint: disable=too-many-arguments + def find_all_active( # pylint: disable=too-many-arguments,too-many-positional-arguments cls, user_name: str, page_number=None, diff --git a/forms-flow-api/src/formsflow_api/models/filter.py b/forms-flow-api/src/formsflow_api/models/filter.py index 0bfde974f3..95e193e347 100644 --- a/forms-flow-api/src/formsflow_api/models/filter.py +++ b/forms-flow-api/src/formsflow_api/models/filter.py @@ -5,7 +5,7 @@ from typing import List from formsflow_api_utils.utils.enums import FilterStatus -from sqlalchemy import JSON, and_, or_ +from sqlalchemy import JSON, and_, asc, case, or_ from sqlalchemy.dialects.postgresql import ARRAY from formsflow_api.models.base_model import BaseModel @@ -29,6 +29,7 @@ class Filter(AuditDateTimeMixin, AuditUserMixin, BaseModel, db.Model): users = db.Column(ARRAY(db.String), nullable=True, comment="Applicable users") status = db.Column(db.String(10), nullable=True) task_visible_attributes = db.Column(JSON, nullable=True) + order = db.Column(db.Integer, nullable=True, comment="Display order") @classmethod def find_all_active_filters(cls, tenant: str = None) -> List[Filter]: @@ -61,6 +62,7 @@ def create_filter_from_dict(cls, filter_data: dict) -> Filter: filter_obj.properties = filter_data.get("properties") filter_obj.roles = filter_data.get("roles") filter_obj.users = filter_data.get("users") + filter_obj.order = filter_data.get("order") filter_obj.status = str(FilterStatus.ACTIVE.value) filter_obj.task_visible_attributes = filter_data.get( "task_visible_attributes" @@ -82,10 +84,14 @@ def find_user_filters( roles, user, tenant, admin, filter_empty_tenant_key=True ) query = query.filter(Filter.status == str(FilterStatus.ACTIVE.value)) + order_by_user_first = case((Filter.created_by == user, 1), else_=2) + query = query.order_by( + order_by_user_first, Filter.order, Filter.created_by, asc(Filter.name) + ) return query.all() @classmethod - def _auth_query( # pylint: disable=too-many-arguments + def _auth_query( # pylint: disable=too-many-arguments, too-many-positional-arguments cls, roles, user, tenant, admin, filter_empty_tenant_key=False ): query = cls.query @@ -117,7 +123,7 @@ def find_filter_by_id(cls, filter_id) -> Filter: return cls.query.filter(Filter.id == filter_id).first() @classmethod - def find_active_filter_by_id( # pylint: disable=too-many-arguments + def find_active_filter_by_id( # pylint: disable=too-many-arguments,too-many-positional-arguments cls, filter_id, roles, user, tenant, admin ) -> Filter: """Find active filter by id.""" @@ -160,7 +166,21 @@ def update(self, filter_info): "modified_by", "status", "task_visible_attributes", + "order", ], filter_info, ) self.commit() + + @classmethod + def find_all_active_filters_formid( + cls, form_id, tenant: str = None + ) -> List[Filter]: + """Find all active filters with specific form id.""" + query = cls.query.filter( + Filter.status == str(FilterStatus.ACTIVE.value), + Filter.properties.op("->>")("formId") == form_id, + ) + if tenant: + query = query.filter(Filter.tenant == tenant) + return query.all() or [] diff --git a/forms-flow-api/src/formsflow_api/models/form_history_logs.py b/forms-flow-api/src/formsflow_api/models/form_history_logs.py index 8e3d9049ee..b775eb3642 100644 --- a/forms-flow-api/src/formsflow_api/models/form_history_logs.py +++ b/forms-flow-api/src/formsflow_api/models/form_history_logs.py @@ -24,6 +24,8 @@ class FormHistory(ApplicationAuditDateTimeMixin, BaseModel, db.Model): anonymous = db.Column(db.Boolean, nullable=True) status = db.Column(db.Boolean, nullable=True) form_type = db.Column(db.Boolean, nullable=True) + major_version = db.Column(db.Integer, index=True) + minor_version = db.Column(db.Integer, index=True) @classmethod def create_history(cls, data) -> "FormHistory": @@ -40,45 +42,43 @@ def create_history(cls, data) -> "FormHistory": history.form_type = data.get("form_type") history.component_change = data.get("component_change") history.anonymous = data.get("anonymous") - history.status = data.get("status") + history.major_version = data.get("major_version") + history.minor_version = data.get("minor_version") history.save() return history return None @classmethod - def fetch_histories_by_parent_id(cls, parent_id) -> List["FormHistory"]: - """Fetch all histories against a form id.""" - assert parent_id is not None - return ( - cls.query.filter( - and_(cls.parent_form_id == parent_id, cls.component_change.is_(True)) + def fetch_published_history_by_parent_form_id(cls, parent_form_id): + """Fetch published version of a form by parent_form_id.""" + query = cls.query.filter( + and_( + cls.parent_form_id == parent_form_id, + cls.status.is_(True), + text("change_log ->>'status' = 'active'"), ) - .order_by(desc(FormHistory.created)) - .all() ) + return query.all() @classmethod - def get_version_count(cls, parent_form_id): - """Get count of form versions.""" - return cls.query.filter( - and_( - cls.parent_form_id == parent_form_id, - cls.component_change.is_(True), - text("CAST(change_log->>'new_version' AS BOOLEAN) = true"), - ) - ).count() + def fetch_histories_by_parent_id( + cls, parent_id, page_no=None, limit=None + ) -> List["FormHistory"]: + """Fetch all histories against a form id.""" + assert parent_id is not None + query = cls.query.filter( + and_(cls.parent_form_id == parent_id, cls.component_change.is_(True)) + ).order_by(desc(FormHistory.created)) + total_count = query.count() + limit = total_count if limit is None else limit + query = query.paginate(page=page_no, per_page=limit, error_out=False) + return query.items, total_count @classmethod def get_latest_version(cls, parent_form_id): """Get latest version number.""" return ( - cls.query.filter( - and_( - cls.parent_form_id == parent_form_id, - cls.component_change.is_(True), - text("CAST(change_log->>'new_version' AS BOOLEAN) = true"), - ) - ) - .order_by(desc(FormHistory.id)) + cls.query.filter(cls.parent_form_id == parent_form_id) + .order_by(cls.major_version.desc(), cls.minor_version.desc()) .first() ) diff --git a/forms-flow-api/src/formsflow_api/models/form_process_mapper.py b/forms-flow-api/src/formsflow_api/models/form_process_mapper.py index c80e646466..6de3bea066 100644 --- a/forms-flow-api/src/formsflow_api/models/form_process_mapper.py +++ b/forms-flow-api/src/formsflow_api/models/form_process_mapper.py @@ -10,34 +10,35 @@ DEFAULT_PROCESS_KEY, DEFAULT_PROCESS_NAME, FILTER_MAPS, - validate_sort_order_and_order_by, + add_sort_filter, ) from formsflow_api_utils.utils.enums import FormProcessMapperStatus from formsflow_api_utils.utils.user_context import UserContext, user_context -from sqlalchemy import UniqueConstraint, and_, desc, func +from sqlalchemy import UniqueConstraint, and_, desc, func, or_ from sqlalchemy.dialects.postgresql import JSON -from sqlalchemy.sql.expression import text from .audit_mixin import AuditDateTimeMixin, AuditUserMixin from .base_model import BaseModel from .db import db -class FormProcessMapper(AuditDateTimeMixin, AuditUserMixin, BaseModel, db.Model): +class FormProcessMapper( + AuditDateTimeMixin, AuditUserMixin, BaseModel, db.Model +): # pylint: disable=too-many-public-methods """This class manages form process mapper information.""" id = db.Column(db.Integer, primary_key=True) form_id = db.Column(db.String(50), nullable=False) - form_name = db.Column(db.String(100), nullable=False) + form_name = db.Column(db.String(200), nullable=False) form_type = db.Column(db.String(20), nullable=False) parent_form_id = db.Column(db.String(50), nullable=False) - process_key = db.Column(db.String(50), nullable=True, default=DEFAULT_PROCESS_KEY) + process_key = db.Column(db.String(200), nullable=True, default=DEFAULT_PROCESS_KEY) process_name = db.Column( - db.String(100), nullable=True, default=DEFAULT_PROCESS_NAME + db.String(200), nullable=True, default=DEFAULT_PROCESS_NAME ) status = db.Column(db.String(10), nullable=True) comments = db.Column(db.String(300), nullable=True) - tenant = db.Column(db.String(100), nullable=True) + tenant = db.Column(db.String(100), nullable=True, index=True) process_tenant = db.Column( db.String(), nullable=True, @@ -52,6 +53,10 @@ class FormProcessMapper(AuditDateTimeMixin, AuditUserMixin, BaseModel, db.Model) task_variable = db.Column(JSON, nullable=True) version = db.Column(db.Integer, nullable=False, default=1) description = db.Column(db.String, nullable=True) + prompt_new_version = db.Column(db.Boolean, nullable=True, default=False) + is_migrated = db.Column( + db.Boolean, nullable=True, default=False, comment="Is workflow migrated" + ) __table_args__ = ( UniqueConstraint("form_id", "version", "tenant", name="_form_version_uc"), @@ -78,6 +83,7 @@ def create_from_dict(cls, mapper_info: dict) -> FormProcessMapper: mapper.task_variable = mapper_info.get("task_variable") mapper.version = mapper_info.get("version") mapper.description = mapper_info.get("description") + mapper.is_migrated = mapper_info.get("is_migrated", True) mapper.save() return mapper except Exception as err: # pylint: disable=broad-except @@ -105,6 +111,8 @@ def update(self, mapper_info: dict): "task_variable", "process_tenant", "description", + "prompt_new_version", + "is_migrated", ], mapper_info, ) @@ -122,17 +130,10 @@ def mark_unpublished(self): self.commit() @classmethod - def find_all(cls, page_number, limit): + def find_all(cls): """Fetch all the form process mappers.""" - if page_number == 0: - query = cls.query.order_by(FormProcessMapper.id.desc()).all() - else: - query = ( - cls.query.order_by(FormProcessMapper.id.desc()) - .paginate(page_number, limit, False) - .items - ) - return query + query = cls.tenant_authorization(query=cls.query) + return query.all() @classmethod def filter_conditions(cls, **filters): @@ -182,6 +183,21 @@ def get_latest_form_mapper_ids(cls): .all() ) + @classmethod + def add_search_filter(cls, query, search): + """Adding search filter in query.""" + if search: + filters = [] + for term in search: + filters.append( + or_( + FormProcessMapper.form_name.ilike(f"%{term}%"), + FormProcessMapper.description.ilike(f"%{term}%"), + ) + ) + query = query.filter(or_(*filters)) + return query + @classmethod def find_all_forms( cls, @@ -192,8 +208,9 @@ def find_all_forms( form_ids=None, is_active=None, form_type=None, + search=None, **filters, - ): # pylint: disable=too-many-arguments + ): # pylint: disable=too-many-arguments, too-many-positional-arguments """Fetch all active and inactive forms which are not deleted.""" # Get latest row for each form_id group filtered_form_query = cls.get_latest_form_mapper_ids() @@ -205,6 +222,16 @@ def find_all_forms( and_(FormProcessMapper.deleted.is_(False)), FormProcessMapper.id.in_(filtered_form_ids), ) + + query = cls.add_search_filter(query=query, search=search) + + query = add_sort_filter( + query=query, + sort_by=sort_by, + sort_order=sort_order, + model_name="form_process_mapper", + ) + # form type is list of type to filter the form if form_type: query = query.filter(FormProcessMapper.form_type.in_(form_type)) @@ -214,9 +241,6 @@ def find_all_forms( query = query.filter(FormProcessMapper.status == value) query = cls.tenant_authorization(query=query) - sort_by, sort_order = validate_sort_order_and_order_by(sort_by, sort_order) - if sort_by and sort_order: - query = query.order_by(text(f"form_process_mapper.{sort_by} {sort_order}")) total_count = query.count() query = query.with_entities( @@ -242,9 +266,10 @@ def find_all_active_by_formid( limit=None, sort_by=None, sort_order=None, + search=None, form_ids=None, **filters, - ): # pylint: disable=too-many-arguments + ): # pylint: disable=too-many-arguments, too-many-positional-arguments """Fetch all active form process mappers by authorized forms.""" # Get latest row for each form_id group filtered_form_query = cls.get_latest_form_mapper_ids() @@ -255,10 +280,14 @@ def find_all_active_by_formid( query = query.filter( FormProcessMapper.id.in_(filtered_form_ids), ) + query = cls.add_search_filter(query=query, search=search) query = cls.access_filter(query=query) - sort_by, sort_order = validate_sort_order_and_order_by(sort_by, sort_order) - if sort_by and sort_order: - query = query.order_by(text(f"form_process_mapper.{sort_by} {sort_order}")) + query = add_sort_filter( + sort_by=sort_by, + sort_order=sort_order, + query=query, + model_name="form_process_mapper", + ) total_count = query.count() query = query.with_entities( @@ -273,36 +302,6 @@ def find_all_active_by_formid( query = query.paginate(page=page_number, per_page=limit, error_out=False) return query.items, total_count - @classmethod - def find_all_active( - cls, - page_number=None, - limit=None, - sort_by=None, - sort_order=None, - process_key=None, - **filters, - ): # pylint: disable=too-many-arguments - """Fetch all active form process mappers.""" - query = cls.filter_conditions(**filters) - if process_key is not None: - query = query.filter(FormProcessMapper.process_key.in_(process_key)) - query = cls.access_filter(query=query) - sort_by, sort_order = validate_sort_order_and_order_by(sort_by, sort_order) - if sort_by and sort_order: - query = query.order_by(text(f"form_process_mapper.{sort_by} {sort_order}")) - - total_count = query.count() - query = query.with_entities( - cls.id, - cls.process_key, - cls.form_id, - cls.form_name, - ) - limit = total_count if limit is None else limit - query = query.paginate(page=page_number, per_page=limit, error_out=False) - return query.items, total_count - @classmethod def find_all_count(cls): """Fetch the total active form process mapper which are active.""" @@ -386,3 +385,72 @@ def find_all_active_forms( limit = total_count if limit is None else limit query = query.paginate(page=page_number, per_page=limit, error_out=False) return query.items, total_count + + @classmethod + def find_forms_by_title(cls, form_title, exclude_id) -> FormProcessMapper: + """Find all form process mapper that matches the provided form title.""" + latest_mapper = ( + db.session.query( + func.max(cls.id).label("latest_id"), + cls.parent_form_id, + ) + .group_by(cls.parent_form_id) + .subquery() + ) + query = ( + db.session.query(cls) + .join(latest_mapper, cls.id == latest_mapper.c.latest_id) + .filter(cls.form_name == form_title, cls.deleted.is_(False)) + ) + + if exclude_id is not None: + query = query.filter(cls.parent_form_id != exclude_id) + + query = cls.tenant_authorization(query=query) + return query.all() + + @classmethod + def get_latest_by_parent_form_id(cls, parent_form_id): + """Get latest of mapper row by parent form id.""" + query = cls.tenant_authorization(query=cls.query) + query = ( + query.filter( + cls.parent_form_id == parent_form_id, + ) + .order_by(cls.id.desc()) + .first() + ) + return query + + @classmethod + @user_context + def get_mappers_by_process_key(cls, process_key=None, mapper_id=None, **kwargs): + """Get all mappers matching given process key.""" + # Define the subquery with the window function to get latest mappers by process_key + user: UserContext = kwargs["user"] + tenant_key: str = user.tenant_key + subquery = ( + db.session.query( + cls.process_key, + cls.parent_form_id, + cls.id, + cls.deleted, + cls.form_id, + cls.tenant, + func.row_number() # pylint: disable=not-callable + .over(partition_by=cls.parent_form_id, order_by=cls.id.desc()) + .label("row_num"), + ).filter( + cls.process_key == process_key, + cls.deleted.is_(False), + cls.id != mapper_id, + cls.tenant == tenant_key, + ) + ).subquery("latest_mapper_rows_by_process_key") + # Only get the latest row in each parent_formid group + query = ( + db.session.query(cls) + .join(subquery, cls.id == subquery.c.id) + .filter(subquery.c.row_num == 1) + ) + return query.all() diff --git a/forms-flow-api/src/formsflow_api/models/process.py b/forms-flow-api/src/formsflow_api/models/process.py new file mode 100644 index 0000000000..e824c9b180 --- /dev/null +++ b/forms-flow-api/src/formsflow_api/models/process.py @@ -0,0 +1,259 @@ +"""This manages Process Data.""" + +from __future__ import annotations + +from enum import Enum, unique +from typing import List + +from flask_sqlalchemy.query import Query +from formsflow_api_utils.utils import FILTER_MAPS, add_sort_filter +from formsflow_api_utils.utils.user_context import UserContext, user_context +from sqlalchemy import Index, LargeBinary, and_, desc, func, or_ +from sqlalchemy.dialects.postgresql import ENUM + +from .audit_mixin import AuditDateTimeMixin, AuditUserMixin +from .base_model import BaseModel +from .db import db + + +@unique +class ProcessType(Enum): + """Process type enum.""" + + BPMN = "BPMN" + LOWCODE = "LOWCODE" + DMN = "DMN" + + +@unique +class ProcessStatus(Enum): + """Process status enum.""" + + DRAFT = "Draft" + PUBLISHED = "Published" + + +class Process(AuditDateTimeMixin, AuditUserMixin, BaseModel, db.Model): + """This class manages process data.""" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String, nullable=False, index=True) + process_type = db.Column(ENUM(ProcessType, name="ProcessType"), nullable=False) + process_data = db.Column(LargeBinary, nullable=False) + status = db.Column( + ENUM(ProcessStatus, name="ProcessStatus"), + nullable=False, + default=ProcessStatus.DRAFT, + ) + tenant = db.Column(db.String(100), nullable=True, index=True) + major_version = db.Column(db.Integer, nullable=False, index=True) + minor_version = db.Column(db.Integer, nullable=False, index=True) + process_key = db.Column(db.String) + parent_process_key = db.Column(db.String, index=True) + is_subflow = db.Column(db.Boolean, default=False) + status_changed = db.Column(db.Boolean, default=False) + + __table_args__ = ( + Index("idx_tenant_is_subflow", "tenant", "is_subflow"), + Index("idx_tenant_parent_process_key", "tenant", "parent_process_key"), + ) + + @classmethod + def create_from_dict(cls, process_data: dict) -> Process: + """Create a new process from a dictionary.""" + if process_data: + process = Process( + name=process_data.get("name"), + process_type=process_data.get("process_type"), + tenant=process_data.get("tenant"), + process_data=process_data.get("process_data"), + created_by=process_data.get("created_by"), + major_version=process_data.get("major_version"), + minor_version=process_data.get("minor_version"), + is_subflow=process_data.get("is_subflow", False), + status=process_data.get("status", ProcessStatus.DRAFT), + status_changed=process_data.get("status_changed", False), + process_key=process_data.get("process_key"), + parent_process_key=process_data.get("parent_process_key"), + ) + + # Save the new process to the database + process.save() + return process + return None + + @classmethod + @user_context + def auth_query(cls, query, **kwargs) -> Process: + """Query to find authorized process.""" + if not isinstance(query, Query): + raise TypeError("Query object must be of type Query") + user: UserContext = kwargs["user"] + tenant_key: str = user.tenant_key + if tenant_key is not None: + query = query.filter(cls.tenant == tenant_key) + return query + + def update(self, process_info: dict): + """Update process data.""" + self.update_from_dict( + [ + "name", + "status", + "process_data", + "modified_by", + "modified", + "major_version", + "minor_version", + "process_key", + "parent_process_key", + "is_subflow", + ], + process_info, + ) + self.commit() + + @classmethod + def find_process_by_id(cls, process_id: int) -> Process: + """Find process that matches the provided id.""" + query = cls.query.filter(cls.id == process_id) + query = cls.auth_query(query=query) + return query.one_or_none() + + @classmethod + def filter_conditions(cls, **filters): + """This method creates dynamic filter conditions based on the input param.""" + filter_conditions = [] + for key, value in filters.items(): + if value: + filter_map = FILTER_MAPS[key] + condition = Process.create_filter_condition( + model=Process, + column_name=filter_map["field"], + operator=filter_map["operator"], + value=value, + ) + filter_conditions.append(condition) + query = cls.query.filter(*filter_conditions) if filter_conditions else cls.query + return query + + @classmethod + @user_context + def subquery_for_getting_latest_process(cls, **kwargs): + """Subquery to get the latest process by parent_process_key.""" + user: UserContext = kwargs["user"] + subquery = ( + db.session.query( + cls.parent_process_key, + cls.tenant, + func.max(cls.major_version).label("latest_major_version"), + func.max(cls.id).label("latest_id"), + ) + .filter(cls.tenant == user.tenant_key) + .group_by(cls.parent_process_key, cls.tenant) + .subquery() + ) + return subquery + + @classmethod + def find_all_process( # pylint: disable=too-many-arguments, too-many-positional-arguments + cls, + page_no=None, + limit=None, + sort_by=None, + sort_order=None, + is_subflow=False, + **filters, + ): + """Find all processes.""" + query = cls.filter_conditions(**filters) + # take the latest row by grouping parent_process_key + subquery = cls.subquery_for_getting_latest_process() + query = query.join( + subquery, + (cls.parent_process_key == subquery.c.parent_process_key) + & (cls.major_version == subquery.c.latest_major_version) + & (cls.id == subquery.c.latest_id), + ) + + if is_subflow: + query = query.filter(cls.is_subflow.is_(True)) + query = add_sort_filter( + query=query, sort_by=sort_by, sort_order=sort_order, model_name="process" + ) + total_count = query.count() + limit = total_count if limit is None else limit + query = query.paginate(page=page_no, per_page=limit, error_out=False) + return query.items, total_count + + @classmethod + def get_latest_version_by_key(cls, process_key): + """Get latest version of process.""" + query = ( + cls.auth_query(cls.query.filter(cls.process_key == process_key)) + .order_by(cls.major_version.desc(), cls.minor_version.desc(), cls.id.desc()) + .first() + ) + + return query + + @classmethod + def fetch_published_history_by_parent_process_key( + cls, parent_process_key: str + ) -> Process: + """Fetch published version of a process by parent_process_key.""" + query = cls.auth_query( + cls.query.filter( + and_( + cls.parent_process_key == parent_process_key, + cls.status == ProcessStatus.PUBLISHED, + ) + ) + ) + return query.all() + + @classmethod + def get_latest_version_by_parent_key(cls, parent_process_key): + """Get latest version of process.""" + query = ( + cls.auth_query( + cls.query.filter(cls.parent_process_key == parent_process_key) + ) + .order_by(cls.major_version.desc(), cls.minor_version.desc(), cls.id.desc()) + .first() + ) + + return query + + @classmethod + def fetch_histories_by_parent_process_key( + cls, parent_process_key: str, page_no=None, limit=None + ) -> List[Process]: + """Fetch all versions (histories) of a process by process_name.""" + assert parent_process_key is not None + + query = cls.auth_query( + cls.query.filter( + and_( + cls.parent_process_key == parent_process_key, + or_(cls.status_changed.is_(False), cls.status_changed.is_(None)), + ) + ) + ).order_by(desc(cls.major_version), desc(cls.minor_version)) + total_count = query.count() + limit = total_count if limit is None else limit + query = query.paginate(page=page_no, per_page=limit, error_out=False) + return query.items, total_count + + @classmethod + def find_process_by_name_key( + cls, name=None, process_key=None, parent_process_key=None + ) -> Process: + """Find all process that matches the provided name/key.""" + query = Process.query.filter( + or_(Process.name == name, Process.process_key == process_key) + ) + if parent_process_key: + query = query.filter(Process.parent_process_key != parent_process_key) + query = cls.auth_query(query=query) + return query.all() diff --git a/forms-flow-api/src/formsflow_api/models/theme.py b/forms-flow-api/src/formsflow_api/models/theme.py new file mode 100644 index 0000000000..45a505e49a --- /dev/null +++ b/forms-flow-api/src/formsflow_api/models/theme.py @@ -0,0 +1,60 @@ +"""This manages theme Database Models.""" + +from sqlalchemy import JSON, UniqueConstraint + +from .audit_mixin import AuditDateTimeMixin, AuditUserMixin +from .base_model import BaseModel +from .db import db + + +class Themes(AuditDateTimeMixin, AuditUserMixin, BaseModel, db.Model): + """This class manages theme customization information.""" + + id = db.Column(db.Integer, primary_key=True) + logo_name = db.Column(db.String(50), nullable=False) + logo_type = db.Column(db.String(50), nullable=False) + logo_data = db.Column( + db.String(), nullable=False, comment="logo_data contain a base64 or a URL." + ) + application_title = db.Column(db.String(50), nullable=False) + theme = db.Column(JSON, nullable=False, comment="Json data") + tenant = db.Column(db.String(20), nullable=True, index=True) + __table_args__ = (UniqueConstraint("tenant", name="uq_tenant"),) + + @classmethod + def create_theme(cls, theme_info: dict): + """Create new theme.""" + assert theme_info is not None + theme = cls() + theme.created_by = theme_info.get("created_by") + theme.logo_name = theme_info["logo_name"] + theme.logo_type = theme_info["logo_type"] + theme.logo_data = theme_info["logo_data"] + theme.application_title = theme_info["application_title"] + theme.tenant = theme_info.get("tenant") + theme.theme = theme_info["theme"] + theme.save() + return theme + + def update(self, theme_info: dict): + """Update theme.""" + self.update_from_dict( + [ + "logo_name", + "logo_type", + "logo_data", + "application_title", + "theme", + ], + theme_info, + ) + self.commit() + + @classmethod + def get_theme(cls, tenant: str = None): + """Find theme that matches the provided tenant.""" + # For multi tenant setup there would be multiple records in this table, + # so match with tenant and return the record. + # For a non-multi tenant setup there SHOULD be only one record in this table, so return the record + query = cls.query.filter(cls.tenant == tenant) if tenant else cls.query + return query.one_or_none() diff --git a/forms-flow-api/src/formsflow_api/models/user.py b/forms-flow-api/src/formsflow_api/models/user.py new file mode 100644 index 0000000000..492601a312 --- /dev/null +++ b/forms-flow-api/src/formsflow_api/models/user.py @@ -0,0 +1,70 @@ +"""This manages User Database Models.""" + +from flask_sqlalchemy.query import Query +from formsflow_api_utils.utils.user_context import UserContext, user_context +from sqlalchemy import UniqueConstraint + +from .audit_mixin import AuditDateTimeMixin, AuditUserMixin +from .base_model import BaseModel +from .db import db + + +class User(AuditDateTimeMixin, AuditUserMixin, BaseModel, db.Model): + """This class manages user information.""" + + id = db.Column(db.Integer, primary_key=True) + user_name = db.Column(db.String(50), nullable=False) + default_filter = db.Column( + db.Integer, db.ForeignKey("filter.id", ondelete="SET NULL"), nullable=True + ) + locale = db.Column(db.String(), nullable=True, comment="language code") + tenant = db.Column(db.String(), nullable=True, comment="tenant key") + __table_args__ = ( + UniqueConstraint("user_name", "tenant", name="uq_tenant_user_name"), + ) + + @classmethod + def create_user(cls, user_data: dict): + """Create new user.""" + assert user_data is not None + user = cls() + user.created_by = user_data.get("created_by") + user.user_name = user_data.get("user_name") + user.locale = user_data.get("locale") + user.tenant = user_data.get("tenant") + user.default_filter = user_data.get("default_filter") + user.save() + return user + + def update(self, user_data: dict): + """Update user data.""" + self.update_from_dict( + [ + "locale", + "tenant", + "default_filter", + ], + user_data, + ) + self.commit() + + @classmethod + @user_context + def tenant_authorization(cls, query: Query, **kwargs): + """Modifies the query to include tenant check if needed.""" + tenant_auth_query: Query = query + user: UserContext = kwargs["user"] + tenant_key: str = user.tenant_key + if not isinstance(query, Query): + raise TypeError("Query object must be of type Query") + if tenant_key is not None: + tenant_auth_query = tenant_auth_query.filter(cls.tenant == tenant_key) + return tenant_auth_query + + @classmethod + def get_user_by_user_name(cls, user_name: str = None): + """Find user data by username.""" + assert user_name is not None + query = cls.query.filter(cls.user_name == user_name) + query = cls.tenant_authorization(query) + return query.one_or_none() diff --git a/forms-flow-api/src/formsflow_api/resources/__init__.py b/forms-flow-api/src/formsflow_api/resources/__init__.py index 805669e82f..36a96bc6ce 100644 --- a/forms-flow-api/src/formsflow_api/resources/__init__.py +++ b/forms-flow-api/src/formsflow_api/resources/__init__.py @@ -25,10 +25,12 @@ from formsflow_api.resources.form_process_mapper import API as FORM_API from formsflow_api.resources.formio import API as FORMIO_API from formsflow_api.resources.groups import API as KEYCLOAK_GROUPS_API +from formsflow_api.resources.import_support import API as IMPORT_API from formsflow_api.resources.ipaas import API as INTEGRATION_API from formsflow_api.resources.metrics import API as APPLICATION_METRICS_API from formsflow_api.resources.process import API as PROCESS_API from formsflow_api.resources.roles import API as KEYCLOAK_ROLES_API +from formsflow_api.resources.theme import API as THEME_CUSTOMIZATION_API from formsflow_api.resources.user import API as KEYCLOAK_USER_API # This will add the Authorize button to the swagger docs @@ -61,3 +63,5 @@ API.add_namespace(KEYCLOAK_ROLES_API, path="/roles") API.add_namespace(FORM_EMBED_API, path="/embed") API.add_namespace(INTEGRATION_API, path="/integrations") +API.add_namespace(THEME_CUSTOMIZATION_API, path="/themes") +API.add_namespace(IMPORT_API, path="/import") diff --git a/forms-flow-api/src/formsflow_api/resources/application.py b/forms-flow-api/src/formsflow_api/resources/application.py index 014e16f03e..f991a317d4 100644 --- a/forms-flow-api/src/formsflow_api/resources/application.py +++ b/forms-flow-api/src/formsflow_api/resources/application.py @@ -5,8 +5,11 @@ from flask import request from flask_restx import Namespace, Resource, fields from formsflow_api_utils.utils import ( - DESIGNER_GROUP, - REVIEWER_GROUP, + CREATE_DESIGNS, + CREATE_SUBMISSIONS, + MANAGE_TASKS, + VIEW_SUBMISSIONS, + VIEW_TASKS, auth, cors_preflight, get_form_and_submission_id_from_form_url, @@ -98,7 +101,7 @@ class ApplicationsResource(Resource): """Resource for managing applications.""" @staticmethod - @auth.require + @auth.has_one_of_roles([VIEW_SUBMISSIONS, VIEW_TASKS, MANAGE_TASKS]) @profiletime @API.doc( params={ @@ -168,7 +171,7 @@ def get(): # pylint:disable=too-many-locals modified_from_date = dict_data.get("modified_from_date") modified_to_date = dict_data.get("modified_to_date") sort_order = dict_data.get("sort_order", "desc") - if auth.has_role([REVIEWER_GROUP]): + if auth.has_role([VIEW_TASKS, MANAGE_TASKS]): ( application_schema_dump, application_count, @@ -226,7 +229,7 @@ class ApplicationResourceById(Resource): """Resource for getting application by id.""" @staticmethod - @auth.require + @auth.has_one_of_roles([VIEW_SUBMISSIONS, VIEW_TASKS, MANAGE_TASKS]) @profiletime @API.response(200, "OK:- Successful request.", model=application_model) @API.response( @@ -239,7 +242,7 @@ class ApplicationResourceById(Resource): ) def get(application_id: int): """Get application by id.""" - if auth.has_role([REVIEWER_GROUP]): + if auth.has_role([VIEW_TASKS, MANAGE_TASKS]): ( application_schema_dump, status, @@ -328,7 +331,7 @@ def get(form_id: str): page_no = 0 limit = 0 - if auth.has_role(["formsflow-reviewer"]): + if auth.has_role([VIEW_TASKS]): application_schema = ApplicationService.get_all_applications_form_id( form_id=form_id, page_no=page_no, limit=limit ) @@ -376,7 +379,7 @@ class ApplicationResourceCountByFormId(Resource): """Resource for getting applications count on formid.""" @staticmethod - @auth.has_one_of_roles([DESIGNER_GROUP]) + @auth.has_one_of_roles([CREATE_DESIGNS]) @profiletime def get(form_id: str): """Get application count by formId.""" @@ -400,7 +403,7 @@ class ApplicationResourcesByIds(Resource): """Resource for application creation.""" @staticmethod - @auth.require + @auth.has_one_of_roles([CREATE_SUBMISSIONS]) @profiletime @API.doc(body=application_create_model) @API.response(201, "CREATED:- Successful request.", model=application_base_model) @@ -442,7 +445,7 @@ class ApplicationResourceByApplicationStatus(Resource): """Get application status list.""" @staticmethod - @auth.require + @auth.has_one_of_roles([VIEW_SUBMISSIONS]) @profiletime @API.response(200, "OK:- Successful request.", model=application_status_list_model) @API.response( @@ -467,7 +470,7 @@ class ApplicationResubmitById(Resource): """Resource for resubmit application.""" @staticmethod - @auth.require + @auth.has_one_of_roles([CREATE_SUBMISSIONS]) @profiletime @API.doc(body=application_resubmit_model) @API.response(200, "OK:- Successful request.") diff --git a/forms-flow-api/src/formsflow_api/resources/application_history.py b/forms-flow-api/src/formsflow_api/resources/application_history.py index df9df377b8..46fd1c6f79 100644 --- a/forms-flow-api/src/formsflow_api/resources/application_history.py +++ b/forms-flow-api/src/formsflow_api/resources/application_history.py @@ -4,7 +4,13 @@ from flask import request from flask_restx import Namespace, Resource, fields -from formsflow_api_utils.utils import auth, cors_preflight, profiletime +from formsflow_api_utils.utils import ( + VIEW_SUBMISSIONS, + VIEW_TASKS, + auth, + cors_preflight, + profiletime, +) from formsflow_api.schemas import ApplicationHistorySchema from formsflow_api.services import ApplicationHistoryService @@ -48,7 +54,7 @@ class ApplicationHistoryResource(Resource): """Resource for managing state.""" @staticmethod - @auth.require + @auth.has_one_of_roles([VIEW_SUBMISSIONS, VIEW_TASKS]) @profiletime @API.response(200, "OK:- Successful request.", model=application_history_list_model) @API.response( @@ -89,12 +95,11 @@ def post(application_id): """Post a new history entry using the request body.""" application_history_json = request.get_json() - # try: application_history_schema = ApplicationHistorySchema() dict_data = application_history_schema.load(application_history_json) dict_data["application_id"] = application_id application_history = ApplicationHistoryService.create_application_history( - data=dict_data + data=dict_data, application_id=application_id ) response, status = ( diff --git a/forms-flow-api/src/formsflow_api/resources/authorization.py b/forms-flow-api/src/formsflow_api/resources/authorization.py index 8bd38bd940..28c9d74298 100644 --- a/forms-flow-api/src/formsflow_api/resources/authorization.py +++ b/forms-flow-api/src/formsflow_api/resources/authorization.py @@ -6,14 +6,17 @@ from flask_restx import Namespace, Resource, fields from formsflow_api_utils.exceptions import BusinessException from formsflow_api_utils.utils import ( - DESIGNER_GROUP, + CREATE_DESIGNS, + CREATE_SUBMISSIONS, + VIEW_DASHBOARDS, + VIEW_DESIGNS, + VIEW_SUBMISSIONS, auth, cors_preflight, profiletime, ) from formsflow_api.constants import BusinessErrorCode -from formsflow_api.models import AuthType from formsflow_api.services import AuthorizationService API = Namespace("authorization", description="Authorization APIs") @@ -107,7 +110,7 @@ def post(auth_type: str): auth_service.create_authorization( auth_type.upper(), request.get_json(), - bool(auth.has_role([DESIGNER_GROUP])), + bool(auth.has_role([CREATE_DESIGNS])), ), HTTPStatus.OK, ) @@ -121,7 +124,7 @@ class UserAuthorizationList(Resource): @staticmethod @API.doc("list_authorization") - @auth.require + @auth.has_one_of_roles([VIEW_DASHBOARDS]) @profiletime @API.doc( responses={ @@ -152,7 +155,9 @@ class AuthorizationDetail(Resource): @staticmethod @API.doc("Authorization detail by Id") - @auth.require + @auth.has_one_of_roles( + [CREATE_DESIGNS, VIEW_DESIGNS, CREATE_SUBMISSIONS, VIEW_SUBMISSIONS] + ) @profiletime @API.doc( responses={ @@ -167,9 +172,18 @@ def get(auth_type: str, resource_id: str): Fetch Authorization details by resource id based on authorization type. """ - response = auth_service.get_resource_by_id( - auth_type.upper(), resource_id, bool(auth.has_role([DESIGNER_GROUP])) - ) + if auth_type.upper() == "APPLICATION": + response = auth_service.get_application_resource_by_id( + auth_type=auth_type.upper(), + resource_id=resource_id, + form_id=request.args.get("formId"), + ) + else: + response = auth_service.get_resource_by_id( + auth_type.upper(), + resource_id, + bool(auth.has_role([CREATE_DESIGNS])), + ) if response: return ( response, @@ -190,7 +204,7 @@ class AuthorizationListById(Resource): @staticmethod @API.doc("Authorization list by Id") - @auth.has_one_of_roles([DESIGNER_GROUP]) + @auth.has_one_of_roles([CREATE_DESIGNS, VIEW_DESIGNS]) @profiletime @API.doc( responses={ @@ -212,7 +226,7 @@ def get(resource_id: str): @staticmethod @API.doc("Authorization create by Id") - @auth.has_one_of_roles([DESIGNER_GROUP]) + @auth.has_one_of_roles([CREATE_DESIGNS]) @profiletime @API.doc( responses={ @@ -224,16 +238,9 @@ def get(resource_id: str): def post(resource_id: str): """Create or Update Authoization of Form by id.""" data = request.get_json() - for auth_type in AuthType: - if ( - data.get(auth_type.value.lower()) - and auth_type.value != AuthType.DASHBOARD.value - ): - auth_service.create_authorization( - auth_type.value.upper(), - data.get(auth_type.value.lower()), - bool(auth.has_role([DESIGNER_GROUP])), - ) + AuthorizationService.create_or_update_resource_authorization( + data, bool(auth.has_role([CREATE_DESIGNS])) + ) response = auth_service.get_auth_list_by_id(resource_id) if response: return ( diff --git a/forms-flow-api/src/formsflow_api/resources/dashboards.py b/forms-flow-api/src/formsflow_api/resources/dashboards.py index 3a42f6ee25..3b1deeaacd 100644 --- a/forms-flow-api/src/formsflow_api/resources/dashboards.py +++ b/forms-flow-api/src/formsflow_api/resources/dashboards.py @@ -4,7 +4,13 @@ from flask import request from flask_restx import Namespace, Resource, fields -from formsflow_api_utils.utils import auth, cors_preflight, profiletime +from formsflow_api_utils.utils import ( + MANAGE_DASHBOARD_AUTHORIZATIONS, + VIEW_DASHBOARDS, + auth, + cors_preflight, + profiletime, +) from formsflow_api.schemas import ApplicationListReqSchema from formsflow_api.services import AuthorizationService, RedashAPIService @@ -71,7 +77,7 @@ class DashboardList(Resource): """Resource to fetch Dashboard List.""" @staticmethod - @auth.require + @auth.has_one_of_roles([MANAGE_DASHBOARD_AUTHORIZATIONS]) @profiletime @API.doc( params={ @@ -127,7 +133,7 @@ class DashboardDetail(Resource): """Resource to fetch Dashboard Detail.""" @staticmethod - @auth.require + @auth.has_one_of_roles([VIEW_DASHBOARDS]) @profiletime @API.response(200, "OK:- Successful request.", model=dashboard_model) @API.response( diff --git a/forms-flow-api/src/formsflow_api/resources/draft.py b/forms-flow-api/src/formsflow_api/resources/draft.py index 28caa5b485..7c537228fa 100644 --- a/forms-flow-api/src/formsflow_api/resources/draft.py +++ b/forms-flow-api/src/formsflow_api/resources/draft.py @@ -5,6 +5,7 @@ from flask import request from flask_restx import Namespace, Resource, fields from formsflow_api_utils.utils import ( + CREATE_SUBMISSIONS, NEW_APPLICATION_STATUS, auth, cors_preflight, @@ -105,7 +106,7 @@ class DraftResource(Resource): """Resource for managing drafts.""" @staticmethod - @auth.require + @auth.has_one_of_roles([CREATE_SUBMISSIONS]) @profiletime @API.doc( params={ @@ -173,7 +174,7 @@ def get(): return (result, HTTPStatus.OK) @staticmethod - @auth.require + @auth.has_one_of_roles([CREATE_SUBMISSIONS]) @profiletime @API.doc(body=draft) @API.response(201, "CREATED:- Successful request.", model=draft_create_response) @@ -203,7 +204,7 @@ class DraftResourceById(Resource): """Resource for managing draft by id.""" @staticmethod - @auth.require + @auth.has_one_of_roles([CREATE_SUBMISSIONS]) @profiletime @API.response(200, "OK:- Successful request.", model=draft_response_by_id) @API.response( @@ -215,7 +216,7 @@ def get(draft_id: str): return DraftService.get_draft(draft_id), HTTPStatus.OK @staticmethod - @auth.require + @auth.has_one_of_roles([CREATE_SUBMISSIONS]) @profiletime @API.doc(body=draft) @API.response( @@ -238,7 +239,7 @@ def put(draft_id: int): ) @staticmethod - @auth.require + @auth.has_one_of_roles([CREATE_SUBMISSIONS]) @profiletime @API.response(200, "OK:- Successful request.", model=message) @API.response( @@ -257,7 +258,7 @@ class DraftSubmissionResource(Resource): """Converts the given draft entry to actual submission.""" @staticmethod - @auth.require + @auth.has_one_of_roles([CREATE_SUBMISSIONS]) @profiletime @API.doc(body=submission) @API.response(200, "OK:- Successful request.", model=submission_response) diff --git a/forms-flow-api/src/formsflow_api/resources/filter.py b/forms-flow-api/src/formsflow_api/resources/filter.py index e8312d075b..4b68c723eb 100644 --- a/forms-flow-api/src/formsflow_api/resources/filter.py +++ b/forms-flow-api/src/formsflow_api/resources/filter.py @@ -5,7 +5,9 @@ from flask import request from flask_restx import Namespace, Resource, fields from formsflow_api_utils.utils import ( - REVIEWER_GROUP, + CREATE_FILTERS, + MANAGE_ALL_FILTERS, + VIEW_FILTERS, auth, cors_preflight, profiletime, @@ -71,6 +73,14 @@ }, ) +filter_response_with_default_filter = API.model( + "FilterResponse", + { + "filters": fields.List(fields.Nested(filter_response)), + "defaultFilter": fields.String(description="Default filter"), + }, +) + @cors_preflight("GET, POST, OPTIONS") @API.route("", methods=["GET", "POST", "OPTIONS"]) @@ -78,7 +88,7 @@ class FilterResource(Resource): """Resource to create and list filter.""" @staticmethod - @auth.has_one_of_roles([REVIEWER_GROUP]) + @auth.has_one_of_roles([MANAGE_ALL_FILTERS, VIEW_FILTERS]) @profiletime @API.doc( responses={ @@ -97,7 +107,7 @@ def get(): return response, status @staticmethod - @auth.has_one_of_roles([REVIEWER_GROUP]) + @auth.has_one_of_roles([MANAGE_ALL_FILTERS, CREATE_FILTERS]) @profiletime @API.doc( responses={ @@ -151,14 +161,14 @@ class UsersFilterList(Resource): """Resource to list filters specific to current user.""" @staticmethod - @auth.has_one_of_roles([REVIEWER_GROUP]) + @auth.has_one_of_roles([MANAGE_ALL_FILTERS, VIEW_FILTERS]) @profiletime @API.doc( responses={ 200: "OK:- Successful request.", 403: "FORBIDDEN:- Permission denied", }, - model=[filter_response], + model=filter_response_with_default_filter, ) def get(): """ @@ -177,7 +187,7 @@ class FilterResourceById(Resource): """Resource for managing filter by id.""" @staticmethod - @auth.has_one_of_roles([REVIEWER_GROUP]) + @auth.has_one_of_roles([MANAGE_ALL_FILTERS]) @profiletime @API.doc( responses={ @@ -199,7 +209,7 @@ def get(filter_id: int): return response, status @staticmethod - @auth.has_one_of_roles([REVIEWER_GROUP]) + @auth.has_one_of_roles([MANAGE_ALL_FILTERS, CREATE_FILTERS]) @profiletime @API.doc( responses={ @@ -225,7 +235,7 @@ def put(filter_id: int): return response, status @staticmethod - @auth.has_one_of_roles([REVIEWER_GROUP]) + @auth.has_one_of_roles([MANAGE_ALL_FILTERS, CREATE_FILTERS]) @profiletime @API.doc( responses={ diff --git a/forms-flow-api/src/formsflow_api/resources/form_process_mapper.py b/forms-flow-api/src/formsflow_api/resources/form_process_mapper.py index 1cf6c0f8b6..af4496cf2b 100644 --- a/forms-flow-api/src/formsflow_api/resources/form_process_mapper.py +++ b/forms-flow-api/src/formsflow_api/resources/form_process_mapper.py @@ -6,9 +6,13 @@ from flask import request from flask_restx import Namespace, Resource, fields from formsflow_api_utils.exceptions import BusinessException -from formsflow_api_utils.services.external import FormioService from formsflow_api_utils.utils import ( - DESIGNER_GROUP, + CREATE_DESIGNS, + CREATE_FILTERS, + CREATE_SUBMISSIONS, + MANAGE_ALL_FILTERS, + VIEW_DESIGNS, + VIEW_FILTERS, auth, cors_preflight, profiletime, @@ -20,6 +24,8 @@ ) from formsflow_api.services import ( ApplicationService, + AuthorizationService, + FilterService, FormHistoryService, FormProcessMapperService, ) @@ -152,22 +158,90 @@ class NullableString(fields.String): form_history_response_model = API.inherit( "FormHistoryResponse", { - "id": fields.String(), - "form_id": fields.String(), - "created_by": fields.String(), - "created": fields.String(), - "change_log": fields.Nested(form_history_change_log_model), + "formHistory": fields.List( + fields.Nested( + API.model( + "FormHistory", + { + "id": fields.String(), + "formId": fields.String(), + "createdBy": fields.String(), + "created": fields.String(), + "changeLog": fields.Nested(form_history_change_log_model), + "majorVersion": fields.Integer(), + "minorVersion": fields.Integer(), + "isMajor": fields.Boolean(), + }, + ) + ) + ), + "totalCount": fields.Integer(), + }, +) +forms_list_model = API.model( + "Forms List Model", + {"formTitle": fields.String(), "type": fields.String(), "content": fields.Raw()}, +) +workflows_list_model = API.model( + "Workflows List", + { + "processKey": fields.String(), + "processName": fields.String(), + "type": fields.String(), + "content": fields.String(), }, ) +dmns_list_model = API.model( + "DMN List", + {"key": fields.String(), "type": fields.String(), "content": fields.String()}, +) +resource_details_model = API.model("resource_details", {"name": fields.String()}) +authorization_model = API.model( + "Authorization", + { + "resourceId": fields.String(), + "resourceDetails": fields.Nested(resource_details_model), + "roles": fields.List(fields.String), + "userName": fields.String(), + }, +) + +authorization_list_model = API.model( + "Authorization List", + { + "APPLICATION": fields.Nested(authorization_model), + "FORM": fields.Nested(authorization_model), + "DESIGNER": fields.Nested(authorization_model), + }, +) +export_response_model = API.model( + "ExportResponse", + { + "forms": fields.List(fields.Nested(forms_list_model)), + "workflows": fields.List(fields.Nested(workflows_list_model)), + "rules": fields.List(fields.Nested(dmns_list_model)), + "authorizations": fields.List(fields.Nested(authorization_list_model)), + }, +) -@cors_preflight("GET,POST,OPTIONS") -@API.route("", methods=["GET", "POST", "OPTIONS"]) + +@cors_preflight("GET,OPTIONS") +@API.route("", methods=["GET", "OPTIONS"]) class FormResourceList(Resource): """Resource for getting forms.""" @staticmethod - @auth.require + @auth.has_one_of_roles( + [ + CREATE_DESIGNS, + VIEW_DESIGNS, + CREATE_SUBMISSIONS, + CREATE_FILTERS, + VIEW_FILTERS, + MANAGE_ALL_FILTERS, + ] + ) @profiletime @API.doc( params={ @@ -191,9 +265,9 @@ class FormResourceList(Resource): "description": "Specify sorting order.", "default": "desc", }, - "formName": { + "search": { "in": "query", - "description": "Retrieve form list based on form name.", + "description": "Retrieve form list based on form name or description.", "default": "", }, } @@ -210,19 +284,27 @@ class FormResourceList(Resource): def get(): # pylint: disable=too-many-locals """Get form process mapper.""" dict_data = FormProcessMapperListRequestSchema().load(request.args) or {} - form_name: str = dict_data.get("form_name") + search: str = dict_data.get("search", "") page_no: int = dict_data.get("page_no") limit: int = dict_data.get("limit") - sort_by: str = dict_data.get("sort_by", "id") - sort_order: str = dict_data.get("sort_order", "desc") + sort_by: str = dict_data.get("sort_by", "") + sort_order: str = dict_data.get("sort_order", "") form_type: str = dict_data.get("form_type", None) is_active = dict_data.get("is_active", None) active_forms = dict_data.get("active_forms", None) - + # when ignore_designer true, exclude designer priorities like + # listing both active and inactive forms or listing forms created by the designer. + ignore_designer = dict_data.get("ignore_designer", False) + is_designer = ( + auth.has_any_role([CREATE_DESIGNS, VIEW_DESIGNS]) and not ignore_designer + ) + sort_by = sort_by.split(",") + sort_order = sort_order.split(",") if form_type: form_type = form_type.split(",") - if form_name: - form_name: str = form_name.replace("%", r"\%").replace("_", r"\_") + if search: + search = search.replace("%", r"\%").replace("_", r"\_") + search = [key for key in search.split(" ") if key.strip()] ( form_process_mapper_schema, @@ -230,12 +312,12 @@ def get(): # pylint: disable=too-many-locals ) = FormProcessMapperService.get_all_forms( page_number=page_no, limit=limit, - form_name=form_name, + search=search if search else [], sort_by=sort_by, sort_order=sort_order, form_type=form_type, is_active=is_active, - is_designer=auth.has_role([DESIGNER_GROUP]), + is_designer=is_designer, active_forms=active_forms, ) return ( @@ -250,37 +332,6 @@ def get(): # pylint: disable=too-many-locals HTTPStatus.OK, ) - @staticmethod - @auth.require - @profiletime - @API.doc(body=mapper_create_model) - @API.response( - 200, "CREATED:- Successful request.", model=mapper_create_response_model - ) - @API.response( - 400, - "BAD_REQUEST:- Invalid request.", - ) - @API.response( - 401, - "UNAUTHORIZED:- Authorization header not provided or an invalid token passed.", - ) - def post(): - """Post a form process mapper using the request body.""" - mapper_json = request.get_json() - mapper_json["taskVariable"] = json.dumps(mapper_json.get("taskVariable") or []) - mapper_schema = FormProcessMapperSchema() - dict_data = mapper_schema.load(mapper_json) - mapper = FormProcessMapperService.create_mapper(dict_data) - - FormProcessMapperService.unpublish_previous_mapper(dict_data) - - response = mapper_schema.dump(mapper) - response["taskVariable"] = json.loads(response["taskVariable"]) - - FormHistoryService.create_form_logs_without_clone(data=mapper_json) - return response, HTTPStatus.CREATED - @cors_preflight("GET,PUT,DELETE,OPTIONS") @API.route("/", methods=["GET", "PUT", "DELETE", "OPTIONS"]) @@ -288,7 +339,7 @@ class FormResourceById(Resource): """Resource for managing forms by mapper_id.""" @staticmethod - @auth.require + @auth.has_one_of_roles([CREATE_DESIGNS]) @profiletime @API.response(200, "OK:- Successful request.", model=mapper_create_response_model) @API.response( @@ -311,7 +362,7 @@ def get(mapper_id: int): ) @staticmethod - @auth.require + @auth.has_one_of_roles([CREATE_DESIGNS]) @profiletime @API.response(200, "OK:- Successful request.") @API.response( @@ -334,7 +385,7 @@ def delete(mapper_id: int): return "Deleted", HTTPStatus.OK @staticmethod - @auth.require + @auth.has_one_of_roles([CREATE_DESIGNS]) @API.doc(body=mapper_update_model) @API.response( 200, "CREATED:- Successful request.", model=mapper_create_response_model @@ -353,21 +404,59 @@ def delete(mapper_id: int): ) def put(mapper_id: int): """Update form by mapper_id.""" - application_json = request.get_json() + data = request.get_json() + + # Extract mapper and authorization data from the request + mapper_data = data.get("mapper") + authorization_data = data.get("authorizations") + + # Get the parentFormId as resource id from mapper data if authorization data is provided + resource_id = mapper_data.get("parentFormId") if authorization_data else None + task_variable = mapper_data.get("taskVariables", []) - if "taskVariable" in application_json: - application_json["taskVariable"] = json.dumps( - application_json.get("taskVariable") + # If task variables are present, update filter variables and serialize them + if "taskVariables" in mapper_data: + FilterService.update_filter_variables( + task_variable, mapper_data.get("formId") ) + mapper_data["taskVariables"] = json.dumps(task_variable) + + # Load the mapper data into the schema mapper_schema = FormProcessMapperSchema() - dict_data = mapper_schema.load(application_json) + dict_data = mapper_schema.load(mapper_data) + + # Update the mapper with the provided data mapper = FormProcessMapperService.update_mapper( form_process_mapper_id=mapper_id, data=dict_data ) - response = mapper_schema.dump(mapper) - response["taskVariable"] = json.loads(response["taskVariable"]) - FormHistoryService.create_form_logs_without_clone(data=application_json) + # If authorization data and resource ID are provided, update resource authorization + if authorization_data and resource_id: + AuthorizationService.create_or_update_resource_authorization( + authorization_data, bool(auth.has_role([CREATE_DESIGNS])) + ) + + # Dump the updated mapper data into the response schema + mapper_response = mapper_schema.dump(mapper) + + if task_variables := mapper_response.get("taskVariables"): + mapper_response["taskVariables"] = json.loads(task_variables) + + # Create form logs without cloning + FormHistoryService.create_form_logs_without_clone(data=mapper_data) + + # Prepare the response + response = {} + major_version, minor_version = FormProcessMapperService.get_form_version(mapper) + mapper_response["majorVersion"] = major_version + mapper_response["minorVersion"] = minor_version + response["mapper"] = mapper_response + if resource_id: + response["authorizations"] = AuthorizationService().get_auth_list_by_id( + resource_id + ) + + # Return the response with HTTP status OK return ( response, HTTPStatus.OK, @@ -380,7 +469,16 @@ class FormResourceByFormId(Resource): """Resource for managing forms by corresponding form_id.""" @staticmethod - @auth.require + @auth.has_one_of_roles( + [ + CREATE_DESIGNS, + VIEW_DESIGNS, + CREATE_SUBMISSIONS, + CREATE_FILTERS, + VIEW_FILTERS, + MANAGE_ALL_FILTERS, + ] + ) @profiletime @API.response( 200, "CREATED:- Successful request.", model=mapper_create_response_model @@ -404,8 +502,8 @@ def get(form_id: str): : form_id:- Get details of only form corresponding to a particular formId """ response = FormProcessMapperService.get_mapper_by_formid(form_id=form_id) - task_variable = response.get("taskVariable") - response["taskVariable"] = json.loads(task_variable) if task_variable else None + task_variable = response.get("taskVariables") + response["taskVariables"] = json.loads(task_variable) if task_variable else None return ( response, HTTPStatus.OK, @@ -418,7 +516,13 @@ class FormResourceApplicationCount(Resource): """Resource for getting applications count according to a mapper id.""" @staticmethod - @auth.require + @auth.has_one_of_roles( + [ + CREATE_DESIGNS, + VIEW_DESIGNS, + CREATE_SUBMISSIONS, + ] + ) @profiletime @API.response(200, "OK:- Successful request.", model=application_count_model) @API.response( @@ -480,7 +584,7 @@ class FormioFormResource(Resource): """Resource for formio form creation.""" @staticmethod - @auth.has_one_of_roles([DESIGNER_GROUP]) + @auth.has_one_of_roles([CREATE_DESIGNS]) @profiletime @API.doc(body=form_create_model) @API.response( @@ -501,22 +605,13 @@ class FormioFormResource(Resource): def post(): """Formio form creation method.""" try: + # form data data = request.get_json() - formio_service = FormioService() - form_io_token = formio_service.get_formio_access_token() - response, status = ( - formio_service.create_form(data, form_io_token), - HTTPStatus.CREATED, - ) - FormHistoryService.create_form_log_with_clone( - data={ - **response, - "parentFormId": data.get("parentFormId"), - "newVersion": data.get("newVersion"), - "componentChanged": True, - } + response = FormProcessMapperService.create_form( + data, bool(auth.has_role([CREATE_DESIGNS])) ) - return response, status + return response, HTTPStatus.CREATED + except BusinessException as err: message = ( err.details[0]["message"] @@ -532,23 +627,14 @@ class FormioFormUpdateResource(Resource): """Resource for formio form Update.""" @staticmethod - @auth.has_one_of_roles([DESIGNER_GROUP]) + @auth.has_one_of_roles([CREATE_DESIGNS]) @profiletime def put(form_id: str): """Formio form update method.""" try: - FormProcessMapperService.check_tenant_authorization_by_formid( - form_id=form_id - ) data = request.get_json() - formio_service = FormioService() - form_io_token = formio_service.get_formio_access_token() - response, status = ( - formio_service.update_form(form_id, data, form_io_token), - HTTPStatus.OK, - ) - FormHistoryService.create_form_log_with_clone(data=data) - return response, status + response = FormProcessMapperService.form_design_update(data, form_id) + return response, HTTPStatus.OK except BusinessException as err: message = ( err.details[0]["message"] @@ -564,7 +650,7 @@ class FormHistoryResource(Resource): """Resource for form history.""" @staticmethod - @auth.has_one_of_roles([DESIGNER_GROUP]) + @auth.has_one_of_roles([CREATE_DESIGNS]) @profiletime @API.doc(body=form_create_model) @API.response(200, "OK:- Successful request.", model=form_history_response_model) @@ -583,4 +669,128 @@ class FormHistoryResource(Resource): def get(form_id: str): """Getting form history.""" FormProcessMapperService.check_tenant_authorization_by_formid(form_id=form_id) - return FormHistoryService.get_all_history(form_id) + form_history, count = FormHistoryService.get_all_history(form_id, request.args) + return ( + ( + { + "formHistory": form_history, + "totalCount": count, + } + ), + HTTPStatus.OK, + ) + + +@cors_preflight("GET,OPTIONS") +@API.route("//export", methods=["GET", "OPTIONS"]) +class ExportById(Resource): + """Resource to support export by mapper_id.""" + + @staticmethod + @auth.has_one_of_roles([CREATE_DESIGNS]) + @profiletime + @API.response(200, "OK:- Successful request.", model=export_response_model) + @API.response( + 400, + "BAD_REQUEST:- Invalid request.", + ) + @API.response( + 401, + "UNAUTHORIZED:- Authorization header not provided or an invalid token passed.", + ) + @API.response( + 403, + "FORBIDDEN:- Authorization will not help.", + ) + def get(mapper_id: int): + """Export by mapper_id.""" + form_service = FormProcessMapperService() + return ( + form_service.export(mapper_id), + HTTPStatus.OK, + ) + + +@cors_preflight("GET,OPTIONS") +@API.route("/validate", methods=["GET", "OPTIONS"]) +class ValidateFormName(Resource): + """Resource for validating a form name.""" + + @staticmethod + @auth.has_one_of_roles([CREATE_DESIGNS]) + @profiletime + @API.response(200, "OK:- Successful request.") + @API.response(400, "BAD_REQUEST:- Invalid request.") + @API.response( + 401, + "UNAUTHORIZED:- Authorization header not provided or an invalid token passed.", + ) + @API.response(403, "FORBIDDEN:- Authorization will not help.") + def get(): + """Handle GET requests for validating form names. + + Retrieves the query parameters from the request, validates the form name, + and returns a response indicating whether the form name is valid or not. + """ + response = FormProcessMapperService.validate_form_name_path_title(request) + return response, HTTPStatus.OK + + +@cors_preflight("POST,OPTIONS") +@API.route("//publish", methods=["POST", "OPTIONS"]) +class PublishResource(Resource): + """Resource to support publish.""" + + @staticmethod + @auth.has_one_of_roles([CREATE_DESIGNS]) + @profiletime + @API.response(200, "OK:- Successful request.") + @API.response( + 400, + "BAD_REQUEST:- Invalid request.", + ) + @API.response( + 401, + "UNAUTHORIZED:- Authorization header not provided or an invalid token passed.", + ) + @API.response( + 403, + "FORBIDDEN:- Authorization will not help.", + ) + def post(mapper_id: int): + """Publish by mapper_id.""" + form_service = FormProcessMapperService() + return ( + form_service.publish(mapper_id), + HTTPStatus.OK, + ) + + +@cors_preflight("POST,OPTIONS") +@API.route("//unpublish", methods=["POST", "OPTIONS"]) +class UnpublishResource(Resource): + """Resource to support unpublish.""" + + @staticmethod + @auth.has_one_of_roles([CREATE_DESIGNS]) + @profiletime + @API.response(200, "OK:- Successful request.") + @API.response( + 400, + "BAD_REQUEST:- Invalid request.", + ) + @API.response( + 401, + "UNAUTHORIZED:- Authorization header not provided or an invalid token passed.", + ) + @API.response( + 403, + "FORBIDDEN:- Authorization will not help.", + ) + def post(mapper_id: int): + """Unpublish by mapper_id.""" + form_service = FormProcessMapperService() + return ( + form_service.unpublish(mapper_id), + HTTPStatus.OK, + ) diff --git a/forms-flow-api/src/formsflow_api/resources/formio.py b/forms-flow-api/src/formsflow_api/resources/formio.py index f241f479c2..989848416c 100644 --- a/forms-flow-api/src/formsflow_api/resources/formio.py +++ b/forms-flow-api/src/formsflow_api/resources/formio.py @@ -9,9 +9,12 @@ from flask_restx import Namespace, Resource, fields from formsflow_api_utils.exceptions import BusinessException, ExternalError from formsflow_api_utils.utils import ( - CLIENT_GROUP, - DESIGNER_GROUP, - REVIEWER_GROUP, + CREATE_DESIGNS, + CREATE_SUBMISSIONS, + MANAGE_TASKS, + VIEW_DESIGNS, + VIEW_SUBMISSIONS, + VIEW_TASKS, Cache, auth, cors_preflight, @@ -86,11 +89,22 @@ def get(**kwargs): def filter_user_based_role_ids(item): filter_list = [] - if DESIGNER_GROUP in user.roles: + if any( + permission in user.roles + for permission in [CREATE_DESIGNS, VIEW_DESIGNS] + ): filter_list.append(FormioRoles.DESIGNER.name) - if REVIEWER_GROUP in user.roles: + if any( + permission in user_role for permission in [MANAGE_TASKS, VIEW_TASKS] + ): filter_list.append(FormioRoles.REVIEWER.name) - if CLIENT_GROUP in user.roles: + if any( + permission in user_role + for permission in [ + CREATE_SUBMISSIONS, + VIEW_SUBMISSIONS, + ] + ): filter_list.append(FormioRoles.CLIENT.name) return item["type"] in filter_list @@ -117,10 +131,15 @@ def add_jwt_token_as_header(response): user.email or f"{user.user_name}@formsflow.ai" ) # Email is not mandatory in keycloak project_id: str = current_app.config.get("FORMIO_PROJECT_URL") + groups = user.groups payload: Dict[str, any] = { "external": True, "form": {"_id": _resource_id}, - "user": {"_id": unique_user_id, "roles": _role_ids, "customRoles": user.roles}, + "user": { + "_id": unique_user_id, + "roles": _role_ids, + "customRoles": groups, + }, } if project_id: payload["project"] = {"_id": project_id} @@ -130,7 +149,10 @@ def add_jwt_token_as_header(response): algorithm="HS256", ) response.headers["Access-Control-Expose-Headers"] = "x-jwt-token" - if DESIGNER_GROUP not in user.roles: + if all( + permission not in user_role + for permission in [CREATE_DESIGNS, VIEW_DESIGNS] + ): response.set_data(json.dumps({"form": []})) return response return response diff --git a/forms-flow-api/src/formsflow_api/resources/import_support.py b/forms-flow-api/src/formsflow_api/resources/import_support.py new file mode 100644 index 0000000000..3d77df103d --- /dev/null +++ b/forms-flow-api/src/formsflow_api/resources/import_support.py @@ -0,0 +1,46 @@ +"""API endpoints for managing import.""" + +from http import HTTPStatus + +from flask import request +from flask_restx import Namespace, Resource +from formsflow_api_utils.utils import ( + CREATE_DESIGNS, + auth, + cors_preflight, + profiletime, +) + +from formsflow_api.services import ImportService + +API = Namespace("Import", description="Import") + + +@cors_preflight("POST,OPTIONS") +@API.route("", methods=["POST", "OPTIONS"]) +class Import(Resource): + """Resource to support import.""" + + @staticmethod + @auth.has_one_of_roles([CREATE_DESIGNS]) + @profiletime + @API.response(200, "OK:- Successful request.") + @API.response( + 400, + "BAD_REQUEST:- Invalid request.", + ) + @API.response( + 401, + "UNAUTHORIZED:- Authorization header not provided or an invalid token passed.", + ) + @API.response( + 403, + "FORBIDDEN:- Authorization will not help.", + ) + def post(): + """Import.""" + import_service = ImportService() + return ( + import_service.import_form_workflow(request), + HTTPStatus.OK, + ) diff --git a/forms-flow-api/src/formsflow_api/resources/metrics.py b/forms-flow-api/src/formsflow_api/resources/metrics.py index fd567cb53e..4fc36239d6 100644 --- a/forms-flow-api/src/formsflow_api/resources/metrics.py +++ b/forms-flow-api/src/formsflow_api/resources/metrics.py @@ -4,7 +4,12 @@ from flask import request from flask_restx import Namespace, Resource, fields -from formsflow_api_utils.utils import auth, cors_preflight, profiletime +from formsflow_api_utils.utils import ( + VIEW_DASHBOARDS, + auth, + cors_preflight, + profiletime, +) from formsflow_api.schemas.aggregated_application import ( ApplicationMetricsRequestSchema, @@ -66,7 +71,7 @@ class AggregatedApplicationsResource(Resource): """Resource for managing aggregated applications.""" @staticmethod - @auth.require + @auth.has_one_of_roles([VIEW_DASHBOARDS]) @profiletime @API.doc( params={ @@ -141,7 +146,7 @@ class AggregatedApplicationStatusResource(Resource): """Resource for managing aggregated applications.""" @staticmethod - @auth.require + @auth.has_one_of_roles([VIEW_DASHBOARDS]) @profiletime @API.doc( params={ diff --git a/forms-flow-api/src/formsflow_api/resources/process.py b/forms-flow-api/src/formsflow_api/resources/process.py index 8eb652db52..f9c3e14cc5 100644 --- a/forms-flow-api/src/formsflow_api/resources/process.py +++ b/forms-flow-api/src/formsflow_api/resources/process.py @@ -4,57 +4,471 @@ from flask import request from flask_restx import Namespace, Resource, fields -from formsflow_api_utils.utils import auth, cors_preflight, profiletime +from formsflow_api_utils.utils import ( + CREATE_DESIGNS, + MANAGE_DECISION_TABLES, + MANAGE_SUBFLOWS, + VIEW_DESIGNS, + VIEW_SUBMISSIONS, + VIEW_TASKS, + auth, + cors_preflight, + profiletime, +) +from formsflow_api.schemas import ProcessDataSchema from formsflow_api.services import ProcessService API = Namespace("Process", description="Process") +process_request = API.model( + "ProcessRequest", + { + "name": fields.String(description="Process name"), + "status": fields.String(description="Process status"), + "processType": fields.String(description="Process Type"), + "processData": fields.String(description="Process data"), + }, +) + +process_history_response_model = API.model( + "ProcessHistoryResponse", + { + "processHistory": fields.List( + fields.Nested( + API.model( + "ProcessHistory", + { + "id": fields.Integer(description="Unique id of the process"), + "created": fields.DateTime(description="Created time"), + "createdBy": fields.String(), + "processType": fields.String(description="Process Type"), + "processName": fields.String(), + "majorVersion": fields.Integer(), + "minorVersion": fields.Integer(), + "isMajor": fields.Boolean(), + }, + ) + ) + ), + "totalCount": fields.Integer(), + }, +) + +process_response = API.inherit( + "ProcessResponse", + process_request, + { + "tenant": fields.String(description="Authorized Tenant to the process"), + "id": fields.Integer(description="Unique id of the process"), + "created": fields.DateTime(description="Created time"), + "modified": fields.DateTime(description="Modified time"), + "createdBy": fields.String(), + "modifiedBy": fields.String(), + }, +) + process_list_model = API.model( "ProcessList", { "process": fields.List( fields.Nested( API.model( - "Process data", + "Process", { - "name": fields.String(), - "key": fields.String(), - "tenantKey": fields.String(), + "id": fields.Integer(description="Unique id of the process"), + "name": fields.String(description="Process name"), + "status": fields.String(description="Process status"), + "processType": fields.String(description="Process Type"), + "processData": fields.String(description="Process data"), + "tenant": fields.String( + description="Authorized Tenant to the process" + ), + "created": fields.DateTime(description="Created time"), + "modified": fields.DateTime(description="Modified time"), + "createdBy": fields.String(), + "modifiedBy": fields.String(), }, ) ) - ) + ), + "totalCount": fields.Integer(), }, ) +@cors_preflight("GET, POST, OPTIONS") +@API.route("", methods=["GET", "POST", "OPTIONS"]) +class ProcessDataResource(Resource): + """Resource to create and list process data.""" + + @staticmethod + @auth.has_one_of_roles([MANAGE_SUBFLOWS, MANAGE_DECISION_TABLES]) + @profiletime + @API.doc( + params={ + "pageNo": { + "in": "query", + "description": "Page number for paginated results", + "default": "1", + }, + "limit": { + "in": "query", + "description": "Limit for paginated results", + "default": "5", + }, + "sortBy": { + "in": "query", + "description": "Name of column to sort by.", + "default": "id", + }, + "sortOrder": { + "in": "query", + "description": "Specify sorting order.", + "default": "desc", + }, + "name": { + "in": "query", + "description": "Retrieve form list based on process name.", + "default": "", + }, + "processType": { + "in": "query", + "description": "Retrieve form list based on process type.", + "default": "", + }, + "status": { + "in": "query", + "description": "Retrieve form list based on status.", + "default": "", + }, + "id": { + "in": "query", + "description": "Filter process by id.", + "type": "int", + }, + "modifiedFrom": { + "in": "query", + "description": "Filter process by modified from.", + "type": "string", + }, + "modifiedTo": { + "in": "query", + "description": "Filter process by modified to.", + "type": "string", + }, + "createdFrom": { + "in": "query", + "description": "Filter process by created from.", + "type": "string", + }, + "createdTo": { + "in": "query", + "description": "Filter process by created to.", + "type": "string", + }, + "createdBy": { + "in": "query", + "description": "Filter process by created by.", + "type": "string", + }, + }, + responses={ + 200: "OK:- Successful request.", + 403: "FORBIDDEN:- Permission denied", + }, + model=process_list_model, + ) + def get(): + """List all process data.""" + process_list, count = ProcessService.get_all_process( + request.args, + auth.has_role([MANAGE_SUBFLOWS]), + auth.has_role([MANAGE_DECISION_TABLES]), + ) + response = { + "process": process_list, + "totalCount": count, + } + return response, HTTPStatus.OK + + @staticmethod + @auth.has_one_of_roles([CREATE_DESIGNS, MANAGE_SUBFLOWS, MANAGE_DECISION_TABLES]) + @profiletime + @API.doc( + responses={ + 201: "CREATED:- Successful request.", + 400: "BAD_REQUEST:- Invalid request.", + 401: "UNAUTHORIZED:- Authorization header not provided or an invalid token passed.", + }, + model=process_response, + ) + def post(): + """Create process data.""" + data = request.get_json() + process_data = data.get("processData") + process_type = data.get("processType") + response = ProcessService.create_process( + process_data=process_data, process_type=process_type, is_subflow=True + ) + response_data = ProcessDataSchema().dump(response) + return response_data, HTTPStatus.CREATED + + +@cors_preflight("GET, PUT, DELETE, OPTIONS") +@API.route("/", methods=["GET", "PUT", "DELETE", "OPTIONS"]) +@API.doc(params={"process_id": "Process data corresponding to process id"}) +class ProcessResourceById(Resource): + """Resource for managing process by id.""" + + @staticmethod + @auth.has_one_of_roles([CREATE_DESIGNS, MANAGE_SUBFLOWS, MANAGE_DECISION_TABLES]) + @profiletime + @API.doc( + responses={ + 200: "OK:- Successful request.", + 400: "BAD_REQUEST:- Invalid request.", + 403: "FORBIDDEN:- Permission denied", + }, + model=process_response, + ) + def get(process_id: int): + """Get process data by process id.""" + response, status = ProcessService.get_process_by_id(process_id), HTTPStatus.OK + + return response, status + + @staticmethod + @auth.has_one_of_roles([CREATE_DESIGNS, MANAGE_SUBFLOWS, MANAGE_DECISION_TABLES]) + @profiletime + @API.doc( + responses={ + 200: "OK:- Successful request.", + 400: "BAD_REQUEST:- Invalid request.", + 403: "FORBIDDEN:- Permission denied", + }, + model=process_response, + ) + @API.expect(process_request) + def put(process_id: int): + """Update process data by id.""" + data = request.get_json() + process_data = data.get("processData") + process_type = data.get("processType") + response, status = ( + ProcessService.update_process( + process_id=process_id, + process_type=process_type, + process_data=process_data, + ), + HTTPStatus.OK, + ) + return response, status + + @staticmethod + @auth.has_one_of_roles([CREATE_DESIGNS]) + @profiletime + @API.doc( + responses={ + 200: "OK:- Successful request.", + 400: "BAD_REQUEST:- Invalid request.", + 403: "FORBIDDEN:- Permission denied", + } + ) + def delete(process_id: int): + """Delete process data by id.""" + response, status = ProcessService.delete_process(process_id), HTTPStatus.OK + return response, status + + +@cors_preflight("GET, OPTIONS") +@API.route("//versions", methods=["GET", "OPTIONS"]) +class ProcessHistoryResource(Resource): + """Resource for retrieving process history.""" + + @staticmethod + @auth.has_one_of_roles([CREATE_DESIGNS, MANAGE_SUBFLOWS, MANAGE_DECISION_TABLES]) + @profiletime + @API.doc( + params={ + "process_name": { + "description": "Unique name of the process", + "type": "string", + }, + "pageNo": { + "in": "query", + "description": "Page number for paginated results", + }, + "limit": {"in": "query", "description": "Limit for paginated results"}, + }, + responses={ + 200: "OK:- Successful request.", + 400: "BAD_REQUEST:- Invalid request.", + 401: "UNAUTHORIZED:- Authorization header not provided or an invalid token passed.", + 403: "FORBIDDEN:- Permission denied.", + }, + model=process_history_response_model, + ) + def get(parent_process_key: str): + """Get history for a process by process_name.""" + # Retrieve all history related to the specified process + + process_history, count = ProcessService.get_all_history( + parent_process_key, request.args + ) + return ( + ( + { + "processHistory": process_history, + "totalCount": count, + } + ), + HTTPStatus.OK, + ) + + @cors_preflight("GET,OPTIONS") -@API.route("", methods=["GET", "OPTIONS"]) -class ProcessResource(Resource): - """Resource for managing process.""" +@API.route("/validate", methods=["GET", "OPTIONS"]) +class ValidateProcess(Resource): + """Resource for validating a process name or key.""" @staticmethod - @auth.require + @auth.has_one_of_roles([CREATE_DESIGNS, MANAGE_SUBFLOWS, MANAGE_DECISION_TABLES]) @profiletime - @API.response(200, "OK:- Successful request.", model=process_list_model) + @API.response(200, "OK:- Successful request.") + @API.response(400, "BAD_REQUEST:- Invalid request.") @API.response( 401, "UNAUTHORIZED:- Authorization header not provided or an invalid token passed.", ) + @API.response(403, "FORBIDDEN:- Authorization will not help.") + def get(): + """Handle GET requests for validating process name/key. + + Retrieves the query parameters from the request, validates the process name or key, + and returns a response indicating whether the process name/key is valid or not. + """ + response = ProcessService.validate_process(request) + return response, HTTPStatus.OK + + +@cors_preflight("POST,OPTIONS") +@API.route("//publish", methods=["POST", "OPTIONS"]) +class PublishResource(Resource): + """Resource to support publish sub-process/worklfow.""" + + @staticmethod + @auth.has_one_of_roles([CREATE_DESIGNS, MANAGE_SUBFLOWS, MANAGE_DECISION_TABLES]) + @profiletime + @API.response(200, "OK:- Successful request.") @API.response( 400, "BAD_REQUEST:- Invalid request.", ) - def get(): - """Get all process.""" + @API.response( + 401, + "UNAUTHORIZED:- Authorization header not provided or an invalid token passed.", + ) + @API.response( + 403, + "FORBIDDEN:- Authorization will not help.", + ) + def post(process_id: int): + """Publish by process id.""" return ( - ( - { - "process": ProcessService.get_all_processes( - token=request.headers["Authorization"] - ) - } - ), + ProcessService.publish(process_id), + HTTPStatus.OK, + ) + + +@cors_preflight("POST,OPTIONS") +@API.route("//unpublish", methods=["POST", "OPTIONS"]) +class UnpublishResource(Resource): + """Resource to support unpublish sub-process/workflow.""" + + @staticmethod + @auth.has_one_of_roles([CREATE_DESIGNS, MANAGE_SUBFLOWS, MANAGE_DECISION_TABLES]) + @profiletime + @API.response(200, "OK:- Successful request.") + @API.response( + 400, + "BAD_REQUEST:- Invalid request.", + ) + @API.response( + 401, + "UNAUTHORIZED:- Authorization header not provided or an invalid token passed.", + ) + @API.response( + 403, + "FORBIDDEN:- Authorization will not help.", + ) + def post(process_id: int): + """Unpublish by process_id.""" + return ( + ProcessService.unpublish(process_id), + HTTPStatus.OK, + ) + + +@cors_preflight("GET, OPTIONS") +@API.route("/key/", methods=["GET", "OPTIONS"]) +@API.doc(params={"process_key": "Process data corresponding to process key"}) +class ProcessResourceByProcessKey(Resource): + """Resource for managing process by process key.""" + + @staticmethod + @auth.has_one_of_roles( + [ + CREATE_DESIGNS, + VIEW_DESIGNS, + VIEW_SUBMISSIONS, + VIEW_TASKS, + MANAGE_SUBFLOWS, + MANAGE_DECISION_TABLES, + ] + ) + @profiletime + @API.doc( + responses={ + 200: "OK:- Successful request.", + 400: "BAD_REQUEST:- Invalid request.", + 403: "FORBIDDEN:- Permission denied", + }, + model=process_response, + ) + def get(process_key: str): + """Get process data by process key.""" + response, status = ( + ProcessService.get_process_by_key(process_key, request), + HTTPStatus.OK, + ) + return response, status + + +@cors_preflight("POST,OPTIONS") +@API.route("/migrate", methods=["POST", "OPTIONS"]) +class MigrateResource(Resource): + """Resource to support migration.""" + + @staticmethod + @auth.has_one_of_roles([CREATE_DESIGNS]) + @profiletime + @API.response(200, "OK:- Successful request.") + @API.response( + 400, + "BAD_REQUEST:- Invalid request.", + ) + @API.response( + 401, + "UNAUTHORIZED:- Authorization header not provided or an invalid token passed.", + ) + @API.response( + 403, + "FORBIDDEN:- Authorization will not help.", + ) + def post(): + """Migrate by process_key.""" + return ( + ProcessService.migrate(request), HTTPStatus.OK, ) diff --git a/forms-flow-api/src/formsflow_api/resources/roles.py b/forms-flow-api/src/formsflow_api/resources/roles.py index 4c76eb8124..9f47f6f095 100644 --- a/forms-flow-api/src/formsflow_api/resources/roles.py +++ b/forms-flow-api/src/formsflow_api/resources/roles.py @@ -5,9 +5,13 @@ from flask import request from flask_restx import Namespace, Resource, fields from formsflow_api_utils.utils import ( - ADMIN_GROUP, - DESIGNER_GROUP, - REVIEWER_GROUP, + ADMIN, + CREATE_DESIGNS, + CREATE_FILTERS, + MANAGE_ALL_FILTERS, + PERMISSION_DETAILS, + VIEW_DESIGNS, + VIEW_FILTERS, auth, cors_preflight, profiletime, @@ -37,7 +41,16 @@ class KeycloakRolesResource(Resource): """Resource to manage keycloak list and create roles/groups.""" @staticmethod - @auth.has_one_of_roles([ADMIN_GROUP, DESIGNER_GROUP, REVIEWER_GROUP]) + @auth.has_one_of_roles( + [ + ADMIN, + CREATE_DESIGNS, + MANAGE_ALL_FILTERS, + CREATE_FILTERS, + VIEW_FILTERS, + VIEW_DESIGNS, + ] + ) @profiletime @API.doc( responses={ @@ -61,7 +74,7 @@ def get(): return response, HTTPStatus.OK @staticmethod - @auth.has_one_of_roles([ADMIN_GROUP]) + @auth.has_one_of_roles([ADMIN]) @profiletime @API.doc( responses={ @@ -88,7 +101,7 @@ class KeycloakRolesResourceById(Resource): """Resource to manage keycloak roles/groups by id.""" @staticmethod - @auth.has_one_of_roles([ADMIN_GROUP]) + @auth.has_one_of_roles([ADMIN]) @profiletime @API.doc( responses={ @@ -109,7 +122,7 @@ def get(role_id: str): return response, HTTPStatus.OK @staticmethod - @auth.has_one_of_roles([ADMIN_GROUP]) + @auth.has_one_of_roles([ADMIN]) @profiletime @API.doc( responses={ @@ -128,7 +141,7 @@ def delete(role_id: str): return {"message": "Deleted successfully."}, HTTPStatus.OK @staticmethod - @auth.has_one_of_roles([ADMIN_GROUP]) + @auth.has_one_of_roles([ADMIN]) @profiletime @API.doc( responses={ @@ -147,3 +160,23 @@ def put(role_id: str): request_data = roles_schema.load(request.get_json()) response = KeycloakFactory.get_instance().update_group(role_id, request_data) return {"message": response}, HTTPStatus.OK + + +@cors_preflight("GET, OPTIONS") +@API.route("/permissions", methods=["GET", "OPTIONS"]) +class Permissions(Resource): + """Resource to list.""" + + @staticmethod + @auth.has_one_of_roles([ADMIN]) + @profiletime + @API.doc( + responses={ + 200: "OK:- Successful request.", + 400: "BAD_REQUEST:- Invalid request.", + 401: "UNAUTHORIZED:- Authorization header not provided or an invalid token passed.", + }, + ) + def get(): + """Return permission list.""" + return PERMISSION_DETAILS diff --git a/forms-flow-api/src/formsflow_api/resources/theme.py b/forms-flow-api/src/formsflow_api/resources/theme.py new file mode 100644 index 0000000000..81978d1a64 --- /dev/null +++ b/forms-flow-api/src/formsflow_api/resources/theme.py @@ -0,0 +1,92 @@ +"""API endpoints for theme resource.""" + +from http import HTTPStatus + +from flask import request +from flask_restx import Namespace, Resource, fields +from formsflow_api_utils.utils import ADMIN, auth, cors_preflight, profiletime + +from formsflow_api.schemas import ThemeCustomizationSchema +from formsflow_api.services import ThemeCustomizationService + +theme_schema = ThemeCustomizationSchema() + +API = Namespace("Themes", description="Theme Customization APIs") + +theme_model = API.model( + "Themes", + { + "id": fields.Integer(), + "logoName": fields.String(), + "logoType": fields.String(), + "value": fields.String(), + "applicationTitle": fields.String(), + "theme": fields.Raw(), + }, +) + + +@cors_preflight("GET, POST,PUT, OPTIONS") +@API.route("", methods=["GET", "POST", "PUT", "OPTIONS"]) +class ThemeCustomizationResource(Resource): + """Resource to manage create update and get theme.""" + + @staticmethod + @auth.has_one_of_roles([ADMIN]) + @profiletime + @API.doc( + responses={ + 201: "CREATED:- Successful request.", + 400: "BAD_REQUEST:- Invalid request.", + 401: "UNAUTHORIZED:- Authorization header not provided or an invalid token passed.", + 403: "FORBIDDEN:- Permission denied", + }, + model=theme_model, + ) + def post(): + """Create Theme.""" + theme_data = theme_schema.load(request.get_json()) + response, status = ( + ThemeCustomizationService.create_theme(theme_data), + HTTPStatus.CREATED, + ) + return response, status + + @staticmethod + @profiletime + @API.doc( + responses={ + 200: "OK:- Successful request.", + 403: "FORBIDDEN:- Permission denied", + }, + model=[theme_model], + ) + def get(): + """Get theme by tenant key. This is a public API.""" + tenant_key = request.args.get("tenantKey", default=None) + response, status = ( + ThemeCustomizationService.get_theme(tenant_key), + HTTPStatus.OK, + ) + return response, status + + @staticmethod + @auth.has_one_of_roles([ADMIN]) + @profiletime + @API.doc( + responses={ + 200: "OK:- Successful request.", + 400: "BAD_REQUEST:- Invalid request.", + 403: "FORBIDDEN:- Permission denied", + }, + model=theme_model, + ) + def put(): + """Update Theme by tenant key.""" + theme_data = theme_schema.load(request.get_json()) + theme_result = ThemeCustomizationService.update_theme(theme_data) + response, status = ( + theme_schema.dump(theme_result), + HTTPStatus.OK, + ) + return response, status diff --git a/forms-flow-api/src/formsflow_api/resources/user.py b/forms-flow-api/src/formsflow_api/resources/user.py index 3a2fe7dea2..89dcfc3dfa 100644 --- a/forms-flow-api/src/formsflow_api/resources/user.py +++ b/forms-flow-api/src/formsflow_api/resources/user.py @@ -5,7 +5,10 @@ from flask import current_app, g, request from flask_restx import Namespace, Resource, fields from formsflow_api_utils.utils import ( - ADMIN_GROUP, + ADMIN, + CREATE_FILTERS, + MANAGE_ALL_FILTERS, + VIEW_TASKS, auth, cors_preflight, profiletime, @@ -15,10 +18,11 @@ TenantUserAddSchema, UserlocaleReqSchema, UserPermissionUpdateSchema, + UserSchema, UsersListSchema, ) from formsflow_api.services import KeycloakAdminAPIService, UserService -from formsflow_api.services.factory import KeycloakFactory +from formsflow_api.services.factory import KeycloakFactory, KeycloakGroupService API = Namespace("user", description="Keycloak user APIs") @@ -67,6 +71,7 @@ ) locale_put_model = API.model("Locale", {"locale": fields.String()}) +default_filter_model = API.model("DefaulFilter", {"defaultFilter": fields.String()}) @cors_preflight("PUT, OPTIONS") @@ -115,6 +120,29 @@ def put(self) -> dict: response = self.client.update_request(url_path=f"users/{user['id']}", data=user) if response is None: return {"message": "User not found"}, HTTPStatus.NOT_FOUND + # Capture "locale" changes in user table + UserService.update_user_data({"locale": dict_data["locale"]}) + return response, HTTPStatus.OK + + +@cors_preflight("POST, OPTIONS") +@API.route("/default-filter", methods=["OPTIONS", "POST"]) +class UserDefaultFilter(Resource): + """Resource to create or update user's default filter.""" + + @staticmethod + @auth.has_one_of_roles([ADMIN, CREATE_FILTERS, MANAGE_ALL_FILTERS]) + @profiletime + @API.doc(body=default_filter_model) + @API.response(200, "OK:- Successful request.") + @API.response( + 400, + "BAD_REQUEST:- Invalid request.", + ) + def post(): + """Update the user's default task filter.""" + data = UserSchema().load(request.get_json()) + response = UserService().update_user_data(data=data) return response, HTTPStatus.OK @@ -124,13 +152,13 @@ class KeycloakUsersList(Resource): """Resource to fetch keycloak users.""" @staticmethod - @auth.require + @auth.has_one_of_roles([ADMIN, CREATE_FILTERS, MANAGE_ALL_FILTERS, VIEW_TASKS]) @profiletime @API.doc( params={ "memberOfGroup": { "in": "query", - "description": "Group/Role name for fetching users.", + "description": "Group name for fetching users.", "default": "", }, "search": { @@ -158,6 +186,11 @@ class KeycloakUsersList(Resource): "description": "Boolean which defines whether count is returned.", "default": "false", }, + "permission": { + "in": "query", + "description": "A string to filter user by permission.", + "default": "", + }, } ) @API.response(200, "OK:- Successful request.", model=user_list_count_model) @@ -172,6 +205,7 @@ class KeycloakUsersList(Resource): def get(): # pylint: disable=too-many-locals """Get users list.""" group_name = request.args.get("memberOfGroup") + permission = request.args.get("permission") search = request.args.get("search") page_no = int(request.args.get("pageNo", 0)) limit = int(request.args.get("limit", 0)) @@ -179,6 +213,7 @@ def get(): # pylint: disable=too-many-locals count = request.args.get("count") == "true" kc_admin = KeycloakFactory.get_instance() if group_name: + (users_list, users_count) = kc_admin.get_users( page_no, limit, role, group_name, count, search ) @@ -189,12 +224,9 @@ def get(): # pylint: disable=too-many-locals } else: (user_list, user_count) = kc_admin.search_realm_users( - search, page_no, limit, role, count + search, page_no, limit, role, count, permission ) - user_list_response = [] - for user in user_list: - user = UsersListSchema().dump(user) - user_list_response.append(user) + user_list_response = UsersListSchema().dump(user_list, many=True) response = {"data": user_list_response, "count": user_count} return response, HTTPStatus.OK @@ -205,10 +237,10 @@ def get(): # pylint: disable=too-many-locals methods=["PUT", "DELETE", "OPTIONS"], ) class UserPermission(Resource): - """Resource to manage keycloak user permissions.""" + """Resource to manage keycloak user.""" @staticmethod - @auth.has_one_of_roles([ADMIN_GROUP]) + @auth.has_one_of_roles([ADMIN]) @profiletime @API.doc(body=user_permission_update_model) @API.response(204, "NO CONTENT:- Successful request.") @@ -225,9 +257,9 @@ def put(user_id, group_id): json_payload = request.get_json() user_and_group = UserPermissionUpdateSchema().load(json_payload) current_app.logger.debug("Initializing admin API service...") - service = KeycloakFactory.get_instance() + service = KeycloakGroupService() current_app.logger.debug("Successfully initialized admin API service !") - response = service.add_user_to_group_role(user_id, group_id, user_and_group) + response = service.add_user_to_group(user_id, group_id, user_and_group) if not response: current_app.logger.error(f"Failed to add {user_id} to group {group_id}") return { @@ -237,7 +269,7 @@ def put(user_id, group_id): return None, HTTPStatus.NO_CONTENT @staticmethod - @auth.has_one_of_roles([ADMIN_GROUP]) + @auth.has_one_of_roles([ADMIN]) @profiletime @API.doc(body=user_permission_update_model) @API.response(204, "NO CONTENT:- Successful request.") @@ -256,9 +288,7 @@ def delete(user_id, group_id): current_app.logger.debug("Initializing admin API service...") service = KeycloakFactory.get_instance() current_app.logger.debug("Successfully initialized admin API service !") - response = service.remove_user_from_group_role( - user_id, group_id, user_and_group - ) + response = service.remove_user_from_group(user_id, group_id, user_and_group) if not response: current_app.logger.error( f"Failed to remove {user_id} from group {group_id}" @@ -279,7 +309,7 @@ class TenantAddUser(Resource): """Resource to manage add user to a tenant.""" @staticmethod - @auth.has_one_of_roles([ADMIN_GROUP]) + @auth.has_one_of_roles([ADMIN]) @profiletime @API.doc(body=tenant_add_user_model) @API.response(200, "OK:- Successful request.") diff --git a/forms-flow-api/src/formsflow_api/schemas/__init__.py b/forms-flow-api/src/formsflow_api/schemas/__init__.py index 5df4c902b3..83d2d4f894 100644 --- a/forms-flow-api/src/formsflow_api/schemas/__init__.py +++ b/forms-flow-api/src/formsflow_api/schemas/__init__.py @@ -25,9 +25,25 @@ TenantUserAddSchema, UserlocaleReqSchema, UserPermissionUpdateSchema, + UserSchema, UsersListSchema, ) -from .form_history_logs import FormHistorySchema -from .process import ProcessListSchema +from .base_schema import AuditDateTimeSchema +from .form_history_logs import FormHistoryReqSchema, FormHistorySchema +from .import_support import ( + ImportEditRequestSchema, + ImportRequestSchema, + form_schema, + form_workflow_schema, +) +from .process import ( + MigrateRequestSchema, + ProcessDataSchema, + ProcessListRequestSchema, + ProcessListSchema, + ProcessRequestSchema, +) +from .process_history_logs import ProcessHistorySchema from .roles import RolesGroupsSchema +from .theme import ThemeCustomizationSchema diff --git a/forms-flow-api/src/formsflow_api/schemas/application.py b/forms-flow-api/src/formsflow_api/schemas/application.py index 75713cbb9c..588e500d95 100644 --- a/forms-flow-api/src/formsflow_api/schemas/application.py +++ b/forms-flow-api/src/formsflow_api/schemas/application.py @@ -2,6 +2,8 @@ from marshmallow import EXCLUDE, Schema, fields +from .base_schema import AuditDateTimeSchema + class ApplicationListReqSchema(Schema): """This is a general class for paginated request schema.""" @@ -38,7 +40,7 @@ class ApplicationListRequestSchema(ApplicationListReqSchema): sort_order = fields.Str(data_key="sortOrder", required=False) -class ApplicationSchema(Schema): +class ApplicationSchema(AuditDateTimeSchema): """This class manages application request and response schema.""" class Meta: # pylint: disable=too-few-public-methods @@ -55,9 +57,7 @@ class Meta: # pylint: disable=too-few-public-methods process_name = fields.Str(data_key="processName") process_tenant = fields.Str(data_key="processTenant") created_by = fields.Str(data_key="createdBy") - created = fields.Str() modified_by = fields.Str(data_key="modifiedBy") - modified = fields.Str() form_id = fields.Str(data_key="formId", load_only=True) latest_form_id = fields.Str(data_key="formId", dump_only=True) submission_id = fields.Str(data_key="submissionId") diff --git a/forms-flow-api/src/formsflow_api/schemas/application_history.py b/forms-flow-api/src/formsflow_api/schemas/application_history.py index bba4ded712..97b05dbf72 100644 --- a/forms-flow-api/src/formsflow_api/schemas/application_history.py +++ b/forms-flow-api/src/formsflow_api/schemas/application_history.py @@ -1,19 +1,11 @@ """This manages application Response Schema.""" -from marshmallow import EXCLUDE, Schema, fields +from marshmallow import EXCLUDE, fields -# class ApplicationHistoryReqSchema(Schema): -# """This class manages application list request schema.""" +from .base_schema import AuditDateTimeSchema -# class Meta: # pylint: disable=too-few-public-methods -# """Exclude unknown fields in the deserialized output.""" -# unknown = EXCLUDE - -# application_id = fields.Str() - - -class ApplicationHistorySchema(Schema): +class ApplicationHistorySchema(AuditDateTimeSchema): """This class manages aggregated application response schema.""" class Meta: # pylint: disable=too-few-public-methods @@ -25,7 +17,6 @@ class Meta: # pylint: disable=too-few-public-methods application_id = fields.Int(data_key="applicationId", load_only=True) application_status = fields.Str(data_key="applicationStatus") form_url = fields.Str(data_key="formUrl", load_only=True) - created = fields.Str() submitted_by = fields.Str(data_key="submittedBy", required=False, allow_none=True) form_id = fields.Str(data_key="formId", dump_only=True) submission_id = fields.Str(data_key="submissionId", dump_only=True) diff --git a/forms-flow-api/src/formsflow_api/schemas/base_schema.py b/forms-flow-api/src/formsflow_api/schemas/base_schema.py new file mode 100644 index 0000000000..d00203719a --- /dev/null +++ b/forms-flow-api/src/formsflow_api/schemas/base_schema.py @@ -0,0 +1,48 @@ +"""This manages common schemas.""" + +import datetime + +from marshmallow import EXCLUDE, Schema, fields + + +class AuditDateTimeSchema(Schema): + """This class manages AuditDateTime fields created & modified.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + created = fields.DateTime( + format="iso", data_key="created", required=False, dump_only=True + ) + modified = fields.DateTime( + format="iso", data_key="modified", required=False, dump_only=True + ) + + def dump(self, obj, many=False, **kwargs): + """Override the dump method to format datetime fields.""" + data = super().dump(obj, many=many, **kwargs) + + def format_datetime_fields(record): + """Helper to format datetime fields for a single record.""" + for field in ["created", "modified"]: + field_value = record.get(field) + if field_value is not None and isinstance(field_value, str): + # Convert the string to datetime + dt = datetime.datetime.fromisoformat(record[field]) + # Ensure it's UTC and return the ISO format with 'Z' + record[field] = ( + dt.replace(tzinfo=datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) + return record + + # If many=True, apply formatting to each item in the list + if many: + data = [format_datetime_fields(record) for record in data] + else: + data = format_datetime_fields(data) + + return data diff --git a/forms-flow-api/src/formsflow_api/schemas/draft.py b/forms-flow-api/src/formsflow_api/schemas/draft.py index 1c5352fbd9..6a2ad07d8c 100644 --- a/forms-flow-api/src/formsflow_api/schemas/draft.py +++ b/forms-flow-api/src/formsflow_api/schemas/draft.py @@ -1,9 +1,11 @@ """This manages draft Response Schema.""" -from marshmallow import EXCLUDE, Schema, fields +from marshmallow import EXCLUDE, fields +from .base_schema import AuditDateTimeSchema -class DraftSchema(Schema): + +class DraftSchema(AuditDateTimeSchema): """This class manages submission request and response schema.""" class Meta: # pylint: disable=too-few-public-methods @@ -15,8 +17,6 @@ class Meta: # pylint: disable=too-few-public-methods id = fields.Int(data_key="id") application_id = fields.Int(data_key="applicationId") data = fields.Dict(data_key="data", required=True) - created = fields.Str() - modified = fields.Str() form_name = fields.Str(data_key="DraftName", dump_only=True) form_id = fields.Str(data_key="formId", dump_only=True) created_by = fields.Str(data_key="CreatedBy", dump_only=True) @@ -24,7 +24,7 @@ class Meta: # pylint: disable=too-few-public-methods process_name = fields.Str(data_key="processName", dump_only=True) -class DraftListSchema(Schema): +class DraftListSchema(AuditDateTimeSchema): """This class manages the draft listing schema.""" class Meta: # pylint: disable=too-few-public-methods @@ -34,7 +34,6 @@ class Meta: # pylint: disable=too-few-public-methods id = fields.Int(data_key="id") form_name = fields.Str(data_key="DraftName") - modified = fields.Str() page_no = fields.Int(data_key="pageNo", required=False, allow_none=True) limit = fields.Int(required=False, allow_none=True) modified_from_date = fields.DateTime( diff --git a/forms-flow-api/src/formsflow_api/schemas/filter.py b/forms-flow-api/src/formsflow_api/schemas/filter.py index 172e9ed95d..d616f52cef 100644 --- a/forms-flow-api/src/formsflow_api/schemas/filter.py +++ b/forms-flow-api/src/formsflow_api/schemas/filter.py @@ -2,6 +2,8 @@ from marshmallow import EXCLUDE, Schema, fields +from .base_schema import AuditDateTimeSchema + class VariableSchema(Schema): """This class provides the schema for variable.""" @@ -15,7 +17,7 @@ class Meta: # pylint: disable=too-few-public-methods label = fields.Str() -class FilterSchema(Schema): +class FilterSchema(AuditDateTimeSchema): """This class manages Filter schema.""" class Meta: # pylint: disable=too-few-public-methods @@ -34,10 +36,9 @@ class Meta: # pylint: disable=too-few-public-methods roles = fields.List(fields.Str()) users = fields.List(fields.Str()) status = fields.Str() - created = fields.Str(dump_only=True) - modified = fields.Str(dump_only=True) created_by = fields.Str(data_key="createdBy", dump_only=True) modified_by = fields.Str(data_key="modifiedBy", dump_only=True) task_visible_attributes = fields.Dict(data_key="taskVisibleAttributes") isMyTasksEnabled = fields.Bool(load_only=True) isTasksForCurrentUserGroupsEnabled = fields.Bool(load_only=True) + order = fields.Int(data_key="order", allow_none=True) diff --git a/forms-flow-api/src/formsflow_api/schemas/form_history_logs.py b/forms-flow-api/src/formsflow_api/schemas/form_history_logs.py index e9bc043dba..6bdd0f1372 100644 --- a/forms-flow-api/src/formsflow_api/schemas/form_history_logs.py +++ b/forms-flow-api/src/formsflow_api/schemas/form_history_logs.py @@ -2,8 +2,10 @@ from marshmallow import EXCLUDE, Schema, fields +from .base_schema import AuditDateTimeSchema -class FormHistorySchema(Schema): + +class FormHistorySchema(AuditDateTimeSchema): """This class provides the schema for Form history.""" class Meta: # pylint: disable=too-few-public-methods @@ -14,5 +16,32 @@ class Meta: # pylint: disable=too-few-public-methods id = fields.Str(dump_only=True) form_id = fields.Str(data_key="formId") created_by = fields.Str(data_key="createdBy") - created = fields.Str(data_key="created") change_log = fields.Dict(data_key="changeLog") + major_version = fields.Int(data_key="majorVersion") + minor_version = fields.Int(data_key="minorVersion") + version = fields.Method("get_combined_version", dump_only=True) + isMajor = fields.Method("get_is_major", dump_only=True) + published_on = fields.Str(data_key="publishedOn", dump_only=True) + published_by = fields.Str(data_key="publishedBy", dump_only=True) + + def get_combined_version(self, obj): + """Combine major and minor versions.""" + major_version = obj.major_version or 1 + minor_version = obj.minor_version or 0 + return f"{major_version}.{minor_version}" + + def get_is_major(self, obj): + """Determine if the version is major.""" + return obj.minor_version == 0 + + +class FormHistoryReqSchema(Schema): + """This is a general class for paginated request schema.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + page_no = fields.Int(data_key="pageNo", required=False, allow_none=True) + limit = fields.Int(required=False, allow_none=True) diff --git a/forms-flow-api/src/formsflow_api/schemas/form_process_mapper.py b/forms-flow-api/src/formsflow_api/schemas/form_process_mapper.py index 1f0f8d9672..5830b1cde7 100644 --- a/forms-flow-api/src/formsflow_api/schemas/form_process_mapper.py +++ b/forms-flow-api/src/formsflow_api/schemas/form_process_mapper.py @@ -2,8 +2,10 @@ from marshmallow import EXCLUDE, Schema, fields +from .base_schema import AuditDateTimeSchema -class FormProcessMapperSchema(Schema): + +class FormProcessMapperSchema(AuditDateTimeSchema): """This class manages form process mapper request and response schema.""" class Meta: # pylint: disable=too-few-public-methods @@ -13,7 +15,6 @@ class Meta: # pylint: disable=too-few-public-methods id = fields.Str(data_key="id") form_id = fields.Str(data_key="formId", required=True) - previous_form_id = fields.Str(data_key="previousFormId", load_only=True) form_name = fields.Str(data_key="formName", required=True) form_type = fields.Str(data_key="formType") parent_form_id = fields.Str(data_key="parentFormId") @@ -21,16 +22,16 @@ class Meta: # pylint: disable=too-few-public-methods process_name = fields.Str(data_key="processName") comments = fields.Str(data_key="comments") is_anonymous = fields.Bool(data_key="anonymous") - status = fields.Str(data_key="status") # active/inactive + status = fields.Str(data_key="status", allow_none=True) # active/inactive created_by = fields.Str(data_key="createdBy") - created = fields.Str(data_key="created") modified_by = fields.Str(data_key="modifiedBy") - modified = fields.Str(data_key="modified") - task_variable = fields.Str(data_key="taskVariable") + task_variable = fields.Str(data_key="taskVariables") version = fields.Str(data_key="version") process_tenant = fields.Str(data_key="processTenant") deleted = fields.Boolean(data_key="deleted") description = fields.Str(data_key="description") + prompt_new_version = fields.Bool(data_key="promptNewVersion", dump_only=True) + is_migrated = fields.Bool(data_key="isMigrated", required=False) class FormProcessMapperListReqSchema(Schema): @@ -48,9 +49,12 @@ class Meta: # pylint: disable=too-few-public-methods class FormProcessMapperListRequestSchema(FormProcessMapperListReqSchema): """This class manages formprocessmapper list request schema.""" - form_name = fields.Str(data_key="formName", required=False) + search = fields.Str(data_key="search", required=False) sort_by = fields.Str(data_key="sortBy", required=False) sort_order = fields.Str(data_key="sortOrder", required=False) form_type = fields.Str(data_key="formType", required=False) is_active = fields.Bool(data_key="isActive", required=False) active_forms = fields.Bool(data_key="activeForms", required=False) + ignore_designer = fields.Bool( + data_key="showForOnlyCreateSubmissionUsers", required=False + ) diff --git a/forms-flow-api/src/formsflow_api/schemas/import_support.py b/forms-flow-api/src/formsflow_api/schemas/import_support.py new file mode 100644 index 0000000000..f17655e942 --- /dev/null +++ b/forms-flow-api/src/formsflow_api/schemas/import_support.py @@ -0,0 +1,152 @@ +"""This manages Import Schema.""" + +from marshmallow import EXCLUDE, Schema, fields + +form_workflow_schema = { + "title": "Form Workflow Schema", + "type": "object", + "properties": { + "forms": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "formTitle": {"type": "string"}, + "formDescription": {"type": "string"}, + "anonymous": {"type": "boolean"}, + "type": {"type": "string"}, + "content": {"type": "object"}, + }, + "required": [ + "formTitle", + "formDescription", + "content", + "anonymous", + "type", + ], + }, + }, + "workflows": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "processKey": {"type": "string"}, + "processName": {"type": "string"}, + "processType": {"type": "string"}, + "type": {"type": "string"}, + "content": {"type": "string"}, + }, + "required": [ + "content", + "processKey", + "processName", + "processType", + "type", + ], + }, + }, + "authorizations": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "APPLICATION": { + "type": "object", + "properties": { + "resourceId": {"type": "string"}, + "resourceDetails": {"type": "object"}, + "roles": {"type": "array", "items": {"type": "string"}}, + "userName": {"type": ["string", "null"]}, + }, + "required": [ + "resourceId", + "resourceDetails", + "roles", + "userName", + ], + }, + "FORM": { + "type": "object", + "properties": { + "resourceId": {"type": "string"}, + "resourceDetails": {"type": "object"}, + "roles": {"type": "array", "items": {"type": "string"}}, + "userName": {"type": ["string", "null"]}, + }, + "required": [ + "resourceId", + "resourceDetails", + "roles", + "userName", + ], + }, + "DESIGNER": { + "type": "object", + "properties": { + "resourceId": {"type": "string"}, + "resourceDetails": {"type": "object"}, + "roles": {"type": "array", "items": {"type": "string"}}, + "userName": {"type": ["string", "null"]}, + }, + "required": [ + "resourceId", + "resourceDetails", + "roles", + "userName", + ], + }, + }, + "required": ["APPLICATION", "FORM", "DESIGNER"], + }, + }, + }, + "required": ["forms", "workflows", "rules", "authorizations"], +} + +form_schema = { + "title": "Form Schema", + "type": "object", + "properties": { + "forms": { + "type": "array", + "properties": { + "title": {"type": "string"}, + "name": {"type": "string"}, + "path": {"type": "boolean"}, + "type": {"type": "string"}, + "components": {"type": "array"}, + }, + "required": ["title", "name", "path", "type", "components"], + } + }, + "additionalProperties": False, +} + + +class ImportRequestSchema(Schema): + """This class manages import request schema.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + import_type = fields.Str(data_key="importType", required=True) + action = fields.Str(data_key="action", required=True) + + +class ImportEditRequestSchema(Schema): + """This class manages import edit request schema.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + mapper_id = fields.Str(data_key="mapperId", required=True) + form = fields.Dict(data_key="form") + workflow = fields.Dict(data_key="workflow") diff --git a/forms-flow-api/src/formsflow_api/schemas/process.py b/forms-flow-api/src/formsflow_api/schemas/process.py index 138920e01d..23175f5ef1 100644 --- a/forms-flow-api/src/formsflow_api/schemas/process.py +++ b/forms-flow-api/src/formsflow_api/schemas/process.py @@ -1,6 +1,14 @@ -# """This manages process Response Schema.""" -"""Process schema.""" -from marshmallow import EXCLUDE, Schema, fields +"""This manages process data Schema.""" + +import json + +from formsflow_api_utils.exceptions import BusinessException +from marshmallow import EXCLUDE, Schema, fields, validates + +from formsflow_api.constants import BusinessErrorCode +from formsflow_api.models import FormProcessMapper + +from .base_schema import AuditDateTimeSchema class ProcessListSchema(Schema): @@ -14,3 +22,123 @@ class Meta: # pylint: disable=too-few-public-methods key = fields.Str() name = fields.Str() tenantId = fields.Str(data_key="tenantKey") + + +class ProcessDataSchema(AuditDateTimeSchema): + """This class manages process data schema.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + id = fields.Int() + name = fields.Str() + process_data = fields.Method( + "get_process_data", data_key="processData", dump_only=True + ) + tenant = fields.Str(dump_only=True) + created_by = fields.Str(data_key="createdBy", dump_only=True) + modified_by = fields.Str(data_key="modifiedBy", dump_only=True) + status = fields.Method("get_status", deserialize="load_status") + process_type = fields.Method( + "get_process_type", deserialize="load_process_type", data_key="processType" + ) + is_subflow = fields.Bool(data_key="isSubflow") + process_key = fields.Str(data_key="processKey") + parent_process_key = fields.Str(data_key="parentProcessKey") + + def get_status(self, obj): + """This method is to get the status.""" + return obj.status.value + + def load_status(self, value): + """This method is to load the status.""" + return value.upper() if value else None + + def get_process_type(self, obj): + """This method is to get the process type.""" + return obj.process_type.value + + def load_process_type(self, value): # check & delete this if not needed + """This method is to load the process type.""" + return value.upper() if value else None + + def get_process_data(self, obj): + """This method is to get the process data.""" + obj.process_data = obj.process_data.decode("utf-8") + if obj.process_type.value == "LOWCODE": + return json.loads(obj.process_data) + return obj.process_data + + @validates("form_process_mapper_id") + def validate_form_process_mapper_id(self, data): + """This method is to validate the form process mapper id.""" + if not FormProcessMapper.find_form_by_id(data): + raise BusinessException(BusinessErrorCode.INVALID_FORM_PROCESS_MAPPER_ID) + + +class ProcessListRequestSchema(Schema): + """This class manages Process list request schema.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + page_no = fields.Int(data_key="pageNo", allow_none=True) + limit = fields.Int(required=False, allow_none=True) + sort_by = fields.Str(data_key="sortBy", required=False) + process_id = fields.Int(data_key="id", required=False) + name = fields.Str(required=False) + status = fields.Str(required=False) + process_data_type = fields.Str(required=False, data_key="processType") + created_by = fields.Str(data_key="createdBy", required=False) + created_from_date = fields.DateTime( + data_key="createdFrom", format="%Y-%m-%dT%H:%M:%S+00:00" + ) + created_to_date = fields.DateTime( + data_key="createdTo", format="%Y-%m-%dT%H:%M:%S+00:00" + ) + modified_from_date = fields.DateTime( + data_key="modifiedFrom", format="%Y-%m-%dT%H:%M:%S+00:00" + ) + modified_to_date = fields.DateTime( + data_key="modifiedTo", format="%Y-%m-%dT%H:%M:%S+00:00" + ) + sort_order = fields.Str(data_key="sortOrder", required=False) + major_version = fields.Int(data_key="majorVersion") + minor_version = fields.Int(data_key="minorVersion") + + +class ProcessRequestSchema(Schema): + """This class manages process request schema.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + process_type = fields.Str(data_key="processType", required=True) + process_data = fields.Str(data_key="processData", required=True) + + def load(self, data, *args, **kwargs): + """Load method for deserializing data.""" + process_type = data.get("processType") + process_data = data.get("processData") + # For "LOWCODE" process type, convert JSON string input of processData to string before loading. + if process_type and process_type.upper() == "LOWCODE" and process_data: + data["processData"] = json.dumps(process_data) + return super().load(data, *args, **kwargs) + + +class MigrateRequestSchema(Schema): + """This class manages migrate request schema.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + process_key = fields.Str(data_key="processKey", required=True) + mapper_id = fields.Str(data_key="mapperId", required=True) diff --git a/forms-flow-api/src/formsflow_api/schemas/process_history_logs.py b/forms-flow-api/src/formsflow_api/schemas/process_history_logs.py new file mode 100644 index 0000000000..0f9fa9dd70 --- /dev/null +++ b/forms-flow-api/src/formsflow_api/schemas/process_history_logs.py @@ -0,0 +1,32 @@ +"""This manages Process history Schema.""" + +from marshmallow import EXCLUDE, fields + +from .base_schema import AuditDateTimeSchema + + +class ProcessHistorySchema(AuditDateTimeSchema): + """This class provides the schema for Form history.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields.""" + + unknown = EXCLUDE + + id = fields.Str(dump_only=True) + name = fields.Str(data_key="processName") + created_by = fields.Str(data_key="createdBy") + major_version = fields.Int(data_key="majorVersion") + minor_version = fields.Int(data_key="minorVersion") + process_type = fields.Method("get_process_type", data_key="processType") + isMajor = fields.Method("get_is_major", dump_only=True) + published_on = fields.Str(data_key="publishedOn", dump_only=True) + published_by = fields.Str(data_key="publishedBy", dump_only=True) + + def get_is_major(self, obj): + """Determine if the version is major.""" + return obj.minor_version == 0 + + def get_process_type(self, obj): + """This method is to get the process type.""" + return obj.process_type.value diff --git a/forms-flow-api/src/formsflow_api/schemas/roles.py b/forms-flow-api/src/formsflow_api/schemas/roles.py index 22ec4d2aef..26a8bca7a8 100644 --- a/forms-flow-api/src/formsflow_api/schemas/roles.py +++ b/forms-flow-api/src/formsflow_api/schemas/roles.py @@ -14,3 +14,4 @@ class Meta: # pylint: disable=too-few-public-methods id = fields.Str(dump_only=True) name = fields.Str(required=True) description = fields.Str() + permissions = fields.List(fields.Str()) diff --git a/forms-flow-api/src/formsflow_api/schemas/theme.py b/forms-flow-api/src/formsflow_api/schemas/theme.py new file mode 100644 index 0000000000..7e0c14a1a1 --- /dev/null +++ b/forms-flow-api/src/formsflow_api/schemas/theme.py @@ -0,0 +1,21 @@ +"""This manages Theme Schema.""" + +from marshmallow import EXCLUDE, Schema, fields + + +class ThemeCustomizationSchema(Schema): + """This class provides the schema for theme.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields.""" + + unknown = EXCLUDE + + id = fields.Int() + tenant = fields.Str(allow_none=True) + logo_name = fields.Str(data_key="logoName") + logo_type = fields.Str(data_key="type") + logo_data = fields.Str(data_key="logoData") + application_title = fields.Str(data_key="applicationTitle") + theme = fields.Dict(data_key="themeJson") + created_by = fields.Str() diff --git a/forms-flow-api/src/formsflow_api/schemas/user.py b/forms-flow-api/src/formsflow_api/schemas/user.py index 611bed0855..cfd503ebea 100644 --- a/forms-flow-api/src/formsflow_api/schemas/user.py +++ b/forms-flow-api/src/formsflow_api/schemas/user.py @@ -60,3 +60,17 @@ class Meta: # pylint: disable=too-few-public-methods user = fields.Str(data_key="user", required=True) roles = fields.List(fields.Nested(AddUserRoleSchema)) + + +class UserSchema(Schema): + """Schema for user data.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + default_filter = fields.Int(data_key="defaultFilter", allow_none=True) + locale = fields.Str(data_key="locale") + user_name = fields.Str(data_key="userName", dump_only=True) + # tenant = fields.Str(data_key="tenantKey", dump_only=True) diff --git a/forms-flow-api/src/formsflow_api/services/__init__.py b/forms-flow-api/src/formsflow_api/services/__init__.py index 17e6d392ef..319af03bd4 100644 --- a/forms-flow-api/src/formsflow_api/services/__init__.py +++ b/forms-flow-api/src/formsflow_api/services/__init__.py @@ -12,7 +12,9 @@ from formsflow_api.services.form_embed import CombineFormAndApplicationCreate from formsflow_api.services.form_history_logs import FormHistoryService from formsflow_api.services.form_process_mapper import FormProcessMapperService +from formsflow_api.services.import_support import ImportService from formsflow_api.services.process import ProcessService +from formsflow_api.services.theme import ThemeCustomizationService from formsflow_api.services.user import UserService __all__ = [ @@ -29,4 +31,6 @@ "UserService", "FormHistoryService", "CombineFormAndApplicationCreate", + "ThemeCustomizationService", + "ImportService", ] diff --git a/forms-flow-api/src/formsflow_api/services/application.py b/forms-flow-api/src/formsflow_api/services/application.py index 3b11973bfc..98c21c0131 100644 --- a/forms-flow-api/src/formsflow_api/services/application.py +++ b/forms-flow-api/src/formsflow_api/services/application.py @@ -1,4 +1,5 @@ """This exposes application service.""" + import asyncio import json from datetime import datetime @@ -11,8 +12,8 @@ from formsflow_api_utils.exceptions import BusinessException, ExternalError from formsflow_api_utils.utils import ( DRAFT_APPLICATION_STATUS, + MANAGE_TASKS, NEW_APPLICATION_STATUS, - REVIEWER_GROUP, ) from formsflow_api_utils.utils.user_context import UserContext, user_context @@ -42,11 +43,11 @@ class ApplicationService: # pylint: disable=too-many-public-methods @staticmethod def get_start_task_payload( - application: Application, - mapper: FormProcessMapper, - form_url: str, - web_form_url: str, - variables: Dict, + application: Application, + mapper: FormProcessMapper, + form_url: str, + web_form_url: str, + variables: Dict, ) -> Dict: """Returns the payload for initiating the task.""" return { @@ -65,7 +66,7 @@ def get_start_task_payload( @staticmethod async def start_task( - mapper: FormProcessMapper, payload: Dict, token: str, application_id: int + mapper: FormProcessMapper, payload: Dict, token: str, application_id: int ) -> None: """Trigger bpmn workflow to create a task.""" try: @@ -93,7 +94,9 @@ async def start_task( "error": camunda_error, } current_app.logger.critical(response) - raise BusinessException(BusinessErrorCode.PROCESS_START_ERROR) from camunda_error + raise BusinessException( + BusinessErrorCode.PROCESS_START_ERROR + ) from camunda_error @staticmethod @user_context @@ -139,12 +142,16 @@ def create_application(data, token, **kwargs): application.commit() # Commit the record # Creating the process instance asynchronously. asyncio.run( - ApplicationService.start_task(mapper, payload, token, application.id) + ApplicationService.start_task( + mapper, payload, token, application.id + ) ) except Exception as e: current_app.logger.error("Error occurred during application creation %s", e) - if application: # If application instance is created, rollback the transaction. + if ( + application + ): # If application instance is created, rollback the transaction. application.rollback() raise BusinessException(BusinessErrorCode.APPLICATION_CREATE_ERROR) from e @@ -177,20 +184,20 @@ def _application_access(token: str) -> bool: @staticmethod @user_context - def get_auth_applications_and_count( # pylint: disable=too-many-arguments,too-many-locals - page_no: int, - limit: int, - order_by: str, - created_from: datetime, - created_to: datetime, - modified_from: datetime, - modified_to: datetime, - application_id: int, - application_name: str, - application_status: str, - created_by: str, - sort_order: str, - **kwargs, + def get_auth_applications_and_count( # pylint: disable=too-many-arguments,too-many-locals,too-many-positional-arguments + page_no: int, + limit: int, + order_by: str, + created_from: datetime, + created_to: datetime, + modified_from: datetime, + modified_to: datetime, + application_id: int, + application_name: str, + application_status: str, + created_by: str, + sort_order: str, + **kwargs, ): """Get applications only from authorized groups.""" # access, resource_list = ApplicationService._application_access(token) @@ -260,20 +267,20 @@ def get_auth_by_application_id(application_id: int, **kwargs): @staticmethod @user_context - def get_all_applications_by_user( # pylint: disable=too-many-arguments,too-many-locals - page_no: int, - limit: int, - order_by: str, - sort_order: str, - created_from: datetime, - created_to: datetime, - modified_from: datetime, - modified_to: datetime, - created_by: str, - application_status: str, - application_name: str, - application_id: int, - **kwargs, + def get_all_applications_by_user( # pylint: disable=too-many-arguments,too-many-locals,too-many-positional-arguments + page_no: int, + limit: int, + order_by: str, + sort_order: str, + created_from: datetime, + created_to: datetime, + modified_from: datetime, + modified_to: datetime, + created_by: str, + application_status: str, + application_name: str, + application_id: int, + **kwargs, ): """Get all applications based on user.""" user: UserContext = kwargs["user"] @@ -328,7 +335,7 @@ def get_all_applications_form_id(form_id, page_no: int, limit: int): @staticmethod @user_context def get_all_applications_form_id_user( - form_id: str, page_no: int, limit: int, **kwargs + form_id: str, page_no: int, limit: int, **kwargs ): """Get all applications.""" user: UserContext = kwargs["user"] @@ -388,15 +395,15 @@ def update_application(application_id: int, data: Dict, **kwargs): raise BusinessException(BusinessErrorCode.APPLICATION_ID_NOT_FOUND) @staticmethod - def get_aggregated_applications( # pylint: disable=too-many-arguments - from_date: str, - to_date: str, - page_no: int, - limit: int, - form_name: str, - sort_by: str, - sort_order: str, - order_by: str, + def get_aggregated_applications( # pylint: disable=too-many-arguments,too-many-positional-arguments + from_date: str, + to_date: str, + page_no: int, + limit: int, + form_name: str, + sort_by: str, + sort_order: str, + order_by: str, ): """Get aggregated applications.""" applications, get_all_metrics_count = Application.find_aggregated_applications( @@ -419,11 +426,11 @@ def get_aggregated_applications( # pylint: disable=too-many-arguments @staticmethod @user_context def get_applications_status_by_parent_form_id( - parent_form_id: str, - from_date: datetime, - to_date: datetime, - order_by: str, - **kwargs, + parent_form_id: str, + from_date: datetime, + to_date: datetime, + order_by: str, + **kwargs, ): """Get aggregated application status by parent form id.""" user: UserContext = kwargs["user"] @@ -443,7 +450,7 @@ def get_applications_status_by_parent_form_id( @staticmethod def get_applications_status_by_form_id( - form_id: int, from_date: str, to_date: str, order_by: str + form_id: int, from_date: str, to_date: str, order_by: str ): """Get aggregated application status by form id.""" application_status = Application.find_aggregated_application_status_by_form_id( @@ -492,7 +499,7 @@ def get_application_count(auth, **kwargs): user_name = user.user_name form_ids: Set[str] = [] application_count = None - if auth.has_role([REVIEWER_GROUP]): + if auth.has_role([MANAGE_TASKS]): forms = Authorization.find_all_resources_authorized( auth_type=AuthType.APPLICATION, roles=user.group_or_roles, @@ -532,7 +539,7 @@ def fetch_task_variable_values(task_variable, form_data): def resubmit_application(application_id: int, payload: Dict, token: str): """Resubmit application and update process variables.""" mapper = ApplicationService.get_application_form_mapper_by_id(application_id) - task_variable = json.loads(mapper.get("taskVariable")) + task_variable = json.loads(mapper.get("taskVariables")) form_data = payload.pop("data", None) payload["processVariables"] = ApplicationService.fetch_task_variable_values( task_variable, form_data diff --git a/forms-flow-api/src/formsflow_api/services/application_history.py b/forms-flow-api/src/formsflow_api/services/application_history.py index afeb95f1be..55c39bc6f2 100644 --- a/forms-flow-api/src/formsflow_api/services/application_history.py +++ b/forms-flow-api/src/formsflow_api/services/application_history.py @@ -3,7 +3,7 @@ from flask import current_app from formsflow_api_utils.utils import get_form_and_submission_id_from_form_url -from formsflow_api.models import ApplicationHistory +from formsflow_api.models import Application, ApplicationHistory from formsflow_api.schemas import ApplicationHistorySchema @@ -11,8 +11,18 @@ class ApplicationHistoryService: """This class manages application service.""" @staticmethod - def create_application_history(data): + def create_application_history(data, application_id): """Create new application history.""" + # Replace service-account with application creator in initial history. + if data.get("submitted_by") and data.get("submitted_by").startswith( + "service-account" + ): + application_history = ApplicationHistory.get_application_history_by_id( + application_id + ) + if not application_history: + application = Application.find_by_id(application_id) + data["submitted_by"] = application.created_by (form_id, submission_id) = get_form_and_submission_id_from_form_url( data["form_url"] ) diff --git a/forms-flow-api/src/formsflow_api/services/authorization.py b/forms-flow-api/src/formsflow_api/services/authorization.py index f890d95b41..fd8796c23d 100644 --- a/forms-flow-api/src/formsflow_api/services/authorization.py +++ b/forms-flow-api/src/formsflow_api/services/authorization.py @@ -7,7 +7,7 @@ from formsflow_api_utils.utils.user_context import UserContext, user_context from formsflow_api.constants import BusinessErrorCode -from formsflow_api.models import Authorization, AuthType +from formsflow_api.models import Application, Authorization, AuthType from formsflow_api.schemas import ApplicationSchema application_schema = ApplicationSchema() @@ -70,7 +70,12 @@ def is_dashboard_authorized(self, resource_id: str, **kwargs) -> bool: @user_context def create_authorization( - self, auth_type: str, resource: Dict[str, str], is_designer: bool, **kwargs + self, + auth_type: str, + resource: Dict[str, str], + is_designer: bool, + edit_import_designer=False, + **kwargs ) -> Dict[str, any]: """Create authorization record.""" user: UserContext = kwargs["user"] @@ -86,9 +91,13 @@ def create_authorization( ) roles = resource.get("roles") if auth: + # Incase of edit import-desiger auth, user_name default to the username already present in auth.user_name + user_name = ( + auth.user_name if edit_import_designer else resource.get("userName") + ) auth.roles = roles auth.resource_details = resource.get("resourceDetails") - auth.user_name = resource.get("userName") + auth.user_name = user_name auth.modified = datetime.datetime.now() auth.modified_by = user.user_name else: @@ -104,6 +113,42 @@ def create_authorization( auth = auth.save() return self._as_dict(auth) + @user_context + def get_application_resource_by_id( + self, auth_type: str, resource_id: str, form_id: str = None, **kwargs + ): + """Get application authorization resource by ID.""" + user: UserContext = kwargs["user"] + auth_type_enum = AuthType(auth_type) + + auth = Authorization.find_resource_by_id( + auth_type=auth_type_enum, + resource_id=resource_id, + ignore_role_check=True, + tenant=user.tenant_key, + ) + + if not auth: + raise BusinessException(BusinessErrorCode.PERMISSION_DENIED) + + response = self._as_dict(auth) + authorized_user = False + + # Check if the user has the required roles + if set(user.group_or_roles).intersection(auth.roles): + authorized_user = True + + # Check if the user created the application associated with the form + if form_id and Application.get_application_by_formid_and_user_name( + formid=form_id, user_name=user.user_name + ): + authorized_user = True + + if authorized_user: + return response + + raise BusinessException(BusinessErrorCode.PERMISSION_DENIED) + @user_context def get_resource_by_id( self, auth_type: str, resource_id: str, is_designer: bool, **kwargs @@ -144,3 +189,15 @@ def get_auth_list_by_id(self, resource_id, **kwargs): auth_detail[auth.auth_type.value] = self._as_dict(auth) return auth_detail raise BusinessException(BusinessErrorCode.PERMISSION_DENIED) + + @staticmethod + def create_or_update_resource_authorization(data, is_designer): + """Create or update resource authorization.""" + for auth_type in AuthType: + auth_data = data.get(auth_type.value.lower()) + if auth_data and auth_type.value != AuthType.DASHBOARD.value: + AuthorizationService().create_authorization( + auth_type.value, + auth_data, + is_designer, + ) diff --git a/forms-flow-api/src/formsflow_api/services/draft.py b/forms-flow-api/src/formsflow_api/services/draft.py index b12dff5388..7fe685bef7 100644 --- a/forms-flow-api/src/formsflow_api/services/draft.py +++ b/forms-flow-api/src/formsflow_api/services/draft.py @@ -1,10 +1,10 @@ """This exposes submission service.""" + import asyncio import json - from typing import Dict -from flask import current_app +from flask import current_app from formsflow_api_utils.exceptions import BusinessException from formsflow_api_utils.utils import ANONYMOUS_USER, DRAFT_APPLICATION_STATUS from formsflow_api_utils.utils.enums import FormProcessMapperStatus @@ -134,7 +134,9 @@ def make_submission_from_draft(data: Dict, draft_id: str, token=None, **kwargs): # was created, update the application with new mapper application.update({"form_process_mapper_id": mapper.id}) task_variables = ( - json.loads(mapper.task_variable) if mapper.task_variable is not None else [] + json.loads(mapper.task_variable) + if mapper.task_variable is not None + else [] ) variables = ApplicationService.fetch_task_variable_values( task_variables, data.get("data", {}) @@ -150,7 +152,9 @@ def make_submission_from_draft(data: Dict, draft_id: str, token=None, **kwargs): except Exception as e: current_app.logger.error("Error occurred during application creation %s", e) - if application: # If application instance is created, rollback the transaction. + if ( + application + ): # If application instance is created, rollback the transaction. application.rollback() raise BusinessException(BusinessErrorCode.APPLICATION_CREATE_ERROR) from e return application diff --git a/forms-flow-api/src/formsflow_api/services/external/admin.py b/forms-flow-api/src/formsflow_api/services/external/admin.py new file mode 100644 index 0000000000..a46e11c2bd --- /dev/null +++ b/forms-flow-api/src/formsflow_api/services/external/admin.py @@ -0,0 +1,29 @@ +"""This exposes the Admin API.""" + +import json + +import requests +from flask import current_app +from formsflow_api_utils.exceptions import BusinessException +from formsflow_api_utils.utils import HTTP_TIMEOUT + +from formsflow_api.constants import BusinessErrorCode + + +class AdminService: # pylint: disable=too-few-public-methods + """This class manages external calls to admin api service.""" + + @classmethod + def get_request(cls, url, token): + """Get HTTP request to Admin API with auth header.""" + headers = {"Authorization": token, "content-type": "application/json"} + try: + response = requests.get(url, headers=headers, timeout=HTTP_TIMEOUT) + current_app.logger.debug( + "GET URL : %s, Response Code : %s", url, response.status_code + ) + if response.ok: + return json.loads(response.text) + raise BusinessException(BusinessErrorCode.INVALID_ADMIN_RESPONSE) + except requests.ConnectionError as e: + raise BusinessException(BusinessErrorCode.ADMIN_SERVICE_UNAVAILABLE) from e diff --git a/forms-flow-api/src/formsflow_api/services/external/base_bpm.py b/forms-flow-api/src/formsflow_api/services/external/base_bpm.py index 26bf9dc791..d85e1dee72 100644 --- a/forms-flow-api/src/formsflow_api/services/external/base_bpm.py +++ b/forms-flow-api/src/formsflow_api/services/external/base_bpm.py @@ -35,11 +35,15 @@ def get_request(cls, url, token): return data @classmethod - def post_request(cls, url, token, payload=None, tenant_key=None): + def post_request( + cls, url, token, payload=None, tenant_key=None, files=None + ): # pylint: disable=too-many-arguments, too-many-positional-arguments """Post HTTP request to BPM API with auth header.""" - headers = cls._get_headers_(token, tenant_key) - payload = json.dumps(payload) - response = requests.post(url, data=payload, headers=headers, timeout=120) + headers = cls._get_headers_(token, tenant_key, files) + payload = payload if files else json.dumps(payload) + response = requests.post( + url, data=payload, headers=headers, timeout=120, files=files + ) current_app.logger.debug( "POST URL : %s, Response Code : %s", url, response.status_code ) @@ -61,7 +65,7 @@ def post_request(cls, url, token, payload=None, tenant_key=None): return data @classmethod - def _get_headers_(cls, token, tenant_key=None): + def _get_headers_(cls, token, tenant_key=None, files=None): """Generate headers.""" bpm_token_api = current_app.config.get("BPM_TOKEN_API") bpm_client_id = current_app.config.get("BPM_CLIENT_ID") @@ -77,6 +81,8 @@ def _get_headers_(cls, token, tenant_key=None): "grant_type": bpm_grant_type, } if token: + if files: + return {"Authorization": token} return {"Authorization": token, "content-type": "application/json"} response = requests.post( diff --git a/forms-flow-api/src/formsflow_api/services/external/bpm.py b/forms-flow-api/src/formsflow_api/services/external/bpm.py index 8f5e16d9f8..aa40082f0c 100644 --- a/forms-flow-api/src/formsflow_api/services/external/bpm.py +++ b/forms-flow-api/src/formsflow_api/services/external/bpm.py @@ -17,15 +17,18 @@ class BPMEndpointType(IntEnum): PROCESS_DEFINITION = 1 FORM_AUTH_DETAILS = 2 MESSAGE_EVENT = 3 + DECISION_DEFINITION = 4 + DEPLOYMENT = 5 class BPMService(BaseBPMService): """This class manages all of the Camunda BPM Service.""" @classmethod - def get_all_process(cls, token): + def get_all_process(cls, token, url_path=None): """Get all process.""" url = cls._get_url_(BPMEndpointType.PROCESS_DEFINITION) + "?latestVersion=true" + url = url + url_path if url_path else url current_app.logger.debug(url) return cls.get_request(url, token) @@ -44,6 +47,15 @@ def get_process_details_by_key(cls, process_key, token): return process_definition return None + @classmethod + def get_decision(cls, token, url_path=None): + """Get decision details.""" + current_app.logger.debug("Getting decision details.") + url = cls._get_url_(BPMEndpointType.DECISION_DEFINITION) + url = url + url_path if url_path else url + current_app.logger.debug(url) + return cls.get_request(url, token) + @classmethod def post_process_start(cls, process_key, payload, token, tenant_key): """Post process start.""" @@ -76,6 +88,34 @@ def send_message(cls, data, token): url = cls._get_url_(BPMEndpointType.MESSAGE_EVENT) return cls.post_request(url, token, data) + @classmethod + def process_definition_xml(cls, process_key, token, tenant_key): + """Get process definition xml.""" + url = ( + f"{cls._get_url_(BPMEndpointType.PROCESS_DEFINITION)}/" + f"key/{process_key}/xml" + ) + if tenant_key: + url += f"?tenantId={tenant_key}" + return cls.get_request(url, token) + + @classmethod + def decision_definition_xml(cls, decision_key, token, tenant_key): + """Get decision definition xml.""" + url = ( + f"{cls._get_url_(BPMEndpointType.DECISION_DEFINITION)}/" + f"key/{decision_key}/xml" + ) + if tenant_key: + url += f"?tenantId={tenant_key}" + return cls.get_request(url, token) + + @classmethod + def post_deployment(cls, token, payload, tenant_key, files): + """Create new deployment.""" + url = f"{cls._get_url_(BPMEndpointType.DEPLOYMENT)}" + return cls.post_request(url, token, payload, tenant_key, files) + @classmethod def _get_url_(cls, endpoint_type: BPMEndpointType): """Get Url.""" @@ -88,6 +128,10 @@ def _get_url_(cls, endpoint_type: BPMEndpointType): url = f"{bpm_api_base}/engine-rest-ext/v1/admin/form/authorization" elif endpoint_type == BPMEndpointType.MESSAGE_EVENT: url = f"{bpm_api_base}/engine-rest-ext/v1/message" + elif endpoint_type == BPMEndpointType.DECISION_DEFINITION: + url = f"{bpm_api_base}/engine-rest-ext/v1/decision-definition" + elif endpoint_type == BPMEndpointType.DEPLOYMENT: + url = f"{bpm_api_base}/engine-rest-ext/v1/deployment/create" return url except BaseException as e: # pylint: disable=broad-except diff --git a/forms-flow-api/src/formsflow_api/services/external/keycloak.py b/forms-flow-api/src/formsflow_api/services/external/keycloak.py index 3c6f9e56d1..39a7fb7b35 100644 --- a/forms-flow-api/src/formsflow_api/services/external/keycloak.py +++ b/forms-flow-api/src/formsflow_api/services/external/keycloak.py @@ -4,15 +4,16 @@ import requests from flask import current_app +from formsflow_api_utils.exceptions import BusinessException from formsflow_api_utils.utils import ( - FORMSFLOW_ROLES, HTTP_TIMEOUT, - KEYCLOAK_DASHBOARD_BASE_GROUP, UserContext, profiletime, user_context, ) +from formsflow_api.constants import BusinessErrorCode + class KeycloakAdminAPIService: """This class manages all the Keycloak service API calls.""" @@ -80,46 +81,6 @@ def get_paginated_request(self, url_path, first, max_results): return response.json() return None - def get_analytics_groups(self, page_no: int, limit: int): - """Return groups for analytics users.""" - dashboard_group_list: list = [] - if page_no == 0 and limit == 0: - group_list_response = self.get_request(url_path="groups") - else: - group_list_response = self.get_paginated_request( - url_path="groups", first=page_no, max_results=limit - ) - - for group in group_list_response: - if group["name"] == KEYCLOAK_DASHBOARD_BASE_GROUP: - if group.get("subGroupCount", 0) > 0: - dashboard_group_list = self.get_subgroups(group["id"]) - else: - dashboard_group_list = list(group["subGroups"]) - return dashboard_group_list - - def get_analytics_roles(self, page_no: int, limit: int): - """Return roles for analytics users.""" - current_app.logger.debug("Getting analytics roles") - dashboard_roles_list: list = [] - client_id = self.get_client_id() - # Look for exact match - if page_no == 0 and limit == 0: - roles = self.get_request(f"clients/{client_id}/roles") - else: - roles = self.get_paginated_request( - url_path=f"clients/{client_id}/roles", - first=page_no, - max_results=limit, - ) - current_app.logger.debug("Client roles %s", roles) - for client_role in roles: - if client_role["name"] not in FORMSFLOW_ROLES: - client_role["path"] = client_role["name"] - dashboard_roles_list.append(client_role) - current_app.logger.debug("dashboard_roles_list %s", dashboard_roles_list) - return dashboard_roles_list - @user_context def get_client_id(self, **kwargs): """Get client id.""" @@ -157,7 +118,9 @@ def update_request( # pylint: disable=inconsistent-return-statements current_app.logger.debug(f"Keycloak Admin PUT API payload {data}") current_app.logger.debug(f"Keycloak response: {response}") except Exception as err_code: - raise f"Request to Keycloak Admin APIs failed., {err_code}" + raise BusinessException( + BusinessErrorCode.KEYCLOAK_REQUEST_FAIL + ) from err_code response.raise_for_status() if response.status_code == 204: return f"Updated - {url_path}" @@ -199,7 +162,9 @@ def delete_request(self, url_path, data=None) -> bool: response = self.session.request("DELETE", url, data=json.dumps(data)) current_app.logger.debug(f"keycloak Admin API DELETE request URL: {url}") except Exception as err_code: - raise f"Request to Keycloak Admin APIs failed., {err_code}" + raise BusinessException( + BusinessErrorCode.KEYCLOAK_REQUEST_FAIL + ) from err_code response.raise_for_status() return response.status_code == 204 @@ -223,7 +188,9 @@ def create_request( # pylint: disable=inconsistent-return-statements current_app.logger.debug(f"Keycloak Admin POST API payload {data}") current_app.logger.debug(f"Keycloak response: {response}") except Exception as err_code: - raise f"Request to Keycloak Admin APIs failed., {err_code}" + raise BusinessException( + BusinessErrorCode.KEYCLOAK_REQUEST_FAIL + ) from err_code response.raise_for_status() return response diff --git a/forms-flow-api/src/formsflow_api/services/factory/__init__.py b/forms-flow-api/src/formsflow_api/services/factory/__init__.py index 0192522679..88d8bcab36 100644 --- a/forms-flow-api/src/formsflow_api/services/factory/__init__.py +++ b/forms-flow-api/src/formsflow_api/services/factory/__init__.py @@ -2,3 +2,4 @@ from .keycloak_admin import KeycloakAdmin from .keycloak_factory import KeycloakFactory +from .keycloak_group_service import KeycloakGroupService diff --git a/forms-flow-api/src/formsflow_api/services/factory/keycloak_admin.py b/forms-flow-api/src/formsflow_api/services/factory/keycloak_admin.py index 67c2608135..930be1af6e 100644 --- a/forms-flow-api/src/formsflow_api/services/factory/keycloak_admin.py +++ b/forms-flow-api/src/formsflow_api/services/factory/keycloak_admin.py @@ -23,7 +23,7 @@ def update_group(self, group_id: str, data: Dict): raise NotImplementedError("Method not implemented") @abstractmethod - def get_users( # pylint: disable-msg=too-many-arguments + def get_users( # pylint: disable-msg=too-many-arguments, too-many-positional-arguments self, page_no: int, limit: int, @@ -51,20 +51,34 @@ def create_group_role(self, data: Dict): raise NotImplementedError("Method not implemented") @abstractmethod - def add_user_to_group_role(self, user_id: str, group_id: str, payload: Dict): - """Add user to role / group.""" + def add_user_to_group(self, user_id: str, group_id: str, payload: Dict): + """Add user to group.""" raise NotImplementedError("Method not implemented") @abstractmethod - def remove_user_from_group_role( - self, user_id: str, group_id: str, payload: Dict = None - ): - """Remove user to role / group.""" + def add_role_to_user(self, user_id: str, role_id: str, payload: Dict): + """Add role to user.""" + raise NotImplementedError("Method not implemented") + + @abstractmethod + def remove_user_from_group(self, user_id: str, group_id: str, payload: Dict = None): + """Remove group from user.""" + raise NotImplementedError("Method not implemented") + + @abstractmethod + def remove_role_from_user(self, user_id: str, group_id: str, payload: Dict = None): + """Remove role from user.""" raise NotImplementedError("Method not implemented") @abstractmethod - def search_realm_users( # pylint: disable-msg=too-many-arguments - self, search: str, page_no: int, limit: int, role: bool, count: bool + def search_realm_users( # pylint: disable-msg=too-many-arguments, too-many-positional-arguments + self, + search: str, + page_no: int, + limit: int, + role: bool, + count: bool, + permission: str, ): """Get users in a realm.""" raise NotImplementedError("Method not implemented") diff --git a/forms-flow-api/src/formsflow_api/services/factory/keycloak_client_service.py b/forms-flow-api/src/formsflow_api/services/factory/keycloak_client_service.py index 5080c78555..c7c061519a 100644 --- a/forms-flow-api/src/formsflow_api/services/factory/keycloak_client_service.py +++ b/forms-flow-api/src/formsflow_api/services/factory/keycloak_client_service.py @@ -2,147 +2,54 @@ import re from http import HTTPStatus -from typing import Dict, List +from typing import Dict from flask import current_app from formsflow_api_utils.exceptions import BusinessException from formsflow_api_utils.utils.user_context import UserContext, user_context from formsflow_api.constants import BusinessErrorCode -from formsflow_api.services import KeycloakAdminAPIService, UserService -from .keycloak_admin import KeycloakAdmin +from .keycloak_group_service import KeycloakGroupService -class KeycloakClientService(KeycloakAdmin): +class KeycloakClientService(KeycloakGroupService): """Keycloak Admin implementation for client related operations.""" - def __init__(self): - """Initialize client.""" - self.client = KeycloakAdminAPIService() - self.user_service = UserService() - - def __populate_user_roles(self, user_list: List) -> List: - """Collects roles for a user list and populates the role attribute.""" - for user in user_list: - user["role"] = ( - self.client.get_user_roles(user.get("id")) if user.get("id") else [] - ) - return user_list - - def get_analytics_groups(self, page_no: int, limit: int): + @user_context + def get_analytics_groups(self, page_no: int, limit: int, **kwargs): """Get analytics roles.""" - return self.client.get_analytics_roles(page_no, limit) - - def get_group(self, group_id: str): - """Get role by role name.""" - client_id = self.client.get_client_id() - response = self.client.get_request( - url_path=f"clients/{client_id}/roles/{group_id}" - ) - response["id"] = response.get("name", None) - return response - - def get_users( # pylint: disable-msg=too-many-arguments - self, - page_no: int, - limit: int, - role: bool, - group_name: str, - count: bool, - search: str, - ): - """Get users under this client with formsflow-reviewer role.""" - # group_name was hardcoded before as `formsflow-reviewer` make sure - # neccessary changes in the client side are made for role based env - current_app.logger.debug( - "Fetching client based users from keycloak with formsflow-reviewer role..." - ) - client_id = self.client.get_client_id() - url = f"clients/{client_id}/roles/{group_name}/users" - users_list = self.client.get_request(url) - if search: - users_list = self.user_service.user_search(search, users_list) - users_count = len(users_list) if count else None + user: UserContext = kwargs["user"] + tenant_key = user.tenant_key + groups = super().get_analytics_groups(page_no=page_no, limit=limit) + response = [ + group for group in groups if group["name"].startswith(f"/{tenant_key}") + ] if page_no and limit: - users_list = self.user_service.paginate(users_list, page_no, limit) - if role: - users_list = self.__populate_user_roles(users_list) - return (users_list, users_count) + response = self.user_service.paginate( + response, page_number=page_no, page_size=limit + ) + return response def update_group(self, group_id: str, data: Dict): - """Update keycloak role by role name.""" - client_id = self.client.get_client_id() - return self.client.update_request( - url_path=f"clients/{client_id}/roles/{group_id}", - data=data, - ) - - def get_groups_roles(self, search: str, sort_order: str): - """Get roles.""" - response = self.client.get_roles(search) - for role in response: - role["id"] = role.get("id") - role["description"] = role.get("description") - return self.sort_results(response, sort_order) - - def delete_group(self, group_id: str): - """Delete role by role name.""" - client_id = self.client.get_client_id() - return self.client.delete_request( - url_path=f"clients/{client_id}/roles/{group_id}" - ) + """Update keycloak group.""" + data = self.append_tenant_key(data) + return super().update_group(group_id, data) def create_group_role(self, data: Dict): - """Create role.""" - client_id = self.client.get_client_id() - response = self.client.create_request( - url_path=f"clients/{client_id}/roles", data=data - ) - role_name = response.headers["Location"].split("/")[-1] - return {"id": role_name} - - def add_user_to_group_role(self, user_id: str, group_id: str, payload: Dict): - """Add user to role.""" - client_id = self.client.get_client_id() - data = { - "containerId": client_id, - "id": group_id, - "name": payload.get("name"), - } - return self.client.create_request( - url_path=f"users/{user_id}/role-mappings/clients/{client_id}", data=[data] - ) - - def remove_user_from_group_role( - self, user_id: str, group_id: str, payload: Dict = None - ): - """Remove user to role.""" - client_id = self.client.get_client_id() - data = { - "containerId": client_id, - "id": group_id, - "name": payload.get("name"), - } - return self.client.delete_request( - url_path=f"users/{user_id}/role-mappings/clients/{client_id}", data=[data] - ) - - def search_realm_users( # pylint: disable-msg=too-many-arguments - self, search: str, page_no: int, limit: int, role: bool, count: bool - ): - """Search users in a realm.""" - if not page_no or not limit: - raise BusinessException(BusinessErrorCode.MISSING_PAGINATION_PARAMETERS) - - user_list, users_count = self.get_tenant_users(search, page_no, limit, count) - if role: - user_list = self.__populate_user_roles(user_list) - return (user_list, users_count) + """Create group.""" + current_app.logger.debug("Creating tenant group...") + self.append_tenant_key(data) + return super().create_group_role(data) @user_context def get_tenant_users( - self, search: str, page_no: int, limit: int, count: bool, **kwargs + self, + search: str, + page_no: int, + limit: int, + count: bool, + **kwargs, ): # pylint: disable=too-many-arguments """Return list of users in the tenant.""" # Search and attribute search (q) in Keycloak doesn't work together. @@ -189,20 +96,29 @@ def add_user_to_tenant( tenant_keys.append(tenant_key) payload = {"attributes": {"tenantKey": tenant_keys}} self.client.update_request(f"users/{user_id}", payload) - # Add user to role - client_id = self.client.get_client_id() + # Add user to group for role in data.get("roles"): + group_id = role.get("role_id") role_data = { - "containerId": client_id, - "id": role.get("role_id"), - "name": role.get("name"), + "userId": user_id, + "groupId": group_id, } current_app.logger.debug( f"Adding user: {user_email} to role {role.get('name')}." ) - self.client.create_request( - url_path=f"users/{user_id}/role-mappings/clients/{client_id}", - data=[role_data], + self.client.update_request( + url_path=f"users/{user_id}/groups/{group_id}", data=role_data ) return {"message": "User added to tenant"}, HTTPStatus.OK raise BusinessException(BusinessErrorCode.USER_NOT_FOUND) + + @user_context + def append_tenant_key(self, data, **kwargs): # pylint: disable=too-many-locals + """Append tenantkey to main group.""" + user: UserContext = kwargs["user"] + tenant_key = user.tenant_key + name = data["name"].lstrip("/") + # Prefix the tenant_key to the main group + data["name"] = f"{tenant_key}-{name}" + current_app.logger.debug(f"Tenant group: {data['name']}") + return data diff --git a/forms-flow-api/src/formsflow_api/services/factory/keycloak_group_service.py b/forms-flow-api/src/formsflow_api/services/factory/keycloak_group_service.py index f3fc166739..f3984562bf 100644 --- a/forms-flow-api/src/formsflow_api/services/factory/keycloak_group_service.py +++ b/forms-flow-api/src/formsflow_api/services/factory/keycloak_group_service.py @@ -6,6 +6,7 @@ import requests from flask import current_app from formsflow_api_utils.exceptions import BusinessException +from formsflow_api_utils.utils import VIEW_DASHBOARDS, UserContext, user_context from formsflow_api.constants import BusinessErrorCode from formsflow_api.services import KeycloakAdminAPIService, UserService @@ -21,24 +22,60 @@ def __init__(self): self.client = KeycloakAdminAPIService() self.user_service = UserService() - def __populate_user_groups(self, user_list: List) -> List: + @user_context + def __populate_user_groups(self, user_list: List, **kwargs) -> List: """Collect groups for a user list and populate the role attribute.""" for user in user_list: - user["role"] = ( + user_groups = ( self.client.get_user_groups(user.get("id")) if user.get("id") else [] ) + if current_app.config.get("MULTI_TENANCY_ENABLED"): + logged_user: UserContext = kwargs["user"] + user_groups = [ + group + for group in user_groups + if group["path"].startswith(f"/{logged_user.tenant_key}") + ] + user["role"] = user_groups + return user_list + + def __populate_user_roles(self, user_list: List) -> List: + """Collects roles for a user list and populates the role attribute.""" + for user in user_list: + user["role"] = ( + self.client.get_user_roles(user.get("id")) if user.get("id") else [] + ) return user_list def get_analytics_groups(self, page_no: int, limit: int): """Get analytics groups.""" - return self.client.get_analytics_groups(page_no, limit) + dashboard_group_list: list = [] + group_list_response = self.client.get_request( + url_path="groups?briefRepresentation=false" + ) + group_list_response = self.flat(group_list_response, dashboard_group_list) + response = [ + group + for group in group_list_response + if VIEW_DASHBOARDS in group.get("permissions", []) + ] + if ( + page_no + and limit + and current_app.config.get("MULTI_TENANCY_ENABLED") is False + ): + response = self.user_service.paginate( + response, page_number=page_no, page_size=limit + ) + return response def get_group(self, group_id: str): """Get group by group_id.""" response = self.client.get_request(url_path=f"groups/{group_id}") return self.format_response(response) - def get_users( # pylint: disable-msg=too-many-arguments + @user_context + def get_users( # pylint: disable-msg=too-many-arguments, too-many-positional-arguments self, page_no: int, limit: int, @@ -46,36 +83,52 @@ def get_users( # pylint: disable-msg=too-many-arguments group_name: str, count: bool, search: str, + **kwargs, ): """Get users under formsflow-reviewer group.""" + user: UserContext = kwargs["user"] user_list: List[Dict] = [] current_app.logger.debug( f"Fetching users from keycloak under {group_name} group..." ) user_count = None + user_list = [] if group_name: + if current_app.config.get("MULTI_TENANCY_ENABLED"): + group_name = group_name.replace("/", f"/{user.tenant_key}-", 1) + group = self.client.get_request(url_path=f"group-by-path/{group_name}") group_id = group.get("id") url_path = f"groups/{group_id}/members" user_list = self.client.get_request(url_path) - if search: - user_list = self.user_service.user_search(search, user_list) - user_count = len(user_list) if count else None - if page_no and limit: - user_list = self.user_service.paginate(user_list, page_no, limit) + + if search: + user_list = self.user_service.user_search(search, user_list) + user_count = len(user_list) if count else None + if page_no and limit: + user_list = self.user_service.paginate(user_list, page_no, limit) if role: user_list = self.__populate_user_groups(user_list) return (user_list, user_count) def update_group(self, group_id: str, data: Dict): """Update group details.""" + permissions = data.pop("permissions") data = self.add_description(data) data["name"] = data["name"].split("/")[-1] + self.update_group_permission_mapping(group_id, permissions) return self.client.update_request(url_path=f"groups/{group_id}", data=data) - def get_groups_roles(self, search: str, sort_order: str): + @user_context + def get_groups_roles(self, search: str, sort_order: str, **kwargs): """Get groups.""" response = self.client.get_groups() + if current_app.config.get("MULTI_TENANCY_ENABLED"): + current_app.logger.debug("Getting groups for tenant...") + user: UserContext = kwargs["user"] + response = [ + group for group in response if group["name"].startswith(user.tenant_key) + ] flat_response: List[Dict] = [] result_list = self.sort_results(self.flat(response, flat_response), sort_order) if search: @@ -91,6 +144,7 @@ def create_group_role(self, data: Dict): Split name parameter to create group/subgroups """ + permissions = data.pop("permissions") data = self.add_description(data) data["name"] = ( data["name"].lstrip("/") if data["name"].startswith("/") else data["name"] @@ -119,6 +173,13 @@ def create_group_role(self, data: Dict): ) group_id = response["id"] url_path = f"groups/{group_id}/children" + client_id = self.client.get_client_id() + try: + self.create_group_permission_mapping(group_id, permissions, client_id) + except Exception as err: + current_app.logger.debug(f"Role mapping creation failed: {err}") + self.delete_group(group_id) + raise BusinessException(BusinessErrorCode.ROLE_MAPPING_FAILED) from err return {"id": group_id} def add_description(self, data: Dict): @@ -171,9 +232,13 @@ def search_group(self, search, data): ) return search_list - def format_response(self, data): + @user_context + def format_response(self, data, **kwargs): """Format group response.""" + user: UserContext = kwargs["user"] + tenant_key = user.tenant_key data["description"] = "" + data["permissions"] = [] data["name"] = data.get("path") if data.get("attributes") != {}: # Reaarange description data["description"] = ( @@ -181,9 +246,16 @@ def format_response(self, data): if data["attributes"].get("description") else "" ) + if data.get("clientRoles") != {}: # Reaarange permissions + client_name = current_app.config.get("JWT_OIDC_AUDIENCE") + client_role = f"{tenant_key}-{client_name}" if tenant_key else client_name + current_app.logger.debug("Client name %s", client_role) + client_roles = data["clientRoles"].get(client_role) + if client_roles: + data["permissions"] = client_roles return data - def add_user_to_group_role(self, user_id: str, group_id: str, payload: Dict): + def add_user_to_group(self, user_id: str, group_id: str, payload: Dict): """Add user to group.""" data = { "realm": current_app.config.get("KEYCLOAK_URL_REALM"), @@ -194,21 +266,87 @@ def add_user_to_group_role(self, user_id: str, group_id: str, payload: Dict): url_path=f"users/{user_id}/groups/{group_id}", data=data ) - def remove_user_from_group_role( - self, user_id: str, group_id: str, payload: Dict = None - ): + def add_role_to_user(self, user_id: str, role_id: str, payload: Dict): + """Add user to role.""" + client_id = self.client.get_client_id() + data = { + "containerId": client_id, + "id": role_id, + "name": payload.get("name"), + } + return self.client.create_request( + url_path=f"users/{user_id}/role-mappings/clients/{client_id}", data=[data] + ) + + def remove_user_from_group(self, user_id: str, group_id: str, payload: Dict = None): """Remove user to group.""" return self.client.delete_request(url_path=f"users/{user_id}/groups/{group_id}") - def search_realm_users( # pylint: disable-msg=too-many-arguments - self, search: str, page_no: int, limit: int, role: bool, count: bool + def remove_role_from_user(self, user_id: str, group_id: str, payload: Dict = None): + """Remove role from user.""" + client_id = self.client.get_client_id() + data = { + "containerId": client_id, + "id": group_id, + "name": payload.get("name"), + } + return self.client.delete_request( + url_path=f"users/{user_id}/role-mappings/clients/{client_id}", data=[data] + ) + + @user_context + def search_realm_users( # pylint: disable-msg=too-many-arguments, too-many-positional-arguments + self, + search: str, + page_no: int, + limit: int, + role: bool, + count: bool, + permission: str, + **kwargs, ): """Search users in a realm.""" - if not page_no or not limit: - raise BusinessException(BusinessErrorCode.MISSING_PAGINATION_PARAMETERS) + user: UserContext = kwargs["user"] + multitenancy = current_app.config.get("MULTI_TENANCY_ENABLED", False) + + tenant_key = user.tenant_key + # Initial url + url = "users?" + + if page_no and limit: + url = f"users?first={(page_no - 1) * limit}&max={limit}" + + if search: + # to add additional query parameter need to check url ends with nothing or any other parameter + url += f"{'' if url.endswith('?') else '&'}search={search}" + + # if multitenancy enabled + if multitenancy: + url = f"users?q=tenantKey:{tenant_key}" + + current_app.logger.debug( + f"{'Getting tenant users...' if multitenancy else 'Getting users...'}" + ) + user_list = self.client.get_request(url) + + # checking the specific permission(roles) + if permission: + users_with_roles = self.__populate_user_roles(user_list=user_list) + user_list = self.user_service.filter_by_permission( + users_with_roles, permission=permission + ) + + if multitenancy and search: + user_list = self.user_service.user_search(search, user_list) + users_count = ( + len(user_list) + if current_app.config.get("MULTI_TENANCY_ENABLED") + else self.client.get_realm_users_count(search) if count else None + ) + + if multitenancy and page_no and limit: + user_list = self.user_service.paginate(user_list, page_no, limit) - user_list = self.client.get_realm_users(search, page_no, limit) - users_count = self.client.get_realm_users_count(search) if count else None if role: user_list = self.__populate_user_groups(user_list) return (user_list, users_count) @@ -218,3 +356,42 @@ def add_user_to_tenant(self, data: Dict): return { "message": "The requested operation is not supported." }, HTTPStatus.BAD_REQUEST + + def create_group_permission_mapping(self, group_id, permissions, client_id): + """Set permission mapping to group.""" + current_app.logger.debug("Setting permission mapping to group") + roles = self.client.get_roles() + role_data_list = [] + for role in roles: + if permissions and role.get("name") in permissions: + role_data = { + "containerId": client_id, + "id": role.get("id"), + "clientRole": True, + "name": role.get("name"), + } + role_data_list.append(role_data) + self.client.create_request( + url_path=f"groups/{group_id}/role-mappings/clients/{client_id}", + data=role_data_list, + ) + + def update_group_permission_mapping(self, group_id, permissions): + """Update permission mapping to group.""" + current_app.logger.debug("Updating permission mapping to group") + client_id = self.client.get_client_id() + permission_list = self.client.get_request( + url_path=f"groups/{group_id}/role-mappings/clients/{client_id}" + ) + + # Determine permissions to remove + role_remove_data_list = [] + for permission in permission_list: + if permission["name"] not in permissions: + role_remove_data_list.append(permission) + self.client.delete_request( + url_path=f"groups/{group_id}/role-mappings/clients/{client_id}", + data=role_remove_data_list, + ) + # Add permissions + self.create_group_permission_mapping(group_id, permissions, client_id) diff --git a/forms-flow-api/src/formsflow_api/services/filter.py b/forms-flow-api/src/formsflow_api/services/filter.py index d3047730b6..df11f24e7a 100644 --- a/forms-flow-api/src/formsflow_api/services/filter.py +++ b/forms-flow-api/src/formsflow_api/services/filter.py @@ -2,11 +2,11 @@ from flask import current_app from formsflow_api_utils.exceptions import BusinessException -from formsflow_api_utils.utils import ADMIN_GROUP +from formsflow_api_utils.utils import ADMIN from formsflow_api_utils.utils.user_context import UserContext, user_context from formsflow_api.constants import BusinessErrorCode -from formsflow_api.models import Filter +from formsflow_api.models import Filter, User from formsflow_api.schemas import FilterSchema filter_schema = FilterSchema() @@ -104,7 +104,7 @@ def get_user_filters(**kwargs): roles=user.group_or_roles, user=user.user_name, tenant=tenant_key, - admin=ADMIN_GROUP in user.roles, + admin=ADMIN in user.roles, ) filter_data = filter_schema.dump(filters, many=True) default_variables = [ @@ -114,14 +114,18 @@ def get_user_filters(**kwargs): # User who created the filter or admin have edit permission. for filter_item in filter_data: filter_item["editPermission"] = ( - filter_item["createdBy"] == user.user_name or ADMIN_GROUP in user.roles + filter_item["createdBy"] == user.user_name or ADMIN in user.roles ) # Check and add default variables if not present filter_item["variables"] = filter_item["variables"] or [] filter_item["variables"] += [ var for var in default_variables if var not in filter_item["variables"] ] - return filter_data + response = {"filters": filter_data} + # get user default filter + user_data = User.get_user_by_user_name(user_name=user.user_name) + response["defaultFilter"] = user_data.default_filter if user_data else None + return response @staticmethod @user_context @@ -134,7 +138,7 @@ def get_filter_by_id(filter_id, **kwargs): roles=user.group_or_roles, user=user.user_name, tenant=tenant_key, - admin=ADMIN_GROUP in user.roles, + admin=ADMIN in user.roles, ) if filter_result: return filter_result @@ -149,7 +153,7 @@ def mark_inactive(filter_id, **kwargs): filter_result = Filter.find_active_auth_filter_by_id( filter_id=filter_id, user=user.user_name, - admin=ADMIN_GROUP in user.roles, + admin=ADMIN in user.roles, ) if filter_result: if ( @@ -172,7 +176,7 @@ def update_filter(filter_id, filter_data, **kwargs): filter_result = Filter.find_active_auth_filter_by_id( filter_id=filter_id, user=user.user_name, - admin=ADMIN_GROUP in user.roles, + admin=ADMIN in user.roles, ) if filter_result: @@ -186,3 +190,46 @@ def update_filter(filter_id, filter_data, **kwargs): filter_result.update(filter_data) return filter_result raise BusinessException(BusinessErrorCode.FILTER_NOT_FOUND) + + @staticmethod + @user_context + def update_filter_variables(task_variables, form_id, **kwargs): + """Update filter variables for all active filters associated with a given form ID. + + It retrieves the task variables from the form mapper table, + creates a mapping of task variable keys to their labels & updates the filter variables for each active filter. + The function ensures that default filter variables ("applicationId" and "formName") are always included + """ + current_app.logger.debug("Updating filter variables..") + user: UserContext = kwargs["user"] + filters = Filter.find_all_active_filters_formid( + form_id=form_id, tenant=user.tenant_key + ) + for filter_item in filters: + # Create a dictionary mapping keys to labels from task_variables + key_to_label = {task_var["key"]: task_var for task_var in task_variables} + default_filter_variables = ["applicationId", "formName"] + # For each filter variable: + # - Include it in the result if its name is in task variables or default filter variables + # - Use the task variable label if available, otherwise keep the existing label + # - Retain the existing labels for default filter variables + + result = [ + { + "name": filter_variable["name"], + "label": ( + filter_variable["label"] + if filter_variable["name"] in default_filter_variables + else key_to_label.get( + filter_variable["name"], filter_variable + ).get("label", filter_variable["label"]) + ), + } + for filter_variable in filter_item.variables + if filter_variable["name"] in key_to_label.keys() + or filter_variable["name"] in default_filter_variables + ] + # Update filter variables in database + filter_obj = Filter.query.get(filter_item.id) + filter_obj.variables = result + filter_obj.save() diff --git a/forms-flow-api/src/formsflow_api/services/form_history_logs.py b/forms-flow-api/src/formsflow_api/services/form_history_logs.py index 69584cca82..8205f91be2 100644 --- a/forms-flow-api/src/formsflow_api/services/form_history_logs.py +++ b/forms-flow-api/src/formsflow_api/services/form_history_logs.py @@ -1,6 +1,5 @@ """This exposes Form history service.""" -from http import HTTPStatus from uuid import uuid1 from formsflow_api_utils.exceptions import BusinessException @@ -9,7 +8,9 @@ from formsflow_api.constants import BusinessErrorCode from formsflow_api.models import FormHistory -from formsflow_api.schemas import FormHistorySchema +from formsflow_api.schemas import FormHistoryReqSchema, FormHistorySchema + +from .process import ProcessService class FormHistoryService: @@ -39,18 +40,16 @@ def create_form_log_with_clone(data, **kwargs): response = formio_service.create_form(data, form_io_token) # Version details is used set version number version_data_schema = FormHistorySchema() + version_data = FormHistory.get_latest_version(parent_form_id) + major_version, minor_version = 0, 0 + if version_data: + major_version = version_data.major_version + minor_version = version_data.minor_version if data.get("newVersion") is True: - version_number = "v" + str( - FormHistory.get_version_count(parent_form_id) + 1 - ) + major_version += 1 + minor_version = 0 else: - version_data = version_data_schema.dump( - FormHistory.get_latest_version(parent_form_id) - ) - version_number = ( - version_data.get("changeLog") - and version_data.get("changeLog").get("version") - ) or None + minor_version += 1 # Form history data to save into form history table form_history_data = { "form_id": form_id, @@ -60,8 +59,9 @@ def create_form_log_with_clone(data, **kwargs): "change_log": { "cloned_form_id": response.get("_id"), "new_version": data.get("newVersion") or False, - "version": version_number, }, + "major_version": major_version, + "minor_version": minor_version, } create_form_history = FormHistory.create_history(form_history_data) return version_data_schema.dump(create_form_history) @@ -94,17 +94,34 @@ def create_form_logs_without_clone(data, **kwargs): form_logs_data["created_by"] = user_name form_logs_data["form_id"] = data.get("formId") form_logs_data["parent_form_id"] = data.get("parentFormId") + # Capture version details in form history + version_data = FormHistory.get_latest_version(data.get("parentFormId")) + if version_data: + form_logs_data["major_version"] = version_data.major_version + form_logs_data["minor_version"] = version_data.minor_version history_schema = FormHistorySchema() create_form_history = FormHistory.create_history(form_logs_data) return history_schema.dump(create_form_history) return None @staticmethod - def get_all_history(form_id: str): + def get_all_history(form_id: str, request_args): """Get all history.""" assert form_id is not None - form_histories = FormHistory.fetch_histories_by_parent_id(form_id) + dict_data = FormHistoryReqSchema().load(request_args) or {} + page_no = dict_data.get("page_no") + limit = dict_data.get("limit") + form_histories, count = FormHistory.fetch_histories_by_parent_id( + form_id, page_no, limit + ) + published_histories = FormHistory.fetch_published_history_by_parent_form_id( + form_id + ) if form_histories: - form_history_schema = FormHistorySchema(many=True) - return form_history_schema.dump(form_histories), HTTPStatus.OK + # populate published on and publised by to the history + form_histories = ProcessService.populate_published_histories( + form_histories, published_histories + ) + form_history_schema = FormHistorySchema() + return form_history_schema.dump(form_histories, many=True), count raise BusinessException(BusinessErrorCode.INVALID_FORM_ID) diff --git a/forms-flow-api/src/formsflow_api/services/form_process_mapper.py b/forms-flow-api/src/formsflow_api/services/form_process_mapper.py index 2004adaba4..ac06e6d06a 100644 --- a/forms-flow-api/src/formsflow_api/services/form_process_mapper.py +++ b/forms-flow-api/src/formsflow_api/services/form_process_mapper.py @@ -1,34 +1,51 @@ """This exposes form process mapper service.""" -from typing import Set +import json +import re +import xml.etree.ElementTree as ET +from typing import List, Set, Tuple from flask import current_app from formsflow_api_utils.exceptions import BusinessException +from formsflow_api_utils.services.external import FormioService from formsflow_api_utils.utils.enums import FormProcessMapperStatus from formsflow_api_utils.utils.user_context import UserContext, user_context -from formsflow_api.constants import BusinessErrorCode +from formsflow_api.constants import ( + BusinessErrorCode, + default_flow_xml_data, + default_task_variables, +) from formsflow_api.models import ( + Application, Authorization, AuthType, Draft, + FormHistory, FormProcessMapper, + Process, + ProcessStatus, + ProcessType, ) from formsflow_api.schemas import FormProcessMapperSchema +from formsflow_api.services.authorization import AuthorizationService from formsflow_api.services.external.bpm import BPMService +from .form_history_logs import FormHistoryService +from .process import ProcessService + -class FormProcessMapperService: +class FormProcessMapperService: # pylint: disable=too-many-public-methods """This class manages form process mapper service.""" @staticmethod @user_context - def get_all_forms( + def get_all_forms( # pylint: disable=too-many-positional-arguments page_number: int, limit: int, - form_name: str, - sort_by: str, - sort_order: str, + search: list, + sort_by: list, + sort_order: list, form_type: str, is_active, is_designer: bool, @@ -38,6 +55,7 @@ def get_all_forms( """Get all forms.""" user: UserContext = kwargs["user"] authorized_form_ids: Set[str] = [] + current_app.logger.info(f"Listing forms for designer: {is_designer}") if active_forms: mappers, get_all_mappers_count = FormProcessMapper.find_all_active_forms( page_number=page_number, @@ -65,7 +83,7 @@ def get_all_forms( mappers, get_all_mappers_count = list_form_mappers( page_number=page_number, limit=limit, - form_name=form_name, + search=search, sort_by=sort_by, sort_order=sort_order, form_ids=authorized_form_ids, @@ -77,30 +95,6 @@ def get_all_forms( get_all_mappers_count, ) - @staticmethod - def get_all_mappers( - page_number: int, - limit: int, - form_name: str, - sort_by: str, - sort_order: str, - process_key: list = None, - ): # pylint: disable=too-many-arguments - """Get all form process mappers.""" - mappers, get_all_mappers_count = FormProcessMapper.find_all_active( - page_number=page_number, - limit=limit, - form_name=form_name, - sort_by=sort_by, - sort_order=sort_order, - process_key=process_key, - ) - mapper_schema = FormProcessMapperSchema() - return ( - mapper_schema.dump(mappers, many=True), - get_all_mappers_count, - ) - @staticmethod def get_mapper_count(form_name=None): """Get form process mapper count.""" @@ -126,6 +120,16 @@ def get_mapper(form_process_mapper_id: int, **kwargs): raise BusinessException(BusinessErrorCode.INVALID_FORM_PROCESS_MAPPER_ID) + @staticmethod + def get_form_version(mapper): + """Get form versions.""" + version_data = FormHistory.get_latest_version(mapper.parent_form_id) + major_version, minor_version = 1, 0 + if version_data: + major_version = version_data.major_version + minor_version = version_data.minor_version + return major_version, minor_version + @staticmethod @user_context def get_mapper_by_formid(form_id: str, **kwargs): @@ -138,6 +142,12 @@ def get_mapper_by_formid(form_id: str, **kwargs): raise PermissionError("Tenant authentication failed.") mapper_schema = FormProcessMapperSchema() response = mapper_schema.dump(mapper) + # Include form versions + major_version, minor_version = FormProcessMapperService.get_form_version( + mapper + ) + response["majorVersion"] = major_version + response["minorVersion"] = minor_version if response.get("deleted") is False: return response @@ -150,7 +160,7 @@ def create_mapper(data, **kwargs): user: UserContext = kwargs["user"] data["created_by"] = user.user_name data["tenant"] = user.tenant_key - FormProcessMapperService._update_process_tenant(data, user) + data["process_tenant"] = user.tenant_key return FormProcessMapper.create_from_dict(data) @staticmethod @@ -174,16 +184,12 @@ def update_mapper(form_process_mapper_id, data, **kwargs): mapper = FormProcessMapper.find_form_by_id( form_process_mapper_id=form_process_mapper_id ) - if not data.get("process_key") and data.get("process_name"): - data["process_key"] = None - data["process_name"] = None if not data.get("comments"): data["comments"] = None if mapper: if tenant_key is not None and mapper.tenant != tenant_key: raise BusinessException(BusinessErrorCode.PERMISSION_DENIED) - FormProcessMapperService._update_process_tenant(data, user) mapper.update(data) return mapper @@ -201,6 +207,11 @@ def mark_inactive_and_delete(form_process_mapper_id: int, **kwargs) -> None: if application: if tenant_key is not None and application.tenant != tenant_key: raise PermissionError("Tenant authentication failed.") + count = Application.get_total_application_corresponding_to_mapper_id( + form_process_mapper_id + ) + if count > 0: + raise BusinessException(BusinessErrorCode.RESTRICT_FORM_DELETE) application.mark_inactive() # fetching all draft application application and delete it draft_applications = Draft.get_draft_by_parent_form_id( @@ -215,7 +226,7 @@ def mark_inactive_and_delete(form_process_mapper_id: int, **kwargs) -> None: @staticmethod def mark_unpublished(form_process_mapper_id): """Mark form process mapper as inactive.""" - mapper = FormProcessMapper.find_form_by_id_active_status( + mapper = FormProcessMapper.find_form_by_id( form_process_mapper_id=form_process_mapper_id ) if mapper: @@ -269,13 +280,665 @@ def check_tenant_authorization(mapper_id: int, **kwargs) -> int: @staticmethod @user_context - def check_tenant_authorization_by_formid(form_id: int, **kwargs) -> int: + def check_tenant_authorization_by_formid( + form_id: int, mapper_data=None, **kwargs + ) -> int: """Check if tenant has permission to access the resource.""" user: UserContext = kwargs["user"] tenant_key = user.tenant_key if tenant_key is None: return - mapper = FormProcessMapper.find_form_by_form_id(form_id=form_id) + # If mapper data is provided as an argument, there's no need to fetch it from the database + mapper = ( + mapper_data + if mapper_data + else FormProcessMapper.find_form_by_form_id(form_id=form_id) + ) if mapper is not None and mapper.tenant != tenant_key: raise BusinessException(BusinessErrorCode.PERMISSION_DENIED) return + + @staticmethod + def validate_process_and_update_mapper(name, mapper): + """Validate process name/key exists, if exists update name & update mapper.""" + current_app.logger.info(f"Validating process key already exists. {name}") + process = Process.find_process_by_name_key(name=name, process_key=name) + if process: + # Since the process key/name already exists create updated process key by appending mapper Id + # Update mapper with updated value + updated_process_name = f"{name}_{mapper.id}" + mapper.process_key = updated_process_name + mapper.process_name = updated_process_name + mapper.save() + return updated_process_name + return None + + @staticmethod + def mapper_create(mapper_json): + """Service to handle mapper create.""" + current_app.logger.debug("Creating mapper..") + mapper_json["taskVariables"] = json.dumps( + mapper_json.get("taskVariables") or [] + ) + mapper_schema = FormProcessMapperSchema() + dict_data = mapper_schema.load(mapper_json) + mapper = FormProcessMapperService.create_mapper(dict_data) + + FormProcessMapperService.unpublish_previous_mapper(dict_data) + return mapper + + @staticmethod + def form_design_update(data, form_id): + """Service to handle form design update.""" + mapper = FormProcessMapper.find_form_by_form_id(form_id=form_id) + FormProcessMapperService.check_tenant_authorization_by_formid( + form_id=form_id, mapper_data=mapper + ) + formio_service = FormioService() + form_io_token = formio_service.get_formio_access_token() + response = formio_service.update_form(form_id, data, form_io_token) + # if user selected to continue with minor version after unpublish + if mapper.prompt_new_version: + mapper.update({"prompt_new_version": False}) + FormHistoryService.create_form_log_with_clone(data=data) + return response + + @classmethod + @user_context + def create_default_process(cls, process_name, status=ProcessStatus.DRAFT, **kwargs): + """Create process with default workflow.""" + user: UserContext = kwargs["user"] + process_dict = { + "name": process_name, + "process_key": process_name, + "parent_process_key": process_name, + "process_type": ProcessType.BPMN, + "status": status, + "process_data": default_flow_xml_data(process_name).encode("utf-8"), + "tenant": user.tenant_key, + "major_version": 1, + "minor_version": 0, + "created_by": user.user_name, + } + process = Process.create_from_dict(process_dict) + return process + + @staticmethod + @user_context + def create_form(data, is_designer, **kwargs): # pylint:disable=too-many-locals + """Service to handle form create.""" + current_app.logger.info("Creating form..") + user: UserContext = kwargs["user"] + # Initialize formio service and get formio token to create the form + formio_service = FormioService() + form_io_token = formio_service.get_formio_access_token() + # creating form and get response from formio + response = formio_service.create_form(data, form_io_token) + form_id = response.get("_id") + parent_form_id = data.get("parentFormId", form_id) + # is_new_form=True if creating a new form, False if creating a new version + is_new_form = parent_form_id == form_id + process_key = None + anonymous = False + description = data.get("description", "") + task_variable = [*default_task_variables] + is_migrated = True + current_app.logger.info(f"Creating new form {is_new_form}") + # If creating new version for a existing form, fetch process key, name from mapper + if not is_new_form: + current_app.logger.debug("Fetching details from mapper") + mapper = FormProcessMapper.get_latest_by_parent_form_id(parent_form_id) + process_name = mapper.process_name + process_key = mapper.process_key + anonymous = mapper.is_anonymous + description = mapper.description + task_variable = json.loads(mapper.task_variable) + is_migrated = mapper.is_migrated + else: + # if new form, form name is kept as process_name & process key + process_name = response.get("name") + # process key/Id doesn't support numbers & special characters at start + # special characters anywhere so clean them before setting as process key + process_name = ProcessService.clean_form_name(process_name) + + mapper_data = { + "formId": form_id, + "formName": response.get("title"), + "description": description, + "formType": response.get("type"), + "processKey": process_name, + "processName": process_key if process_key else process_name, + "formTypeChanged": True, + "parentFormId": parent_form_id, + "titleChanged": True, + "formRevisionNumber": "V1", + "status": FormProcessMapperStatus.INACTIVE.value, + "anonymous": anonymous, + "taskVariables": task_variable, + "isMigrated": is_migrated, + } + + mapper = FormProcessMapperService.mapper_create(mapper_data) + current_app.logger.debug("Creating form log with clone..") + FormHistoryService.create_form_log_with_clone( + data={ + **response, + "parentFormId": parent_form_id, + "newVersion": True, + "componentChanged": True, + } + ) + if is_new_form: + # create default data for authorization of the resource + authorization_data = { + "application": { + "resourceId": parent_form_id, + "resourceDetails": {"submitter": True}, + "roles": [], + "userName": None, + }, + "designer": { + "resourceId": parent_form_id, + "resourceDetails": {}, + "roles": [], + "userName": user.user_name, + }, + "form": { + "resourceId": parent_form_id, + "resourceDetails": {}, + "roles": [], + }, + } + current_app.logger.debug( + "Creating default data for authorization of the resource.." + ) + AuthorizationService.create_or_update_resource_authorization( + authorization_data, is_designer=is_designer + ) + # validate process key already exists, if exists append mapper id to process_key. + updated_process_name = ( + FormProcessMapperService.validate_process_and_update_mapper( + process_name, mapper + ) + ) + process_name = ( + updated_process_name if updated_process_name else process_name + ) + process_data = data.get("processData") + process_type = data.get("processType") + if process_data and process_type: + # Incase of duplicate form we get process data from payload + ProcessService.create_process( + process_data, process_type, process_name, process_name + ) + else: + # create entry in process with default flow. + FormProcessMapperService.create_default_process(process_name) + return response + + def _remove_tenant_key(self, form_json, tenant_key): + """Remove tenant key from path & name.""" + tenant_prefix = f"{tenant_key}-" + form_path = form_json.get("path", "") + form_name = form_json.get("name", "") + current_app.logger.info( + f"Removing tenant key from path: {form_path} & name: {form_name}" + ) + if form_path.startswith(tenant_prefix): + form_json["path"] = form_path[len(tenant_prefix) :] + + if form_name.startswith(tenant_prefix): + form_json["name"] = form_name[len(tenant_prefix) :] + return form_json + + def _sanitize_form_json(self, form_json, tenant_key): + """Clean form JSON data for export.""" + keys_to_remove = [ + "_id", + "machineName", + "access", + "submissionAccess", + "parentFormId", + "owner", + "tenantKey", + ] + for key in keys_to_remove: + form_json.pop(key, None) + # Remove 'tenantkey-' from 'path' and 'name' + if current_app.config.get("MULTI_TENANCY_ENABLED"): + form_json = self._remove_tenant_key(form_json, tenant_key) + return form_json + + def _get_form( # pylint: disable=too-many-arguments, too-many-positional-arguments + self, + title_or_path: str, + scope_type: str, + form_id: str = None, + description: str = None, + tenant_key: str = None, + anonymous: bool = False, + ) -> dict: + """Get form details.""" + try: + current_app.logger.info(f"Fetching form : {title_or_path}") + formio_service = FormioService() + form_io_token = formio_service.get_formio_access_token() + if form_id: + form_json = formio_service.get_form_by_id(form_id, form_io_token) + else: + form_json = formio_service.get_form_by_path( + title_or_path, form_io_token + ) + if not form_json: + raise BusinessException(BusinessErrorCode.INVALID_FORM_ID) + # In a (sub form)connected form, the workflow provides the form path, + # and the title is obtained from the form JSON + title_or_path = ( + form_json.get("title", "") if scope_type == "sub" else title_or_path + ) + form_json = self._sanitize_form_json(form_json, tenant_key) + + return { + "formTitle": title_or_path, + "formDescription": description, + "anonymous": anonymous or False, + "type": scope_type, + "content": form_json, + } + except Exception as e: + current_app.logger.error(e) + raise BusinessException(BusinessErrorCode.FORM_ID_NOT_FOUND) from e + + def _get_workflow( + self, process_key: str, process_name: str, scope_type: str + ) -> dict: + """Get workflow details.""" + current_app.logger.info(f"Fetching Process : {process_key}") + process = Process.get_latest_version_by_key(process_key) + if process: + process_data = process.process_data.decode("utf-8") + process_type = process.process_type.value + content = ( + json.loads(process_data) if process_type == "LOWCODE" else process_data + ) + return { + "processKey": process_key, + "processName": process_name, + "processType": process_type, + "type": scope_type, + "content": content, + } + raise BusinessException(BusinessErrorCode.PROCESS_DEF_NOT_FOUND) + + def _get_dmn(self, dmn_key: str, scope_type: str, user: UserContext) -> dict: + """Get DMN details.""" + try: + current_app.logger.info(f"Fetching xml for DMN: {dmn_key}") + dmn_tenant = None + if current_app.config.get("MULTI_TENANCY_ENABLED"): + url_path = ( + f"?latestVersion=true&includeDecisionDefinitionsWithoutTenantId=true" + f"&key={dmn_key}&tenantIdIn={user.tenant_key}" + ) + dmn = BPMService.get_decision(user.bearer_token, url_path) + if dmn: + dmn_tenant = dmn[0].get("tenantId") + current_app.logger.info( + f"Found tenant ID: {dmn_tenant} for DMN: {dmn_key}" + ) + dmn_xml = BPMService.decision_definition_xml( + dmn_key, user.bearer_token, dmn_tenant + ).get("dmnXml") + return { + "key": dmn_key, + "type": scope_type, + "content": dmn_xml, + } + except Exception as e: + current_app.logger.error(e) + raise BusinessException(BusinessErrorCode.DECISION_DEF_NOT_FOUND) from e + + def _get_authorizations(self, resource_id: str, user) -> dict: + """Get authorization details.""" + auth_details = Authorization.find_auth_list_by_id(resource_id, user.tenant_key) + auth_detail = {} + for auth in auth_details: + auth_detail[auth.auth_type.value] = { + "resourceId": auth.resource_id, + "resourceDetails": auth.resource_details, + "roles": auth.roles, + "userName": None, + } + return auth_detail + + def _parse_xml( # pylint:disable=too-many-locals + self, bpmn_xml: str, user: UserContext + ) -> Tuple[List[str], List[str], List[dict]]: + """Parse the XML string.""" + current_app.logger.info("Parsing XML...") + root = ET.fromstring(bpmn_xml) + namespaces = { + "bpmn": "http://www.omg.org/spec/BPMN/20100524/MODEL", + "camunda": "http://camunda.org/schema/1.0/bpmn", + } + + form_names = [] + dmn_names = [] + workflows = [] + + # Find all 'camunda:taskListener' with class="FormConnectorListener" + current_app.logger.info("Search for task with form connector...") + form_connector_tasks = root.findall( + ".//camunda:taskListener" + "[@class='org.camunda.bpm.extension.hooks.listeners.task.FormConnectorListener']" + "/../camunda:properties/camunda:property", + namespaces, + ) + + for task in form_connector_tasks: + if task.get("name") == "formName": + form_names.append(task.get("value")) + current_app.logger.info(f"Forms found: {form_names}") + + # Find DMNs + current_app.logger.info("Search for task with DMN...") + dmn_tasks = root.findall( + ".//bpmn:businessRuleTask[@camunda:decisionRef]", namespaces + ) + for task in dmn_tasks: + decision_ref = task.attrib.get( + "{http://camunda.org/schema/1.0/bpmn}decisionRef" + ) + dmn_names.append(decision_ref) + current_app.logger.info( + f"Task ID: {task.attrib.get('id')}, DMN: {decision_ref}" + ) + + # Find subprocesses + current_app.logger.info("Search for subprocess...") + sub_processes = root.findall(".//bpmn:callActivity[@calledElement]", namespaces) + for subprocess in sub_processes: + subprocess_name = subprocess.attrib.get("calledElement") + current_app.logger.info(f"Subprocess: {subprocess_name}") + # Here subprocess_name will be the process key + # Since we didn't get process name, we will use the subprocess name as process name + sub_workflow = self._get_workflow(subprocess_name, subprocess_name, "sub") + workflows.append(sub_workflow) + + sub_form_names, sub_dmn_names, sub_workflows = self._parse_xml( + sub_workflow["content"], user + ) + + form_names.extend(sub_form_names) + dmn_names.extend(sub_dmn_names) + workflows.extend(sub_workflows) + + return form_names, dmn_names, workflows + + @user_context + def export( # pylint:disable=too-many-locals + self, mapper_id: int, **kwargs + ) -> dict: + """Export form & workflow.""" + current_app.logger.info(f"Exporting form process mapper: {mapper_id}") + mapper = FormProcessMapper.find_form_by_id(form_process_mapper_id=mapper_id) + user: UserContext = kwargs["user"] + tenant_key = user.tenant_key + + if mapper: + if tenant_key is not None and mapper.tenant != tenant_key: + raise PermissionError(BusinessErrorCode.PERMISSION_DENIED) + + forms = [] + workflows = [] + rules = [] + authorizations = [] + + # Capture main form & workflow + forms.append( + self._get_form( + mapper.form_name, + "main", + mapper.form_id, + mapper.description, + tenant_key, + mapper.is_anonymous, + ) + ) + workflow = self._get_workflow( + mapper.process_key, mapper.process_name, "main" + ) + workflows.append(workflow) + authorizations.append(self._get_authorizations(mapper.parent_form_id, user)) + + # Parse bpm xml to get subforms & workflows + # The following lines are currently commented out but may be needed for future use. + # forms_names, dmns, sub_workflows = self._parse_xml( + # workflow["content"], user + # ) + + # for form in set(forms_names): + # forms.append( + # self._get_form(form, "sub", form_id=None, description=None, tenant_key=tenant_key) + # ) + # for dmn in set(dmns): + # rules.append(self._get_dmn(dmn, "sub", user)) + + # workflows.extend(sub_workflows) + + return { + "forms": forms, + "workflows": workflows, + "rules": rules, + "authorizations": authorizations, + } + + raise BusinessException(BusinessErrorCode.INVALID_FORM_PROCESS_MAPPER_ID) + + @classmethod + def is_valid_field(cls, field: str, pattern: str) -> bool: + """Checks if the given field matches the provided regex pattern.""" + return bool(re.fullmatch(pattern, field)) + + @classmethod + def validate_title_name_path(cls, title: str, path: str, name: str): + """Validates the title, path, and name fields.""" + title_pattern = r"(?=.*[A-Za-z])^[A-Za-z0-9 ]+(-{1,}[A-Za-z0-9 ]+)*$" + path_name = r"(?=.*[A-Za-z])^[A-Za-z0-9]+(-{1,}[A-Za-z0-9]+)*$" + + invalid_fields = [] + + error_messages = { + "title": "Title: Only contain alphanumeric characters, hyphens(not at the start or end), spaces," + "and must include at least one letter.", + "path": "Path: Only contain alphanumeric characters, hyphens(not at the start or end), no spaces," + "and must include at least one letter.", + "name": "Name: Only contain alphanumeric characters, hyphens(not at the start or end), no spaces," + "and must include at least one letter.", + } + + # Validate title + if title and not cls.is_valid_field(title, title_pattern): + invalid_fields.append("title") + + # Validate path and name + for field_name, field_value in (("path", path), ("name", name)): + if field_value and not cls.is_valid_field(field_value, path_name): + invalid_fields.append(field_name) + + # Determine overall validity + is_valid = len(invalid_fields) == 0 + if not is_valid: + # Generate detailed validation error message + error_message = ",\n ".join( + error_messages[field] for field in invalid_fields + ) + raise BusinessException( + BusinessErrorCode.FORM_VALIDATION_FAILED, + detail_message=error_message, + include_details=True, + ) + + @classmethod + def validate_form_title(cls, title, exclude_id=None): + """Validate form tile in the form_process_mapper table.""" + # Exclude the current mapper from the query + current_app.logger.info( + f"Validation for form title...{title}..with exclude id-{exclude_id}" + ) + mappers = FormProcessMapper.find_forms_by_title(title, exclude_id=exclude_id) + if mappers: + current_app.logger.debug(f"Other mappers matching the title- {mappers}") + raise BusinessException(BusinessErrorCode.FORM_EXISTS) + return True + + @staticmethod + @user_context + def validate_form_name_path_title(request, **kwargs): + """Validate a form name by calling the external validation API.""" + # Retrieve the parameters from the query string + title = request.args.get("title") + name = request.args.get("name") + path = request.args.get("path") + form_id = request.args.get("id") + parent_form_id = request.args.get("parentFormId") + current_app.logger.info( + f"Title:{title}, Name:{name}, Path:{path}, form_id:{form_id}, parent_form_id: {parent_form_id}" + ) + + # Check if at least one query parameter is provided + if not (title or name or path): + raise BusinessException(BusinessErrorCode.INVALID_FORM_VALIDATION_INPUT) + + if title and len(title) > 200: + raise BusinessException(BusinessErrorCode.INVALID_FORM_TITLE_LENGTH) + + FormProcessMapperService.validate_title_name_path(title, path, name) + + if current_app.config.get("MULTI_TENANCY_ENABLED"): + user: UserContext = kwargs["user"] + tenant_key = user.tenant_key + name = f"{tenant_key}-{name}" + path = f"{tenant_key}-{path}" + # Validate title exists validation on mapper & path, name in formio. + if title: + FormProcessMapperService.validate_form_title(title, parent_form_id) + # Validate path, name exits in formio. + if path or name: + query_params = f"name={name}&path={path}&select=title,path,name" + # Initialize the FormioService and get the access token + formio_service = FormioService() + form_io_token = formio_service.get_formio_access_token() + validation_response = formio_service.get_form_search( + query_params, form_io_token + ) + + # Check if the validation response has any results + if validation_response: + # Check if the form ID matches + if ( + form_id + and len(validation_response) == 1 + and validation_response[0].get("_id") == form_id + ): + return {} + # If there are results but no matching ID, the form name is still considered invalid + raise BusinessException(BusinessErrorCode.FORM_EXISTS) + # If no results, the form name is valid + return {} + + def validate_mapper(self, mapper_id, tenant_key): + """Validate mapper by mapper Id.""" + mapper = FormProcessMapper.find_form_by_id(form_process_mapper_id=mapper_id) + if not mapper: + raise BusinessException(BusinessErrorCode.INVALID_FORM_PROCESS_MAPPER_ID) + + # Check tenant authentication + if tenant_key and mapper.tenant != tenant_key: + raise PermissionError(BusinessErrorCode.PERMISSION_DENIED) + # Check the mapper_id provided is the latest mapper for the specific parent_form_id. + latest = FormProcessMapper.get_latest_by_parent_form_id(mapper.parent_form_id) + if latest and mapper.id != latest.id: + raise BusinessException(BusinessErrorCode.MAPPER_NOT_LATEST_VERSION) + return mapper + + def capture_form_history(self, mapper, data, user_name): + """Capture form history.""" + major_version, minor_version = 1, 0 + latest_form_history = FormHistory.get_latest_version(mapper.parent_form_id) + if latest_form_history: + major_version, minor_version = ( + latest_form_history.major_version, + latest_form_history.minor_version, + ) + FormHistory( + created_by=user_name, + parent_form_id=mapper.parent_form_id, + form_id=mapper.form_id, + change_log=data, + status=True, + major_version=major_version, + minor_version=minor_version, + ).save() + + @user_context + def publish(self, mapper_id, **kwargs): + """Publish by mapper_id.""" + user: UserContext = kwargs["user"] + tenant_key = user.tenant_key + token = user.bearer_token + user_name = user.user_name + mapper = self.validate_mapper(mapper_id, tenant_key) + process_name = mapper.process_key + + # Fetch process data from process table + process = Process.get_latest_version_by_key(process_name) + process_data, process_type = ( + (process.process_data, process.process_type) if process else (None, None) + ) + + # Deploy process + ProcessService.deploy_process( + process_name, process_data, tenant_key, token, process_type + ) + if not process: + # create entry in process with default flow with status "PUBLISHED". + FormProcessMapperService.create_default_process( + process_name, status=ProcessStatus.PUBLISHED + ) + else: + # Update process status + ProcessService.update_process_status(process, ProcessStatus.PUBLISHED, user) + + # Capture publish(active) status in form history table. + self.capture_form_history(mapper, {"status": "active"}, user_name) + # Update status in mapper table + mapper.update( + { + "status": str(FormProcessMapperStatus.ACTIVE.value), + "prompt_new_version": False, + } + ) + return {} + + @user_context + def unpublish(self, mapper_id: int, **kwargs): + """Publish by mapper_id.""" + user: UserContext = kwargs["user"] + user_name = user.user_name + tenant_key = user.tenant_key + mapper = self.validate_mapper(mapper_id, tenant_key) + # Capture unpublish status in form history table. + self.capture_form_history(mapper, {"status": "inactive"}, user_name) + # Update status(inactive) in mapper table + mapper.update( + { + "status": str(FormProcessMapperStatus.INACTIVE.value), + "prompt_new_version": True, + } + ) + # Update process status to Draft + process = Process.get_latest_version_by_key(mapper.process_key) + if process: + ProcessService.update_process_status(process, ProcessStatus.DRAFT, user) + return {} diff --git a/forms-flow-api/src/formsflow_api/services/import_support.py b/forms-flow-api/src/formsflow_api/services/import_support.py new file mode 100644 index 0000000000..d4355b0b73 --- /dev/null +++ b/forms-flow-api/src/formsflow_api/services/import_support.py @@ -0,0 +1,757 @@ +"""This exposes Import service.""" + +import json +from uuid import uuid1 + +from flask import current_app +from formsflow_api_utils.exceptions import BusinessException +from formsflow_api_utils.services.external import FormioService +from formsflow_api_utils.utils import Cache +from formsflow_api_utils.utils.enums import FormProcessMapperStatus +from formsflow_api_utils.utils.startup import collect_role_ids +from formsflow_api_utils.utils.user_context import UserContext, user_context +from jsonschema import ValidationError, validate +from lxml import etree + +from formsflow_api.constants import BusinessErrorCode, default_task_variables +from formsflow_api.models import AuthType, FormHistory, Process, ProcessType +from formsflow_api.schemas import ( + FormProcessMapperSchema, + ImportEditRequestSchema, + ImportRequestSchema, + ProcessDataSchema, + form_schema, + form_workflow_schema, +) +from formsflow_api.services.external.admin import AdminService + +from .authorization import AuthorizationService +from .form_history_logs import FormHistoryService +from .form_process_mapper import FormProcessMapperService +from .process import ProcessService + + +class ImportService: # pylint: disable=too-many-public-methods + """This class manages import service.""" + + def __init__(self) -> None: + """Initialize.""" + self.formio = FormioService() + self.auth_service = AuthorizationService() + + def __get_formio_access_token(self): + """Returns formio access token.""" + return self.formio.get_formio_access_token() + + def append_tenant_key_form_name_path(self, form_json, tenant_key): + """Append tenant key to form name & path.""" + name = form_json.get("name") + path = form_json.get("path") + current_app.logger.debug( + f"Appending tenant key: {tenant_key} to form name: {name} & path: {path}.." + ) + form_json["name"] = f"{tenant_key}-{name}" + form_json["path"] = f"{tenant_key}-{path}" + return form_json + + @user_context + def set_form_and_submission_access(self, form_data, anonymous, **kwargs): + """Add form and submission access to form.""" + if current_app.config.get("MULTI_TENANCY_ENABLED"): + user: UserContext = kwargs["user"] + url = f"{current_app.config.get('ADMIN_URL')}/tenant" + current_app.logger.debug(f"Admin url: {url}") + response = AdminService.get_request(url, user.bearer_token) + role_ids = response["form"] + form_data["tenantKey"] = user.tenant_key + else: + role_ids = Cache.get("formio_role_ids") + if not role_ids: + collect_role_ids(current_app) + role_ids = Cache.get("formio_role_ids") + + role_dict = {role["type"]: role["roleId"] for role in role_ids} + + client_id = role_dict.get("CLIENT") + designer_id = role_dict.get("DESIGNER") + reviewer_id = role_dict.get("REVIEWER") + anonymous_id = role_dict.get("ANONYMOUS") + + # Include anonymous_id, if anonymous is True + read_all_roles = [client_id, designer_id, reviewer_id] + create_own_roles = [client_id] + if anonymous: + read_all_roles.append(anonymous_id) + create_own_roles.append(anonymous_id) + + form_data["access"] = [ + { + "type": "read_all", + "roles": read_all_roles, + }, + { + "type": "update_all", + "roles": [designer_id], + }, + { + "type": "delete_all", + "roles": [designer_id], + }, + ] + + form_data["submissionAccess"] = [ + { + "roles": [designer_id], + "type": "create_all", + }, + { + "roles": [reviewer_id], + "type": "read_all", + }, + { + "roles": [reviewer_id], + "type": "update_all", + }, + { + "roles": [designer_id, reviewer_id], + "type": "delete_all", + }, + { + "roles": create_own_roles, + "type": "create_own", + }, + { + "roles": [client_id], + "type": "read_own", + }, + { + "roles": [client_id], + "type": "update_own", + }, + { + "roles": [reviewer_id], + "type": "delete_own", + }, + ] + return form_data + + @user_context + def create_authorization(self, data, new_import=False, **kwargs): + """Create authorization.""" + for auth_type in AuthType: + if auth_type.value in [ + AuthType.APPLICATION.value, + AuthType.FORM.value, + AuthType.DESIGNER.value, + ]: + auth_data = data.get(auth_type.value.upper()) + is_designer = auth_type.value == AuthType.DESIGNER.value + # If edit import, add created_by user as username in case of designer + edit_import_designer = not new_import and is_designer + # Update designer's username if new_import + if new_import is True and is_designer: + user: UserContext = kwargs["user"] + auth_data["userName"] = user.user_name + self.auth_service.create_authorization( + auth_type.value.upper(), + auth_data, + is_designer=True, + edit_import_designer=edit_import_designer, + ) + + def get_latest_version_workflow(self, process_name): + """Get latest version of workflow by process name.""" + process = Process.get_latest_version_by_key(process_name) + # If process not found, consider as initial version + if not process: + return (1, 0, None, None) + return ( + process.major_version, + process.minor_version, + process.status, + process.status_changed, + ) + + def determine_process_version_by_key(self, name): + """Finding the process version by process key.""" + major_version, minor_version, status, status_changed = ( + self.get_latest_version_workflow(name) + ) + return ProcessService.determine_process_version( + status, status_changed, major_version, minor_version + ) + + def get_latest_version_form(self, parent_form_id): + """Get latest version of form by parent ID.""" + version_data = FormHistory.get_latest_version(parent_form_id) + if not version_data: + raise BusinessException(BusinessErrorCode.FORM_ID_NOT_FOUND) + return version_data.major_version, version_data.minor_version + + def form_create(self, data): + """Create form in formio.""" + return self.formio.create_form(data, self.__get_formio_access_token()) + + def form_update(self, data, form_id): + """Update form in formio.""" + return self.formio.update_form(form_id, data, self.__get_formio_access_token()) + + def get_form_by_formid(self, form_id): + """Get form by form ID.""" + return self.formio.get_form_by_id(form_id, self.__get_formio_access_token()) + + def get_form_by_query(self, query_params): + """Get form by query.""" + return self.formio.get_form_search( + query_params, self.__get_formio_access_token() + ) + + def get_process_details(self, file_data): + """Get workflow details from the imported file.""" + process_data = file_data.get("workflows")[0].get("content") + process_type = file_data.get("workflows")[0].get( + "processType", ProcessType.BPMN.value + ) + return process_data, process_type + + def validate_file_type(self, filename: str, file_types: tuple): + """Validate file type.""" + current_app.logger.info(f"Validating file type for file.{filename}") + for file_type in file_types: + if filename.endswith(file_type): + return file_type + return None + + def validate_input_json(self, data, workflow_form_schema): + """Validate JSON.""" + try: + validate(instance=data, schema=workflow_form_schema) + current_app.logger.info("Valid json.") + return True + except ValidationError: + return False + + def read_json_data(self, file): + """Read JSON file.""" + file_content = file.read().decode("utf-8") + file_data = json.loads(file_content) + return file_data + + def validate_input_data(self, request): + """Validate input data.""" + # Get the uploaded file from the request + file = request.files.get("file") + if not file or not file.filename: + raise BusinessException(BusinessErrorCode.FILE_NOT_FOUND) + # Get the request data + request_data = request.form.get("data") + current_app.logger.info(f"Request data...{request_data}") + + if request_data: + try: + request_data = json.loads(request_data) + except json.JSONDecodeError as err: + raise BusinessException(BusinessErrorCode.INVALID_INPUT) from err + return request_data, file + + def validate_form( + self, form_json, tenant_key, validate_path_only=False, mapper=None + ): + """Validate form already exists & title/path/name validation.""" + title = form_json.get("title") + name = form_json.get("name") + path = form_json.get("path") + # Validate form title, name, path + FormProcessMapperService.validate_title_name_path(title, path, name) + # Add 'tenantkey-' from 'path' and 'name' + if current_app.config.get("MULTI_TENANCY_ENABLED"): + if not validate_path_only: + name = f"{tenant_key}-{name}" + path = f"{tenant_key}-{path}" + + if len(title) > 200 or len(name) > 200: + raise BusinessException(BusinessErrorCode.INVALID_FORM_TITLE_LENGTH) + + # Build query params based on validation type + if validate_path_only and mapper: + # In case of edit import validate title in mapper table & path in formio. + FormProcessMapperService.validate_form_title(title, mapper.parent_form_id) + query_params = f"path={path}&select=title,path,name,_id" + else: + # In case of new import validate title in mapper table & path,name in formio. + FormProcessMapperService.validate_form_title(title, exclude_id=None) + query_params = f"path={path}&name={name}&select=title,path,name,_id" + current_app.logger.info(f"Validating form exists...{query_params}") + response = self.get_form_by_query(query_params) + return response + + def validate_edit_form_exists(self, form_json, mapper, tenant_key): + """Validate form exists on edit import.""" + current_app.logger.info("Validating form exists in import edit...") + response = self.validate_form( + form_json, tenant_key, validate_path_only=True, mapper=mapper + ) + # If response is not empty, check if the form_id is not the same as the mapper form_id + # Then the path is taken by another form + if response: + if len(response) == 1 and (response[0].get("_id") != mapper.form_id): + raise BusinessException(BusinessErrorCode.FORM_EXISTS) + return True + + def update_workflow(self, xml_data, process_name): + """Parse the workflow XML data & update process name.""" + current_app.logger.info("Updating workflow...") + root = ProcessService.xml_parser(xml_data) + + # Find the bpmn:process element + process = root.find(".//{http://www.omg.org/spec/BPMN/20100524/MODEL}process") + if process is not None: + process.set("id", process_name) + process.set("name", process_name) + + # Convert the XML tree back to a string + updated_xml = etree.tostring( + root, pretty_print=True, encoding="unicode", xml_declaration=False + ) + # Prepend the XML declaration + updated_xml = '\n' + updated_xml + return updated_xml + + @user_context + def save_process_data( # pylint: disable=too-many-arguments, too-many-positional-arguments + self, + workflow_data, + name, + selected_workflow_version=None, + is_new=False, + process_type=ProcessType.BPMN.value, + **kwargs, + ): + """Save process data.""" + current_app.logger.info("Saving process data...") + user: UserContext = kwargs["user"] + updated_xml = ( + self.update_workflow(workflow_data, name) + if process_type == ProcessType.BPMN.value + else json.dumps(workflow_data) + ) + # Save workflow on new import will have major version as 1 and minor version as 0 + major_version, minor_version = 1, 0 + if not is_new: + # Save workflow on edit import + current_app.logger.info( + f"Capturing version for process {name} in edit import..." + ) + if selected_workflow_version: + major_version, minor_version, _, _ = self.get_latest_version_workflow( + name + ) + if selected_workflow_version == "major": + major_version += 1 + minor_version = 0 + else: + minor_version += 1 + else: + # If selected workflow version not specified + # Then update version as major if latest process data is published + # Otherwise update version as minor + major_version, minor_version = self.determine_process_version_by_key( + name + ) + # Save workflow as draft + process_data = updated_xml.encode("utf-8") + process = Process( + name=name, + process_type=process_type, + tenant=user.tenant_key, + process_data=process_data, + created_by=user.user_name, + major_version=major_version, + minor_version=minor_version, + process_key=name, + parent_process_key=name, + ) + process.save() + current_app.logger.info("Process data saved successfully...") + return process + + def version_response(self, form_major, form_minor, workflow_major, workflow_minor): + """Version response.""" + return { + "form": { + "majorVersion": form_major, + "minorVersion": form_minor, + }, + "workflow": { + "majorVersion": workflow_major, + "minorVersion": workflow_minor, + }, + } + + def import_new_form_workflow( + self, file_data, form_json, workflow_data, process_type + ): + """Import new form+workflow.""" + anonymous = file_data.get("forms")[0].get("anonymous") or False + form_json = self.set_form_and_submission_access(form_json, anonymous) + form_json.pop("parentFormId", None) + form_response = self.form_create(form_json) + form_id = form_response.get("_id") + FormHistoryService.create_form_log_with_clone( + data={ + **form_response, + "parentFormId": form_id, + "newVersion": True, + "componentChanged": True, + } + ) + process_name = form_response.get("name") + # process key/Id doesn't support numbers & special characters at start + # special characters anywhere so clean them before setting as process key + process_name = ProcessService.clean_form_name(process_name) + mapper_data = { + "form_id": form_id, + "form_name": form_response.get("title"), + "form_type": form_response.get("type"), + "parent_form_id": form_id, + "is_anonymous": file_data.get("forms")[0].get("anonymous") or False, + "task_variable": json.dumps(default_task_variables), + "process_key": process_name, + "process_name": process_name, + "status": "inactive", + "description": file_data.get("forms")[0].get("formDescription") or "", + } + mapper = FormProcessMapperService.create_mapper(mapper_data) + form_logs_data = { + "titleChanged": True, + "formName": form_response.get("title"), + "formTypeChanged": True, + "formType": form_response.get("type"), + "anonymousChanged": True, + "anonymous": file_data.get("forms")[0].get("anonymous") or False, + "formId": form_id, + "parentFormId": form_id, + } + FormHistoryService.create_form_logs_without_clone(data=form_logs_data) + # Update the form_id in the data dictionary with the new form_id + for auth in file_data["authorizations"][0]: + file_data["authorizations"][0][auth]["resourceId"] = form_id + # Create authorizations for the form + self.create_authorization(file_data["authorizations"][0], new_import=True) + # validate process key already exists, if exists append mapper id to process_key. + updated_process_name = ( + FormProcessMapperService.validate_process_and_update_mapper( + process_name, mapper + ) + ) + process_name = updated_process_name if updated_process_name else process_name + current_app.logger.info(f"Process Name: {process_name}") + process = self.save_process_data( + workflow_data, process_name, is_new=True, process_type=process_type + ) + return mapper, process + + def import_form( + self, selected_form_version, form_json, mapper, form_only=False, **kwargs + ): # pylint: disable=too-many-locals, too-many-statements + """Import form as major or minor version.""" + current_app.logger.info("Form import inprogress...") + # Get current form by mapper form_id + current_form = self.get_form_by_formid(mapper.form_id) + name = current_form.get("name") + title_changed = bool( + not form_only and mapper.form_name != form_json.get("title") + ) + if form_only: + # In case of form only import take title, path from current form + # and anonymous, description from mapper + path = current_form.get("path") + title = current_form.get("title") + anonymous = mapper.is_anonymous + description = mapper.description + else: + # form+workflow import take title, path, anonymous, description from incoming form json + path = form_json.get("path") + title = form_json.get("title") + anonymous = kwargs.get("anonymous", False) + description = kwargs.get("description", None) + anonymous_changed = bool( + anonymous is not None and mapper.is_anonymous != anonymous + ) + + if selected_form_version == "major": + # Update current form with random value to path, name & title + # Create new form with current form name, title & path from incoming form + # Create mapper entry for new form version, mark previous version inactive & delete + # Capture form history + current_app.logger.info("Form import major version inprogress...") + # Update name & path of current form + current_form["path"] = f"{current_form['path']}-v-{uuid1().hex}" + current_form["name"] = f"{name}-v-{uuid1().hex}" + FormProcessMapperService.form_design_update(current_form, mapper.form_id) + # Create new form with current form name + # But incase of form only no validation done, so use current form path & title itself. + form_json["title"] = title + form_json["path"] = path + form_json["parentFormId"] = mapper.parent_form_id + form_json = self.set_form_and_submission_access(form_json, anonymous) + form_response = self.form_create(form_json) + form_id = form_response.get("_id") + FormHistoryService.create_form_log_with_clone( + data={ + **form_response, + "parentFormId": mapper.parent_form_id, + "newVersion": True, + "componentChanged": True, + } + ) + mapper_data = { + "formId": form_id, + "previousFormId": mapper.form_id, + "formName": form_response.get("title"), + "formType": mapper.form_type, + "parentFormId": mapper.parent_form_id, + "anonymous": anonymous, + "taskVariables": json.loads(mapper.task_variable), + "processKey": mapper.process_key, + "processName": mapper.process_name, + "status": mapper.status, + "id": str(mapper.id), + "formTypeChanged": False, + "titleChanged": title_changed, + "anonymousChanged": anonymous_changed, + "description": description, + "isMigrated": mapper.is_migrated, + } + mapper = FormProcessMapperService.mapper_create(mapper_data) + FormProcessMapperService.mark_unpublished(mapper.id) + else: + current_app.logger.info("Form import minor version inprogress...") + form_id = mapper.form_id + FormProcessMapperService.check_tenant_authorization_by_formid( + form_id=form_id + ) + # Minor version update form components in formio & create form history. + form_components = {} + form_components["components"] = form_json.get("components") + # Incase of form+workflow title/path is updated even in minor version + form_components["title"] = title + form_components["path"] = path + form_components["parentFormId"] = mapper.parent_form_id + form_response = self.form_update(form_components, form_id) + form_response["componentChanged"] = True + form_response["parentFormId"] = mapper.parent_form_id + FormHistoryService.create_form_log_with_clone(data=form_response) + if not form_only: + # Update description, anonymous & status in mapper if form+workflow import + current_app.logger.info("Updating mapper & form logs...") + mapper.description = description + mapper.is_anonymous = anonymous + mapper.form_name = title + mapper.save() + form_logs_data = { + "titleChanged": title_changed, + "formName": title, + "anonymousChanged": anonymous_changed, + "anonymous": anonymous, + "formId": form_id, + "parentFormId": mapper.parent_form_id, + } + FormHistoryService.create_form_logs_without_clone(data=form_logs_data) + return mapper + + def import_edit_form(self, file_data, selected_form_version, form_json, mapper): + """Import edit form.""" + current_app.logger.info("Form import with form+workflow json inprogress...") + anonymous = file_data.get("forms")[0].get("anonymous") or False + description = file_data.get("forms")[0].get("formDescription", "") + mapper = self.import_form( + selected_form_version, + form_json, + mapper, + anonymous=anonymous, + description=description, + ) + # Update authorizations with incoming form authorizations + # resourceId(formId) differ in incoming import form+workflow json + # Use parent_form_id from mapper & auth details from incoming data + for auth in file_data["authorizations"][0]: + file_data["authorizations"][0][auth]["resourceId"] = mapper.parent_form_id + # Update authorizations for the form + self.create_authorization(file_data["authorizations"][0]) + return mapper + + @user_context + def import_form_workflow( + self, request, **kwargs + ): # pylint: disable=too-many-locals, too-many-statements, too-many-branches + """Import form/workflow.""" + current_app.logger.info("Processing import data...") + request_data, file = self.validate_input_data(request) + schema = ImportRequestSchema() + input_data = schema.load(request_data) + import_type = input_data.get("import_type") + action = input_data.get("action") + user: UserContext = kwargs["user"] + tenant_key = user.tenant_key + mapper_response = None + process = None + response = {} + + # Check if the action is valid + if action not in ["validate", "import"]: + raise BusinessException(BusinessErrorCode.INVALID_INPUT) + + if import_type == "new": # pylint: disable=too-many-nested-blocks + current_app.logger.info("Import new processing..") + # Validate input file type whether it is json + if not self.validate_file_type(file.filename, (".json",)): + raise BusinessException(BusinessErrorCode.INVALID_FILE_TYPE) + current_app.logger.info("Valid json file type.") + file_data = self.read_json_data(file) + # Vaidate file data + valid_input_json = self.validate_input_json(file_data, form_workflow_schema) + if not valid_input_json: + raise BusinessException(BusinessErrorCode.INVALID_FILE_TYPE) + form_json = file_data.get("forms")[0].get("content") + workflow_data, process_type = self.get_process_details(file_data) + validate_form_response = self.validate_form(form_json, tenant_key) + if validate_form_response: + raise BusinessException(BusinessErrorCode.FORM_EXISTS) + if action == "validate": + # On Import new, version will be 1.0 + return self.version_response( + form_major=1, form_minor=0, workflow_major=1, workflow_minor=0 + ) + if action == "import": + if current_app.config.get("MULTI_TENANCY_ENABLED"): + form_json = self.append_tenant_key_form_name_path( + form_json, tenant_key + ) + mapper_response, process = self.import_new_form_workflow( + file_data, form_json, workflow_data, process_type + ) + else: + current_app.logger.info("Import edit processing..") + edit_request = ImportEditRequestSchema().load(request_data) + valid_file = self.validate_file_type(file.filename, (".json", ".bpmn")) + mapper_id = edit_request.get("mapper_id") + # mapper is required for edit. Add validation + mapper = FormProcessMapperService().validate_mapper(mapper_id, tenant_key) + + if mapper.status == FormProcessMapperStatus.ACTIVE.value: + # Raise an exception if the user try to update published form + raise BusinessException(BusinessErrorCode.FORM_INVALID_OPERATION) + if valid_file == ".json": + file_data = self.read_json_data(file) + # Validate input json file whether only form or form+workflow + if self.validate_input_json(file_data, form_schema): + current_app.logger.info("Form only import inprogress...") + form_json = file_data.get("forms")[0] + form_major, form_minor = self.get_latest_version_form( + mapper.parent_form_id + ) + # No need to validate form exists + # Incoming form data need to be updated as either major or minor version + if action == "validate": + return self.version_response( + form_major=form_major, + form_minor=form_minor, + workflow_major=None, + workflow_minor=None, + ) + if action == "import": + selected_form_version = edit_request.get("form", {}).get( + "selectedVersion" + ) + mapper_response = self.import_form( + selected_form_version, form_json, mapper, form_only=True + ) + elif self.validate_input_json(file_data, form_workflow_schema): + current_app.logger.info("Form and workflow import inprogress...") + form_json = file_data.get("forms")[0].get("content") + # Validate form exists + self.validate_edit_form_exists(form_json, mapper, tenant_key) + if action == "validate": + major, minor = self.determine_process_version_by_key( + mapper.process_key + ) + form_major, form_minor = self.get_latest_version_form( + mapper.parent_form_id + ) + return self.version_response( + form_major=form_major, + form_minor=form_minor, + workflow_major=major, + workflow_minor=minor, + ) + if action == "import": + skip_form = edit_request.get("form", {}).get("skip", True) + skip_workflow = edit_request.get("workflow", {}).get( + "skip", True + ) + # selected version of form and workflow: major/minor + selected_form_version = edit_request.get("form", {}).get( + "selectedVersion" + ) + selected_workflow_version = edit_request.get( + "workflow", {} + ).get("selectedVersion") + # If skipform/skip workflow is none or true then skip + # If selected version(major/minor) not provided then use minor in case of form + # major/minor based on workflow published/draft + + if not skip_form: + # Import form + if current_app.config.get("MULTI_TENANCY_ENABLED"): + form_json = self.append_tenant_key_form_name_path( + form_json, tenant_key + ) + mapper_response = self.import_edit_form( + file_data, selected_form_version, form_json, mapper + ) + if not skip_workflow: + # import workflow + current_app.logger.info("Workflow import inprogress...") + workflow_data, process_type = self.get_process_details( + file_data + ) + process = self.save_process_data( + workflow_data, + mapper.process_key, + selected_workflow_version, + process_type=process_type, + ) + + else: + raise BusinessException(BusinessErrorCode.INVALID_FILE_TYPE) + elif valid_file == ".bpmn": + current_app.logger.info("Workflow validated successfully.") + if action == "validate": + major, minor = self.determine_process_version_by_key( + mapper.process_key + ) + return self.version_response( + form_major=None, + form_minor=None, + workflow_major=major, + workflow_minor=minor, + ) + if action == "import": + selected_workflow_version = edit_request.get("workflow", {}).get( + "selectedVersion" + ) + file_content = file.read().decode("utf-8") + process = self.save_process_data( + file_content, + mapper.process_key, + selected_workflow_version, + ) + if mapper_response: + mapper_response = FormProcessMapperSchema().dump(mapper_response) + if task_variables := mapper_response.get("taskVariables"): + mapper_response["taskVariables"] = json.loads(task_variables) + response["mapper"] = mapper_response + if process: + response["process"] = ProcessDataSchema().dump(process) + return response diff --git a/forms-flow-api/src/formsflow_api/services/process.py b/forms-flow-api/src/formsflow_api/services/process.py index ef3e0e6bfb..dbc28641fe 100644 --- a/forms-flow-api/src/formsflow_api/services/process.py +++ b/forms-flow-api/src/formsflow_api/services/process.py @@ -1,28 +1,764 @@ """This exposes process service.""" +import json import re +from collections import Counter -from formsflow_api.schemas import ProcessListSchema -from formsflow_api.services.external import BPMService +from flask import current_app +from formsflow_api_utils.exceptions import BusinessException +from formsflow_api_utils.services.external import FormioService +from formsflow_api_utils.utils.user_context import UserContext, user_context +from lxml import etree -# from formsflow_api.exceptions import BusinessException +from formsflow_api.constants import BusinessErrorCode, default_flow_xml_data +from formsflow_api.models import ( + FormProcessMapper, + Process, + ProcessStatus, + ProcessType, +) +from formsflow_api.schemas import ( + MigrateRequestSchema, + ProcessDataSchema, + ProcessHistorySchema, + ProcessListRequestSchema, +) +from formsflow_api.services.external.bpm import BPMService +processSchema = ProcessDataSchema() -class ProcessService: # pylint: disable=too-few-public-methods + +class ProcessService: # pylint: disable=too-few-public-methods,too-many-public-methods """This class manages process service.""" + @classmethod + def xml_parser(cls, process_data): + """Parse the process data.""" + # pylint: disable=I1101 + parser = etree.XMLParser(resolve_entities=False) + return etree.fromstring(process_data.encode("utf-8"), parser=parser) + + @classmethod + def remove_duplicate_multitenant(cls, process_list, process_type): + """Remove duplicates on default workflows provided.""" + # Incase of multitenant env, there's possiblity of duplicate workflow with tenant & without tenant + # Exclude workflow without tenant in this scenario while migrating + default_bpm_list = [ + "Defaultflow", + "onestepapproval", + "two-step-approval", + "EmailNotification", + ] + default_dmn_list = ["email-template-example"] + default_process_list = ( + default_dmn_list + if process_type == ProcessType.DMN.value + else default_bpm_list + ) + # Count occurrences of each key in default_list in process list + key_counts = Counter( + process["key"] + for process in process_list + if process["key"] in default_process_list + ) + # This variable is used to fetch default process which is found once & is without tenant + unique_default_process_non_tenant_list = [] + for process in process_list: + if ( + process["key"] in default_process_list + and key_counts[process["key"]] == 1 + and process["tenantId"] is None + ): + unique_default_process_non_tenant_list.append(process["key"]) + # Filter process_list based on key counts and tenant condition + filtered_process_list = [ + process + for process in process_list + if process["key"] not in default_process_list + or key_counts[process["key"]] == 1 + or process["tenantId"] is not None + ] + return filtered_process_list, unique_default_process_non_tenant_list + + @classmethod + def check_duplicate_names(cls, process_list): + """Check for duplicate bpmn/dmn names before migrate.""" + # DMN/BPMN keys will be unique but names can be duplicate + # To avoid exists error before migrate to process table make it unique + current_app.logger.info("Check for duplicate bpmn/dmn names...") + name_tenant_counts = {} + name_tenant_suffix_tracker = {} + + # count occurrences and prepare suffix tracker + for item in process_list: + key = (item["name"], item["tenantId"]) + name_tenant_counts[key] = name_tenant_counts.get(key, 0) + 1 + + # create the unique names based on the counts + unique_name_process_list = [] + for item in process_list: + key = (item["name"], item["tenantId"]) + if name_tenant_counts[key] > 1: + # Increment suffix count and create new name + name_tenant_suffix_tracker[key] = ( + name_tenant_suffix_tracker.get(key, 0) + 1 + ) + new_name = f"{item['name']}_{name_tenant_suffix_tracker[key] - 1}" + else: + new_name = item["name"] + + # Append item with the unique name to the final list + unique_name_process_list.append({**item, "name": new_name}) + return unique_name_process_list + + @classmethod + @user_context + def get_subflows_dmns(cls, process_type, **kwargs): + """Fetch subflows & dmns from camunda & save to process table.""" + current_app.logger.debug(f"Fetching DMN/BPMN...{process_type}") + user: UserContext = kwargs["user"] + tenant_key = user.tenant_key + token = user.bearer_token + process_list = [] + mapper_process_keys = [] + if process_type == ProcessType.BPMN.value: + mappers = FormProcessMapper.find_all() + mapper_process_keys = [mapper.process_key for mapper in mappers] + current_app.logger.debug(f"mapper_process_keys...{mapper_process_keys}") + url_path = "&includeProcessDefinitionsWithoutTenantId=true" + process_list = BPMService.get_all_process(token, url_path) + elif process_type == ProcessType.DMN.value: + url_path = ( + "?latestVersion=true&includeDecisionDefinitionsWithoutTenantId=true" + ) + process_list = BPMService.get_decision(token, url_path) + if process_list: + unique_default_non_tenant_list = [] + if current_app.config.get("MULTI_TENANCY_ENABLED"): + process_list, unique_default_non_tenant_list = ( + cls.remove_duplicate_multitenant(process_list, process_type) + ) + process_list = cls.check_duplicate_names(process_list) + # Exclude process keys from mapper to exclude any keys present in unique_mapper_keys + filtered_processes = [ + (process["key"], process["name"]) + for process in process_list + if process["key"] not in set(mapper_process_keys) + ] + for process_key, process_name in filtered_processes: + if process_key in unique_default_non_tenant_list: + tenant_key = None + cls.fetch_save_xml( + process_key, + tenant_key=tenant_key, + process_type=process_type, + is_subflow=True, + process_name=process_name, + ) + + @classmethod + def get_all_process( + cls, request_args, subflows_auth, decision_auth + ): # pylint:disable=too-many-locals + """Get all process list.""" + dict_data = ProcessListRequestSchema().load(request_args) or {} + process_type = ( + dict_data.get("process_data_type").upper() + if dict_data.get("process_data_type") + else None + ) + if ( + process_type + and (process_type == ProcessType.BPMN.value and not subflows_auth) + or (process_type == ProcessType.DMN.value and not decision_auth) + ): + raise BusinessException(BusinessErrorCode.PERMISSION_DENIED) + page_no = dict_data.get("page_no") + limit = dict_data.get("limit") + sort_by = dict_data.get("sort_by", "") + process_id = dict_data.get("process_id") + process_name = dict_data.get("name") + status = dict_data.get("status").upper() if dict_data.get("status") else None + created_by = dict_data.get("created_by") + created_from_date = dict_data.get("created_from_date") + created_to_date = dict_data.get("created_to_date") + modified_from_date = dict_data.get("modified_from_date") + modified_to_date = dict_data.get("modified_to_date") + sort_order = dict_data.get("sort_order", "") + sort_by = sort_by.split(",") + sort_order = sort_order.split(",") + + def list_process(): + process, count = Process.find_all_process( + created_from=created_from_date, + created_to=created_to_date, + modified_from=modified_from_date, + modified_to=modified_to_date, + sort_by=sort_by, + is_subflow=True, # now only for subflow listing + sort_order=sort_order, + created_by=created_by, + id=process_id, + process_name=process_name, + process_status=status, + process_type=process_type, + page_no=page_no, + limit=limit, + ) + return process, count + + process, count = list_process() + # If process empty consider it as subflows not migrated, so fetch from camunda + if not process: + # Check subflows exists without search before fetching from camunda. + check_subflows_exists, count = Process.find_all_process( + is_subflow=True, + process_type=process_type, + ) + if not check_subflows_exists: + current_app.logger.debug("Fetching subflows...") + cls.get_subflows_dmns(process_type) + process, count = list_process() + return ( + ProcessDataSchema(exclude=["process_data"]).dump(process, many=True), + count, + ) + + @classmethod + def _upate_process_name_and_id( + cls, xml_data, process_name, process_key, process_type + ): + """Parse the workflow XML data & update process name.""" + current_app.logger.info("Updating workflow...") + # pylint: disable=I1101 + root = cls.xml_parser(xml_data) + + # Find the bpmn:process element + process = cls.get_process_by_type(root, process_type) + current_app.logger.debug( + f"Process key: {process_key}, Process name: {process_name}" + ) + # Note: If id have space in name, then process view in bpmn modeller throws error + if process is not None: + process.set("id", process_key or process_name) + process.set("name", process_name) + + # Convert the XML tree back to a string + updated_xml = etree.tostring( + root, pretty_print=True, encoding="unicode", xml_declaration=False + ) + + # Prepend the XML declaration + updated_xml = '\n' + updated_xml + return updated_xml.encode("utf-8") + + @classmethod + @user_context + def create_process( # pylint: disable=too-many-arguments, too-many-positional-arguments + cls, + process_data=None, + process_type=None, + process_name=None, + process_key=None, + is_subflow=False, + is_migrate=False, + **kwargs, + ): + """Save process data.""" + user: UserContext = kwargs["user"] + tenant_key = user.tenant_key + current_app.logger.debug("Save process data..") + + if process_data is None or process_type is None: + raise BusinessException(BusinessErrorCode.INVALID_PROCESS_DATA) + + # Process the data name and key based on the process type and subflow status + process_data, process_name, process_key = cls._process_data_name_and_key( + process_data=process_data, + process_type=process_type, + is_subflow=is_subflow, + process_name=process_name, + process_key=process_key, + is_migrate=is_migrate, + ) + + # Check if the process already exists if it is a subflow + if is_subflow: + if Process.find_process_by_name_key( + name=process_name, process_key=process_key + ): + current_app.logger.debug( + f"Process already exists..{process_name}:-{process_key}" + ) + raise BusinessException(BusinessErrorCode.PROCESS_EXISTS) + + # Initialize version numbers for the new process + major_version, minor_version = 1, 0 + + # Create a new process instance + process_dict = { + "name": process_name, + "process_type": process_type.upper(), + "tenant": tenant_key, + "process_data": process_data, + "created_by": user.user_name, + "major_version": major_version, + "minor_version": minor_version, + "is_subflow": is_subflow, + "process_key": process_key, + "parent_process_key": process_key, + } + process = Process.create_from_dict(process_dict) + return process + @staticmethod - def get_all_processes(token): - """Get all processes.""" - process = BPMService.get_all_process(token=token) + def get_process_by_type(root, process_type): + """Get process name and id by type (BPMN or DMN).""" + # Define namespaces for BPMN and DMN + process_type = process_type.lower() + namespaces = { + "bpmn": "http://www.omg.org/spec/BPMN/20100524/MODEL", + "dmn": "https://www.omg.org/spec/DMN/20191111/MODEL/", + } + + # Check if the provided type exists in the namespace dictionary + if process_type not in namespaces: + raise ValueError(f"Unsupported process type: {process_type}") + + # Use the appropriate namespace for the type + target = ( + f"{process_type}:decision" + if process_type == "dmn" + else f"{process_type}:process" + ) + process = root.find(target, namespaces) + + # Check if process is found + if process is None: + raise ValueError(f"No process found for the given type: {process_type}") + + return process + + @classmethod + def _process_data_name_and_key( # pylint: disable=too-many-arguments, too-many-positional-arguments + cls, + process_data=None, + process_type=None, + is_subflow=False, + process_name=None, + process_key=None, + is_migrate=False, + ): + """Process data name key.""" + # if the process is not a subflow, update the process name and ID in the XML data + # if the process is a subflow, parse the XML data to extract the process name and key + # if the process is of type LOWCODE, convert the process data to JSON format + if is_subflow and process_type.upper() != "LOWCODE" and not is_migrate: + # Parse the XML data to extract process name and key for subflows + root = cls.xml_parser(process_data) + process = cls.get_process_by_type(root, process_type) + process_key = process.get("id") + process_name = process.get("name") + process_data = process_data.encode("utf-8") + else: + if process_type.upper() == "LOWCODE": + # Convert process data to JSON format for LOWCODE type processes + process_data = json.dumps(process_data) + else: + # Update the process name and ID in the XML data for other process types + process_data = cls._upate_process_name_and_id( + xml_data=process_data, + process_name=process_name, + process_key=process_key, + process_type=process_type, + ) + return process_data, process_name, process_key + + @staticmethod + def clean_form_name(name): + """Remove invalid characters from form_name before setting as process key.""" + # Remove non-letters at the start, and any invalid characters elsewhere + name = re.sub(r"(^[^a-zA-Z]+)|([^a-zA-Z0-9\-_])", "", name) + return name + + @classmethod + @user_context + def fetch_save_xml( # pylint: disable=too-many-arguments, too-many-positional-arguments + cls, + process_key, + tenant_key, + process_type=ProcessType.BPMN.value, + updated_process_key=None, + is_subflow=False, + process_name=None, + xml_data=None, + **kwargs, + ): + """Fetch process xml from camunda & save in process.""" + current_app.logger.debug(f"Fetch & save for process: {process_key}") + user: UserContext = kwargs["user"] + if not xml_data: + current_app.logger.debug(f"Fetching xml for process: {process_key}") + if process_type == ProcessType.DMN.value: + xml_data = BPMService.decision_definition_xml( + process_key, user.bearer_token, tenant_key + ).get("dmnXml") + else: + xml_data = BPMService.process_definition_xml( + process_key, user.bearer_token, tenant_key + ).get("bpmn20Xml") + current_app.logger.debug( + f"Completed fetching xml for process: {process_key}" + ) + # Incase of migration we need to use the filtered form name as process key + process_key = updated_process_key if updated_process_key else process_key + current_app.logger.debug(f"Create process: {process_key}") + process = cls.create_process( + process_data=xml_data, + process_type=process_type, + process_key=process_key, + process_name=process_name if process_name else process_key, + is_subflow=is_subflow, + is_migrate=True, + ) + current_app.logger.debug(f"Completed fetch &save process {process_key}") + return process + + @classmethod + def get_process_by_key(cls, process_key, request): + """Get process by key.""" + current_app.logger.debug(f"Get process data for process key: {process_key}") + process = Process.get_latest_version_by_key(process_key) + mapper_id = request.args.get("mapperId") + # If process is not found, fetch & save to process table. + if not process and mapper_id: + current_app.logger.debug("Process not found in db. Fetching & save it.") + mapper = FormProcessMapper.find_form_by_id(mapper_id) + process = cls.fetch_save_xml(mapper.process_key, mapper.process_tenant) + if process: + process_data = processSchema.dump(process) + # Determine version numbers based on the process status + major_version, minor_version = cls.determine_process_version( + process.status, + process.status_changed, + process.major_version, + process.minor_version, + ) + process_data["majorVersion"] = major_version + process_data["minorVersion"] = minor_version + return process_data + raise BusinessException(BusinessErrorCode.PROCESS_ID_NOT_FOUND) + + @classmethod + def validate_process_by_id(cls, process_id): + """Validate process by id.""" + process = Process.find_process_by_id(process_id) + # If process not available or if publish/unpublish non subflow + if not process or (process and process.is_subflow is False): + raise BusinessException(BusinessErrorCode.PROCESS_ID_NOT_FOUND) + return process + + @classmethod + def determine_process_version( + cls, status, status_changed, major_version, minor_version + ): + """Determine process version.""" + current_app.logger.debug("Identifying process version..") + is_unpublished = status == ProcessStatus.DRAFT and status_changed + major_version = major_version + 1 if is_unpublished else major_version + minor_version = 0 if is_unpublished else minor_version + 1 + return major_version, minor_version + + @classmethod + @user_context + def update_process(cls, process_id, process_data, process_type, **kwargs): + """Update process data.""" + current_app.logger.debug(f"Update process data for process id: {process_id}") + user: UserContext = kwargs["user"] + tenant_key = user.tenant_key + # Find the process by its ID + process = Process.find_process_by_id(process_id) + if process is None: + # Raise an exception if the process is not found + raise BusinessException(BusinessErrorCode.PROCESS_ID_NOT_FOUND) + + # Get the latest version of the process by its parent key + latest_process = Process.get_latest_version_by_parent_key( + process.parent_process_key + ) + if process.id != latest_process.id: + # Raise an exception if the process is not the latest version + raise BusinessException(BusinessErrorCode.PROCESS_NOT_LATEST_VERSION) + if process.status == ProcessStatus.PUBLISHED: + # Raise an exception if the user try to update published process + raise BusinessException(BusinessErrorCode.PROCESS_INVALID_OPERATION) + + # Process the data name and key based on the process type and subflow status + process_data, process_name, process_key = cls._process_data_name_and_key( + process_data=process_data, + process_type=process_type or process.process_type, + is_subflow=process.is_subflow, + process_name=process.name, + process_key=process.process_key, + ) + + # Check if the process name or key already exists if it is a subflow + if process.is_subflow and Process.find_process_by_name_key( + name=process_name, + process_key=process_key, + parent_process_key=process.parent_process_key, + ): + raise BusinessException(BusinessErrorCode.PROCESS_EXISTS) + + # Determine version numbers based on the process status + major_version, minor_version = cls.determine_process_version( + process.status, + process.status_changed, + process.major_version, + process.minor_version, + ) + + # Create a new process instance with updated data + process_dict = { + "name": process_name, + "process_type": process_type.upper(), + "tenant": tenant_key, + "process_data": process_data, + "created_by": user.user_name, + "major_version": major_version, + "minor_version": minor_version, + "is_subflow": process.is_subflow, + "process_key": process_key, + "parent_process_key": process.parent_process_key, + } + process = Process.create_from_dict(process_dict) + + # Return the serialized process data + return processSchema.dump(process) + + @classmethod + def delete_process(cls, process_id): + """Delete process.""" + current_app.logger.debug(f"Delete process data for process id: {process_id}") + process = Process.find_process_by_id(process_id) if process: - result = ProcessListSchema().dump(process, many=True) - new_result = [] - internal = re.compile(r"\((Internal+)\)") - for data in result: - if data["name"] is not None and internal.search(data["name"]) is None: - new_result.append(data) + process.delete() + return {"message": "Process deleted."} + raise BusinessException(BusinessErrorCode.PROCESS_ID_NOT_FOUND) - return new_result + @staticmethod + def populate_published_histories(histories, published_histories): + """Populating published on and publised by to the history.""" + published_history_dict = { + f"{history.major_version}.{history.minor_version}": history + for history in published_histories + } + for history in histories: + published_history = published_history_dict.get( + f"{history.major_version}.{history.minor_version}" + ) + if published_history: + history.published_on = published_history.created + history.published_by = published_history.created_by + return histories + @staticmethod + def get_all_history(parent_process_key: str, request_args): + """Get all history.""" + assert parent_process_key is not None + dict_data = ProcessListRequestSchema().load(request_args) or {} + page_no = dict_data.get("page_no") + limit = dict_data.get("limit") + process_histories, count = Process.fetch_histories_by_parent_process_key( + parent_process_key, page_no, limit + ) + + published_histories = Process.fetch_published_history_by_parent_process_key( + parent_process_key + ) + + if process_histories: + # populating published on and publised by to the history + process_histories = ProcessService.populate_published_histories( + process_histories, published_histories + ) + process_history_schema = ProcessHistorySchema() + return process_history_schema.dump(process_histories, many=True), count + raise BusinessException(BusinessErrorCode.PROCESS_ID_NOT_FOUND) + + @staticmethod + def validate_process(request): + """Validate process name/key.""" + process_key = request.args.get("processKey") + process_name = request.args.get("processName") + parent_process_key = request.args.get("parentProcessKey") + + if not (process_key or process_name): + raise BusinessException(BusinessErrorCode.INVALID_PROCESS_VALIDATION_INPUT) + + validation_response = Process.find_process_by_name_key( + process_name, process_key, parent_process_key + ) + + if validation_response: + raise BusinessException(BusinessErrorCode.PROCESS_EXISTS) + # If no results, the process name/key is valid + return {} + + @classmethod + def deploy_process( # pylint: disable=too-many-arguments, too-many-positional-arguments + cls, process_name, process_data, tenant_key, token, process_type + ): + """Deploy process.""" + # file path and type based on process type + file_extension = ( + "dmn" + if process_type and process_type.value == ProcessType.DMN.value + else "bpmn" + ) + file_path = f"{process_name}.{file_extension}" + file_type = f"text/{file_extension}" + # If process data empty deploy default workflow data. + if process_data: + if isinstance(process_data, str): + process_data = process_data.encode("utf-8") + else: + process_data = default_flow_xml_data(process_name).encode("utf-8") + + # Prepare the parameters for the deployment + payload = { + "deployment-name": process_name, + "enable-duplicate-filtering": "true", + "deploy-changed-only": "false", + "deployment-source": "Camunda Modeler", + "tenant-id": tenant_key, + } + files = {"upload": (file_path, process_data, file_type)} + BPMService.post_deployment(token, payload, tenant_key, files) + + @classmethod + def update_process_status(cls, process, status, user): + """Update process status.""" + process_dict = { + "name": process.name, + "process_type": process.process_type, + "status": status, + "tenant": user.tenant_key, + "process_data": process.process_data, + "created_by": user.user_name, + "major_version": process.major_version, + "minor_version": process.minor_version, + "is_subflow": process.is_subflow, + "process_key": process.process_key, + "parent_process_key": process.parent_process_key, + "status_changed": True, + } + process = Process.create_from_dict(process_dict) return process + + @classmethod + @user_context + def publish(cls, process_id, **kwargs): + """Publish by process_id.""" + user: UserContext = kwargs["user"] + process = cls.validate_process_by_id(process_id) + latest = Process.get_latest_version_by_parent_key(process.parent_process_key) + if process.id != latest.id: + raise BusinessException(BusinessErrorCode.PROCESS_NOT_LATEST_VERSION) + cls.update_process_status(process, ProcessStatus.PUBLISHED, user) + cls.deploy_process( + process.name, + process.process_data, + user.tenant_key, + user.bearer_token, + process.process_type, + ) + return {} + + @classmethod + @user_context + def unpublish(cls, process_id, **kwargs): + """Unpublish by process_id.""" + user: UserContext = kwargs["user"] + process = cls.validate_process_by_id(process_id) + latest = Process.get_latest_version_by_parent_key(process.parent_process_key) + if process.id != latest.id: + raise BusinessException(BusinessErrorCode.PROCESS_NOT_LATEST_VERSION) + cls.update_process_status(process, ProcessStatus.DRAFT, user) + return {} + + @classmethod + def get_process_by_id(cls, process_id): + """Get process by id.""" + current_app.logger.debug(f"Get process data for process id: {process_id}") + process = Process.find_process_by_id(process_id) + if process: + return processSchema.dump(process) + raise BusinessException(BusinessErrorCode.PROCESS_ID_NOT_FOUND) + + @classmethod + @user_context + def migrate(cls, request, **kwargs): # pylint:disable=too-many-locals + """Migrate by process key.""" + current_app.logger.debug("Migrate process started..") + user: UserContext = kwargs["user"] + data = MigrateRequestSchema().load(request.get_json()) + process_key = data.get("process_key") + mapper_id = data.get("mapper_id") + request_mapper = FormProcessMapper.find_form_by_id(mapper_id) + # If the process_key in the mapper is different from the process_key in the payload + if request_mapper.process_key != process_key: + raise BusinessException(BusinessErrorCode.INVALID_PROCESS) + mappers = FormProcessMapper.get_mappers_by_process_key(process_key, mapper_id) + current_app.logger.debug(f"Mappers found..{mappers}") + if mappers: + xml_data = None + if not current_app.config.get("MULTI_TENANCY_ENABLED"): + # Incase of non multitenant env, fetch once the process xml data + current_app.logger.debug("Fetching process..") + xml_data = BPMService.process_definition_xml( + process_key, user.bearer_token, user.tenant_key + ).get("bpmn20Xml") + for mapper in mappers: + formio_service = FormioService() + form_io_token = formio_service.get_formio_access_token() + form_json = formio_service.get_form_by_id(mapper.form_id, form_io_token) + form_name = form_json.get("name") + # process key doesn't support numbers & special characters at start + # special characters anywhere so clean them before setting as process key + updated_process_key = cls.clean_form_name(form_name) + if updated_process_key: + # validate process key already exists, if exists append mapper id to process_key. + process = Process.find_process_by_name_key( + name=updated_process_key, process_key=updated_process_key + ) + if process: + updated_process_key = f"{updated_process_key}_{mapper.id}" + # This is to avoid empty process_key after clean form name + else: + updated_process_key = f"{process_key}_migrate_{mapper.id}" + process = cls.fetch_save_xml( + process_key, + mapper.process_tenant, + updated_process_key=updated_process_key, + xml_data=xml_data, + ) + # Update mapper with new process key & is_migrated as True + mapper.update( + { + "is_migrated": True, + "process_key": updated_process_key, + "process_name": updated_process_key, + } + ) + # Deploy process to camunda + cls.deploy_process( + updated_process_key, + process.process_data, + user.tenant_key, + user.bearer_token, + ProcessType.BPMN, + ) + # Update is_migrated to main mapper by id. + request_mapper.update({"is_migrated": True}) + return {} diff --git a/forms-flow-api/src/formsflow_api/services/theme.py b/forms-flow-api/src/formsflow_api/services/theme.py new file mode 100644 index 0000000000..1759e68948 --- /dev/null +++ b/forms-flow-api/src/formsflow_api/services/theme.py @@ -0,0 +1,48 @@ +"""This exposes theme service.""" + +from formsflow_api_utils.exceptions import BusinessException +from formsflow_api_utils.utils.user_context import UserContext, user_context + +from formsflow_api.constants import BusinessErrorCode +from formsflow_api.models import Themes +from formsflow_api.schemas import ThemeCustomizationSchema + +theme_schema = ThemeCustomizationSchema() + + +class ThemeCustomizationService: + """This class manages theme service.""" + + @staticmethod + @user_context + def create_theme(data, **kwargs): + """Create new theme entry.""" + user: UserContext = kwargs["user"] + tenant = user.tenant_key + data["created_by"] = user.user_name + data["tenant"] = tenant + theme = Themes.get_theme(tenant) + if theme: + raise BusinessException(BusinessErrorCode.THEME_EXIST) + theme_customization = Themes.create_theme(data) + return theme_schema.dump(theme_customization) + + @staticmethod + def get_theme(tenant_key): + """Return theme using tenant key else default theme.""" + theme = Themes.get_theme(tenant_key) + result = theme_schema.dump(theme) + if result: + return result + raise BusinessException(BusinessErrorCode.THEME_NOT_FOUND) + + @staticmethod + @user_context + def update_theme(data, **kwargs): + """Updates theme.""" + user: UserContext = kwargs["user"] + theme = Themes.get_theme(user.tenant_key) + if theme: + theme.update(data) + return theme + raise BusinessException(BusinessErrorCode.THEME_NOT_FOUND) diff --git a/forms-flow-api/src/formsflow_api/services/user.py b/forms-flow-api/src/formsflow_api/services/user.py index 96601bd0b0..0cc8c22305 100644 --- a/forms-flow-api/src/formsflow_api/services/user.py +++ b/forms-flow-api/src/formsflow_api/services/user.py @@ -2,6 +2,11 @@ from typing import Dict, List +from formsflow_api_utils.utils.user_context import UserContext, user_context + +from formsflow_api.models import User +from formsflow_api.schemas import UserSchema + class UserService: """This class manages keycloak user service.""" @@ -84,3 +89,43 @@ def paginate(self, data, page_number, page_size): start_index = (page_number - 1) * page_size end_index = start_index + page_size return data[start_index:end_index] + + @staticmethod + def filter_by_permission(users_with_roles, permission): + """Filter users by permission.""" + update_user_list = [] + for user in users_with_roles: + roles = [role["name"] for role in user.get("role", [])] + if permission in roles: + del user["role"] + update_user_list.append(user) + return update_user_list + + @staticmethod + @user_context + def update_user_data(data, **kwargs): + """Update user data.""" + user: UserContext = kwargs["user"] + user_data = User.get_user_by_user_name(user_name=user.user_name) + if user_data: + if user_data.tenant is None and user.tenant_key: + data["tenant"] = user.tenant_key + user_data.update(data) + else: + data["user_name"] = user.user_name + data["tenant"] = user.tenant_key + data["created_by"] = user.user_name + user_data = User.create_user(data) + return UserSchema().dump(user_data) + + @staticmethod + @user_context + def filter_user_by_tenant_key(users_list, **kwargs): + """Filter users by tenant key.""" + user: UserContext = kwargs["user"] + tenant_key = user.tenant_key + return [ + data + for data in users_list + if tenant_key in data["attributes"].get("tenantKey", []) + ] diff --git a/forms-flow-api/tests/conftest.py b/forms-flow-api/tests/conftest.py index 2595e215c6..4f48245690 100644 --- a/forms-flow-api/tests/conftest.py +++ b/forms-flow-api/tests/conftest.py @@ -11,7 +11,9 @@ from sqlalchemy import text from formsflow_api import create_app +from formsflow_api.models import FormProcessMapper from formsflow_api.models import db as _db +from formsflow_api.schemas import FormProcessMapperSchema @pytest.fixture(scope="session", autouse=True) @@ -155,3 +157,59 @@ def get(self, key): return_value=mock_redis, ) as _mock: # noqa yield mock_redis + + +def get_form_request_payload(): + """Return a form request payload object.""" + return { + "formId": "1234", + "formName": "Sample form", + "processKey": "onestepapproval", + "processName": "One Step Approval", + "status": "active", + "comments": "test", + "tenant": 12, + "anonymous": False, + "formType": "form", + "parentFormId": "1234", + } + + +@pytest.fixture +def create_mapper(): + """Create a mapper instance.""" + mapper_data = FormProcessMapperSchema().load({**get_form_request_payload()}) + response = FormProcessMapper.create_from_dict( + {**mapper_data, "created_by": "test", "tenant": None} + ) + return FormProcessMapperSchema().dump(response) + + +@pytest.fixture +def create_mapper_anonymous(): + """Create a mapper instance.""" + mapper_data = FormProcessMapperSchema().load({**get_form_request_payload()}) + response = FormProcessMapper.create_from_dict( + {**mapper_data, "created_by": "test", "tenant": None, "is_anonymous": True} + ) + return FormProcessMapperSchema().dump(response) + + +@pytest.fixture +def create_mapper_custom(): + """Create a custom mapper instance.""" + + def _create_mapper_custom(data, created_by="test", tenant=None, is_anonymous=False): + """Create a custom mapper instance.""" + mapper_data = FormProcessMapperSchema().load(data) + response = FormProcessMapper.create_from_dict( + { + **mapper_data, + "created_by": created_by, + "tenant": tenant, + "is_anonymous": is_anonymous, + } + ) + return FormProcessMapperSchema().dump(response) + + return _create_mapper_custom diff --git a/forms-flow-api/tests/docker/realms/forms-flow-ai-realm.json b/forms-flow-api/tests/docker/realms/forms-flow-ai-realm.json index 62d4700a77..1f0dd06e8c 100644 --- a/forms-flow-api/tests/docker/realms/forms-flow-ai-realm.json +++ b/forms-flow-api/tests/docker/realms/forms-flow-ai-realm.json @@ -254,27 +254,129 @@ ], "forms-flow-web": [ { - "name": "formsflow-client", - "description": "Provides access to use the formsflow.ai solution. Required to access and submit forms.", + "name": "admin", + "description": "Administrator Role", "composite": false, "clientRole": true, - "attributes": {} }, { - "name": "formsflow-designer", - "description": "Provides access to use the formsflow.ai solution. Access to wok on form designer studio.", + "name": "manage_tasks", + "description": "Can assign, re-assign and work on tasks", "composite": false, "clientRole": true, - "attributes": {} }, { - "name": "formsflow-reviewer", - "description": "Provides access to use the formsflow.ai solution. Identifies the staff to work on applications and forms submissions.", + "name": "view_tasks", + "description": "Access to tasks", + "composite": false, + "clientRole": true, + "attributes": {} + }, + { + "name": "view_submissions", + "description": "Access to submissions", + "composite": false, + "clientRole": true, + "attributes": {} + }, + { + "name": "create_submissions", + "description": "Create submissions", + "composite": false, + "clientRole": true, + "attributes": {} + }, + { + "name": "manage_all_filters", + "description": "Manage all filters", + "composite": false, + "clientRole": true, + "attributes": {} + }, + { + "name": "view_designs", + "description": "Access to designs", + "composite": false, + "clientRole": true, + "attributes": {} + }, + { + "name": "manage_dashboard_authorizations", + "description": "Manage Dashboard Authorization", + "composite": false, + "clientRole": true, + "attributes": {} + }, + { + "name": "view_filters", + "description": "Access to view filters", + "composite": false, + "clientRole": true, + "attributes": {} + }, + { + "name": "manage_roles", + "description": "Manage Roles", + "composite": false, + "clientRole": true, + "attributes": {} + }, + { + "name": "manage_integrations", + "description": "Access to Integrations", + "composite": false, + "clientRole": true, + "attributes": {} + }, + { + "name": "create_filters", + "description": "Access to create filters", + "composite": false, + "clientRole": true, + "attributes": {} + }, + { + "name": "manage_users", + "description": "Manage Users", + "composite": false, + "clientRole": true, + "attributes": {} + }, + { + "name": "create_designs", + "description": "Design layout and flow", + "composite": false, + "clientRole": true, + "attributes": {} + }, + { + "name": "view_dashboards", + "description": "Access to dashboards", + "composite": false, + "clientRole": true, + "attributes": {} + }, + { + "name": "create_bpmn_flows", + "description": "Access to BPMN workflows", + "composite": false, + "clientRole": true, + "attributes": {} + }, + { + "name": "manage_subflows", + "description": "Access to Subflows", + "composite": false, + "clientRole": true, + "attributes": {} + }, + { + "name": "manage_decision_tables", + "description": "Access to Decision Tables", "composite": false, "clientRole": true, - "attributes": {} } ], @@ -381,9 +483,7 @@ "attributes": {}, "realmRoles": [], "clientRoles": { - "forms-flow-web": [ - "formsflow-client" - ] + "forms-flow-web": ["view_submissions", "create_submissions"] }, "subGroups": [] }, @@ -394,7 +494,9 @@ "realmRoles": [], "clientRoles": { "forms-flow-web": [ - "formsflow-designer" + "create_designs", + "view_designs", + "manage_integrations" ] }, "subGroups": [] @@ -405,8 +507,13 @@ "attributes": {}, "realmRoles": [], "clientRoles": { - "forms-flow-web": [ - "formsflow-reviewer" + "forms-flow-web": [ + "manage_tasks", + "view_tasks", + "create_filters", + "view_dashboards", + "view_submissions", + "view_filters" ] }, "subGroups": [ @@ -621,7 +728,7 @@ "requiredActions" : [ ], "realmRoles" : [ "uma_authorization", "offline_access" ], "clientRoles" : { - "realm-management" : [ "manage-users", "query-users", "query-groups", "view-users" ], + "realm-management" : [ "manage-users", "query-users", "query-groups", "view-users", "manage-clients" ], "account" : [ "view-profile", "manage-account" ] }, "notBefore" : 0, diff --git a/forms-flow-api/tests/unit/api/test_anonymous_application.py b/forms-flow-api/tests/unit/api/test_anonymous_application.py index 80d7608cb6..4bed2e1aa5 100644 --- a/forms-flow-api/tests/unit/api/test_anonymous_application.py +++ b/forms-flow-api/tests/unit/api/test_anonymous_application.py @@ -1,32 +1,20 @@ """Test suite for application API public endpoint.""" + from pytest import mark from formsflow_api.constants import BusinessErrorCode -from tests.utilities.base_test import ( - get_application_create_payload, - get_form_request_anonymous_payload, - get_form_request_payload_private, - get_form_request_payload_public_inactive, - get_token, -) +from tests.utilities.base_test import get_application_create_payload @mark.describe("Initialize application public API") class TestApplicationAnonymousResourcesByIds: """Test suite for anonymosu application endpoint.""" - def test_application_valid_post(self, app, client, session, jwt): + def test_application_valid_post( + self, app, client, session, jwt, mock_redis_client, create_mapper_anonymous + ): """Assert that public API /application when passed with valid payload returns 201 status code.""" - token = get_token(jwt) - headers = { - "Authorization": f"Bearer {token}", - "content-type": "application/json", - } - rv = client.post( - "/form", headers=headers, json=get_form_request_anonymous_payload() - ) - assert rv.status_code == 201 - form_id = rv.json.get("formId") + form_id = create_mapper_anonymous["formId"] response = client.post( "/public/application/create", json=get_application_create_payload(form_id) ) @@ -46,18 +34,11 @@ def test_application_invalid_post(self, app, client, session): "details": [], } - def test_application_unauthorized_post(self, app, client, session, jwt): + def test_application_unauthorized_post( + self, app, client, session, jwt, create_mapper + ): """Assert that public API /application when passed with valid payload returns 403 status code when the form is not anonymos.""" - token = get_token(jwt) - headers = { - "Authorization": f"Bearer {token}", - "content-type": "application/json", - } - rv = client.post( - "/form", headers=headers, json=get_form_request_payload_private() - ) - assert rv.status_code == 201 - form_id = rv.json.get("formId") + form_id = create_mapper["formId"] response = client.post( "/public/application/create", json=get_application_create_payload(form_id) ) @@ -68,18 +49,21 @@ def test_application_unauthorized_post(self, app, client, session, jwt): "details": [], } - def test_application_inactive_post(self, app, client, session, jwt): + def test_application_inactive_post( + self, app, client, session, jwt, create_mapper_custom + ): """Assert that public API /application when passed with valid payload returns 403 status code when the form is anonymous but Inactive.""" - token = get_token(jwt) - headers = { - "Authorization": f"Bearer {token}", - "content-type": "application/json", + payload = { + "formId": "1234", + "formName": "Sample form", + "processKey": "two-step-approval", + "processName": "Two Step Approval", + "status": "inactive", + "formType": "form", + "parentFormId": "1234", } - rv = client.post( - "/form", headers=headers, json=get_form_request_payload_public_inactive() - ) - assert rv.status_code == 201 - form_id = rv.json.get("formId") + rv = create_mapper_custom(payload, is_anonymous=True) + form_id = rv["formId"] response = client.post( "/public/application/create", json=get_application_create_payload(form_id) ) @@ -94,20 +78,11 @@ def test_application_inactive_post(self, app, client, session, jwt): class TestAnonymousFormById: """Class for unit test check form is Anonymous and published.""" - def test_anonymous_active_form_by_form_id(self, client, session, jwt): + def test_anonymous_active_form_by_form_id( + self, client, session, jwt, create_mapper_anonymous + ): """Assert that public API when passed with valid payload returns 200 status code.""" - token = get_token(jwt) - headers = { - "Authorization": f"Bearer {token}", - "content-type": "application/json", - } - response = client.post( - "/form", headers=headers, json=get_form_request_anonymous_payload() - ) - assert response.status_code == 201 - - form_id = response.json.get("formId") - + form_id = create_mapper_anonymous["formId"] response = client.get(f"/public/form/{form_id}") assert response.status_code == 200 diff --git a/forms-flow-api/tests/unit/api/test_application.py b/forms-flow-api/tests/unit/api/test_application.py index d3adc3e6e7..d99febcae2 100644 --- a/forms-flow-api/tests/unit/api/test_application.py +++ b/forms-flow-api/tests/unit/api/test_application.py @@ -1,13 +1,18 @@ """Test suite for application API endpoint.""" + import os import pytest import requests +from formsflow_api_utils.utils import ( + CREATE_DESIGNS, + CREATE_SUBMISSIONS, + VIEW_SUBMISSIONS, +) from tests.utilities.base_test import ( get_application_create_payload, get_draft_create_payload, - get_form_request_payload, get_formio_form_request_payload, get_token, ) @@ -28,7 +33,7 @@ def test_application_no_auth_api(self, app, client, session): def test_application_list(self, app, client, session, jwt): """Assert that API/application when passed with valid token returns 200 status code.""" - token = get_token(jwt) + token = get_token(jwt, role=VIEW_SUBMISSIONS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", @@ -40,7 +45,7 @@ def test_application_list(self, app, client, session, jwt): @pytest.mark.parametrize(("pageNo", "limit"), ((1, 5), (1, 10), (1, 20))) def test_application_paginated_list(self, app, client, session, jwt, pageNo, limit): """Tests the API/application endpoint with pageNo and limit query params.""" - token = get_token(jwt) + token = get_token(jwt, role=VIEW_SUBMISSIONS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", @@ -58,7 +63,7 @@ def test_application_paginated_sorted_list( self, app, client, session, jwt, pageNo, limit, sortBy, sortOrder ): """Tests the API/application endpoint with pageNo, limit, sortBy and SortOrder params.""" - token = get_token(jwt) + token = get_token(jwt, role=VIEW_SUBMISSIONS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", @@ -78,25 +83,21 @@ def test_application_paginated_sorted_list( ), ) def test_application_paginated_filtered_list( - self, - app, - client, - session, - jwt, - pageNo, - limit, - filters, + self, app, client, session, jwt, pageNo, limit, filters, create_mapper ): """Tests the API/application endpoint with filter params.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", } - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - assert rv.status_code == 201 - form_id = rv.json.get("formId") + token = get_token(jwt, role=CREATE_SUBMISSIONS) + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + form_id = create_mapper["formId"] rv = client.post( "/application/create", headers=headers, @@ -104,29 +105,43 @@ def test_application_paginated_filtered_list( ) assert rv.status_code == 201 + + token = get_token(jwt, role=VIEW_SUBMISSIONS) + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } response = client.get( f"/application?pageNo={pageNo}&limit={limit}&{filters}", headers=headers, ) assert response.status_code == 200 - def test_application_list_with_no_draft(self, app, client, session, jwt): + def test_application_list_with_no_draft( + self, app, client, session, jwt, create_mapper + ): """Application list should not contain draft applications.""" - for role in ["formsflow-client", "formsflow-designer", "formsflow-reviewer"]: - token = get_token(jwt, role=role) - headers = { - "Authorization": f"Bearer {token}", - "content-type": "application/json", - } - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - form_id = rv.json.get("formId") - # creating a draft will create a draft application - client.post( - "/draft", headers=headers, json=get_draft_create_payload(form_id) - ) - - response = client.get("/application", headers=headers) - assert len(response.json["applications"]) == 0 + token = get_token(jwt, role=CREATE_DESIGNS) + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + form_id = create_mapper["formId"] + # creating a draft will create a draft application + token = get_token(jwt, role=CREATE_SUBMISSIONS) + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + client.post("/draft", headers=headers, json=get_draft_create_payload(form_id)) + token = get_token(jwt, role=VIEW_SUBMISSIONS) + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = client.get("/application", headers=headers) + assert response.status_code == 200 + assert len(response.json["applications"]) == 0 class TestApplicationDetailView: @@ -142,17 +157,20 @@ def test_application_no_auth_api(self, app, client, session): "details": [], } - def test_application_detailed_view(self, app, client, session, jwt): + def test_application_detailed_view(self, app, client, session, jwt, create_mapper): """Tests the endpoint with valid token.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", } - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - assert rv.status_code == 201 - form_id = rv.json.get("formId") + form_id = create_mapper["formId"] + token = get_token(jwt, role=CREATE_SUBMISSIONS) + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } rv = client.post( "/application/create", headers=headers, @@ -160,70 +178,75 @@ def test_application_detailed_view(self, app, client, session, jwt): ) assert rv.status_code == 201 application_id = rv.json.get("id") - + token = get_token(jwt, role=VIEW_SUBMISSIONS) + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } response = client.get(f"/application/{application_id}", headers=headers) assert response.status_code == 200 assert response.json["applicationName"] == "Sample form" assert response.json["processKey"] == "onestepapproval" -def test_application_resource_by_form_id(app, client, session, jwt): +def test_application_resource_by_form_id(app, client, session, jwt, create_mapper): """Tests the application by formid endpoint with valid token.""" - token = get_token(jwt) + token = get_token(jwt, CREATE_DESIGNS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", } - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - assert rv.status_code == 201 - form_id = rv.json.get("formId") + form_id = create_mapper["formId"] + token = get_token(jwt, role=CREATE_SUBMISSIONS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.post( "/application/create", headers=headers, json=get_application_create_payload(form_id), ) assert rv.status_code == 201 - + token = get_token(jwt, role=VIEW_SUBMISSIONS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.get(f"/application/formid/{form_id}", headers=headers) assert response.status_code == 200 -def test_application_status_list(app, client, session, jwt): +def test_application_status_list(app, client, session, jwt, create_mapper): """Tests the application status list endpoint with valid payload.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", } - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - assert rv.status_code == 201 - - form_id = rv.json.get("formId") + form_id = create_mapper["formId"] + token = get_token(jwt, role=CREATE_SUBMISSIONS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.post( "/application/create", headers=headers, json=get_application_create_payload(form_id), ) assert rv.status_code == 201 + token = get_token(jwt, role=VIEW_SUBMISSIONS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.get("/application/status/list", headers=headers) assert response.status_code == 200 assert response.json["applicationStatus"] -def test_application_create_method(app, client, session, jwt): +def test_application_create_method(app, client, session, jwt, create_mapper): """Tests the application create method with valid payload.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", } - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - assert rv.status_code == 201 - - form_id = rv.json.get("formId") + form_id = create_mapper["formId"] + token = get_token(jwt, role=CREATE_SUBMISSIONS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.post( "/application/create", headers=headers, @@ -232,18 +255,29 @@ def test_application_create_method(app, client, session, jwt): assert rv.status_code == 201 -def test_application_create_method_tenant_based(app, client, session, jwt): +def test_application_create_method_tenant_based( + app, client, session, jwt, create_mapper_custom +): """Tests the tenant based application create method with valid payload.""" - token = get_token(jwt, tenant_key="test-tenant") + token = get_token(jwt, tenant_key="test-tenant", role=CREATE_DESIGNS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", } - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - assert rv.status_code == 201 - - form_id = rv.json.get("formId") + payload = { + "formId": "1234", + "formName": "Sample form", + "processKey": "two-step-approval", + "processName": "Two Step Approval", + "status": "active", + "formType": "form", + "parentFormId": "1234", + } + rv = create_mapper_custom(payload, tenant="test-tenant") + form_id = rv["formId"] + token = get_token(jwt, tenant_key="test-tenant", role=CREATE_SUBMISSIONS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.post( "/application/create", headers=headers, @@ -252,18 +286,17 @@ def test_application_create_method_tenant_based(app, client, session, jwt): assert rv.status_code == 201 -def test_application_payload(app, client, session, jwt): +def test_application_payload(app, client, session, jwt, create_mapper): """Tests the application create endpoint with valid payload.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", } - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - assert rv.status_code == 201 - - form_id = rv.json.get("formId") + form_id = create_mapper["formId"] + token = get_token(jwt, role=CREATE_SUBMISSIONS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.post( "/application/create", headers=headers, @@ -275,18 +308,17 @@ def test_application_payload(app, client, session, jwt): assert application_response["submissionId"] == "1233432" -def test_application_update_details_api(app, client, session, jwt): +def test_application_update_details_api(app, client, session, jwt, create_mapper): """Tests the application update endpoint with valid payload.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", } - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - assert rv.status_code == 201 - - form_id = rv.json.get("formId") + form_id = create_mapper["formId"] + token = get_token(jwt, role=CREATE_SUBMISSIONS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.post( "/application/create", headers=headers, @@ -295,7 +327,8 @@ def test_application_update_details_api(app, client, session, jwt): assert rv.status_code == 201 application_id = rv.json.get("id") assert rv != {} - + token = get_token(jwt, role=VIEW_SUBMISSIONS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.get(f"/application/{application_id}", headers=headers) payload = rv.json payload["applicationStatus"] = "New" @@ -310,9 +343,9 @@ def test_application_update_details_api(app, client, session, jwt): assert application.json.get("submissionId") == "1234" -def test_application_resubmit(app, client, session, jwt): +def test_application_resubmit(app, client, session, jwt, create_mapper_custom): """Tests the application resubmit endpoint.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", @@ -325,12 +358,14 @@ def test_application_resubmit(app, client, session, jwt): "status": "active", "formType": "form", "parentFormId": "1234", + "taskVariables": '[{"key":"abcd","label":"BusinessName"}]', } - rv = client.post("/form", headers=headers, json=payload) - assert rv.status_code == 201 + rv = create_mapper_custom(payload) - form_id = rv.json.get("formId") + form_id = rv.get("formId") + token = get_token(jwt, role=CREATE_SUBMISSIONS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.post( "/application/create", headers=headers, @@ -354,7 +389,7 @@ def test_capture_process_variables_application_create( app, client, session, jwt, mock_redis_client ): """Tests the capturing of process variables in the application creation method.""" - token = get_token(jwt, role="formsflow-designer", username="designer") + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", @@ -365,26 +400,35 @@ def test_capture_process_variables_application_create( ) assert response.status_code == 201 form_id = response.json.get("_id") + + # fetch mapper data + rv = client.get(f"/form/formid/{form_id}", headers=headers) + assert rv.status_code == 200 + mapper_id = rv.json.get("id") + # Added task variable to the form payload = { - "formId": form_id, - "formName": "Sample form", - "processKey": "two-step-approval", - "processName": "Two Step Approval", - "status": "active", - "formType": "form", - "parentFormId": "1234", - "taskVariable": [ - { - "key": "textField", - "defaultLabel": "Text Field", - "label": "Text Field", - } - ], + "mapper": { + "id": mapper_id, + "formId": form_id, + "formName": "Sample form", + "processKey": "two-step-approval", + "processName": "Two Step Approval", + "status": "active", + "formType": "form", + "parentFormId": "1234", + "taskVariable": [ + { + "key": "textField", + "defaultLabel": "Text Field", + "label": "Text Field", + } + ], + } } - rv = client.post("/form", headers=headers, json=payload) - assert rv.status_code == 201 - form_id = rv.json.get("formId") + rv = client.put(f"/form/{mapper_id}", headers=headers, json=payload) + assert rv.status_code == 200 + form_id = rv.json.get("mapper").get("formId") # Submit new application as client payload = get_application_create_payload(form_id) @@ -393,7 +437,7 @@ def test_capture_process_variables_application_create( "applicationId": "", "applicationStatus": "", } - token = get_token(jwt) + token = get_token(jwt, role=CREATE_SUBMISSIONS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", diff --git a/forms-flow-api/tests/unit/api/test_application_history.py b/forms-flow-api/tests/unit/api/test_application_history.py index 802a2ff856..3ec4ffe4b6 100644 --- a/forms-flow-api/tests/unit/api/test_application_history.py +++ b/forms-flow-api/tests/unit/api/test_application_history.py @@ -1,6 +1,10 @@ """Test suite for application History API endpoint.""" + from typing import Dict, List +from formsflow_api_utils.utils import VIEW_SUBMISSIONS + +from formsflow_api.models import Application from tests.utilities.base_test import get_token @@ -38,7 +42,7 @@ def test_post_application_history_create_method(app, client, session, jwt): def test_get_application_history(app, client, session, jwt): """Get the json request for application /application/{application_id}/history.""" - token = get_token(jwt) + token = get_token(jwt, role=VIEW_SUBMISSIONS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} new_entry = client.post( "/application/1/history", @@ -68,3 +72,26 @@ def test_application_history_get_un_authorized(app, client, session, jwt): # sending get request withouttoken rv = client.get("/application/1/history") assert rv.status_code == 401 + + +def create_application_history_service_account(app, client, session, jwt): + """Tests if the initial application history created with a service account replaced by application creator.""" + application = Application() + application.created_by = "client" + application.application_status = "New" + application.form_process_mapper_id = 1 + application.submission_id = "2345" + application.latest_form_id = "1234" + application.save() + + payload = { + "applicationId": 1, + "applicationStatus": "New", + "formUrl": "http://testsample.com/form/23/submission/3423", + "submittedBy": "service-account-bpmn", + } + token = get_token(jwt) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + new_entry = client.post("/application/1/history", headers=headers, json=payload) + assert new_entry.status_code == 201 + assert new_entry.submitted_by == "client" diff --git a/forms-flow-api/tests/unit/api/test_authorization.py b/forms-flow-api/tests/unit/api/test_authorization.py index 9a16a7c903..19dc6a1112 100644 --- a/forms-flow-api/tests/unit/api/test_authorization.py +++ b/forms-flow-api/tests/unit/api/test_authorization.py @@ -2,6 +2,8 @@ import json +from formsflow_api_utils.utils import VIEW_DASHBOARDS + from tests.utilities.base_test import factory_auth, get_token @@ -63,7 +65,7 @@ def test_current_user_dashboard_authorization(self, app, client, session, jwt): roles=["clerk"], ) - token = get_token(jwt) + token = get_token(jwt, role=VIEW_DASHBOARDS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", @@ -73,7 +75,7 @@ def test_current_user_dashboard_authorization(self, app, client, session, jwt): assert response.status_code == 200 assert len(response.json) == 0 - token = get_token(jwt, roles=["clerk"]) + token = get_token(jwt, role=VIEW_DASHBOARDS, roles=["clerk"]) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", @@ -82,7 +84,7 @@ def test_current_user_dashboard_authorization(self, app, client, session, jwt): assert response.status_code == 200 assert len(response.json) == 2 - token = get_token(jwt, roles=["approver"]) + token = get_token(jwt, role=VIEW_DASHBOARDS, roles=["approver"]) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", @@ -157,37 +159,3 @@ def test_form_authorization_list(self, app, client, session, jwt): response = client.get("/authorizations/form", headers=headers) assert response.status_code == 200 - - def test_current_user_form_authorization(self, app, client, session, jwt): - """Assert that formid authorization returns based on the user's role.""" - factory_auth( - resource_id="1234", - resource_details={}, - auth_type="form", - roles=["formsflow-reviewer"], - ) - factory_auth( - resource_id="12345", - resource_details={}, - auth_type="form", - roles=["formsflow-approver"], - ) - - token = get_token(jwt) - headers = { - "Authorization": f"Bearer {token}", - "content-type": "application/json", - } - - response = client.get("/authorizations/users/form", headers=headers) - assert response.status_code == 200 - assert len(response.json) == 0 - - token = get_token(jwt, roles=["formsflow-reviewer"]) - headers = { - "Authorization": f"Bearer {token}", - "content-type": "application/json", - } - response = client.get("/authorizations/users/form", headers=headers) - assert response.status_code == 200 - assert len(response.json) == 1 diff --git a/forms-flow-api/tests/unit/api/test_checkpoint.py b/forms-flow-api/tests/unit/api/test_checkpoint.py index 8250d89a0c..b1193c18ee 100644 --- a/forms-flow-api/tests/unit/api/test_checkpoint.py +++ b/forms-flow-api/tests/unit/api/test_checkpoint.py @@ -1,4 +1,5 @@ """Test suite for Checkpoint API endpoint.""" + from pytest import mark from formsflow_api import create_app diff --git a/forms-flow-api/tests/unit/api/test_dashboards.py b/forms-flow-api/tests/unit/api/test_dashboards.py index 7b97b27b4e..3255bb77ee 100644 --- a/forms-flow-api/tests/unit/api/test_dashboards.py +++ b/forms-flow-api/tests/unit/api/test_dashboards.py @@ -1,10 +1,16 @@ """Unit test for APIs of Dashboards.""" + +from formsflow_api_utils.utils import ( + MANAGE_DASHBOARD_AUTHORIZATIONS, + VIEW_DASHBOARDS, +) + from tests.utilities.base_test import get_token def test_get_dashboards(app, client, session, jwt): """Testing the get dashboards endpoint.""" - token = get_token(jwt) + token = get_token(jwt, role=MANAGE_DASHBOARD_AUTHORIZATIONS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.get("/dashboards", headers=headers) assert rv.status_code == 200 @@ -13,12 +19,14 @@ def test_get_dashboards(app, client, session, jwt): def test_get_dashboard_details(app, client, session, jwt): """Testing the get dashboard details endpoint.""" - token = get_token(jwt) + token = get_token(jwt, role=MANAGE_DASHBOARD_AUTHORIZATIONS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.get("/dashboards", headers=headers) assert rv.status_code == 200 data = rv.json dashboard_id = data["results"][0]["id"] + token = get_token(jwt, role=VIEW_DASHBOARDS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.get(f"/dashboards/{dashboard_id}", headers=headers) assert rv.json is not None @@ -31,7 +39,7 @@ def test_no_auth_get_dashboards(app, client, session): def test_get_dashboard_error_details(app, client, session, jwt): """Get dashboards with invalid resource id.""" - token = get_token(jwt) + token = get_token(jwt, role=VIEW_DASHBOARDS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.get("/dashboards/10000", headers=headers) @@ -40,7 +48,9 @@ def test_get_dashboard_error_details(app, client, session, jwt): def test_get_dashboard_error_details_tenant(app, client, session, jwt): """Get dashboards for tenant with analytics not created.""" - token = get_token(jwt, tenant_key="test-tenant") + token = get_token( + jwt, tenant_key="test-tenant", role=MANAGE_DASHBOARD_AUTHORIZATIONS + ) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.get("/dashboards", headers=headers) diff --git a/forms-flow-api/tests/unit/api/test_draft.py b/forms-flow-api/tests/unit/api/test_draft.py index 7c07e9673b..8c2eae2134 100644 --- a/forms-flow-api/tests/unit/api/test_draft.py +++ b/forms-flow-api/tests/unit/api/test_draft.py @@ -1,9 +1,12 @@ """Test suite for 'draft' namespace API endpoints.""" + import os import requests from formsflow_api_utils.utils import ( ANONYMOUS_USER, + CREATE_DESIGNS, + CREATE_SUBMISSIONS, DRAFT_APPLICATION_STATUS, NEW_APPLICATION_STATUS, ) @@ -15,18 +18,18 @@ get_application_create_payload, get_draft_create_payload, get_form_model_object, - get_form_request_payload, get_formio_form_request_payload, get_token, ) -def test_draft_create_method(app, client, session, jwt): +def test_draft_create_method(app, client, session, jwt, create_mapper): """Tests the draft create method with valid payload.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + form_id = create_mapper["formId"] + token = get_token(jwt, role=CREATE_SUBMISSIONS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - form_id = rv.json.get("formId") response = client.post( "/draft", headers=headers, json=get_draft_create_payload(form_id) ) @@ -40,12 +43,13 @@ def test_draft_create_method(app, client, session, jwt): assert draft_application.application_status == DRAFT_APPLICATION_STATUS -def test_draft_list(app, client, session, jwt): +def test_draft_list(app, client, session, jwt, create_mapper): """Testing draft listing API.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + form_id = create_mapper["formId"] + token = get_token(jwt, role=CREATE_SUBMISSIONS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - form_id = rv.json.get("formId") for _ in range(2): client.post("/draft", headers=headers, json=get_draft_create_payload(form_id)) @@ -55,7 +59,7 @@ def test_draft_list(app, client, session, jwt): assert len(response.json["drafts"]) == 2 # tests if the draft listing is user specific - token = get_token(jwt, username="different_user") + token = get_token(jwt, username="different_user", role=CREATE_SUBMISSIONS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.get("/draft", headers=headers) assert response.status_code == 200 @@ -63,14 +67,14 @@ def test_draft_list(app, client, session, jwt): assert len(response.json["drafts"]) == 0 -def test_draft_detail_view(app, client, session, jwt): +def test_draft_detail_view(app, client, session, jwt, create_mapper): """Testing draft details endpoint.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - assert rv.status_code == 201 - form_id = rv.json.get("formId") + token = get_token(jwt, role=CREATE_SUBMISSIONS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + form_id = create_mapper["formId"] response = client.post( "/draft", headers=headers, json=get_draft_create_payload(form_id) ) @@ -90,17 +94,17 @@ def test_draft_detail_view(app, client, session, jwt): ) -def test_draft_update_details_api(app, client, session, jwt): +def test_draft_update_details_api(app, client, session, jwt, create_mapper): """Tests the draft update endpoint with valid payload.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", } - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - assert rv.status_code == 201 - form_id = rv.json.get("formId") + form_id = create_mapper["formId"] + token = get_token(jwt, role=CREATE_SUBMISSIONS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.post( "/draft", headers=headers, json=get_draft_create_payload(form_id) ) @@ -113,15 +117,16 @@ def test_draft_update_details_api(app, client, session, jwt): assert rv.status_code == 200 -def test_draft_submission_resource(app, client, session, jwt): +def test_draft_submission_resource(app, client, session, jwt, create_mapper): """Tests the '//submit' endpoint.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", } - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - form_id = rv.json.get("formId") + form_id = create_mapper["formId"] + token = get_token(jwt, role=CREATE_SUBMISSIONS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} draft = client.post( "/draft", headers=headers, json=get_draft_create_payload(form_id) ) @@ -139,14 +144,27 @@ def test_draft_submission_resource(app, client, session, jwt): assert draft.application_id == response.json.get("id") -def test_draft_tenant_authorization(app, client, session, jwt): +def test_draft_tenant_authorization(app, client, session, jwt, create_mapper_custom): """Tests if the draft detail is tenant authorized.""" - token = get_token(jwt, role="formsflow-designer", tenant_key="tenant1") + token = get_token(jwt, role=CREATE_DESIGNS, tenant_key="tenant1") headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - form_id = rv.json.get("formId") + payload = { + "formId": "1234", + "formName": "Sample form", + "processKey": "onestepapproval", + "processName": "One Step Approval", + "status": "active", + "comments": "test", + "anonymous": False, + "formType": "form", + "parentFormId": "1234", + } + rv = create_mapper_custom(payload, tenant="tenant1") + form_id = rv["formId"] assert FormProcessMapper().find_form_by_form_id(form_id) is not None assert FormProcessMapper().find_form_by_form_id(form_id).tenant == "tenant1" + token = get_token(jwt, role=CREATE_SUBMISSIONS, tenant_key="tenant1") + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.post( "/draft", headers=headers, json=get_draft_create_payload(form_id) ) @@ -156,7 +174,7 @@ def test_draft_tenant_authorization(app, client, session, jwt): assert rv.status_code == 200 # tests if another tenant can get the draft created by different tenant - token = get_token(jwt, tenant_key="tenant2") + token = get_token(jwt, tenant_key="tenant2", role=CREATE_SUBMISSIONS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.get(f"/draft/{draft_id}", headers=headers) assert rv.status_code == 400 @@ -224,14 +242,14 @@ def test_anonymous_drafts(app, client, session, jwt): } -def test_delete_draft(app, client, session, jwt): +def test_delete_draft(app, client, session, jwt, create_mapper): """Tests the delete draft endpoint.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - assert rv.status_code == 201 - form_id = rv.json.get("formId") + form_id = create_mapper["formId"] + token = get_token(jwt, role=CREATE_SUBMISSIONS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.post( "/draft", headers=headers, json=get_draft_create_payload(form_id) ) @@ -241,7 +259,7 @@ def test_delete_draft(app, client, session, jwt): assert rv.status_code == 200 # Tests if delete is user specific - token = get_token(jwt, username="different_user") + token = get_token(jwt, username="different_user", role=CREATE_SUBMISSIONS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.delete(f"/draft/{draft_id}", headers=headers) assert rv.status_code == 400 @@ -251,7 +269,7 @@ def test_capture_process_variables_draft_create_method( app, client, session, jwt, mock_redis_client ): """Tests the capturing of process variables in the draft create method.""" - token = get_token(jwt, role="formsflow-designer", username="designer") + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", @@ -262,9 +280,15 @@ def test_capture_process_variables_draft_create_method( ) assert response.status_code == 201 form_id = response.json.get("_id") + + # fetch mapper data + rv = client.get(f"/form/formid/{form_id}", headers=headers) + assert rv.status_code == 200 + mapper_id = rv.json.get("id") # Added task variable to the form payload = { "formId": form_id, + "id": mapper_id, "formName": "Sample form", "processKey": "two-step-approval", "processName": "Two Step Approval", @@ -279,11 +303,11 @@ def test_capture_process_variables_draft_create_method( } ], } - rv = client.post("/form", headers=headers, json=payload) - assert rv.status_code == 201 - form_id = rv.json.get("formId") + rv = client.put(f"/form/{mapper_id}", headers=headers, json={"mapper": payload}) + assert rv.status_code == 200 + form_id = rv.json.get("mapper").get("formId") # Draft submission - token = get_token(jwt) + token = get_token(jwt, role=CREATE_SUBMISSIONS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", diff --git a/forms-flow-api/tests/unit/api/test_filter.py b/forms-flow-api/tests/unit/api/test_filter.py index 3c4d5b94ce..3b75cc2c20 100644 --- a/forms-flow-api/tests/unit/api/test_filter.py +++ b/forms-flow-api/tests/unit/api/test_filter.py @@ -1,11 +1,17 @@ """Test suite for Filter API endpoint.""" +from formsflow_api_utils.utils import ( + CREATE_FILTERS, + MANAGE_ALL_FILTERS, + VIEW_FILTERS, +) + from tests.utilities.base_test import get_filter_payload, get_token def test_create_filter(app, client, session, jwt): """Test create filter with valid payload.""" - token = get_token(jwt, role="formsflow-reviewer", username="reviewer") + token = get_token(jwt, role=CREATE_FILTERS, username="reviewer") headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.post( "/filter", headers=headers, json=get_filter_payload(roles=["clerk"]) @@ -17,7 +23,7 @@ def test_create_filter(app, client, session, jwt): def test_get_user_filters(app, client, session, jwt): """Test - Get filters based on user role.""" - token = get_token(jwt, role="formsflow-reviewer", username="reviewer") + token = get_token(jwt, role=CREATE_FILTERS, username="reviewer") headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} # Create filter for clerk role response = client.post( @@ -35,16 +41,18 @@ def test_get_user_filters(app, client, session, jwt): assert response.status_code == 201 # Test '/filter/user' endpoint with reviewer token # Since reviewer created both filters response will include both. + token = get_token(jwt, role=VIEW_FILTERS, username="reviewer") + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.get("/filter/user", headers=headers) assert response.status_code == 200 - assert len(response.json) == 2 - assert response.json[0].get("name") == "Clerk Task" - assert response.json[1].get("name") == "Reviewer Task" + assert len(response.json.get("filters")) == 2 + assert response.json.get("filters")[0].get("name") == "Clerk Task" + assert response.json.get("filters")[1].get("name") == "Reviewer Task" def test_filter_update(app, client, session, jwt): """Test filter update with valid payload.""" - token = get_token(jwt, role="formsflow-reviewer", username="reviewer") + token = get_token(jwt, role=CREATE_FILTERS, username="reviewer") headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.post( "/filter", @@ -61,13 +69,15 @@ def test_filter_update(app, client, session, jwt): def test_filter_delete(app, client, session, jwt): """Test filter delete.""" - token = get_token(jwt, role="formsflow-reviewer", username="reviewer") + token = get_token(jwt, role=CREATE_FILTERS, username="reviewer") headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.post( "/filter", headers=headers, json=get_filter_payload(roles=["clerk"]) ) assert response.status_code == 201 filter_id = response.json.get("id") + token = get_token(jwt, role=MANAGE_ALL_FILTERS, username="reviewer") + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.delete(f"/filter/{filter_id}", headers=headers) assert response.status_code == 200 assert response.json == "Deleted" @@ -77,7 +87,7 @@ def test_filter_delete(app, client, session, jwt): def test_create_filter_current_user_task(app, client, session, jwt): """Test create filter for current user's tasks.""" - token = get_token(jwt, role="formsflow-reviewer", username="reviewer") + token = get_token(jwt, role=CREATE_FILTERS, username="reviewer") headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} filter_payload = get_filter_payload(name="My Tasks") filter_payload.update({"isMyTasksEnabled": True}) @@ -93,7 +103,7 @@ def test_create_filter_current_user_task(app, client, session, jwt): def test_create_filter_current_user_group_task(app, client, session, jwt): """Test create filter based on the roles of the currently logged-in user.""" - token = get_token(jwt, role="formsflow-reviewer", username="reviewer") + token = get_token(jwt, role=CREATE_FILTERS, username="reviewer") headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} filter_payload = get_filter_payload(name="My Group Tasks") filter_payload.update({"isTasksForCurrentUserGroupsEnabled": True}) @@ -105,3 +115,34 @@ def test_create_filter_current_user_group_task(app, client, session, jwt): response.json.get("criteria", {}).get("candidateGroupsExpression") == "${currentUserGroups()}" ) + + +def test_get_user_filters_by_order(app, client, session, jwt): + """Test - Get filters based on user role and based on the order.""" + token = get_token(jwt, role=CREATE_FILTERS, username="reviewer") + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + # Create filter for clerk role and giving display order 2 + response = client.post( + "/filter", + headers=headers, + json=get_filter_payload(name="Clerk Task", order=2, roles=["clerk"]), + ) + assert response.status_code == 201 + # Create filter for reviewer role and giving display order 1 + response = client.post( + "/filter", + headers=headers, + json=get_filter_payload( + name="Reviewer Task", order=1, roles=["formsflow-reviewer"] + ), + ) + assert response.status_code == 201 + # Test '/filter/user' endpoint with reviewer token + # Since reviewer created both filters response will include both. + token = get_token(jwt, role=VIEW_FILTERS, username="reviewer") + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + response = client.get("/filter/user", headers=headers) + assert response.status_code == 200 + assert len(response.json.get("filters")) == 2 + assert response.json.get("filters")[0].get("name") == "Reviewer Task" + assert response.json.get("filters")[1].get("name") == "Clerk Task" diff --git a/forms-flow-api/tests/unit/api/test_form_embed.py b/forms-flow-api/tests/unit/api/test_form_embed.py index 120d79f318..a4bfa2ab41 100644 --- a/forms-flow-api/tests/unit/api/test_form_embed.py +++ b/forms-flow-api/tests/unit/api/test_form_embed.py @@ -1,25 +1,23 @@ """Test suit for embed APIs.""" +from formsflow_api_utils.utils import CREATE_DESIGNS + from tests.utilities.base_test import ( get_embed_application_create_payload, get_embed_token, get_form_payload, - get_form_request_payload, get_token, ) -def test_get_external_form_valid_request(app, client, session, jwt, mock_redis_client): +def test_get_external_form_valid_request( + app, client, session, jwt, mock_redis_client, create_mapper_custom +): """Testing the external get form by pathname.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) assert token is not None headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - response = client.post( - "/form", - headers=headers, - json=get_form_payload(), - ) - assert response.status_code == 201 + create_mapper_custom(data=get_form_payload()) token = get_embed_token() assert token is not None headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} @@ -37,17 +35,14 @@ def test_get_external_form_invalid_request(app, client, session, jwt): assert rv.status_code == 401 -def test_get_internal_form_valid_request(app, client, session, jwt, mock_redis_client): +def test_get_internal_form_valid_request( + app, client, session, jwt, mock_redis_client, create_mapper_custom +): """Testing the internal get form by pathname.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) assert token is not None headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - response = client.post( - "/form", - headers=headers, - json=get_form_payload(), - ) - assert response.status_code == 201 + create_mapper_custom(data=get_form_payload()) rv = client.get("/embed/internal/form/selectcheckresouce", headers=headers) assert rv.status_code == 200 assert len(rv.json) >= 1 @@ -61,19 +56,13 @@ def test_get_internal_form_invalid_request(app, client, session, jwt): assert rv.status_code == 401 -def test_form_embed_external_submission(app, client, session, jwt, mock_redis_client): +def test_form_embed_external_submission( + app, client, session, jwt, mock_redis_client, create_mapper +): """Testing form process mapper update endpoint.""" - token = get_token(jwt) - headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - response = client.post( - "/form", - headers=headers, - json=get_form_request_payload(), - ) - assert response.status_code == 201 token = get_embed_token() headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - form_id = response.json["formId"] + form_id = create_mapper["formId"] res = client.post( "/embed/external/application/create", headers=headers, @@ -82,17 +71,14 @@ def test_form_embed_external_submission(app, client, session, jwt, mock_redis_cl assert res.status_code == 201 -def test_form_embed_internal_submission(app, client, session, jwt, mock_redis_client): +def test_form_embed_internal_submission( + app, client, session, jwt, mock_redis_client, create_mapper +): """Testing form process mapper update endpoint.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - response = client.post( - "/form", - headers=headers, - json=get_form_request_payload(), - ) - assert response.status_code == 201 - form_id = response.json["formId"] + + form_id = create_mapper["formId"] res = client.post( "/embed/internal/application/create", headers=headers, diff --git a/forms-flow-api/tests/unit/api/test_form_process_mapper.py b/forms-flow-api/tests/unit/api/test_form_process_mapper.py index 26c93f8559..a5910cb4cf 100644 --- a/forms-flow-api/tests/unit/api/test_form_process_mapper.py +++ b/forms-flow-api/tests/unit/api/test_form_process_mapper.py @@ -1,11 +1,21 @@ """Test suite for FormProcessMapper API endpoint.""" + import json +from unittest.mock import MagicMock, patch import pytest +from formsflow_api_utils.utils import ( + ADMIN, + CREATE_DESIGNS, + CREATE_SUBMISSIONS, + VIEW_DESIGNS, + VIEW_SUBMISSIONS, +) +from formsflow_api.models import FormProcessMapper +from formsflow_api.services import FormHistoryService from tests.utilities.base_test import ( get_application_create_payload, - get_form_request_anonymous_payload, get_form_request_payload, get_formio_form_request_payload, get_token, @@ -14,26 +24,17 @@ def test_form_process_mapper_list(app, client, session, jwt): """Testing form process mapper listing API.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.get("/form", headers=headers) assert response.status_code == 200 assert response.json is not None -def test_form_process_mapper_creation(app, client, session, jwt): - """Testing form process mapper create API.""" - token = get_token(jwt) - headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - response = client.post("/form", headers=headers, json=get_form_request_payload()) - assert response.status_code == 201 - assert response.json.get("id") is not None - - @pytest.mark.parametrize(("pageNo", "limit"), ((1, 5), (1, 10), (1, 20))) def test_form_process_mapper_paginated_list(app, client, session, jwt, pageNo, limit): """Testing form process mapper paginated list.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.get(f"/form?pageNo={pageNo}&limit={limit}", headers=headers) assert response.status_code == 200 @@ -47,7 +48,7 @@ def test_form_process_mapper_paginated_sorted_list( app, client, session, jwt, pageNo, limit, sortBy, sortOrder ): """Testing form process mapper paginated sorted list.""" - token = get_token(jwt) + token = get_token(jwt, role=VIEW_SUBMISSIONS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.get( f"/application?pageNo={pageNo}&limit={limit}&sortBy={sortBy}&sortOrder={sortOrder}", @@ -59,180 +60,186 @@ def test_form_process_mapper_paginated_sorted_list( @pytest.mark.parametrize( ("pageNo", "limit", "filters"), ( - (1, 5, "formName=free"), - (1, 10, "formName=Free"), - (1, 20, "formName=privacy"), + (1, 5, "search=free"), + (1, 10, "search=Free"), + (1, 20, "search=privacy"), ), ) def test_form_process_mapper_paginated_filtered_list( - app, client, session, jwt, pageNo, limit, filters + app, client, session, jwt, pageNo, limit, filters, create_mapper ): """Testing form process mapper paginated filtered list.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - response = client.post("/form", headers=headers, json=get_form_request_payload()) - assert response.status_code == 201 + response = create_mapper # noqa: F841 rv = client.get(f"/form?pageNo={pageNo}&limit={limit}&{filters}", headers=headers) assert rv.status_code == 200 -def test_anonymous_form_process_mapper_creation(app, client, session, jwt): - """Testing anonymous form process mapper creation.""" - token = get_token(jwt) - headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - response = client.post( - "/form", headers=headers, json=get_form_request_anonymous_payload() - ) - assert response.status_code == 201 - assert response.json.get("id") is not None - - -def test_form_process_mapper_detail_view(app, client, session, jwt): +def test_form_process_mapper_detail_view(app, client, session, jwt, create_mapper): """Testing form process mapper details endpoint.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - response = client.post( - "/form", - headers=headers, - json=get_form_request_payload(), - ) - assert response.status_code == 201 - mapper_id = response.json.get("id") + + mapper_id = create_mapper.get("id") rv = client.get(f"/form/{mapper_id}", headers=headers) assert rv.status_code == 200 assert rv.json.get("id") == mapper_id -def test_form_process_mapper_by_formid(app, client, session, jwt): +def test_form_process_mapper_by_formid(app, client, session, jwt, create_mapper): """Testing API/form/formid/ with valid data.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - response = client.post( - "/form", - headers=headers, - json=get_form_request_payload(), - ) - assert response.status_code == 201 - form_id = response.json.get("formId") + response = create_mapper + form_id = response.get("formId") assert form_id is not None rv = client.get(f"/form/formid/{form_id}", headers=headers) assert rv.status_code == 200 -def test_form_process_mapper_id_deletion(app, client, session, jwt): +def test_form_process_mapper_id_deletion(app, client, session, jwt, create_mapper): """Testing form process mapper delete endpoint.""" - token = get_token(jwt, roles=["/formsflow/formsflow-designer"]) + token = get_token(jwt, role=CREATE_DESIGNS, roles=["/formsflow/formsflow-designer"]) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - response = client.post( - "/form", - headers=headers, - json=get_form_request_payload(), - ) - assert response.status_code == 201 + response = create_mapper auth_payload = { "resourceId": "1234", "resourceDetails": {}, "roles": ["/formsflow/formsflow-designer"], } + token = get_token(jwt, role=CREATE_DESIGNS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.post( "/authorizations/form", headers=headers, data=json.dumps(auth_payload) ) + response = client.post( + "/authorizations/designer", headers=headers, data=json.dumps(auth_payload) + ) assert response.status_code == 200 - + token = get_token(jwt, role=VIEW_DESIGNS, roles=["/formsflow/formsflow-designer"]) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.get("/form", headers=headers) assert response.status_code == 200 data = response.json form_id = data["forms"][0]["id"] + token = get_token(jwt, role=CREATE_DESIGNS, roles=["/formsflow/formsflow-designer"]) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} r = client.delete(f"/form/{form_id}", headers=headers) assert r.json == "Deleted" assert r.status_code == 200 -def test_form_process_mapper_test_update(app, client, session, jwt): +def test_form_process_mapper_test_update(app, client, session, jwt, create_mapper): """Testing form process mapper update endpoint.""" - token = get_token(jwt, roles=["/formsflow/formsflow-designer"]) + token = get_token(jwt, role=CREATE_DESIGNS, roles=["/formsflow/formsflow-designer"]) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - response = client.post( - "/form", - headers=headers, - json=get_form_request_payload(), - ) - assert response.status_code == 201 + response = create_mapper auth_payload = { "resourceId": "1234", "resourceDetails": {}, "roles": ["/formsflow/formsflow-designer"], } + token = get_token(jwt, role=ADMIN) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.post( "/authorizations/form", headers=headers, data=json.dumps(auth_payload) ) + response = client.post( + "/authorizations/designer", headers=headers, data=json.dumps(auth_payload) + ) assert response.status_code == 200 - + token = get_token(jwt, role=VIEW_DESIGNS, roles=["/formsflow/formsflow-designer"]) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.get("/form", headers=headers) assert response.status_code == 200 - form_id = response.json["forms"][0]["id"] - rv = client.put( - f"/form/{form_id}", headers=headers, json=get_form_request_payload() - ) + form_id = response.json.get("forms")[0].get("id") + token = get_token(jwt, role=CREATE_DESIGNS, roles=["/formsflow/formsflow-designer"]) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + data = { + "mapper": { + **get_form_request_payload(), + "formName": "Test Form", + "taskVariables": [], + } + } + rv = client.put(f"/form/{form_id}", headers=headers, json=data) + assert rv.json.get("mapper")["formName"] == "Test Form" assert rv.status_code == 200 -def test_anonymous_form_process_mapper_test_update(app, client, session, jwt): +def test_anonymous_form_process_mapper_test_update( + app, client, session, jwt, create_mapper +): """Testing anonymous form process mapper update endpoint.""" - token = get_token(jwt, roles=["/formsflow/formsflow-designer"]) + token = get_token(jwt, role=CREATE_DESIGNS, roles=["/formsflow/formsflow-designer"]) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - response = client.post( - "/form", - headers=headers, - json=get_form_request_payload(), - ) - assert response.status_code == 201 + response = create_mapper auth_payload = { "resourceId": "1234", "resourceDetails": {}, "roles": ["/formsflow/formsflow-designer"], } + token = get_token(jwt, role=ADMIN) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.post( "/authorizations/form", headers=headers, data=json.dumps(auth_payload) ) + response = client.post( + "/authorizations/designer", headers=headers, data=json.dumps(auth_payload) + ) assert response.status_code == 200 - + token = get_token(jwt, role=VIEW_DESIGNS, roles=["/formsflow/formsflow-designer"]) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.get("/form", headers=headers) assert response.status_code == 200 data = response.json form_id = data["forms"][0]["id"] + token = get_token(jwt, role=CREATE_DESIGNS, roles=["/formsflow/formsflow-designer"]) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.put( - f"/form/{form_id}", headers=headers, json=get_form_request_anonymous_payload() + f"/form/{form_id}", + headers=headers, + json={ + "mapper": { + "formId": "1234", + "formName": "Sample form", + "anonymous": True, + "status": "active", + "formType": "form", + "parentFormId": "1234", + } + }, ) assert rv.status_code == 200 def test_get_application_count_based_on_form_process_mapper_id( - app, client, session, jwt + app, client, session, jwt, create_mapper ): """Testing the count API for applications corresponding to mapper id.""" - token = get_token(jwt, roles=["/formsflow/formsflow-designer"]) + token = get_token(jwt, role=CREATE_DESIGNS, roles=["/formsflow/formsflow-designer"]) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - response = client.post( - "/form", - headers=headers, - json=get_form_request_payload(), - ) - assert response.status_code == 201 - + response = create_mapper auth_payload = { "resourceId": "1234", "resourceDetails": {}, "roles": ["/formsflow/formsflow-designer"], } + token = get_token(jwt, role=ADMIN) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.post( "/authorizations/form", headers=headers, data=json.dumps(auth_payload) ) + response = client.post( + "/authorizations/designer", headers=headers, data=json.dumps(auth_payload) + ) assert response.status_code == 200 - + token = get_token(jwt, role=VIEW_DESIGNS, roles=["/formsflow/formsflow-designer"]) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.get("/form", headers=headers) assert response.status_code == 200 data = response.json @@ -244,19 +251,18 @@ def test_get_application_count_based_on_form_process_mapper_id( def test_get_application_count_based_on_form_process_mapper_id1( - app, client, session, jwt + app, client, session, jwt, create_mapper ): """Testing the count api.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", } - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - assert rv.status_code == 201 - - form_id = rv.json.get("formId") - + rv = create_mapper + form_id = rv["formId"] + token = get_token(jwt, role=CREATE_SUBMISSIONS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.post( "/application/create", headers=headers, @@ -268,18 +274,18 @@ def test_get_application_count_based_on_form_process_mapper_id1( assert rv.status_code == 200 -def test_get_task_variable_based_on_form_process_mapper_id(app, client, session, jwt): +def test_get_task_variable_based_on_form_process_mapper_id( + app, client, session, jwt, create_mapper +): """Assert that API when passed with valid payload returns 200 status code.""" - token = get_token(jwt) + token = get_token(jwt, role=CREATE_DESIGNS) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", } - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - assert rv.status_code == 201 - - form_id = rv.json.get("formId") - + form_id = create_mapper["formId"] + token = get_token(jwt, role=CREATE_SUBMISSIONS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.post( "/application/create", headers=headers, @@ -295,9 +301,480 @@ def test_get_task_variable_based_on_form_process_mapper_id(app, client, session, def test_formio_form_creation(app, client, session, jwt, mock_redis_client): """Testing formio form create API.""" - token = get_token(jwt, role="formsflow-designer", username="designer") + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} response = client.post( "/form/form-design", headers=headers, json=get_formio_form_request_payload() ) assert response.status_code == 201 + + +def get_export(client, headers, mapper_id): + """Get export.""" + return client.get(f"/form/{mapper_id}/export", headers=headers) + + +def get_authorizations(form_id): + """Get authorizations.""" + return { + "APPLICATION": { + "resourceId": form_id, + "resourceDetails": {}, + "roles": [], + "userName": None, + }, + "DESIGNER": { + "resourceId": form_id, + "resourceDetails": {}, + "roles": [], + "userName": None, + }, + "FORM": { + "resourceId": form_id, + "resourceDetails": {}, + "roles": [], + "userName": None, + }, + } + + +def get_forms(form_name, scope_type): + """Get forms.""" + return {"formTitle": form_name, "type": scope_type, "content": "json form content"} + + +def get_workflows(process_key, process_name, scope_type, xml): + """Get workflows.""" + return { + "processKey": process_key, + "processName": process_name, + "type": scope_type, + "content": xml, + } + + +def get_dmns(dmn_key, scope_type, xml): + """Get DMN.""" + return {"key": dmn_key, "type": scope_type, "content": xml} + + +def mapper_payload(form_name, process_key, process_name): + """Mapper payload.""" + return { + "form_id": "1234", + "form_name": form_name, + "process_key": process_key, + "process_name": process_name, + "status": "active", + "tenant": None, + "form_type": "form", + "parent_form_id": "1234", + "created_by": "test", + } + + +def test_export(app, client, session, jwt, mock_redis_client): + """Testing export by mapper id.""" + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + + # Test export - no DMN - no subprocess -no task based forms(form connector) + form = FormProcessMapper( + **mapper_payload("sample form2", "onestepapproval", "One Step Approval") + ) + form.save() + mapper_id = form.id + form_id = form.form_id + + # Mock response + client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json = { + "forms": [get_forms("sample form1", "main")], + "workflows": [ + get_workflows("onestepapproval", "One Step Approval", "main", "xml") + ], + "rules": [], + "authorizations": [get_authorizations(form_id)], + } + client.get.return_value = mock_response + response = get_export(client, headers, mapper_id) + assert response.status_code == 200 + assert response.json is not None + assert len(response.json["forms"]) == 1 + assert len(response.json["workflows"]) == 1 + assert len(response.json["rules"]) == 0 + assert len(response.json["authorizations"]) == 1 + + # Test export - with task based forms - no DMN - no subprocess + # form = FormProcessMapper( + # **mapper_payload("sample form2", "formconectflow", "FormConnectFlow") + # ) + # form.save() + # mapper_id = form.id + # form_id = form.form_id + + # # Mock response + # mock_response = MagicMock() + # mock_response.status_code = 200 + # mock_response.json = { + # "forms": [get_forms("sample form2", "main"), get_forms("sample form3", "sub")], + # "workflows": [ + # get_workflows("formconectflow", "FormConnectFlow", "main", "xml") + # ], + # "rules": [], + # "authorizations": [get_authorizations(form_id)], + # } + # client.get.return_value = mock_response + # response = get_export(client, headers, mapper_id) + # assert response.status_code == 200 + # assert response.json is not None + # assert len(response.json["forms"]) == 2 + # assert len(response.json["workflows"]) == 1 + # assert len(response.json["rules"]) == 0 + # assert len(response.json["authorizations"]) == 1 + + # Test export - with DMN- no task based forms - no subprocess + # form = FormProcessMapper( + # **mapper_payload("sample form2", "rulebasedflow", "RuleBasedFlow") + # ) + # form.save() + # mapper_id = form.id + # form_id = form.form_id + + # # Mock response + # mock_response = MagicMock() + # mock_response.status_code = 200 + # mock_response.json = { + # "forms": [get_forms("sample form2", "main")], + # "workflows": [get_workflows("rulebasedflow", "RuleBasedFlow", "main", "xml")], + # "rules": [get_dmns("dmn1", "main", "dmn xml")], + # "authorizations": [get_authorizations(form_id)], + # } + # client.get.return_value = mock_response + # response = get_export(client, headers, mapper_id) + # assert response.status_code == 200 + # assert response.json is not None + # assert len(response.json["forms"]) == 1 + # assert len(response.json["workflows"]) == 1 + # assert len(response.json["rules"]) == 1 + # assert len(response.json["authorizations"]) == 1 + + # Test export - with subprocess - no DMN- no task based forms + # form = FormProcessMapper( + # **mapper_payload("sample form2", "subprocessflow", "SubprocessFlow") + # ) + # form.save() + # mapper_id = form.id + # form_id = form.form_id + + # # Mock response + # mock_response = MagicMock() + # mock_response.status_code = 200 + # mock_response.json = { + # "forms": [get_forms("sample form2", "main")], + # "workflows": [ + # get_workflows("subprocessflow", "SubprocessFlow", "main", "xml"), + # get_workflows("subflow1", "subflow1", "sub", "xml"), + # get_workflows("subflow2", "subflow2", "sub", "xml"), + # ], + # "rules": [], + # "authorizations": [get_authorizations(form_id)], + # } + # client.get.return_value = mock_response + # response = get_export(client, headers, mapper_id) + # assert response.status_code == 200 + # assert response.json is not None + # assert len(response.json["forms"]) == 1 + # assert len(response.json["workflows"]) == 3 + # assert len(response.json["rules"]) == 0 + # assert len(response.json["authorizations"]) == 1 + + +def test_form_name_validate_invalid(app, client, session, jwt, mock_redis_client): + """Testing form name validation with valid parameters.""" + token = get_token(jwt, role=CREATE_DESIGNS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + + # Mock the requests.get method + with patch("requests.get") as mock_get: + # Create a mock response object + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.text = '{"message": "Form name, path, or title is invalid."}' + # Assign the mock response to the mocked get method + mock_get.return_value = mock_response + + # Test with valid parameters + response = client.get( + "/form/validate?title=TestForm&name=TestForm&path=TestForm", headers=headers + ) + + assert response.status_code == 400 + assert response.json is not None + assert ( + response.json["message"] + == "Form validation failed: The Name or Path already exists. They must be unique." + ) + + +def test_form_name_validate_missing_params( + app, client, session, jwt, mock_redis_client +): + """Testing form name validation with missing parameters.""" + token = get_token(jwt, role=CREATE_DESIGNS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + + # Mock the requests.get method + with patch("requests.get") as mock_get: + # Create a mock response object + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.text = '{"message": "At least one query parameter (title, name, path) must be provided."}' + # Assign the mock response to the mocked get method + mock_get.return_value = mock_response + + # Test with missing query parameters + response = client.get("/form/validate", headers=headers) + + assert response.status_code == 400 + assert response.json is not None + assert ( + response.json["message"] + == "At least one query parameter (title, name, path) must be provided." + ) + + +def test_form_name_validate_unauthorized(app, client): + """Testing form name validation without proper authorization.""" + # Mock the requests.get method + with patch("requests.get") as mock_get: + # Create a mock response object + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.text = '{"message": "Unauthorized"}' + # Assign the mock response to the mocked get method + mock_get.return_value = mock_response + + # Test without proper authorization + response = client.get("/form/validate?title=TestForm") + + assert response.status_code == 401 + + +def test_form_name_invalid_form_title(app, client, session, jwt, mock_redis_client): + """Testing invalid form title.""" + token = get_token(jwt, role=CREATE_DESIGNS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + # With only numbers + response = client.get("/form/validate?title=1234", headers=headers) + assert response.status_code == 400 + assert response.json is not None + assert ( + response.json["message"] + == "Title: Only contain alphanumeric characters, hyphens(not at the start or end), spaces,and must include at least one letter." + ) + # With special characters + response = client.get("/form/validate?title=$$", headers=headers) + assert response.status_code == 400 + assert response.json is not None + assert ( + response.json["message"] + == "Title: Only contain alphanumeric characters, hyphens(not at the start or end), spaces,and must include at least one letter." + ) + response = client.get("/form/validate?title=1234$@@#test", headers=headers) + assert response.status_code == 400 + assert response.json is not None + assert ( + response.json["message"] + == "Title: Only contain alphanumeric characters, hyphens(not at the start or end), spaces,and must include at least one letter." + ) + + +def test_form_name_invalid_form_name(app, client, session, jwt, mock_redis_client): + """Testing with invalid form name.""" + token = get_token(jwt, role=CREATE_DESIGNS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + # With only numbers + response = client.get("/form/validate?name=1234", headers=headers) + assert response.status_code == 400 + assert response.json is not None + assert ( + response.json["message"] + == "Name: Only contain alphanumeric characters, hyphens(not at the start or end), no spaces,and must include at least one letter." + ) + # With special characters + response = client.get("/form/validate?name=1234", headers=headers) + assert response.status_code == 400 + assert response.json is not None + assert ( + response.json["message"] + == "Name: Only contain alphanumeric characters, hyphens(not at the start or end), no spaces,and must include at least one letter." + ) + # With spaces + response = client.get("/form/validate?name=test form", headers=headers) + assert response.status_code == 400 + assert response.json is not None + assert ( + response.json["message"] + == "Name: Only contain alphanumeric characters, hyphens(not at the start or end), no spaces,and must include at least one letter." + ) + + +def test_form_name_invalid_form_path(app, client, session, jwt, mock_redis_client): + """Testing with invalid form path.""" + token = get_token(jwt, role=CREATE_DESIGNS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + # With only numbers + response = client.get("/form/validate?path=1234", headers=headers) + assert response.status_code == 400 + assert response.json is not None + assert ( + response.json["message"] + == "Path: Only contain alphanumeric characters, hyphens(not at the start or end), no spaces,and must include at least one letter." + ) + # With special characters + response = client.get("/form/validate?path=1234", headers=headers) + assert response.status_code == 400 + assert response.json is not None + assert ( + response.json["message"] + == "Path: Only contain alphanumeric characters, hyphens(not at the start or end), no spaces,and must include at least one letter." + ) + # With spaces + response = client.get("/form/validate?path=test form", headers=headers) + assert response.status_code == 400 + assert response.json is not None + assert ( + response.json["message"] + == "Path: Only contain alphanumeric characters, hyphens(not at the start or end), no spaces,and must include at least one letter." + ) + + +def test_form_name_invalid_form_name_title_path( + app, client, session, jwt, mock_redis_client +): + """Testing with invalid form name, title & path.""" + token = get_token(jwt, role=CREATE_DESIGNS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + + # Invalid path, title + response = client.get( + "/form/validate?name=testform&title=1234&path=$$$", headers=headers + ) + assert response.status_code == 400 + assert response.json is not None + assert ( + response.json["message"] + == """Title: Only contain alphanumeric characters, hyphens(not at the start or end), spaces,and must include at least one letter.,\n Path: Only contain alphanumeric characters, hyphens(not at the start or end), no spaces,and must include at least one letter.""" + ) + # Invalid name, title + response = client.get( + "/form/validate?name=test form&title=1234&path=testform123", headers=headers + ) + assert response.status_code == 400 + assert response.json is not None + assert ( + response.json["message"] + == """Title: Only contain alphanumeric characters, hyphens(not at the start or end), spaces,and must include at least one letter.,\n Name: Only contain alphanumeric characters, hyphens(not at the start or end), no spaces,and must include at least one letter.""" + ) + # Invalid path, name + response = client.get( + "/form/validate?name=test form&title=test form&path=$$$", headers=headers + ) + assert response.status_code == 400 + assert response.json is not None + assert ( + response.json["message"] + == """Path: Only contain alphanumeric characters, hyphens(not at the start or end), no spaces,and must include at least one letter.,\n Name: Only contain alphanumeric characters, hyphens(not at the start or end), no spaces,and must include at least one letter.""" + ) + + +def test_form_history( + app, + client, + session, + jwt, + mock_redis_client, +): + """Testing form history.""" + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + payload = get_formio_form_request_payload() + payload["componentChanged"] = True + payload["newVersion"] = True + response = client.post("/form/form-design", headers=headers, json=payload) + assert response.status_code == 201 + form_id = response.json["_id"] + # Assert form history with major version + response = client.get(f"/form/form-history/{form_id}", headers=headers) + assert response.status_code == 200 + assert response.json is not None + form_history = response.json["formHistory"] + assert len(form_history) == 1 + assert form_history[0]["majorVersion"] == 1 + assert form_history[0]["minorVersion"] == 0 + assert form_history[0]["formId"] == form_id + assert form_history[0]["version"] == "1.0" + assert form_history[0]["isMajor"] is True + assert response.json["totalCount"] == 1 + + # Assert form history with minor version + update_payload = get_formio_form_request_payload() + update_payload["componentChanged"] = True + update_payload["parentFormId"] = form_id + update_payload["_id"] = form_id + FormHistoryService.create_form_log_with_clone(data=update_payload) + response = client.get(f"/form/form-history/{form_id}", headers=headers) + assert response.status_code == 200 + assert response.json is not None + form_history = response.json["formHistory"] + assert len(form_history) == 2 + assert form_history[0]["majorVersion"] == 1 + assert form_history[0]["minorVersion"] == 1 + assert form_history[0]["formId"] == form_id + assert form_history[0]["version"] == "1.1" + assert form_history[0]["isMajor"] is False + assert form_history[1]["majorVersion"] == 1 + assert form_history[1]["minorVersion"] == 0 + assert form_history[1]["formId"] == form_id + assert form_history[1]["version"] == "1.0" + assert form_history[1]["isMajor"] is True + assert response.json["totalCount"] == 2 + + +def test_publish(app, client, session, jwt, mock_redis_client, create_mapper): + """Testing publish endpoint.""" + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + mapper_id = create_mapper["id"] + rv = client.get(f"/form/{mapper_id}", headers=headers) + assert rv.status_code == 200 + assert rv.json.get("id") == mapper_id + # Test publish endpoint with valid response. + with patch("requests.post") as mock_post: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "{}" + mock_post.return_value = mock_response + response = client.post(f"/form/{mapper_id}/publish", headers=headers) + assert response.status_code == 200 + + +def test_unpublish(app, client, session, jwt, mock_redis_client, create_mapper): + """Testing unpublish endpoint.""" + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + mapper_id = create_mapper["id"] + rv = client.get(f"/form/{mapper_id}", headers=headers) + assert rv.status_code == 200 + assert rv.json.get("id") == mapper_id + # Test unpublish endpoint with valid response. + with patch("requests.post") as mock_post: + mock_response = MagicMock() + mock_response.text = "{}" + mock_response.status_code = 200 + mock_post.return_value = mock_response + response = client.post(f"/form/{mapper_id}/unpublish", headers=headers) + assert response.status_code == 200 diff --git a/forms-flow-api/tests/unit/api/test_formio.py b/forms-flow-api/tests/unit/api/test_formio.py index e985c62603..045b52ff96 100644 --- a/forms-flow-api/tests/unit/api/test_formio.py +++ b/forms-flow-api/tests/unit/api/test_formio.py @@ -1,12 +1,24 @@ """Test suit for formio role id cached endpoint.""" +from unittest.mock import MagicMock + import jwt as pyjwt -from formsflow_api_utils.utils import Cache +from formsflow_api_utils.utils import ( + CREATE_DESIGNS, + CREATE_SUBMISSIONS, + MANAGE_TASKS, + Cache, +) from formsflow_api_utils.utils.enums import FormioRoles from tests.utilities.base_test import get_formio_roles, get_token +def get_roles(client, headers): + """Get formio roles.""" + return client.get("/formio/roles", headers=headers) + + def test_formio_roles(app, client, session, jwt, mock_redis_client): """Passing case of role API.""" role_ids_filtered = get_formio_roles() @@ -19,9 +31,30 @@ def test_formio_roles(app, client, session, jwt, mock_redis_client): Cache.set("user_resource_id", resource_id, timeout=0) # Requesting from client role - token = get_token(jwt, role="formsflow-client") + token = get_token(jwt, role=CREATE_SUBMISSIONS, roles=["/formsflow-client"]) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - response = client.get("/formio/roles", headers=headers) + client = MagicMock() + payload = { + "external": True, + "form": {"_id": "62cc9223b5cad9348f5880a9"}, + "user": { + "_id": "test", + "roles": ["65f808c6d5af8b9fccc9c330", "65f808c8d5af8b9fccc9c35b"], + "customRoles": ["/formsflow-client"], + }, + } + mock_jwt_token = pyjwt.encode( + payload, app.config["FORMIO_JWT_SECRET"], algorithm="HS256" + ) + + # Mock response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json = {"form": []} + mock_response.headers = {"x-jwt-token": mock_jwt_token} + client.get.return_value = mock_response + + response = get_roles(client, headers) assert response.status_code == 200 assert response.json["form"] == [] @@ -32,12 +65,12 @@ def test_formio_roles(app, client, session, jwt, mock_redis_client): key=app.config["FORMIO_JWT_SECRET"], ) assert decoded_token["form"]["_id"] == resource_id - assert decoded_token["user"]["customRoles"] == ["formsflow-client"] + assert decoded_token["user"]["customRoles"] == ["/formsflow-client"] # Requesting from reviewer role - token = get_token(jwt, role="formsflow-reviewer") + token = get_token(jwt, role=MANAGE_TASKS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - response = client.get("/formio/roles", headers=headers) + response = get_roles(client, headers) assert response.status_code == 200 assert response.json["form"] == [] @@ -45,9 +78,22 @@ def test_formio_roles(app, client, session, jwt, mock_redis_client): # Requesting from designer role Cache.set("user_resource_id", "123456789", timeout=0) - token = get_token(jwt, role="formsflow-designer") + token = get_token(jwt, role=CREATE_DESIGNS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - response = client.get("/formio/roles", headers=headers) + # Mock response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json = { + "form": [ + {"roleId": 1, "type": "CLIENT"}, + {"roleId": 2, "type": "REVIEWER"}, + {"roleId": 3, "type": "DESIGNER"}, + {"roleId": "123456789", "type": "RESOURCE_ID"}, + ] + } + client.get.return_value = mock_response + + response = get_roles(client, headers) assert response.json["form"][0]["roleId"] == 1 assert response.json["form"][0]["type"] == FormioRoles.CLIENT.name diff --git a/forms-flow-api/tests/unit/api/test_import_support.py b/forms-flow-api/tests/unit/api/test_import_support.py new file mode 100644 index 0000000000..1944e17e59 --- /dev/null +++ b/forms-flow-api/tests/unit/api/test_import_support.py @@ -0,0 +1,622 @@ +"""Test suite for Import API endpoint.""" + +import io +import json +from unittest.mock import patch + +from formsflow_api_utils.utils import CREATE_DESIGNS +from werkzeug.datastructures import FileStorage + +from formsflow_api.services import ImportService +from tests.utilities.base_test import get_formio_form_request_payload, get_token + + +def form_workflow_json_data(name="testform"): + """Form workflow json.""" + return { + "forms": [ + { + "formTitle": name, + "formDescription": "", + "anonymous": False, + "type": "main", + "content": { + "title": name, + "name": name, + "path": name, + "type": "form", + "display": "form", + "tags": ["common"], + "isBundle": "false", + "access": [ + { + "type": "read_all", + "roles": [ + "66d93e5986f02eb25448d611", + "66d93e5986f02eb25448d5f1", + "66d93e5986f02eb25448d608", + ], + }, + ], + "submissionAccess": [ + {"roles": ["628f0edf19cebb9cea4f1226"], "type": "create_all"}, + {"roles": ["628f0edf19cebb9cea4f1232"], "type": "read_all"}, + {"roles": ["628f0edf19cebb9cea4f1232"], "type": "update_all"}, + { + "roles": [ + "628f0edf19cebb9cea4f1226", + "628f0edf19cebb9cea4f1232", + ], + "type": "delete_all", + }, + {"roles": ["628f0ee019cebb9cea4f1236"], "type": "create_own"}, + {"roles": ["628f0ee019cebb9cea4f1236"], "type": "read_own"}, + {"roles": ["628f0ee019cebb9cea4f1236"], "type": "update_own"}, + {"roles": ["628f0edf19cebb9cea4f1232"], "type": "delete_own"}, + ], + "owner": "66d93e5986f02eb25448d68f", + "components": [ + { + "label": "Text Field", + "labelPosition": "top", + "placeholder": "", + "description": "", + "tooltip": "", + "prefix": "", + "suffix": "", + "widget": {"type": "input"}, + "inputMask": "", + "displayMask": "", + "allowMultipleMasks": "false", + "customClass": "", + "tabindex": "", + "autocomplete": "", + "hidden": "false", + "hideLabel": "false", + "showWordCount": "false", + "showCharCount": "false", + "mask": "false", + "autofocus": "false", + "spellcheck": "true", + "disabled": "false", + "tableView": "true", + "modalEdit": "false", + "multiple": "false", + "persistent": "true", + "inputFormat": "plain", + "protected": "false", + "dbIndex": "false", + "case": "", + "truncateMultipleSpaces": "false", + "encrypted": "false", + "redrawOn": "", + "clearOnHide": "true", + "customDefaultValue": "", + "calculateValue": "", + "calculateServer": "false", + "allowCalculateOverride": "false", + "validateOn": "change", + "validate": { + "required": "false", + "pattern": "", + "customMessage": "", + "custom": "", + "customPrivate": "false", + "json": "", + "minLength": "", + "maxLength": "", + "strictDateValidation": "false", + "multiple": "false", + "unique": "false", + }, + "unique": "false", + "errorLabel": "", + "errors": "", + "key": "textField", + "tags": [], + "properties": {}, + "conditional": { + "show": "null", + "when": "null", + "eq": "", + "json": "", + }, + "customConditional": "", + "logic": [], + "attributes": {}, + "overlay": { + "style": "", + "page": "", + "left": "", + "top": "", + "width": "", + "height": "", + }, + "type": "textfield", + "input": "true", + "refreshOn": "", + "dataGridLabel": "false", + "addons": [], + "inputType": "text", + "id": "eabmhto", + "defaultValue": "null", + }, + { + "type": "button", + "label": "Submit", + "key": "submit", + "size": "md", + "block": "false", + "action": "submit", + "disableOnInvalid": "true", + "theme": "primary", + "input": "true", + "placeholder": "", + "prefix": "", + "customClass": "", + "suffix": "", + "multiple": "false", + "defaultValue": "null", + "protected": "false", + "unique": "false", + "persistent": "false", + "hidden": "false", + "clearOnHide": "true", + "refreshOn": "", + "redrawOn": "", + "tableView": "false", + "modalEdit": "false", + "dataGridLabel": "true", + "labelPosition": "top", + "description": "", + "errorLabel": "", + "tooltip": "", + "hideLabel": "false", + "tabindex": "", + "disabled": "false", + "autofocus": "false", + "dbIndex": "false", + "customDefaultValue": "", + "calculateValue": "", + "calculateServer": "false", + "widget": {"type": "input"}, + "attributes": {}, + "validateOn": "change", + "validate": { + "required": "false", + "custom": "", + "customPrivate": "false", + "strictDateValidation": "false", + "multiple": "false", + "unique": "false", + }, + "conditional": {"show": "null", "when": "null", "eq": ""}, + "overlay": { + "style": "", + "left": "", + "top": "", + "width": "", + "height": "", + }, + "allowCalculateOverride": "false", + "encrypted": "false", + "showCharCount": "false", + "showWordCount": "false", + "properties": {}, + "allowMultipleMasks": "false", + "addons": [], + "leftIcon": "", + "rightIcon": "", + "id": "ehluayb", + }, + { + "label": "applicationId", + "customClass": "", + "addons": [], + "modalEdit": "false", + "persistent": "true", + "protected": "false", + "dbIndex": "false", + "encrypted": "false", + "redrawOn": "", + "customDefaultValue": "", + "calculateValue": "", + "calculateServer": "false", + "key": "applicationId", + "tags": [], + "properties": {}, + "logic": [], + "attributes": {}, + "overlay": { + "style": "", + "page": "", + "left": "", + "top": "", + "width": "", + "height": "", + }, + "type": "hidden", + "input": "true", + "placeholder": "", + "prefix": "", + "suffix": "", + "multiple": "false", + "unique": "false", + "hidden": "false", + "clearOnHide": "true", + "refreshOn": "", + "tableView": "false", + "labelPosition": "top", + "Description": "", + "errorLabel": "", + "tooltip": "", + "hideLabel": "false", + "tabindex": "", + "disabled": "false", + "autofocus": "false", + "widget": {"type": "input"}, + "validateOn": "change", + "validate": { + "required": "false", + "custom": "", + "customPrivate": "false", + "strictDateValidation": "false", + "multiple": "false", + "unique": "false", + }, + "conditional": {"show": "null", "when": "null", "eq": ""}, + "allowCalculateOverride": "false", + "showCharCount": "false", + "showWordCount": "false", + "allowMultipleMasks": "false", + "inputType": "hidden", + "id": "em1y8gd", + "defaultValue": "", + "dataGridLabel": "false", + "description": "", + }, + { + "label": "applicationStatus", + "addons": [], + "customClass": "", + "modalEdit": "false", + "defaultValue": "null", + "persistent": "true", + "protected": "false", + "dbIndex": "false", + "encrypted": "false", + "redrawOn": "", + "customDefaultValue": "", + "calculateValue": "", + "calculateServer": "false", + "key": "applicationStatus", + "tags": [], + "properties": {}, + "logic": [], + "attributes": {}, + "overlay": { + "style": "", + "page": "", + "left": "", + "top": "", + "width": "", + "height": "", + }, + "type": "hidden", + "input": "true", + "tableView": "false", + "placeholder": "", + "prefix": "", + "suffix": "", + "multiple": "false", + "unique": "false", + "hidden": "false", + "clearOnHide": "true", + "refreshOn": "", + "dataGridLabel": "false", + "labelPosition": "top", + "Description": "", + "errorLabel": "", + "tooltip": "", + "hideLabel": "false", + "tabindex": "", + "disabled": "false", + "autofocus": "false", + "widget": {"type": "input"}, + "validateOn": "change", + "validate": { + "required": "false", + "custom": "", + "customPrivate": "false", + "strictDateValidation": "false", + "multiple": "false", + "unique": "false", + }, + "conditional": {"show": "null", "when": "null", "eq": ""}, + "allowCalculateOverride": "false", + "showCharCount": "false", + "showWordCount": "false", + "allowMultipleMasks": "false", + "inputType": "hidden", + "id": "e6z1qd9", + "description": "", + }, + ], + "created": "2024-09-05T06:33:02.367Z", + "modified": "2024-09-05T06:33:02.385Z", + }, + } + ], + "workflows": [ + { + "processKey": "Defaultflow", + "processName": "Default Flow", + "type": "main", + "content": '\n\n\n\nFlow_09rbji4\n\n\n\n\nexecution.setVariable(\'applicationStatus\', \'Completed\');\n\n\n\n["applicationId","applicationStatus"]\n\n\n\n\nFlow_09rbji4\nFlow_0klorcg\n\n\n\n\nexecution.setVariable(\'applicationStatus\', \'New\');\n\n\n\n\n\nFlow_0klorcg\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n', + } + ], + "rules": [], + "authorizations": [ + { + "APPLICATION": { + "resourceId": "66d9509e86f02eb25448d75b", + "resourceDetails": {}, + "roles": [], + "userName": None, + }, + "DESIGNER": { + "resourceId": "66d9509e86f02eb25448d75b", + "resourceDetails": {}, + "roles": [], + "userName": None, + }, + "FORM": { + "resourceId": "66d9509e86f02eb25448d75b", + "resourceDetails": {}, + "roles": [], + "userName": None, + }, + } + ], + } + + +def form_json_data(): + """Form json data.""" + """Form json data format + {"forms":[{"title":"test form1","display":"form", }] + """ + form_data = get_formio_form_request_payload() + return {"forms": [form_data]} + + +def bpmn_data(): + """Bpmn data.""" + bpmn_data = """\n\n\n + \nFlow_09rbji4\n\n\n\n + \nexecution.setVariable(\'applicationStatus\', \'Completed\');\n\n + \n\n["applicationId","applicationStatus"]\n + \n\n\n\nFlow_09rbji4\n + Flow_0klorcg\n\n\n\n\n + execution.setVariable(\'applicationStatus\', \'New\');\n\n\n + \n\n\nFlow_0klorcg\n\n\n + \n\n\n\n\n\n\n + \n\n\n\n\n\n + \n\n\n\n\n\n\n + \n\n\n\n\n\n\n\n\n', + """ + return bpmn_data + + +def create_file( + form_content, filename="response_export.json", content_type="application/json" +): + """Create a file-like object.""" + return FileStorage( + stream=io.BytesIO(form_content.encode("utf-8")), + filename=filename, + content_type=content_type, + ) + + +def test_import_new(app, client, session, jwt, mock_redis_client): + """Testing import new form+workflow.""" + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "multipart/form-data", + } + + # Test case 1: Import new form+workflow - validate form + with patch.object(ImportService, "import_form_workflow") as mock_import_service: + mock_response = { + "form": {"majorVersion": 1, "minorVersion": 0}, + "workflow": {"majorVersion": 1, "minorVersion": 0}, + } + mock_import_service.return_value = mock_response + + # Prepare the file content + form_content = json.dumps(form_workflow_json_data()) + file = create_file(form_content) + + form_data = { + "file": file, + "data": json.dumps( + { + "importType": "new", + "action": "validate", + } + ), + } + + # Send the POST request with form-data + response = client.post("/import", data=form_data, headers=headers) + # Assertions to validate the response + assert response.status_code == 200 + assert response.json is not None + assert response.json == { + "form": {"majorVersion": 1, "minorVersion": 0}, + "workflow": {"majorVersion": 1, "minorVersion": 0}, + } + + # Test case 2: Import new form+workflow - import + with patch.object(ImportService, "import_form_workflow") as mock_import_service: + mock_import_service.return_value = mock_response + + # Prepare the file content + form_content = json.dumps(form_workflow_json_data()) + file = create_file(form_content) + + form_data = { + "file": file, + "data": json.dumps( + { + "importType": "new", + "action": "import", + } + ), + } + response = client.post("/import", data=form_data, headers=headers) + assert response.status_code == 200 + assert response.json is not None + + # Test case 3: Import with invalid json. + form_content = json.dumps(form_json_data()) + file = create_file(form_content) + + form_data = { + "file": file, + "data": json.dumps( + { + "importType": "new", + "action": "import", + } + ), + } + response = client.post("/import", data=form_data, headers=headers) + assert response.status_code == 400 + assert response.json == { + "message": "File format not supported", + "code": "INVALID_FILE_TYPE", + "details": [], + } + + +def test_import_edit(app, client, session, jwt, mock_redis_client): + """Testing import edit form+workflow.""" + # Initial mock tests have been added, integration tests will be incorporated in future iterations. + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "multipart/form-data", + } + # Test case 1: Import edit form+workflow - validate form + with patch.object(ImportService, "import_form_workflow") as mock_import_service: + mock_response = { + "form": {"majorVersion": 2, "minorVersion": 1}, + "workflow": {"majorVersion": 2, "minorVersion": 1}, + } + mock_import_service.return_value = mock_response + + # Prepare the file content + form_content = json.dumps(form_workflow_json_data()) + file = create_file(form_content) + + form_data = { + "file": file, + "data": json.dumps( + { + "importType": "edit", + "action": "validate", + "mapperId": "1", + "form": {"skip": "false", "selectedVersion": "major"}, + "workflow": {"skip": "false", "selectedVersion": "major"}, + } + ), + } + + response = client.post("/import", data=form_data, headers=headers) + assert response.status_code == 200 + assert response.json is not None + assert response.json == { + "form": {"majorVersion": 2, "minorVersion": 1}, + "workflow": {"majorVersion": 2, "minorVersion": 1}, + } + + # Test case 2: Import edit form+workflow + with patch.object(ImportService, "import_form_workflow") as mock_import_service: + mock_import_service.return_value = mock_response + + # Prepare the file content + form_content = json.dumps(form_workflow_json_data()) + file = create_file(form_content) + + form_data = { + "file": file, + "data": json.dumps( + { + "importType": "edit", + "action": "import", + "mapperId": "1", + "form": {"skip": "false", "selectedVersion": "major"}, + "workflow": {"skip": "false", "selectedVersion": "major"}, + } + ), + } + response = client.post("/import", data=form_data, headers=headers) + assert response.status_code == 200 + assert response.json is not None + + # Test case 3: Import edit - only form + with patch.object(ImportService, "import_form_workflow") as mock_import_service: + mock_import_service.return_value = mock_response + + # Prepare the file content + form_content = json.dumps(form_json_data()) + file = create_file(form_content) + + form_data = { + "file": file, + "data": json.dumps( + { + "importType": "edit", + "action": "import", + "mapperId": "1", + "form": {"skip": "false", "selectedVersion": "major"}, + "workflow": {"skip": "true", "selectedVersion": "major"}, + } + ), + } + response = client.post("/import", data=form_data, headers=headers) + assert response.status_code == 200 + assert response.json is not None + + # Test case 4: Import edit - only workflow + with patch.object(ImportService, "import_form_workflow") as mock_import_service: + mock_import_service.return_value = mock_response + + # Prepare the file content + form_content = bpmn_data() + file = create_file( + form_content, + filename="workflow.bpmn", + content_type="application/bpmn20-xml", + ) + + form_data = { + "file": file, + "data": json.dumps( + { + "importType": "edit", + "action": "import", + "mapperId": "1", + "form": {"skip": "true", "selectedVersion": "major"}, + "workflow": {"skip": "false", "selectedVersion": "major"}, + } + ), + } + response = client.post("/import", data=form_data, headers=headers) + assert response.status_code == 200 + assert response.json is not None diff --git a/forms-flow-api/tests/unit/api/test_keycloak_groups.py b/forms-flow-api/tests/unit/api/test_keycloak_groups.py index af95c060af..8db75c39eb 100644 --- a/forms-flow-api/tests/unit/api/test_keycloak_groups.py +++ b/forms-flow-api/tests/unit/api/test_keycloak_groups.py @@ -1,4 +1,5 @@ """Unit test for APIs of Keycloak Group.""" + import pytest from tests.utilities.base_test import get_token, update_dashboard_payload diff --git a/forms-flow-api/tests/unit/api/test_metrics.py b/forms-flow-api/tests/unit/api/test_metrics.py index 59491c4e4c..7f48c48cb1 100644 --- a/forms-flow-api/tests/unit/api/test_metrics.py +++ b/forms-flow-api/tests/unit/api/test_metrics.py @@ -1,13 +1,11 @@ """Test suite for metrics API endpoint.""" + import datetime import pytest +from formsflow_api_utils.utils import CREATE_SUBMISSIONS, VIEW_DASHBOARDS -from tests.utilities.base_test import ( - get_application_create_payload, - get_form_request_payload, - get_token, -) +from tests.utilities.base_test import get_application_create_payload, get_token METRICS_ORDER_BY_VALUES = ["created", "modified"] today = datetime.date.today().strftime("%Y-%m-%dT%H:%M:%S+00:00").replace("+", "%2B") @@ -21,7 +19,7 @@ @pytest.mark.parametrize("orderBy", METRICS_ORDER_BY_VALUES) def test_metrics_get_200(orderBy, app, client, session, jwt): """Tests the API/metrics endpoint with valid param.""" - token = get_token(jwt) + token = get_token(jwt, VIEW_DASHBOARDS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.get( f"/metrics?from={today}&to={tomorrow}&orderBy={orderBy}", headers=headers @@ -37,22 +35,19 @@ def test_metrics_get_401(orderBy, app, client, session): @pytest.mark.parametrize("orderBy", METRICS_ORDER_BY_VALUES) -def test_metrics_list_view(orderBy, app, client, session, jwt): +def test_metrics_list_view(orderBy, app, client, session, jwt, create_mapper): """Tests API/metrics endpoint with valid data.""" - token = get_token(jwt) + form_id = create_mapper["formId"] + token = get_token(jwt, role=CREATE_SUBMISSIONS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - assert rv.status_code == 201 - form_id = rv.json.get("formId") - rv = client.post( "/application/create", headers=headers, json=get_application_create_payload(form_id), ) assert rv.status_code == 201 - + token = get_token(jwt, VIEW_DASHBOARDS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.get( f"/metrics?from={today}&to={tomorrow}&orderBy={orderBy}", headers=headers ) @@ -70,22 +65,19 @@ def test_metrics_detailed_get_401(orderBy, app, client, session): @pytest.mark.parametrize("orderBy", METRICS_ORDER_BY_VALUES) -def test_metrics_detailed_view(orderBy, app, client, session, jwt): +def test_metrics_detailed_view(orderBy, app, client, session, jwt, create_mapper): """Tests API/metrics/ endpoint with valid data.""" - token = get_token(jwt) + form_id = create_mapper["formId"] + token = get_token(jwt, CREATE_SUBMISSIONS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - assert rv.status_code == 201 - form_id = rv.json.get("formId") - rv = client.post( "/application/create", headers=headers, json=get_application_create_payload(form_id), ) assert rv.status_code == 201 - + token = get_token(jwt, VIEW_DASHBOARDS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.get( f"/metrics/{form_id}?from={today}&to={tomorrow}&orderBy={orderBy}&formType=form", headers=headers, @@ -96,22 +88,21 @@ def test_metrics_detailed_view(orderBy, app, client, session, jwt): @pytest.mark.parametrize("orderBy", METRICS_ORDER_BY_VALUES) @pytest.mark.parametrize(("pageNo", "limit"), ((1, 5), (1, 10), (1, 20))) -def test_metrics_paginated_list(orderBy, pageNo, limit, app, client, session, jwt): +def test_metrics_paginated_list( + orderBy, pageNo, limit, app, client, session, jwt, create_mapper +): """Tests API/metrics endpoint with valid data.""" - token = get_token(jwt) + form_id = create_mapper["formId"] + token = get_token(jwt, CREATE_SUBMISSIONS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - assert rv.status_code == 201 - form_id = rv.json.get("formId") - rv = client.post( "/application/create", headers=headers, json=get_application_create_payload(form_id), ) assert rv.status_code == 201 - + token = get_token(jwt, VIEW_DASHBOARDS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.get( f"/metrics?from={today}&to={tomorrow}&orderBy={orderBy}&pageNo={pageNo}&limit={limit}", headers=headers, @@ -129,23 +120,20 @@ def test_metrics_paginated_list(orderBy, pageNo, limit, app, client, session, jw ), ) def test_metrics_paginated_sorted_list( - orderBy, pageNo, limit, sortBy, sortOrder, app, client, session, jwt + orderBy, pageNo, limit, sortBy, sortOrder, app, client, session, jwt, create_mapper ): """Tests API/metrics endpoint with valid data.""" - token = get_token(jwt) + form_id = create_mapper["formId"] + token = get_token(jwt, CREATE_SUBMISSIONS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - assert rv.status_code == 201 - form_id = rv.json.get("formId") - rv = client.post( "/application/create", headers=headers, json=get_application_create_payload(form_id), ) assert rv.status_code == 201 - + token = get_token(jwt, VIEW_DASHBOARDS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.get( f"/metrics?from={today}&to={tomorrow}&orderBy={orderBy}&pageNo={pageNo}&limit={limit}&sortBy={sortBy}&sortOrder={sortOrder}", headers=headers, @@ -163,23 +151,20 @@ def test_metrics_paginated_sorted_list( ), ) def test_metrics_paginated_filtered_list( - orderBy, pageNo, limit, formName, app, client, session, jwt + orderBy, pageNo, limit, formName, app, client, session, jwt, create_mapper ): """Tests API/metrics endpoint with valid data.""" - token = get_token(jwt) + form_id = create_mapper["formId"] + token = get_token(jwt, CREATE_SUBMISSIONS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} - - rv = client.post("/form", headers=headers, json=get_form_request_payload()) - assert rv.status_code == 201 - form_id = rv.json.get("formId") - rv = client.post( "/application/create", headers=headers, json=get_application_create_payload(form_id), ) assert rv.status_code == 201 - + token = get_token(jwt, VIEW_DASHBOARDS) + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.get( f"/metrics?from={today}&to={tomorrow}&orderBy={orderBy}&pageNo={pageNo}&limit={limit}&formName={formName}", headers=headers, diff --git a/forms-flow-api/tests/unit/api/test_process.py b/forms-flow-api/tests/unit/api/test_process.py new file mode 100644 index 0000000000..8fc6b149cb --- /dev/null +++ b/forms-flow-api/tests/unit/api/test_process.py @@ -0,0 +1,653 @@ +"""Test suite for Process API endpoints.""" + +from unittest.mock import MagicMock, patch + +import pytest +from formsflow_api_utils.utils import ( + CREATE_DESIGNS, + MANAGE_SUBFLOWS, + MANAGE_TASKS, +) + +from formsflow_api.models import Process +from formsflow_api.services import ProcessService +from tests.utilities.base_test import ( + get_process_request_payload, + get_process_request_payload_for_dmn, + get_process_request_payload_low_code, + get_token, +) + + +def mapper_payload(form_id, form_name): + """Mapper payload.""" + return { + "formId": form_id, + "formName": form_name, + "processKey": "onestepapproval", + "processName": "One Step Approval", + "status": "inactive", + "formType": "form", + "parentFormId": form_id, + "is_migrated": False, + } + + +def ensure_process_data_binary(process_id): + """Convert process_data to binary if string.""" + process = Process.query.get(process_id) + if isinstance(process.process_data, str): + process.process_data = process.process_data.encode("utf-8") + process.save() + + +@pytest.fixture +def create_process(app, client, session, jwt): + """Create a process.""" + process = Process( + name="Test Workflow", + process_type="BPMN", + tenant=None, + process_data=get_process_request_payload()["processData"].encode("utf-8"), + created_by="test", + major_version=1, + minor_version=0, + is_subflow=False, + process_key="testworkflow", + parent_process_key="testworkflow", + ) + process.save() + return process + + +@pytest.fixture +def create_process_with_api_call(app, client, session, jwt): + """Create a process with API call.""" + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = client.post( + "/process", headers=headers, json=get_process_request_payload() + ) + assert response.status_code == 201 + return response + + +class TestProcessCreate: + """Test suite for the process create method.""" + + def test_process_create_method( + self, app, client, session, jwt, create_process_with_api_call + ): + """Tests the process create method with valid payload.""" + response = create_process_with_api_call + assert response.json.get("id") is not None + assert response.json.get("name") == "Test workflow" + + def test_process_create_method_with_invalid_token(self, app, client, session, jwt): + """Tests the process create method with invalid token.""" + token = get_token(jwt, role=MANAGE_TASKS, username="reviewer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = client.post( + "/process", headers=headers, json=get_process_request_payload() + ) + assert response.status_code == 401 + + +class TestProcessUpdate: + """Test suite for the process update method.""" + + def test_process_update_subflow( + self, app, client, session, jwt, create_process_with_api_call + ): + """Tests the process update method with valid payload.""" + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = create_process_with_api_call + assert response.status_code == 201 + assert response.json.get("processType") == "BPMN" + assert response.json.get("id") is not None + process_id = response.json.get("id") + ensure_process_data_binary(process_id) + response = client.put( + f"/process/{process_id}", + headers=headers, + json=get_process_request_payload_for_dmn(), + ) + assert response.status_code == 200 + assert response.json.get("processType") == "DMN" + + def test_process_update_invalid_token( + self, app, client, session, jwt, create_process_with_api_call + ): + """Tests the process update method with invalid token.""" + response = create_process_with_api_call + assert response.status_code == 201 + assert response.json.get("id") is not None + process_id = response.json.get("id") + ensure_process_data_binary(process_id) + token = get_token(jwt, role=MANAGE_TASKS) + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = client.put( + f"/process/{process_id}", + headers=headers, + json=get_process_request_payload_low_code(status="Published"), + ) + assert response.status_code == 401 + + +class TestProcessList: + """Test suite for the process list.""" + + def test_process_list( + self, app, client, session, jwt, create_process_with_api_call + ): + """Testing process listing API.""" + token = get_token(jwt, role=MANAGE_SUBFLOWS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = create_process_with_api_call + ensure_process_data_binary(response.json.get("id")) + response = client.get("/process", headers=headers) + assert response.status_code == 200 + assert response.json is not None + assert response.json["totalCount"] == 1 + assert response.json["process"][0]["name"] == "Test workflow" + + @pytest.mark.parametrize( + ("pageNo", "limit", "sortBy", "sortOrder"), + ((1, 5, "id", "asc"), (1, 10, "id", "desc"), (1, 20, "id", "desc")), + ) + def test_process_list_with_pagination_sorted_list( + self, + app, + client, + session, + jwt, + pageNo, + limit, + sortBy, + sortOrder, + create_process_with_api_call, + ): + """Testing process listing API with pagination and sorted list.""" + token = get_token(jwt, role=MANAGE_SUBFLOWS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + + response = create_process_with_api_call + ensure_process_data_binary(response.json.get("id")) + response = client.get( + f"/process?pageNo={pageNo}&limit={limit}&sortBy={sortBy}&sortOrder={sortOrder}", + headers=headers, + ) + assert response.status_code == 200 + assert response.json is not None + + def test_process_list_with_filters( + self, app, client, session, jwt, create_process_with_api_call + ): + """Testing process listing API with filters.""" + token = get_token(jwt, role=MANAGE_SUBFLOWS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = create_process_with_api_call + ensure_process_data_binary(response.json.get("id")) + + # testing with processType filter with status + response = client.get( + "/process?status=Draft&processType=BPMN&name=Test", headers=headers + ) + assert response.status_code == 200 + assert response.json is not None + assert response.json["totalCount"] == 1 + + def test_process_list_with_invalid_token(self, app, client, session, jwt): + """Testing process listing API.""" + token = get_token(jwt, role=MANAGE_TASKS, username="reviewer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = client.get("/process", headers=headers) + assert response.status_code == 401 + + +class TestProcessDelete: + """Test suite for the process delete method.""" + + def test_process_delete_method( + self, app, client, session, jwt, create_process_with_api_call + ): + """Tests the process delete method.""" + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = create_process_with_api_call + assert response.status_code == 201 + assert response.json.get("id") is not None + process_id = response.json.get("id") + ensure_process_data_binary(process_id) + response = client.delete(f"/process/{process_id}", headers=headers) + assert response.status_code == 200 + assert response.json.get("message") == "Process deleted." + response = client.get(f"/process/{process_id}", headers=headers) + assert response.status_code == 400 + + def test_process_delete_method_with_invalid_token(self, app, client, session, jwt): + """Tests the process delete method with invalid token.""" + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = client.post( + "/process", headers=headers, json=get_process_request_payload() + ) + assert response.status_code == 201 + assert response.json.get("id") is not None + process_id = response.json.get("id") + token = get_token(jwt, role=MANAGE_TASKS) + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = client.delete(f"/process/{process_id}", headers=headers) + assert response.status_code == 401 + + +class TestProcessHistory: + """Test suite for the process version history endpoint.""" + + def test_process_version_history_success(self, app, client, session, jwt): + """Test the process version history endpoint success case.""" + # Mock token + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + with patch.object(ProcessService, "get_all_history") as mock_import_service: + mock_response = ( + [ + { + "id": "3", + "processName": "test", + "createdBy": "formsflow-designer", + "created": "2024-09-12 06:33:31.101156", + "majorVersion": 3, + "minorVersion": 0, + }, + { + "id": "2", + "processName": "test", + "createdBy": "formsflow-designer", + "created": "2024-09-12 06:33:06.454344", + "majorVersion": 2, + "minorVersion": 0, + }, + { + "id": "1", + "processName": "test", + "createdBy": "formsflow-designer", + "created": "2024-09-12 06:32:59.930011", + "majorVersion": 1, + "minorVersion": 0, + }, + ], + 3, + ) + + mock_import_service.return_value = mock_response + response = client.get("/process/test/versions", headers=headers) + # Assertions + assert response.status_code == 200 + assert response.json is not None + response_json = response.json["processHistory"] + assert len(response_json) == 3 + assert response_json[0]["id"] == "3" + assert response_json[0]["processName"] == "test" + assert response_json[0]["createdBy"] == "formsflow-designer" + assert response_json[0]["created"] == "2024-09-12 06:33:31.101156" + assert response_json[0]["majorVersion"] == 3 + assert response_json[0]["minorVersion"] == 0 + + assert response_json[1]["id"] == "2" + assert response_json[1]["processName"] == "test" + assert response_json[1]["createdBy"] == "formsflow-designer" + assert response_json[1]["created"] == "2024-09-12 06:33:06.454344" + assert response_json[1]["majorVersion"] == 2 + assert response_json[1]["minorVersion"] == 0 + + assert response_json[2]["id"] == "1" + assert response_json[2]["processName"] == "test" + assert response_json[2]["createdBy"] == "formsflow-designer" + assert response_json[2]["created"] == "2024-09-12 06:32:59.930011" + assert response_json[2]["majorVersion"] == 1 + assert response_json[2]["minorVersion"] == 0 + + assert response.json["totalCount"] == 3 + + def test_process_version_history_non_existent_process( + self, app, client, session, jwt + ): + """Test version history of a non-existent process.""" + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + + # Call the process version history endpoint with a non-existent process name + response = client.get("/process/non_existent_process/versions", headers=headers) + assert response.status_code == 400 + assert response.json.get("message") == "The specified process ID does not exist" + + def test_process_version_history_invalid_token(self, app, client, session, jwt): + """Test version history with an invalid token.""" + token = get_token(jwt, role=MANAGE_TASKS, username="reviewer") # Incorrect role + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + + response = client.get("/process/Testworkflow/versions", headers=headers) + assert response.status_code == 401 + + +class TestProcessValidation: + """Test suite for the process validation endpoint.""" + + def test_process_validation_invalid(self, app, client, session, jwt): + """Test process validation api with already exists process key/name.""" + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = client.post( + "/process", + headers=headers, + json=get_process_request_payload(), + ) + assert response.status_code == 201 + assert response.json.get("id") is not None + ensure_process_data_binary(response.json.get("id")) + response = client.get( + "/process/validate?processKey=Testworkflow&processName=Testworkflow", + headers=headers, + ) + assert response.status_code == 400 + assert ( + response.json.get("message") + == "The Process name or ID already exists. It must be unique." + ) + + def test_process_validate_missing_params(app, client, session, jwt): + """Testing process name validation with missing parameters.""" + token = get_token(jwt, role=CREATE_DESIGNS) + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = client.get("/process/validate", headers=headers) + assert response.status_code == 400 + assert ( + response.json.get("message") + == "At least one query parameter (name, key) must be provided." + ) + + def test_process_validate_unauthorized(app, client, session, jwt): + """Testing process name validation without proper authorization.""" + response = client.get("/process/validate") + assert response.status_code == 401 + + def test_process_validation_success(self, app, client, session, jwt): + """Test process validation api with success.""" + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + # Validate process validate api with no exists process key & name + response = client.get( + "/process/validate?processKey=testflow&processName=testflow", + headers=headers, + ) + assert response.status_code == 200 + + # Validate process validate api excluding a specific parent process key + # Which will be used in edit process + response = client.post( + "/process", + headers=headers, + json=get_process_request_payload(), + ) + assert response.status_code == 201 + assert response.json.get("id") is not None + ensure_process_data_binary(response.json.get("id")) + # Invoke the process validation endpoint with parentProcessKey=Testworkflow, + # which excludes rows with the specified parent key during validation. + response = client.get( + "/process/validate?processKey=Testworkflow&processName=Testworkflow&parentProcessKey=Testworkflow", + headers=headers, + ) + assert response.status_code == 200 + + +class TestProcessPublish: + """Test suite for the process publish.""" + + def test_process_publish_success(self, app, client, session, jwt): + """Test process publish success.""" + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = client.post( + "/process", headers=headers, json=get_process_request_payload() + ) + + assert response.status_code == 201 + assert response.json.get("id") is not None + assert response.json.get("name") == "Test workflow" + process_id = response.json.get("id") + ensure_process_data_binary(process_id) + + with patch("requests.post") as mock_post: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "{}" + mock_post.return_value = mock_response + + response = client.post( + f"/process/{process_id}/publish", headers=headers, json={} + ) + assert response.status_code == 200 + + def test_process_publish_invalid_id(self, app, client, session, jwt): + """Test process publish with invalid id.""" + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = client.post("/process/55/publish", headers=headers, json={}) + assert response.status_code == 400 + + def test_process_publish_unauthorized(app, client, session, jwt): + """Testing process publish without proper authorization.""" + response = client.post("/process/55/publish", json={}) + assert response.status_code == 401 + + +class TestProcessUnPublish: + """Test suite for the process unpublish.""" + + def test_process_unpublish_success(self, app, client, session, jwt): + """Test process unpublish success.""" + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = client.post( + "/process", headers=headers, json=get_process_request_payload() + ) + + assert response.status_code == 201 + assert response.json.get("id") is not None + assert response.json.get("name") == "Test workflow" + process_id = response.json.get("id") + ensure_process_data_binary(process_id) + + response = client.post( + f"/process/{process_id}/unpublish", headers=headers, json={} + ) + assert response.status_code == 200 + + def test_process_unpublish_invalid_id(self, app, client, session, jwt): + """Test process unpublish with invalid id.""" + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = client.post("/process/55/unpublish", headers=headers, json={}) + assert response.status_code == 400 + + def test_process_unpublish_unauthorized(app, client, session, jwt): + """Testing process unpublish without proper authorization.""" + response = client.post("/process/55/publish", json={}) + assert response.status_code == 401 + + +class GetProcessByProcessKey: + """Test suite for the process get by process key.""" + + def test_get_process_by_key_success(self, app, client, session, jwt): + """Testing process get by process key with success.""" + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = create_process_with_api_call + assert response.status_code == 201 + assert response.json.get("id") is not None + process_id = response.json.get("id") + ensure_process_data_binary(process_id) + response = client.get( + "/process/key/Testworkflow", + headers=headers, + ) + assert response.status_code == 200 + assert response.json.get("processKey") == "Testworkflow" + + def test_process_get_process_by_key_unauthorized(self, app, client, session, jwt): + """Testing process get by process key without proper authorization.""" + response = client.post("/process/key/Testworkflow", json={}) + assert response.status_code == 401 + + def test_get_process_by_key_invalid_key(self, app, client, session, jwt): + """Testing process get by process key with invalid key.""" + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + response = create_process_with_api_call + assert response.status_code == 201 + assert response.json.get("id") is not None + process_id = response.json.get("id") + ensure_process_data_binary(process_id) + response = client.get( + "/process/key/dummykey", + headers=headers, + ) + assert response.status_code == 400 + + +class MigrateProcess: + """Test suite for the migrate process.""" + + def migrate_process_success(self, app, client, session, jwt, create_mapper_custom): + """Migrate process with success.""" + rv = create_mapper_custom( + mapper_payload(form_id="1234", form_name="Sample form1") + ) + mapper_id = rv["id"] + create_mapper_custom(mapper_payload(form_id="12345", form_name="Sample form2")) + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + rv = client.post( + "/process/migrate", + headers=headers, + json={"mapperId": mapper_id, "processKey": "onestepapproval"}, + ) + assert rv.status_code == 200 + + def migrate_process_unauthorized( + self, app, client, session, jwt, create_mapper_custom + ): + """Migrate process without proper authorization.""" + rv = create_mapper_custom( + mapper_payload(form_id="1234", form_name="Sample form1") + ) + mapper_id = rv["id"] + create_mapper_custom(mapper_payload(form_id="12345", form_name="Sample form2")) + rv = client.post( + "/process/migrate", + json={"mapperId": mapper_id, "processKey": "onestepapproval"}, + ) + assert rv.status_code == 401 + + def migrate_process_invalid(self, app, client, session, jwt, create_mapper_custom): + """Migrate process with invalid data.""" + rv = create_mapper_custom( + mapper_payload(form_id="1234", form_name="Sample form1") + ) + mapper_id = rv["id"] + create_mapper_custom(mapper_payload(form_id="12345", form_name="Sample form2")) + token = get_token(jwt, role=CREATE_DESIGNS, username="designer") + headers = { + "Authorization": f"Bearer {token}", + "content-type": "application/json", + } + # Test with different process_key other than mapper process_key + rv = client.post( + "/process/migrate", + headers=headers, + json={"mapperId": mapper_id, "processKey": "twostepapproval"}, + ) + assert rv.status_code == 400 + # Test with invalid mapper Id + rv = client.post( + "/process/migrate", + headers=headers, + json={"mapperId": 99, "processKey": "twostepapproval"}, + ) + assert rv.status_code == 400 diff --git a/forms-flow-api/tests/unit/api/test_roles.py b/forms-flow-api/tests/unit/api/test_roles.py index c8c3779b37..d74c1e0365 100644 --- a/forms-flow-api/tests/unit/api/test_roles.py +++ b/forms-flow-api/tests/unit/api/test_roles.py @@ -1,5 +1,7 @@ """Test suite for keycloak roles API endpoint.""" +from formsflow_api_utils.utils import ADMIN + from tests.utilities.base_test import get_token @@ -8,7 +10,7 @@ class TestKeycloakRolesResource: def test_keycloak_roles_list(self, app, client, session, jwt): """Test roles list API.""" - token = get_token(jwt, role="formsflow-admin") + token = get_token(jwt, role=ADMIN) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", @@ -18,19 +20,27 @@ def test_keycloak_roles_list(self, app, client, session, jwt): def test_keycloak_role_crud(self, app, client, session, jwt): """Test role CRUD APIs.""" - token = get_token(jwt, role="formsflow-admin") + token = get_token(jwt, role=ADMIN) headers = { "Authorization": f"Bearer {token}", "content-type": "application/json", } # Create new user group. - data = {"name": "new-test-group", "description": "Group"} + data = { + "name": "new-test-group", + "description": "Group", + "permissions": ["view_designs", "create_designs"], + } rv = client.post("/roles", headers=headers, json=data) assert rv.status_code == 201 assert rv.json.get("id") is not None id = rv.json.get("id") # Update group. - data = {"name": "new-test-group", "description": "Test Group"} + data = { + "name": "new-test-group", + "description": "Test Group", + "permissions": ["view_designs", "create_designs"], + } rv = client.put(f"/roles/{id}", headers=headers, json=data) assert rv.status_code == 200 # Get group by id. diff --git a/forms-flow-api/tests/unit/api/test_theme.py b/forms-flow-api/tests/unit/api/test_theme.py new file mode 100644 index 0000000000..511c7974de --- /dev/null +++ b/forms-flow-api/tests/unit/api/test_theme.py @@ -0,0 +1,59 @@ +"""Test suite for Theme API endpoint.""" + +import json + +from formsflow_api_utils.utils import ADMIN + +from tests.utilities.base_test import get_token + +payload = { + "logoName": "logo 2", + "type": "url", + "logoData": "61ef7aa2555663dsef", + "applicationTitle": "Public Plandsfsdf", + "themeJson": {"sample": "test123sdfsfsdf"}, +} + + +def test_create_theme(app, client, session, jwt): + """Test create theme with valid payload.""" + token = get_token(jwt, role=ADMIN, username="admin") + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + response = client.post("/themes", headers=headers, data=json.dumps(payload)) + assert response.status_code == 201 + + +def test_get_theme(app, client, session, jwt): + """Testing Theme customization get endpoint.""" + token = get_token(jwt, role=ADMIN, username="admin") + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + response = client.post( + "/themes", + headers=headers, + data=json.dumps(payload), + ) + assert response.status_code == 201 + + response = client.get("/themes") + assert response.status_code == 200 + + +def test_theme_update(app, client, session, jwt): + """Testing Theme customization update endpoint.""" + token = get_token(jwt, role=ADMIN, username="admin") + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + response = client.post( + "/themes", + headers=headers, + data=json.dumps(payload), + ) + assert response.status_code == 201 + + response = client.get("/themes", headers=headers) + assert response.status_code == 200 + update_payload = { + "type": "base64", + "logoData": "61ef7aa25556eeffna", + } + rv = client.put("/themes", headers=headers, data=json.dumps(update_payload)) + assert rv.status_code == 200 diff --git a/forms-flow-api/tests/unit/api/test_user.py b/forms-flow-api/tests/unit/api/test_user.py index a27213b84a..8940764a24 100644 --- a/forms-flow-api/tests/unit/api/test_user.py +++ b/forms-flow-api/tests/unit/api/test_user.py @@ -1,7 +1,15 @@ """Test suite for keycloak user API endpoint.""" # from tests import skip_in_ci -from tests.utilities.base_test import get_locale_update_valid_payload, get_token +import json + +from formsflow_api_utils.utils import CREATE_FILTERS, VIEW_TASKS + +from tests.utilities.base_test import ( + get_filter_payload, + get_locale_update_valid_payload, + get_token, +) class TestKeycloakUserServiceResource: @@ -33,7 +41,7 @@ def test_unsuccessful_user_locale_update(self, app, client, session, jwt): def test_keycloak_users_list(app, client, session, jwt): """Test users list API with formsflow-reviewer group.""" - token = get_token(jwt) + token = get_token(jwt, role=VIEW_TASKS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.get("/user?memberOfGroup=formsflow/formsflow-reviewer", headers=headers) assert rv.status_code == 200 @@ -59,12 +67,32 @@ def test_keycloak_users_list(app, client, session, jwt): assert type(user.get("role")) == list assert len(user["role"]) != 0 realm_users = client.get("/user?role=true", headers=headers) - assert realm_users.status_code == 400 + assert realm_users.status_code == 200 def test_keycloak_users_list_invalid_group(app, client, session, jwt): """Test users list API with invalid group.""" - token = get_token(jwt) + token = get_token(jwt, role=VIEW_TASKS) headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} rv = client.get("/user?memberOfGroup=test123", headers=headers) assert rv.status_code == 400 + + +def test_default_filter(app, client, session, jwt): + """Test create a filter and update default filter of a user.""" + token = get_token(jwt, role=CREATE_FILTERS, username="reviewer") + headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"} + # Create filter for clerk role + response = client.post( + "/filter", + headers=headers, + json=get_filter_payload(name="Clerk Task", roles=["clerk"]), + ) + assert response.status_code == 201 + response = client.post( + "/user/default-filter", + headers=headers, + data=json.dumps({"defaultFilter": response.json.get("id")}), + content_type="application/json", + ) + assert response.status_code == 200 diff --git a/forms-flow-api/tests/unit/models/test_application.py b/forms-flow-api/tests/unit/models/test_application.py index 29ca6e6bdc..c60868c817 100644 --- a/forms-flow-api/tests/unit/models/test_application.py +++ b/forms-flow-api/tests/unit/models/test_application.py @@ -1,4 +1,5 @@ """Unit tests for application Model.""" + from formsflow_api.models import Application, FormProcessMapper diff --git a/forms-flow-api/tests/unit/models/test_application_audit.py b/forms-flow-api/tests/unit/models/test_application_audit.py index 3dc36bd0f5..94ed021d10 100644 --- a/forms-flow-api/tests/unit/models/test_application_audit.py +++ b/forms-flow-api/tests/unit/models/test_application_audit.py @@ -1,4 +1,5 @@ """Unit tests for Application Audit Model.""" + from formsflow_api.models import ApplicationHistory diff --git a/forms-flow-api/tests/unit/models/test_form_process_mapper.py b/forms-flow-api/tests/unit/models/test_form_process_mapper.py index 92b8d4bd52..d314d4cb0a 100644 --- a/forms-flow-api/tests/unit/models/test_form_process_mapper.py +++ b/forms-flow-api/tests/unit/models/test_form_process_mapper.py @@ -1,4 +1,5 @@ """Unit tests for FormProcessMapper Model.""" + from formsflow_api.models import FormProcessMapper diff --git a/forms-flow-api/tests/unit/models/test_formprocessmapper.py b/forms-flow-api/tests/unit/models/test_formprocessmapper.py index 92b8d4bd52..d314d4cb0a 100644 --- a/forms-flow-api/tests/unit/models/test_formprocessmapper.py +++ b/forms-flow-api/tests/unit/models/test_formprocessmapper.py @@ -1,4 +1,5 @@ """Unit tests for FormProcessMapper Model.""" + from formsflow_api.models import FormProcessMapper diff --git a/forms-flow-api/tests/unit/services/test_application.py b/forms-flow-api/tests/unit/services/test_application.py index 5aa8ae10b1..5fe3cc447a 100644 --- a/forms-flow-api/tests/unit/services/test_application.py +++ b/forms-flow-api/tests/unit/services/test_application.py @@ -1,4 +1,5 @@ """Tests to assure the Application Service.""" + from formsflow_api.services import ApplicationService application_service = ApplicationService() diff --git a/forms-flow-api/tests/unit/services/test_application_history.py b/forms-flow-api/tests/unit/services/test_application_history.py index b7a5cea946..580e7096b6 100644 --- a/forms-flow-api/tests/unit/services/test_application_history.py +++ b/forms-flow-api/tests/unit/services/test_application_history.py @@ -1,4 +1,5 @@ """Tests to assure the Application History Service.""" + from formsflow_api.services import ApplicationHistoryService application_history_service = ApplicationHistoryService() @@ -13,7 +14,7 @@ def test_create_application_history(app, client, session): } payload["application_id"] = 1222 # sample value application_history = application_history_service.create_application_history( - data=payload + data=payload, application_id=1222 ) assert application_history.application_id == 1222 assert application_history.application_status == "Pending" diff --git a/forms-flow-api/tests/unit/services/test_form_process_mapper.py b/forms-flow-api/tests/unit/services/test_form_process_mapper.py index 081ff28928..575bfcf111 100644 --- a/forms-flow-api/tests/unit/services/test_form_process_mapper.py +++ b/forms-flow-api/tests/unit/services/test_form_process_mapper.py @@ -1,4 +1,5 @@ """Tests to assure the FormProcessMapper Service.""" + from formsflow_api.services import FormProcessMapperService # from tests.utilities.base_test import get_form_service_payload @@ -6,52 +7,8 @@ form_service = FormProcessMapperService() -def test_form_get_all_mappers(app, client, session): - """Tests get_all_mappers handler when query params are None.""" - rv = form_service.get_all_mappers( - page_number=None, limit=None, form_name=None, sort_by=None, sort_order=None - ) - assert rv == ([], 0) - - def test_get_form_mapper_count(app, client, session): """Tets the get_mapper_count method.""" rv = form_service.get_mapper_count() assert not rv assert isinstance(rv, int) - - -# def test_get_form_mapper(session): -# rv = form_service.get_mapper(1) -# assert not rv -# assert type(rv) == dict - - -# def test_get_form_mapper_by_formid(session): -# rv = form_service.get_mapper_by_formid(1) -# assert not rv -# assert type(rv) == dict - -# commenting out since duplicate test - -# def test_create_form_mapper(app, client, session): -# """Tests the create_mapper method with valid payload.""" -# rv = form_service.create_mapper(data=get_form_service_payload()) -# assert rv.form_id == "1234" -# assert rv.form_name == "Sample form" - - -# def test_update_form_mapper(app, session, client): -# """Tests the update_mapper method with valid payload.""" -# rv = form_service.create_mapper(data=get_form_service_payload()) -# assert rv.form_id == "1234" -# assert rv.form_name == "Sample form" -# form_id = rv.id -# rv = form_service.update_mapper(form_id, data=get_form_service_payload()) -# assert rv.form_id == "1234" -# assert rv.form_name == "Sample form" - - -# def test_mark_inactive(session): -# rv = form_service.mark_inactive(form_process_mapper_id=1) -# assert rv.status_code == 200 diff --git a/forms-flow-api/tests/unit/utils/test_logging.py b/forms-flow-api/tests/unit/utils/test_logging.py index 379ccedbd8..6d89e8d6c6 100644 --- a/forms-flow-api/tests/unit/utils/test_logging.py +++ b/forms-flow-api/tests/unit/utils/test_logging.py @@ -2,6 +2,7 @@ Test-Suite to ensure that the logging setup is working as expected. """ + from formsflow_api_utils.utils.logging import setup_logging diff --git a/forms-flow-api/tests/utilities/base_test.py b/forms-flow-api/tests/utilities/base_test.py index e3fe15b022..f5eec66c04 100644 --- a/forms-flow-api/tests/utilities/base_test.py +++ b/forms-flow-api/tests/utilities/base_test.py @@ -1,9 +1,11 @@ """Base Test Class to be used by test suites. Used for getting JWT token purpose.""" + import datetime import time from dotenv import find_dotenv, load_dotenv from flask import current_app +from formsflow_api_utils.utils import CREATE_SUBMISSIONS from jose import jwt as json_web_token from formsflow_api.models import Authorization, AuthType @@ -15,7 +17,7 @@ def get_token( jwt, - role: str = "formsflow-client", + role: str = CREATE_SUBMISSIONS, username: str = "client", roles: list = [], tenant_key: str = None, @@ -89,34 +91,6 @@ def get_form_request_payload_private(): } -def get_form_request_payload_public_inactive(): - """Return a form request payload object which is not active.""" - return { - "formId": "12", - "formName": "Sample private form", - "processKey": "onestepapproval", - "processName": "OneStep Approval", - "status": "Inactive", - "comments": "test", - "tenant": 11, - "anonymous": True, - "formType": "form", - "parentFormId": "12", - } - - -def get_form_request_anonymous_payload(): - """Return a form request payload object with anonymous true.""" - return { - "formId": "1234", - "formName": "Sample form", - "anonymous": True, - "status": "active", - "formType": "form", - "parentFormId": "1234", - } - - def get_application_create_payload(form_id: str = "1234"): """Returns an application create payload.""" return { @@ -132,21 +106,6 @@ def get_draft_create_payload(form_id: str = "1234"): return {"formId": form_id, "data": {"name": "testing sample"}} -def get_form_service_payload(): - """Return a form Service payload object.""" - return { - "form_id": "1234", - "form_name": "Sample form", - "form_revision_number": "v1", - "process_key": "121312", - "process_name": "OneStep Approval", - "status": "active", - "comments": "test", - "tenant": 12, - "created_by": "test-user", - } - - def get_form_payload(): """Return a form request payload object.""" return { @@ -185,6 +144,7 @@ def get_formio_form_request_payload(): """Return a formio form create request payload object.""" return { "display": "form", + "description": "", "components": [ { "label": "Text Field", @@ -555,6 +515,7 @@ def get_filter_payload( name: str = "Test Task", roles: list = [], users: list = [], + order: int = None, ): """Return filter create payload.""" return { @@ -565,6 +526,7 @@ def get_filter_payload( "properties": {"priority": 10}, "users": users, "roles": roles, + "order": order, "taskVisibleAttributes": { "applicationId": True, "assignee": True, @@ -584,11 +546,13 @@ def get_embed_token( """Return token for embed APIs.""" return json_web_token.encode( {"preferred_username": user_name, "email": email, "tenant_key": tenant_key}, - current_app.config.get( - "TEST_FORM_EMBED_JWT_SECRET", "f6a69a42-7f8a-11ed-a1eb-0242ac120002" - ) - if not invalid - else "invalid-secret", + ( + current_app.config.get( + "TEST_FORM_EMBED_JWT_SECRET", "f6a69a42-7f8a-11ed-a1eb-0242ac120002" + ) + if not invalid + else "invalid-secret" + ), algorithm="HS256", ) @@ -603,3 +567,100 @@ def get_embed_application_create_payload(formId): "contact": {"addressLine1": "1234 Street", "email": "john.doe@example.com"}, }, } + + +def get_process_request_payload( + name="Testworkflow", + is_subflow=False, + parent_process_key="Testworkflow", + major_version=1, + minor_version=0, +): + """Return process request payload.""" "" + return { + "status": "Draft", + "processType": "BPMN", + "name": name, + "processData": """\n + + Flow_01r7ulv + Flow_01r7ulvFlow_0worf4d + Flow_0worf4d + + + + + """, + "majorVersion": major_version, + "minorVersion": minor_version, + "isSubflow": is_subflow, + "processKey": name, + "parentProcessKey": parent_process_key, + } + + +def get_process_request_payload_for_dmn(): + """Return process request payload.""" "" + return { + "processType": "dmn", + "processData": """\ncategory\"assignment_notification\"\"Task Assignment\"\"Hello @name,\r\n \r\nYou have a new task for the process. Please click the following link to access your new task.\r\n\r\n@formUrl\r\n\r\n \r\nBest Regards\"\"activity_reminder\"\"Task Reminder\"\"Dear @name,\r\n\r\nThis is a reminder that your outstanding task is due in one day.\r\n\r\nApplication Number : @applicationId\r\n\r\n \r\nPlease click the following link to access your new task.\r\n\r\nTo access the task through formsflow.ai please follow this link: http://localhost:3000/task/@pid\r\n\r\n \r\n Regards, \r\n \"\"activity_escalation\"\"Task Escalation\"\"Dear @name,\r\n \r\nYou have exceeded the deadline for the task. \r\n\r\nApplication Number : @applicationId\r\n\r\n \r\nPlease click the following link to access your new task.\r\n\r\nTo access the task through formsflow.ai please follow this link: http://localhost:3000/task/@pid\r\n \r\n Regards, \r\n \"""", + } + + +def get_process_request_payload_low_code(name="Lowcode workflow", status="Draft"): + """Return process request payload for lowcode.""" "" + return { + "status": status, + "processType": "LOWCODE", + "name": name, + "majorVersion": 1, + "minorVersion": 0, + "processData": [ + { + "id": "dndID5ade74badb758", + "type": "START", + "position": {"x": 305.4333267211914, "y": 97.29998779296875}, + "data": { + "label": "START", + "type": "START", + "color": "#FC4F00", + "optionTitle": "Start", + "title": "Start Task", + "description": "Start Here", + "attributes": {}, + }, + "width": 89, + "height": 42, + }, + { + "id": "dndID48754650019b8", + "type": "END", + "position": {"x": 388.1499900817871, "y": 192.19998168945312}, + "data": { + "label": "END", + "type": "END", + "color": "#9bebd0", + "title": "End Task", + "optionTitle": "End", + "description": "Process ends", + "attributes": {}, + }, + "width": 91, + "height": 42, + }, + { + "id": "dndID1f941f8b8dfbb", + "source": "dndID5ade74badb758", + "sourceHandle": "a", + "target": "dndID48754650019b8", + "targetHandle": "a", + "type": "smoothstep", + "data": {"label": ""}, + "style": {"stroke": "#FC4F00"}, + "animated": True, + }, + ], + } diff --git a/forms-flow-api/tests/utils/test_logging.py b/forms-flow-api/tests/utils/test_logging.py index 4dfa5c91dd..f5ca101ff8 100644 --- a/forms-flow-api/tests/utils/test_logging.py +++ b/forms-flow-api/tests/utils/test_logging.py @@ -2,6 +2,7 @@ Test-Suite to ensure that the logging setup is working as expected. """ + # import os from formsflow_api_utils.utils.logging import setup_logging diff --git a/forms-flow-bpm/Dockerfile b/forms-flow-bpm/Dockerfile index baaaebd8bf..5e5c91a374 100644 --- a/forms-flow-bpm/Dockerfile +++ b/forms-flow-bpm/Dockerfile @@ -1,4 +1,3 @@ -# Modified by Yichun Zhao and Walter Moar # Maven build FROM maven:3.8.1-openjdk-17-slim AS MAVEN_TOOL_CHAIN @@ -18,17 +17,15 @@ COPY forms-flow-bpm-utils/src ./forms-flow-bpm-utils/src/ # RUN mvn -s /usr/share/maven/ref/settings-docker.xml dependency:resolve-plugins dependency:resolve dependency:go-offline -B -P camunda RUN mvn -s /usr/share/maven/ref/settings-docker.xml install -P camunda -# Final custom slim java image (for apk command see 17-jdk-alpine-slim) -FROM openjdk:17-jdk-alpine -# Update packages including OpenSSL -RUN apk update && apk upgrade + +FROM openjdk:21-ea-jdk # set label for image LABEL Name="formsflow" -ENV JAVA_VERSION=17-ea+14 -ENV JAVA_HOME=/opt/java/openjdk-17\ - PATH="/opt/java/openjdk-17/bin:$PATH" +ENV JAVA_VERSION=21-ea+14 +ENV JAVA_HOME=/opt/java/openjdk-21\ + PATH="/opt/java/openjdk-21/bin:$PATH" EXPOSE 8080 # OpenShift has /app in the image, but it's missing when doing local development - Create it when missing diff --git a/forms-flow-bpm/Dockerfile-ARM64 b/forms-flow-bpm/Dockerfile-ARM64 deleted file mode 100644 index 2d70697246..0000000000 --- a/forms-flow-bpm/Dockerfile-ARM64 +++ /dev/null @@ -1,39 +0,0 @@ - -# Maven build -FROM arm64v8/maven:3.8.1-openjdk-17-slim AS MAVEN_TOOL_CHAIN -COPY settings-docker.xml /usr/share/maven/ref/ -WORKDIR /tmp/ - -COPY pom*.xml . -COPY forms-flow-bpm-utils/pom.xml ./forms-flow-bpm-utils/ -COPY forms-flow-bpm-camunda/pom.xml ./forms-flow-bpm-camunda/ - -# COPY src /tmp/src/ -COPY forms-flow-bpm-camunda/src ./forms-flow-bpm-camunda/src/ -COPY forms-flow-bpm-utils/src ./forms-flow-bpm-utils/src/ - -# This allows Docker to cache most of the maven dependencies -#TODO This needs to be fixed, It throws error saying sub modules cannot be found -# RUN mvn -s /usr/share/maven/ref/settings-docker.xml dependency:resolve-plugins dependency:resolve dependency:go-offline -B -P camunda -RUN mvn -s /usr/share/maven/ref/settings-docker.xml install -P camunda - -# Final custom slim java image (for apk command see 17-jdk-alpine-slim) -FROM arm64v8/openjdk:17-ea-16-jdk - -# set label for image -LABEL Name="formsflow" - -ENV JAVA_VERSION=17-ea+14 -ENV JAVA_HOME=/opt/java/openjdk-17\ - PATH="/opt/java/openjdk-17/bin:$PATH" - -EXPOSE 8080 -# OpenShift has /app in the image, but it's missing when doing local development - Create it when missing -RUN test ! -d /app && mkdir /app || : -# Add spring boot application -RUN mkdir -p /app -COPY --from=MAVEN_TOOL_CHAIN /tmp/forms-flow-bpm-camunda/target/forms-flow-bpm.jar ./app -RUN chmod a+rwx -R /app -WORKDIR /app -VOLUME /tmp -ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom", "-Dpolyglot.js.nashorn-compat=true", "-Dpolyglot.engine.WarnInterpreterOnly=false", "-jar","/app/forms-flow-bpm.jar"] \ No newline at end of file diff --git a/forms-flow-bpm/README.md b/forms-flow-bpm/README.md index a7bc969ed9..b771bd1e26 100644 --- a/forms-flow-bpm/README.md +++ b/forms-flow-bpm/README.md @@ -1,7 +1,7 @@ # Workflow Engine [![FormsFlow BPM CI](https://github.com/AOT-Technologies/forms-flow-ai/actions/workflows/forms-flow-bpm-ci.yml/badge.svg)](https://github.com/AOT-Technologies/forms-flow-ai/actions) -![Camunda](https://img.shields.io/badge/Camunda-7.20.0-blue) ![Spring Boot](https://img.shields.io/badge/Spring_Boot-3.1.10-blue) ![postgres](https://img.shields.io/badge/postgres-latest-blue) +![Camunda](https://img.shields.io/badge/Camunda-7.21-blue) ![Spring Boot](https://img.shields.io/badge/Spring_Boot-3.3.5-blue) ![postgres](https://img.shields.io/badge/postgres-latest-blue) **formsflow.ai** leverages Camunda for workflow and decision automation. To know more about Camunda, visit https://camunda.com/. @@ -117,6 +117,12 @@ To know more about Camunda, visit https://camunda.com/. `IDENTITY_PROVIDER_MAX_RESULT_SIZE`|Maximum result size for Keycloak user queries||`250` `BPM_CLIENT_CONN_TIMEOUT`|Webclient Connection timeout in milli seconds||`5000` `BPM_API_URL`:triangular_flag_on_post:|BPM Client URL||`http://{your-ip-address}:8000/camunda` + `VAULT_ENABLED`|Support to fetch secrets from vault|`true`/`false`|`false` + `VAULT_URL`|Support to fetch secrets from vault + `VAULT_TOKEN`|Support to fetch secrets from vault + `VAULT_PATH`|Support to fetch secrets from vault + `VAULT_SECRET`|Support to fetch secrets from vault + `FORMSFLOW_DOC_API_URL`|To support forms-flow-documents sevice||`http://localhost:5006` **Additionally, you may want to change these** * The value of Datastore credentials (especially if this instance is not just for testing purposes) diff --git a/forms-flow-bpm/docker-compose.yml b/forms-flow-bpm/docker-compose.yml index aa0f5cf864..34a3493d01 100644 --- a/forms-flow-bpm/docker-compose.yml +++ b/forms-flow-bpm/docker-compose.yml @@ -73,6 +73,7 @@ services: - REDIS_PORT=${REDIS_PORT:-6379} - REDIS_PASSCODE=${REDIS_PASSCODE:-changeme} - SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-false} + - FORMSFLOW_DOC_API_URL=${FORMSFLOW_DOC_API_URL} networks: - forms-flow-bpm-network diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/pom.xml b/forms-flow-bpm/forms-flow-bpm-camunda/pom.xml index d7e94f073b..d7f2862cfb 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/pom.xml +++ b/forms-flow-bpm/forms-flow-bpm-camunda/pom.xml @@ -7,11 +7,11 @@ formsflow.ai forms-flow-bpm - 6.0.2 + 7.0.0 formsflow-bpm-camunda - 6.0.2 + 7.0.0 formsflow BPM Camunda Extension formsflow BPM Camunda Extension @@ -27,16 +27,16 @@ false - 7.20.0 - 7.20.0 + 7.21.5 + 7.21.0 1.5.4 1.5.0 - 3.1.10 - 2.6.8 + 3.3.5 + 2.6.8 2.15.0 1.5 2.2 + 2022.0.5 @@ -62,6 +62,13 @@ ${version.camundaKeycloak} provided + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + @@ -69,7 +76,7 @@ formsflow.ai forms-flow-bpm-utils - 6.0.2 + 7.0.0 @@ -175,7 +182,7 @@ org.camunda.bpm camunda-engine-rest-core - 7.20.0 + ${version.camunda} @@ -319,14 +326,14 @@ org.springframework spring-websocket - 6.0.11 + 6.1.12 - org.springframework - spring-messaging - 6.0.11 - + org.springframework + spring-messaging + 6.1.12 + org.graalvm.js @@ -362,7 +369,13 @@ org.camunda.bpm.extension camunda-platform-7-keycloak-jwt - 7.20.0 + ${version.camunda} + + + + + org.springframework.cloud + spring-cloud-starter-vault-config diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/commons/connector/support/BPMAccessHandler.java b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/commons/connector/support/BPMAccessHandler.java index 1a777d703e..08ac67f87e 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/commons/connector/support/BPMAccessHandler.java +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/commons/connector/support/BPMAccessHandler.java @@ -5,6 +5,7 @@ import org.camunda.bpm.extension.commons.ro.req.IRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -31,7 +32,8 @@ public class BPMAccessHandler extends AbstractAccessHandler{ private final Properties properties; private final WebClient webClient; - public BPMAccessHandler(Properties integrationCredentialProperties, WebClient unauthenticatedWebClient){ + public BPMAccessHandler(@Qualifier("integrationCredentialProperties") Properties integrationCredentialProperties, + @Qualifier("unauthenticatedWebClient") WebClient unauthenticatedWebClient){ this.properties = integrationCredentialProperties; this.webClient = unauthenticatedWebClient; } diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/commons/utils/VaultConfig.java b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/commons/utils/VaultConfig.java new file mode 100644 index 0000000000..8b350f5a04 --- /dev/null +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/commons/utils/VaultConfig.java @@ -0,0 +1,66 @@ +package org.camunda.bpm.extension.commons.utils; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Configuration; +import org.springframework.vault.core.VaultTemplate; +import org.springframework.vault.support.VaultResponse; +import org.springframework.vault.core.VaultKeyValueOperationsSupport.KeyValueBackend; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Configuration +@ConditionalOnProperty(name = "spring.cloud.vault.enabled", havingValue = "true", matchIfMissing = false) +public class VaultConfig { + + private static final Logger LOGGER = LoggerFactory.getLogger(VaultConfig.class); + + private static VaultTemplate vaultTemplate; + private static String vaultPath; + private static String vaultSecret; + + @Autowired(required = false) + public void setVaultTemplate(VaultTemplate template) { + vaultTemplate = template; + } + + @Value("${spring.cloud.vault.path}") + public void setVaultPath(String path) { + vaultPath = path; + } + + @Value("${spring.cloud.vault.secret}") + public void setVaultSecret(String secret) { + vaultSecret = secret; + } + + public static String getSecret(String key) { + try { + VaultResponse vaultResponse = vaultTemplate + .opsForKeyValue(vaultPath, KeyValueBackend.KV_2) + .get(vaultSecret); + + if (vaultResponse != null && vaultResponse.getData() != null) { + LOGGER.debug("Fetching vault data from path: {} and Secret: {}", vaultPath, vaultSecret); + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode jsonNode = objectMapper.valueToTree(vaultResponse.getData()); + + if (jsonNode.has(key)) { + return jsonNode.get(key).asText(); + } else { + LOGGER.debug("Key {} not found in vault data", key); + return null; + } + } else { + LOGGER.debug("No data found for vault for path: {} and secret: {}", vaultPath, vaultSecret); + return null; + } + } catch (Exception e) { + LOGGER.error("Error fetching secret: {}", e.getMessage()); + return "Error fetching secret: " + e.getMessage(); + } + } +} diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/hooks/listeners/data/FormProcessMappingData.java b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/hooks/listeners/data/FormProcessMappingData.java index 0c57bee435..2a978c3cbd 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/hooks/listeners/data/FormProcessMappingData.java +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/hooks/listeners/data/FormProcessMappingData.java @@ -17,11 +17,11 @@ public class FormProcessMappingData implements IResponse, Serializable { private static final long serialVersionUID = 1L; - private String taskVariable; + private String taskVariables; private String processName; private String processKey; public FilterInfo[] getTaskVariableList(ObjectMapper objectMapper) throws JsonProcessingException { - return objectMapper.readValue(this.taskVariable, FilterInfo[].class); + return objectMapper.readValue(this.taskVariables, FilterInfo[].class); } } diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/hooks/rest/service/impl/AdminRestServiceImpl.java b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/hooks/rest/service/impl/AdminRestServiceImpl.java index 4bc15a8648..37e4c5866c 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/hooks/rest/service/impl/AdminRestServiceImpl.java +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/hooks/rest/service/impl/AdminRestServiceImpl.java @@ -4,6 +4,7 @@ import org.camunda.bpm.engine.AuthorizationService; import org.camunda.bpm.engine.ProcessEngines; import org.camunda.bpm.engine.RepositoryService; +import org.camunda.bpm.engine.authorization.AuthorizationQuery; import org.camunda.bpm.engine.authorization.Permissions; import org.camunda.bpm.engine.authorization.ProcessDefinitionPermissions; import org.camunda.bpm.engine.authorization.Resources; @@ -78,52 +79,46 @@ public void createTenant(TenantAuthorizationDto dto) throws ServletException { LOGGER.info("Creating authorizations for tenant"); String tenantKey = dto.getTenantKey(); - // Administrator gets access to the tasklist and cockpit. - for (String adminRole : dto.getAdminRoles()) { - createAuthorization(tenantKey, adminRole, Resources.APPLICATION, "tasklist"); - createAuthorization(tenantKey, adminRole, Resources.APPLICATION, "cockpit"); - createAuthorization(tenantKey, adminRole, Resources.PROCESS_DEFINITION, "*"); - createAuthorization(tenantKey, adminRole, Resources.PROCESS_INSTANCE, "*"); - createAuthorization(tenantKey, adminRole, Resources.TASK, "*"); - createAuthorization(tenantKey, adminRole, Resources.TENANT, tenantKey); - createAuthorization(tenantKey, adminRole, Resources.DEPLOYMENT, "*"); - createAuthorization(tenantKey, adminRole, Resources.FILTER, "*"); - createAuthorization(tenantKey, adminRole, Resources.DECISION_DEFINITION, "*"); - createAuthorization(tenantKey, adminRole, Resources.DECISION_REQUIREMENTS_DEFINITION, "*"); - } - - // Client authorizations - for (String clientRole : dto.getClientRoles()) { - createAuthorization(tenantKey, clientRole, Resources.PROCESS_DEFINITION, "*"); - createAuthorization(tenantKey, clientRole, Resources.PROCESS_INSTANCE, "*"); - createAuthorization(tenantKey, clientRole, Resources.TENANT, tenantKey); - createAuthorization(tenantKey, clientRole, Resources.AUTHORIZATION, "*"); - createAuthorization(tenantKey, clientRole, Resources.DECISION_DEFINITION, "*"); - createAuthorization(tenantKey, clientRole, Resources.DECISION_REQUIREMENTS_DEFINITION, "*"); - } + // Add all the roles with correct authorizations for multi tenancy + // for camunda-admin, group name would start with tenantKey. For other users, it's a REST operation and role is retrieved from token. + createAuthorization(tenantKey, tenantKey+"-admin", Resources.APPLICATION, "tasklist"); + createAuthorization(tenantKey, tenantKey+"-admin", Resources.APPLICATION, "cockpit"); + createAuthorization(tenantKey, tenantKey+"-admin", Resources.PROCESS_DEFINITION, "*"); + createAuthorization(tenantKey, tenantKey+"-admin", Resources.PROCESS_INSTANCE, "*"); + createAuthorization(tenantKey, tenantKey+"-admin", Resources.TASK, "*"); + createAuthorization(tenantKey, tenantKey+"-admin", Resources.TENANT, tenantKey); + createAuthorization(tenantKey, tenantKey+"-admin", Resources.DEPLOYMENT, "*"); + createAuthorization(tenantKey, tenantKey+"-admin", Resources.FILTER, "*"); + createAuthorization(tenantKey, tenantKey+"-admin", Resources.DECISION_DEFINITION, "*"); + createAuthorization(tenantKey, tenantKey+"-admin", Resources.DECISION_REQUIREMENTS_DEFINITION, "*"); + + // Client role + createAuthorization(tenantKey, "ROLE_create_submissions", Resources.PROCESS_DEFINITION, "*"); + createAuthorization(tenantKey, "ROLE_create_submissions", Resources.PROCESS_INSTANCE, "*"); + createAuthorization(tenantKey, "ROLE_create_submissions", Resources.TENANT, tenantKey); + createAuthorization(tenantKey, "ROLE_create_submissions", Resources.AUTHORIZATION, "*"); + createAuthorization(tenantKey, "ROLE_create_submissions", Resources.DECISION_DEFINITION, "*"); + createAuthorization(tenantKey, "ROLE_create_submissions", Resources.DECISION_REQUIREMENTS_DEFINITION, "*"); + + // Designer + createAuthorization(tenantKey, "ROLE_view_designs", Resources.PROCESS_DEFINITION, "*"); + createAuthorization(tenantKey, "ROLE_view_designs", Resources.PROCESS_INSTANCE, "*"); + createAuthorization(tenantKey, "ROLE_view_designs", Resources.TENANT, tenantKey); + createAuthorization(tenantKey, "ROLE_view_designs", Resources.DEPLOYMENT, "*"); + createAuthorization(tenantKey, "ROLE_view_designs", Resources.DECISION_DEFINITION, "*"); + createAuthorization(tenantKey, "ROLE_view_designs", Resources.DECISION_REQUIREMENTS_DEFINITION, "*"); + + // Reviewer + createAuthorization(tenantKey, "ROLE_view_tasks", Resources.PROCESS_DEFINITION, "*"); + createAuthorization(tenantKey, "ROLE_view_tasks", Resources.PROCESS_INSTANCE, "*"); + createAuthorization(tenantKey, "ROLE_view_tasks", Resources.TASK, "*"); + createAuthorization(tenantKey, "ROLE_view_tasks", Resources.TENANT, tenantKey); + createAuthorization(tenantKey, "ROLE_view_tasks", Resources.FILTER, "*"); + createAuthorization(tenantKey, "ROLE_view_tasks", Resources.USER, "*"); + createAuthorization(tenantKey, "ROLE_view_tasks", Resources.AUTHORIZATION, "*"); + createAuthorization(tenantKey, "ROLE_view_tasks", Resources.DECISION_DEFINITION, "*"); + createAuthorization(tenantKey, "ROLE_view_tasks", Resources.DECISION_REQUIREMENTS_DEFINITION, "*"); - // Designer authorizations - for (String designerRole : dto.getDesignerRoles()) { - createAuthorization(tenantKey, designerRole, Resources.PROCESS_DEFINITION, "*"); - createAuthorization(tenantKey, designerRole, Resources.PROCESS_INSTANCE, "*"); - createAuthorization(tenantKey, designerRole, Resources.TENANT, tenantKey); - createAuthorization(tenantKey, designerRole, Resources.DEPLOYMENT, "*"); - createAuthorization(tenantKey, designerRole, Resources.DECISION_DEFINITION, "*"); - createAuthorization(tenantKey, designerRole, Resources.DECISION_REQUIREMENTS_DEFINITION, "*"); - } - - // Reviewer authorizations - for (String reviewerRole : dto.getReviewerRoles()) { - createAuthorization(tenantKey, reviewerRole, Resources.PROCESS_DEFINITION, "*"); - createAuthorization(tenantKey, reviewerRole, Resources.PROCESS_INSTANCE, "*"); - createAuthorization(tenantKey, reviewerRole, Resources.TASK, "*"); - createAuthorization(tenantKey, reviewerRole, Resources.TENANT, tenantKey); - createAuthorization(tenantKey, reviewerRole, Resources.FILTER, "*"); - createAuthorization(tenantKey, reviewerRole, Resources.USER, "*"); - createAuthorization(tenantKey, reviewerRole, Resources.AUTHORIZATION, "*"); - createAuthorization(tenantKey, reviewerRole, Resources.DECISION_DEFINITION, "*"); - createAuthorization(tenantKey, reviewerRole, Resources.DECISION_REQUIREMENTS_DEFINITION, "*"); - } LOGGER.info("Finished creating authorizations for tenant"); } @@ -177,8 +172,8 @@ private List getKeyValues(Map claims, String claimName, if (StringUtils.startsWith(groupName, "/")) { groupIds.add(StringUtils.substring(groupName, 1)); } else { - if (tenantKey != null) - groupName = tenantKey + "-" + groupName; +// if (tenantKey != null) +// groupName = tenantKey + "-" + groupName; groupIds.add(groupName); } } @@ -231,12 +226,24 @@ private Set getAuthorization(List groups) { * @param resourceId */ private void createAuthorization(String tenantKey, String role, Resources resourceType, String resourceId) { - AuthorizationEntity authEntity = new AuthorizationEntity(); - authEntity.setAuthorizationType(AUTH_TYPE_GRANT); - authEntity.setGroupId(tenantKey + "-" + role); - authEntity.addPermission(Permissions.ALL); - authEntity.setResourceId(resourceId); - authEntity.setResourceType(resourceType.resourceType()); - this.authService.saveAuthorization(authEntity); + // Check if authorization exists + AuthorizationQuery query = authService.createAuthorizationQuery() + .resourceType(resourceType.resourceType()) + .resourceId(resourceId); + + query.groupIdIn(role); + + // Check if there is any matching authorization + org.camunda.bpm.engine.authorization.Authorization existingAuthorization = query.singleResult(); + + if (existingAuthorization == null) { + AuthorizationEntity authEntity = new AuthorizationEntity(); + authEntity.setAuthorizationType(AUTH_TYPE_GRANT); + authEntity.setGroupId(/*tenantKey + "-" +*/ role); + authEntity.addPermission(Permissions.ALL); + authEntity.setResourceId(resourceId); + authEntity.setResourceType(resourceType.resourceType()); + this.authService.saveAuthorization(authEntity); + } } -} +} \ No newline at end of file diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/plugin/CamundaAuthorizationInitializer.java b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/plugin/CamundaAuthorizationInitializer.java new file mode 100644 index 0000000000..690dfcdb62 --- /dev/null +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/plugin/CamundaAuthorizationInitializer.java @@ -0,0 +1,79 @@ +package org.camunda.bpm.extension.keycloak.plugin; + +import org.camunda.bpm.engine.AuthorizationService; +import org.camunda.bpm.engine.ProcessEngine; +import org.camunda.bpm.engine.authorization.Authorization; +import org.camunda.bpm.engine.authorization.AuthorizationQuery; +import org.camunda.bpm.engine.authorization.Permissions; +import org.camunda.bpm.engine.authorization.Resources; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; + + +@Component +public class CamundaAuthorizationInitializer { + + private static final Logger LOGGER = LoggerFactory.getLogger(CamundaAuthorizationInitializer.class); + + @Autowired + private CamundaAuthorizationProperties authorizationProperties; + + @Autowired + private ProcessEngine processEngine; + + @Value("${plugin.identity.keycloak.enableMultiTenancy}") + private boolean enableMultiTenancy; + + @PostConstruct + public void initializeAuthorizations() { + if (!enableMultiTenancy) { + AuthorizationService authorizationService = processEngine.getAuthorizationService(); + + for (CamundaAuthorizationProperties.AuthorizationConfig config : authorizationProperties.getAuthorizations()) { + // Check if the authorization already exists + for (String resourceType : config.getResourceType().split(",") ) { + AuthorizationQuery query = authorizationService.createAuthorizationQuery() + .resourceType(Resources.valueOf(resourceType)) + .resourceId(config.getResourceId()); + + if (config.getGroupId() != null) { + query.groupIdIn(config.getGroupId()); + } else if (config.getUserId() != null) { + query.userIdIn(config.getUserId()); + } + + // Check if there is any matching authorization + Authorization existingAuthorization = query.singleResult(); + + if (existingAuthorization == null) { + // Create a new authorization if none exists + Authorization authorization = authorizationService.createNewAuthorization(Authorization.AUTH_TYPE_GRANT); + + if (config.getGroupId() != null) { + authorization.setGroupId(config.getGroupId()); + } else if (config.getUserId() != null) { + authorization.setUserId(config.getUserId()); + } + + authorization.setResource(Resources.valueOf(resourceType)); + authorization.setResourceId(config.getResourceId()); + + for (String perm : config.getPermissions()) { + authorization.addPermission(Permissions.valueOf(perm)); + } + + authorizationService.saveAuthorization(authorization); + } else { + LOGGER.info("Authorization already exists for: " + (config.getGroupId() != null ? "Group " + config.getGroupId() : "User " + config.getUserId())); + } + } + + } + } + } +} \ No newline at end of file diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/plugin/CamundaAuthorizationProperties.java b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/plugin/CamundaAuthorizationProperties.java new file mode 100644 index 0000000000..ce46d90fdd --- /dev/null +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/plugin/CamundaAuthorizationProperties.java @@ -0,0 +1,69 @@ +package org.camunda.bpm.extension.keycloak.plugin; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +@ConfigurationProperties(prefix = "formsflow.ai") +public class CamundaAuthorizationProperties { + + private List authorizations; + + public List getAuthorizations() { + return authorizations; + } + + public void setAuthorizations(List authorizations) { + this.authorizations = authorizations; + } + + public static class AuthorizationConfig { + private String groupId; + private String userId; + private String resourceType; + private String resourceId; + private List permissions; + + public String getGroupId() { + return groupId; + } + + public void setGroupId(String groupId) { + this.groupId = groupId; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getResourceType() { + return resourceType; + } + + public void setResourceType(String resourceType) { + this.resourceType = resourceType; + } + + public String getResourceId() { + return resourceId; + } + + public void setResourceId(String resourceId) { + this.resourceId = resourceId; + } + + public List getPermissions() { + return permissions; + } + + public void setPermissions(List permissions) { + this.permissions = permissions; + } + } +} \ No newline at end of file diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/plugin/KeycloakGroupService.java b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/plugin/KeycloakGroupService.java index 65d2078b85..e35f385ded 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/plugin/KeycloakGroupService.java +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/plugin/KeycloakGroupService.java @@ -11,6 +11,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.camunda.bpm.engine.authorization.Groups; @@ -69,11 +70,20 @@ public KeycloakGroupService(KeycloakConfiguration keycloakConfiguration, Keycloa public List requestGroupsByUserId(CacheableKeycloakGroupQuery query) { LOG.debug("requestGroupsByUserId >> enableClientAuth value " + enableClientAuth); List userGroups = null; - if (enableClientAuth) { + if (enableClientAuth && !enableMultiTenancy) { userGroups = this.requestClientRolesByUserId(query); + userGroups = userGroups.stream().filter(group -> group.getName().startsWith("GROUP_")).collect(Collectors.toList()); } else { userGroups = super.requestGroupsByUserId(query); } + // Hacky solution, TODO Fix this. Here check if the group name ends with camunda-admin, then set the group as system group + for (Group group : userGroups) { + if (group.getName().endsWith(Groups.CAMUNDA_ADMIN) || group.getName().endsWith(keycloakConfiguration.getAdministratorGroupName())) { + group.setType(Groups.GROUP_TYPE_SYSTEM); + group.setId(keycloakConfiguration.getAdministratorGroupName()); + group.setName(keycloakConfiguration.getAdministratorGroupName()); + } + } return userGroups; } @@ -92,6 +102,7 @@ public List requestGroupsWithoutUserId(CacheableKeycloakGroupQuery query) } return roles; } + /** diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/plugin/KeycloakUserService.java b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/plugin/KeycloakUserService.java index 65c978f52f..3cf388dddc 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/plugin/KeycloakUserService.java +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/plugin/KeycloakUserService.java @@ -60,12 +60,12 @@ public KeycloakUserService(KeycloakConfiguration keycloakConfiguration, Keycloak @Override public List requestUsersByGroupId(CacheableKeycloakUserQuery query) { List users; - if (enableClientAuth) { - if (enableMultiTenancy) { - users = this.requestUsersByClientRoleAndTenantId(); - } else { + if (enableClientAuth && !enableMultiTenancy) { +// if (enableMultiTenancy) { +// users = this.requestUsersByClientRoleAndTenantId(); +// } else { users = this.requestUsersByClientRole(); - } +// } } else { users = super.requestUsersByGroupId(query); } @@ -76,12 +76,12 @@ public List requestUsersByGroupId(CacheableKeycloakUserQuery query) { @Override public List requestUsersWithoutGroupId(CacheableKeycloakUserQuery query) { List users; - if (enableClientAuth) { - if (enableMultiTenancy) { - users = this.requestUsersByClientRoleAndTenantId(); - } else { - users = this.requestUsersByClientRole(); - } + if (enableClientAuth && !enableMultiTenancy) { +// if (enableMultiTenancy) { +// users = this.requestUsersByClientRoleAndTenantId(); +// } else { + users = this.requestUsersByClientRole(); +// } } else { users = super.requestUsersWithoutGroupId(query); } @@ -106,7 +106,7 @@ protected List requestUsersByClientRole(){ // get groups of this user ResponseEntity response = restTemplate.exchange(keycloakConfiguration.getKeycloakAdminUrl() - + "/clients/" + keycloakClientID + "/roles/formsflow-reviewer/users", HttpMethod.GET, + + "/clients/" + keycloakClientID + "/roles/view_tasks/users", HttpMethod.GET, String.class); if (!response.getStatusCode().equals(HttpStatus.OK)) { throw new IdentityProviderException( @@ -163,7 +163,7 @@ protected List requestUsersByClientRoleAndTenantId(){ // get groups of this user ResponseEntity response = restTemplate.exchange(keycloakConfiguration.getKeycloakAdminUrl() - + "/clients/" + keycloakClientID + "/roles/formsflow-reviewer/users", HttpMethod.GET, + + "/clients/" + keycloakClientID + "/roles/view_tasks/users", HttpMethod.GET, String.class); if (!response.getStatusCode().equals(HttpStatus.OK)) { throw new IdentityProviderException( diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/rest/KeycloakAuthenticationFilter.java b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/rest/KeycloakAuthenticationFilter.java index 7ee9e5c3b8..2900f90948 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/rest/KeycloakAuthenticationFilter.java +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/rest/KeycloakAuthenticationFilter.java @@ -2,8 +2,10 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; @@ -16,6 +18,7 @@ import org.camunda.bpm.extension.commons.utils.RestAPIBuilderUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; @@ -33,6 +36,9 @@ public class KeycloakAuthenticationFilter implements Filter { /** This class' logger. */ private static final Logger LOG = LoggerFactory.getLogger(KeycloakAuthenticationFilter.class); + + private static List HARD_CODED_ROLES = Arrays.asList("create_designs", "view_designs", "create_submissions", "view_submissions", "view_tasks", "manage_tasks", "admin"); + private final String userNameAttribute; @@ -42,15 +48,24 @@ public class KeycloakAuthenticationFilter implements Filter { /** Access to the OAuth2 client service. */ private OAuth2AuthorizedClientService clientService; + private boolean enableClientAuth; + + + private boolean enableMultiTenancy; + + + /** * Creates a new KeycloakAuthenticationFilter. * * @param identityService access to Camunda's IdentityService */ - public KeycloakAuthenticationFilter(IdentityService identityService, OAuth2AuthorizedClientService clientService, String userNameAttribute) { + public KeycloakAuthenticationFilter(IdentityService identityService, OAuth2AuthorizedClientService clientService, String userNameAttribute, boolean enableClientAuth, boolean enableMultiTenancy) { this.identityService = identityService; this.clientService = clientService; this.userNameAttribute = userNameAttribute; + this.enableClientAuth = enableClientAuth; + this.enableMultiTenancy = enableMultiTenancy; } /** @@ -78,6 +93,8 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha } LOG.debug("Extracted userId from bearer token: {}", userId); + LOG.debug("enableClientAuth--> {}", enableClientAuth); + LOG.debug("enableMultiTenancy--> {}", enableMultiTenancy); try { String tenantKey = null; @@ -86,8 +103,15 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha if (claims != null && claims.containsKey("tenantKey")) { tenantKey = claims.get("tenantKey").toString(); tenantIds.add(tenantKey); + MDC.put("tenantKey", tenantKey); } userGroups = getUserGroups(userId, claims, tenantKey); + // Add role claims to match with dynamically created authorization. + if (claims.containsKey("role")) { + for (String role : getKeys(claims, "role")) { + userGroups.add("ROLE_"+role); + } + } if (tenantKey != null) identityService.setAuthentication(userId, userGroups, tenantIds); else @@ -110,28 +134,56 @@ private List getUserGroups(String userId, Map claims, St List groupIds = new ArrayList<>(); if (claims != null && claims.containsKey("groups")) { - groupIds.addAll(getKeys(claims, "groups", null)); + List groups = getKeys(claims, "groups"); + if (enableMultiTenancy) { // For multi-tenant setup filter out the groups which are not part of the current tenant. + groups = groups.stream().filter(group -> group.startsWith(tenantKey)).collect(Collectors.toList()); + // For existing setup we may need to use existing camunda-admin role + List roles = getKeys(claims, "roles"); + if (roles.indexOf("camunda-admin") >= 0 ) { + groups.add(tenantKey+"-camunda-admin"); + } + } + groupIds.addAll(groups); } else if (claims != null && claims.containsKey("roles")) { // Treat roles as alternative to groups - groupIds.addAll(getKeys(claims, "roles", tenantKey)); + List groups = getKeys(claims, "roles"); + if (enableClientAuth) { // If client-auth is enabled, means customer cannot create group and is using client roles instead. In this case create each group as role with prefix GROUP_. + groups = groups.stream().filter(group -> group.startsWith("GROUP_")).collect(Collectors.toList()); + } + groupIds.addAll(groups); } else { identityService.createGroupQuery().groupMember(userId).list().forEach(g -> groupIds.add(g.getId())); } + // Set the permission roles to match with the authorizations. + // Iterate the user's roles with HARD_CODED_ROLES, and set the matching ones as groups. + if (claims != null && claims.containsKey("roles")) { + + List roles = getKeys(claims, "roles"); + for (String role : roles) { + if (HARD_CODED_ROLES.contains(role)) { + if (enableMultiTenancy) { + //groupIds.add(tenantKey+"-"+role); // No need to add tenantKey to the role as ROLE_ prefix is there. + groupIds.add("ROLE_"+role); + }else{ + groupIds.add("ROLE_"+role); + } + } + } + + } return groupIds; } - private List getKeys(Map claims, String nodeName, String tenantKey) { + private List getKeys(Map claims, String nodeName) { List keys = new ArrayList<>(); if (claims.containsKey(nodeName)) { for (Object key : (List) claims.get(nodeName)) { String keyValue = key.toString(); keyValue = StringUtils.contains(keyValue, "/") ? StringUtils.substringAfter(keyValue, "/") : keyValue; - if (tenantKey != null) - keyValue = tenantKey + "-" + keyValue; keys.add(keyValue); } } return keys; } -} +} \ No newline at end of file diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/sso/KeycloakAuthenticationProvider.java b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/sso/KeycloakAuthenticationProvider.java index 46ea820b29..7d731d4445 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/sso/KeycloakAuthenticationProvider.java +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/sso/KeycloakAuthenticationProvider.java @@ -17,7 +17,7 @@ import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; - +import java.util.Collections; /** * Keycloak Authentication Provider. * OAuth2 Authentication Provider for usage with Keycloak and @@ -49,6 +49,16 @@ public AuthenticationResult extractAuthenticatedUser(HttpServletRequest request, // Authentication successful AuthenticationResult authenticationResult = new AuthenticationResult(userId, true); authenticationResult.setGroups(getUserGroups(userId, engine, oidcUserPrincipal)); + + String tenantKeyAttr = (String) oidcUserPrincipal.getAttributes().get("tenantKey"); + if (tenantKeyAttr != null) { + // Create a list with tenantKeyAttr and set it to the authentication result + List tenants = Collections.singletonList(tenantKeyAttr); + authenticationResult.setTenants(tenants); + } else { + // Handle the case where tenantKeyAttr is null + authenticationResult.setTenants(Collections.emptyList()); + } return authenticationResult; } @@ -56,6 +66,7 @@ public AuthenticationResult extractAuthenticatedUser(HttpServletRequest request, private List getUserGroups(String userId, ProcessEngine engine, OidcUser principal) { List groupIds = new ArrayList<>(); // Find groups or roles from the idToken. + // TODO Fix this to get the values from here itself, currently in all case if - else if are always FALSE. Fix this if (!enableClientAuth && principal.getIdToken().getClaims().containsKey("groups")) { groupIds.addAll(getKeys(principal.getIdToken(), "groups")); } else if (enableClientAuth && principal.getIdToken().getClaims().containsKey("roles")) { @@ -72,7 +83,7 @@ private List getKeys(OidcIdToken token, String nodeName) { List keys = new ArrayList<>(); if (token.getClaims().containsKey(nodeName)) { Object claimValue = token.getClaim(nodeName); - if (claimValue instanceof JSONArray jsonArray) { + if (claimValue instanceof ArrayList jsonArray) { for (Object array : jsonArray) { String keyString = array.toString(); keys.add(StringUtils.contains(keyString, "/") ? StringUtils.substringAfter(keyString, "/") : keyString); diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/sso/OAuth2LoginSecurityConfig.java b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/sso/OAuth2LoginSecurityConfig.java index 7533ed7823..78e5ac50a4 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/sso/OAuth2LoginSecurityConfig.java +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/java/org/camunda/bpm/extension/keycloak/sso/OAuth2LoginSecurityConfig.java @@ -27,6 +27,7 @@ import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher; import org.camunda.bpm.engine.IdentityService; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.context.ApplicationContext; import org.springframework.http.HttpMethod; @@ -70,6 +71,13 @@ public class OAuth2LoginSecurityConfig { @Inject private KeycloakCockpitConfiguration keycloakCockpitConfiguration; + + @Value("${plugin.identity.keycloak.enableClientAuth}") + private boolean enableClientAuth; + + + @Value("${plugin.identity.keycloak.enableMultiTenancy}") + private boolean enableMultiTenancy; @Bean @Order(1) @@ -154,7 +162,7 @@ public FilterRegistrationBean keycloakAuthenticationFilter(){ String userNameAttribute = this.applicationContext.getEnvironment().getRequiredProperty( "spring.security.oauth2.client.provider." + this.configProps.getProvider() + ".user-name-attribute"); - filterRegistration.setFilter(new KeycloakAuthenticationFilter(identityService, clientService, userNameAttribute)); + filterRegistration.setFilter(new KeycloakAuthenticationFilter(identityService, clientService, userNameAttribute, enableMultiTenancy, enableClientAuth)); filterRegistration.setOrder(102); filterRegistration.addUrlPatterns("/engine-rest/*"); filterRegistration.addUrlPatterns("/engine-rest-ext/*"); @@ -189,11 +197,11 @@ public RequestContextListener requestContextListener() { return new RequestContextListener(); } - @Bean - public FilterRegistrationBean cockpitConfigurationFilter() { - return new KeycloakConfigurationFilterRegistrationBean( - keycloakCockpitConfiguration, - camundaBpmProperties.getWebapp().getApplicationPath() - ); - } +// @Bean //UNCOMMENT FOR JWT AUTH +// public FilterRegistrationBean cockpitConfigurationFilter() { +// return new KeycloakConfigurationFilterRegistrationBean( +// keycloakCockpitConfiguration, +// camundaBpmProperties.getWebapp().getApplicationPath() +// ); +// } } \ No newline at end of file diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/admin/scripts/config.js b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/admin/scripts/config.js index 88406446d1..01c63f8df6 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/admin/scripts/config.js +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/admin/scripts/config.js @@ -27,6 +27,6 @@ window.camAdminConf = { export default { customScripts: [ 'custom/logout', - '../identity-keycloak/scripts/identity-keycloak-auth.js' + //UNCOMMENT FOR JWT AUTH '../identity-keycloak/scripts/identity-keycloak-auth.js' ] }; \ No newline at end of file diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/admin/styles/user-styles.css b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/admin/styles/user-styles.css index 107f846a75..a2bcfa2cb4 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/admin/styles/user-styles.css +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/admin/styles/user-styles.css @@ -74,4 +74,7 @@ aside li.active>a { font-size: 30px; padding: 7px; line-height: 36px; +} +.ce-eol-banner { + display: none !important; } \ No newline at end of file diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/cockpit/scripts/config.js b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/cockpit/scripts/config.js index 83f5dbe387..091ff8eb42 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/cockpit/scripts/config.js +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/cockpit/scripts/config.js @@ -38,7 +38,7 @@ export default { 'scripts/definition-historic-activities.js', 'scripts/instance-historic-activities.js', 'scripts/instance-route-history.js', - '../identity-keycloak/scripts/identity-keycloak-auth.js' + //UNCOMMENT FOR JWT AUTH '../identity-keycloak/scripts/identity-keycloak-auth.js' ], disableWelcomeMessage: true, // userOperationLogAnnotationLength: 5000, diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/cockpit/styles/user-styles.css b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/cockpit/styles/user-styles.css index 107f846a75..d99cad3169 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/cockpit/styles/user-styles.css +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/cockpit/styles/user-styles.css @@ -74,4 +74,8 @@ aside li.active>a { font-size: 30px; padding: 7px; line-height: 36px; -} \ No newline at end of file +} + +.ce-eol-banner { + display: none !important; +} diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/tasklist/scripts/config.js b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/tasklist/scripts/config.js index e273b75839..04f1fae736 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/tasklist/scripts/config.js +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/tasklist/scripts/config.js @@ -27,7 +27,7 @@ window.camTasklistConf = { export default { customScripts: [ 'custom/logout', - '../identity-keycloak/scripts/identity-keycloak-auth.js' + //UNCOMMENT FOR JWT AUTH '../identity-keycloak/scripts/identity-keycloak-auth.js' ] }; diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/tasklist/styles/user-styles.css b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/tasklist/styles/user-styles.css index 107f846a75..d99cad3169 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/tasklist/styles/user-styles.css +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/tasklist/styles/user-styles.css @@ -74,4 +74,8 @@ aside li.active>a { font-size: 30px; padding: 7px; line-height: 36px; -} \ No newline at end of file +} + +.ce-eol-banner { + display: none !important; +} diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/welcome/styles/user-styles.css b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/welcome/styles/user-styles.css index 107f846a75..fd8f605814 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/welcome/styles/user-styles.css +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/META-INF/resources/webjars/camunda/app/welcome/styles/user-styles.css @@ -74,4 +74,8 @@ aside li.active>a { font-size: 30px; padding: 7px; line-height: 36px; +} + +.ce-eol-banner { + display: none !important; } \ No newline at end of file diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/application.yaml b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/application.yaml index 31608f378c..e945abe5f1 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/application.yaml +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/main/resources/application.yaml @@ -37,9 +37,30 @@ formsflow.ai: webclient: maxInMemorySize: ${DATA_BUFFER_SIZE:2} connectionTimeout: ${BPM_CLIENT_CONN_TIMEOUT:5000} + documentService: + url: ${FORMSFLOW_DOC_API_URL} + authorizations: + - groupId: "ROLE_create_submissions" + resourceType: "PROCESS_DEFINITION,PROCESS_INSTANCE,DECISION_DEFINITION,DECISION_REQUIREMENTS_DEFINITION" + resourceId: "*" + permissions: ["ALL"] + - groupId: "ROLE_view_designs" + resourceType: "PROCESS_DEFINITION,PROCESS_INSTANCE,DECISION_DEFINITION,DECISION_REQUIREMENTS_DEFINITION" + resourceId: "*" + permissions: ["ALL"] + - groupId: "ROLE_create_designs" + resourceType: "DEPLOYMENT" + resourceId: "*" + permissions: ["ALL"] + - groupId: "ROLE_view_tasks" + resourceType: "PROCESS_DEFINITION,PROCESS_INSTANCE,TASK,FILTER,USER,DECISION_DEFINITION,DECISION_REQUIREMENTS_DEFINITION" + resourceId: "*" + permissions: ["ALL"] camunda.bpm: + database: + schema-update: true job-execution: enabled: true history-level: ${CAMUNDA_BPM_HISTORY_LEVEL:none} @@ -114,6 +135,15 @@ spring: issuer-uri: ${keycloak.url}${keycloak.url.httpRelativePath}/realms/${keycloak.url.realm} main: allow-bean-definition-overriding: true + cloud: + vault: + enabled: ${VAULT_ENABLED:false} + path: ${VAULT_PATH} + secret: ${VAULT_SECRET} # secret name + token: ${VAULT_TOKEN} + uri: ${VAULT_URL} + compatibility-verifier: + enabled: false # Keycloak JWT Client configuration keycloak.jwt.realm: ${KEYCLOAK_URL_REALM} @@ -202,8 +232,8 @@ logging: org.springframework.jdbc: ${CAMUNDA_APP_ROOT_LOG_FLAG} org.camunda.bpm: ${CAMUNDA_APP_ROOT_LOG_FLAG} pattern: - console: '%d{yyyy-MM-dd HH:mm:ss} - %msg%n' - file: '%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n' + console: '%X{tenantKey:-default} - %d{yyyy-MM-dd HH:mm:ss} - %msg%n' + file: '%X{tenantKey:-default} - %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n' logback: rollingpolicy: file-name-pattern: /logs/archive/forms-flow-bpm-%d{yyyy-MM-dd}.%i.log diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/test/java/org/camunda/bpm/extension/hooks/listeners/FormBPMFilteredDataPipelineListenerTest.java b/forms-flow-bpm/forms-flow-bpm-camunda/src/test/java/org/camunda/bpm/extension/hooks/listeners/FormBPMFilteredDataPipelineListenerTest.java index 8d0c864826..aefb88133e 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/test/java/org/camunda/bpm/extension/hooks/listeners/FormBPMFilteredDataPipelineListenerTest.java +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/test/java/org/camunda/bpm/extension/hooks/listeners/FormBPMFilteredDataPipelineListenerTest.java @@ -1,34 +1,37 @@ package org.camunda.bpm.extension.hooks.listeners; -import com.fasterxml.jackson.databind.ObjectMapper; +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + import org.camunda.bpm.engine.delegate.DelegateExecution; import org.camunda.bpm.engine.delegate.DelegateTask; import org.camunda.bpm.extension.commons.connector.HTTPServiceInvoker; -import org.camunda.bpm.extension.hooks.listeners.data.FilterInfo; +import static org.camunda.bpm.extension.commons.utils.VariableConstants.APPLICATION_ID; +import static org.camunda.bpm.extension.commons.utils.VariableConstants.FORM_URL; import org.camunda.bpm.extension.hooks.listeners.data.FormProcessMappingData; import org.camunda.bpm.extension.hooks.services.FormSubmissionService; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import org.mockito.InjectMocks; import org.mockito.Mock; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.util.ReflectionTestUtils; -import java.lang.reflect.Field; -import java.util.*; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.*; - -import static org.camunda.bpm.extension.commons.utils.VariableConstants.FORM_URL; -import static org.camunda.bpm.extension.commons.utils.VariableConstants.APPLICATION_ID; +import com.fasterxml.jackson.databind.ObjectMapper; /** * FormBPM FilteredData Pipeline Listener Test. @@ -81,7 +84,7 @@ public void syncFormVariables_with_delegatetask_and_validapi_withdata_test() thr FormProcessMappingData formProcessMappingData = new FormProcessMappingData(); formProcessMappingData.setProcessKey("onestepapproval"); formProcessMappingData.setProcessKey("onestepapproval"); - formProcessMappingData.setTaskVariable("[{\"key\" : \"businessOwner\", \"defaultLabel\" : \"Business Owner\", \"label\" : \"Business Owner\"}]"); + formProcessMappingData.setTaskVariables("[{\"key\" : \"businessOwner\", \"defaultLabel\" : \"Business Owner\", \"label\" : \"Business Owner\"}]"); when(httpServiceInvoker.execute(anyString(), any(HttpMethod.class), any(), any())) .thenReturn(ResponseEntity.ok(formProcessMappingData)); @@ -115,7 +118,7 @@ public void syncFormVariables_with_delegateExecution_and_validapi_withdata_test( FormProcessMappingData formProcessMappingData = new FormProcessMappingData(); formProcessMappingData.setProcessKey("onestepapproval"); formProcessMappingData.setProcessKey("onestepapproval"); - formProcessMappingData.setTaskVariable("[{\"key\" : \"businessOwner\", \"defaultLabel\" : \"Business Owner\", \"label\" : \"Business Owner\"}]"); + formProcessMappingData.setTaskVariables("[{\"key\" : \"businessOwner\", \"defaultLabel\" : \"Business Owner\", \"label\" : \"Business Owner\"}]"); when(httpServiceInvoker.execute(anyString(), any(HttpMethod.class), any(), any())) .thenReturn(ResponseEntity.ok(formProcessMappingData)); @@ -153,7 +156,7 @@ public void syncFormVariables_with_validapi_and_emptydata_test() throws Exceptio FormProcessMappingData formProcessMappingData = new FormProcessMappingData(); formProcessMappingData.setProcessKey("onestepapproval"); formProcessMappingData.setProcessKey("onestepapproval"); - formProcessMappingData.setTaskVariable("[{\"key\" : \"businessOwner\", \"value\" : \"john\", \"label\" : \"Business Owner\"}]"); + formProcessMappingData.setTaskVariables("[{\"key\" : \"businessOwner\", \"value\" : \"john\", \"label\" : \"Business Owner\"}]"); when(httpServiceInvoker.execute(anyString(), any(HttpMethod.class), any(), any())) .thenReturn(ResponseEntity.ok(formProcessMappingData)); formBPMFilteredDataPipelineListener.notify(delegateTask); diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/test/java/org/camunda/bpm/extension/keycloak/plugin/KeycloakGroupServiceTest.java b/forms-flow-bpm/forms-flow-bpm-camunda/src/test/java/org/camunda/bpm/extension/keycloak/plugin/KeycloakGroupServiceTest.java index c519e697ee..04463cdb58 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/test/java/org/camunda/bpm/extension/keycloak/plugin/KeycloakGroupServiceTest.java +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/test/java/org/camunda/bpm/extension/keycloak/plugin/KeycloakGroupServiceTest.java @@ -55,6 +55,7 @@ public void requestGroupsByUserIdForAuthByGroup() throws IOException, ServletExc keycloakContextProvider, customConfig); when(cacheableKeycloakGroupQuery.getUserId()).thenReturn(userId); when(configuration.getKeycloakAdminUrl()).thenReturn(kcUrl); + when(configuration.getAdministratorGroupName()).thenReturn("camunda-admin"); HttpHeaders header = new HttpHeaders(); header.setContentType(MediaType.APPLICATION_JSON); @@ -83,6 +84,7 @@ public void requestGroupsByUserIdForAuthByClient() throws IOException, ServletEx keycloakContextProvider, customConfig); when(cacheableKeycloakGroupQuery.getUserId()).thenReturn(userId); when(configuration.getKeycloakAdminUrl()).thenReturn(kcUrl); + when(configuration.getAdministratorGroupName()).thenReturn("camunda-admin"); HttpHeaders header = new HttpHeaders(); header.setContentType(MediaType.APPLICATION_JSON); @@ -100,7 +102,7 @@ public void requestGroupsByUserIdForAuthByClient() throws IOException, ServletEx HttpMethod.GET, String.class)).thenReturn(clientRoleEntity); List roles = groupService.requestGroupsByUserId(cacheableKeycloakGroupQuery); - assertEquals(roles.size(), 1); + assertEquals(roles.size(), 0); } } diff --git a/forms-flow-bpm/forms-flow-bpm-camunda/src/test/java/org/camunda/bpm/extension/keycloak/rest/KeycloakAuthenticationFilterTest.java b/forms-flow-bpm/forms-flow-bpm-camunda/src/test/java/org/camunda/bpm/extension/keycloak/rest/KeycloakAuthenticationFilterTest.java index baffdeda76..71ce04bdde 100644 --- a/forms-flow-bpm/forms-flow-bpm-camunda/src/test/java/org/camunda/bpm/extension/keycloak/rest/KeycloakAuthenticationFilterTest.java +++ b/forms-flow-bpm/forms-flow-bpm-camunda/src/test/java/org/camunda/bpm/extension/keycloak/rest/KeycloakAuthenticationFilterTest.java @@ -9,11 +9,12 @@ import jakarta.servlet.ServletResponse; import org.camunda.bpm.engine.IdentityService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockitoAnnotations; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; @@ -35,7 +36,6 @@ @ExtendWith(SpringExtension.class) public class KeycloakAuthenticationFilterTest { - @InjectMocks private KeycloakAuthenticationFilter keycloakAuthenticationFilter; @Mock @@ -60,8 +60,17 @@ public class KeycloakAuthenticationFilterTest { * This test perform to check the groups and users * This will validate the userId and userGroups */ + + @BeforeEach + public void setUp() { + MockitoAnnotations.initMocks(this); + } @Test public void doFilterTest() throws IOException, ServletException { + String userNameAttribute = "test-user"; + boolean enableClientAuth = false; + boolean enableMultiTenancy = false; + keycloakAuthenticationFilter = new KeycloakAuthenticationFilter(identityService, clientService, userNameAttribute, enableClientAuth, enableMultiTenancy); SecurityContextHolder.getContext().setAuthentication(auth); Map claims = new HashMap<>(); @@ -98,14 +107,21 @@ public void doFilterTest() throws IOException, ServletException { */ @Test public void doFilterTestForclientRoles() throws IOException, ServletException { + String userNameAttribute = "User1"; + boolean enableClientAuth = true; + boolean enableMultiTenancy = true; + String tenantKey = "testtenant"; + keycloakAuthenticationFilter = new KeycloakAuthenticationFilter(identityService, clientService, userNameAttribute, enableClientAuth, enableMultiTenancy); + SecurityContextHolder.getContext().setAuthentication(auth); Map claims = new HashMap<>(); String userId = "User1"; - JSONArray roles = new JSONArray(); - roles.add(new String("camunda-admin")); - roles.add(new String("formsflow-reviewer")); - claims.put("roles", roles); + JSONArray groups = new JSONArray(); + groups.add(new String(tenantKey+"-camunda-admin")); + groups.add(new String(tenantKey+"-formsflow-reviewer")); + claims.put("groups", groups); + claims.put("tenantKey", tenantKey); OidcUser oidcUser = mock(OidcUser.class); when(auth.getPrincipal()).thenReturn(oidcUser); @@ -117,10 +133,11 @@ public void doFilterTestForclientRoles() throws IOException, ServletException { ArgumentCaptor userIdCaptor = ArgumentCaptor.forClass(String.class); ArgumentCaptor userRolesCaptor = ArgumentCaptor.forClass(List.class); - verify(identityService).setAuthentication(userIdCaptor.capture(), userRolesCaptor.capture()); + ArgumentCaptor userTenanatCaptor = ArgumentCaptor.forClass(List.class); + verify(identityService).setAuthentication(userIdCaptor.capture(), userRolesCaptor.capture(), userTenanatCaptor.capture()); assertEquals("User1", userIdCaptor.getValue()); - assertTrue(userRolesCaptor.getValue().contains("camunda-admin")); - assertTrue(userRolesCaptor.getValue().contains("formsflow-reviewer")); + assertTrue(userRolesCaptor.getValue().contains(tenantKey+"-camunda-admin")); + assertTrue(userRolesCaptor.getValue().contains(tenantKey+"-formsflow-reviewer")); } } diff --git a/forms-flow-bpm/forms-flow-bpm-utils/pom.xml b/forms-flow-bpm/forms-flow-bpm-utils/pom.xml index 31ba6e2f26..d7581206e4 100644 --- a/forms-flow-bpm/forms-flow-bpm-utils/pom.xml +++ b/forms-flow-bpm/forms-flow-bpm-utils/pom.xml @@ -8,11 +8,11 @@ formsflow.ai forms-flow-bpm - 6.0.2 + 7.0.0 forms-flow-bpm-utils - 6.0.2 + 7.0.0 formsflow BPM Extension Utils formsflow BPM Extension diff --git a/forms-flow-bpm/migration/README.md b/forms-flow-bpm/migration/README.md new file mode 100644 index 0000000000..962fe426f7 --- /dev/null +++ b/forms-flow-bpm/migration/README.md @@ -0,0 +1,10 @@ +### Migration tasks for BPM +This document lists down any manual execution needed as part of the migration between versions. + +### DB Migration +Source : https://artifacts.camunda.com/ui/native/camunda-bpm/org/camunda/bpm/distro/camunda-sql-scripts/ + +#### v6.0.2 to 7.0.0 +Execute the database scripts under scripts/7.0.0.sql + + diff --git a/forms-flow-bpm/migration/scripts/7.0.0.sql b/forms-flow-bpm/migration/scripts/7.0.0.sql new file mode 100644 index 0000000000..bef6ecb7d2 --- /dev/null +++ b/forms-flow-bpm/migration/scripts/7.0.0.sql @@ -0,0 +1,27 @@ +-- +-- Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH +-- under one or more contributor license agreements. See the NOTICE file +-- distributed with this work for additional information regarding copyright +-- ownership. Camunda licenses this file to you under the Apache License, +-- Version 2.0; you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +insert into ACT_GE_SCHEMA_LOG +values ('1000', CURRENT_TIMESTAMP, '7.21.0'); + +alter table ACT_RU_EXT_TASK + add column CREATE_TIME_ timestamp; + +alter table ACT_RU_JOB + add column ROOT_PROC_INST_ID_ varchar(64); + +create index ACT_IDX_JOB_ROOT_PROCINST on ACT_RU_JOB(ROOT_PROC_INST_ID_); \ No newline at end of file diff --git a/forms-flow-bpm/pom-default.xml b/forms-flow-bpm/pom-default.xml index ed4eef0c08..aea1aed5bb 100644 --- a/forms-flow-bpm/pom-default.xml +++ b/forms-flow-bpm/pom-default.xml @@ -7,7 +7,7 @@ formsflow.ai forms-flow-bpm - 6.0.2 + 7.0.0 pom.xml diff --git a/forms-flow-bpm/pom.xml b/forms-flow-bpm/pom.xml index d67b840c92..4ea6a79e2d 100644 --- a/forms-flow-bpm/pom.xml +++ b/forms-flow-bpm/pom.xml @@ -6,7 +6,7 @@ formsflow.ai forms-flow-bpm - 6.0.2 + 7.0.0 pom formsflow BPM Extension @@ -22,16 +22,16 @@ false - 7.20.0 - 7.20.0 - 1.5.4 - 1.5.0 - 3.1.10 - 2.6.8 - 2.15.0 - 1.5 - 2.2 + 7.21.5 + 7.21.0 + 1.5.4 + 1.5.0 + 3.3.5 + 2.6.8 + 2.15.0 + 1.5 + 2.2 + 2022.0.5 @@ -296,15 +296,15 @@ - org.springframework - spring-websocket - 6.0.11 - + org.springframework + spring-websocket + 6.1.12 + org.springframework spring-messaging - 6.0.11 + 6.1.12 @@ -334,7 +334,7 @@ org.camunda.bpm camunda-engine-rest-core - 7.20.0 + ${version.camunda} @@ -343,6 +343,13 @@ commons-fileupload ${version.commonsFileUpload} + + + org.testng + testng + 7.5.1 + test + diff --git a/forms-flow-bpm/sample.env b/forms-flow-bpm/sample.env index 0fe53b43b5..09746d8d9d 100644 --- a/forms-flow-bpm/sample.env +++ b/forms-flow-bpm/sample.env @@ -86,3 +86,14 @@ CUSTOM_SUBMISSION_ENABLED=false # Cookie secure flag, default value is true. # SESSION_COOKIE_SECURE=false +# Vault configuration +# VAULT_ENABLED=false +# VAULT_URL=http://{your-ip-address}:8200 +# VAULT_TOKEN= +# VAULT_PATH= +# VAULT_SECRET= + +#formsflsow.ai doc api URL +FORMSFLOW_DOC_API_URL=http://{your-ip-address}:5006 + + diff --git a/forms-flow-data-analysis-api/Dockerfile b/forms-flow-data-analysis-api/Dockerfile index 50953038ab..1aff68df14 100644 --- a/forms-flow-data-analysis-api/Dockerfile +++ b/forms-flow-data-analysis-api/Dockerfile @@ -1,5 +1,5 @@ #Author: Kurian Benoy -FROM python:3.11.7-slim-bullseye +FROM python:3.12.6-slim # set label for image LABEL Name="formsflow" @@ -19,5 +19,5 @@ RUN pip install . EXPOSE 5000 RUN python3 -c "from transformers import pipeline; pipeline('sentiment-analysis', model='$MODEL_ID', truncation=True)" -RUN chmod u+x ./entrypoint -ENTRYPOINT ["/bin/sh", "entrypoint"] +RUN chmod u+x ./entrypoint.sh +ENTRYPOINT ["/bin/sh", "entrypoint.sh"] diff --git a/forms-flow-data-analysis-api/README.md b/forms-flow-data-analysis-api/README.md index 45e59ebe93..33dc63fc7e 100644 --- a/forms-flow-data-analysis-api/README.md +++ b/forms-flow-data-analysis-api/README.md @@ -1,8 +1,8 @@ # formsflow.ai Sentiment Analysis Component -![Python](https://img.shields.io/badge/Python-3.11.7-blue) ![Flask](https://img.shields.io/badge/Flask-2.3.3-blue) ![postgres](https://img.shields.io/badge/postgres-13.0-blue) -![Transformers](https://img.shields.io/badge/Transformers-4.36.2-blue) -![Torch](https://img.shields.io/badge/Torch-2.0.1-blue) +![Python](https://img.shields.io/badge/Python-3.12.6-blue) ![Flask](https://img.shields.io/badge/Flask-2.3.3-blue) ![postgres](https://img.shields.io/badge/postgres-13.0-blue) +![Transformers](https://img.shields.io/badge/Transformers-4.47.0-blue) +![Torch](https://img.shields.io/badge/Torch-2.5.1-blue) Sentiment Analysisis used to understand the sentiments of the customer for products, movies, and other such things, whether they feel positive, negative, or neutral about it. BERT is a very good pre-trained language model which helps machines learn excellent representations of text with respect to context in many natural language tasks. diff --git a/forms-flow-data-analysis-api/docker-compose.yml b/forms-flow-data-analysis-api/docker-compose.yml index 7507651e4c..7879524555 100644 --- a/forms-flow-data-analysis-api/docker-compose.yml +++ b/forms-flow-data-analysis-api/docker-compose.yml @@ -28,9 +28,12 @@ services: volumes: - ./:/app:rw environment: - POSTGRES_USER: ${DATA_ANALYSIS_DB_USER:-general} - POSTGRES_PASSWORD: ${DATA_ANALYSIS_DB_PASSWORD:-changeme} - POSTGRES_DB: ${DATA_ANALYSIS_DB_NAME:-dataanalysis} + DATABASE_URL: ${DATA_ANALYSIS_DB_URL} + DATABASE_USERNAME: ${DATA_ANALYSIS_DB_USER} + DATABASE_PASSWORD: ${DATA_ANALYSIS_DB_PASSWORD} + DATABASE_HOST: ${DATA_ANALYSIS_DB_HOST} + DATABASE_PORT: ${DATA_ANALYSIS_DB_PORT} + DATABASE_NAME: ${DATA_ANALYSIS_DB_NAME} JWT_OIDC_WELL_KNOWN_CONFIG: ${KEYCLOAK_URL}${KEYCLOAK_URL_HTTP_RELATIVE_PATH:-/auth}/realms/${KEYCLOAK_URL_REALM:-forms-flow-ai}/.well-known/openid-configuration JWT_OIDC_ALGORITHMS: 'RS256' JWT_OIDC_JWKS_URI: ${KEYCLOAK_URL}${KEYCLOAK_URL_HTTP_RELATIVE_PATH:-/auth}/realms/${KEYCLOAK_URL_REALM:-forms-flow-ai}/protocol/openid-connect/certs diff --git a/forms-flow-data-analysis-api/entrypoint b/forms-flow-data-analysis-api/entrypoint.sh similarity index 66% rename from forms-flow-data-analysis-api/entrypoint rename to forms-flow-data-analysis-api/entrypoint.sh index 6bc65c8969..ba50fec9e2 100644 --- a/forms-flow-data-analysis-api/entrypoint +++ b/forms-flow-data-analysis-api/entrypoint.sh @@ -4,4 +4,5 @@ if [ "$DATABASE_SUPPORT" = "ENABLED" ] then flask db upgrade fi -gunicorn -b :5000 'gunicorn_config:app' --timeout 120 --worker-class=gthread --workers=5 --threads=10 --preload +# running the flask server using gunicorn +gunicorn -b :5000 'gunicorn_config:app' --timeout 120 --worker-class=gthread --workers=5 --threads=10 --preload \ No newline at end of file diff --git a/forms-flow-data-analysis-api/model_training/requirements.txt b/forms-flow-data-analysis-api/model_training/requirements.txt index 9ddf63f71c..f6d76cf0d5 100644 --- a/forms-flow-data-analysis-api/model_training/requirements.txt +++ b/forms-flow-data-analysis-api/model_training/requirements.txt @@ -1,6 +1,6 @@ -NumPy==1.24.1 -Transformers==4.36.2 -torch==2.0.1 -scikit-learn==1.2.0 -datasets==2.8.0 +numpy==1.26.4 +transformers==4.45.1 +torch==2.2.0 +scikit-learn==1.5.0 +datasets==3.0.1 pyarrow>=14.0.1 # not directly required, pinned to avoid a vulnerability diff --git a/forms-flow-data-analysis-api/requirements.txt b/forms-flow-data-analysis-api/requirements.txt index 929416d2cf..72479ee902 100644 --- a/forms-flow-data-analysis-api/requirements.txt +++ b/forms-flow-data-analysis-api/requirements.txt @@ -1,66 +1,85 @@ -Flask-Migrate==4.0.5 +Flask-Migrate==4.0.7 Flask-SQLAlchemy==3.1.1 -Flask==2.3.3 -Jinja2==3.1.3 -Mako==1.2.4 -MarkupSafe==2.1.3 -PyYAML==6.0.1 -SQLAlchemy-Utils==0.41.1 -SQLAlchemy==2.0.21 -Werkzeug==3.0.1 -alembic==1.12.0 +Flask==3.1.0 +Jinja2==3.1.4 +Mako==1.3.8 +MarkupSafe==3.0.2 +PyYAML==6.0.2 +Pygments==2.18.0 +SQLAlchemy-Utils==0.41.2 +SQLAlchemy==2.0.36 +Werkzeug==3.1.3 +alembic==1.14.0 aniso8601==9.0.1 -attrs==23.1.0 -blinker==1.6.2 -blis==0.7.10 -cachelib==0.10.2 -catalogue==1.0.2 -certifi==2023.7.22 -charset-normalizer==3.2.0 +annotated-types==0.7.0 +attrs==24.2.0 +blinker==1.9.0 +blis==1.0.2 +cachelib==0.13.0 +catalogue==2.0.10 +certifi==2024.8.30 +charset-normalizer==3.4.0 click==8.1.7 -cymem==2.0.8 -ecdsa==0.18.0 -filelock==3.12.4 -flask-jwt-oidc==0.3.0 -flask-restx==1.1.0 -fsspec==2023.9.1 -gunicorn==21.2.0 -huggingface-hub==0.19.4 -idna==3.4 -itsdangerous==2.1.2 -joblib==1.3.2 -jsonschema-specifications==2023.7.1 -jsonschema==4.19.1 +cloudpathlib==0.20.0 +confection==0.1.5 +cymem==2.0.10 +ecdsa==0.19.0 +filelock==3.16.1 +flask-jwt-oidc==0.7.0 +flask-restx==1.3.0 +fsspec==2024.10.0 +gunicorn==23.0.0 +huggingface-hub==0.26.5 +idna==3.10 +importlib_resources==6.4.5 +itsdangerous==2.2.0 +joblib==1.4.2 +jsonschema-specifications==2024.10.1 +jsonschema==4.23.0 +langcodes==3.5.0 +language_data==1.3.0 +marisa-trie==1.2.1 +markdown-it-py==3.0.0 +mdurl==0.1.2 mpmath==1.3.0 -murmurhash==1.0.10 -networkx==3.1 -nltk==3.8.1 -numpy==1.26.0 -packaging==23.1 -plac==1.1.3 +murmurhash==1.0.11 +networkx==3.4.2 +nltk==3.9.1 +numpy==2.0.2 +packaging==24.2 preshed==3.0.9 -protobuf==4.24.3 -psycopg2-binary==2.9.7 -pyasn1==0.5.0 -python-dotenv==1.0.0 +protobuf==5.29.1 +psycopg2-binary==2.9.10 +pyasn1==0.6.1 +pydantic==2.10.3 +pydantic_core==2.27.1 +python-dotenv==1.0.1 python-jose==3.3.0 -pytz==2023.3.post1 -referencing==0.30.2 -regex==2023.8.8 -requests==2.31.0 -rpds-py==0.10.3 +pytz==2024.2 +referencing==0.35.1 +regex==2024.11.6 +requests==2.32.3 +rich==13.9.4 +rpds-py==0.22.3 rsa==4.9 -safetensors==0.3.3 -sentencepiece==0.1.99 -six==1.16.0 -spacy==2.3.9 -srsly==1.0.7 -sympy==1.12 -thinc==7.4.6 -tokenizers==0.15.2 -torch==2.0.1 -tqdm==4.66.1 -transformers==4.36.2 -typing_extensions==4.8.0 -urllib3==2.0.7 -wasabi==0.10.1 +safetensors==0.4.5 +sentencepiece==0.2.0 +shellingham==1.5.4 +six==1.17.0 +smart-open==7.0.5 +spacy-legacy==3.0.12 +spacy-loggers==1.0.5 +spacy==3.8.2 +srsly==2.5.0 +sympy==1.13.1 +thinc==8.3.2 +tokenizers==0.21.0 +torch==2.5.1 +tqdm==4.67.1 +transformers==4.47.0 +typer==0.15.1 +typing_extensions==4.12.2 +urllib3==2.2.3 +wasabi==1.1.3 +weasel==0.4.1 +wrapt==1.17.0 diff --git a/forms-flow-data-analysis-api/requirements/prod.txt b/forms-flow-data-analysis-api/requirements/prod.txt index 4a9fe4360c..6e2d8df4f4 100755 --- a/forms-flow-data-analysis-api/requirements/prod.txt +++ b/forms-flow-data-analysis-api/requirements/prod.txt @@ -1,5 +1,5 @@ gunicorn -Flask<3 +Flask Flask-SQLAlchemy flask-restx flask-jwt-oidc @@ -10,6 +10,6 @@ attrs Werkzeug sqlalchemy_utils sqlalchemy -spacy<3 +spacy nltk Flask-Migrate diff --git a/forms-flow-data-analysis-api/sample.env b/forms-flow-data-analysis-api/sample.env index 7c2dd65846..ffd8d59206 100644 --- a/forms-flow-data-analysis-api/sample.env +++ b/forms-flow-data-analysis-api/sample.env @@ -2,10 +2,15 @@ KEYCLOAK_URL=http://{your-ip-address}:8080 KEYCLOAK_URL_REALM=forms-flow-ai KEYCLOAK_WEB_CLIENT_ID=forms-flow-web -DATA_ANALYSIS_DB_USER=general -DATA_ANALYSIS_DB_PASSWORD=changeme -DATA_ANALYSIS_DB_NAME=dataanalysis +#DATABASE URL configuration DATA_ANALYSIS_DB_URL=postgresql://general:changeme@forms-flow-data-analysis-db:5432/dataanalysis +# You can pass the full database URL or split it into the following variables: +DATA_ANALYSIS_DB_USER="" +DATA_ANALYSIS_DB_PASSWORD="" +DATA_ANALYSIS_DB_HOST="" +DATA_ANALYSIS_DB_PORT="" +DATA_ANALYSIS_DB_NAME="" + MODEL_ID=Seethal/sentiment_analysis_generic_dataset DATABASE_SUPPORT=DISABLED diff --git a/forms-flow-data-analysis-api/src/api/__init__.py b/forms-flow-data-analysis-api/src/api/__init__.py index ea75acb8b2..b308a1c9e2 100644 --- a/forms-flow-data-analysis-api/src/api/__init__.py +++ b/forms-flow-data-analysis-api/src/api/__init__.py @@ -12,8 +12,8 @@ from .resources import data_analysis_api from .utils.auth import jwt from .utils.enumerator import Service -from .utils.logging import setup_logging from .utils.file_log_handler import register_log_handlers +from .utils.logging import setup_logging flask_logger = setup_logging( os.path.join(os.path.abspath(os.path.dirname(__file__)), "logging.conf") diff --git a/forms-flow-data-analysis-api/src/api/config.py b/forms-flow-data-analysis-api/src/api/config.py index 836015e0bf..6f5a8ddcbe 100644 --- a/forms-flow-data-analysis-api/src/api/config.py +++ b/forms-flow-data-analysis-api/src/api/config.py @@ -72,18 +72,16 @@ class _Config: # pylint: disable=too-few-public-methods DATABASE_SUPPORT = os.getenv("DATABASE_SUPPORT", default=Service.DISABLED.value) - DB_PG_CONFIG = { - "host": os.getenv("POSTGRES_DB_HOST", "forms-flow-data-analysis-db"), - "port": os.getenv("POSTGRES_DB_PORT", "5432"), - "dbname": os.getenv("POSTGRES_DB"), - "user": os.getenv("POSTGRES_USER"), - "password": os.getenv("POSTGRES_PASSWORD"), - } - SQLALCHEMY_DATABASE_URI = ( - f"postgresql://" - f"{DB_PG_CONFIG['user']}:{DB_PG_CONFIG['password']}" - f"@{DB_PG_CONFIG['host']}:{int(DB_PG_CONFIG['port'])}/{DB_PG_CONFIG['dbname']}" + # PostgreSQL configuration + DB_USER = os.getenv("DATABASE_USERNAME", "general") + DB_PASSWORD = os.getenv("DATABASE_PASSWORD", "changeme") + DB_HOST = os.getenv("DATABASE_HOST", "localhost") + DB_PORT = os.getenv("DATABASE_PORT", "5432") + DB_NAME = os.getenv("DATABASE_NAME", "dataanalysis") + SQLALCHEMY_DATABASE_URI = os.getenv( + "DATABASE_URL", f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" ) + MODEL_ID = os.getenv("MODEL_ID") # Configure LOG diff --git a/forms-flow-data-analysis-api/src/api/utils/file_log_handler.py b/forms-flow-data-analysis-api/src/api/utils/file_log_handler.py index 0be52d7e18..c735c81e86 100644 --- a/forms-flow-data-analysis-api/src/api/utils/file_log_handler.py +++ b/forms-flow-data-analysis-api/src/api/utils/file_log_handler.py @@ -64,7 +64,7 @@ def getFilesToDelete(self): return result -def register_log_handlers( # pylint: disable=too-many-arguments +def register_log_handlers( # pylint: disable=too-many-arguments,too-many-positional-arguments app, log_file, when, interval, backup_count, configure_log_file: bool = True): """Configure console and file log handlers.""" logs = logging.StreamHandler() diff --git a/forms-flow-data-analysis-api/tests/conftest.py b/forms-flow-data-analysis-api/tests/conftest.py index e891f886d7..b244aaed2b 100644 --- a/forms-flow-data-analysis-api/tests/conftest.py +++ b/forms-flow-data-analysis-api/tests/conftest.py @@ -3,6 +3,7 @@ from flask_migrate import Migrate, upgrade from sqlalchemy import event, text from sqlalchemy.schema import DropConstraint, MetaData + from api import create_app, setup_jwt_manager from api.models import db as _db from api.utils import jwt as _jwt diff --git a/forms-flow-data-analysis-api/tests/unit/api/test_sentiment_analysis.py b/forms-flow-data-analysis-api/tests/unit/api/test_sentiment_analysis.py index 70ea76d6e6..4881737c09 100644 --- a/forms-flow-data-analysis-api/tests/unit/api/test_sentiment_analysis.py +++ b/forms-flow-data-analysis-api/tests/unit/api/test_sentiment_analysis.py @@ -1,7 +1,4 @@ -from tests.utilities.base_test import ( - get_sentiment_analysis_api_payload, - get_token -) +from tests.utilities.base_test import get_sentiment_analysis_api_payload, get_token def test_sentiment_analysis_api_without_bearer_token(client): diff --git a/forms-flow-data-analysis-api/tests/unit/services/test_sentiment_analysis.py b/forms-flow-data-analysis-api/tests/unit/services/test_sentiment_analysis.py index 6c455c6177..c9e164e795 100644 --- a/forms-flow-data-analysis-api/tests/unit/services/test_sentiment_analysis.py +++ b/forms-flow-data-analysis-api/tests/unit/services/test_sentiment_analysis.py @@ -2,10 +2,7 @@ Test-suite """ -from api.services.transformers import ( - sentiment_analysis_pipeline_transformers - -) +from api.services.transformers import sentiment_analysis_pipeline_transformers def sentiment_analysis_pipeline_transformers(): diff --git a/forms-flow-data-analysis-api/tests/utilities/base_test.py b/forms-flow-data-analysis-api/tests/utilities/base_test.py index c6f2ba088d..b4bfaeaf99 100644 --- a/forms-flow-data-analysis-api/tests/utilities/base_test.py +++ b/forms-flow-data-analysis-api/tests/utilities/base_test.py @@ -1,9 +1,9 @@ """Utils for Test Suite""" import ast import os +import time import requests -import time from dotenv import find_dotenv, load_dotenv from flask import current_app diff --git a/forms-flow-documents/Dockerfile b/forms-flow-documents/Dockerfile index 3d5d8e89e7..c5f1ec7c39 100644 --- a/forms-flow-documents/Dockerfile +++ b/forms-flow-documents/Dockerfile @@ -1,5 +1,5 @@ #Author: Kurian Benoy -FROM python:3.12.1-slim-bullseye +FROM python:3.12.6-slim # set label for image LABEL Name="formsflow" diff --git a/forms-flow-documents/Dockerfile-ARM64 b/forms-flow-documents/Dockerfile-ARM64 index 8dc4edda6f..ed605fe324 100644 --- a/forms-flow-documents/Dockerfile-ARM64 +++ b/forms-flow-documents/Dockerfile-ARM64 @@ -1,5 +1,5 @@ #Author: Kurian Benoy -FROM python:3.12.1-slim-bullseye +FROM python:3.12.6-slim WORKDIR /forms-flow-documents/app diff --git a/forms-flow-documents/README.md b/forms-flow-documents/README.md index ac08054eae..c3edce7d7e 100644 --- a/forms-flow-documents/README.md +++ b/forms-flow-documents/README.md @@ -1,6 +1,6 @@ # formsflow.ai Documents API -![Python](https://img.shields.io/badge/python-3.12.1-blue) ![Flask](https://img.shields.io/badge/Flask-2.3.3-blue) ![postgres](https://img.shields.io/badge/postgres-11.0-blue) +![Python](https://img.shields.io/badge/python-3.12.6-blue) ![Flask](https://img.shields.io/badge/Flask-2.3.3-blue) [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![linting: pylint](https://img.shields.io/badge/linting-pylint-yellowgreen)](https://github.com/PyCQA/pylint) @@ -123,6 +123,61 @@ The example template will produce a PDF in a tabular form [Preview](https://github.com/sreehari-aot/forms-flow-ai/blob/pdf-template/.images/export_pdf_template_1.pdf) +Example template for bundle + +In case of a bundle, the form object contains a list of forms along with the submission data. +``` +{% extends "template.html" %} +{% block links %} + +{% endblock %} +{% block content %} +
+ {% for form_dict in form %} + {% for form_key, form_value in form_dict.items() %} +
+

{{ form_value['form']['title'] }}

+
+ + {% for item in form_value['data'] %} + + + {% if is_signature(form_value['data'][item]['value']) %} + + {% else %} + + {% endif %} + + {% endfor %} +
{{form_value['data'][item]['label']}}{{form_value['data'][item]['value']}}
+ {% endfor %} + {% endfor %} +
+{% endblock %} +``` +The example template will generate a PDF with a table for each form. + +[Preview](https://github.com/auslin-aot/forms-flow-ai/blob/feature/FWF-3257-export-pdf-bundle/.images/export_pdf_bundle_template.pdf) + TODO: Provide details for `form` object TODO: Add usecases @@ -153,6 +208,8 @@ template should be valid jinja template. * For docker based installation [Docker](https://docker.com) need to be installed. * Admin access to [Keycloak](../forms-flow-idm/keycloak) server and ensure audience(camunda-rest-api) is setup in Keycloak-bpm server. +* Ensure that the `forms-flow-redis` service is running and accessible on port `6379`. For more details, refer to the [forms-flow-redis README](../forms-flow-redis/README.md). + ## Solution Setup @@ -196,6 +253,7 @@ Variable name | Meaning | Possible values | Default value | `FORMSFLOW_DOC_API_URL`:triangular_flag_on_post:|formsflow.ai Document service URL||`http://{your-ip-address}:5006` `FORMSFLOW_API_CORS_ORIGINS`| formsflow.ai Rest API allowed origins, for allowing multiple origins you can separate host address using a comma seperated string or use * to allow all origins |eg:`host1, host2, host3`| `*` + **NOTE : Default realm is `forms-flow-ai`** ### Running the Application diff --git a/forms-flow-documents/docker-compose.yml b/forms-flow-documents/docker-compose.yml index 5a0739f3b1..bf991f5162 100644 --- a/forms-flow-documents/docker-compose.yml +++ b/forms-flow-documents/docker-compose.yml @@ -12,6 +12,11 @@ services: - ./:/app:rw environment: DATABASE_URL: ${FORMSFLOW_API_DB_URL:-postgresql://postgres:changeme@forms-flow-webapi-db:5432/webapi} + DATABASE_USERNAME: ${FORMSFLOW_API_DB_USER} + DATABASE_PASSWORD: ${FORMSFLOW_API_DB_PASSWORD} + DATABASE_HOST: ${FORMSFLOW_API_DB_HOST} + DATABASE_PORT: ${FORMSFLOW_API_DB_PORT} + DATABASE_NAME: ${FORMSFLOW_API_DB_NAME} FORMSFLOW_API_CORS_ORIGINS: ${FORMSFLOW_API_CORS_ORIGINS:-*} JWT_OIDC_WELL_KNOWN_CONFIG: ${KEYCLOAK_URL}${KEYCLOAK_URL_HTTP_RELATIVE_PATH:-/auth}/realms/${KEYCLOAK_URL_REALM:-forms-flow-ai}/.well-known/openid-configuration JWT_OIDC_JWKS_URI: ${KEYCLOAK_URL}${KEYCLOAK_URL_HTTP_RELATIVE_PATH:-/auth}/realms/${KEYCLOAK_URL_REALM:-forms-flow-ai}/protocol/openid-connect/certs @@ -40,14 +45,6 @@ services: tty: true # -t networks: - forms-flow-webapi-network - - redis - - redis: - image: "redis:alpine" - ports: - - "6379:6379" - networks: - - forms-flow-webapi-network networks: forms-flow-webapi-network: diff --git a/forms-flow-documents/requirements.txt b/forms-flow-documents/requirements.txt index 20cb4d8ea4..e9a4a5b8e5 100644 --- a/forms-flow-documents/requirements.txt +++ b/forms-flow-documents/requirements.txt @@ -1,72 +1,72 @@ Brotli==1.1.0 -Flask-Caching==2.1.0 +Flask-Caching==2.0.1 Flask-Migrate==4.0.7 -Flask-Moment==1.0.5 +Flask-Moment==1.0.6 Flask-SQLAlchemy==3.1.1 Flask==2.3.3 -Jinja2==3.1.3 -Mako==1.3.2 -MarkupSafe==2.1.5 -PyJWT==2.8.0 +Jinja2==3.1.4 +Mako==1.3.8 +MarkupSafe==3.0.2 +PyJWT==2.10.1 PySocks==1.7.1 -SQLAlchemy-Utils==0.41.1 -SQLAlchemy==2.0.28 -Werkzeug==3.0.1 -alembic==1.13.1 +SQLAlchemy-Utils==0.41.2 +SQLAlchemy==2.0.36 +Werkzeug==3.1.3 +alembic==1.14.0 aniso8601==9.0.1 -async-timeout==4.0.3 -attrs==23.2.0 +attrs==24.2.0 blinker==1.7.0 -cachelib==0.9.0 -certifi==2024.2.2 -cffi==1.16.0 -charset-normalizer==3.3.2 +cachelib==0.13.0 +certifi==2024.8.30 +cffi==1.17.1 +charset-normalizer==3.4.0 click==8.1.7 -cryptography==42.0.5 -ecdsa==0.18.0 -flask-jwt-oidc==0.3.0 +cryptography==44.0.0 +ecdsa==0.19.0 +flask-jwt-oidc==0.7.0 flask-marshmallow==1.2.1 flask-restx==1.3.0 -formsflow_api_utils @ git+https://github.com/AOT-Technologies/forms-flow-ai.git@release/6.0.2#subdirectory=forms-flow-api-utils -gunicorn==21.2.0 +formsflow_api_utils @ git+https://github.com/AOT-Technologies/forms-flow-ai.git@release/7.0.0#subdirectory=forms-flow-api-utils +gunicorn==23.0.0 h11==0.14.0 h2==4.1.0 hpack==4.0.0 hyperframe==6.0.1 -idna==3.6 -importlib_resources==6.3.2 -itsdangerous==2.1.2 -jsonschema-specifications==2023.12.1 -jsonschema==4.21.1 +idna==3.10 +importlib_resources==6.4.5 +itsdangerous==2.2.0 +jsonschema-specifications==2024.10.1 +jsonschema==4.23.0 kaitaistruct==0.10 -marshmallow-sqlalchemy==1.0.0 -marshmallow==3.21.1 +marshmallow-sqlalchemy==1.1.0 +marshmallow==3.23.1 nested-lookup==0.2.25 outcome==1.3.0.post0 -packaging==24.0 -psycopg2-binary==2.9.9 -pyOpenSSL==24.1.0 -pyasn1==0.5.1 -pycparser==2.21 -pyparsing==3.1.2 +packaging==24.2 +psycopg2-binary==2.9.10 +pyOpenSSL==24.3.0 +pyasn1==0.6.1 +pycparser==2.22 +pyparsing==3.2.0 python-dotenv==1.0.1 python-jose==3.3.0 -pytz==2024.1 -redis==5.0.3 -referencing==0.34.0 -requests==2.31.0 -rpds-py==0.18.0 +pytz==2024.2 +redis==5.2.1 +referencing==0.35.1 +requests==2.32.3 +rpds-py==0.22.3 rsa==4.9 selenium-wire==5.1.0 -selenium==4.19.0 -sentry-sdk==1.43.0 -six==1.16.0 +selenium==4.25.0 +sentry-sdk==2.19.2 +six==1.17.0 sniffio==1.3.1 sortedcontainers==2.4.0 trio-websocket==0.11.1 -trio==0.25.0 -typing_extensions==4.10.0 -urllib3==2.2.1 +trio==0.27.0 +typing_extensions==4.12.2 +urllib3==2.2.3 +websocket-client==1.8.0 wsproto==1.2.0 -zstandard==0.22.0 -setuptools==69.0.2 +zstandard==0.23.0 +setuptools==75.2.0 diff --git a/forms-flow-documents/requirements/prod.txt b/forms-flow-documents/requirements/prod.txt index f10fa09282..8b626ac239 100644 --- a/forms-flow-documents/requirements/prod.txt +++ b/forms-flow-documents/requirements/prod.txt @@ -1,11 +1,11 @@ gunicorn -Flask<3 +Flask Flask-Caching Flask-Migrate Flask-Moment Flask-SQLAlchemy flask-restx -flask-jwt-oidc +flask-jwt-oidc==0.7.0 # somehow latest version is not geting pulled from pypi python-dotenv psycopg2-binary marshmallow-sqlalchemy @@ -14,7 +14,9 @@ Werkzeug sqlalchemy_utils markupsafe PyJWT -selenium +selenium==4.25.0 selenium-wire +blinker<1.8.0 # latest selenium-wire have breaking changes with blinker nested-lookup -git+https://github.com/AOT-Technologies/forms-flow-ai.git@release/6.0.2#egg=formsflow_api_utils&subdirectory=forms-flow-api-utils \ No newline at end of file +setuptools +git+https://github.com/AOT-Technologies/forms-flow-ai.git@release/7.0.0#egg=formsflow_api_utils&subdirectory=forms-flow-api-utils diff --git a/forms-flow-documents/sample.env b/forms-flow-documents/sample.env index 0454fd3982..abd2756da2 100644 --- a/forms-flow-documents/sample.env +++ b/forms-flow-documents/sample.env @@ -47,6 +47,10 @@ FORMIO_ROOT_PASSWORD=changeme CUSTOM_SUBMISSION_ENABLED=false CUSTOM_SUBMISSION_URL=http://{your-ip-address}:6212 +#Redis configuration +REDIS_URL=redis://{your-ip-address}:6379/0 +REDIS_CLUSTER=false + ##Log File Rotation Configuration for API Logs ##CONFIGURE_LOGS: Set to 'false' to disable log file rotation. Default value is true ##API_LOG_ROTATION_WHEN: Specifies the frequency of log file rotation - 'd' for days, 'h' for hours, 'm' for minutes. diff --git a/forms-flow-documents/setup.cfg b/forms-flow-documents/setup.cfg index 88e73c667a..18c7cb609a 100644 --- a/forms-flow-documents/setup.cfg +++ b/forms-flow-documents/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = formsflow_documents -version = 6.0.2 +version = 7.0.0 author = aot-technologies classifiers = Development Status :: Beta diff --git a/forms-flow-documents/src/formsflow_documents/app.py b/forms-flow-documents/src/formsflow_documents/app.py index d78f4ccf74..77119db65c 100644 --- a/forms-flow-documents/src/formsflow_documents/app.py +++ b/forms-flow-documents/src/formsflow_documents/app.py @@ -55,7 +55,7 @@ def create_app( when=os.getenv("API_LOG_ROTATION_WHEN", "d"), interval=int(os.getenv("API_LOG_ROTATION_INTERVAL", "1")), backupCount=int(os.getenv("API_LOG_BACKUP_COUNT", "7")), - configure_log_file=app.config["CONFIGURE_LOGS"] + configure_log_file=app.config["CONFIGURE_LOGS"], ) app.logger.propagate = False logging.log.propagate = False diff --git a/forms-flow-documents/src/formsflow_documents/resources/pdf.py b/forms-flow-documents/src/formsflow_documents/resources/pdf.py index ada7ad7abe..6f9b72204f 100644 --- a/forms-flow-documents/src/formsflow_documents/resources/pdf.py +++ b/forms-flow-documents/src/formsflow_documents/resources/pdf.py @@ -1,4 +1,5 @@ """API endpoints for managing form resource.""" + import string from http import HTTPStatus @@ -6,8 +7,7 @@ from flask_restx import Namespace, Resource from formsflow_api_utils.exceptions import BusinessException from formsflow_api_utils.utils import ( - CLIENT_GROUP, - REVIEWER_GROUP, + VIEW_SUBMISSIONS, auth, cors_preflight, profiletime, @@ -82,7 +82,7 @@ class FormResourceExportPdf(Resource): @staticmethod @auth.require - @auth.has_one_of_roles([REVIEWER_GROUP, CLIENT_GROUP]) + @auth.has_one_of_roles([VIEW_SUBMISSIONS]) @profiletime def post(form_id: string, submission_id: string): """PDF generation and rendering method.""" diff --git a/forms-flow-documents/src/formsflow_documents/services/pdf.py b/forms-flow-documents/src/formsflow_documents/services/pdf.py index e6901c1753..1fc8791ddf 100644 --- a/forms-flow-documents/src/formsflow_documents/services/pdf.py +++ b/forms-flow-documents/src/formsflow_documents/services/pdf.py @@ -1,4 +1,5 @@ """Helper module for PDF export.""" + import json import os import urllib.parse @@ -70,24 +71,42 @@ def __get_submission_data(self) -> Any: def __get_form_data(self) -> Any: """Returns the form data from formio.""" - return self.formio.get_form( - {"form_id": self.form_id}, self.__get_formio_access_token() + return self.formio.get_form_by_id( + self.form_id, self.__get_formio_access_token() ) def __get_chrome_driver_path(self) -> str: """Returns the configured chrome driver path.""" return self.__chrome_driver_path + def __get_headers(self, token): + """Returns the headers.""" + return {"Authorization": token, "content-type": "application/json"} + + def __fetch_custom_submission_data(self, token: str) -> Any: + """Returns the submission data from form adapter.""" + sub_url = self.__get_custom_submission_url() + submission_url = ( + f"{sub_url}/form/" + self.form_id + "/submission/" + self.submission_id + ) + current_app.logger.debug(f"Fetching custom submission data..{submission_url}") + headers = self.__get_headers(token) + response = requests.get(submission_url, headers=headers, timeout=HTTP_TIMEOUT) + current_app.logger.debug( + f"Custom submission response code: {response.status_code}" + ) + data = {} + if response.status_code == 200: + data = response.json() + return data + def __get_form_and_submission_urls(self, token: str) -> Tuple[str, str, str]: - """Returns the appropriate form and submission url based on the config.""" + """Returns the appropriate form url and submission data based on the config.""" form_io_url = self.__get_formio_url() + current_app.logger.debug("Fetching form and submission data..") if self.__is_form_adapter(): - sub_url = self.__get_custom_submission_url() form_url = form_io_url + "/form/" + self.form_id - submission_url = ( - sub_url + "/form/" + self.form_id + "/submission/" + self.submission_id - ) - auth_token = token + submission_data = self.__fetch_custom_submission_data(token) else: form_url = ( form_io_url @@ -96,25 +115,21 @@ def __get_form_and_submission_urls(self, token: str) -> Tuple[str, str, str]: + "/submission/" + self.submission_id ) - submission_url = None - auth_token = None - return (form_url, submission_url, auth_token) + submission_data = None + return (form_url, submission_data) def __get_template_params(self, token: str) -> dict: """Returns the jinja template parameters for pdf export with formio renderer.""" form_io_url = self.__get_formio_url() - (form_url, submission_url, auth_token) = self.__get_form_and_submission_urls( - token - ) + (form_url, submission_data) = self.__get_form_and_submission_urls(token) return { "form": { "base_url": form_io_url, "project_url": form_io_url, "form_url": form_url, "token": self.__get_formio_access_token(), - "submission_url": submission_url, "form_adapter": self.__is_form_adapter(), - "auth_token": auth_token, + "submission_data": submission_data, } } @@ -237,7 +252,11 @@ def get_render_data( if template_variable_name: return self.__read_json(template_variable_name) - submission_data = self.__get_submission_data() + submission_data = ( + self.__fetch_custom_submission_data(token) + if self.__is_form_adapter() + else self.__get_submission_data() + ) form_data = self.__get_form_data() return self.__get_formatted_data(form_data, submission_data) diff --git a/forms-flow-documents/src/formsflow_documents/static/js/from_io_render.js b/forms-flow-documents/src/formsflow_documents/static/js/from_io_render.js index a042038d56..b0838ef81c 100644 --- a/forms-flow-documents/src/formsflow_documents/static/js/from_io_render.js +++ b/forms-flow-documents/src/formsflow_documents/static/js/from_io_render.js @@ -1,16 +1,4 @@ const form_options = { readOnly: true, renderMode: "flat" }; -// Function to get submission data if formadapter is enabled -async function fetchSubmission() { - const submission = await fetch(form_info.submission_url); - // const submission = await fetch(form_info.submission_url, { - // headers: { - // "Content-Type": "application/json", - // Authorization: form_info.auth_token, - // }, - // }); - const result = await submission.json(); - return result; -} // Help web driver to idetify the form rendered completely. function formReady() { @@ -24,12 +12,10 @@ function renderFormWithSubmission() { form_info.form_url, form_options ).then((form) => { - fetchSubmission().then((submission) => { - form.submission = submission; + form.submission = form_info.submission_data; form.ready.then(() => { formReady(); }); - }); }); } diff --git a/forms-flow-documents/src/formsflow_documents/utils/constants.py b/forms-flow-documents/src/formsflow_documents/utils/constants.py index fee5bd48d2..3ae00e690d 100644 --- a/forms-flow-documents/src/formsflow_documents/utils/constants.py +++ b/forms-flow-documents/src/formsflow_documents/utils/constants.py @@ -2,6 +2,7 @@ Constants file needed for the static values. """ + from enum import Enum from http import HTTPStatus diff --git a/forms-flow-documents/src/formsflow_documents/utils/util.py b/forms-flow-documents/src/formsflow_documents/utils/util.py index a6fac0088c..1e4db1e12f 100644 --- a/forms-flow-documents/src/formsflow_documents/utils/util.py +++ b/forms-flow-documents/src/formsflow_documents/utils/util.py @@ -1,4 +1,5 @@ """Utility module for Document generation.""" + import base64 import urllib.parse diff --git a/forms-flow-documents/tests/conftest.py b/forms-flow-documents/tests/conftest.py index d0843d791a..ac882c9a22 100644 --- a/forms-flow-documents/tests/conftest.py +++ b/forms-flow-documents/tests/conftest.py @@ -1,4 +1,6 @@ """Common setup and fixtures for the pytest suite used by this service.""" +import time + import pytest from unittest.mock import patch from formsflow_api_utils.utils import jwt as _jwt @@ -67,6 +69,7 @@ def auto(docker_services, app): docker_services.start("forms") docker_services.start("proxy") + time.sleep(15) @pytest.fixture(scope="session") diff --git a/forms-flow-documents/tests/unit/services/test_pdf.py b/forms-flow-documents/tests/unit/services/test_pdf.py index 6b0e6b1058..0eef347e20 100644 --- a/forms-flow-documents/tests/unit/services/test_pdf.py +++ b/forms-flow-documents/tests/unit/services/test_pdf.py @@ -24,9 +24,8 @@ def test_get_render_data_without_template_and_template_variable(self, app, mock_ assert "project_url" in render_data["form"] assert "form_url" in render_data["form"] assert "token" in render_data["form"] - assert "submission_url" in render_data["form"] + assert "submission_data" in render_data["form"] assert "form_adapter" in render_data["form"] - assert "auth_token" in render_data["form"] def test_get_render_data_with_template_and_template_variable(self, app): """Test get_render_data method for the request with template and template variables.""" diff --git a/forms-flow-documents/tests/utilities/base_test.py b/forms-flow-documents/tests/utilities/base_test.py index 7f28954d63..19175e7a6c 100644 --- a/forms-flow-documents/tests/utilities/base_test.py +++ b/forms-flow-documents/tests/utilities/base_test.py @@ -4,6 +4,7 @@ from dotenv import find_dotenv, load_dotenv from flask import current_app +from formsflow_api_utils.utils import VIEW_SUBMISSIONS load_dotenv(find_dotenv()) @@ -12,7 +13,7 @@ def get_token( jwt, - role: str = "formsflow-client", + role: str = VIEW_SUBMISSIONS, username: str = "client", roles: list = [], tenant_key: str = None, diff --git a/forms-flow-forms/docker-compose.yml b/forms-flow-forms/docker-compose.yml index 95c9664261..b8cb129b6b 100644 --- a/forms-flow-forms/docker-compose.yml +++ b/forms-flow-forms/docker-compose.yml @@ -36,7 +36,7 @@ services: forms-flow-forms: container_name: forms-flow-forms - image: formsflow/forms-flow-forms:v6.0.0 + image: formsflow/forms-flow-forms:v7.0.0 # The app will restart until Mongo is listening restart: always links: diff --git a/forms-flow-idm/README.md b/forms-flow-idm/README.md index 95a7983629..79df6e3d6b 100644 --- a/forms-flow-idm/README.md +++ b/forms-flow-idm/README.md @@ -2,11 +2,16 @@ The **formsflow.ai** framework could be hooked up with any OpenID Connect compliant Identity Management Server. To date, we have only tested [Keycloak](https://github.com/keycloak/keycloak) ## Table of Contents -* [Authentication](#authentication) -* [Authorization](#authorization) - * [User Roles](#user-roles) - * [User Groups](#user-groups) -* [Keycloak Setup](#keycloak-setup) +- [Identity Management](#identity-management) + - [Table of Contents](#table-of-contents) + - [Authentication](#authentication) + - [Authorization](#authorization) + - [User Roles](#user-roles) + - [User Groups](#user-groups) + - [Keycloak Setup](#keycloak-setup) + - [Migration](#migration) + - [v7.0.0 (Permission Matrix)](#v700-permission-matrix) + - [For migrating default roles (formsflow-client, formsflow-designer, formsflow-reviewer) to use permission matrix introduced in v7.0.0 follow the steps mentioned here](#for-migrating-default-roles-formsflow-client-formsflow-designer-formsflow-reviewer-to-use-permission-matrix-introduced-in-v700-follow-the-steps-mentioned-here) ## Authentication All the resources in the formsflow.ai solution require authentication i.e. users must be a member of a realm. @@ -44,14 +49,19 @@ There are two groups Group | Sub Group | Roles | Description | --- | --- | --- | --- `camunda-admin`| | |Able to administer Camunda directly and create new workflows -`formsflow`|`formsflow-designer` |formsflow-bpm|Able to design forms and publish for use. -`formsflow`|`formsflow-reviewer` |formsflow-bpm|Able to access applications, tasks, metrics and Insight of formsflow UI -`formsflow`|`formsflow-client` |formsflow-client|Able to access form fill-in only +`formsflow`|`formsflow-designer` |`view_designs`,`create_designs`,`manage_integrations`|Able to design forms and publish for use. +`formsflow`|`formsflow-reviewer` |`manage_tasks`, `view_tasks`, `create_filters`, `view_dashboards`, `view_filters`, `view_submissions`|Able to access applications, tasks, metrics and Insight of formsflow UI +`formsflow`|`formsflow-client` |`create_submissions`, `view_submissions`|Able to access form fill-in only * Please note, it is possible to assign a user to multiple groups say `formsflow-designer` and `formsflow-reviewer`, in order to provide access to both designer and staff behavior. * Also, based on the workflow process `user task` candidate groups; new groups (main or sub group of `formsflow-reviewer`) can be created in keycloak. * In case of creating the candidate group as main group; ensure to add the role `formsflow-reviewer` role to it. -Keycloak Setup +## Keycloak Setup ---------- [Instructions for Keycloak setup](./keycloak/README.md) + +## Migration +### v7.0.0 (Permission Matrix) +For migrating default roles (formsflow-client, formsflow-designer, formsflow-reviewer) to use permission matrix introduced in v7.0.0 follow the steps mentioned [here](./migration/README.md) +- diff --git a/forms-flow-idm/keycloak/Dockerfile b/forms-flow-idm/keycloak/Dockerfile index cf90a5ed79..ef1b392671 100644 --- a/forms-flow-idm/keycloak/Dockerfile +++ b/forms-flow-idm/keycloak/Dockerfile @@ -1,5 +1,17 @@ -FROM jboss/keycloak +FROM maven:3.8.7-openjdk-18-slim AS builder -RUN sed -i -E "s/()2592000(<\/staticMaxAge>)/\1\-1\2/" /opt/jboss/keycloak/standalone/configuration/standalone.xml -RUN sed -i -E "s/()true(<\/cacheThemes>)/\1false\2/" /opt/jboss/keycloak/standalone/configuration/standalone.xml -RUN sed -i -E "s/()true(<\/cacheTemplates>)/\1false\2/" /opt/jboss/keycloak/standalone/configuration/standalone.xml \ No newline at end of file +WORKDIR /build + +COPY idp-selector/pom.xml idp-selector/ +COPY idp-selector/src idp-selector/src/ + +RUN mvn -f idp-selector/pom.xml clean package + +FROM alpine:latest + +WORKDIR /custom + +COPY --from=builder /build/idp-selector/target/*.jar /custom/providers/ +COPY ./themes /custom/themes +COPY ./imports /custom/imports +COPY ./start-keycloak.sh /custom/start-keycloak.sh diff --git a/forms-flow-idm/keycloak/docker-compose.yml b/forms-flow-idm/keycloak/docker-compose.yml index c92869052d..462f574782 100644 --- a/forms-flow-idm/keycloak/docker-compose.yml +++ b/forms-flow-idm/keycloak/docker-compose.yml @@ -3,6 +3,7 @@ version: "3.7" volumes: postgres: + keycloak_custom_data: networks: keycloak-server-network: @@ -20,35 +21,44 @@ services: POSTGRES_USER: ${KEYCLOAK_JDBC_USER:-admin} POSTGRES_PASSWORD: ${KEYCLOAK_JDBC_PASSWORD:-changeme} ports: - - 5431:5431 + - 5431:5432 networks: - keycloak-server-network keycloak: - image: quay.io/keycloak/keycloak:23.0.7 + image: quay.io/keycloak/keycloak:25.0.4 restart: unless-stopped container_name: keycloak volumes: - - ./imports:/opt/keycloak/data/import - - ./themes/formsflow:/opt/keycloak/themes/formsflow - - ./start-keycloak.sh:/opt/keycloak/bin/start-keycloak.sh - entrypoint: ["/bin/bash", "/opt/keycloak/bin/start-keycloak.sh"] + - keycloak_custom_data:/keycloak_custom_data + entrypoint: ["/bin/bash", "-c", "/keycloak_custom_data/start-keycloak.sh"] environment: - - DB_VENDOR=POSTGRES - - DB_ADDR=keycloak-db - - DB_PORT=5432 - - DB_DATABASE=${KEYCLOAK_JDBC_DB:-keycloak} - - DB_USER=${KEYCLOAK_JDBC_USER-admin} - - DB_PASSWORD=${KEYCLOAK_JDBC_PASSWORD:-changeme} + - KC_DB=postgres + - KC_DB_URL_HOST=keycloak-db + - KC_DB_URL_PORT=5432 + - KC_DB_URL_DATABASE=${KEYCLOAK_JDBC_DB:-keycloak} + - KC_DB_USERNAME=${KEYCLOAK_JDBC_USER:-admin} + - KC_DB_PASSWORD=${KEYCLOAK_JDBC_PASSWORD:-changeme} - KEYCLOAK_ADMIN=${KEYCLOAK_ADMIN_USER:-admin} - KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD:-changeme} - KEYCLOAK_START_MODE=${KEYCLOAK_START_MODE:-start-dev} - KEYCLOAK_HTTP_PATH=${KEYCLOAK_HTTP_PATH:-/auth} - ports: - "8080:8080" - links: + depends_on: - keycloak-db + - keycloak-customizations networks: - keycloak-server-network - + + keycloak-customizations: + build: + context: . + dockerfile: Dockerfile + volumes: + - keycloak_custom_data:/custom + command: /bin/sh + tty: true + stdin_open: true + networks: + - keycloak-server-network \ No newline at end of file diff --git a/forms-flow-idm/keycloak/idp-selector/.gitignore b/forms-flow-idm/keycloak/idp-selector/.gitignore new file mode 100644 index 0000000000..b83d22266a --- /dev/null +++ b/forms-flow-idm/keycloak/idp-selector/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/forms-flow-idm/keycloak/idp-selector/pom.xml b/forms-flow-idm/keycloak/idp-selector/pom.xml new file mode 100644 index 0000000000..9f9531a9ff --- /dev/null +++ b/forms-flow-idm/keycloak/idp-selector/pom.xml @@ -0,0 +1,70 @@ + + 4.0.0 + formsflow.ai + idp-selector + jar + 1.0.0 + idp-selector + http://maven.apache.org + + 25.0.4 + + + + org.keycloak + keycloak-server-spi + ${keycloak.version} + + + org.keycloak + keycloak-server-spi-private + ${keycloak.version} + + + org.keycloak + keycloak-common + ${keycloak.version} + + + org.keycloak + keycloak-core + ${keycloak.version} + + + org.keycloak + keycloak-services + ${keycloak.version} + + + org.slf4j + slf4j-api + 1.7.32 + + + ch.qos.logback + logback-classic + 1.2.13 + + + junit + junit + 3.8.1 + test + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 17 + 17 + + + + + diff --git a/forms-flow-idm/keycloak/idp-selector/src/main/java/com/formsflow/idm/authenticator/ConfigurableIdpAuthenticator.java b/forms-flow-idm/keycloak/idp-selector/src/main/java/com/formsflow/idm/authenticator/ConfigurableIdpAuthenticator.java new file mode 100644 index 0000000000..b8a2a01878 --- /dev/null +++ b/forms-flow-idm/keycloak/idp-selector/src/main/java/com/formsflow/idm/authenticator/ConfigurableIdpAuthenticator.java @@ -0,0 +1,100 @@ +package com.formsflow.idm.authenticator; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.Authenticator; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * IDP Authenticator used to selectively display Identity Providers based on the client scopes. + */ +public class ConfigurableIdpAuthenticator implements Authenticator { + + private static final Logger logger = LoggerFactory.getLogger(ConfigurableIdpAuthenticator.class); + + /** + * Set the identity providers based on the client scope + * 1 : Get the default client scopes for the client which initiated the authentication + * 2 : Find out the IDPs configured for the client using the scope which starts with idp_ + * 3 : Filter the provider models using the scoped idps and set ito the attribute + */ + @Override + public void authenticate(AuthenticationFlowContext context) { + AuthenticatorConfigModel config = context.getAuthenticatorConfig(); + logger.info("Inside ConfigurableIdpAuthenticator --> authenticate"); + + List idpScopes = new ArrayList(); + List identityProviders = new ArrayList(); + + logger.info("context.getAuthenticationSession().getClient().getAttributes() {}", + context.getAuthenticationSession().getClient().getClientScopes(true)); + + + for (String key : context.getAuthenticationSession().getClient().getClientScopes(true).keySet()) { + if (key.startsWith("idp_")) { + idpScopes.add(key.replaceFirst("idp_", "")); + } + } + + logger.info("Inside ConfigurableIdpAuthenticator --> idpScopes: {}", idpScopes); + + if (!idpScopes.isEmpty()) { + // Filter the IDPs based on the configuration + identityProviders = context.getRealm().getIdentityProvidersStream() + .filter(idp -> idpScopes.contains(idp.getAlias())).collect(Collectors.toList()); + + logger.info("Inside ConfigurableIdpAuthenticator --> identityProviders: {}", identityProviders); + + } + + for (IdentityProviderModel idpModel : identityProviders) { + logger.info("IdentityProviderModel: {}", idpModel); + logger.info("idpModel.getDisplayName: {}", idpModel.getDisplayName()); + logger.info("idpModel.getAlias: {}", idpModel.getAlias()); + logger.info("idpModel.getInternalId: {}", idpModel.getInternalId()); + logger.info("idpModel.getProviderId: {}", idpModel.getProviderId()); + } + context.form().setAttribute("numberOfIdps", identityProviders.size()); + context.challenge( + context.form().setAttribute("identityProviders", identityProviders).createLoginUsernamePassword()); + + } + + @Override + public void action(AuthenticationFlowContext context) { + String selectedIdp = context.getHttpRequest().getDecodedFormParameters().getFirst("selectedIdp"); + if (selectedIdp != null) { + context.getAuthenticationSession().setClientNote("selectedIdp", selectedIdp); + context.getAuthenticationSession().setAuthNote("selectedIdp", selectedIdp); + context.success(); + } + + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + } + + @Override + public void close() { + } +} diff --git a/forms-flow-idm/keycloak/idp-selector/src/main/java/com/formsflow/idm/authenticator/ConfigurableIdpAuthenticatorFactory.java b/forms-flow-idm/keycloak/idp-selector/src/main/java/com/formsflow/idm/authenticator/ConfigurableIdpAuthenticatorFactory.java new file mode 100644 index 0000000000..87084b4b21 --- /dev/null +++ b/forms-flow-idm/keycloak/idp-selector/src/main/java/com/formsflow/idm/authenticator/ConfigurableIdpAuthenticatorFactory.java @@ -0,0 +1,87 @@ +package com.formsflow.idm.authenticator; + +import static org.keycloak.provider.ProviderConfigProperty.STRING_TYPE; + +import java.util.Collections; +import java.util.List; + +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +public class ConfigurableIdpAuthenticatorFactory implements AuthenticatorFactory { + + public static final String ID = "configurable-idp-authenticator"; + + private static final Authenticator AUTHENTICATOR_INSTANCE = new ConfigurableIdpAuthenticator(); + + static final String IDP_LIST = "idpList"; + + @Override + public Authenticator create(KeycloakSession keycloakSession) { + return AUTHENTICATOR_INSTANCE; + } + + @Override + public String getDisplayType() { + return "Custom IDP Selector"; + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return new AuthenticationExecutionModel.Requirement[] { AuthenticationExecutionModel.Requirement.REQUIRED }; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public String getHelpText() { + return "formsflow.ai addon to limit the IDPs which can be used for the client application"; + } + + @Override + public List getConfigProperties() { + ProviderConfigProperty name = new ProviderConfigProperty(); + + name.setType(STRING_TYPE); + name.setName(IDP_LIST); + name.setLabel("Comma-separated list of IDP aliases to display"); + name.setHelpText("Comma-separated list of IDP aliases to display"); + + return Collections.singletonList(name); + } + + @Override + public String getReferenceCategory() { + return null; + } + + @Override + public void init(org.keycloak.Config.Scope scope) { + } + + @Override + public void postInit(KeycloakSessionFactory keycloakSessionFactory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return ID; + } + +} diff --git a/forms-flow-idm/keycloak/idp-selector/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/forms-flow-idm/keycloak/idp-selector/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory new file mode 100644 index 0000000000..27de0a9b21 --- /dev/null +++ b/forms-flow-idm/keycloak/idp-selector/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -0,0 +1 @@ +com.formsflow.idm.authenticator.ConfigurableIdpAuthenticatorFactory \ No newline at end of file diff --git a/forms-flow-idm/keycloak/imports/formsflow-ai-realm.json b/forms-flow-idm/keycloak/imports/formsflow-ai-realm.json index 60661938b7..5bbde92dfd 100644 --- a/forms-flow-idm/keycloak/imports/formsflow-ai-realm.json +++ b/forms-flow-idm/keycloak/imports/formsflow-ai-realm.json @@ -1,945 +1,493 @@ { "id": "forms-flow-ai", "realm": "forms-flow-ai", - "notBefore": 0, - "revokeRefreshToken": false, - "refreshTokenMaxReuse": 0, - "accessTokenLifespan": 300, - "accessTokenLifespanForImplicitFlow": 900, - "ssoSessionIdleTimeout": 1800, - "ssoSessionMaxLifespan": 36000, - "ssoSessionIdleTimeoutRememberMe": 0, - "ssoSessionMaxLifespanRememberMe": 0, - "offlineSessionIdleTimeout": 2592000, - "offlineSessionMaxLifespanEnabled": false, - "offlineSessionMaxLifespan": 5184000, - "clientSessionIdleTimeout": 0, - "clientSessionMaxLifespan": 0, - "clientOfflineSessionIdleTimeout": 0, - "clientOfflineSessionMaxLifespan": 0, - "accessCodeLifespan": 60, - "accessCodeLifespanUserAction": 300, - "accessCodeLifespanLogin": 1800, - "actionTokenGeneratedByAdminLifespan": 43200, - "actionTokenGeneratedByUserLifespan": 300, + "loginTheme": "formsflow", "enabled": true, - "sslRequired": "external", - "registrationAllowed": false, - "registrationEmailAsUsername": false, - "rememberMe": false, - "verifyEmail": false, - "loginWithEmailAllowed": true, - "duplicateEmailsAllowed": false, - "resetPasswordAllowed": false, - "editUsernameAllowed": false, - "bruteForceProtected": false, + "registrationAllowed": true, + "bruteForceProtected": true, "permanentLockout": false, + "maxTemporaryLockouts": 0, "maxFailureWaitSeconds": 900, "minimumQuickLoginWaitSeconds": 60, "waitIncrementSeconds": 60, "quickLoginCheckMilliSeconds": 1000, - "maxDeltaTimeSeconds": 43200, - "failureFactor": 30, + "maxDeltaTimeSeconds": 1800, + "failureFactor": 5, "roles": { - "realm": [ - { - "name": "offline_access", - "description": "${role_offline-access}", - "composite": false, - "clientRole": false, - "containerId": "forms-flow-ai", - "attributes": {} - }, - { - "name": "uma_authorization", - "description": "${role_uma_authorization}", - "composite": false, - "clientRole": false, - "containerId": "forms-flow-ai", - "attributes": {} - } - ], "client": { - "realm-management": [ + "forms-flow-web": [ { - "name": "query-groups", - "description": "${role_query-groups}", + "name": "manage_users", + "description": "Manage Users", "composite": false, "clientRole": true, - "attributes": {} }, { - "name": "query-clients", - "description": "${role_query-clients}", + "name": "view_designs", + "description": "Access to designs", "composite": false, "clientRole": true, - "attributes": {} }, { - "name": "view-events", - "description": "${role_view-events}", + "name": "create_designs", + "description": "Design layout and flow", "composite": false, "clientRole": true, - "attributes": {} }, { - "name": "impersonation", - "description": "${role_impersonation}", + "name": "view_filters", + "description": "Access to view filters", "composite": false, "clientRole": true, - "attributes": {} }, { - "name": "manage-events", - "description": "${role_manage-events}", + "name": "manage_roles", + "description": "Manage Roles", "composite": false, "clientRole": true, - "attributes": {} }, { - "name": "query-realms", - "description": "${role_query-realms}", + "name": "manage_integrations", + "description": "Access to Integrations", "composite": false, "clientRole": true, - "attributes": {} }, { - "name": "query-users", - "description": "${role_query-users}", + "name": "view_dashboards", + "description": "Access to dashboards", "composite": false, "clientRole": true, - "attributes": {} }, { - "name": "create-client", - "description": "${role_create-client}", + "name": "manage_tasks", + "description": "Can assign, re-assign and work on tasks", "composite": false, "clientRole": true, - - "attributes": {} - }, - { - "name": "view-clients", - "description": "${role_view-clients}", - "composite": true, - "composites": { - "client": { - "realm-management": [ - "query-clients" - ] - } - }, - "clientRole": true, - "attributes": {} }, { - "name": "manage-authorization", - "description": "${role_manage-authorization}", + "name": "create_submissions", + "description": "Create submissions", "composite": false, "clientRole": true, - "attributes": {} }, { - "name": "view-identity-providers", - "description": "${role_view-identity-providers}", + "name": "create_filters", + "description": "Access to create filters", "composite": false, "clientRole": true, - - "attributes": {} - }, - { - "name": "view-users", - "description": "${role_view-users}", - "composite": true, - "composites": { - "client": { - "realm-management": [ - "query-users", - "query-groups" - ] - } - }, - "clientRole": true, - - "attributes": {} - }, - { - "name": "realm-admin", - "description": "${role_realm-admin}", - "composite": true, - "composites": { - "client": { - "realm-management": [ - "query-groups", - "query-clients", - "view-events", - "impersonation", - "manage-events", - "query-realms", - "query-users", - "create-client", - "view-clients", - "manage-authorization", - "view-identity-providers", - "view-users", - "view-realm", - "view-authorization", - "manage-identity-providers", - "manage-users", - "manage-realm", - "manage-clients" - ] - } - }, - "clientRole": true, - "attributes": {} }, { - "name": "view-realm", - "description": "${role_view-realm}", + "name": "view_tasks", + "description": "Access to tasks", "composite": false, "clientRole": true, - "attributes": {} }, { - "name": "view-authorization", - "description": "${role_view-authorization}", + "name": "manage_dashboard_authorizations", + "description": "Manage Dashboard Authorization", "composite": false, "clientRole": true, - "attributes": {} }, { - "name": "manage-identity-providers", - "description": "${role_manage-identity-providers}", + "name": "view_submissions", + "description": "Access to submissions", "composite": false, "clientRole": true, - "attributes": {} }, { - "name": "manage-users", - "description": "${role_manage-users}", + "name": "admin", + "description": "Administrator Role", "composite": false, "clientRole": true, "attributes": {} }, { - "name": "manage-realm", - "description": "${role_manage-realm}", + "name": "manage_all_filters", + "description": "Manage all filters", "composite": false, "clientRole": true, - "attributes": {} }, { - "name": "manage-clients", - "description": "${role_manage-clients}", - "composite": false, - "clientRole": true, - - "attributes": {} - } - ], - "forms-flow-web": [ - { - "name": "formsflow-client", - "description": "Provides access to use the formsflow.ai solution. Required to access and submit forms.", + "name": "create_bpmn_flows", + "description": "Access to BPMN workflows", "composite": false, "clientRole": true, - "attributes": {} }, { - "name": "formsflow-designer", - "description": "Provides access to use the formsflow.ai solution. Access to wok on form designer studio.", + "name": "manage_subflows", + "description": "Access to Subflows", "composite": false, "clientRole": true, - "attributes": {} }, { - "name": "formsflow-reviewer", - "description": "Provides access to use the formsflow.ai solution. Identifies the staff to work on applications and forms submissions.", + "name": "manage_decision_tables", + "description": "Access to Decision Tables", "composite": false, "clientRole": true, - "attributes": {} } ], - "security-admin-console": [], - "admin-cli": [], "forms-flow-bpm": [], - "account-console": [], - "broker": [ - { - "name": "read-token", - "description": "${role_read-token}", - "composite": false, - "clientRole": true, - - "attributes": {} - } - ], - "forms-flow-analytics": [], - "account": [ - { - "name": "view-profile", - "description": "${role_view-profile}", - "composite": false, - "clientRole": true, - - "attributes": {} - }, - { - "name": "manage-account", - "description": "${role_manage-account}", - "composite": true, - "composites": { - "client": { - "account": [ - "manage-account-links" - ] - } - }, - "clientRole": true, - - "attributes": {} - }, - { - "name": "manage-account-links", - "description": "${role_manage-account-links}", - "composite": false, - "clientRole": true, - - "attributes": {} - }, - { - "name": "view-consent", - "description": "${role_view-consent}", - "composite": false, - "clientRole": true, - - "attributes": {} - }, - { - "name": "manage-consent", - "description": "${role_manage-consent}", - "composite": true, - "composites": { - "client": { - "account": [ - "view-consent" - ] - } - }, - "clientRole": true, - - "attributes": {} - }, - { - "name": "view-applications", - "description": "${role_view-applications}", - "composite": false, - "clientRole": true, - - "attributes": {} - } - ] + "forms-flow-analytics": [] } }, "groups": [ { "name": "camunda-admin", "path": "/camunda-admin", - "attributes": {}, + "subGroups": [], + "attributes": { + "description": ["Camunda Administrator Role."] + }, "realmRoles": [], - "clientRoles": {}, - "subGroups": [] + "clientRoles": {} }, { "name": "formsflow", "path": "/formsflow", - "attributes": {}, - "realmRoles": [], - "clientRoles": {}, "subGroups": [ { - "name": "formsflow-client", - "path": "/formsflow/formsflow-client", - "attributes": {}, + "name": "formsflow-admin", + "path": "/formsflow/formsflow-admin", + "subGroups": [], + "attributes": { + "description": ["Administrator Role."] + }, "realmRoles": [], "clientRoles": { + "realm-management": [ + "query-groups", + "view-users", + "manage-clients", + "query-clients", + "view-authorization", + "query-users", + "manage-users", + "create-client", + "view-clients" + ], "forms-flow-web": [ - "formsflow-client" + "manage_users", + "manage_roles", + "manage_dashboard_authorizations", + "admin" ] + } + }, + { + "name": "formsflow-client", + "path": "/formsflow/formsflow-client", + "subGroups": [], + "attributes": { + "description": ["Client role to create & view submissions."] }, - "subGroups": [] + "realmRoles": [], + "clientRoles": { + "forms-flow-web": ["create_submissions", "view_submissions"] + } }, { "name": "formsflow-designer", "path": "/formsflow/formsflow-designer", - "attributes": {}, + "subGroups": [], + "attributes": { + "description": ["Designer role to create forms and workflows."] + }, "realmRoles": [], "clientRoles": { "forms-flow-web": [ - "formsflow-designer" + "manage_integrations", + "view_designs", + "create_designs", + "create_bpmn_flows", + "manage_subflows", + "manage_decision_tables" ] - }, - "subGroups": [] + } }, { "name": "formsflow-reviewer", "path": "/formsflow/formsflow-reviewer", - "attributes": {}, + "subGroups": [ + { + "name": "approver", + "path": "/formsflow/formsflow-reviewer/approver", + "subGroups": [], + "attributes": { + "description": ["Staff role for reviewing tasks."] + }, + "realmRoles": [], + "clientRoles": { + "forms-flow-web": ["manage_tasks", "view_tasks", "view_filters"] + } + }, + { + "name": "clerk", + "path": "/formsflow/formsflow-reviewer/clerk", + "subGroups": [], + "attributes": { + "description": ["Staff role for reviewing tasks."] + }, + "realmRoles": [], + "clientRoles": { + "forms-flow-web": ["manage_tasks", "view_tasks", "view_filters"] + } + } + ], + "attributes": { + "description": [ + "Staff role for monitoring submissions and performing tasks." + ] + }, "realmRoles": [], "clientRoles": { "forms-flow-web": [ - "formsflow-reviewer" + "view_dashboards", + "manage_tasks", + "view_tasks", + "create_filters", + "view_submissions", + "view_filters" ] - }, - "subGroups": [ - { - "name": "clerk", - "path": "/formsflow/formsflow-reviewer/clerk", - "attributes": {}, - "realmRoles": [], - "clientRoles": {}, - "subGroups": [] - }, - { - "name": "approver", - "path": "/formsflow/formsflow-reviewer/approver", - "attributes": {}, - "realmRoles": [], - "clientRoles": {}, - "subGroups": [] - } - ] + } } - ] + ], + "attributes": {}, + "realmRoles": [], + "clientRoles": {} }, { "name": "formsflow-analytics", "path": "/formsflow-analytics", - "attributes": {}, - "realmRoles": [], - "clientRoles": {}, "subGroups": [ { - "name": "group2", - "path": "/formsflow-analytics/group2", - "attributes": {}, + "name": "group1", + "path": "/formsflow-analytics/group1", + "subGroups": [], + "attributes": { + "description": ["Role with dashboard authorization."] + }, "realmRoles": [], - "clientRoles": {}, - "subGroups": [] + "clientRoles": { + "forms-flow-web": ["view_dashboards"] + } }, { - "name": "group1", - "path": "/formsflow-analytics/group1", - "attributes": {}, + "name": "group2", + "path": "/formsflow-analytics/group2", + "subGroups": [], + "attributes": { + "description": ["Role with dashboard authorization."] + }, "realmRoles": [], - "clientRoles": {}, - "subGroups": [] + "clientRoles": { + "forms-flow-web": ["view_dashboards"] + } } - ] + ], + "attributes": { + "description": ["Role with dashboard authorization."] + }, + "realmRoles": [], + "clientRoles": { + "forms-flow-web": ["view_dashboards"] + } } ], - "defaultRoles": [ - "offline_access", - "uma_authorization" - ], - "defaultGroups": [ - "/camunda-admin", - "/formsflow", - "/formsflow-analytics" - ], - "requiredCredentials": [ - "password" - ], - "otpPolicyType": "totp", - "otpPolicyAlgorithm": "HmacSHA1", - "otpPolicyInitialCounter": 0, - "otpPolicyDigits": 6, - "otpPolicyLookAheadWindow": 1, - "otpPolicyPeriod": 30, - "otpSupportedApplications": [ - "FreeOTP", - "Google Authenticator" - ], - "webAuthnPolicyRpEntityName": "keycloak", - "webAuthnPolicySignatureAlgorithms": [ - "ES256" - ], - "webAuthnPolicyRpId": "", - "webAuthnPolicyAttestationConveyancePreference": "not specified", - "webAuthnPolicyAuthenticatorAttachment": "not specified", - "webAuthnPolicyRequireResidentKey": "not specified", - "webAuthnPolicyUserVerificationRequirement": "not specified", - "webAuthnPolicyCreateTimeout": 0, - "webAuthnPolicyAvoidSameAuthenticatorRegister": false, - "webAuthnPolicyAcceptableAaguids": [], - "webAuthnPolicyPasswordlessRpEntityName": "keycloak", - "webAuthnPolicyPasswordlessSignatureAlgorithms": [ - "ES256" - ], - "webAuthnPolicyPasswordlessRpId": "", - "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", - "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", - "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", - "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", - "webAuthnPolicyPasswordlessCreateTimeout": 0, - "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, - "webAuthnPolicyPasswordlessAcceptableAaguids": [], - "users" : [ { - "createdTimestamp" : 1621862607660, - "username" : "formsflow-client", - "enabled" : true, - "totp" : false, - "emailVerified" : false, - "firstName" : "Client", - "lastName" : "FFA", - "email" : "formsflow-client@example.com", - "credentials" : [ { - "type" : "password", - "createdDate" : 1621863987325, - "secretData" : "{\"value\":\"9R9a8Onha7JcZt59SIq8ngfqwDJwPKiKb8mJ2WO6p2eI3S9qhzR1GPDFtKjOWq8qpm8vsGfp/a/DyHWQuIvmlA==\",\"salt\":\"R/OTBeSXHzsKtJOV/bufEA==\"}", - "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\"}" - } ], - "disableableCredentialTypes" : [ ], - "requiredActions" : [ "UPDATE_PASSWORD" ], - "realmRoles" : [ "uma_authorization", "offline_access" ], - "clientRoles" : { - "account" : [ "view-profile", "manage-account" ] - }, - "notBefore" : 0, - "groups" : [ "/camunda-admin", "/formsflow/formsflow-client" ] - }, { - "createdTimestamp" : 1621862546931, - "username" : "formsflow-designer", - "enabled" : true, - "totp" : false, - "emailVerified" : false, - "firstName" : "Designer", - "lastName" : "FFA", - "email" : "formsflow-designer@example.com", - "credentials" : [ { - "type" : "password", - "createdDate" : 1621864001408, - "secretData" : "{\"value\":\"XlcFXNSJAfv5YzTh5vd4NtyEjWm4B47CS9MA3aHmEjLNjdRMbnGFVFZwlZx3alXYBCg4Evs3md25DQ6Xvl+nZg==\",\"salt\":\"TefHE1L0xpqlAMg/h6w6BA==\"}", - "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\"}" - } ], - "disableableCredentialTypes" : [ ], - "requiredActions" : [ "UPDATE_PASSWORD" ], - "realmRoles" : [ "uma_authorization", "offline_access" ], - "clientRoles" : { - "account" : [ "view-profile", "manage-account" ] - }, - "notBefore" : 0, - "groups" : [ "/camunda-admin", "/formsflow/formsflow-designer" ] - }, { - "createdTimestamp" : 1625009614956, - "username" : "formsflow-approver", - "enabled" : true, - "totp" : false, - "emailVerified" : false, - "firstName" : "Approver", - "lastName" : "FFA", - "email" : "formsflow-approver@aot-technologies.com", - "credentials" : [ { - "type" : "password", - "createdDate" : 1625009625540, - "secretData" : "{\"value\":\"Ej6BGTe5D+jLChY9zmoty3Jzt8i+KoV+UTPK6+1Vi+GaUpVfdJ0RFJ/7M4+1Y1jNGBcvMgc8knQT2AJDtixxRQ==\",\"salt\":\"MDP6nouKEx0l7hdJA+lIJw==\"}", - "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\"}" - } ], - "disableableCredentialTypes" : [ ], - "requiredActions" : [ "UPDATE_PASSWORD" ], - "notBefore" : 0, - "groups" : [ "/formsflow/formsflow-reviewer/approver", "/camunda-admin", "/formsflow" , "/formsflow-analytics/group1"] - }, { - "createdTimestamp" : 1625009564217, - "username" : "formsflow-clerk", - "enabled" : true, - "totp" : false, - "emailVerified" : false, - "firstName" : "Clerk", - "lastName" : "FFA", - "email" : "formsflow-clerk@aot-technologies.com", - "credentials" : [ { - "type" : "password", - "createdDate" : 1625009575561, - "secretData" : "{\"value\":\"iWFPywh7ck8FesufjXu81lxpJ0XKSvPd9ladBcJrE4TTXLeQOhvqBOC5e+bwVg20Y61EwtkTta0L9MWtGpSraw==\",\"salt\":\"noE1kDJ7Lo20VYuTOHOBTw==\"}", - "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\"}" - } ], - "disableableCredentialTypes" : [ ], - "requiredActions" : [ "UPDATE_PASSWORD" ], - "notBefore" : 0, - "groups" : [ "/camunda-admin", "/formsflow/formsflow-reviewer/clerk", "/formsflow", "/formsflow-analytics/group2" ] - }, { - "createdTimestamp" : 1621862578318, - "username" : "formsflow-reviewer", - "enabled" : true, - "totp" : false, - "emailVerified" : false, - "firstName" : "Reviewer", - "lastName" : "FFA", - "email" : "formsflow-reviewer@example.com", - "credentials" : [ { - "type" : "password", - "createdDate" : 1621864014317, - "secretData" : "{\"value\":\"bXzhJ0BrMJBWMzRRjO2khWgCRgDAA6vfTrE0UNNO1DNRzp1aMrGCz5kF20H76PjyuqNDZaKF1nKApEjccg+KRA==\",\"salt\":\"gmoZwO3i7Y6B+jpgnsnixw==\"}", - "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\"}" - } ], - "disableableCredentialTypes" : [ ], - "requiredActions" : [ "UPDATE_PASSWORD" ], - "realmRoles" : [ "uma_authorization", "offline_access" ], - "clientRoles" : { - "account" : [ "view-profile", "manage-account" ] - }, - "notBefore" : 0, - "groups" : [ "/camunda-admin", "/formsflow/formsflow-reviewer", "/formsflow-analytics/group1" ] - }, { - "createdTimestamp" : 1621585233480, - "username" : "service-account-forms-flow-bpm", - "enabled" : true, - "totp" : false, - "emailVerified" : false, - "serviceAccountClientId" : "forms-flow-bpm", - "credentials" : [ ], - "disableableCredentialTypes" : [ ], - "requiredActions" : [ ], - "realmRoles" : [ "uma_authorization", "offline_access" ], - "clientRoles" : { - "realm-management" : [ "manage-users", "query-users", "query-groups", "view-users" , "manage-clients"], - "account" : [ "view-profile", "manage-account" ] - }, - "notBefore" : 0, - "groups" : [ "/camunda-admin" ] - } ], - "scopeMappings" : [ { - "clientScope" : "offline_access", - "roles" : [ "offline_access" ] - } ], - "clientScopeMappings" : { - "account" : [ { - "client" : "account-console", - "roles" : [ "manage-account" ] - } ] - }, - "clients": [ + "users": [ { - "clientId": "account", - "name": "${client_account}", - "rootUrl": "${authBaseUrl}", - "baseUrl": "/realms/forms-flow-ai/account/", - "surrogateAuthRequired": false, + "username": "formsflow-admin", + "firstName": "ff", + "lastName": "admin", + "email": "formsflow-admin@aot-technologies.com", + "emailVerified": false, + "createdTimestamp": 1718023583532, "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "**********", - "defaultRoles": [ - "view-profile", - "manage-account" - ], - "redirectUris": [ - "/realms/forms-flow-ai/account/*" + "totp": false, + "credentials": [ + { + "type": "password", + "userLabel": "My password", + "createdDate": 1718087465595, + "secretData": "{\"value\":\"EpHuQHA+nMza4usSNWSDxl6bZ9pbKFe4vOjuMMEZijQ=\",\"salt\":\"Ht1p7xh28sqw6+FZ1ICcOg==\",\"additionalParameters\":{}}", + "credentialData": "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" + } ], - "webOrigins": [], + "disableableCredentialTypes": [], + "requiredActions": ["UPDATE_PASSWORD"], + "realmRoles": ["default-roles-forms-flow-ai"], "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": false, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": {}, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" + "groups": [ + "/camunda-admin", + "/formsflow/formsflow-admin", + "/formsflow-analytics" ] }, { - "clientId": "account-console", - "name": "${client_account-console}", - "rootUrl": "${authBaseUrl}", - "baseUrl": "/realms/forms-flow-ai/account/", + "username": "service-account-forms-flow-bpm", + "emailVerified": false, + "createdTimestamp": 1621585233480, + "enabled": true, + "totp": false, + "serviceAccountClientId": "forms-flow-bpm", + "credentials": [], + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": ["offline_access", "uma_authorization"], + "clientRoles": { + "realm-management": [ + "query-users", + "query-groups", + "view-users", + "manage-users", + "manage-clients", + "realm-admin" + ], + "account": ["view-profile", "manage-account"] + }, + "notBefore": 0, + "groups": ["/camunda-admin"] + } + ], + "clients": [ + { + "clientId": "forms-flow-analytics", + "description": "Redash-Analytics", + "adminUrl": "http://localhost:7000/saml/callback?org_slug=default", "surrogateAuthRequired": false, "enabled": true, "alwaysDisplayInConsole": false, "clientAuthenticatorType": "client-secret", - "secret": "**********", - "redirectUris": [ - "/realms/forms-flow-ai/account/*" - ], + "redirectUris": ["http://localhost:7000/*", "*"], "webOrigins": [], "notBefore": 0, "bearerOnly": false, "consentRequired": false, "standardFlowEnabled": true, "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, + "directAccessGrantsEnabled": true, "serviceAccountsEnabled": false, "publicClient": true, "frontchannelLogout": false, - "protocol": "openid-connect", + "protocol": "saml", "attributes": { - "pkce.code.challenge.method": "S256" + "saml.assertion.signature": "true", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "post.logout.redirect.uris": "+", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "saml.artifact.binding.identifier": "OOFH7REqnhW7gnm7DkWoK9smSR4=", + "saml.signature.algorithm": "RSA_SHA256", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "true", + "display.on.consent.screen": "false", + "saml_name_id_format": "email", + "saml.onetimeuse.condition": "false", + "saml_signature_canonicalization_method": "http://www.w3.org/2001/10/xml-exc-c14n#WithComments" }, "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, "protocolMappers": [ { - "name": "audience resolve", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-resolve-mapper", + "name": "X500 surname", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", "consentRequired": false, - "config": {} + "config": { + "user.attribute": "lastName", + "friendly.name": "LastName", + "attribute.name": "urn:oid:2.5.4.4" + } + }, + { + "name": "X500 givenName", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "firstName", + "friendly.name": "FirstName", + "attribute.name": "urn:oid:2.5.4.42" + } } ], - "defaultClientScopes": [ - "web-origins", - "role_list", - "roles", - "profile", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] + "defaultClientScopes": ["role_list"], + "optionalClientScopes": [] }, { - "clientId": "admin-cli", - "name": "${client_admin-cli}", + "clientId": "forms-flow-bpm", + "description": "Camunda Process Engine Components", "surrogateAuthRequired": false, "enabled": true, "alwaysDisplayInConsole": false, "clientAuthenticatorType": "client-secret", - "secret": "**********", - "redirectUris": [], - "webOrigins": [], + "secret": "e4bdbd25-1467-4f7f-b993-bc4b1944c943", + "redirectUris": ["http://localhost:8000/camunda/*", "*"], + "webOrigins": ["*"], "notBefore": 0, "bearerOnly": false, "consentRequired": false, - "standardFlowEnabled": false, + "standardFlowEnabled": true, "implicitFlowEnabled": false, "directAccessGrantsEnabled": true, - "serviceAccountsEnabled": false, - "publicClient": true, + "serviceAccountsEnabled": true, + "publicClient": false, "frontchannelLogout": false, "protocol": "openid-connect", - "attributes": {}, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - }, - { - "clientId": "broker", - "name": "${client_broker}", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "**********", - "redirectUris": [], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": false, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": {}, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - }, - { - "clientId": "forms-flow-analytics", - "description": "Redash-Analytics", - "adminUrl": "http://localhost:7000/saml/callback?org_slug=default", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "**********", - "redirectUris": [ - "http://localhost:7000/*", - "*" - ], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": true, - "serviceAccountsEnabled": false, - "publicClient": true, - "frontchannelLogout": false, - "protocol": "saml", - "attributes": { - "saml.assertion.signature": "true", - "saml.force.post.binding": "false", - "saml.multivalued.roles": "false", - "saml.encrypt": "false", - "saml.server.signature": "false", - "saml.server.signature.keyinfo.ext": "false", - "exclude.session.state.from.auth.response": "false", - "saml.signature.algorithm": "RSA_SHA256", - "saml_force_name_id_format": "false", - "saml.client.signature": "false", - "tls.client.certificate.bound.access.tokens": "false", - "saml.authnstatement": "true", - "display.on.consent.screen": "false", - "saml_name_id_format": "email", - "saml.onetimeuse.condition": "false", - "saml_signature_canonicalization_method": "http://www.w3.org/2001/10/xml-exc-c14n#WithComments" - }, + "attributes": { + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "post.logout.redirect.uris": "+", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": true, "nodeReRegistrationTimeout": -1, "protocolMappers": [ { - "name": "X500 surname", - "protocol": "saml", - "protocolMapper": "saml-user-property-mapper", + "name": "formsflow-web-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", "consentRequired": false, "config": { - "user.attribute": "lastName", - "friendly.name": "LastName", - "attribute.name": "urn:oid:2.5.4.4" + "included.client.audience": "forms-flow-web", + "id.token.claim": "false", + "access.token.claim": "true", + "userinfo.token.claim": "false" } }, - { - "name": "X500 givenName", - "protocol": "saml", - "protocolMapper": "saml-user-property-mapper", - "consentRequired": false, - "config": { - "user.attribute": "firstName", - "friendly.name": "FirstName", - "attribute.name": "urn:oid:2.5.4.42" - } - } - ], - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - }, - { - "clientId": "forms-flow-bpm", - "description": "Camunda Process Engine Components", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "e4bdbd25-1467-4f7f-b993-bc4b1944c943", - "redirectUris": [ - "http://localhost:8000/camunda/*", - "*" - ], - "webOrigins": [ - "*" - ], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": true, - "serviceAccountsEnabled": true, - "publicClient": false, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": { - "saml.assertion.signature": "false", - "saml.force.post.binding": "false", - "saml.multivalued.roles": "false", - "saml.encrypt": "false", - "saml.server.signature": "false", - "saml.server.signature.keyinfo.ext": "false", - "exclude.session.state.from.auth.response": "false", - "saml_force_name_id_format": "false", - "saml.client.signature": "false", - "tls.client.certificate.bound.access.tokens": "false", - "saml.authnstatement": "false", - "display.on.consent.screen": "false", - "saml.onetimeuse.condition": "false" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": true, - "nodeReRegistrationTimeout": -1, - "protocolMappers": [ { "name": "Client Host", "protocol": "openid-connect", @@ -947,23 +495,25 @@ "consentRequired": false, "config": { "user.session.note": "clientHost", - "userinfo.token.claim": "true", "id.token.claim": "true", "access.token.claim": "true", "claim.name": "clientHost", - "jsonType.label": "String" + "jsonType.label": "String", + "userinfo.token.claim": "true" } }, { - "name": "formsflow-web-mapper", + "name": "username", "protocol": "openid-connect", - "protocolMapper": "oidc-audience-mapper", + "protocolMapper": "oidc-usermodel-property-mapper", "consentRequired": false, "config": { - "included.client.audience": "forms-flow-web", - "id.token.claim": "false", + "user.attribute": "username", + "id.token.claim": "true", "access.token.claim": "true", - "userinfo.token.claim": "false" + "claim.name": "preferred_username", + "jsonType.label": "String", + "userinfo.token.claim": "true" } }, { @@ -973,36 +523,36 @@ "consentRequired": false, "config": { "user.session.note": "clientAddress", - "userinfo.token.claim": "true", "id.token.claim": "true", "access.token.claim": "true", "claim.name": "clientAddress", - "jsonType.label": "String" + "jsonType.label": "String", + "userinfo.token.claim": "true" } }, - { - "name": "camunda-rest-api", + { + "name": "groups", "protocol": "openid-connect", - "protocolMapper": "oidc-audience-mapper", + "protocolMapper": "oidc-group-membership-mapper", "consentRequired": false, "config": { - "id.token.claim": "false", + "full.path": "true", + "id.token.claim": "true", "access.token.claim": "true", - "included.custom.audience": "camunda-rest-api" + "claim.name": "groups", + "userinfo.token.claim": "true" } }, { - "name": "username", + "name": "camunda-rest-api", "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", + "protocolMapper": "oidc-audience-mapper", "consentRequired": false, "config": { - "userinfo.token.claim": "true", - "user.attribute": "username", - "id.token.claim": "true", + "id.token.claim": "false", "access.token.claim": "true", - "claim.name": "preferred_username", - "jsonType.label": "String" + "included.custom.audience": "camunda-rest-api", + "userinfo.token.claim": "false" } }, { @@ -1012,32 +562,19 @@ "consentRequired": false, "config": { "user.session.note": "clientId", - "userinfo.token.claim": "true", "id.token.claim": "true", "access.token.claim": "true", "claim.name": "clientId", - "jsonType.label": "String" - } - }, - { - "name": "groups", - "protocol": "openid-connect", - "protocolMapper": "oidc-group-membership-mapper", - "consentRequired": false, - "config": { - "full.path": "true", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "groups", + "jsonType.label": "String", "userinfo.token.claim": "true" } } ], "defaultClientScopes": [ "web-origins", - "role_list", - "profile", "roles", + "profile", + "basic", "camunda-rest-api", "email" ], @@ -1055,14 +592,8 @@ "enabled": true, "alwaysDisplayInConsole": false, "clientAuthenticatorType": "client-secret", - "secret": "**********", - "redirectUris": [ - "http://localhost:3000/*", - "*" - ], - "webOrigins": [ - "*" - ], + "redirectUris": ["http://localhost:3000/*", "*"], + "webOrigins": ["*"], "notBefore": 0, "bearerOnly": false, "consentRequired": false, @@ -1078,6 +609,7 @@ "saml.force.post.binding": "false", "saml.multivalued.roles": "false", "saml.encrypt": "false", + "post.logout.redirect.uris": "+", "saml.server.signature": "false", "saml.server.signature.keyinfo.ext": "false", "exclude.session.state.from.auth.response": "false", @@ -1093,26 +625,27 @@ "nodeReRegistrationTimeout": -1, "protocolMappers": [ { - "name": "formsflow-web-mapper", + "name": "camunda-rest-api", "protocol": "openid-connect", "protocolMapper": "oidc-audience-mapper", "consentRequired": false, "config": { - "included.client.audience": "forms-flow-web", "id.token.claim": "false", "access.token.claim": "true", + "included.custom.audience": "camunda-rest-api", "userinfo.token.claim": "false" } }, { - "name": "camunda-rest-api", + "name": "formsflow-web-mapper", "protocol": "openid-connect", "protocolMapper": "oidc-audience-mapper", "consentRequired": false, "config": { + "included.client.audience": "forms-flow-web", "id.token.claim": "false", "access.token.claim": "true", - "included.custom.audience": "camunda-rest-api" + "userinfo.token.claim": "false" } }, { @@ -1146,9 +679,9 @@ ], "defaultClientScopes": [ "web-origins", - "role_list", - "profile", "roles", + "profile", + "basic", "camunda-rest-api", "email" ], @@ -1158,327 +691,344 @@ "offline_access", "microprofile-jwt" ] - }, + } + ], + "clientScopes": [ { - "clientId": "realm-management", - "name": "${client_realm-management}", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "**********", - "redirectUris": [], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": true, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": false, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": {}, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - }, - { - "clientId": "security-admin-console", - "name": "${client_security-admin-console}", - "rootUrl": "${authAdminUrl}", - "baseUrl": "/admin/forms-flow-ai/console/", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "**********", - "redirectUris": [ - "/admin/forms-flow-ai/console/*" - ], - "webOrigins": [ - "+" - ], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": true, - "frontchannelLogout": false, + "name": "address", + "description": "OpenID Connect built-in scope: address", "protocol": "openid-connect", "attributes": { - "pkce.code.challenge.method": "S256" + "include.in.token.scope": "true", + "consent.screen.text": "${addressScopeConsentText}", + "display.on.consent.screen": "true" }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, "protocolMappers": [ { - "name": "locale", + "id": "5d9b4833-6472-4de5-8a32-ac8111d77aa1", + "name": "address", "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", + "protocolMapper": "oidc-address-mapper", "consentRequired": false, "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", "userinfo.token.claim": "true", - "user.attribute": "locale", + "user.attribute.street": "street", "id.token.claim": "true", + "user.attribute.region": "region", "access.token.claim": "true", - "claim.name": "locale", - "jsonType.label": "String" + "user.attribute.locality": "locality" } } - ], - "defaultClientScopes": [ - "web-origins", - "role_list", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" ] - } - ], - "clientScopes": [ + }, { - "name": "role_list", - "description": "SAML role list", - "protocol": "saml", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", "attributes": { - "consent.screen.text": "${samlRoleListScopeConsentText}", - "display.on.consent.screen": "true" + "include.in.token.scope": "false", + "display.on.consent.screen": "false" }, "protocolMappers": [ { - "name": "role list", - "protocol": "saml", - "protocolMapper": "saml-role-list-mapper", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", "consentRequired": false, "config": { - "single": "false", - "attribute.nameformat": "Basic", - "attribute.name": "Role" + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" } } ] }, { - "name": "offline_access", - "description": "OpenID Connect built-in scope: offline_access", - "protocol": "openid-connect", - "attributes": { - "consent.screen.text": "${offlineAccessScopeConsentText}", - "display.on.consent.screen": "true" - } - }, - { - "name": "profile", - "description": "OpenID Connect built-in scope: profile", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", "protocol": "openid-connect", "attributes": { "include.in.token.scope": "true", - "display.on.consent.screen": "true", - "consent.screen.text": "${profileScopeConsentText}" + "display.on.consent.screen": "false" }, "protocolMappers": [ { - "name": "full name", + "name": "upn", "protocol": "openid-connect", - "protocolMapper": "oidc-full-name-mapper", + "protocolMapper": "oidc-usermodel-property-mapper", "consentRequired": false, "config": { + "user.attribute": "username", "id.token.claim": "true", "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String", "userinfo.token.claim": "true" } }, { - "name": "profile", + "name": "groups", "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", + "protocolMapper": "oidc-usermodel-realm-role-mapper", "consentRequired": false, "config": { + "multivalued": "true", "userinfo.token.claim": "true", - "user.attribute": "profile", + "user.attribute": "foo", "id.token.claim": "true", "access.token.claim": "true", - "claim.name": "profile", + "claim.name": "groups", "jsonType.label": "String" } - }, + } + ] + }, + { + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "${rolesScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ { - "name": "gender", + "name": "Role", "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", + "protocolMapper": "oidc-usermodel-client-role-mapper", "consentRequired": false, "config": { - "userinfo.token.claim": "true", - "user.attribute": "gender", "id.token.claim": "true", "access.token.claim": "true", - "claim.name": "gender", - "jsonType.label": "String" + "claim.name": "role", + "usermodel.clientRoleMapping.clientId": "forms-flow-web", + "multivalued": "true", + "userinfo.token.claim": "true" } }, { - "name": "website", + "name": "client roles", "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", + "protocolMapper": "oidc-usermodel-client-role-mapper", "consentRequired": false, "config": { - "userinfo.token.claim": "true", - "user.attribute": "website", - "id.token.claim": "true", + "user.attribute": "foo", "access.token.claim": "true", - "claim.name": "website", - "jsonType.label": "String" + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" } }, { - "name": "zoneinfo", + "name": "realm roles", "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", + "protocolMapper": "oidc-usermodel-realm-role-mapper", "consentRequired": false, "config": { - "userinfo.token.claim": "true", - "user.attribute": "zoneinfo", - "id.token.claim": "true", + "user.attribute": "foo", "access.token.claim": "true", - "claim.name": "zoneinfo", - "jsonType.label": "String" + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" } }, { - "name": "given name", + "name": "audience resolve", "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", + "protocolMapper": "oidc-audience-resolve-mapper", "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "firstName", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "given_name", - "jsonType.label": "String" - } - }, + "config": {} + } + ] + }, + { + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "name": "basic", + "description": "OpenID Connect scope for add all basic claims to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ { - "name": "nickname", + "name": "sub", "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", + "protocolMapper": "oidc-sub-mapper", "consentRequired": false, "config": { - "userinfo.token.claim": "true", - "user.attribute": "nickname", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "nickname", - "jsonType.label": "String" + "introspection.token.claim": "true", + "access.token.claim": "true" } }, { - "name": "locale", + "name": "auth_time", "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", + "protocolMapper": "oidc-usersessionmodel-note-mapper", "consentRequired": false, "config": { - "userinfo.token.claim": "true", - "user.attribute": "locale", + "user.session.note": "AUTH_TIME", "id.token.claim": "true", + "introspection.token.claim": "true", "access.token.claim": "true", - "claim.name": "locale", - "jsonType.label": "String" + "claim.name": "auth_time", + "jsonType.label": "long" } - }, + } + ] + }, + { + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${emailScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ { - "name": "updated at", + "name": "email", "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", + "protocolMapper": "oidc-usermodel-property-mapper", "consentRequired": false, "config": { - "userinfo.token.claim": "true", - "user.attribute": "updatedAt", + "user.attribute": "email", "id.token.claim": "true", "access.token.claim": "true", - "claim.name": "updated_at", - "jsonType.label": "String" + "claim.name": "email", + "jsonType.label": "String", + "userinfo.token.claim": "true" } }, { - "name": "birthdate", + "name": "email verified", "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", + "protocolMapper": "oidc-usermodel-property-mapper", "consentRequired": false, "config": { - "userinfo.token.claim": "true", - "user.attribute": "birthdate", + "user.attribute": "emailVerified", "id.token.claim": "true", "access.token.claim": "true", - "claim.name": "birthdate", - "jsonType.label": "String" + "claim.name": "email_verified", + "jsonType.label": "boolean", + "userinfo.token.claim": "true" } - }, + } + ] + }, + { + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ { - "name": "family name", + "name": "allowed web origins", "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", + "protocolMapper": "oidc-allowed-origins-mapper", "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "lastName", - "id.token.claim": "true", + "config": {} + } + ] + }, + { + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "name": "camunda-rest-api", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "name": "camunda-rest-api", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "false", "access.token.claim": "true", - "claim.name": "family_name", - "jsonType.label": "String" + "included.custom.audience": "camunda-rest-api", + "userinfo.token.claim": "false" } - }, + } + ] + }, + { + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${profileScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ { - "name": "middle name", + "name": "zoneinfo", "protocol": "openid-connect", "protocolMapper": "oidc-usermodel-attribute-mapper", "consentRequired": false, "config": { - "userinfo.token.claim": "true", - "user.attribute": "middleName", + "user.attribute": "zoneinfo", "id.token.claim": "true", "access.token.claim": "true", - "claim.name": "middle_name", - "jsonType.label": "String" + "claim.name": "zoneinfo", + "jsonType.label": "String", + "userinfo.token.claim": "true" } }, { - "name": "username", + "name": "gender", "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", + "protocolMapper": "oidc-usermodel-attribute-mapper", "consentRequired": false, "config": { - "userinfo.token.claim": "true", - "user.attribute": "username", + "user.attribute": "gender", "id.token.claim": "true", "access.token.claim": "true", - "claim.name": "preferred_username", - "jsonType.label": "String" + "claim.name": "gender", + "jsonType.label": "String", + "userinfo.token.claim": "true" } }, { @@ -1487,261 +1037,203 @@ "protocolMapper": "oidc-usermodel-attribute-mapper", "consentRequired": false, "config": { - "userinfo.token.claim": "true", "user.attribute": "picture", "id.token.claim": "true", "access.token.claim": "true", "claim.name": "picture", - "jsonType.label": "String" + "jsonType.label": "String", + "userinfo.token.claim": "true" } - } - ] - }, - { - "name": "email", - "description": "OpenID Connect built-in scope: email", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "true", - "consent.screen.text": "${emailScopeConsentText}" - }, - "protocolMappers": [ + }, { - "name": "email verified", + "name": "username", "protocol": "openid-connect", "protocolMapper": "oidc-usermodel-property-mapper", "consentRequired": false, "config": { - "userinfo.token.claim": "true", - "user.attribute": "emailVerified", + "user.attribute": "username", "id.token.claim": "true", "access.token.claim": "true", - "claim.name": "email_verified", - "jsonType.label": "boolean" + "claim.name": "preferred_username", + "jsonType.label": "String", + "userinfo.token.claim": "true" } }, { - "name": "email", + "name": "website", "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", + "protocolMapper": "oidc-usermodel-attribute-mapper", "consentRequired": false, "config": { - "userinfo.token.claim": "true", - "user.attribute": "email", + "user.attribute": "website", "id.token.claim": "true", "access.token.claim": "true", - "claim.name": "email", - "jsonType.label": "String" + "claim.name": "website", + "jsonType.label": "String", + "userinfo.token.claim": "true" } - } - ] - }, - { - "name": "address", - "description": "OpenID Connect built-in scope: address", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "true", - "consent.screen.text": "${addressScopeConsentText}" - }, - "protocolMappers": [ + }, { - "name": "address", + "name": "locale", "protocol": "openid-connect", - "protocolMapper": "oidc-address-mapper", + "protocolMapper": "oidc-usermodel-attribute-mapper", "consentRequired": false, "config": { - "user.attribute.formatted": "formatted", - "user.attribute.country": "country", - "user.attribute.postal_code": "postal_code", - "userinfo.token.claim": "true", - "user.attribute.street": "street", + "user.attribute": "locale", "id.token.claim": "true", - "user.attribute.region": "region", "access.token.claim": "true", - "user.attribute.locality": "locality" + "claim.name": "locale", + "jsonType.label": "String", + "userinfo.token.claim": "true" } - } - ] - }, - { - "name": "phone", - "description": "OpenID Connect built-in scope: phone", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "true", - "consent.screen.text": "${phoneScopeConsentText}" - }, - "protocolMappers": [ + }, { - "name": "phone number verified", + "name": "middle name", "protocol": "openid-connect", "protocolMapper": "oidc-usermodel-attribute-mapper", "consentRequired": false, "config": { - "userinfo.token.claim": "true", - "user.attribute": "phoneNumberVerified", + "user.attribute": "middleName", "id.token.claim": "true", "access.token.claim": "true", - "claim.name": "phone_number_verified", - "jsonType.label": "boolean" + "claim.name": "middle_name", + "jsonType.label": "String", + "userinfo.token.claim": "true" } }, { - "name": "phone number", + "name": "nickname", "protocol": "openid-connect", "protocolMapper": "oidc-usermodel-attribute-mapper", "consentRequired": false, "config": { - "userinfo.token.claim": "true", - "user.attribute": "phoneNumber", + "user.attribute": "nickname", "id.token.claim": "true", "access.token.claim": "true", - "claim.name": "phone_number", - "jsonType.label": "String" + "claim.name": "nickname", + "jsonType.label": "String", + "userinfo.token.claim": "true" } - } - ] - }, - { - "name": "roles", - "description": "OpenID Connect scope for add user roles to the access token", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "false", - "display.on.consent.screen": "true", - "consent.screen.text": "${rolesScopeConsentText}" - }, - "protocolMappers": [ + }, { - "name": "realm roles", + "name": "profile", "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-realm-role-mapper", + "protocolMapper": "oidc-usermodel-attribute-mapper", "consentRequired": false, "config": { - "user.attribute": "foo", + "user.attribute": "profile", + "id.token.claim": "true", "access.token.claim": "true", - "claim.name": "realm_access.roles", + "claim.name": "profile", "jsonType.label": "String", - "multivalued": "true" + "userinfo.token.claim": "true" } }, { - "name": "client roles", + "name": "birthdate", "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-client-role-mapper", + "protocolMapper": "oidc-usermodel-attribute-mapper", "consentRequired": false, "config": { - "user.attribute": "foo", + "user.attribute": "birthdate", + "id.token.claim": "true", "access.token.claim": "true", - "claim.name": "resource_access.${client_id}.roles", + "claim.name": "birthdate", "jsonType.label": "String", - "multivalued": "true" + "userinfo.token.claim": "true" } }, { - "name": "Role", + "name": "full name", "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-client-role-mapper", + "protocolMapper": "oidc-full-name-mapper", "consentRequired": false, "config": { - "multivalued": "true", - "userinfo.token.claim": "true", "id.token.claim": "true", "access.token.claim": "true", - "claim.name": "role", - "usermodel.clientRoleMapping.clientId": "forms-flow-web" + "userinfo.token.claim": "true" } }, { - "name": "audience resolve", + "name": "updated at", "protocol": "openid-connect", - "protocolMapper": "oidc-audience-resolve-mapper", + "protocolMapper": "oidc-usermodel-attribute-mapper", "consentRequired": false, - "config": {} - } - ] - }, - { - "name": "web-origins", - "description": "OpenID Connect scope for add allowed web origins to the access token", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "false", - "display.on.consent.screen": "false", - "consent.screen.text": "" - }, - "protocolMappers": [ + "config": { + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, { - "name": "allowed web origins", + "name": "family name", "protocol": "openid-connect", - "protocolMapper": "oidc-allowed-origins-mapper", - "consentRequired": false, - "config": {} - } - ] - }, - { - "name": "microprofile-jwt", - "description": "Microprofile - JWT built-in scope", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "false" - }, - "protocolMappers": [ - { - "name": "groups", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-realm-role-mapper", + "protocolMapper": "oidc-usermodel-property-mapper", "consentRequired": false, "config": { - "multivalued": "true", - "userinfo.token.claim": "true", - "user.attribute": "foo", + "user.attribute": "lastName", "id.token.claim": "true", "access.token.claim": "true", - "claim.name": "groups", - "jsonType.label": "String" + "claim.name": "family_name", + "jsonType.label": "String", + "userinfo.token.claim": "true" } }, { - "name": "upn", + "name": "given name", "protocol": "openid-connect", "protocolMapper": "oidc-usermodel-property-mapper", "consentRequired": false, "config": { - "userinfo.token.claim": "true", - "user.attribute": "username", + "user.attribute": "firstName", "id.token.claim": "true", "access.token.claim": "true", - "claim.name": "upn", - "jsonType.label": "String" + "claim.name": "given_name", + "jsonType.label": "String", + "userinfo.token.claim": "true" } } ] }, { - "name": "camunda-rest-api", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", "protocol": "openid-connect", "attributes": { "include.in.token.scope": "true", + "consent.screen.text": "${phoneScopeConsentText}", "display.on.consent.screen": "true" }, "protocolMappers": [ { - "name": "camunda-rest-api", + "name": "phone number", "protocol": "openid-connect", - "protocolMapper": "oidc-audience-mapper", + "protocolMapper": "oidc-usermodel-attribute-mapper", "consentRequired": false, "config": { - "id.token.claim": "false", + "user.attribute": "phoneNumber", + "id.token.claim": "true", "access.token.claim": "true", - "included.custom.audience": "camunda-rest-api", - "userinfo.token.claim": "false" + "claim.name": "phone_number", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean", + "userinfo.token.claim": "true" } } ] @@ -1753,7 +1245,9 @@ "email", "roles", "web-origins", - "camunda-rest-api" + "camunda-rest-api", + "acr", + "basic" ], "defaultOptionalClientScopes": [ "offline_access", @@ -1761,757 +1255,116 @@ "phone", "microprofile-jwt" ], - "browserSecurityHeaders": { - "contentSecurityPolicyReportOnly": "", - "xContentTypeOptions": "nosniff", - "xRobotsTag": "none", - "xFrameOptions": "SAMEORIGIN", - "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", - "xXSSProtection": "1; mode=block", - "strictTransportSecurity": "max-age=31536000; includeSubDomains" - }, - "smtpServer": {}, - "eventsEnabled": false, - "eventsListeners": [ - "jboss-logging" + "eventsEnabled": true, + "eventsExpiration": 1800, + "eventsListeners": ["jboss-logging"], + "enabledEventTypes": [ + "UPDATE_CONSENT_ERROR", + "SEND_RESET_PASSWORD", + "GRANT_CONSENT", + "VERIFY_PROFILE_ERROR", + "UPDATE_TOTP", + "REMOVE_TOTP", + "REVOKE_GRANT", + "LOGIN_ERROR", + "CLIENT_LOGIN", + "RESET_PASSWORD_ERROR", + "IMPERSONATE_ERROR", + "CODE_TO_TOKEN_ERROR", + "CUSTOM_REQUIRED_ACTION", + "OAUTH2_DEVICE_CODE_TO_TOKEN_ERROR", + "RESTART_AUTHENTICATION", + "UPDATE_PROFILE_ERROR", + "IMPERSONATE", + "LOGIN", + "UPDATE_PASSWORD_ERROR", + "OAUTH2_DEVICE_VERIFY_USER_CODE", + "CLIENT_INITIATED_ACCOUNT_LINKING", + "USER_DISABLED_BY_PERMANENT_LOCKOUT", + "OAUTH2_EXTENSION_GRANT", + "TOKEN_EXCHANGE", + "REGISTER", + "LOGOUT", + "AUTHREQID_TO_TOKEN", + "DELETE_ACCOUNT_ERROR", + "CLIENT_REGISTER", + "IDENTITY_PROVIDER_LINK_ACCOUNT", + "USER_DISABLED_BY_TEMPORARY_LOCKOUT", + "UPDATE_PASSWORD", + "DELETE_ACCOUNT", + "FEDERATED_IDENTITY_LINK_ERROR", + "CLIENT_DELETE", + "IDENTITY_PROVIDER_FIRST_LOGIN", + "VERIFY_EMAIL", + "CLIENT_DELETE_ERROR", + "CLIENT_LOGIN_ERROR", + "RESTART_AUTHENTICATION_ERROR", + "REMOVE_FEDERATED_IDENTITY_ERROR", + "EXECUTE_ACTIONS", + "TOKEN_EXCHANGE_ERROR", + "PERMISSION_TOKEN", + "FEDERATED_IDENTITY_OVERRIDE_LINK", + "SEND_IDENTITY_PROVIDER_LINK_ERROR", + "EXECUTE_ACTION_TOKEN_ERROR", + "SEND_VERIFY_EMAIL", + "OAUTH2_EXTENSION_GRANT_ERROR", + "OAUTH2_DEVICE_AUTH", + "EXECUTE_ACTIONS_ERROR", + "REMOVE_FEDERATED_IDENTITY", + "OAUTH2_DEVICE_CODE_TO_TOKEN", + "IDENTITY_PROVIDER_POST_LOGIN", + "IDENTITY_PROVIDER_LINK_ACCOUNT_ERROR", + "FEDERATED_IDENTITY_OVERRIDE_LINK_ERROR", + "UPDATE_EMAIL", + "OAUTH2_DEVICE_VERIFY_USER_CODE_ERROR", + "REGISTER_ERROR", + "REVOKE_GRANT_ERROR", + "LOGOUT_ERROR", + "UPDATE_EMAIL_ERROR", + "EXECUTE_ACTION_TOKEN", + "CLIENT_UPDATE_ERROR", + "UPDATE_PROFILE", + "AUTHREQID_TO_TOKEN_ERROR", + "INVITE_ORG_ERROR", + "FEDERATED_IDENTITY_LINK", + "CLIENT_REGISTER_ERROR", + "INVITE_ORG", + "SEND_VERIFY_EMAIL_ERROR", + "SEND_IDENTITY_PROVIDER_LINK", + "RESET_PASSWORD", + "CLIENT_INITIATED_ACCOUNT_LINKING_ERROR", + "OAUTH2_DEVICE_AUTH_ERROR", + "UPDATE_CONSENT", + "REMOVE_TOTP_ERROR", + "VERIFY_EMAIL_ERROR", + "SEND_RESET_PASSWORD_ERROR", + "CLIENT_UPDATE", + "IDENTITY_PROVIDER_POST_LOGIN_ERROR", + "CUSTOM_REQUIRED_ACTION_ERROR", + "UPDATE_TOTP_ERROR", + "CODE_TO_TOKEN", + "VERIFY_PROFILE", + "GRANT_CONSENT_ERROR", + "IDENTITY_PROVIDER_FIRST_LOGIN_ERROR" ], - "enabledEventTypes": [], "adminEventsEnabled": false, "adminEventsDetailsEnabled": false, "components": { - "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ - { - "name": "Consent Required", - "providerId": "consent-required", - "subType": "anonymous", - "subComponents": {}, - "config": {} - }, + "org.keycloak.userprofile.UserProfileProvider": [ { - "name": "Allowed Client Scopes", - "providerId": "allowed-client-templates", - "subType": "authenticated", + "providerId": "declarative-user-profile", "subComponents": {}, "config": { - "allow-default-scopes": [ - "true" - ] - } - }, - { - "name": "Allowed Client Scopes", - "providerId": "allowed-client-templates", - "subType": "anonymous", - "subComponents": {}, - "config": { - "allow-default-scopes": [ - "true" - ] - } - }, - { - "name": "Full Scope Disabled", - "providerId": "scope", - "subType": "anonymous", - "subComponents": {}, - "config": {} - }, - { - "name": "Trusted Hosts", - "providerId": "trusted-hosts", - "subType": "anonymous", - "subComponents": {}, - "config": { - "host-sending-registration-request-must-match": [ - "true" - ], - "client-uris-must-match": [ - "true" - ] - } - }, - { - "name": "Allowed Protocol Mapper Types", - "providerId": "allowed-protocol-mappers", - "subType": "authenticated", - "subComponents": {}, - "config": { - "allowed-protocol-mapper-types": [ - "oidc-usermodel-attribute-mapper", - "oidc-full-name-mapper", - "saml-user-attribute-mapper", - "saml-user-property-mapper", - "oidc-address-mapper", - "saml-role-list-mapper", - "oidc-sha256-pairwise-sub-mapper", - "oidc-usermodel-property-mapper" - ] - } - }, - { - "name": "Max Clients Limit", - "providerId": "max-clients", - "subType": "anonymous", - "subComponents": {}, - "config": { - "max-clients": [ - "200" - ] - } - }, - { - "name": "Allowed Protocol Mapper Types", - "providerId": "allowed-protocol-mappers", - "subType": "anonymous", - "subComponents": {}, - "config": { - "allowed-protocol-mapper-types": [ - "saml-role-list-mapper", - "oidc-usermodel-attribute-mapper", - "oidc-address-mapper", - "oidc-sha256-pairwise-sub-mapper", - "oidc-full-name-mapper", - "saml-user-attribute-mapper", - "oidc-usermodel-property-mapper", - "saml-user-property-mapper" - ] - } - } - ], - "org.keycloak.keys.KeyProvider": [ - { - "name": "hmac-generated", - "providerId": "hmac-generated", - "subComponents": {}, - "config": { - "priority": [ - "100" - ], - "algorithm": [ - "HS256" - ] - } - }, - { - "name": "rsa-generated", - "providerId": "rsa-generated", - "subComponents": {}, - "config": { - "priority": [ - "100" - ] - } - }, - { - "name": "aes-generated", - "providerId": "aes-generated", - "subComponents": {}, - "config": { - "priority": [ - "100" + "kc.user.profile.config": [ + "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}],\"unmanagedAttributePolicy\":\"ENABLED\"}" ] } } ] }, - "internationalizationEnabled": false, - "supportedLocales": [], - "authenticationFlows": [ - { - "alias": "Account verification options", - "description": "Method with which to verity the existing account", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "idp-email-verification", - "requirement": "ALTERNATIVE", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "ALTERNATIVE", - "priority": 20, - "flowAlias": "Verify Existing Account by Re-authentication", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "alias": "Authentication Options", - "description": "Authentication options.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "basic-auth", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "basic-auth-otp", - "requirement": "DISABLED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "auth-spnego", - "requirement": "DISABLED", - "priority": 30, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "alias": "Browser - Conditional OTP", - "description": "Flow to determine if the OTP is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "auth-otp-form", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "alias": "Direct Grant - Conditional OTP", - "description": "Flow to determine if the OTP is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "direct-grant-validate-otp", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "alias": "First broker login - Conditional OTP", - "description": "Flow to determine if the OTP is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "auth-otp-form", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "alias": "Handle Existing Account", - "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "idp-confirm-link", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "REQUIRED", - "priority": 20, - "flowAlias": "Account verification options", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "alias": "Reset - Conditional OTP", - "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "reset-otp", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "alias": "User creation or linking", - "description": "Flow for the existing/non-existing user alternatives", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticatorConfig": "create unique user config", - "authenticator": "idp-create-user-if-unique", - "requirement": "ALTERNATIVE", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "ALTERNATIVE", - "priority": 20, - "flowAlias": "Handle Existing Account", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "alias": "Verify Existing Account by Re-authentication", - "description": "Reauthentication of existing account", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "idp-username-password-form", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "CONDITIONAL", - "priority": 20, - "flowAlias": "First broker login - Conditional OTP", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "alias": "browser", - "description": "browser based authentication", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "auth-cookie", - "requirement": "ALTERNATIVE", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "auth-spnego", - "requirement": "DISABLED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "identity-provider-redirector", - "requirement": "ALTERNATIVE", - "priority": 25, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "ALTERNATIVE", - "priority": 30, - "flowAlias": "forms", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "alias": "clients", - "description": "Base authentication for clients", - "providerId": "client-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "client-secret", - "requirement": "ALTERNATIVE", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "client-jwt", - "requirement": "ALTERNATIVE", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "client-secret-jwt", - "requirement": "ALTERNATIVE", - "priority": 30, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "client-x509", - "requirement": "ALTERNATIVE", - "priority": 40, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "alias": "direct grant", - "description": "OpenID Connect Resource Owner Grant", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "direct-grant-validate-username", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "direct-grant-validate-password", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "CONDITIONAL", - "priority": 30, - "flowAlias": "Direct Grant - Conditional OTP", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "alias": "docker auth", - "description": "Used by Docker clients to authenticate against the IDP", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "docker-http-basic-authenticator", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "alias": "first broker login", - "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticatorConfig": "review profile config", - "authenticator": "idp-review-profile", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "REQUIRED", - "priority": 20, - "flowAlias": "User creation or linking", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "alias": "forms", - "description": "Username, password, otp and other auth forms.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "auth-username-password-form", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "CONDITIONAL", - "priority": 20, - "flowAlias": "Browser - Conditional OTP", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "alias": "http challenge", - "description": "An authentication flow based on challenge-response HTTP Authentication Schemes", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "no-cookie-redirect", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "REQUIRED", - "priority": 20, - "flowAlias": "Authentication Options", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "alias": "registration", - "description": "registration flow", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "registration-page-form", - "requirement": "REQUIRED", - "priority": 10, - "flowAlias": "registration form", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "alias": "registration form", - "description": "registration form", - "providerId": "form-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "registration-user-creation", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "registration-profile-action", - "requirement": "REQUIRED", - "priority": 40, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "registration-password-action", - "requirement": "REQUIRED", - "priority": 50, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "registration-recaptcha-action", - "requirement": "DISABLED", - "priority": 60, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - }, - { - "alias": "reset credentials", - "description": "Reset credentials for a user if they forgot their password or something", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "reset-credentials-choose-user", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "reset-credential-email", - "requirement": "REQUIRED", - "priority": 20, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "authenticator": "reset-password", - "requirement": "REQUIRED", - "priority": 30, - "userSetupAllowed": false, - "autheticatorFlow": false - }, - { - "requirement": "CONDITIONAL", - "priority": 40, - "flowAlias": "Reset - Conditional OTP", - "userSetupAllowed": false, - "autheticatorFlow": true - } - ] - }, - { - "alias": "saml ecp", - "description": "SAML ECP Profile Authentication Flow", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "http-basic-authenticator", - "requirement": "REQUIRED", - "priority": 10, - "userSetupAllowed": false, - "autheticatorFlow": false - } - ] - } - ], - "authenticatorConfig": [ - { - "alias": "create unique user config", - "config": { - "require.password.update.after.registration": "false" - } - }, - { - "alias": "review profile config", - "config": { - "update.profile.on.first.login": "missing" - } - } - ], - "requiredActions": [ - { - "alias": "CONFIGURE_TOTP", - "name": "Configure OTP", - "providerId": "CONFIGURE_TOTP", - "enabled": true, - "defaultAction": false, - "priority": 10, - "config": {} - }, - { - "alias": "terms_and_conditions", - "name": "Terms and Conditions", - "providerId": "terms_and_conditions", - "enabled": false, - "defaultAction": false, - "priority": 20, - "config": {} - }, - { - "alias": "UPDATE_PASSWORD", - "name": "Update Password", - "providerId": "UPDATE_PASSWORD", - "enabled": true, - "defaultAction": false, - "priority": 30, - "config": {} - }, - { - "alias": "UPDATE_PROFILE", - "name": "Update Profile", - "providerId": "UPDATE_PROFILE", - "enabled": true, - "defaultAction": false, - "priority": 40, - "config": {} - }, - { - "alias": "VERIFY_EMAIL", - "name": "Verify Email", - "providerId": "VERIFY_EMAIL", - "enabled": true, - "defaultAction": false, - "priority": 50, - "config": {} - }, - { - "alias": "update_user_locale", - "name": "Update User Locale", - "providerId": "update_user_locale", - "enabled": true, - "defaultAction": false, - "priority": 1000, - "config": {} - } + "internationalizationEnabled": true, + "supportedLocales": [ + "en" ], - "browserFlow": "browser", - "registrationFlow": "registration", - "directGrantFlow": "direct grant", - "resetCredentialsFlow": "reset credentials", - "clientAuthenticationFlow": "clients", - "dockerAuthenticationFlow": "docker auth", - "attributes": { - "clientOfflineSessionMaxLifespan": "0", - "clientSessionIdleTimeout": "0", - "clientSessionMaxLifespan": "0", - "clientOfflineSessionIdleTimeout": "0" - }, - "keycloakVersion": "11.0.0", - "userManagedAccessAllowed": false + "defaultLocale": "en" } diff --git a/forms-flow-idm/keycloak/start-keycloak.sh b/forms-flow-idm/keycloak/start-keycloak.sh index f3620ee2bc..74ae430d6b 100755 --- a/forms-flow-idm/keycloak/start-keycloak.sh +++ b/forms-flow-idm/keycloak/start-keycloak.sh @@ -1,4 +1,12 @@ #!/bin/bash +# Ensure the directories exist +mkdir -p /opt/keycloak/themes +mkdir -p /opt/keycloak/data/import + +# Copy custom themes and imports + +cp -rf /keycloak_custom_data/themes/* /opt/keycloak/themes/ +cp -rf /keycloak_custom_data/imports/* /opt/keycloak/data/import/ # Default values if the variables are not set START_MODE=${KEYCLOAK_START_MODE:-"start"} diff --git a/forms-flow-idm/keycloak/themes/formsflow/login/login.ftl b/forms-flow-idm/keycloak/themes/formsflow/login/login.ftl index 6b43916a46..a2bf1f9e10 100644 --- a/forms-flow-idm/keycloak/themes/formsflow/login/login.ftl +++ b/forms-flow-idm/keycloak/themes/formsflow/login/login.ftl @@ -3,97 +3,140 @@ <#if section = "header"> ${msg("loginAccountTitle")} <#elseif section = "form"> -
-
- <#if realm.password> -
-
- +
+
+ <#if realm.password> + + <#if !usernameHidden??> +
+ - <#if usernameEditDisabled??> - - <#else> - + - <#if messagesPerField.existsError('username','password')> - - ${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc} - - + <#if messagesPerField.existsError('username','password')> + + ${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc} + + + +
-
-
- +
+ - -
+
+ + +
-
-
- <#if realm.rememberMe && !usernameEditDisabled??> -
- -
+ <#if usernameHidden?? && messagesPerField.existsError('username','password')> + + ${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc} + -
-
- <#if realm.resetPasswordAllowed> - ${msg("doForgotPassword")} - -
-
+
-
- value="${auth.selectedCredential}"/> - -
- - -
+
+
+ <#if realm.rememberMe && !usernameHidden??> +
+ +
+ +
+
+ <#if realm.resetPasswordAllowed> + ${msg("doForgotPassword")} + +
- <#if realm.password && social.providers??> -
-
- +
- +
+ value="${auth.selectedCredential}"/> + +
+ +
- - -
+
+ <#elseif section = "info" > <#if realm.password && realm.registrationAllowed && !registrationDisabled??> + <#elseif section = "socialProviders" > + <#if realm.password && social?? && social.providers?has_content> +
+
+ <#if numberOfIdps??> + <#if numberOfIdps gt 0> +

${msg("identity-provider-login-label")}

+ + <#else> + <#if social.providers??> +

${msg("identity-provider-login-label")}

+ + + +
+ \ No newline at end of file diff --git a/forms-flow-idm/keycloak/themes/formsflow/login/register.ftl b/forms-flow-idm/keycloak/themes/formsflow/login/register.ftl new file mode 100644 index 0000000000..73538c880b --- /dev/null +++ b/forms-flow-idm/keycloak/themes/formsflow/login/register.ftl @@ -0,0 +1,109 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout; section> + <#if section == "header"> + ${msg("registerTitle")} + <#elseif section == "form"> +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + <#if !realm.registrationEmailAsUsername> +
+
+ +
+
+ +
+
+ + + <#if passwordRequired> +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + <#if recaptchaRequired??> +
+
+
+
+
+ + +
+ + +
+ +
+
+ + +
+ + + + + diff --git a/forms-flow-idm/keycloak/themes/formsflow/login/resources/css/login.css b/forms-flow-idm/keycloak/themes/formsflow/login/resources/css/login.css index 7fa8a02bae..026584c6d4 100644 --- a/forms-flow-idm/keycloak/themes/formsflow/login/resources/css/login.css +++ b/forms-flow-idm/keycloak/themes/formsflow/login/resources/css/login.css @@ -1,790 +1,642 @@ -:root{ - --main-theme: #5f79ff; - --light-blue: #0066cc29; - --required-message: #D8292F; - --label: #313132; - --add-on: #6A6E73; - --common-white: #fff; - - - - -} - - -.login-pf { - height: 100%; - overflow: hidden; -} - -.login-pf body { - height: 100%; - font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif; - margin-bottom: -50px; - background: var(--common-white); - } - - - -.login-pf-page { - height: 100%; - margin: 0; - display: flex; - flex-direction: row-reverse; - position: static; - -} - -input { - -webkit-appearance: none; -} - -.pf-c-form-control { - font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif; - font-size: 18px; - border: 1px solid #B2B5B6; - border-radius: 8px; - background-color: var(--light-blue); - height: 20px; - display: block; - outline: none; - width: 100%; - margin: 8px 0 0 0; - padding: 15px 10px; -} - -.pf-c-form-control:focus, -.pf-c-form-control:focus-visible, -.pf-c-form-control:active { - border: 2px solid var(--main-theme); - border-radius: 8px; -} - -.pf-c-form-control:focus { - border: 2px solid var(--main-theme); -} - -.pf-c-form-control[aria-invalid="true"] { - border: 1px solid var(--required-message); - border-radius: 8px; -} - -.pf-c-form-control[aria-invalid="true"]:focus, -.pf-c-form-control[aria-invalid="true"]:focus-visible { - border: 2px solid var(--required-message); -} - -.pf-c-form-control[aria-invalid="true"]:focus { - border: 2px solid var(--required-message); -} - -.pf-c-form__label { - font-size: 16px; - font-weight: bold; - line-height: 24px; - letter-spacing: 0px; - color: var(--label); -} - -.pf-c-button{ - padding:12px 32px; - margin-right: -20px; - font-size: 16px; - font-weight: bold; - outline: none; - border: 0px; - border-radius: 8px; - -} - -.form-group { - margin-top: 20px; - width: 100%; -} - -.pf-m-primary { - color: var(--common-white); - background-color: var(--main-theme); - margin-top: 10px; - margin-bottom: 0px; - cursor: pointer; - text-decoration: none !important; -} - -.pf-m-primary:hover { +/* Patternfly CSS places a "bg-login.jpg" as the background on this ".login-pf" class. + This clashes with the "keycloak-bg.png' background defined on the body below. + Therefore the Patternfly background must be set to none. */ + .login-pf { + background: none; + } - text-decoration: underline; -} - -.pf-c-button.pf-m-control { - border-color: rgba(230, 230, 230, 0.5); -} - - -h1#kc-page-title a { - font-size: 16px; - line-height: 24px; - letter-spacing: 0px; - font-weight: normal; - text-decoration: none; -} - -h1#kc-page-title .link { - margin-bottom: 40px; - margin-top: -64px; -} - -#kc-locale ul { - background-color: var(--common-white); - display: none; - top: 20px; - min-width: 100px; - padding: 0; -} - -#kc-locale:hover ul { - display: block; -} - -#kc-locale-dropdown a { - color: var(--add-on); - text-align: right; - font-size: 14px; -} - -a#kc-current-locale-link::after { - content: "\2c5"; - margin-left: 4px; -} - -.login-pf .container { - padding-top: 40px; -} - -#kc-logo { - width: 100%; -} - - -#kc-header { - background-image: url(../img/img2.png); - background-repeat: no-repeat; - background-position: top ; - background-color:transparent; - overflow: visible; - white-space: nowrap; - height: 100%; - vertical-align: middle; - margin: 0; - padding: 0 50px; - width: 572px; -} - -img { - margin-top: 10px; -} - -#kc-attempted-username { - font-size: 20px; - font-family: inherit; - font-weight: normal; - padding-right: 10px; -} - -#kc-username { - text-align: center; - margin-bottom:-10px; -} - -#kc-webauthn-settings-form { - padding-top: 8px; -} - -#kc-content-wrapper { - margin-top: 20px; -} - -#kc-register-form.kc-content-wrapper{ - margin-top: 0px; -} -#kc-form-wrapper { - margin-top: 10px; -} - -#kc-info-wrapper { - font-size: 16px; - letter-spacing: 0px; - padding: 0; - color: var(--label); - border-radius: 8px; - margin-top: 20px; -} - -#kc-form-options span { - display: block; -} - - -#kc-form-options .checkbox { - margin-top: 0; - color: var(--label); -} - - -#kc-terms-text { - margin-bottom: 20px; -} - - - -/* TOTP */ - -.subtitle { - text-align: right; - margin-top: 30px; - color: #909090; -} - -.required { - color: var(--required-message); /* default - IE compatibility */ -} - -ol#kc-totp-settings { - margin: 0; - padding-left: 20px; -} - -ul#kc-totp-supported-apps { - margin-bottom: 10px; -} - -#kc-totp-secret-qr-code { - max-width:150px; - max-height:150px; -} - -#kc-totp-secret-key { - background-color: var(--common-white); - color: #333333; - font-size: 16px; - padding: 10px 0; -} - -/* OAuth */ - -#kc-oauth h3 { - margin-top: 0; -} - -#kc-oauth ul { - list-style: none; - padding: 0; - margin: 0; -} - -#kc-oauth ul li { - border-top: 1px solid rgba(255, 255, 255, 0.1); - font-size: 12px; - padding: 10px 0; -} - -#kc-oauth ul li:first-of-type { - border-top: 0; -} - -#kc-oauth .kc-role { - display: inline-block; - width: 50%; -} - -/* Code */ -#kc-code textarea { - width: 100%; - height: 8em; -} - -/* Social */ -.kc-social-links { - margin-top: 20px; -} - -.kc-social-provider-logo { - font-size: 23px; - width: 30px; - height: 25px; - float: left; -} - -.kc-social-gray { - color: black; /* default - IE compatibility */ -} - -.kc-social-item { - margin-bottom: 0.5rem; /* default - IE compatibility */ - font-size: 15px; - text-align: center; -} - -.kc-social-provider-name { - position: relative; - top: 3px; -} - -.kc-social-icon-text { - left: -15px; -} - -.kc-social-grid { - display:grid; - grid-column-gap: 10px; - grid-row-gap: 5px; - grid-column-end: span 6; - --pf-l-grid__item--GridColumnEnd: span 6; -} - -.kc-social-grid .kc-social-icon-text { - left: -10px; -} - -.kc-login-tooltip { - position: relative; - display: inline-block; -} - -.kc-social-section { - text-align: center; -} - -.kc-social-section hr{ - margin-bottom: 10px -} - -.kc-login-tooltip .kc-tooltip-text{ - top:-3px; - left:160%; - background-color: black; - visibility: hidden; - color: var(--common-white); - - min-width:130px; - text-align: center; - border-radius: 2px; - box-shadow:0 1px 8px rgba(0,0,0,0.6); - padding: 5px; - - position: absolute; - opacity:0; - transition:opacity 0.5s; -} - -/* Show tooltip */ -.kc-login-tooltip:hover .kc-tooltip-text { - visibility: visible; - opacity:0.7; -} - -/* Arrow for tooltip */ -.kc-login-tooltip .kc-tooltip-text::after { - content: " "; - position: absolute; - top: 15px; - right: 100%; - margin-top: -5px; - border-width: 5px; - border-style: solid; - border-color: transparent black transparent transparent; -} - -.pf-c-alert.pf-m-inline { - margin-bottom: 0.5rem; /* default - IE compatibility */ - padding: 0; - display: -ms-flexbox; - display: grid; - -ms-grid-columns: max-content 1fr max-content; - grid-template-columns:max-content 1fr max-content; - grid-template-rows: 1fr auto; - border-radius: 8px; -} - -.pf-c-alert.pf-m-inline.pf-m-success { - background-color: #DFF0D8; -} - -.pf-c-alert.pf-m-inline.pf-m-danger { - background-color: #F2DEDE; -} - -.pf-c-alert.pf-m-inline.pf-m-warning { - background-color: #F9F1C6; -} - -.pf-c-alert.pf-m-inline .pf-c-alert__icon { - padding: 16px; - font-size: 24px; -} - -.pf-c-alert.pf-m-success .pf-c-alert__icon { - color: #2D4822; -} - -.pf-c-alert.pf-m-success .pf-c-alert__title { - color: #2D4822; -} - -.pf-c-alert.pf-m-danger .pf-c-alert__icon { - color: #A12722; -} - -.pf-c-alert.pf-m-danger .pf-c-alert__title { - color: #A12722; -} - -.pf-c-alert.pf-m-warning .pf-c-alert__icon { - color: #6C4A00; -} - -.pf-c-alert.pf-m-warning .pf-c-alert__title { - color: #6C4A00; -} - -.pf-c-alert__title { - font-size: 16px; - line-height: 24px; - padding: 16px 16px 16px 0; -} - -.card-pf form.form-actions .btn { - float: right; - margin-left: 10px; -} - - - -.login-pf-page .login-pf-brand { - margin-top: 20px; - max-width: 360px; - width: 40%; -} - -.select-auth-box-arrow{ - display: flex; - align-items: center; - margin-right: 2rem; -} - -.select-auth-box-icon{ - display: flex; - flex: 0 0 2em; - justify-content: center; - margin-right: 1rem; - margin-left: 3rem; -} - -.select-auth-box-parent{ - padding-top: 1rem; - padding-bottom: 1rem; - cursor: pointer; -} - -.select-auth-box-parent:hover{ - background-color: #f7f8f8; -} - -.select-auth-container { - padding-bottom: 0px !important; -} - -.select-auth-box-headline { - font-weight: bold; -} - -.select-auth-box-desc { - font-size: 14px; -} - -.card-pf { - margin: 100px 115px 5px 0px; - padding: 0 50px; - max-width: 588px; - width: 60%; - border: 0; - position: static; - margin-top: 14px; - overflow: auto; -} - -#create-account { - margin-top: 40px; - padding-top: 40px; - border-top: 1px solid #DBDCDC; - width: 100%; -} - -#create-account a { - font-weight: bold; -} - -/* .login-pf-header { - border-bottom: 1px solid #DBDCDC; -} */ - -h1#kc-page-title { - font-size: 32px; - line-height: 36px; - text-align: left; - font-weight: bold; - letter-spacing: -0.48px; - color: var(--label); - margin: 0; - margin-top: 80px; - text-transform: uppercase; -} - -@media (min-width: 768px) { - .login-pf-header { - padding-bottom: 24px; - } - - #kc-container-wrapper { - position: absolute; - width: 100%; - } - - .login-pf .container { - padding-right: 80px; - } - - #kc-locale { - position: relative; - text-align: right; - z-index: 9999; - } -} - -.login-pf-settings { - /* padding-top: 16px; */ - text-align: right; - /* width: 582px; */ - -} - -#kc-form-buttons.form-group { - + .login-pf body { + background: url(../img/img2.png) no-repeat; + /* background-position: center; */ + background-size: 50rem 40rem; + } + + textarea.pf-c-form-control { + height: auto; + } + + .pf-c-alert__title { + font-size: var(--pf-global--FontSize--xs); + } + + p.instruction { + margin: 5px 0; + } + + .pf-c-button.pf-m-control { + border-color: rgba(230, 230, 230, 0.5); + } + + h1#kc-page-title { + margin-top: 10px; + } + + #kc-locale ul { + background-color: var(--pf-global--BackgroundColor--100); + display: none; + top: 20px; + min-width: 100px; + padding: 0; + } + + #kc-locale-dropdown{ + display: inline-block; + } + + #kc-locale-dropdown:hover ul { + display:block; + } + + #kc-locale-dropdown a { + color: var(--pf-global--Color--200); + text-align: right; + font-size: var(--pf-global--FontSize--sm); + } + + #kc-locale-dropdown button { + background: none; + border: none; + padding: 0; + cursor: pointer; + color: var(--pf-global--Color--200); + text-align: right; + font-size: var(--pf-global--FontSize--sm); + } + + button#kc-current-locale-link::after { + content: "\2c5"; + margin-left: var(--pf-global--spacer--xs) + } + + .login-pf .container { + padding-top: 40px; + } + + .login-pf a:hover { + color: #0099d3; + } + + #kc-logo { + width: 100%; + } + + div.kc-logo-text { + /* background-image: url(../img/keycloak-logo-text.png); */ + background-repeat: no-repeat; + height: 63px; + width: 300px; + margin: 0 auto; + } + + div.kc-logo-text span { + display: none; + } + + #kc-header { + color: #ededed; + overflow: visible; + white-space: nowrap; + } + + #kc-header-wrapper { + font-size: 29px; + text-transform: uppercase; + letter-spacing: 3px; + line-height: 1.2em; + padding: 62px 10px 20px; + white-space: normal; + } + + #kc-content { + width: 100%; + } + + #kc-attempted-username { + font-size: 20px; + font-family: inherit; + font-weight: normal; + padding-right: 10px; + } + + #kc-username { + text-align: center; + margin-bottom:-10px; + } + + #kc-webauthn-settings-form { + padding-top: 8px; + } + + #kc-form-webauthn .select-auth-box-parent { + pointer-events: none; + } + + #kc-form-webauthn .select-auth-box-desc { + color: var(--pf-global--palette--black-600); + } + + #kc-form-webauthn .select-auth-box-headline { + color: var(--pf-global--Color--300); + } + + #kc-form-webauthn .select-auth-box-icon { + flex: 0 0 3em; + } + + #kc-form-webauthn .select-auth-box-icon-properties { + margin-top: 10px; + font-size: 1.8em; + } + + #kc-form-webauthn .select-auth-box-icon-properties.unknown-transport-class { + margin-top: 3px; + } + + #kc-form-webauthn .pf-l-stack__item { + margin: -1px 0; + } + + #kc-content-wrapper { + margin-top: 20px; + } + + #kc-form-wrapper { + margin-top: 10px; + } + + #kc-info { + margin: 20px -40px -30px; + } + + #kc-info-wrapper { + font-size: 13px; + padding: 15px 35px; + background-color: #F0F0F0; + } + + #kc-form-options span { + display: block; + } + + #kc-form-options .checkbox { + margin-top: 0; + color: #72767b; + } + + #kc-terms-text { + margin-bottom: 20px; + } + + #kc-registration-terms-text { + max-height: 100px; + overflow-y: auto; + overflow-x: hidden; + margin: 5px; + } + + #kc-registration { + margin-bottom: 0; + } + + /* TOTP */ + + .subtitle { + text-align: right; + margin-top: 30px; + color: #909090; + } + + .required { + color: var(--pf-global--danger-color--200); + } + + ol#kc-totp-settings { + margin: 0; + padding-left: 20px; + } + + ul#kc-totp-supported-apps { + margin-bottom: 10px; + } + + #kc-totp-secret-qr-code { + max-width:150px; + max-height:150px; + } + + #kc-totp-secret-key { + background-color: #fff; + color: #333333; + font-size: 16px; + padding: 10px 0; + } + + /* OAuth */ + + #kc-oauth h3 { + margin-top: 0; + } + + #kc-oauth ul { + list-style: none; + padding: 0; + margin: 0; + } + + #kc-oauth ul li { + border-top: 1px solid rgba(255, 255, 255, 0.1); + font-size: 12px; + padding: 10px 0; + } + + #kc-oauth ul li:first-of-type { + border-top: 0; + } + + #kc-oauth .kc-role { + display: inline-block; + width: 50%; + } + + /* Code */ + #kc-code textarea { + width: 100%; + height: 8em; + } + + /* Social */ + .kc-social-links { + margin-top: 20px; + } + + .kc-social-links li { + width: 100%; + } + + .kc-social-provider-logo { + font-size: 23px; + width: 30px; + height: 25px; + float: left; + } + + .kc-social-gray { + color: var(--pf-global--Color--200); + } + + .kc-social-gray h2 { + font-size: 1em; + } + + .kc-social-item { + margin-bottom: var(--pf-global--spacer--sm); + font-size: 15px; + text-align: center; + } + + .kc-social-provider-name { + position: relative; + } + + .kc-social-icon-text { + left: -15px; + } + + .kc-social-grid { + display:grid; + grid-column-gap: 10px; + grid-row-gap: 5px; + grid-column-end: span 6; + --pf-l-grid__item--GridColumnEnd: span 6; + } + + .kc-social-grid .kc-social-icon-text { + left: -10px; + } + + .kc-login-tooltip { + position: relative; + display: inline-block; + } + + .kc-social-section { + text-align: center; + } + + .kc-social-section hr{ + margin-bottom: 10px + } + + .kc-login-tooltip .kc-tooltip-text{ + top:-3px; + left:160%; + background-color: black; + visibility: hidden; + color: #fff; + + min-width:130px; + text-align: center; + border-radius: 2px; + box-shadow:0 1px 8px rgba(0,0,0,0.6); + padding: 5px; + + position: absolute; + opacity:0; + transition:opacity 0.5s; + } + + /* Show tooltip */ + .kc-login-tooltip:hover .kc-tooltip-text { + visibility: visible; + opacity:0.7; + } + + /* Arrow for tooltip */ + .kc-login-tooltip .kc-tooltip-text::after { + content: " "; + position: absolute; + top: 15px; + right: 100%; + margin-top: -5px; + border-width: 5px; + border-style: solid; + border-color: transparent black transparent transparent; + } + + @media (min-width: 768px) { + #kc-container-wrapper { + position: absolute; + width: 100%; + } + + .login-pf .container { + padding-right: 80px; + } + + #kc-locale { + position: relative; + text-align: right; + z-index: 9999; + } + } + + @media (max-width: 767px) { + + .login-pf body { + background: none; + /* background-size: cover; */ + background-size: 50rem 40rem; + } + + #kc-header { + float: none; + text-align: left; + } + + #kc-header-wrapper { + font-size: 16px; + font-weight: bold; + color: #72767b; + letter-spacing: 0; + padding: 0; + } + + div.kc-logo-text { + margin: 0; + width: 150px; + height: 32px; + background-size: 100%; + } + + #kc-form { + float: none; + } + + #kc-info-wrapper { + border-top: 1px solid rgba(255, 255, 255, 0.1); + background-color: transparent; + } + + .login-pf .container { + padding-top: 15px; + padding-bottom: 15px; + } + + #kc-locale { + position: absolute; + width: 200px; + top: 20px; + right: 20px; + text-align: right; + z-index: 9999; + } + } + + @media (min-height: 646px) { + #kc-container-wrapper { + bottom: 12%; + } + } + + @media (max-height: 645px) { + #kc-container-wrapper { + padding-top: 50px; + top: 20%; + } + } + + .card-pf form.form-actions .btn { + float: right; + margin-left: 10px; + } + + #kc-form-buttons { + margin-top: 20px; + } + + .login-pf-page .login-pf-brand { + margin-top: 20px; + max-width: 360px; + width: 40%; + } + + .select-auth-box-arrow{ + display: flex; + align-items: center; + margin-right: 2rem; + } + + .select-auth-box-icon{ + display: flex; + flex: 0 0 2em; + justify-content: center; + margin-right: 1rem; + margin-left: 3rem; + } + + .select-auth-box-parent{ + border-top: 1px solid var(--pf-global--palette--black-200); + padding-top: 1rem; + padding-bottom: 1rem; + cursor: pointer; + text-align: left; + align-items: unset; + background-color: unset; + border-right: unset; + border-bottom: unset; + border-left: unset; + } + + .select-auth-box-parent:hover{ + background-color: #f7f8f8; + } + + .select-auth-container { + padding-bottom: 0px !important; + } + + .select-auth-box-headline { + font-size: var(--pf-global--FontSize--md); + color: var(--pf-global--primary-color--100); + font-weight: bold; + } + + .select-auth-box-desc { + font-size: var(--pf-global--FontSize--sm); + } + + .select-auth-box-paragraph { + text-align: center; + font-size: var(--pf-global--FontSize--md); + margin-bottom: 5px; + } + + .card-pf { + padding: 5rem !important; + margin: 0 10rem 0 auto; + box-shadow: var(--pf-global--BoxShadow--lg); + /* padding: 0 20px; */ + max-width: 500px; + /* float: right; */ + } + + /*phone*/ + @media (max-width: 767px) { + .login-pf-page .card-pf { + /* max-width: none; */ + /* margin-left: 0; */ + border-top: 0; + /* box-shadow: 0 0; */ + margin: 0 10px ; + } + + .kc-social-grid { + grid-column-end: 12; + --pf-l-grid__item--GridColumnEnd: span 12; + } + + .kc-social-grid .kc-social-icon-text { + left: -15px; + } + .card-pf { + padding: 1.5rem ; + } + .login-pf-page { + padding-top: 0 ; + } + } + + + .login-pf-page .login-pf-signup { + font-size: 15px; + color: #72767b; + } + #kc-content-wrapper .row { + margin-left: 0; + margin-right: 0; + } + + .login-pf-page.login-pf-page-accounts { + margin-left: auto; + margin-right: auto; + } + + .login-pf-page .btn-primary { + margin-top: 0; + } + + .login-pf-page .list-view-pf .list-group-item { + border-bottom: 1px solid #ededed; + } + + .login-pf-page .list-view-pf-description { + width: 100%; + } + + #kc-form-login div.form-group:last-of-type, + #kc-register-form div.form-group:last-of-type, + #kc-update-profile-form div.form-group:last-of-type, + #kc-update-email-form div.form-group:last-of-type{ + margin-bottom: 0px; + } + + .no-bottom-margin { + margin-bottom: 0; + } + + #kc-back { + margin-top: 5px; + } + + /* Recovery codes */ + .kc-recovery-codes-warning { + margin-bottom: 32px; + } + .kc-recovery-codes-warning .pf-c-alert__description p { + font-size: 0.875rem; + } + .kc-recovery-codes-list { + list-style: none; + columns: 2; + margin: 16px 0; + padding: 16px 16px 8px 16px; + border: 1px solid #D2D2D2; + } + .kc-recovery-codes-list li { + margin-bottom: 8px; + font-size: 11px; + } + .kc-recovery-codes-list li span { + color: #6A6E73; + width: 16px; + text-align: right; + display: inline-block; + margin-right: 1px; + } + + .kc-recovery-codes-actions { + margin-bottom: 24px; + } + .kc-recovery-codes-actions button { + padding-left: 0; + } + .kc-recovery-codes-actions button i { + margin-right: 8px; + } + + .kc-recovery-codes-confirmation { + align-items: baseline; + margin-bottom: 16px; + } + + #certificate_subjectDN { + overflow-wrap: break-word + } + /* End Recovery codes */ + .login-pf-page{ display: flex; - flex-direction: row-reverse; - justify-content: flex-end; - width: 265px; - -} - - -@media (max-width: 767px) { - .login-pf-header { - padding-bottom: 16px; - } - - - .login-pf-page { - display: block; - margin: 0; - padding: 24px; - } - - - - - div.kc-header-wrapper { - margin-top: 10px; - } - - div.kc-logo-text { - margin: 0; - margin-bottom: 40px; - height: auto; - width: auto; - background-size: 102px 27px; - } - - div.kc-logo-text span { - display: inline; - padding-top: 0px; - padding-left: 118px; - font-size: 20px; - line-height: 30px; - font-weight: bold; - letter-spacing: -0.4px; - color: #003366; - } - - #kc-form { - float: none; - } - - - - .login-pf .container { - padding-top: 15px; - padding-bottom: 15px; - } - - #kc-locale { - position: absolute; - width: 200px; - top: 20px; - right: 20px; - text-align: right; - z-index: 9999; - } - - #kc-logo-wrapper { - background-size: 100px 21px; - height: 21px; - width: 100px; - margin: 20px 0 0 20px; - } - + align-items: center; + justify-content: center; + min-height: 100vh; + } - - h1#kc-page-title .link { - display: none; - } - - #create-account { - display: none; - } - - .form-group { - margin-top: 10px; - } - - .form-group p { - margin: 0; - } - - - - .kc-social-grid { - grid-column-end: 12; - --pf-l-grid__item--GridColumnEnd: span 12; - } - - .kc-social-grid .kc-social-icon-text { - left: -15px; - } - - -/* @media (max-height: 699px) { - #kc-header { - background-image: none; - } - -} */ - -/* Internet Explorer 11 compatibility workaround for select-authenticator screen */ -@media all and (-ms-high-contrast: none), -(-ms-high-contrast: active) { - .select-auth-box-parent { - border-top: 1px solid #f0f0f0; - padding-top: 1rem; - padding-bottom: 1rem; - cursor: pointer; - } - - .select-auth-box-headline { - font-size: 16px; - color: #06c; - font-weight: bold; - } - - .select-auth-box-desc { - font-size: 14px; - } - - .pf-l-stack { - flex-basis: 100%; - } -} - -.login-pf-page .login-pf-signup { - font-size: 15px; - color: #72767b; -} -#kc-content-wrapper .row { - margin-left: 0; - margin-right: 0; -} - -#kc-registration -login-pf-page-header{ - background-color: transparent; - -} - -.login-pf-page.login-pf-page-accounts { - margin-left: auto; - margin-right: auto; -} - -.login-pf-page .btn-primary { - margin-top: 0; -} - -.login-pf-page .list-view-pf .list-group-item { - border-bottom: 1px solid #ededed; -} - -.login-pf-page .list-view-pf-description { - width: 100%; -} - -#kc-form-login div.form-group:last-of-type, -#kc-register-form div.form-group:last-of-type, -#kc-update-profile-form div.form-group:last-of-type { - margin-bottom: 0px; -} - -.no-bottom-margin { - margin-bottom: 0; -} - -#kc-back { - margin-top: 5px; -} - - - -.login-pf-settings span { - padding-top: 0; - margin-top: 0; -} -} - - - -.ul-social-links { - display: flex !important; - padding: 0 !important; - flex-wrap: wrap !important; -} - -.a-social-link { - margin-bottom: 0.5rem; - font-size: 15px; - text-align: center; - padding: 0 !important; - margin: 0 !important; - color: #06c; - text-decoration: none !important; -} -.a-social-link:hover{ - color: rgb(3, 67, 131); -} - -.kc-social-provider-name{ - top: 0 !important; -} - -.login-with-social { - text-align: left !important; - margin-left: 1rem !important; -} \ No newline at end of file diff --git a/forms-flow-idm/keycloak/themes/formsflow/login/resources/img/logo.png b/forms-flow-idm/keycloak/themes/formsflow/login/resources/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d71169cb03530e72bfd6c13e29ce0c53109b34b2 GIT binary patch literal 1499 zcmb7EXH*kr6#Wu5VFW}FBC8UXvrIuEqhSO?L${wOvw%E`z1Q8-L#E1ex4X0&_ zAVWYB1O#P6SOSuOfZ!lutJO3;{pXLK(|69h_nv#+egED|cgEREOR7i$0HiSvXcqx9 z1+6YFB*5#T27>}hamM0p?ZdGG`G3pF_pk3F0_QvPU+2G3utYEr{hU!KQ2Nm>I9I@w zJ8e)B)t3yjpSRYax6YGn;m9~2hH10?NAUuOXTjL2G zV}z-^2j)`7*K4}qvGP!fspe5GpKqFX$-j2C&25F+6e`;7K5d?trR9Uo?!S7)N8JK# znbnfRZL!G&MRZ2BY}&JEMeN+I)AZTUedV06uSJXCKm&)-)ncDy7m_0MmT|HMwIaI` z{WZUjW3})hfGV3H>LU_-h$F$<$YdN|c+0=L{vf}>aeWrEn9^rt4i%u@6zUKO#9G0haw_G^7zaCCbaIq9(MN zN$G(Rv5p7}W+Kgu{TN}WweB|Jhoh!<`u@zR+*)CaqeclG8JeXou*PO1!d@=cD_MaP z&sAB%cJGsqVsxdJckH4M<_7LptwGLEgtd7+Eu_P=)+WTD-EconZa)6hf*COMHkSl< zC_pQH=EY$oRJ`Yk(R&?G80)#QI91&iP9pDWv!2<&h&Dn!^ykzKHH6C$U+Y4AJcNma zy`j6gID%`U;1)}AZwgXQVT<%Cb!%JB3ggn-nip~gTr3S)oHH12rxy@Njds=9*#s=m z{SrL!PHk(h2=7mwjR@4kc-@yCO7cCJi3WLYAK-vg7hQ(+mQ{djL<$P zvB2|bw%jO)d4`UMuC9Y{nk^!fm}ILSb2CO)&o1qVXo9|KOh?kF+O%dBA0;N0*we(y z`;4h@056;-9U+mOIp~HsVK{DynW}G7fmh@}+ml^|XWeNfysA(cHnH_(8)vzEL9Cg? zGl40C;>}0Z{U(qaM_a~da(4))!NRt9+Xqt&#g2$`UMF2yO;A-Mn)?x<00bQg6Xn4 zmlx}%;D@s77L>*+HBjP6S)&^_8!DA|6x`;O}<&)!Ae*f5S25zE8z9hz1&D9CD z$zD1S@o_SCdmu?(e5htst)y097!Tla*UWI+G94u$K|xyQA|eyUV$#=#Sy}8BLTnxl z`5D$$tlD`BR2F0;bWZUlG-ut1=6^GN8G}@RH53r6e<0FC#8M&X9(&1*qA?0j9)5^m z(O#3fx0WyyDjv6xcgvae@*&!a>o)4+ZhEzqH{j3ECPv2~LK!My<97IyeuSSKQ#@}7 zPHvxk@0jXY=@UGoia>cTS&?7vRnK-(GODSJj1{F16>qJ!bYA7(qq%d#e)sbJmG_9& skkjX_v)wKvx2gU$X0zr*!c6yY!kykXUAuT=)<4e{V~ayq+xR5^4GH(3WB>pF literal 0 HcmV?d00001 diff --git a/forms-flow-idm/keycloak/themes/formsflow/login/template.ftl b/forms-flow-idm/keycloak/themes/formsflow/login/template.ftl index 49933b6163..d6a7c95c89 100644 --- a/forms-flow-idm/keycloak/themes/formsflow/login/template.ftl +++ b/forms-flow-idm/keycloak/themes/formsflow/login/template.ftl @@ -1,6 +1,6 @@ -<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayRequiredFields=false showAnotherWayIfPresent=true> - - +<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayRequiredFields=false> + + lang="${locale.currentLanguageTag}"> @@ -29,31 +29,48 @@ + + <#if scripts??> <#list scripts as script> +
- -
-

-
+
+
+
<#if realm.internationalizationEnabled && locale.supported?size gt 1>
-
- ${locale.current} -
    + @@ -66,11 +83,15 @@
    * ${msg("requiredFields")}
    +

    <#nested "header">

<#else> +
+ Centered Image +

<#nested "header">

<#else> @@ -83,7 +104,7 @@ <#nested "show-username">
- +