diff --git a/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/Dockerfile b/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/Dockerfile new file mode 100644 index 00000000..959ee8af --- /dev/null +++ b/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/Dockerfile @@ -0,0 +1,91 @@ +#Author: Kurian Benoy +FROM python:3.9-slim-buster + +# set label for image +LABEL Name="formsflow" + +WORKDIR /forms-flow-documents/app + +# install curl, gnupg2 and unzip +RUN apt-get update \ + && apt-get install -y gnupg2 \ + && apt-get install -y curl \ + && apt-get install -y wget \ + && apt-get install -y unzip \ + && apt-get install -y git \ + && rm -rf /var/lib/apt/lists/* + +# expect a build-time variable +ARG FORMFLOW_SOURCE_REPO_URL +# expect ssh private key +ARG ssh_prv_key +# expect ssh public key +ARG ssh_pub_key +# expect a build-time variable +ARG FORMFLOW_SOURCE_REPO_BRANCH +# use the value to set the ENV var default +ENV FORMFLOW_SOURCE_REPO_BRANCH epd-ff-ee-5.2.0 +# use the value to set the ENV var default +ENV FORMFLOW_SOURCE_REPO_URL git@github.com:AOT-Technologies/forms-flow-ai-ee.git + +#RUN mkdir -p /root/.ssh && \ +# chmod 0700 /root/.ssh && \ +# echo " IdentityFile ~/.ssh/id_rsa" >> /etc/ssh/ssh_config + +RUN echo "$ssh_prv_key" | sed 's/\\n/\n/g' > /root/.ssh/id_rsa && \ + echo "$ssh_pub_key" | sed 's/\\n/\n/g' > /root/.ssh/id_rsa.pub && \ + chmod 600 /root/.ssh/id_rsa && \ + chmod 600 /root/.ssh/id_rsa.pub + +COPY id_rsa_epd /root/.ssh/id_rsa +COPY id_rsa_epd.pub /root/.ssh/id_rsa.pub + +RUN chmod 600 /root/.ssh/id_rsa + +RUN chmod 600 /root/.ssh/id_rsa.pub + +RUN mkdir -p /root/.ssh && ssh-keyscan github.com >> /root/.ssh/known_hosts + +# Clone code +RUN git clone ${FORMFLOW_SOURCE_REPO_URL} -b ${FORMFLOW_SOURCE_REPO_BRANCH} /documents/ + +RUN cp -r /documents/forms-flow-documents/. /forms-flow-documents/app + +#Copy custom files for EPD +COPY ./src/formsflow_documents/resources /forms-flow-documents/app/src/formsflow_documents/resources +COPY ./src/formsflow_documents/services /forms-flow-documents/app/src/formsflow_documents/services +COPY ./src/formsflow_documents/static /forms-flow-documents/app/src/formsflow_documents/static +COPY ./src/formsflow_documents /forms-flow-documents/app/src/formsflow_documents + +# Install Chrome WebDriver - version 116.0.5845.96 +RUN mkdir -p /opt/chromedriver && \ + curl -sS -o /tmp/chromedriver_linux64.zip https://formsflow-documentsapi.aot-technologies.com/chromedriver-linux64.zip && \ + unzip -qq /tmp/chromedriver_linux64.zip -d /opt/chromedriver && \ + rm /tmp/chromedriver_linux64.zip && \ + chmod +x /opt/chromedriver/chromedriver-linux64/chromedriver && \ + ln -fs /opt/chromedriver/chromedriver-linux64/chromedriver /usr/local/bin/chromedriver + +# Install Google Chrome +RUN wget --no-verbose -O /tmp/chrome.deb https://formsflow-documentsapi.aot-technologies.com/google-chrome-stable_116.0.5845.140-1_amd64.deb &&\ + apt-get update && \ + apt install -y /tmp/chrome.deb &&\ + rm /tmp/chrome.deb + + +# set display port to avoid crash +ENV DISPLAY=:99 + +COPY requirements.txt . +ENV PATH=/venv/bin:$PATH + +RUN : \ + && python3 -m venv /venv \ + && pip install --upgrade pip \ + && pip install -r requirements.txt + +ADD . /forms-flow-documents/app +RUN pip install -e . + +EXPOSE 5006 +RUN chmod u+x ./entrypoint +ENTRYPOINT ["/bin/sh", "entrypoint"] diff --git a/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/docker-compose.yml b/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/docker-compose.yml new file mode 100644 index 00000000..a7531db0 --- /dev/null +++ b/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/docker-compose.yml @@ -0,0 +1,44 @@ +version: '3.7' + +services: + forms-flow-documents-api: + build: + context: ./ + dockerfile: Dockerfile + restart: unless-stopped + ports: + - '5006:5006' + volumes: + - ./:/app:rw + environment: + DATABASE_URL: ${FORMSFLOW_API_DB_URL:-postgresql://postgres:changeme@forms-flow-webapi-db:5432/webapi} + FORMSFLOW_API_CORS_ORIGINS: ${FORMSFLOW_API_CORS_ORIGINS:-*} + JWT_OIDC_WELL_KNOWN_CONFIG: ${KEYCLOAK_URL}/auth/realms/${KEYCLOAK_URL_REALM:-forms-flow-ai}/.well-known/openid-configuration + JWT_OIDC_JWKS_URI: ${KEYCLOAK_URL}/auth/realms/${KEYCLOAK_URL_REALM:-forms-flow-ai}/protocol/openid-connect/certs + JWT_OIDC_ISSUER: ${KEYCLOAK_URL}/auth/realms/${KEYCLOAK_URL_REALM:-forms-flow-ai} + JWT_OIDC_AUDIENCE: ${KEYCLOAK_WEB_CLIENT_ID:-forms-flow-web} + JWT_OIDC_CACHING_ENABLED: 'True' + KEYCLOAK_URL: ${KEYCLOAK_URL} + KEYCLOAK_URL_REALM: ${KEYCLOAK_URL_REALM:-forms-flow-ai} + FORMSFLOW_API_URL: ${FORMSFLOW_API_URL} + FORMSFLOW_DOC_API_URL: ${FORMSFLOW_DOC_API_URL} + FORMIO_URL: ${FORMIO_DEFAULT_PROJECT_URL} + FORMIO_ROOT_EMAIL: ${FORMIO_ROOT_EMAIL:-admin@example.com} + FORMIO_ROOT_PASSWORD: ${FORMIO_ROOT_PASSWORD:-changeme} + CHROME_DRIVER_PATH: ${CHROME_DRIVER_PATH} + CUSTOM_SUBMISSION_URL: ${CUSTOM_SUBMISSION_URL} + CUSTOM_SUBMISSION_ENABLED: ${CUSTOM_SUBMISSION_ENABLED} + FORMIO_JWT_SECRET: ${FORMIO_JWT_SECRET:---- change me now ---} + MULTI_TENANCY_ENABLED: ${MULTI_TENANCY_ENABLED:-false} + KEYCLOAK_ENABLE_CLIENT_AUTH: ${KEYCLOAK_ENABLE_CLIENT_AUTH:-false} + stdin_open: true # -i + tty: true # -t + networks: + - forms-flow-webapi-network + +networks: + forms-flow-webapi-network: + driver: 'bridge' + +volumes: + mdb-data: diff --git a/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/entrypoint b/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/entrypoint new file mode 100644 index 00000000..17cf664f --- /dev/null +++ b/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/entrypoint @@ -0,0 +1,2 @@ +#!/bin/bash +gunicorn -b :5006 'formsflow_documents:create_app()' --timeout 120 --worker-class=gthread --workers=5 --threads=10 diff --git a/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/requirements.txt b/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/requirements.txt new file mode 100644 index 00000000..b4d70682 --- /dev/null +++ b/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/requirements.txt @@ -0,0 +1,67 @@ +Brotli==1.0.9 +Flask-Caching==2.0.2 +Flask-Migrate==4.0.4 +Flask-Moment==1.0.5 +Flask-SQLAlchemy==3.0.5 +Flask==2.3.2 +Jinja2==3.1.2 +Mako==1.2.4 +MarkupSafe==2.1.3 +PyJWT==2.7.0 +PySocks==1.7.1 +SQLAlchemy-Utils==0.41.1 +SQLAlchemy==2.0.17 +Werkzeug==2.3.6 +alembic==1.11.1 +aniso8601==9.0.1 +async-generator==1.10 +attrs==23.1.0 +blinker==1.6.2 +cachelib==0.9.0 +certifi==2023.5.7 +cffi==1.15.1 +charset-normalizer==3.1.0 +click==8.1.3 +cryptography==41.0.1 +ecdsa==0.18.0 +exceptiongroup==1.1.1 +flask-jwt-oidc==0.3.0 +flask-marshmallow==0.15.0 +flask-restx==1.1.0 +formsflow-api-utils @ git+https://github.com/AOT-Technologies/forms-flow-ai.git@epd-5.2.2#subdirectory=forms-flow-api-utils +gunicorn==20.1.0 +h11==0.14.0 +h2==4.1.0 +hpack==4.0.0 +hyperframe==6.0.1 +idna==3.4 +itsdangerous==2.1.2 +jsonschema==4.17.3 +kaitaistruct==0.10 +marshmallow-sqlalchemy==0.29.0 +marshmallow==3.19.0 +nested-lookup==0.2.25 +outcome==1.2.0 +packaging==23.1 +psycopg2-binary==2.9.6 +pyOpenSSL==23.2.0 +pyasn1==0.5.0 +pycparser==2.21 +pyparsing==3.1.0 +pyrsistent==0.19.3 +python-dotenv==1.0.0 +python-jose==3.3.0 +pytz==2023.3 +requests==2.31.0 +rsa==4.9 +selenium-wire==5.1.0 +selenium==4.10.0 +six==1.16.0 +sniffio==1.3.0 +sortedcontainers==2.4.0 +trio-websocket==0.10.3 +trio==0.22.0 +typing_extensions==4.7.0 +urllib3==2.0.3 +wsproto==1.2.0 +zstandard==0.21.0 diff --git a/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/sample.env b/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/sample.env new file mode 100644 index 00000000..274815f4 --- /dev/null +++ b/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/sample.env @@ -0,0 +1,48 @@ +############################################################################### +# This file is a sample file, for Docker compose to work with the settings # +# rename this file to .env # +# Uncomment the variables if any changes from the default values # +############################################################################### +# ===== formsflow.ai Python Exportapi ENV Variables - START ===================== + + + +##Environment variables for WEB_API/FORMSFLOW_EXPORT_API in the adaptive tier. +##DB Connection URL for formsflow.ai +#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 + +##URL to your Keycloak server +KEYCLOAK_URL=http://{your-ip-address}:8080 +##The Keycloak realm to use +#KEYCLOAK_URL_REALM=forms-flow-ai +#KEYCLOAK_BPM_CLIENT_ID=forms-flow-bpm +#KEYCLOAK_WEB_CLIENT_ID=forms-flow-web +#KEYCLOAK_BPM_CLIENT_SECRET=e4bdbd25-1467-4f7f-b993-bc4b1944c943 + +##web Api End point +FORMSFLOW_API_URL=http://{your-ip-address}:5000 +## Port should whether Docker starts internally +FORMSFLOW_DOC_API_URL=http://{your-ip-address}:5006 +CHROME_DRIVER_PATH=/usr/local/bin/chromedriver +##web API CORS origins +FORMSFLOW_API_CORS_ORIGINS=* + +##Env For Unit Testing +# TEST_REVIEWER_USERID= +# TEST_REVIEWER_PASSWORD= +# DATABASE_URL_TEST= + +#FORMIO configuration +FORMIO_DEFAULT_PROJECT_URL=http://{your-ip-address}:3001 +FORMIO_ROOT_EMAIL=admin@example.com +FORMIO_ROOT_PASSWORD=changeme + +#custom submission +CUSTOM_SUBMISSION_ENABLED=false +CUSTOM_SUBMISSION_URL=http://{your-ip-address}:6212 diff --git a/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/src/formsflow_documents/config.py b/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/src/formsflow_documents/config.py new file mode 100644 index 00000000..d30e2bcf --- /dev/null +++ b/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/src/formsflow_documents/config.py @@ -0,0 +1,195 @@ +"""All of the configuration for the api is captured here. + +All items are loaded, or have Constants defined here that +are loaded into the Flask configuration. +All modules and lookups get their configuration from the +Flask config, rather than reading environment variables directly +or by accessing this configuration directly. +""" + +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) +load_dotenv(find_dotenv()) + +CONFIGURATION = { + "development": "formsflow_documents.config.DevConfig", + "testing": "formsflow_documents.config.TestConfig", + "production": "formsflow_documents.config.ProdConfig", + "default": "formsflow_documents.config.ProdConfig", +} + + +def get_named_config(config_name: str = "production"): + """Return the configuration object based on the name. + + :raise: KeyError: if an unknown configuration is requested + """ + if config_name in ["production", "staging", "default"]: + config = ProdConfig() + elif config_name == "testing": + config = TestConfig() + elif config_name == "development": + config = DevConfig() + else: + raise KeyError(f"Unknown configuration '{config_name}'") + return config + + +class _Config: # pylint: disable=too-few-public-methods + """Base class configuration. + + that should set reasonable defaults for all the other configurations. + """ + + PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) + + SECRET_KEY = "secret value" + + SQLALCHEMY_TRACK_MODIFICATIONS = False + + TESTING = False + DEBUG = False + + # JWT_OIDC Settings + JWT_OIDC_WELL_KNOWN_CONFIG = os.getenv("JWT_OIDC_WELL_KNOWN_CONFIG") + JWT_OIDC_ALGORITHMS = os.getenv("JWT_OIDC_ALGORITHMS", "RS256") + JWT_OIDC_JWKS_URI = os.getenv("JWT_OIDC_JWKS_URI") + JWT_OIDC_ISSUER = os.getenv("JWT_OIDC_ISSUER") + JWT_OIDC_AUDIENCE = os.getenv("JWT_OIDC_AUDIENCE") + JWT_OIDC_CACHING_ENABLED = os.getenv("JWT_OIDC_CACHING_ENABLED") + JWT_OIDC_JWKS_CACHE_TIMEOUT = 300 + + # Formio url + FORMIO_URL = os.getenv("FORMIO_URL") + FORMIO_USERNAME = os.getenv("FORMIO_ROOT_EMAIL") + FORMIO_PASSWORD = os.getenv("FORMIO_ROOT_PASSWORD") + + # API Base URL (Self) + FORMSFLOW_DOC_API_URL = os.getenv("FORMSFLOW_DOC_API_URL") + CHROME_DRIVER_PATH = os.getenv("CHROME_DRIVER_PATH", "/usr/local/bin/chromedriver") + + CUSTOM_SUBMISSION_URL = os.getenv("CUSTOM_SUBMISSION_URL", "") + CUSTOM_SUBMISSION_ENABLED = ( + os.getenv("CUSTOM_SUBMISSION_ENABLED", "false").lower() == "true" + ) + + # Keycloak client authorization enabled flag + KEYCLOAK_ENABLE_CLIENT_AUTH = ( + str(os.getenv("KEYCLOAK_ENABLE_CLIENT_AUTH", default="false")).lower() == "true" + ) + MULTI_TENANCY_ENABLED = ( + str(os.getenv("MULTI_TENANCY_ENABLED", default="false")).lower() == "true" + ) + + # Webapi url + FORMSFLOW_API_URL = os.getenv("FORMSFLOW_API_URL") + +class DevConfig(_Config): # pylint: disable=too-few-public-methods + """Development environment configuration.""" + + TESTING = False + DEBUG = True + + +class TestConfig(_Config): # pylint: disable=too-few-public-methods + """In support of testing only used by the py.test suite.""" + + DEBUG = True + TESTING = True + + FORMSFLOW_API_URL = os.getenv("WEB_API_BASE_URL") + # POSTGRESQL + SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL_TEST") + + JWT_OIDC_TEST_MODE = True + # USE_TEST_KEYCLOAK_DOCKER = os.getenv("USE_TEST_KEYCLOAK_DOCKER") + USE_DOCKER_MOCK = os.getenv("USE_DOCKER_MOCK", default=None) + + # JWT_OIDC Settings + JWT_OIDC_TEST_AUDIENCE = os.getenv("JWT_OIDC_AUDIENCE") + JWT_OIDC_TEST_ISSUER = os.getenv("JWT_OIDC_ISSUER") + JWT_OIDC_TEST_WELL_KNOWN_CONFIG = os.getenv("JWT_OIDC_WELL_KNOWN_CONFIG") + JWT_OIDC_TEST_ALGORITHMS = "RS256" + JWT_OIDC_TEST_JWKS_URI = os.getenv("JWT_OIDC_JWKS_URI") + JWT_OIDC_TEST_JWKS_CACHE_TIMEOUT = 6000 + + # Keycloak Service for BPM Camunda + KEYCLOAK_URL_REALM = os.getenv("KEYCLOAK_URL_REALM", default="forms-flow-ai") + KEYCLOAK_URL = os.getenv("KEYCLOAK_URL", default="http://localhost:8081") + + # Use docker to spin up mocks + USE_DOCKER_MOCK = os.getenv("USE_DOCKER_MOCK", "False").lower() == "true" + + JWT_OIDC_TEST_KEYS = { + "keys": [ + { + "kid": JWT_OIDC_TEST_AUDIENCE, + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "n": "AN-fWcpCyE5KPzHDjigLaSUVZI0uYrcGcc40InVtl-rQRDmAh-C2W8H4_Hxhr5VLc6crsJ2LiJTV_E72S03pzpOOaaYV6-" + "TzAjCou2GYJIXev7f6Hh512PuG5wyxda_TlBSsI-gvphRTPsKCnPutrbiukCYrnPuWxX5_cES9eStR", + "e": "AQAB", + } + ] + } + + JWT_OIDC_TEST_PRIVATE_KEY_JWKS = { + "keys": [ + { + "kid": JWT_OIDC_TEST_AUDIENCE, + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "n": "AN-fWcpCyE5KPzHDjigLaSUVZI0uYrcGcc40InVtl-rQRDmAh-C2W8H4_Hxhr5VLc6crsJ2LiJTV_E72S03pzpOOaaYV6-" + "TzAjCou2GYJIXev7f6Hh512PuG5wyxda_TlBSsI-gvphRTPsKCnPutrbiukCYrnPuWxX5_cES9eStR", + "e": "AQAB", + "d": "C0G3QGI6OQ6tvbCNYGCqq043YI_8MiBl7C5dqbGZmx1ewdJBhMNJPStuckhskURaDwk4-" + "8VBW9SlvcfSJJrnZhgFMjOYSSsBtPGBIMIdM5eSKbenCCjO8Tg0BUh_" + "xa3CHST1W4RQ5rFXadZ9AeNtaGcWj2acmXNO3DVETXAX3x0", + "p": "APXcusFMQNHjh6KVD_hOUIw87lvK13WkDEeeuqAydai9Ig9JKEAAfV94W6Aftka7tGgE7ulg1vo3eJoLWJ1zvKM", + "q": "AOjX3OnPJnk0ZFUQBwhduCweRi37I6DAdLTnhDvcPTrrNWuKPg9uGwHjzFCJgKd8KBaDQ0X1rZTZLTqi3peT43s", + "dp": "AN9kBoA5o6_Rl9zeqdsIdWFmv4DB5lEqlEnC7HlAP-3oo3jWFO9KQqArQL1V8w2D4aCd0uJULiC9pCP7aTHvBhc", + "dq": "ANtbSY6njfpPploQsF9sU26U0s7MsuLljM1E8uml8bVJE1mNsiu9MgpUvg39jEu9BtM2tDD7Y51AAIEmIQex1nM", + "qi": "XLE5O360x-MhsdFXx8Vwz4304-MJg-oGSJXCK_ZWYOB_FGXFRTfebxCsSYi0YwJo-oNu96bvZCuMplzRI1liZw", + } + ] + } + + JWT_OIDC_TEST_PRIVATE_KEY_PEM = """ +-----BEGIN RSA PRIVATE KEY----- +MIICXQIBAAKBgQDfn1nKQshOSj8xw44oC2klFWSNLmK3BnHONCJ1bZfq0EQ5gIfg +tlvB+Px8Ya+VS3OnK7Cdi4iU1fxO9ktN6c6TjmmmFevk8wIwqLthmCSF3r+3+h4e +ddj7hucMsXWv05QUrCPoL6YUUz7Cgpz7ra24rpAmK5z7lsV+f3BEvXkrUQIDAQAB +AoGAC0G3QGI6OQ6tvbCNYGCqq043YI/8MiBl7C5dqbGZmx1ewdJBhMNJPStuckhs +kURaDwk4+8VBW9SlvcfSJJrnZhgFMjOYSSsBtPGBIMIdM5eSKbenCCjO8Tg0BUh/ +xa3CHST1W4RQ5rFXadZ9AeNtaGcWj2acmXNO3DVETXAX3x0CQQD13LrBTEDR44ei +lQ/4TlCMPO5bytd1pAxHnrqgMnWovSIPSShAAH1feFugH7ZGu7RoBO7pYNb6N3ia +C1idc7yjAkEA6Nfc6c8meTRkVRAHCF24LB5GLfsjoMB0tOeEO9w9Ous1a4o+D24b +AePMUImAp3woFoNDRfWtlNktOqLel5PjewJBAN9kBoA5o6/Rl9zeqdsIdWFmv4DB +5lEqlEnC7HlAP+3oo3jWFO9KQqArQL1V8w2D4aCd0uJULiC9pCP7aTHvBhcCQQDb +W0mOp436T6ZaELBfbFNulNLOzLLi5YzNRPLppfG1SRNZjbIrvTIKVL4N/YxLvQbT +NrQw+2OdQACBJiEHsdZzAkBcsTk7frTH4yGx0VfHxXDPjfTj4wmD6gZIlcIr9lZg +4H8UZcVFN95vEKxJiLRjAmj6g273pu9kK4ymXNEjWWJn +-----END RSA PRIVATE KEY----- +""" + + +class ProdConfig(_Config): # pylint: disable=too-few-public-methods + """Production environment configuration.""" + + SECRET_KEY = os.getenv("SECRET_KEY", None) + SQLALCHEMY_ENGINE_OPTIONS = { + "pool_pre_ping": True, + "pool_recycle": 300, + } + + if not SECRET_KEY: + SECRET_KEY = os.urandom(24) + # print("WARNING: SECRET_KEY being set as a one-shot", file=sys.stderr) + + TESTING = False + DEBUG = False diff --git a/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/src/formsflow_documents/resources/pdf.py b/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/src/formsflow_documents/resources/pdf.py new file mode 100644 index 00000000..ea8303f3 --- /dev/null +++ b/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/src/formsflow_documents/resources/pdf.py @@ -0,0 +1,145 @@ +"""API endpoints for managing form resource.""" + +import string +from http import HTTPStatus + +from flask import current_app, make_response, render_template, request +from flask_restx import Namespace, Resource +from formsflow_api_utils.exceptions import BusinessException +from formsflow_api_utils.utils import ( + CLIENT_GROUP, + REVIEWER_GROUP, + auth, + cors_preflight, + profiletime, +) +from werkzeug.utils import secure_filename + +from formsflow_documents.services import PDFService +from formsflow_documents.utils import DocUtils + +API = Namespace("Form", description="Form") + + +@API.route("//submission//render", doc=False) +class FormResourceRenderPdf(Resource): + """Resource to render form and submission details as html.""" + + @staticmethod + @auth.require + @profiletime + def get(form_id: string, submission_id: string): + """Form rendering method.""" + is_bundle = bool(request.args.get("is_bundle")) + mapper_id = request.args.get("mapper_id", None) + pdf_service = PDFService( + form_id=form_id, + submission_id=submission_id, + is_bundle=is_bundle, + mapper_id=mapper_id, + ) + default_template = "index.html" + template_name = request.args.get("template_name") + template_variable_name = request.args.get("template_variable") + use_template = bool(template_name) + + template_name = ( + DocUtils.url_decode(secure_filename(template_name)) + if use_template + else default_template + ) + + template_variable_name = ( + DocUtils.url_decode(secure_filename(template_variable_name)) + if template_variable_name + else None + ) + if not pdf_service.search_template(template_name): + raise BusinessException("Template not found!", HTTPStatus.BAD_REQUEST) + if template_variable_name and not pdf_service.search_template( + template_variable_name + ): + raise BusinessException( + "Template variables not found!", HTTPStatus.BAD_REQUEST + ) + render_data = pdf_service.get_render_data( + use_template, + template_variable_name, + request.headers.get("Authorization"), + ) + headers = {"Content-Type": "text/html"} + return make_response( + render_template(template_name, **render_data), 200, headers + ) + + +@cors_preflight("POST,OPTIONS") +@API.route( + "//submission//export/pdf", + methods=["POST", "OPTIONS"], +) +@API.doc( + params={ + "timezone": { + "description": "Timezone of client device eg: Asia/Calcutta", + "in": "query", + "type": "string", + } + } +) +class FormResourceExportPdf(Resource): + """Resource to export form and submission details as pdf.""" + + @staticmethod + @auth.require + @auth.has_one_of_roles([REVIEWER_GROUP, CLIENT_GROUP]) + @profiletime + def post(form_id: string, submission_id: string): # pylint:disable=too-many-locals + """PDF generation and rendering method.""" + timezone = request.args.get("timezone") + request_json = request.get_json() + template = request_json.get("template") + template_variables = request_json.get("templateVars") + token = request.headers.get("Authorization") + use_template = bool(template) + is_bundle = bool(request.args.get("isBundle")) + mapper_id = request.args.get("mapperId", None) + if is_bundle and not mapper_id: + raise BusinessException( + "Form Mapper ID Not Found !", HTTPStatus.BAD_REQUEST + ) + pdf_service = PDFService( + form_id=form_id, + submission_id=submission_id, + is_bundle=is_bundle, + mapper_id=mapper_id, + ) + + template_name = None + template_variable_name = None + if use_template: + ( + template_name, + template_variable_name, + ) = pdf_service.create_template(template, template_variables) + current_app.logger.info(template_name) + current_app.logger.info(template_variable_name) + assert pdf_service.get_render_status(token, template_name) == 200 + current_app.logger.info("Generating PDF...") + result = pdf_service.generate_pdf( + timezone, token, template_name, template_variable_name + ) + if result: + if use_template: + current_app.logger.info("Removing temporary files...") + pdf_service.delete_template(template_name) + if template_variable_name: + pdf_service.delete_template(template_variable_name) + return result + response, status = ( + { + "message": "Cannot render pdf.", + }, + HTTPStatus.BAD_REQUEST, + ) + return response, status diff --git a/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/src/formsflow_documents/services/pdf.py b/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/src/formsflow_documents/services/pdf.py new file mode 100644 index 00000000..17803eb9 --- /dev/null +++ b/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/src/formsflow_documents/services/pdf.py @@ -0,0 +1,454 @@ +"""Helper module for PDF export.""" + +import json +import os +import urllib.parse +import uuid +from typing import Any, Tuple, Union + +import requests +from flask import current_app +from formsflow_api_utils.services import FormioService +from formsflow_api_utils.utils import HTTP_TIMEOUT +from formsflow_api_utils.utils.pdf import get_pdf_from_html, pdf_response +from nested_lookup import get_occurrences_and_values + +from formsflow_documents.utils import DocUtils + + +class PDFService: + """Helper class for pdf generation.""" + + __slots__ = ( + "__is_form_adaptor", + "__custom_submission_url", + "__form_io_url", + "__host_name", + "__chrome_driver_path", + "form_id", + "submission_id", + "formio", + "__webapi_url", + "__is_bundle", + "__mapper_id", + ) + + def __init__( + self, + form_id: str, + submission_id: str, + is_bundle: bool = False, + mapper_id: int = None, + ) -> None: + """ + Initializes PDFService. + + form_id: formid corresponding to the PDF + submission_id: submissionid corresponding to the PDF. + """ + self.__is_form_adaptor = current_app.config.get("CUSTOM_SUBMISSION_ENABLED") + self.__custom_submission_url = current_app.config.get("CUSTOM_SUBMISSION_URL") + self.__form_io_url = current_app.config.get("FORMIO_URL") + self.__host_name = current_app.config.get("FORMSFLOW_DOC_API_URL") + self.__chrome_driver_path = current_app.config.get("CHROME_DRIVER_PATH") + self.form_id = form_id + self.submission_id = submission_id + self.formio = FormioService() + self.__webapi_url = current_app.config.get("FORMSFLOW_API_URL") + self.__is_bundle = is_bundle + self.__mapper_id = mapper_id + + def __is_form_adapter(self) -> bool: + """Returns whether th eapplication is using a form adaptor.""" + return self.__is_form_adaptor + + def __get_custom_submission_url(self) -> str: + """Returns the custom submission url based on config.""" + return self.__custom_submission_url + + def __get_formio_url(self) -> str: + """Returns the formio url based on config.""" + return self.__form_io_url + + def __get_formio_access_token(self) -> Any: + """Returns formio access token.""" + return self.formio.get_formio_access_token() + + def __get_submission_data(self) -> Any: + """Returns the submission data from formio.""" + formio_get_payload = {"form_id": self.form_id, "sub_id": self.submission_id} + return self.formio.get_submission( + formio_get_payload, self.__get_formio_access_token() + ) + + def __get_form_data(self) -> Any: + """Returns the form data from formio.""" + return self.formio.get_form_by_id( + self.form_id, self.__get_formio_access_token() + ) + + def __get_form(self, query_params) -> Any: + """Returns the form data from formio.""" + return self.formio.get_form(query_params, 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_bundle_forms(self, token: str, submission_data) -> Any: + """Returns the formids of the bundle.""" + url = f"{self.__webapi_url}/form/{self.__mapper_id}/bundles/execute-rules" + headers = self.__get_headers(token) + payload = {"data": submission_data.get("data")} + current_app.logger.debug(f"API call to execute rules endpoint..{url}") + response = requests.post( + url, headers=headers, timeout=HTTP_TIMEOUT, data=json.dumps(payload) + ) + current_app.logger.debug(f"Execute rules response {response}") + data = [] + if response.status_code == 200: + data = response.json() + return data + + def __fetch_bundle_form_data(self, token, submission_data) -> Any: + """Returns bundle form data & form order map.""" + bundle_forms = self.__fetch_bundle_forms(token, submission_data) + form_ids = [form["formId"] for form in bundle_forms] + # Initialize an empty list to store the results + form_data = [] + # Iterate through form_ids and call self.formio.get_form_by_id for each + for form_id in form_ids: + form = self.formio.get_form_by_id(form_id, self.__get_formio_access_token()) + form_data.append(form) + # Dictionary to map formId to formOrder + form_order_map = {form["formId"]: form["formOrder"] for form in bundle_forms} + return form_data, form_order_map + + 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 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(): + form_url = form_io_url + "/form/" + self.form_id + submission_data = self.__fetch_custom_submission_data(token) + else: + form_url = ( + form_io_url + + "/form/" + + self.form_id + + "/submission/" + + self.submission_id + ) + submission_data = self.__get_submission_data() if self.__is_bundle else None + return (form_url, submission_data) + + def __get_template_params( # pylint:disable=too-many-locals + 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_data) = self.__get_form_and_submission_urls(token) + ordered_form_components = [] + if self.__is_bundle: + current_app.logger.debug("Fetching form components for bundle..") + form_components, form_order_map = self.__fetch_bundle_form_data( + token, submission_data + ) + # Add formOrder to each form component + for form in form_components: + form_id = form.get("_id") + form_component = { + "form_order": form_order_map.get(form_id), + "form_component": form["components"], + } + ordered_form_components.append(form_component) + return { + "form": { + "base_url": form_io_url, + "project_url": form_io_url, + "form_url": form_url, + "token": self.__get_formio_access_token(), + "form_adapter": self.__is_form_adapter(), + "is_bundle": self.__is_bundle, + "bundle_forms": ordered_form_components, + "submission_data": submission_data, + } + } + + @staticmethod + def __generate_template_name() -> str: + """Generate unique template name.""" + return f"{str(uuid.uuid4())}.html" + + @staticmethod + def __generate_template_variables_name() -> str: + """Generate unique template variable name.""" + return f"{str(uuid.uuid4())}.json" + + @staticmethod + def __get_template_path() -> str: + """Returns the path to template directory.""" + path = os.path.dirname(__file__) + path = path.replace("services", "templates") + return path + + def __generate_pdf_file_name(self) -> str: + """Generated the PDF file name.""" + return "Application_" + self.form_id + "_" + self.submission_id + "_export.pdf" + + def __get_render_url( + self, + template_name: Union[str, None] = None, + template_variable_name: Union[str, None] = None, + ) -> str: + """Returns the render URL.""" + url = ( + self.__host_name + + "/form/" + + self.form_id + + "/submission/" + + self.submission_id + + "/render" + ) + + params = [] + if self.__is_bundle: + params.append("is_bundle=true") + params.append(f"mapper_id={self.__mapper_id}") + if template_name: + params.append(f"template_name={self.__url_encode(template_name)}") + if template_variable_name: + params.append( + f"template_variable={self.__url_encode(template_variable_name)}" + ) + + if params: + url += "?" + "&".join(params) + current_app.logger.debug(f"Render URL {url}") + return url + + @staticmethod + def __get_render_args( + timezone: str, token: str, use_template: bool = False + ) -> dict: + """Returns PDF render arguments.""" + args = {"wait": "completed", "timezone": timezone, "auth_token": token} + if use_template: + del args["wait"] + return args + + @staticmethod + def __url_encode(payload: str) -> str: + """Escapes url unsafe characters.""" + return urllib.parse.quote(payload) + + def __read_json(self, file_name: str) -> Any: + """Reads the json file contents to a variable.""" + path = self.__get_template_path() + with open(f"{path}/{file_name}", encoding="utf-8") as file: + data = json.load(file) + file.close() + return data + + def get_render_status( + self, token: str, template_name: Union[str, None] = None + ) -> int: + """Returns the render status code.""" + res = requests.get( + url=self.__get_render_url(template_name), + headers={"Authorization": token}, + timeout=HTTP_TIMEOUT, + ) + return res.status_code + + def __format_data(self, form_data, submission_data): + """Helper function to format the data for a form.""" + formatted_data = {"data": {}} + + for key, value in submission_data["data"].items(): + key_formatted = get_occurrences_and_values([form_data], value=key)[key].get( + "values" + ) + key_formatted = ( + key_formatted[0].get("label", "") if len(key_formatted) > 0 else None + ) + if key_formatted is None: + continue + value_formatted = value + if value and DocUtils.is_camel_case(value): + value_formatted = get_occurrences_and_values([form_data], value=value)[ + value + ].get("values") + value_formatted = ( + value_formatted[0].get("label", "") + if len(value_formatted) > 0 + else value + ) + formatted_data["data"][key] = { + "label": key_formatted, + "value": value_formatted, + } + + return formatted_data + + def __get_formatted_data(self, form_data, submission_data): + """Returns the presentable data from the submission data for a single form.""" + formatted_data = self.__format_data(form_data, submission_data) + return {"form": {"form": form_data, "data": formatted_data["data"]}} + + def __get_formatted_data_bundle( + self, form_data_list, submission_data, form_order_map + ): + """Handles multiple forms and formats their submission data. + + {"form": [{"form1": {"form": form_data, "data": {}}}, {"form2": {"form": form_data, "data": {}}}] + """ + submission_data_formatted = {"form": []} + + # Sort form_data_list based on formOrder + form_data_list_sorted = sorted( + form_data_list, key=lambda x: form_order_map.get(x["_id"], float("inf")) + ) + + for form_index, form_data in enumerate(form_data_list_sorted): + form_key = f"form{form_index + 1}" + formatted_data = self.__format_data(form_data, submission_data) + submission_data_formatted["form"].append( + {form_key: {"form": form_data, "data": formatted_data["data"]}} + ) + return submission_data_formatted + + def get_render_data( # pylint: disable=too-many-arguments + self, + use_template: bool, + template_variable_name: Union[str, None], + token: str, + ) -> Any: + """ + Returns the render data for the pdf template. + + use_template: boolean, whether to use a template for generating pdf. + template_variable_name: template variable file name for requests with + template variable payload. + token: token for the template parameters when using formio renderer. + Raw data will be passed to the template if the export is using any + form of template. Else default formio renderer will be used where + only the formio render parameters will be passed to the template. + """ + if not use_template: + return self.__get_template_params( + token=token, + ) + if template_variable_name: + return self.__read_json(template_variable_name) + + submission_data = ( + self.__fetch_custom_submission_data(token) + if self.__is_form_adapter() + else self.__get_submission_data() + ) + form_order_map = {} + if self.__is_bundle: + form_data, form_order_map = self.__fetch_bundle_form_data( + token, submission_data + ) + else: + form_data = self.__get_form_data() + formattted_data = ( + self.__get_formatted_data_bundle(form_data, submission_data, form_order_map) + if self.__is_bundle + else self.__get_formatted_data(form_data, submission_data) + ) + return formattted_data + + def __write_to_file( + self, template_name: str, content: Union[str, dict], is_json: bool = False + ) -> None: + """Write data to file.""" + path = self.__get_template_path() + with open(f"{path}/{template_name}", "w", encoding="utf-8") as file: + # disabling pylint warning since it is a function call + json.dump( # pylint: disable=expression-not-assigned + content, file + ) if is_json else file.write(content) + file.close() + + def create_template( + self, template: str, template_var: dict = None + ) -> Tuple[str, Union[str, None]]: + """ + Creates a temporary template in the template directory. + + template: base64 encoded template, supported template formats: jinja + """ + template_name = self.__generate_template_name() + template_var_name = ( + self.__generate_template_variables_name() if template_var else None + ) + decoded_template = DocUtils.b64decode(template) + self.__write_to_file(template_name, content=decoded_template) + if template_var: + self.__write_to_file(template_var_name, content=template_var, is_json=True) + return (template_name, template_var_name) + + def search_template(self, file_name: str) -> bool: + """ + Check if the given file exists in the template directory. + + file_name: name of the file to check with extension. + """ + path = self.__get_template_path() + current_app.logger.info("Searching for template...") + return os.path.isfile(f"{path}/{file_name}") + + def delete_template(self, file_name: str) -> None: + """ + Delete the given file from template directory. + + file_name: name of the file with extension. + """ + try: + path = self.__get_template_path() + os.remove(f"{path}/{file_name}") + except BaseException as err: # pylint: disable=broad-except + current_app.logger.error(err) + current_app.logger.error( + f"Failed to delete template: {file_name} not found" + ) + + def generate_pdf( # pylint: disable=too-many-arguments + self, + timezone: str, + token: str, + template_name: Union[str, None] = None, + template_variable_name: str = None, + ) -> Any: + """Generates PDF from HTML.""" + pdf = get_pdf_from_html( + self.__get_render_url(template_name, template_variable_name), + self.__get_chrome_driver_path(), + args=self.__get_render_args(timezone, token, bool(template_name)), + ) + return pdf_response(pdf, self.__generate_pdf_file_name()) if pdf else False diff --git a/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/src/formsflow_documents/static/js/from_io_render.js b/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/src/formsflow_documents/static/js/from_io_render.js new file mode 100644 index 00000000..781d4aa4 --- /dev/null +++ b/forms-flow-ai/forms-flow-ai-ee/forms-flow-documents/src/formsflow_documents/static/js/from_io_render.js @@ -0,0 +1,119 @@ +const form_options = { readOnly: true, renderMode: "flat" }; + +// Help web driver to idetify the form rendered completely. +function formReady() { + document.getElementById("formio").classList.add("completed"); +} + +// Render form with form adapter +function renderFormWithSubmission() { + Formio.createForm( + document.getElementById("formio"), + form_info.form_url, + form_options + ).then((form) => { + form.submission = form_info.submission_data; + form.ready.then(() => { + formReady(); + }); + }); +} + +// Render form from formio +function renderFormWithOutSubmission() { + Formio.createForm( + document.getElementById("formio"), + form_info.form_url, + form_options + ).then((form) => { + form.ready.then(() => { + formReady(); + }); + }); +} + +// Render bundle +function renderFormBundle() { + + // Sort the bundle_forms array based on the 'form_order' + const sortedBundleForms = form_info.bundle_forms.sort((a, b) => a.form_order - b.form_order); + // Create an array to hold all form creation promises + const formCreationPromises = sortedBundleForms.map((bundle_forms, index) => { + return new Promise((resolve, reject) => { + // Create a unique container element for each form + const container = document.createElement('div'); + container.className = 'form-container'; + container.id = `form-container-${index}`; + + document.getElementById("formio").appendChild(container); + Formio.createForm( + container, + {components: bundle_forms.form_component}, + form_options + ).then((form) => { + form.submission = form_info.submission_data; + + // Ensure form rules are executed after the form is fully ready + form.on('change', () => { + resolve(form); + }); + + form.ready.then(() => { + form.triggerChange(); + }); + }).catch((error) => { + reject(error); + }); + }); + }); + + // Wait for all form creation promises to resolve + Promise.all(formCreationPromises) + .then((forms) => { + forms.forEach((form, index) => { + const containerElement = document.getElementById(`form-container-${index}`); + if (index < forms.length - 1) { + const pageBreak = document.createElement('div'); + pageBreak.style.pageBreakAfter = 'always'; + containerElement.appendChild(pageBreak); + } + }); + formReady(); + }) + .catch((error) => { + console.error('Error creating forms:', error); + }); +} + + +function renderForm() { + // loading custom components from formsflow-formio-custom-elements (npm package) + try { + const components = FormioCustom.components; + for (var key of Object.keys(components)) { + Formio.registerComponent(key, components[key]); + } + } catch (err) { + console.log("Cannot load custom components."); + } + + try { + Formio.setBaseUrl(form_info.base_url); + Formio.setProjectUrl(form_info.project_url); + Formio.setToken(form_info.token); + if (form_info.is_bundle) { + renderFormBundle(); + } + else if (form_info.form_adapter) { + renderFormWithSubmission(); + } else { + renderFormWithOutSubmission(); + } + } catch (err) { + console.log("Cannot render form", err); + document.getElementById("formio").innerHTML('Cannot render form') + formReady(); + } + + +} diff --git a/forms-flow-ai/forms-flow-ai-ee/forms-flow-web/src/components/Application/ViewApplication.js b/forms-flow-ai/forms-flow-ai-ee/forms-flow-web/src/components/Application/ViewApplication.js index 0b76be0a..f9ad29df 100644 --- a/forms-flow-ai/forms-flow-ai-ee/forms-flow-web/src/components/Application/ViewApplication.js +++ b/forms-flow-ai/forms-flow-ai-ee/forms-flow-web/src/components/Application/ViewApplication.js @@ -50,7 +50,7 @@ const ViewApplication = React.memo(() => { const tenantKey = useSelector((state) => state.tenants?.tenantId); const dispatch = useDispatch(); const redirectUrl = MULTITENANCY_ENABLED ? `/tenant/${tenantKey}/` : "/"; - const [customSubmissionAPIFailed,setCustomSubmissionAPIFailed] = useState(false); + const [customSubmissionAPIFailed, setCustomSubmissionAPIFailed] = useState(false); useEffect(() => { dispatch(setApplicationDetailLoader(true)); @@ -64,14 +64,12 @@ const ViewApplication = React.memo(() => { getCustomSubmission( res.submissionId, res.formId, - (err, data) => { - if(err) - { + (err, data) => { + if (err) { setCustomSubmissionAPIFailed(true); dispatch(setBundleSubmissionData({ data: null })); } - else - { + else { if (res.formType === TYPE_BUNDLE) { dispatch(setBundleSubmissionData({ data: data.data })); } @@ -131,7 +129,7 @@ const ViewApplication = React.memo(() => { -

+