commit db69de1c11daf0a0d89f083415c385ae38c1cdbf Author: upstream-mirror Date: Fri Sep 18 01:36:01 2026 +0500 snapshot of https://github.com/frappe/crm.git (develop, 2026-09-18; blobs > 131072B omitted) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..adeb31a --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,21 @@ +{ + "name": "Frappe Bench", + "forwardPorts": [8000, 9000, 6787], + "remoteUser": "frappe", + "settings": { + "terminal.integrated.defaultProfile.linux": "bash", + "debug.node.autoAttach": "disabled" + }, + "dockerComposeFile": "./docker-compose.yml", + "service": "frappe", + "workspaceFolder": "/workspace/frappe-bench", + "postCreateCommand": "bash /workspace/scripts/init.sh", + "shutdownAction": "stopCompose", + "extensions": [ + "ms-python.python", + "ms-vscode.live-server", + "grapecity.gc-excelviewer", + "mtxr.sqltools", + "visualstudioexptteam.vscodeintellicode" + ] + } \ No newline at end of file diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..e18f84b --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,46 @@ +version: "3.7" +services: + mariadb: + image: mariadb:10.6 + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --skip-character-set-client-handshake + - --skip-innodb-read-only-compressed # Temporary fix for MariaDB 10.6 + environment: + MYSQL_ROOT_PASSWORD: 123 + volumes: + - mariadb-data:/var/lib/mysql + + # Enable PostgreSQL only if you use it, see development/README.md for more information. + # postgresql: + # image: postgres:11.8 + # environment: + # POSTGRES_PASSWORD: 123 + # volumes: + # - postgresql-data:/var/lib/postgresql/data + + redis-cache: + image: redis:alpine + + redis-queue: + image: redis:alpine + + redis-socketio: + image: redis:alpine + + frappe: + image: frappe/bench:latest + command: sleep infinity + environment: + - SHELL=/bin/bash + volumes: + - ..:/workspace:cached + working_dir: /workspace/frappe-bench + ports: + - 8000-8005:8000-8005 + - 9000-9005:9000-9005 + +volumes: + mariadb-data: + postgresql-data: \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..81c7b58 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: 'bug' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/actions/frontend-tests/action.yml b/.github/actions/frontend-tests/action.yml new file mode 100644 index 0000000..598b783 --- /dev/null +++ b/.github/actions/frontend-tests/action.yml @@ -0,0 +1,43 @@ +name: Run Frontend Tests +description: Install frontend dependencies and run Vitest with coverage. +inputs: + node-version: + description: Node.js version to use + required: false + default: "24" + enable-coverage: + description: When true, run with coverage collection + required: false + default: "true" + working-directory: + description: Path to the frontend directory + required: false + default: "frontend" +outputs: + coverage-path: + description: Path to the generated lcov coverage file + value: ${{ steps.set-output.outputs.coverage-path }} +runs: + using: composite + steps: + - name: Install dependencies + shell: bash + working-directory: ${{ inputs.working-directory }} + run: yarn install --frozen-lockfile + + - name: Run tests (with coverage) + if: inputs.enable-coverage == 'true' + shell: bash + working-directory: ${{ inputs.working-directory }} + run: yarn test:coverage + + - name: Run tests (without coverage) + if: inputs.enable-coverage != 'true' + shell: bash + working-directory: ${{ inputs.working-directory }} + run: yarn test:run + + - name: Set coverage output path + id: set-output + shell: bash + run: echo "coverage-path=${{ inputs.working-directory }}/coverage/lcov.info" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/publish-codecov/action.yml b/.github/actions/publish-codecov/action.yml new file mode 100644 index 0000000..fee83b6 --- /dev/null +++ b/.github/actions/publish-codecov/action.yml @@ -0,0 +1,28 @@ +name: Publish Coverage to Codecov +description: Upload downloaded coverage artifacts to Codecov. +inputs: + name: + description: Coverage upload name in Codecov + required: false + default: "MariaDB" + token: + description: Codecov upload token + required: true + fail-ci-if-error: + description: Fail workflow if Codecov upload fails + required: false + default: "true" + verbose: + description: Enable verbose Codecov logging + required: false + default: "true" +runs: + using: composite + steps: + - name: Upload coverage data + uses: codecov/codecov-action@v4 + with: + name: ${{ inputs.name }} + token: ${{ inputs.token }} + fail_ci_if_error: ${{ inputs.fail-ci-if-error }} + verbose: ${{ inputs.verbose }} diff --git a/.github/actions/run-server-tests/action.yml b/.github/actions/run-server-tests/action.yml new file mode 100644 index 0000000..30f9434 --- /dev/null +++ b/.github/actions/run-server-tests/action.yml @@ -0,0 +1,42 @@ +name: Run CRM Server Tests +description: Execute bench tests for the CRM app with optional coverage collection. +inputs: + bench-path: + description: Absolute path to the bench directory + required: false + default: "/home/runner/frappe-bench" + site: + description: Bench site on which to run tests + required: false + default: "test_site" + app: + description: App to test + required: false + default: "crm" + enable-coverage: + description: When true, include coverage flag during test execution + required: false + default: "true" + type: + description: Bench command type environment variable + required: false + default: "server" + ci-build-id: + description: CI build identifier propagated to bench commands + required: false +runs: + using: composite + steps: + - name: Run Tests + shell: bash + working-directory: ${{ inputs.bench-path }} + env: + TYPE: ${{ inputs.type }} + CI_BUILD_ID: ${{ inputs.ci-build-id }} + run: | + bench --site "${{ inputs.site }}" set-config allow_tests true + if [ "${{ inputs.enable-coverage }}" = "true" ]; then + bench --site "${{ inputs.site }}" run-tests --app "${{ inputs.app }}" --coverage + else + bench --site "${{ inputs.site }}" run-tests --app "${{ inputs.app }}" + fi diff --git a/.github/actions/setup-server-env/action.yml b/.github/actions/setup-server-env/action.yml new file mode 100644 index 0000000..12da927 --- /dev/null +++ b/.github/actions/setup-server-env/action.yml @@ -0,0 +1,95 @@ +name: Setup Server Environment +description: Prepare Python, Node, caching, and bench prerequisites for CRM server tests. +inputs: + python-version: + description: Python version to install + required: false + default: "3.14" + node-version: + description: Node version to install + required: false + default: "24" + type: + description: Bench install type passed to install script + required: false + default: "server" + install-erpnext: + description: When "true", fetch and install erpnext alongside crm + required: false + default: "false" + frappe-branch: + description: Frappe (and erpnext) branch to install and test against + required: false + default: "develop" +runs: + using: composite + steps: + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: ${{ inputs.python-version }} + + - name: Check for valid Python & merge conflicts + shell: bash + run: | + python -m compileall -f "${GITHUB_WORKSPACE}" + if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}"; then + echo "Found merge conflicts" + exit 1 + fi + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version }} + check-latest: true + + - name: Install Yarn + shell: bash + run: npm install -g yarn + + - name: Add to hosts file + shell: bash + run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-pip- + ${{ runner.os }}- + + - name: Cache node modules + uses: actions/cache@v4 + env: + cache-name: cache-node-modules + with: + path: ~/.npm + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + shell: bash + run: echo "dir=$(yarn cache dir)" >> "${GITHUB_OUTPUT}" + + - name: Cache yarn + uses: actions/cache@v4 + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install bench dependencies + shell: bash + env: + TYPE: ${{ inputs.type }} + INSTALL_ERPNEXT: ${{ inputs.install-erpnext }} + FRAPPE_BRANCH: ${{ inputs.frappe-branch }} + run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh diff --git a/.github/actions/upload-coverage/action.yml b/.github/actions/upload-coverage/action.yml new file mode 100644 index 0000000..cef2938 --- /dev/null +++ b/.github/actions/upload-coverage/action.yml @@ -0,0 +1,18 @@ +name: Upload Coverage Artifact +description: Persist coverage artifacts produced during CRM server testing. +inputs: + artifact-name: + description: Name assigned to the uploaded artifact + required: false + default: "coverage-server" + coverage-path: + description: Path to the coverage XML or directory to upload + required: true +runs: + using: composite + steps: + - name: Upload coverage data + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: ${{ inputs.coverage-path }} diff --git a/.github/helper/install.sh b/.github/helper/install.sh new file mode 100644 index 0000000..4914b32 --- /dev/null +++ b/.github/helper/install.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +set -e + +cd ~ || ex + +sudo apt update +sudo apt remove mysql-server mysql-client +sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev + +# Frappe (and erpnext) branch to test against; set by CI matrix, defaults to develop. +FRAPPE_BRANCH="${FRAPPE_BRANCH:-develop}" + +pip install frappe-bench +git clone "https://github.com/frappe/frappe" --branch "${FRAPPE_BRANCH}" --depth 1 +bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench + +mkdir ~/frappe-bench/sites/test_site + +cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config.json" ~/frappe-bench/sites/test_site/site_config.json + +mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" +mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" + +mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'" +mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe" +mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'" + +mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "FLUSH PRIVILEGES" + +install_whktml() { + wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz + tar -xf /tmp/wkhtmltox.tar.xz -C /tmp + sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf + sudo chmod o+x /usr/local/bin/wkhtmltopdf +} +install_whktml & + +cd ~/frappe-bench || exit + +sed -i 's/watch:/# watch:/g' Procfile +sed -i 's/schedule:/# schedule:/g' Procfile +sed -i 's/socketio:/# socketio:/g' Procfile +sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile + +bench get-app crm "${GITHUB_WORKSPACE}" + +# Only pull erpnext when the integration is under test, to keep other runs fast. +if [ "${INSTALL_ERPNEXT}" = "true" ]; then + bench get-app erpnext --branch "${FRAPPE_BRANCH}" +fi + +bench setup requirements --dev + +bench start &>> ~/frappe-bench/bench_start.log & +CI=Yes bench build --app frappe & +bench --site test_site reinstall --yes + +if [ "${INSTALL_ERPNEXT}" = "true" ]; then + bench --verbose --site test_site install-app erpnext crm +else + bench --verbose --site test_site install-app crm +fi \ No newline at end of file diff --git a/.github/helper/site_config.json b/.github/helper/site_config.json new file mode 100644 index 0000000..fadf17c --- /dev/null +++ b/.github/helper/site_config.json @@ -0,0 +1,16 @@ +{ + "db_host": "127.0.0.1", + "db_port": 3306, + "db_name": "test_frappe", + "db_password": "test_frappe", + "auto_email_id": "test@example.com", + "mail_server": "smtp.example.com", + "mail_login": "test@example.com", + "mail_password": "test", + "admin_password": "admin", + "root_login": "root", + "root_password": "root", + "host_name": "http://test_site:8000", + "install_apps": ["crm"], + "throttle_user_limit": 100 +} \ No newline at end of file diff --git a/.github/helper/update_pot_file.sh b/.github/helper/update_pot_file.sh new file mode 100644 index 0000000..b5fbd0c --- /dev/null +++ b/.github/helper/update_pot_file.sh @@ -0,0 +1,59 @@ +#!/bin/bash +set -e +cd ~ || exit + +echo "Setting Up Bench..." + +pip install frappe-bench +bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)" --frappe-branch develop +cd ./frappe-bench || exit + +echo "Get FCRM..." +bench get-app --skip-assets crm "${GITHUB_WORKSPACE}" + +echo "Generating POT file..." +bench generate-pot-file --app crm + +cd ./apps/crm || exit + +echo "Configuring git user..." +git config user.email "developers@erpnext.com" +git config user.name "frappe-pr-bot" + +echo "Setting the correct git remote..." +# Here, the git remote is a local file path by default. Let's change it to the upstream repo. +git remote set-url upstream https://github.com/frappe/crm.git + +gh auth setup-git + +# One stable branch, not a dated one. The POT file is fully regenerated every run, so a +# fresh dated branch/PR each week just piles up and conflicts once any one is merged. Reusing +# a single branch (reset to the base tip, force-pushed) keeps exactly one PR that updates in +# place and can never conflict, since it's always rebuilt from the current base. +branch_name="pot_${BASE_BRANCH}" + +echo "Resetting ${branch_name} onto ${BASE_BRANCH}..." +generated_pot=$(mktemp) +cp crm/locale/main.pot "${generated_pot}" +git fetch upstream "${BASE_BRANCH}" +git checkout -f -B "${branch_name}" "upstream/${BASE_BRANCH}" +cp "${generated_pot}" crm/locale/main.pot + +git add crm/locale/main.pot +if git diff --cached --quiet; then + echo "No POT changes; nothing to do." + exit 0 +fi + +echo "Commiting changes..." +git commit -m "chore: update POT file" + +echo "Pushing ${branch_name}..." +git push -u upstream "${branch_name}" --force + +if gh pr view "${branch_name}" -R frappe/crm --json state -q '.state' 2>/dev/null | grep -q OPEN; then + echo "PR already open for ${branch_name}; force-push updated it." +else + echo "Creating a PR..." + gh pr create --fill --base "${BASE_BRANCH}" --head "${branch_name}" -R frappe/crm +fi diff --git a/.github/logo.png b/.github/logo.png new file mode 100644 index 0000000..6bf9bd9 Binary files /dev/null and b/.github/logo.png differ diff --git a/.github/logo.svg b/.github/logo.svg new file mode 100644 index 0000000..e203a0f --- /dev/null +++ b/.github/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml new file mode 100644 index 0000000..c2d024d --- /dev/null +++ b/.github/workflows/builds.yml @@ -0,0 +1,65 @@ +name: Build Container Image +on: + workflow_dispatch: + push: + branches: + - main + tags: + - "*" + +jobs: + build: + name: Build + + runs-on: ubuntu-latest + + strategy: + matrix: + arch: [amd64, arm64] + + steps: + - name: Checkout Entire Repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + platforms: linux/${{ matrix.arch }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set Branch + run: | + export APPS_JSON='[{"url": "https://github.com/frappe/crm","branch": "main"}]' + echo "APPS_JSON_BASE64=$(echo $APPS_JSON | base64 -w 0)" >> $GITHUB_ENV + echo "FRAPPE_BRANCH=version-15" >> $GITHUB_ENV + + - name: Set Image Tag + run: | + echo "IMAGE_TAG=stable" >> $GITHUB_ENV + + - uses: actions/checkout@v4 + with: + repository: frappe/frappe_docker + path: builds + + - name: Build and push + uses: docker/build-push-action@v6 + with: + push: true + context: builds + file: builds/images/layered/Containerfile + tags: > + ghcr.io/${{ github.repository }}:${{ github.ref_name }}, + ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }} + build-args: | + "FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}" + "APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}" \ No newline at end of file diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 0000000..be4fa97 --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,125 @@ +name: Frontend + +on: + pull_request: + paths-ignore: + - "**.md" + - "**.py" + - "**.txt" + - "**.csv" + - "**.po" + - "**.pot" + - ".github/workflows/server-tests.yml" + - ".github/workflows/builds.yml" + schedule: + # Run every day at midnight UTC / 5:30 IST + - cron: "0 0 * * *" + workflow_dispatch: + +concurrency: + group: frontend-${{ github.event.number || github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + # frontend/package.json links `@framework/ui` to a sibling frappe checkout + # (link:../../frappe/ui); without it, anything importing from @framework/ui + # fails to resolve. actions/checkout cannot place a repo outside the + # workspace, and only the `ui` package is needed. + - name: Clone frappe for @framework/ui + run: | + git clone --depth 1 --branch develop --filter=blob:none --sparse \ + https://github.com/frappe/frappe "$GITHUB_WORKSPACE/../frappe" + git -C "$GITHUB_WORKSPACE/../frappe" sparse-checkout set ui + + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: yarn + cache-dependency-path: frontend/yarn.lock + + - name: Install dependencies + run: cd frontend && yarn install --frozen-lockfile + + - name: ESLint + run: cd frontend && npx eslint src --ext .js,.ts,.vue + + - name: Oxlint + run: cd frontend && npx oxlint src + + build: + name: Production Build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + # see the lint job: @framework/ui links to a sibling frappe checkout + - name: Clone frappe for @framework/ui + run: | + git clone --depth 1 --branch develop --filter=blob:none --sparse \ + https://github.com/frappe/frappe "$GITHUB_WORKSPACE/../frappe" + git -C "$GITHUB_WORKSPACE/../frappe" sparse-checkout set ui + + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: yarn + cache-dependency-path: frontend/yarn.lock + + - name: Install dependencies + run: cd frontend && yarn install --frozen-lockfile + + - name: Build frontend + run: cd frontend && yarn build + + test: + name: Unit Tests & Coverage + runs-on: ubuntu-latest + needs: lint + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: yarn + cache-dependency-path: frontend/yarn.lock + + - name: Run frontend tests + uses: ./.github/actions/frontend-tests + with: + node-version: "24" + enable-coverage: "true" + + - name: Upload coverage artifact + if: github.base_ref == 'develop' || github.ref_name == 'develop' + uses: ./.github/actions/upload-coverage + with: + artifact-name: coverage-frontend + coverage-path: frontend/coverage/lcov.info + + coverage: + name: Coverage Wrap Up + needs: test + if: github.base_ref == 'develop' || github.ref_name == 'develop' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Download artifacts + uses: actions/download-artifact@v4 + + - name: Publish coverage to Codecov + uses: ./.github/actions/publish-codecov + with: + name: Frontend + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/generate-pot-file.yml b/.github/workflows/generate-pot-file.yml new file mode 100644 index 0000000..49da4a9 --- /dev/null +++ b/.github/workflows/generate-pot-file.yml @@ -0,0 +1,40 @@ +name: Regenerate POT file (translatable strings) +on: + schedule: + # 9:30 UTC => 3 PM IST Sunday + - cron: "30 9 * * 0" + workflow_dispatch: + +jobs: + regenerate-pot-file: + name: Regenerate POT file + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + branch: ["develop", "main-hotfix"] + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ matrix.branch }} + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Run script to update POT file + run: | + bash ${GITHUB_WORKSPACE}/.github/helper/update_pot_file.sh + env: + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} + BASE_BRANCH: ${{ matrix.branch }} diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml new file mode 100644 index 0000000..f3108d6 --- /dev/null +++ b/.github/workflows/linters.yml @@ -0,0 +1,85 @@ +name: Linters + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +jobs: + commit-lint: + name: Semantic Commits + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 200 + + - uses: actions/setup-node@v6 + with: + node-version: "24" + check-latest: true + + - name: Install commitlint + run: npm install --no-save @commitlint/cli @commitlint/config-conventional + + - name: Check commit titles + run: npx commitlint --verbose --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}" + + semgrep: + name: Semgrep Rules + runs-on: ubuntu-latest + + env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + PYTHONDONTWRITEBYTECODE: "1" + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + cache: pip + + - name: Install Semgrep + run: pip install --upgrade semgrep + + - name: Download Semgrep rules + run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules + + - name: Run Semgrep rules + run: semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness + + pre-commit: + name: Pre-commit Checks + runs-on: ubuntu-latest + + env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + PYTHONDONTWRITEBYTECODE: "1" + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + cache: pip + + - uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Install frontend dependencies + run: cd frontend && yarn install + + - name: Run Pre-commit + uses: pre-commit/action@v3.0.1 + with: + extra_args: --all-files diff --git a/.github/workflows/migration-test.yml b/.github/workflows/migration-test.yml new file mode 100644 index 0000000..1ba1ea7 --- /dev/null +++ b/.github/workflows/migration-test.yml @@ -0,0 +1,107 @@ +name: Migration + +# Verifies that an existing site on the PR's base branch can `bench migrate` +# to the PR code. Catches broken patches / schema changes before release. + +on: + pull_request: + paths-ignore: + - "**.css" + - "**.js" + - "**.md" + - "**.html" + - "**.csv" + - "**.po" + - "**.pot" + workflow_dispatch: + +concurrency: + group: migration-${{ github.event.number || github.ref }} + cancel-in-progress: true + +jobs: + migrate: + name: Upgrade base branch -> PR + runs-on: ubuntu-latest + timeout-minutes: 60 + + services: + mysql: + image: mariadb:10.6 + env: + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + + env: + BASE_BRANCH: ${{ github.base_ref || github.ref_name }} + BENCH_PATH: /home/runner/frappe-bench + + steps: + - name: Checkout PR code + uses: actions/checkout@v6 + with: + path: pr-crm + + - name: Resolve frappe branch for base + id: frappe + run: | + # Stable lanes run on frappe version-15; develop tracks frappe develop. + case "$BASE_BRANCH" in + main|main-hotfix) echo "branch=version-15" >> "$GITHUB_OUTPUT" ;; + *) echo "branch=develop" >> "$GITHUB_OUTPUT" ;; + esac + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Install system dependencies + run: | + sudo apt update + sudo apt install -y libcups2-dev redis-server mariadb-client libmariadb-dev + npm install -g yarn + + - name: Configure database + run: | + echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'" + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe" + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'" + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "FLUSH PRIVILEGES" + + - name: Init bench with frappe + run: | + pip install frappe-bench + git clone "https://github.com/frappe/frappe" --branch "${{ steps.frappe.outputs.branch }}" --depth 1 ~/frappe + bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" "$BENCH_PATH" + + - name: Install CRM at base branch + working-directory: /home/runner/frappe-bench + run: | + mkdir sites/test_site + cp "${GITHUB_WORKSPACE}/pr-crm/.github/helper/site_config.json" sites/test_site/site_config.json + sed -i 's/watch:/# watch:/g; s/schedule:/# schedule:/g; s/socketio:/# socketio:/g; s/redis_socketio:/# redis_socketio:/g' Procfile + bench get-app crm "https://github.com/frappe/crm" --branch "$BASE_BRANCH" + bench setup requirements --dev + bench start &>> "$BENCH_PATH/bench_start.log" & + CI=Yes bench build --app frappe & + bench --site test_site reinstall --yes + bench --verbose --site test_site install-app crm + + - name: Upgrade CRM to PR code and migrate + working-directory: /home/runner/frappe-bench + run: | + # Swap the installed (base branch) code for the PR code, keep the git dir. + rsync -a --delete --exclude=".git" "${GITHUB_WORKSPACE}/pr-crm/" apps/crm/ + bench setup requirements --dev + bench --site test_site migrate diff --git a/.github/workflows/on_release.yml b/.github/workflows/on_release.yml new file mode 100644 index 0000000..3b697d0 --- /dev/null +++ b/.github/workflows/on_release.yml @@ -0,0 +1,32 @@ +name: Generate Semantic Release +on: + workflow_dispatch: + push: + branches: + - main +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout Entire Repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Setup dependencies + run: | + npm install @semantic-release/git @semantic-release/exec --no-save + - name: Create Release + env: + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} + GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} + GIT_AUTHOR_NAME: "Frappe PR Bot" + GIT_AUTHOR_EMAIL: "developers@frappe.io" + GIT_COMMITTER_NAME: "Frappe PR Bot" + GIT_COMMITTER_EMAIL: "developers@frappe.io" + run: npx semantic-release \ No newline at end of file diff --git a/.github/workflows/release_notes.yml b/.github/workflows/release_notes.yml new file mode 100644 index 0000000..641230c --- /dev/null +++ b/.github/workflows/release_notes.yml @@ -0,0 +1,40 @@ +# This action: +# +# 1. Generates release notes using github API. +# 2. Strips unnecessary info like chore/style etc from notes. +# 3. Updates release info. + +name: 'Release Notes' + +on: + workflow_dispatch: + inputs: + tag_name: + description: 'Tag of release like v1.0.0' + required: true + type: string + release: + types: [released] + +permissions: + contents: read + +jobs: + regen-notes: + name: 'Regenerate release notes' + runs-on: ubuntu-latest + + steps: + - name: Update notes + run: | + NEW_NOTES=$(gh api --method POST -H "Accept: application/vnd.github+json" /repos/frappe/crm/releases/generate-notes -f tag_name=$RELEASE_TAG \ + | jq -r '.body' \ + | sed -E '/^\* (chore|ci|test|docs|style)/d' \ + | sed -E 's/by @mergify //' + ) + RELEASE_ID=$(gh api -H "Accept: application/vnd.github+json" /repos/frappe/crm/releases/tags/$RELEASE_TAG | jq -r '.id') + gh api --method PATCH -H "Accept: application/vnd.github+json" /repos/frappe/crm/releases/$RELEASE_ID -f body="$NEW_NOTES" + + env: + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} + RELEASE_TAG: ${{ github.event.inputs.tag_name || github.event.release.tag_name }} \ No newline at end of file diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml new file mode 100644 index 0000000..25660a2 --- /dev/null +++ b/.github/workflows/server-tests.yml @@ -0,0 +1,106 @@ +name: Server + +on: + pull_request: + paths-ignore: + - "**.css" + - "**.js" + - "**.md" + - "**.html" + - "**.csv" + - "**.po" + - "**.pot" + schedule: + # Run everday at midnight UTC / 5:30 IST + - cron: "0 0 * * *" +env: + CRM_BRANCH: ${{ github.base_ref || github.ref_name }} + +concurrency: + group: develop-crm-${{ github.event.number }} + cancel-in-progress: true + +jobs: + setup: + name: Resolve frappe matrix + runs-on: ubuntu-latest + outputs: + frappe-branches: ${{ steps.resolve.outputs.branches }} + steps: + - name: Resolve frappe branches from target branch + id: resolve + run: | + BASE="${{ github.base_ref || github.ref_name }}" + # develop tracks frappe develop; the stable lane ships frappe 15 and 16. + if [ "$BASE" = "develop" ]; then + echo 'branches=["develop"]' >> "$GITHUB_OUTPUT" + else + echo 'branches=["version-15","version-16"]' >> "$GITHUB_OUTPUT" + fi + + test: + name: Tests (frappe ${{ matrix.frappe-branch }}) + needs: setup + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + frappe-branch: ${{ fromJson(needs.setup.outputs.frappe-branches) }} + + services: + mysql: + image: mariadb:10.6 + env: + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + + steps: + - name: Clone + uses: actions/checkout@v6 + + - name: Setup environment + uses: ./.github/actions/setup-server-env + with: + python-version: "3.14" + node-version: "24" + type: "server" + # erpnext: stable lane only, never v16 (v16 test bootstrap broken on bare bench) + install-erpnext: ${{ (github.base_ref == 'main-hotfix' || github.base_ref == 'main') && matrix.frappe-branch != 'version-16' }} + frappe-branch: ${{ matrix.frappe-branch }} + + - name: Run server tests + uses: ./.github/actions/run-server-tests + with: + bench-path: "/home/runner/frappe-bench" + site: "test_site" + app: "crm" + enable-coverage: "true" + type: "server" + ci-build-id: ${{ github.run_id }} + + - name: Persist coverage artifact + if: github.base_ref == 'develop' || github.ref_name == 'develop' + uses: ./.github/actions/upload-coverage + with: + artifact-name: coverage-${{ matrix.frappe-branch }} + coverage-path: /home/runner/frappe-bench/sites/coverage.xml + + coverage: + name: Coverage Wrap Up + needs: test + if: github.base_ref == 'develop' || github.ref_name == 'develop' + runs-on: ubuntu-latest + steps: + - name: Clone + uses: actions/checkout@v6 + + - name: Download artifacts + uses: actions/download-artifact@v4 + + - name: Publish coverage + uses: ./.github/actions/publish-codecov + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml new file mode 100644 index 0000000..f4e8b51 --- /dev/null +++ b/.github/workflows/ui-tests.yml @@ -0,0 +1,181 @@ +name: UI Tests + +# E2E (Playwright) suite. Heavy gate: runs on the main-hotfix staging lane +# before promoting to main, plus on demand. Develop stays on the fast lane. +on: + push: + branches: + - main-hotfix + pull_request: + branches: + - main-hotfix + workflow_dispatch: + +concurrency: + group: ui-tests-crm-${{ github.event.number || github.ref }} + cancel-in-progress: true + +env: + FRAPPE_BRANCH: develop + +jobs: + ui-tests: + runs-on: ubuntu-latest + timeout-minutes: 60 + name: Playwright E2E Tests + + services: + redis-cache: + image: redis:alpine + ports: + - 13000:6379 + redis-queue: + image: redis:alpine + ports: + - 11000:6379 + mariadb: + image: mariadb:10.6 + env: + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + + steps: + - name: Clone + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + check-latest: true + + - name: Add to Hosts + run: echo "127.0.0.1 crm.test" | sudo tee -a /etc/hosts + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: 'echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT' + + - name: Cache yarn + uses: actions/cache@v4 + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('package.json') }} + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Install MariaDB Client + run: | + sudo apt update + sudo apt-get install mariadb-client + + - name: Setup Bench + run: | + pip install frappe-bench + bench init --skip-redis-config-generation --skip-assets --frappe-branch "$FRAPPE_BRANCH" --python "$(which python)" ~/frappe-bench + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" + + - name: Install CRM + working-directory: /home/runner/frappe-bench + run: | + bench get-app crm $GITHUB_WORKSPACE + bench setup requirements --dev + bench new-site --db-root-password root --admin-password admin crm.test + bench --site crm.test install-app crm + bench build --app crm + env: + CI: "Yes" + + - name: Configure Site for UI Tests + working-directory: /home/runner/frappe-bench + run: | + bench --site crm.test set-config allow_tests true + bench --site crm.test set-config host_name "http://crm.test:8000" + # Dummy default-outgoing account so send-email queues without real SMTP. + bench --site crm.test console <<'PY' + import frappe + if not frappe.db.exists("Email Account", {"default_outgoing": 1}): + frappe.get_doc({ + "doctype": "Email Account", + "email_account_name": "E2E Outgoing", + "email_id": "e2e@example.com", + "smtp_server": "localhost", + "smtp_port": 25, + "enable_outgoing": 1, + "default_outgoing": 1, + "no_smtp_authentication": 1, + }).insert(ignore_permissions=True) + frappe.db.commit() + PY + + - name: Start Frappe Server + working-directory: /home/runner/frappe-bench + run: | + sed -i 's/^watch:/# watch:/g' Procfile + sed -i 's/^schedule:/# schedule:/g' Procfile + bench start &> bench_start.log & + echo "Waiting for Frappe server to start..." + timeout 60 bash -c 'until curl -s http://crm.test:8000 > /dev/null; do sleep 2; done' + echo "Frappe server is ready!" + + - name: Install Playwright + run: | + npm install --ignore-scripts + npx playwright install --with-deps chromium + + - name: Run Playwright Tests + run: npx playwright test + env: + BASE_URL: http://crm.test:8000 + FRAPPE_USER: Administrator + FRAPPE_PASSWORD: admin + + - name: Upload Playwright Report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: failure() + with: + name: test-results + path: test-results/ + retention-days: 7 + + - name: Show Bench Logs on Failure + if: failure() + working-directory: /home/runner/frappe-bench + run: | + echo "=== Bench Start Log ===" + cat bench_start.log || true + echo "" + echo "=== Frappe Logs ===" + cat logs/*.log || true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1bb08aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +.DS_Store +*.pyc +*.local.md +*.egg-info +*.swp +__pycache__ +dev-dist +tags +node_modules +crm/public/frontend +crm/www/crm.html +build +frontend/coverage + +e2e/.auth +test-results +playwright-report +package-lock.json + +.vscode +.github/instructions +.github/skills +.github/copilot-instructions.md + +CLAUDE.md +AGENTS.md +.claude +.pi +.patch diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..aac084b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "frappe-ui"] + path = frappe-ui + url = https://github.com/frappe/frappe-ui diff --git a/.mergify.yml b/.mergify.yml new file mode 100644 index 0000000..1820a60 --- /dev/null +++ b/.mergify.yml @@ -0,0 +1,67 @@ +pull_request_rules: + - name: Auto-close PRs on stable branch + conditions: + - and: + - and: + - author!=shariquerik + - author!=shahzeelahmed + - author!=ps173 + - author!=frappe-pr-bot + - author!=mergify[bot] + - or: + - base=main + actions: + close: + comment: + message: | + @{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on develop branch. + + - name: backport to develop + conditions: + - label="backport develop" + actions: + backport: + branches: + - develop + assignees: + - "{{ author }}" + + - name: backport to main-hotfix + conditions: + - label="backport main-hotfix" + actions: + backport: + branches: + - main-hotfix + assignees: + - "{{ author }}" + + - name: backport to main + conditions: + - label="backport main" + actions: + backport: + branches: + - main + assignees: + - "{{ author }}" + + - name: add conflicts label when PR has conflicts + conditions: + - conflict + actions: + label: + add: + - conflicts + comment: + message: | + @{{author}} this pull request has merge conflicts. Please resolve them so it can be reviewed and merged. + + - name: remove conflicts label once resolved + conditions: + - -conflict + - label=conflicts + actions: + label: + remove: + - conflicts diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b117528 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,70 @@ +exclude: "node_modules|.git" +default_stages: [pre-commit] +fail_fast: false + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + files: "crm.*" + exclude: ".*json$|.*txt$|.*csv|.*md|.*svg" + - id: no-commit-to-branch + args: ["--branch", "develop"] + - id: check-merge-conflict + - id: check-ast + - id: check-json + - id: check-toml + - id: check-yaml + - id: debug-statements + + - repo: https://github.com/oxc-project/mirrors-oxlint + rev: v1.50.0 + hooks: + - id: oxlint + files: ^frontend/.*\.(js|ts|vue)$ + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.1.0 + hooks: + - id: prettier + files: ^frontend/.*\.(js|ts|vue|css|scss|html|json)$ + additional_dependencies: + - prettier@3.2.5 + exclude: | + (?x)^( + crm/public/dist/.*| + crm/public/frontend/.*| + .*node_modules.*| + )$ + + - repo: local + hooks: + - id: eslint + name: eslint + entry: bash -c 'cd frontend && eslint --fix src/**/*.{js,ts,vue}' + language: node + files: ^frontend/.*\.(js|ts|vue)$ + pass_filenames: false + additional_dependencies: + - "eslint@^10.0.2" + - "@eslint/js@^10.0.1" + - "typescript-eslint@^8.56.1" + - "eslint-plugin-vue@^10.8.0" + - "eslint-config-prettier@^10.1.8" + - "globals@^17.3.0" + - "vue-eslint-parser@^10.4.0" + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.1 + hooks: + - id: ruff + name: "Run ruff linter and apply fixes" + args: ["--fix"] + - id: ruff-format + name: "Run ruff formatter" + +ci: + autoupdate_schedule: weekly + skip: [] + submodules: false diff --git a/.releaserc b/.releaserc new file mode 100644 index 0000000..0c43b99 --- /dev/null +++ b/.releaserc @@ -0,0 +1,21 @@ +{ + "branches": ["main"], + "plugins": [ + "@semantic-release/commit-analyzer", { + "preset": "angular" + }, + "@semantic-release/release-notes-generator", + [ + "@semantic-release/exec", { + "prepareCmd": 'sed -ir "s/[0-9]*\.[0-9]*\.[0-9]*/${nextRelease.version}/" crm/__init__.py' + } + ], + [ + "@semantic-release/git", { + "assets": ["crm/__init__.py"], + "message": "chore(release): Bumped to Version ${nextRelease.version}" + } + ], + "@semantic-release/github" + ] +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..29ebfa5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e66305 --- /dev/null +++ b/README.md @@ -0,0 +1,205 @@ +
+ + + Frappe CRM Logo + + +

Frappe CRM

+ +**Simplify Sales, Amplify Relationships** + +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/frappe/crm)](https://github.com/frappe/crm/releases) +[![Frontend](https://github.com/frappe/crm/actions/workflows/frontend-tests.yml/badge.svg)](https://github.com/frappe/crm/actions/workflows/frontend-tests.yml) +[![Migration](https://github.com/frappe/crm/actions/workflows/migration-test.yml/badge.svg)](https://github.com/frappe/crm/actions/workflows/migration-test.yml) +[![Server](https://github.com/frappe/crm/actions/workflows/server-tests.yml/badge.svg)](https://github.com/frappe/crm/actions/workflows/server-tests.yml) +
+ + + Frappe CRM Hero Image + +
+ +[Live Demo](https://frappecrm-demo.frappe.cloud/#login) - [Website](https://frappe.io/crm) - [Documentation](https://docs.frappe.io/crm) + +
+ +## Frappe CRM + +Frappe CRM is a simple, affordable, open-source CRM tool designed for modern sales teams with unlimited users. Frappe CRM is crafted for providing a great user experience, packed with features for core CRM activities helping you build strong customer relationships while keeping things clean and organised. + +### Motivation + +The motivation behind building Frappe CRM stems from the need for a simple, customizable, and open-source solution tailored to modern business needs. Many existing CRMs are either too complex, overly generic, or locked behind steep pricing models that hinder accessibility and flexibility. Frappe CRM was designed to bridge this gap, offering a tool that empowers businesses to manage their customer relationships seamlessly while being easy to adapt to specific workflows. Built on the Frappe framework, it prioritizes usability, extensibility, and affordability, making it an ideal choice for growing teams and organizations looking for a CRM that aligns with their unique processes. + +### Key Features + +- **User-Friendly and Flexible:** A simple, intuitive interface that’s easy to navigate and highly customizable, enabling teams to adapt it to their specific processes effortlessly. +- **All-in-One Lead/Deal Page:** Consolidate all essential actions and details—like activities, comments, notes, tasks, and more—into a single page for a seamless workflow experience. +- **Kanban View:** Manage leads and deals visually with a drag-and-drop Kanban board, offering clarity and efficiency in tracking progress across stages. +- **Custom Views:** Design personalized views to organize and display leads and deals using custom filters, sorting, and columns, ensuring quick access to the most relevant information. + +
+ Screenshots + +
+ + + Lead List + +
+
+ + + Lead Page + +
+
+ + + Email Template + +
+
+ + + Call UI + +
+
+ + + Call Log + +
+ +
+ +### Integrations + +- **Twilio:** Integrate Twilio to make and receive calls from the CRM. You can also record calls. It is a built-in integration. +- **Exotel:** Integrate Exotel to make and receive calls via agents mobile phone from the CRM. You can also record calls. It is a built-in integration. +- **WhatsApp:** Integrate WhatsApp to send and receive messages from the CRM. [Frappe WhatsApp](https://github.com/shridarpatil/frappe_whatsapp) is used for this integration. +- **ERPNext:** Integrate with [ERPNext](https://erpnext.com) to extend the CRM capabilities to include invoicing, accounting, and more. + +### Under the Hood + +- [Frappe Framework](https://github.com/frappe/frappe): A full-stack web application framework. +- [Frappe UI](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface. + +### Compatibility +This app is compatible with the following versions of Frappe and ERPNext: + +| CRM branch | Stability | Frappe branch | ERPNext branch | +| :-------------------- | :-------- | :------------------- | :------------------- | +| main - v1.x | stable | v15.x & v16.x | v15.x & v16.x | +| develop - future/v2.x | unstable | develop - future/v17 | develop - future/v17 | + +## Getting Started (Production) + +### Managed Hosting + +Get started with your personal or business site with a few clicks on Frappe Cloud - our official hosting service. + + +### Self Hosting + +Follow these steps to set up Frappe CRM in production: + +**Step 1**: Download the easy install script + +```bash +wget https://frappe.io/easy-install.py +``` + +**Step 2**: Run the deployment command + +```bash +python3 ./easy-install.py deploy \ + --project=crm_prod_setup \ + --email=email.example.com \ + --image=ghcr.io/frappe/crm \ + --version=stable \ + --app=crm \ + --sitename subdomain.domain.tld +``` + +Replace the following parameters with your values: + +- `email.example.com`: Your email address +- `subdomain.domain.tld`: Your domain name where CRM will be hosted + +The script will set up a production-ready instance of Frappe CRM with all the necessary configurations in about 5 minutes. + +## Getting Started (Development) + +### Local Setup + +1. [Setup Bench](https://docs.frappe.io/framework/user/en/installation). +1. In the frappe-bench directory, run `bench start` and keep it running. +1. Open a new terminal session and cd into `frappe-bench` directory and run following commands: + ```sh + $ bench get-app crm + $ bench new-site sitename.localhost --install-app crm + $ bench browse sitename.localhost --user Administrator + ``` +1. Access the crm page at `sitename.localhost:8000/crm` in your web browser. + +**For Frontend Development** +1. Open a new terminal session and cd into `frappe-bench/apps/crm`, and run the following commands: + ``` + yarn install + yarn dev + ``` +1. Now, you can access the site on vite dev server at `http://sitename.localhost:8080` + +**Note:** You'll find all the code related to Frappe CRM's frontend inside `frappe-bench/apps/crm/frontend` + +### Docker + +You need Docker, docker-compose and git setup on your machine. Refer [Docker documentation](https://docs.docker.com/). After that, follow below steps: + +**Step 1**: Setup folder and download the required files + + mkdir frappe-crm + cd frappe-crm + + # Download the docker-compose file + wget -O docker-compose.yml https://raw.githubusercontent.com/frappe/crm/develop/docker/docker-compose.yml + + # Download the setup script + wget -O init.sh https://raw.githubusercontent.com/frappe/crm/develop/docker/init.sh + +**Step 2**: Run the container and daemonize it + + docker compose up -d + +**Step 3**: The site [http://crm.localhost:8000/crm](http://crm.localhost:8000/crm) should now be available. The default credentials are: + +- Username: Administrator +- Password: admin + +## Learn and connect + +- [Telegram Public Group](https://t.me/frappecrm) +- [Discuss Forum](https://discuss.frappe.io/c/frappe-crm) +- [Documentation](https://docs.frappe.io/crm) +- [YouTube](https://www.youtube.com/@frappetech) +- [X/Twitter](https://x.com/frappetech) + +
+
+ diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..7a8db1f --- /dev/null +++ b/codecov.yml @@ -0,0 +1,23 @@ +codecov: + require_ci_to_pass: yes + +coverage: + status: + project: + default: + target: auto + threshold: 0.5% + + patch: + default: + target: 85% + threshold: 0% + base: auto + branches: + - develop + if_ci_failed: ignore + only_pulls: true + +comment: + layout: "diff, files" + require_changes: true diff --git a/commitlint.config.cjs b/commitlint.config.cjs new file mode 100644 index 0000000..9cd9995 --- /dev/null +++ b/commitlint.config.cjs @@ -0,0 +1,27 @@ +module.exports = { + parserPreset: "conventional-changelog-conventionalcommits", + rules: { + "subject-empty": [2, "never"], + "type-case": [2, "always", "lower-case"], + "type-empty": [2, "never"], + "type-enum": [ + 2, + "always", + [ + "build", + "chore", + "ci", + "docs", + "feat", + "fix", + "perf", + "refactor", + "revert", + "style", + "test", + "patch", + "deprecate", // deprecation decision + ], + ], + }, +}; diff --git a/crm/__init__.py b/crm/__init__.py new file mode 100644 index 0000000..fafe51a --- /dev/null +++ b/crm/__init__.py @@ -0,0 +1,2 @@ +__version__ = "2.0.0-dev" +__title__ = "Frappe CRM" diff --git a/crm/activation.py b/crm/activation.py new file mode 100644 index 0000000..17d6693 --- /dev/null +++ b/crm/activation.py @@ -0,0 +1,36 @@ +import frappe + + +def get_site_info(site_info): + # called via hook + return {"activation": get_sales_data(site_info)} + + +def get_sales_data(site_info): + activation_level = site_info.get("activation", {}).get("activation_level", 0) + sales_data = site_info.get("activation", {}).get("sales_data", []) + doctypes = [ + "CRM Lead", + "CRM Deal", + "CRM Organization", + "Contact", + "CRM Task", + "FCRM Note", + "CRM Call Log", + "CRM Lead Status", + "CRM Deal Status", + "CRM Lead Source", + "CRM Industry", + "CRM Territory", + "CRM Lost Reason", + "CRM Product", + "CRM Form Script", + "CRM Fields Layout", + "CRM View Settings", + ] + + for doctype in doctypes: + count = frappe.db.count(doctype) + sales_data.append({doctype: count}) + + return {"activation_level": activation_level, "sales_data": sales_data} diff --git a/crm/api/__init__.py b/crm/api/__init__.py new file mode 100644 index 0000000..16bdf4a --- /dev/null +++ b/crm/api/__init__.py @@ -0,0 +1,179 @@ +import frappe +from bs4 import BeautifulSoup +from frappe import _ +from frappe.core.api.file import get_max_file_size +from frappe.rate_limiter import rate_limit +from frappe.translate import get_all_translations +from frappe.utils import cstr, split_emails, validate_email_address + +from crm.utils import is_frappe_version + + +@frappe.whitelist(allow_guest=True) +def get_translations(): + if frappe.session.user != "Guest": + language = frappe.db.get_value("User", frappe.session.user, "language") + else: + language = frappe.db.get_single_value("System Settings", "language") + + return get_all_translations(language) + + +@frappe.whitelist() +def get_user_signature(): + user = frappe.session.user + user_email_signature = ( + frappe.db.get_value( + "User", + user, + "email_signature", + ) + if user + else None + ) + + signature = user_email_signature or frappe.db.get_value( + "Email Account", + {"default_outgoing": 1, "add_signature": 1}, + "signature", + ) + + if not signature: + return + + soup = BeautifulSoup(signature, "html.parser") + html_signature = soup.find("div", {"class": "ql-editor read-mode"}) + _signature = None + if html_signature: + _signature = html_signature.renderContents() + content = "" + if cstr(_signature) or signature: + content = f'

{signature}

' + return content + + +def check_app_permission(): + if frappe.session.user == "Administrator": + return True + + allowed_modules = [] + + if is_frappe_version("15"): + allowed_modules = frappe.config.get_modules_from_all_apps_for_user() + elif is_frappe_version("16", above=True): + from frappe.utils.modules import get_modules_from_all_apps_for_user + + allowed_modules = get_modules_from_all_apps_for_user() + + allowed_modules = [x["module_name"] for x in allowed_modules] + if "FCRM" not in allowed_modules: + return False + + roles = frappe.get_roles() + if any(role in ["System Manager", "Sales User", "Sales Manager"] for role in roles): + return True + + return False + + +@frappe.whitelist(allow_guest=True) +@rate_limit(limit=10, seconds=60 * 60) +def accept_invitation(key: str | None = None): + if not key: + frappe.throw(_("Invalid or expired key")) + + result = frappe.db.get_all("CRM Invitation", filters={"key": key}, pluck="name") + if not result: + frappe.throw(_("Invalid or expired key")) + invitation = frappe.get_doc("CRM Invitation", result[0]) + is_new_user = invitation.accept() + invitation.reload() + + # this is a GET request, which is rolled back unless a commit is requested + frappe.local.flags.commit = True + + if invitation.status == "Accepted": + frappe.local.response["type"] = "redirect" + if is_new_user: + # a new user has no password yet, send them to the set password page + # which logs them in and redirects to /crm once the password is set + user = frappe.get_doc("User", invitation.email) + frappe.local.response["location"] = user._reset_password() + else: + frappe.local.login_manager.login_as(invitation.email) + frappe.local.response["location"] = "/crm" + + +@frappe.whitelist() +def invite_by_email(emails: str, role: str): + frappe.only_for(["Sales Manager", "System Manager"], True) + + user_roles = frappe.get_roles(frappe.session.user) + + if role == "System Manager" and "System Manager" not in user_roles: + frappe.throw(_("You are not allowed to invite System Managers"), frappe.PermissionError) + + if role == "Sales Manager" and "System Manager" not in user_roles: + frappe.throw(_("You are not allowed to invite Sales Managers"), frappe.PermissionError) + + if role not in ["System Manager", "Sales Manager", "Sales User"]: + frappe.throw(_("Cannot invite for this role"), frappe.PermissionError) + + if not emails: + return + + email_string = validate_email_address(emails, throw=False) + email_list = split_emails(email_string) + if not email_list: + return + existing_members = frappe.db.get_all("User", filters={"email": ["in", email_list]}, pluck="email") + existing_invites = frappe.db.get_all( + "CRM Invitation", + filters={ + "email": ["in", email_list], + "role": ["in", ["System Manager", "Sales Manager", "Sales User"]], + }, + pluck="email", + ) + + to_invite = list(set(email_list) - set(existing_members) - set(existing_invites)) + + for email in to_invite: + frappe.get_doc(doctype="CRM Invitation", email=email, role=role).insert(ignore_permissions=True) + + return { + "existing_members": existing_members, + "existing_invites": existing_invites, + "to_invite": to_invite, + } + + +@frappe.whitelist(methods=["DELETE", "POST"]) +def delete_attachment(doctype: str, docname: str, file_url: str): + if not frappe.has_permission(doctype, doc=docname, ptype="write"): + frappe.throw(_("You don't have permission to delete this attachment"), frappe.PermissionError) + + file_name = frappe.db.get_value( + "File", + {"file_url": file_url, "attached_to_doctype": doctype, "attached_to_name": docname}, + "name", + ) + if file_name: + frappe.delete_doc("File", file_name) + + +@frappe.whitelist() +def get_file_uploader_defaults(doctype: str): + max_number_of_files = None + make_attachments_public = False + if doctype: + meta = frappe.get_meta(doctype) + max_number_of_files = meta.get("max_attachments") + make_attachments_public = meta.get("make_attachments_public") + + return { + "allowed_file_types": frappe.get_system_settings("allowed_file_extensions"), + "max_file_size": get_max_file_size(), + "max_number_of_files": max_number_of_files, + "make_attachments_public": bool(make_attachments_public), + } diff --git a/crm/api/activities.py b/crm/api/activities.py new file mode 100644 index 0000000..36488c8 --- /dev/null +++ b/crm/api/activities.py @@ -0,0 +1,536 @@ +import json + +import frappe +from bs4 import BeautifulSoup +from frappe import _ +from frappe.desk.form.load import get_docinfo +from frappe.query_builder import JoinType +from frappe.translate import get_translated_doctypes + +from crm.fcrm.doctype.crm_call_log.crm_call_log import parse_call_log +from crm.fcrm.doctype.crm_fields_layout.crm_fields_layout import get_permlevel_access + + +@frappe.whitelist() +def get_activities(name: str): + if frappe.db.exists("CRM Deal", name): + return get_deal_activities(name) + elif frappe.db.exists("CRM Lead", name): + return get_lead_activities(name) + else: + frappe.throw(_("Document not found"), frappe.DoesNotExistError) + + +def get_deal_activities(name: str): + if not frappe.has_permission("CRM Deal", "read", name): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + get_docinfo("", "CRM Deal", name) + docinfo = frappe.response["docinfo"] + deal_fields = get_readable_fields("CRM Deal") + avoid_fields = [ + "lead", + "response_by", + "sla_creation", + "sla", + "first_response_time", + "first_responded_on", + ] + + doc = frappe.db.get_values("CRM Deal", name, ["creation", "owner", "lead"])[0] + lead = doc[2] + + activities = [] + calls = [] + notes = [] + tasks = [] + attachments = [] + creation_text = _("created this deal") + + if lead: + creation_text = _("converted the lead to this deal") + # a user can have access to the deal but not the lead it came from, so + # skip the lead's history instead of failing the whole timeline + if frappe.has_permission("CRM Lead", "read", lead): + activities, calls, notes, tasks, attachments = get_lead_activities(lead) + + activities.append( + { + "activity_type": "creation", + "creation": doc[0], + "owner": doc[1], + "data": creation_text, + "is_lead": False, + } + ) + + docinfo.versions.reverse() + + for version in docinfo.versions: + data = json.loads(version.data) + if not data.get("changed"): + continue + + if change := data.get("changed")[0]: + field = deal_fields.get(change[0], None) + + if not field or change[0] in avoid_fields or (not change[1] and not change[2]): + continue + + field_label = field.get("label") or change[0] + field_option = field.get("options") or None + + activity_type = "changed" + data = { + "field": change[0], + "field_label": field_label, + "old_value": change[1], + "value": change[2], + } + + if not change[1] and change[2]: + activity_type = "added" + data = { + "field": change[0], + "field_label": field_label, + "value": change[2], + } + elif change[1] and not change[2]: + activity_type = "removed" + data = { + "field": change[0], + "field_label": field_label, + "value": change[1], + } + + if data.get("value") and field_option and is_translatable(field_option): + data["value"] = _(data["value"]) + + if data.get("old_value"): + data["old_value"] = _(data["old_value"]) + + activity = { + "activity_type": activity_type, + "creation": version.creation, + "owner": version.owner, + "data": data, + "is_lead": False, + "options": field_option, + } + activities.append(activity) + + for comment in docinfo.comments: + activity = { + "name": comment.name, + "activity_type": "comment", + "creation": comment.creation, + "owner": comment.owner, + "content": comment.content, + "attachments": get_attachments("Comment", comment.name), + "is_lead": False, + } + activities.append(activity) + + for communication in docinfo.communications + docinfo.automated_messages: + activity = { + "activity_type": "communication", + "communication_type": communication.communication_type, + "communication_date": communication.communication_date or communication.creation, + "creation": communication.creation, + "data": { + "subject": communication.subject, + "content": communication.content, + "sender_full_name": communication.sender_full_name, + "sender": communication.sender, + "recipients": communication.recipients, + "cc": communication.cc, + "bcc": communication.bcc, + "attachments": get_attachments("Communication", communication.name), + "read_by_recipient": communication.read_by_recipient, + "delivery_status": communication.delivery_status, + }, + "is_lead": False, + } + activities.append(activity) + + for attachment_log in docinfo.attachment_logs: + activity = { + "name": attachment_log.name, + "activity_type": "attachment_log", + "creation": attachment_log.creation, + "owner": attachment_log.owner, + "data": parse_attachment_log(attachment_log.content, attachment_log.comment_type), + "is_lead": False, + } + activities.append(activity) + + calls = calls + get_linked_calls(name).get("calls", []) + notes = notes + get_linked_notes(name) + get_linked_calls(name).get("notes", []) + tasks = tasks + get_linked_tasks(name) + get_linked_calls(name).get("tasks", []) + attachments = attachments + get_attachments("CRM Deal", name) + + activities.sort(key=lambda x: x["creation"], reverse=True) + activities = handle_multiple_versions(activities) + + return activities, calls, notes, tasks, attachments + + +def get_lead_activities(name: str): + if not frappe.has_permission("CRM Lead", "read", name): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + get_docinfo("", "CRM Lead", name) + docinfo = frappe.response["docinfo"] + lead_fields = get_readable_fields("CRM Lead") + avoid_fields = [ + "converted", + "response_by", + "sla_creation", + "sla", + "first_response_time", + "first_responded_on", + ] + + doc = frappe.db.get_values("CRM Lead", name, ["creation", "owner"])[0] + activities = [ + { + "activity_type": "creation", + "creation": doc[0], + "owner": doc[1], + "data": _("created this lead"), + "is_lead": True, + } + ] + + docinfo.versions.reverse() + + for version in docinfo.versions: + data = json.loads(version.data) + if not data.get("changed"): + continue + + if change := data.get("changed")[0]: + field = lead_fields.get(change[0], None) + + if not field or change[0] in avoid_fields or (not change[1] and not change[2]): + continue + + field_label = field.get("label") or change[0] + field_option = field.get("options") or None + + activity_type = "changed" + data = { + "field": change[0], + "field_label": field_label, + "old_value": change[1], + "value": change[2], + } + + if not change[1] and change[2]: + activity_type = "added" + data = { + "field": change[0], + "field_label": field_label, + "value": change[2], + } + elif change[1] and not change[2]: + activity_type = "removed" + data = { + "field": change[0], + "field_label": field_label, + "value": change[1], + } + + if data.get("value") and field_option and is_translatable(field_option): + data["value"] = _(data["value"]) + + if data.get("old_value"): + data["old_value"] = _(data["old_value"]) + + activity = { + "activity_type": activity_type, + "creation": version.creation, + "owner": version.owner, + "data": data, + "is_lead": True, + "options": field_option, + } + activities.append(activity) + + for comment in docinfo.comments: + activity = { + "name": comment.name, + "activity_type": "comment", + "creation": comment.creation, + "owner": comment.owner, + "content": comment.content, + "attachments": get_attachments("Comment", comment.name), + "is_lead": True, + } + activities.append(activity) + + for communication in docinfo.communications + docinfo.automated_messages: + activity = { + "activity_type": "communication", + "communication_type": communication.communication_type, + "communication_date": communication.communication_date or communication.creation, + "creation": communication.creation, + "data": { + "subject": communication.subject, + "content": communication.content, + "sender_full_name": communication.sender_full_name, + "sender": communication.sender, + "recipients": communication.recipients, + "cc": communication.cc, + "bcc": communication.bcc, + "attachments": get_attachments("Communication", communication.name), + "read_by_recipient": communication.read_by_recipient, + "delivery_status": communication.delivery_status, + }, + "is_lead": True, + } + activities.append(activity) + + for attachment_log in docinfo.attachment_logs: + activity = { + "name": attachment_log.name, + "activity_type": "attachment_log", + "creation": attachment_log.creation, + "owner": attachment_log.owner, + "data": parse_attachment_log(attachment_log.content, attachment_log.comment_type), + "is_lead": True, + } + activities.append(activity) + + calls = get_linked_calls(name).get("calls", []) + notes = get_linked_notes(name) + get_linked_calls(name).get("notes", []) + tasks = get_linked_tasks(name) + get_linked_calls(name).get("tasks", []) + attachments = get_attachments("CRM Lead", name) + + activities.sort(key=lambda x: x["creation"], reverse=True) + activities = handle_multiple_versions(activities) + + return activities, calls, notes, tasks, attachments + + +def get_readable_fields(doctype: str): + """Map of fieldname to label & options, skipping fields the user cannot read. + + Permlevel restrictions already hide these fields on the form layout, so the + activity timeline has to hide them too instead of leaking their values. + """ + allowed_permlevels = get_permlevel_access("read", doctype) + + return { + field.fieldname: {"label": field.label, "options": field.options} + for field in frappe.get_meta(doctype).fields + if field.permlevel == 0 or field.permlevel in allowed_permlevels + } + + +def get_attachments(doctype: str, name: str): + return ( + frappe.db.get_all( + "File", + filters={"attached_to_doctype": doctype, "attached_to_name": name}, + fields=[ + "name", + "file_name", + "file_type", + "file_url", + "file_size", + "is_private", + "modified", + "creation", + "owner", + ], + ) + or [] + ) + + +def handle_multiple_versions(versions: list): + activities = [] + grouped_versions = [] + old_version = None + for version in versions: + is_version = version["activity_type"] in ["changed", "added", "removed"] + if not is_version: + activities.append(version) + if not old_version: + old_version = version + if is_version: + grouped_versions.append(version) + continue + if is_version and old_version.get("owner") and version["owner"] == old_version["owner"]: + grouped_versions.append(version) + else: + if grouped_versions: + activities.append(parse_grouped_versions(grouped_versions)) + grouped_versions = [] + if is_version: + grouped_versions.append(version) + old_version = version + if version == versions[-1] and grouped_versions: + activities.append(parse_grouped_versions(grouped_versions)) + + return activities + + +def parse_grouped_versions(versions: list): + version = versions[0] + if len(versions) == 1: + return version + other_versions = versions[1:] + version["other_versions"] = other_versions + return version + + +def get_linked_calls(name: str): + calls = frappe.db.get_all( + "CRM Call Log", + filters={"reference_docname": name}, + fields=[ + "name", + "caller", + "receiver", + "from", + "to", + "duration", + "start_time", + "end_time", + "status", + "type", + "recording_url", + "creation", + "note", + ], + ) + + linked_calls = frappe.db.get_all( + "Dynamic Link", filters={"link_name": name, "parenttype": "CRM Call Log"}, pluck="parent" + ) + + notes = [] + tasks = [] + + if linked_calls: + CallLog = frappe.qb.DocType("CRM Call Log") + Link = frappe.qb.DocType("Dynamic Link") + query = ( + frappe.qb.from_(CallLog) + .select( + CallLog.name, + CallLog.caller, + CallLog.receiver, + CallLog["from"], + CallLog.to, + CallLog.duration, + CallLog.start_time, + CallLog.end_time, + CallLog.status, + CallLog.type, + CallLog.recording_url, + CallLog.creation, + CallLog.note, + Link.link_doctype, + Link.link_name, + ) + .join(Link, JoinType.inner) + .on(Link.parent == CallLog.name) + .where(CallLog.name.isin(linked_calls)) + ) + _calls = query.run(as_dict=True) + + for call in _calls: + if call.get("link_doctype") == "FCRM Note": + notes.append(call.link_name) + elif call.get("link_doctype") == "CRM Task": + tasks.append(call.link_name) + + _calls = [call for call in _calls if call.get("link_doctype") not in ["FCRM Note", "CRM Task"]] + if _calls: + calls = calls + _calls + + if notes: + notes = frappe.db.get_all( + "FCRM Note", + filters={"name": ("in", notes)}, + fields=["name", "title", "content", "owner", "modified"], + ) + + if tasks: + tasks = frappe.db.get_all( + "CRM Task", + filters={"name": ("in", tasks)}, + fields=[ + "name", + "title", + "description", + "assigned_to", + "due_date", + "priority", + "status", + "modified", + ], + ) + + calls = [parse_call_log(call) for call in calls] if calls else [] + + return {"calls": calls, "notes": notes, "tasks": tasks} + + +def get_linked_notes(name: str): + notes = frappe.db.get_all( + "FCRM Note", + filters={"reference_docname": name}, + fields=["name", "title", "content", "owner", "modified", "creation"], + ) + return notes or [] + + +def get_linked_tasks(name: str): + tasks = frappe.db.get_all( + "CRM Task", + filters={"reference_docname": name}, + fields=[ + "name", + "title", + "description", + "assigned_to", + "due_date", + "priority", + "status", + "modified", + "creation", + ], + ) + return tasks or [] + + +def parse_attachment_log(html: str, type: str): + soup = BeautifulSoup(html, "html.parser") + a_tag = soup.find("a") + type = "added" if type == "Attachment" else "removed" + if not a_tag: + return { + "type": type, + "file_name": html.replace("Removed ", ""), + "file_url": "", + "is_private": False, + } + + is_private = False + if "private/files" in a_tag["href"]: + is_private = True + + return { + "type": type, + "file_name": a_tag.text, + "file_url": a_tag["href"], + "is_private": is_private, + } + + +def is_translatable(doctype: str) -> bool: + return doctype in get_translated_doctypes() diff --git a/crm/api/assignment_rule.py b/crm/api/assignment_rule.py new file mode 100644 index 0000000..c4f31ea --- /dev/null +++ b/crm/api/assignment_rule.py @@ -0,0 +1,32 @@ +import frappe + + +@frappe.whitelist() +def get_assignment_rules_list(): + assignment_rules = [] + for docname in frappe.get_all( + "Assignment Rule", filters={"document_type": ["in", ["CRM Lead", "CRM Deal"]]} + ): + doc = frappe.get_value( + "Assignment Rule", + docname, + fieldname=[ + "name", + "description", + "disabled", + "priority", + ], + as_dict=True, + ) + users_exists = bool(frappe.db.exists("Assignment Rule User", {"parent": docname.name})) + assignment_rules.append({**doc, "users_exists": users_exists}) + return assignment_rules + + +@frappe.whitelist() +def duplicate_assignment_rule(docname: str, new_name: str): + doc = frappe.get_doc("Assignment Rule", docname) + doc.name = new_name + doc.assignment_rule_name = new_name + doc.insert() + return doc diff --git a/crm/api/auth.py b/crm/api/auth.py new file mode 100644 index 0000000..e0aa49c --- /dev/null +++ b/crm/api/auth.py @@ -0,0 +1,39 @@ +import frappe + + +@frappe.whitelist(allow_guest=True) +def oauth_providers(): + from frappe.utils.html_utils import get_icon_html + from frappe.utils.oauth import get_oauth2_authorize_url, get_oauth_keys + from frappe.utils.password import get_decrypted_password + + out = [] + providers = frappe.get_all( + "Social Login Key", + filters={"enable_social_login": 1}, + fields=["name", "client_id", "base_url", "provider_name", "icon"], + order_by="name", + ) + + for provider in providers: + client_secret = get_decrypted_password("Social Login Key", provider.name, "client_secret") + if not client_secret: + continue + + icon = None + if provider.icon: + if provider.provider_name == "Custom": + icon = get_icon_html(provider.icon, small=True) + else: + icon = f"{provider.provider_name}" + + if provider.client_id and provider.base_url and get_oauth_keys(provider.name): + out.append( + { + "name": provider.name, + "provider_name": provider.provider_name, + "auth_url": get_oauth2_authorize_url(provider.name, "/crm"), + "icon": icon, + } + ) + return out diff --git a/crm/api/comment.py b/crm/api/comment.py new file mode 100644 index 0000000..6785127 --- /dev/null +++ b/crm/api/comment.py @@ -0,0 +1,128 @@ +from collections.abc import Iterable + +import frappe +from bs4 import BeautifulSoup +from frappe import _ +from frappe.desk.form.utils import add_comment as frappe_add_comment +from frappe.utils import get_fullname + +from crm.fcrm.doctype.crm_notification.crm_notification import notify_user + + +def on_update(self, method): + notify_mentions(self) + + +def notify_mentions(doc): + """ + Extract mentions from `content`, and notify. + `content` must have `HTML` content. + """ + if doc.reference_doctype not in ["CRM Lead", "CRM Deal"]: + return + content = getattr(doc, "content", None) + if not content: + return + mentions = extract_mentions(content) + reference_doc = frappe.get_doc(doc.reference_doctype, doc.reference_name) + for mention in mentions: + owner = frappe.get_cached_value("User", doc.owner, "full_name") + doctype = doc.reference_doctype + if doctype.startswith("CRM "): + doctype = doctype[4:].lower() + name = ( + reference_doc.lead_name + if doctype == "lead" + else reference_doc.organization or reference_doc.lead_name + ) + notification_text = f""" +
+ {owner} + {_("mentioned you in {0}").format(doctype)} + {name} +
+ """ + notify_user( + { + "owner": doc.owner, + "assigned_to": mention.email, + "notification_type": "Mention", + "message": doc.content, + "notification_text": notification_text, + "reference_doctype": "Comment", + "reference_docname": doc.name, + "redirect_to_doctype": doc.reference_doctype, + "redirect_to_docname": doc.reference_name, + } + ) + + +def extract_mentions(html): + if not html: + return [] + soup = BeautifulSoup(html, "html.parser") + mentions = [] + for d in soup.find_all("span", attrs={"data-type": "mention"}): + mentions.append(frappe._dict(full_name=d.get("data-label"), email=d.get("data-id"))) + return mentions + + +@frappe.whitelist() +def add_comment(reference_doctype: str, reference_name: str, content: str, attachments: list | None = None): + """Add a comment to the given document + + :param reference_doctype: Reference Doctype + :param reference_name: Reference Document Name + :param content: Comment Content (HTML) + :param attachments: List of File names or dicts with keys "fname" and "fcontent" + :return: Comment Document + """ + comment = frappe_add_comment( + reference_doctype, + reference_name, + content, + comment_email=frappe.session.user, + comment_by=get_fullname(frappe.session.user), + ) + + if attachments and comment.name: + add_attachments(comment.name, attachments) + + return comment + + +def add_attachments(name: str, attachments: Iterable[str | dict]) -> None: + """Add attachments to the given Comment + + :param name: Comment name + :param attachments: File names or dicts with keys "fname" and "fcontent" + """ + # loop through attachments + for a in attachments: + if isinstance(a, str): + attach = frappe.db.get_value("File", {"name": a}, ["file_url", "is_private"], as_dict=1) + file_args = { + "file_url": attach.file_url, + "is_private": attach.is_private, + } + elif isinstance(a, dict) and "fcontent" in a and "fname" in a: + # dict returned by frappe.attach_print() + file_args = { + "file_name": a["fname"], + "content": a["fcontent"], + "is_private": 1, + } + else: + continue + + file_args.update( + { + "attached_to_doctype": "Comment", + "attached_to_name": name, + "folder": "Home/Attachments", + } + ) + + _file = frappe.new_doc("File") + _file.update(file_args) + _file.save(ignore_permissions=True) diff --git a/crm/api/contact.py b/crm/api/contact.py new file mode 100644 index 0000000..50c8247 --- /dev/null +++ b/crm/api/contact.py @@ -0,0 +1,145 @@ +import frappe +from frappe import _ + + +def validate(doc, method): + update_deals_email_mobile_no(doc) + + +def update_deals_email_mobile_no(doc): + linked_deals = frappe.get_all( + "CRM Contacts", + filters={"contact": doc.name, "is_primary": 1}, + fields=["parent"], + ) + + for linked_deal in linked_deals: + deal = frappe.db.get_values("CRM Deal", linked_deal.parent, ["email", "mobile_no"], as_dict=True)[0] + if deal.email != doc.email_id or deal.mobile_no != doc.mobile_no: + frappe.db.set_value( + "CRM Deal", + linked_deal.parent, + { + "email": doc.email_id, + "mobile_no": doc.mobile_no, + }, + ) + + +@frappe.whitelist() +def get_linked_deals(contact: str): + """Get linked deals for a contact""" + + if not frappe.has_permission("Contact", "read", contact): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + deal_names = frappe.get_all( + "CRM Contacts", + filters={"contact": contact, "parenttype": "CRM Deal"}, + fields=["parent"], + distinct=True, + ) + + # get deals data + deals = [] + for d in deal_names: + deal = frappe.get_cached_doc( + "CRM Deal", + d.parent, + fields=[ + "name", + "organization", + "currency", + "deal_value", + "status", + "email", + "mobile_no", + "deal_owner", + "modified", + ], + ) + deals.append(deal.as_dict()) + + return deals + + +@frappe.whitelist() +def create_new(contact: str, field: str, value: str): + """Create new email or phone for a contact""" + if not frappe.has_permission("Contact", "write", contact): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + contact = frappe.get_cached_doc("Contact", contact) + + if field == "email": + email = {"email_id": value, "is_primary": 1 if len(contact.email_ids) == 0 else 0} + contact.append("email_ids", email) + elif field in ("mobile_no", "phone"): + mobile_no = {"phone": value, "is_primary_mobile_no": 1 if len(contact.phone_nos) == 0 else 0} + contact.append("phone_nos", mobile_no) + else: + frappe.throw(_("Invalid field")) + + contact.save() + return True + + +@frappe.whitelist() +def set_as_primary(contact: str, field: str, value: str): + """Set email or phone as primary for a contact""" + if not frappe.has_permission("Contact", "write", contact): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + contact = frappe.get_doc("Contact", contact) + + if field == "email": + for email in contact.email_ids: + if email.email_id == value: + email.is_primary = 1 + else: + email.is_primary = 0 + elif field in ("mobile_no", "phone"): + name = "is_primary_mobile_no" if field == "mobile_no" else "is_primary_phone" + for phone in contact.phone_nos: + if phone.phone == value: + phone.set(name, 1) + else: + phone.set(name, 0) + else: + frappe.throw(_("Invalid field")) + + contact.save() + return True + + +@frappe.whitelist() +def search_emails(txt: str): + doctype = "Contact" + meta = frappe.get_meta(doctype) + filters = [["Contact", "email_id", "is", "set"]] + + if meta.get("fields", {"fieldname": "enabled", "fieldtype": "Check"}): + filters.append([doctype, "enabled", "=", 1]) + if meta.get("fields", {"fieldname": "disabled", "fieldtype": "Check"}): + filters.append([doctype, "disabled", "!=", 1]) + + or_filters = [] + search_fields = ["full_name", "email_id", "name"] + if txt: + for f in search_fields: + or_filters.append([doctype, f.strip(), "like", f"%{txt}%"]) + + results = frappe.get_list( + doctype, + filters=filters, + fields=search_fields, + or_filters=or_filters, + limit_start=0, + limit_page_length=20, + order_by="email_id, full_name, name", + ignore_permissions=False, + as_list=True, + strict=False, + ) + + return results diff --git a/crm/api/dashboard.py b/crm/api/dashboard.py new file mode 100644 index 0000000..4c53aee --- /dev/null +++ b/crm/api/dashboard.py @@ -0,0 +1,1323 @@ +import json + +import frappe +from frappe import _ +from frappe.query_builder import Case, DocType +from frappe.query_builder.functions import Avg, Coalesce, Count, Date, DateFormat, IfNull, Sum +from pypika.functions import Function + +from crm.fcrm.doctype.crm_dashboard.crm_dashboard import create_default_manager_dashboard +from crm.utils import sales_user_only + + +# Custom function for TIMESTAMPDIFF (MySQL/MariaDB) +class TimestampDiff(Function): + def __init__(self, unit, start, end, **kwargs): + super().__init__("TIMESTAMPDIFF", unit, start, end, **kwargs) + + +@frappe.whitelist() +def reset_to_default(): + frappe.only_for("System Manager", True) + create_default_manager_dashboard(force=True) + + +@frappe.whitelist() +@sales_user_only +def get_dashboard(from_date: str | None = None, to_date: str | None = None, user: str | None = None): + """ + Get the dashboard data for the CRM dashboard. + """ + + if not from_date or not to_date: + from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate()) + to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate()) + + roles = frappe.get_roles(frappe.session.user) + is_sales_manager = "Sales Manager" in roles or "System Manager" in roles + is_sales_user = "Sales User" in roles and not is_sales_manager + + if is_sales_user: + user = frappe.session.user + + dashboard = frappe.db.exists("CRM Dashboard", "Manager Dashboard") + + layout = [] + + if not dashboard: + layout = json.loads(create_default_manager_dashboard()) + frappe.db.commit() + else: + layout = json.loads(frappe.db.get_value("CRM Dashboard", "Manager Dashboard", "layout") or "[]") + + for l in layout: + method_name = f"get_{l['name']}" + if hasattr(frappe.get_attr("crm.api.dashboard"), method_name): + method = getattr(frappe.get_attr("crm.api.dashboard"), method_name) + l["data"] = method(from_date, to_date, user) + else: + l["data"] = None + + return layout + + +#: Chart types that accept contributed options via the crm_dashboard_charts +#: hook. Deliberately excludes "chart_types" itself — contributing an +#: entirely new top-level chart type would also need DashboardItem.vue to +#: know how to render it, which this hook does not attempt to solve. +CONTRIBUTABLE_CHART_TYPES = ("number_chart", "axis_chart", "donut_chart") + + +def get_core_chart_options(): + """ + CRM's own built-in chart options, grouped by chart type. Every "value" here + must have a matching get_(from_date, to_date, user) function in this + module. + """ + return { + "chart_types": [ + {"label": _("Spacer"), "value": "spacer"}, + {"label": _("Number Chart"), "value": "number_chart"}, + {"label": _("Axis Chart"), "value": "axis_chart"}, + {"label": _("Donut Chart"), "value": "donut_chart"}, + ], + "number_chart": [ + {"label": _("Total Leads"), "value": "total_leads"}, + {"label": _("Ongoing Deals"), "value": "ongoing_deals"}, + {"label": _("Avg Ongoing Deal Value"), "value": "average_ongoing_deal_value"}, + {"label": _("Won Deals"), "value": "won_deals"}, + {"label": _("Avg Won Deal Value"), "value": "average_won_deal_value"}, + {"label": _("Avg Deal Value"), "value": "average_deal_value"}, + {"label": _("Avg Time to Close a Lead"), "value": "average_time_to_close_a_lead"}, + {"label": _("Avg Time to Close a Deal"), "value": "average_time_to_close_a_deal"}, + ], + "axis_chart": [ + {"label": _("Sales Trend"), "value": "sales_trend"}, + {"label": _("Forecasted Revenue"), "value": "forecasted_revenue"}, + {"label": _("Funnel Conversion"), "value": "funnel_conversion"}, + {"label": _("Deals by Ongoing & Won Stage"), "value": "deals_by_stage_axis"}, + {"label": _("Lost Deal Reasons"), "value": "lost_deal_reasons"}, + {"label": _("Deals by Territory"), "value": "deals_by_territory"}, + {"label": _("Deals by Salesperson"), "value": "deals_by_salesperson"}, + ], + "donut_chart": [ + {"label": _("Deals by Stage"), "value": "deals_by_stage_donut"}, + {"label": _("Leads by Source"), "value": "leads_by_source"}, + {"label": _("Deals by Source"), "value": "deals_by_source"}, + ], + } + + +def get_contributed_charts(): + """ + Charts contributed by other apps via the crm_dashboard_charts hook, grouped + by chart type and validated. Each app's hooks.py declares: + + crm_dashboard_charts = { + "number_chart": [ + {"label": "Total Calls", "value": "total_calls", "resolver": "app.api.dashboard.get_total_calls"}, + ], + } + + Every contributing app's list is merged (not "last one wins" — see + frappe.append_hook), so multiple apps can extend the same chart type + without clobbering each other. resolver is a dotted path to a + get_(from_date, to_date, user) function, and does not need to live + in this module — that's the whole point of the hook. + """ + core_values = { + option["value"] + for chart_type in CONTRIBUTABLE_CHART_TYPES + for option in get_core_chart_options()[chart_type] + } + + contributed = {chart_type: [] for chart_type in CONTRIBUTABLE_CHART_TYPES} + seen_values = set(core_values) + + for chart_type, options in frappe.get_hooks("crm_dashboard_charts", default={}).items(): + if chart_type not in CONTRIBUTABLE_CHART_TYPES: + continue + for option in options: + value = option.get("value") + resolver = option.get("resolver") + label = option.get("label") + if not value or not resolver or not label or value in seen_values: + # Missing fields, or a core/earlier-app chart name — core (and + # whichever app registered first) always wins; never let a + # later app's contribution shadow an existing chart. + continue + seen_values.add(value) + contributed[chart_type].append({"label": label, "value": value, "resolver": resolver}) + + return contributed + + +@frappe.whitelist() +@sales_user_only +def get_chart( + name: str, type: str, from_date: str | None = None, to_date: str | None = None, user: str | None = None +): + """ + Get number chart data for the dashboard. + """ + options = get_chart_options() + valid_chart_names = {option["value"] for values in options.values() for option in values} + if name not in valid_chart_names: + return {"error": _("Invalid chart name")} + + if not from_date or not to_date: + from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate()) + to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate()) + + roles = frappe.get_roles(frappe.session.user) + is_sales_manager = "Sales Manager" in roles or "System Manager" in roles + is_sales_user = "Sales User" in roles and not is_sales_manager + + if is_sales_user: + user = frappe.session.user + + method_name = f"get_{name}" + if hasattr(frappe.get_attr("crm.api.dashboard"), method_name): + method = getattr(frappe.get_attr("crm.api.dashboard"), method_name) + return method(from_date, to_date, user) + + contributed = get_contributed_charts() + for chart_type in CONTRIBUTABLE_CHART_TYPES: + for option in contributed[chart_type]: + if option["value"] == name: + return frappe.get_attr(option["resolver"])(from_date, to_date, user) + + return {"error": _("Invalid chart name")} + + +@frappe.whitelist() +@sales_user_only +def get_chart_options(): + """ + Return the selectable options for the "Add Chart" dialog, grouped by chart + type — CRM's own built-in charts plus anything contributed via the + crm_dashboard_charts hook. + """ + options = get_core_chart_options() + contributed = get_contributed_charts() + for chart_type in CONTRIBUTABLE_CHART_TYPES: + options[chart_type] = options[chart_type] + [ + {"label": option["label"], "value": option["value"]} for option in contributed[chart_type] + ] + return options + + +def get_total_leads(from_date: str | None = None, to_date: str | None = None, user: str | None = None): + """ + Get lead count for the dashboard. + """ + diff = frappe.utils.date_diff(to_date, from_date) + if diff == 0: + diff = 1 + + prev_from_date = frappe.utils.add_days(from_date, -diff) + to_date_plus_one = frappe.utils.add_days(to_date, 1) + + Lead = DocType("CRM Lead") + + # Build conditions for current period + current_cond = (Lead.creation >= from_date) & (Lead.creation < to_date_plus_one) + if user: + current_cond = current_cond & (Lead.lead_owner == user) + + # Build conditions for previous period + prev_cond = (Lead.creation >= prev_from_date) & (Lead.creation < from_date) + if user: + prev_cond = prev_cond & (Lead.lead_owner == user) + + # Build query with CASE expressions + query = frappe.qb.from_(Lead).select( + Count(Case().when(current_cond, Lead.name).else_(None)).as_("current_month_leads"), + Count(Case().when(prev_cond, Lead.name).else_(None)).as_("prev_month_leads"), + ) + + result = query.run(as_dict=True) + + current_month_leads = result[0].current_month_leads or 0 + prev_month_leads = result[0].prev_month_leads or 0 + + delta_in_percentage = ( + (current_month_leads - prev_month_leads) / prev_month_leads * 100 if prev_month_leads else 0 + ) + + return { + "title": _("Total leads"), + "tooltip": _("Total number of leads"), + "value": current_month_leads, + "delta": delta_in_percentage, + "deltaSuffix": "%", + } + + +def get_ongoing_deals(from_date: str | None = None, to_date: str | None = None, user: str | None = None): + """ + Get ongoing deal count for the dashboard, and also calculate average deal value for ongoing deals. + """ + diff = frappe.utils.date_diff(to_date, from_date) + if diff == 0: + diff = 1 + + prev_from_date = frappe.utils.add_days(from_date, -diff) + to_date_plus_one = frappe.utils.add_days(to_date, 1) + + Deal = DocType("CRM Deal") + Status = DocType("CRM Deal Status") + + # Build conditions for current period + current_cond = ( + (Deal.creation >= from_date) + & (Deal.creation < to_date_plus_one) + & (Status.type.notin(["Won", "Lost"])) + ) + if user: + current_cond = current_cond & (Deal.deal_owner == user) + + # Build conditions for previous period + prev_cond = ( + (Deal.creation >= prev_from_date) & (Deal.creation < from_date) & (Status.type.notin(["Won", "Lost"])) + ) + if user: + prev_cond = prev_cond & (Deal.deal_owner == user) + + # Build query with CASE expressions + query = ( + frappe.qb.from_(Deal) + .join(Status) + .on(Deal.status == Status.name) + .select( + Count(Case().when(current_cond, Deal.name).else_(None)).as_("current_month_deals"), + Count(Case().when(prev_cond, Deal.name).else_(None)).as_("prev_month_deals"), + ) + ) + + result = query.run(as_dict=True) + + current_month_deals = result[0].current_month_deals or 0 + prev_month_deals = result[0].prev_month_deals or 0 + + delta_in_percentage = ( + (current_month_deals - prev_month_deals) / prev_month_deals * 100 if prev_month_deals else 0 + ) + + return { + "title": _("Ongoing deals"), + "tooltip": _("Total number of non won/lost deals"), + "value": current_month_deals, + "delta": delta_in_percentage, + "deltaSuffix": "%", + } + + +def get_average_ongoing_deal_value( + from_date: str | None = None, to_date: str | None = None, user: str | None = None +): + """ + Get ongoing deal count for the dashboard, and also calculate average deal value for ongoing deals. + """ + diff = frappe.utils.date_diff(to_date, from_date) + if diff == 0: + diff = 1 + + prev_from_date = frappe.utils.add_days(from_date, -diff) + to_date_plus_one = frappe.utils.add_days(to_date, 1) + + Deal = DocType("CRM Deal") + Status = DocType("CRM Deal Status") + + # Build conditions for current period + current_cond = ( + (Deal.creation >= from_date) + & (Deal.creation < to_date_plus_one) + & (Status.type.notin(["Won", "Lost"])) + ) + if user: + current_cond = current_cond & (Deal.deal_owner == user) + + # Build conditions for previous period + prev_cond = ( + (Deal.creation >= prev_from_date) & (Deal.creation < from_date) & (Status.type.notin(["Won", "Lost"])) + ) + if user: + prev_cond = prev_cond & (Deal.deal_owner == user) + + # Calculate deal value with exchange rate + deal_value_expr = Deal.deal_value * IfNull(Deal.exchange_rate, 1) + + # Build query with CASE expressions + query = ( + frappe.qb.from_(Deal) + .join(Status) + .on(Deal.status == Status.name) + .select( + Avg(Case().when(current_cond, deal_value_expr).else_(None)).as_("current_month_avg_value"), + Avg(Case().when(prev_cond, deal_value_expr).else_(None)).as_("prev_month_avg_value"), + ) + ) + + result = query.run(as_dict=True) + + current_month_avg_value = result[0].current_month_avg_value or 0 + prev_month_avg_value = result[0].prev_month_avg_value or 0 + + avg_value_delta = current_month_avg_value - prev_month_avg_value if prev_month_avg_value else 0 + + return { + "title": _("Avg. ongoing deal value"), + "tooltip": _("Average deal value of non won/lost deals"), + "value": current_month_avg_value, + "delta": avg_value_delta, + "prefix": get_base_currency_symbol(), + } + + +def get_won_deals(from_date: str | None = None, to_date: str | None = None, user: str | None = None): + """ + Get won deal count for the dashboard, and also calculate average deal value for won deals. + """ + diff = frappe.utils.date_diff(to_date, from_date) + if diff == 0: + diff = 1 + + prev_from_date = frappe.utils.add_days(from_date, -diff) + to_date_plus_one = frappe.utils.add_days(to_date, 1) + + Deal = DocType("CRM Deal") + Status = DocType("CRM Deal Status") + + # Build conditions for current period + current_cond = ( + (Deal.closed_date >= from_date) & (Deal.closed_date < to_date_plus_one) & (Status.type == "Won") + ) + if user: + current_cond = current_cond & (Deal.deal_owner == user) + + # Build conditions for previous period + prev_cond = (Deal.closed_date >= prev_from_date) & (Deal.closed_date < from_date) & (Status.type == "Won") + if user: + prev_cond = prev_cond & (Deal.deal_owner == user) + + # Build query with CASE expressions + query = ( + frappe.qb.from_(Deal) + .join(Status) + .on(Deal.status == Status.name) + .select( + Count(Case().when(current_cond, Deal.name).else_(None)).as_("current_month_deals"), + Count(Case().when(prev_cond, Deal.name).else_(None)).as_("prev_month_deals"), + ) + ) + + result = query.run(as_dict=True) + + current_month_deals = result[0].current_month_deals or 0 + prev_month_deals = result[0].prev_month_deals or 0 + + delta_in_percentage = ( + (current_month_deals - prev_month_deals) / prev_month_deals * 100 if prev_month_deals else 0 + ) + + return { + "title": _("Won deals"), + "tooltip": _("Total number of won deals based on its closure date"), + "value": current_month_deals, + "delta": delta_in_percentage, + "deltaSuffix": "%", + } + + +def get_average_won_deal_value( + from_date: str | None = None, to_date: str | None = None, user: str | None = None +): + """ + Get won deal count for the dashboard, and also calculate average deal value for won deals. + """ + diff = frappe.utils.date_diff(to_date, from_date) + if diff == 0: + diff = 1 + + prev_from_date = frappe.utils.add_days(from_date, -diff) + to_date_plus_one = frappe.utils.add_days(to_date, 1) + + Deal = DocType("CRM Deal") + Status = DocType("CRM Deal Status") + + # Build conditions for current period + current_cond = ( + (Deal.closed_date >= from_date) & (Deal.closed_date < to_date_plus_one) & (Status.type == "Won") + ) + if user: + current_cond = current_cond & (Deal.deal_owner == user) + + # Build conditions for previous period + prev_cond = (Deal.closed_date >= prev_from_date) & (Deal.closed_date < from_date) & (Status.type == "Won") + if user: + prev_cond = prev_cond & (Deal.deal_owner == user) + + # Calculate deal value with exchange rate + deal_value_expr = Deal.deal_value * IfNull(Deal.exchange_rate, 1) + + # Build query with CASE expressions + query = ( + frappe.qb.from_(Deal) + .join(Status) + .on(Deal.status == Status.name) + .select( + Avg(Case().when(current_cond, deal_value_expr).else_(None)).as_("current_month_avg_value"), + Avg(Case().when(prev_cond, deal_value_expr).else_(None)).as_("prev_month_avg_value"), + ) + ) + + result = query.run(as_dict=True) + + current_month_avg_value = result[0].current_month_avg_value or 0 + prev_month_avg_value = result[0].prev_month_avg_value or 0 + + avg_value_delta = current_month_avg_value - prev_month_avg_value if prev_month_avg_value else 0 + + return { + "title": _("Avg. won deal value"), + "tooltip": _("Average deal value of won deals"), + "value": current_month_avg_value, + "delta": avg_value_delta, + "prefix": get_base_currency_symbol(), + } + + +def get_average_deal_value(from_date: str | None = None, to_date: str | None = None, user: str | None = None): + """ + Get average deal value for the dashboard. + """ + diff = frappe.utils.date_diff(to_date, from_date) + if diff == 0: + diff = 1 + + prev_from_date = frappe.utils.add_days(from_date, -diff) + to_date_plus_one = frappe.utils.add_days(to_date, 1) + + Deal = DocType("CRM Deal") + Status = DocType("CRM Deal Status") + + # Build conditions for current period + current_cond = (Deal.creation >= from_date) & (Deal.creation < to_date_plus_one) & (Status.type != "Lost") + if user: + current_cond = current_cond & (Deal.deal_owner == user) + + # Build conditions for previous period + prev_cond = (Deal.creation >= prev_from_date) & (Deal.creation < from_date) & (Status.type != "Lost") + if user: + prev_cond = prev_cond & (Deal.deal_owner == user) + + # Calculate deal value with exchange rate + deal_value_expr = Deal.deal_value * IfNull(Deal.exchange_rate, 1) + + # Build query with CASE expressions + query = ( + frappe.qb.from_(Deal) + .join(Status) + .on(Deal.status == Status.name) + .select( + Avg(Case().when(current_cond, deal_value_expr).else_(None)).as_("current_month_avg"), + Avg(Case().when(prev_cond, deal_value_expr).else_(None)).as_("prev_month_avg"), + ) + ) + + result = query.run(as_dict=True) + + current_month_avg = result[0].current_month_avg or 0 + prev_month_avg = result[0].prev_month_avg or 0 + + delta = current_month_avg - prev_month_avg if prev_month_avg else 0 + + return { + "title": _("Avg. deal value"), + "tooltip": _("Average deal value of ongoing & won deals"), + "value": current_month_avg, + "prefix": get_base_currency_symbol(), + "delta": delta, + "deltaSuffix": "%", + } + + +def get_average_time_to_close_a_lead( + from_date: str | None = None, to_date: str | None = None, user: str | None = None +): + """ + Get average time to close deals for the dashboard. + """ + diff = frappe.utils.date_diff(to_date, from_date) + if diff == 0: + diff = 1 + + prev_from_date = frappe.utils.add_days(from_date, -diff) + to_date_plus_one = frappe.utils.add_days(to_date, 1) + prev_to_date = from_date + + Deal = DocType("CRM Deal") + Status = DocType("CRM Deal Status") + Lead = DocType("CRM Lead") + + # Base condition: closed_date is not null and status type is Won + base_cond = (Deal.closed_date.isnotnull()) & (Status.type == "Won") + if user: + base_cond = base_cond & (Deal.deal_owner == user) + + # Current period condition + current_cond = (Deal.closed_date >= from_date) & (Deal.closed_date < to_date_plus_one) + + # Previous period condition + prev_cond = (Deal.closed_date >= prev_from_date) & (Deal.closed_date < prev_to_date) + + # Calculate time difference from lead/deal creation to deal closure + time_diff = TimestampDiff( + frappe.qb.terms.LiteralValue("DAY"), Coalesce(Lead.creation, Deal.creation), Deal.closed_date + ) + + # Build query + query = ( + frappe.qb.from_(Deal) + .join(Status) + .on(Deal.status == Status.name) + .left_join(Lead) + .on(Deal.lead == Lead.name) + .where(base_cond) + .select( + Avg(Case().when(current_cond, time_diff).else_(None)).as_("current_avg_lead"), + Avg(Case().when(prev_cond, time_diff).else_(None)).as_("prev_avg_lead"), + ) + ) + + result = query.run(as_dict=True) + + current_avg_lead = result[0].current_avg_lead or 0 + prev_avg_lead = result[0].prev_avg_lead or 0 + delta_lead = current_avg_lead - prev_avg_lead if prev_avg_lead else 0 + + return { + "title": _("Avg. time to close a lead"), + "tooltip": _("Average time taken from lead creation to deal closure"), + "value": current_avg_lead, + "suffix": " days", + "delta": delta_lead, + "deltaSuffix": " days", + "negativeIsBetter": True, + } + + +def get_average_time_to_close_a_deal( + from_date: str | None = None, to_date: str | None = None, user: str | None = None +): + """ + Get average time to close deals for the dashboard. + """ + diff = frappe.utils.date_diff(to_date, from_date) + if diff == 0: + diff = 1 + + prev_from_date = frappe.utils.add_days(from_date, -diff) + to_date_plus_one = frappe.utils.add_days(to_date, 1) + prev_to_date = from_date + + Deal = DocType("CRM Deal") + Status = DocType("CRM Deal Status") + Lead = DocType("CRM Lead") + + # Base condition: closed_date is not null and status type is Won + base_cond = (Deal.closed_date.isnotnull()) & (Status.type == "Won") + if user: + base_cond = base_cond & (Deal.deal_owner == user) + + # Current period condition + current_cond = (Deal.closed_date >= from_date) & (Deal.closed_date < to_date_plus_one) + + # Previous period condition + prev_cond = (Deal.closed_date >= prev_from_date) & (Deal.closed_date < prev_to_date) + + # Calculate time difference from deal creation to deal closure + time_diff = TimestampDiff(frappe.qb.terms.LiteralValue("DAY"), Deal.creation, Deal.closed_date) + + # Build query + query = ( + frappe.qb.from_(Deal) + .join(Status) + .on(Deal.status == Status.name) + .left_join(Lead) + .on(Deal.lead == Lead.name) + .where(base_cond) + .select( + Avg(Case().when(current_cond, time_diff).else_(None)).as_("current_avg_deal"), + Avg(Case().when(prev_cond, time_diff).else_(None)).as_("prev_avg_deal"), + ) + ) + + result = query.run(as_dict=True) + + current_avg_deal = result[0].current_avg_deal or 0 + prev_avg_deal = result[0].prev_avg_deal or 0 + delta_deal = current_avg_deal - prev_avg_deal if prev_avg_deal else 0 + + return { + "title": _("Avg. time to close a deal"), + "tooltip": _("Average time taken from deal creation to deal closure"), + "value": current_avg_deal, + "suffix": " days", + "delta": delta_deal, + "deltaSuffix": " days", + "negativeIsBetter": True, + } + + +def get_sales_trend(from_date: str | None = None, to_date: str | None = None, user: str | None = None): + """ + Get sales trend data for the dashboard. + [ + { date: new Date('2024-05-01'), leads: 45, deals: 23, won_deals: 12 }, + { date: new Date('2024-05-02'), leads: 50, deals: 30, won_deals: 15 }, + ... + ] + """ + if not from_date or not to_date: + from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate()) + to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate()) + + Lead = DocType("CRM Lead") + Deal = DocType("CRM Deal") + Status = DocType("CRM Deal Status") + + # Build leads query + leads_query = ( + frappe.qb.from_(Lead) + .select( + Date(Lead.creation).as_("date"), + Count("*").as_("leads"), + frappe.qb.terms.ValueWrapper(0).as_("deals"), + frappe.qb.terms.ValueWrapper(0).as_("won_deals"), + ) + .where(Date(Lead.creation).between(from_date, to_date)) + ) + + if user: + leads_query = leads_query.where(Lead.lead_owner == user) + + leads_query = leads_query.groupby(Date(Lead.creation)) + + # Build deals query + deals_query = ( + frappe.qb.from_(Deal) + .join(Status) + .on(Deal.status == Status.name) + .select( + Date(Deal.creation).as_("date"), + frappe.qb.terms.ValueWrapper(0).as_("leads"), + Count("*").as_("deals"), + Sum(Case().when(Status.type == "Won", 1).else_(0)).as_("won_deals"), + ) + .where(Date(Deal.creation).between(from_date, to_date)) + ) + + if user: + deals_query = deals_query.where(Deal.deal_owner == user) + + deals_query = deals_query.groupby(Date(Deal.creation)) + + # Combine with UNION ALL and aggregate by date + union_query = leads_query.union_all(deals_query) + + # Wrap in outer query to aggregate by date + daily = ( + frappe.qb.from_(union_query) + .select( + DateFormat(union_query.date, "%Y-%m-%d").as_("date"), + Sum(union_query.leads).as_("leads"), + Sum(union_query.deals).as_("deals"), + Sum(union_query.won_deals).as_("won_deals"), + ) + .groupby(union_query.date) + .orderby(union_query.date) + ) + + result = daily.run(as_dict=True) + + sales_trend = [ + { + "date": frappe.utils.get_datetime(row.date).strftime("%Y-%m-%d"), + "leads": row.leads or 0, + "deals": row.deals or 0, + "won_deals": row.won_deals or 0, + } + for row in result + ] + + return { + "data": sales_trend, + "title": _("Sales trend"), + "subtitle": _("Daily performance of leads, deals, and wins"), + "xAxis": { + "title": _("Date"), + "key": "date", + "type": "time", + "timeGrain": "day", + }, + "yAxis": { + "title": _("Count"), + }, + "series": [ + {"name": "leads", "type": "line", "showDataPoints": True}, + {"name": "deals", "type": "line", "showDataPoints": True}, + {"name": "won_deals", "type": "line", "showDataPoints": True}, + ], + } + + +def get_forecasted_revenue(from_date: str | None = None, to_date: str | None = None, user: str | None = None): + """ + Get forecasted revenue for the dashboard. + [ + { date: new Date('2024-05-01'), forecasted: 1200000, actual: 980000 }, + { date: new Date('2024-06-01'), forecasted: 1350000, actual: 1120000 }, + { date: new Date('2024-07-01'), forecasted: 1600000, actual: "" }, + { date: new Date('2024-08-01'), forecasted: 1500000, actual: "" }, + ... + ] + """ + # Using Frappe Query Builder with CASE expressions + CRMDeal = DocType("CRM Deal") + CRMDealStatus = DocType("CRM Deal Status") + + # Calculate the date 12 months ago + twelve_months_ago = frappe.utils.add_months(frappe.utils.nowdate(), -12) + + forecasted_value = ( + Case() + .when(CRMDealStatus.type == "Lost", CRMDeal.expected_deal_value * IfNull(CRMDeal.exchange_rate, 1)) + .else_( + CRMDeal.expected_deal_value + * IfNull(CRMDeal.probability, 0) + / 100 + * IfNull(CRMDeal.exchange_rate, 1) + ) + ) + + actual_value = ( + Case() + .when(CRMDealStatus.type == "Won", CRMDeal.deal_value * IfNull(CRMDeal.exchange_rate, 1)) + .else_(0) + ) + + query = ( + frappe.qb.from_(CRMDeal) + .join(CRMDealStatus) + .on(CRMDeal.status == CRMDealStatus.name) + .select( + DateFormat(CRMDeal.expected_closure_date, "%Y-%m").as_("month"), + Sum(forecasted_value).as_("forecasted"), + Sum(actual_value).as_("actual"), + ) + .where(CRMDeal.expected_closure_date >= twelve_months_ago) + .groupby(DateFormat(CRMDeal.expected_closure_date, "%Y-%m")) + .orderby(DateFormat(CRMDeal.expected_closure_date, "%Y-%m")) + ) + + if user: + query = query.where(CRMDeal.deal_owner == user) + + result = query.run(as_dict=True) + + for row in result: + row["month"] = frappe.utils.get_datetime(row["month"]).strftime("%Y-%m-01") + row["forecasted"] = row["forecasted"] or "" + row["actual"] = row["actual"] or "" + + return { + "data": result or [], + "title": _("Forecasted revenue"), + "subtitle": _("Projected vs actual revenue based on deal probability"), + "xAxis": { + "title": _("Month"), + "key": "month", + "type": "time", + "timeGrain": "month", + }, + "yAxis": { + "title": _("Revenue") + f" ({get_base_currency_symbol()})", + }, + "series": [ + {"name": "forecasted", "type": "line", "showDataPoints": True}, + {"name": "actual", "type": "line", "showDataPoints": True}, + ], + } + + +def get_funnel_conversion(from_date: str | None = None, to_date: str | None = None, user: str | None = None): + """ + Get funnel conversion data for the dashboard. + [ + { stage: 'Leads', count: 120 }, + { stage: 'Qualification', count: 100 }, + { stage: 'Negotiation', count: 80 }, + { stage: 'Ready to Close', count: 60 }, + { stage: 'Won', count: 30 }, + ... + ] + """ + lead_conds = "" + deal_conds = "" + + if not from_date or not to_date: + from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate()) + to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate()) + + lead_filters = {"from": from_date, "to": to_date} + deal_filters = {"from": from_date, "to": to_date} + + if user: + lead_conds += " AND lead_owner = %(user)s" + deal_conds += " AND deal_owner = %(user)s" + lead_filters["user"] = user + deal_filters["user"] = user + + result = [] + + # Get total leads using Query Builder + CRMLead = DocType("CRM Lead") + + query = ( + frappe.qb.from_(CRMLead) + .select(Count("*").as_("count")) + .where(Date(CRMLead.creation).between(from_date, to_date)) + ) + + if user: + query = query.where(CRMLead.lead_owner == user) + + total_leads = query.run(as_dict=True) + total_leads_count = total_leads[0].count if total_leads else 0 + + result.append({"stage": "Leads", "count": total_leads_count}) + + result += get_deal_status_change_counts(from_date, to_date, deal_conds, deal_filters) + + return { + "data": result or [], + "title": _("Funnel conversion"), + "subtitle": _("Lead to deal conversion pipeline"), + "xAxis": { + "title": _("Stage"), + "key": "stage", + "type": "category", + }, + "yAxis": { + "title": _("Count"), + }, + "swapXY": True, + "series": [ + { + "name": "count", + "type": "bar", + "echartOptions": { + "colorBy": "data", + }, + }, + ], + } + + +def get_deals_by_stage_axis( + from_date: str | None = None, to_date: str | None = None, user: str | None = None +): + """ + Get deal data by stage for the dashboard. + [ + { stage: 'Prospecting', count: 120 }, + { stage: 'Negotiation', count: 45 }, + ... + ] + """ + if not from_date or not to_date: + from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate()) + to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate()) + + # Using Frappe Query Builder with NOT IN clause + CRMDeal = DocType("CRM Deal") + CRMDealStatus = DocType("CRM Deal Status") + + query = ( + frappe.qb.from_(CRMDeal) + .join(CRMDealStatus) + .on(CRMDeal.status == CRMDealStatus.name) + .select(CRMDeal.status.as_("stage"), Count("*").as_("count"), CRMDealStatus.type.as_("status_type")) + .where((Date(CRMDeal.creation).between(from_date, to_date)) & (CRMDealStatus.type.notin(["Lost"]))) + .groupby(CRMDeal.status) + .orderby(Count("*"), order=frappe.qb.desc) + ) + + if user: + query = query.where(CRMDeal.deal_owner == user) + + result = query.run(as_dict=True) + + return { + "data": result or [], + "title": _("Deals by ongoing & won stage"), + "xAxis": { + "title": _("Stage"), + "key": "stage", + "type": "category", + }, + "yAxis": {"title": _("Count")}, + "series": [ + {"name": "count", "type": "bar"}, + ], + } + + +def get_deals_by_stage_donut( + from_date: str | None = None, to_date: str | None = None, user: str | None = None +): + """ + Get deal data by stage for the dashboard. + [ + { stage: 'Prospecting', count: 120 }, + { stage: 'Negotiation', count: 45 }, + ... + ] + """ + if not from_date or not to_date: + from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate()) + to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate()) + + # Using Frappe Query Builder with JOIN + CRMDeal = DocType("CRM Deal") + CRMDealStatus = DocType("CRM Deal Status") + + query = ( + frappe.qb.from_(CRMDeal) + .join(CRMDealStatus) + .on(CRMDeal.status == CRMDealStatus.name) + .select(CRMDeal.status.as_("stage"), Count("*").as_("count"), CRMDealStatus.type.as_("status_type")) + .where(Date(CRMDeal.creation).between(from_date, to_date)) + .groupby(CRMDeal.status) + .orderby(Count("*"), order=frappe.qb.desc) + ) + + if user: + query = query.where(CRMDeal.deal_owner == user) + + result = query.run(as_dict=True) + + return { + "data": result or [], + "title": _("Deals by stage"), + "subtitle": _("Current pipeline distribution"), + "categoryColumn": "stage", + "valueColumn": "count", + } + + +def get_lost_deal_reasons(from_date: str | None = None, to_date: str | None = None, user: str | None = None): + """ + Get lost deal reasons for the dashboard. + [ + { reason: 'Price too high', count: 20 }, + { reason: 'Competitor won', count: 15 }, + ... + ] + """ + if not from_date or not to_date: + from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate()) + to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate()) + + # Using Frappe Query Builder with JOIN + CRMDeal = DocType("CRM Deal") + CRMDealStatus = DocType("CRM Deal Status") + + query = ( + frappe.qb.from_(CRMDeal) + .join(CRMDealStatus) + .on(CRMDeal.status == CRMDealStatus.name) + .select(CRMDeal.lost_reason.as_("reason"), Count("*").as_("count")) + .where((Date(CRMDeal.creation).between(from_date, to_date)) & (CRMDealStatus.type == "Lost")) + .groupby(CRMDeal.lost_reason) + .having((CRMDeal.lost_reason.isnotnull()) & (CRMDeal.lost_reason != "")) + .orderby(Count("*"), order=frappe.qb.desc) + ) + + if user: + query = query.where(CRMDeal.deal_owner == user) + + result = query.run(as_dict=True) + + return { + "data": result or [], + "title": _("Lost deal reasons"), + "subtitle": _("Common reasons for losing deals"), + "xAxis": { + "title": _("Reason"), + "key": "reason", + "type": "category", + }, + "yAxis": { + "title": _("Count"), + }, + "series": [ + {"name": "count", "type": "bar"}, + ], + } + + +def get_leads_by_source(from_date: str | None = None, to_date: str | None = None, user: str | None = None): + """ + Get lead data by source for the dashboard. + [ + { source: 'Website', count: 120 }, + { source: 'Referral', count: 45 }, + ... + ] + """ + if not from_date or not to_date: + from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate()) + to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate()) + + # Using Frappe Query Builder (safer, more maintainable) + CRMLead = DocType("CRM Lead") + + query = ( + frappe.qb.from_(CRMLead) + .select(IfNull(CRMLead.source, "Empty").as_("source"), Count("*").as_("count")) + .where(Date(CRMLead.creation).between(from_date, to_date)) + .groupby(CRMLead.source) + .orderby(Count("*"), order=frappe.qb.desc) + ) + + if user: + query = query.where(CRMLead.lead_owner == user) + + result = query.run(as_dict=True) + + return { + "data": result or [], + "title": _("Leads by source"), + "subtitle": _("Lead generation channel analysis"), + "categoryColumn": "source", + "valueColumn": "count", + } + + +def get_deals_by_source(from_date: str | None = None, to_date: str | None = None, user: str | None = None): + """ + Get deal data by source for the dashboard. + [ + { source: 'Website', count: 120 }, + { source: 'Referral', count: 45 }, + ... + ] + """ + if not from_date or not to_date: + from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate()) + to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate()) + + # Using Frappe Query Builder + CRMDeal = DocType("CRM Deal") + + query = ( + frappe.qb.from_(CRMDeal) + .select(IfNull(CRMDeal.source, "Empty").as_("source"), Count("*").as_("count")) + .where(Date(CRMDeal.creation).between(from_date, to_date)) + .groupby(CRMDeal.source) + .orderby(Count("*"), order=frappe.qb.desc) + ) + + if user: + query = query.where(CRMDeal.deal_owner == user) + + result = query.run(as_dict=True) + + return { + "data": result or [], + "title": _("Deals by source"), + "subtitle": _("Deal generation channel analysis"), + "categoryColumn": "source", + "valueColumn": "count", + } + + +def get_deals_by_territory(from_date: str | None = None, to_date: str | None = None, user: str | None = None): + """ + Get deal data by territory for the dashboard. + [ + { territory: 'North America', deals: 45, value: 2300000 }, + { territory: 'Europe', deals: 30, value: 1500000 }, + ... + ] + """ + if not from_date or not to_date: + from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate()) + to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate()) + + # Using Frappe Query Builder with complex aggregations + CRMDeal = DocType("CRM Deal") + + query = ( + frappe.qb.from_(CRMDeal) + .select( + IfNull(CRMDeal.territory, "Empty").as_("territory"), + Count("*").as_("deals"), + Sum(Coalesce(CRMDeal.deal_value, 0) * IfNull(CRMDeal.exchange_rate, 1)).as_("value"), + ) + .where(Date(CRMDeal.creation).between(from_date, to_date)) + .groupby(CRMDeal.territory) + .orderby(Count("*"), order=frappe.qb.desc) + .orderby( + Sum(Coalesce(CRMDeal.deal_value, 0) * IfNull(CRMDeal.exchange_rate, 1)), order=frappe.qb.desc + ) + ) + + if user: + query = query.where(CRMDeal.deal_owner == user) + + result = query.run(as_dict=True) + + return { + "data": result or [], + "title": _("Deals by territory"), + "subtitle": _("Geographic distribution of deals and revenue"), + "xAxis": { + "title": _("Territory"), + "key": "territory", + "type": "category", + }, + "yAxis": { + "title": _("Number of deals"), + }, + "y2Axis": { + "title": _("Deal value") + f" ({get_base_currency_symbol()})", + }, + "series": [ + {"name": "deals", "type": "bar"}, + {"name": "value", "type": "line", "showDataPoints": True, "axis": "y2"}, + ], + } + + +def get_deals_by_salesperson( + from_date: str | None = None, to_date: str | None = None, user: str | None = None +): + """ + Get deal data by salesperson for the dashboard. + [ + { salesperson: 'John Smith', deals: 45, value: 2300000 }, + { salesperson: 'Jane Doe', deals: 30, value: 1500000 }, + ... + ] + """ + if not from_date or not to_date: + from_date = frappe.utils.get_first_day(from_date or frappe.utils.nowdate()) + to_date = frappe.utils.get_last_day(to_date or frappe.utils.nowdate()) + + # Using Frappe Query Builder with LEFT JOIN + CRMDeal = DocType("CRM Deal") + User = DocType("User") + + query = ( + frappe.qb.from_(CRMDeal) + .left_join(User) + .on(User.name == CRMDeal.deal_owner) + .select( + IfNull(User.full_name, CRMDeal.deal_owner).as_("salesperson"), + Count("*").as_("deals"), + Sum(Coalesce(CRMDeal.deal_value, 0) * IfNull(CRMDeal.exchange_rate, 1)).as_("value"), + ) + .where(Date(CRMDeal.creation).between(from_date, to_date)) + .groupby(CRMDeal.deal_owner) + .orderby(Count("*"), order=frappe.qb.desc) + .orderby( + Sum(Coalesce(CRMDeal.deal_value, 0) * IfNull(CRMDeal.exchange_rate, 1)), order=frappe.qb.desc + ) + ) + + if user: + query = query.where(CRMDeal.deal_owner == user) + + result = query.run(as_dict=True) + + return { + "data": result or [], + "title": _("Deals by salesperson"), + "subtitle": _("Number of deals and total value per salesperson"), + "xAxis": { + "title": _("Salesperson"), + "key": "salesperson", + "type": "category", + }, + "yAxis": { + "title": _("Number of deals"), + }, + "y2Axis": { + "title": _("Deal value") + f" ({get_base_currency_symbol()})", + }, + "series": [ + {"name": "deals", "type": "bar"}, + {"name": "value", "type": "line", "showDataPoints": True, "axis": "y2"}, + ], + } + + +def get_base_currency_symbol(): + """ + Get the base currency symbol from the system settings. + """ + base_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD" + return frappe.db.get_value("Currency", base_currency, "symbol") or "" + + +def get_deal_status_change_counts( + from_date: str | None = None, + to_date: str | None = None, + deal_conds: str = "", + filters: dict | None = None, +): + """ + Get count of each status change (to) for each deal, excluding deals with current status type 'Lost'. + Order results by status position. + Returns: + [ + {"status": "Qualification", "count": 120}, + {"status": "Negotiation", "count": 85}, + ... + ] + """ + # Using Frappe Query Builder with multiple JOINs and table aliases + CRMStatusChangeLog = DocType("CRM Status Change Log") + CRMDeal = DocType("CRM Deal") + CurrentStatus = DocType("CRM Deal Status").as_("s") + TargetStatus = DocType("CRM Deal Status").as_("st") + + query = ( + frappe.qb.from_(CRMStatusChangeLog) + .join(CRMDeal) + .on(CRMStatusChangeLog.parent == CRMDeal.name) + .join(CurrentStatus) + .on(CRMDeal.status == CurrentStatus.name) + .join(TargetStatus) + .on(CRMStatusChangeLog.to == TargetStatus.name) + .select(CRMStatusChangeLog.to.as_("stage"), Count("*").as_("count")) + .where( + (CRMStatusChangeLog.to.isnotnull()) + & (CRMStatusChangeLog.to != "") + & (CurrentStatus.type != "Lost") + & (Date(CRMDeal.creation).between(from_date, to_date)) + ) + .groupby(CRMStatusChangeLog.to, TargetStatus.position) + .orderby(TargetStatus.position) + ) + + # Handle optional user filter if deal_conds contains user condition + if filters and filters.get("user"): + query = query.where(CRMDeal.deal_owner == filters["user"]) + + result = query.run(as_dict=True) + return result or [] diff --git a/crm/api/doc.py b/crm/api/doc.py new file mode 100644 index 0000000..8806d90 --- /dev/null +++ b/crm/api/doc.py @@ -0,0 +1,857 @@ +import json + +import frappe +from frappe import _ +from frappe.custom.doctype.property_setter.property_setter import make_property_setter +from frappe.desk.form.assign_to import set_status +from frappe.model import no_value_fields +from frappe.model.delete_doc import get_dynamic_linked_docs, get_linked_docs +from frappe.model.document import get_controller +from frappe.utils import make_filter_tuple +from pypika import Criterion + +from crm.api.views import get_views +from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script +from crm.utils import is_frappe_version + +COUNT_NAME = ( + {"COUNT": "name", "as": "total_count"} + if is_frappe_version("16", above=True) + else "count(name) as total_count" +) + + +@frappe.whitelist() +def sort_options(doctype: str): + fields = frappe.get_meta(doctype).fields + fields = [field for field in fields if field.fieldtype not in no_value_fields] + fields = [ + { + "label": _(field.label), + "value": field.fieldname, + "fieldname": field.fieldname, + } + for field in fields + if field.label and field.fieldname + ] + + standard_fields = [ + {"label": "Name", "fieldname": "name"}, + {"label": "Created On", "fieldname": "creation"}, + {"label": "Last Modified", "fieldname": "modified"}, + {"label": "Modified By", "fieldname": "modified_by"}, + {"label": "Owner", "fieldname": "owner"}, + ] + + for field in standard_fields: + field["label"] = _(field["label"]) + field["value"] = field["fieldname"] + fields.append(field) + + return fields + + +@frappe.whitelist() +def get_filterable_fields(doctype: str): + allowed_fieldtypes = [ + "Check", + "Data", + "Float", + "Int", + "Currency", + "Dynamic Link", + "Link", + "Long Text", + "Select", + "Small Text", + "Text Editor", + "Text", + "Duration", + "Rating", + "Date", + "Datetime", + ] + + c = get_controller(doctype) + restricted_fields = [] + if hasattr(c, "get_non_filterable_fields"): + restricted_fields = c.get_non_filterable_fields() + + fields = [] + + meta = frappe.get_meta(doctype).as_dict() + + # append standard fields (getting error when using frappe.model.std_fields) + standard_fields = [ + {"fieldname": "name", "fieldtype": "Link", "label": "Name", "options": doctype}, + {"fieldname": "owner", "fieldtype": "Link", "label": "Created By", "options": "User"}, + { + "fieldname": "modified_by", + "fieldtype": "Link", + "label": "Last Updated By", + "options": "User", + }, + {"fieldname": "_user_tags", "fieldtype": "Data", "label": "Tags"}, + {"fieldname": "_liked_by", "fieldtype": "Data", "label": "Like"}, + {"fieldname": "_comments", "fieldtype": "Text", "label": "Comments"}, + {"fieldname": "_assign", "fieldtype": "Text", "label": "Assigned To"}, + {"fieldname": "creation", "fieldtype": "Datetime", "label": "Created On"}, + {"fieldname": "modified", "fieldtype": "Datetime", "label": "Last Updated On"}, + ] + + for field in standard_fields + meta.get("fields", []): + if field.get("fieldname") not in restricted_fields and field.get("fieldtype") in allowed_fieldtypes: + field["name"] = field.get("fieldname") + field["label"] = _(field.get("label")) + field["value"] = field.get("fieldname") + fields.append(field) + + return fields + + +@frappe.whitelist() +def get_group_by_fields(doctype: str): + allowed_fieldtypes = [ + "Check", + "Data", + "Float", + "Int", + "Currency", + "Dynamic Link", + "Link", + "Select", + "Duration", + "Date", + "Datetime", + ] + + fields = frappe.get_meta(doctype).fields + fields = [ + field + for field in fields + if field.fieldtype not in no_value_fields and field.fieldtype in allowed_fieldtypes + ] + fields = [ + { + "label": _(field.label), + "fieldname": field.fieldname, + } + for field in fields + if field.label and field.fieldname + ] + + standard_fields = [ + {"label": "Name", "fieldname": "name"}, + {"label": "Created On", "fieldname": "creation"}, + {"label": "Last Modified", "fieldname": "modified"}, + {"label": "Modified By", "fieldname": "modified_by"}, + {"label": "Owner", "fieldname": "owner"}, + {"label": "Like", "fieldname": "_liked_by"}, + {"label": "Assigned To", "fieldname": "_assign"}, + {"label": "Comments", "fieldname": "_comments"}, + {"label": "Created On", "fieldname": "creation"}, + {"label": "Modified On", "fieldname": "modified"}, + ] + + for field in standard_fields: + field["label"] = _(field["label"]) + fields.append(field) + + return fields + + +@frappe.whitelist() +def get_quick_filters(doctype: str, cached: bool = True): + meta = frappe.get_meta(doctype, cached) + quick_filters = [] + + if global_settings := frappe.db.exists("CRM Global Settings", {"dt": doctype, "type": "Quick Filters"}): + _quick_filters = frappe.db.get_value("CRM Global Settings", global_settings, "json") + _quick_filters = json.loads(_quick_filters) or [] + + fields = [] + + for filter in _quick_filters: + if filter == "name": + fields.append({"label": "Name", "fieldname": "name", "fieldtype": "Data"}) + else: + field = next((f for f in meta.fields if f.fieldname == filter), None) + if field: + fields.append(field) + + else: + fields = [field for field in meta.fields if field.in_standard_filter] + + for field in fields: + options = field.get("options") + if field.get("fieldtype") == "Select" and options and isinstance(options, str): + options = options.split("\n") + options = [{"label": option, "value": option} for option in options] + if not any([not option.get("value") for option in options]): + options.insert(0, {"label": "", "value": ""}) + quick_filters.append( + { + "label": _(field.get("label")), + "fieldname": field.get("fieldname"), + "fieldtype": field.get("fieldtype"), + "options": options, + } + ) + + if doctype == "CRM Lead": + quick_filters = [filter for filter in quick_filters if filter.get("fieldname") != "converted"] + + return quick_filters + + +@frappe.whitelist() +def update_quick_filters(quick_filters: str, old_filters: str, doctype: str): + quick_filters = json.loads(quick_filters) + old_filters = json.loads(old_filters) + + new_filters = [filter for filter in quick_filters if filter not in old_filters] + removed_filters = [filter for filter in old_filters if filter not in quick_filters] + + # update or create global quick filter settings + create_update_global_settings(doctype, quick_filters) + + # remove old filters + for filter in removed_filters: + update_in_standard_filter(filter, doctype, 0) + + # add new filters + for filter in new_filters: + update_in_standard_filter(filter, doctype, 1) + + +def create_update_global_settings(doctype, quick_filters): + if global_settings := frappe.db.exists("CRM Global Settings", {"dt": doctype, "type": "Quick Filters"}): + frappe.db.set_value("CRM Global Settings", global_settings, "json", json.dumps(quick_filters)) + else: + # create CRM Global Settings doc + doc = frappe.new_doc("CRM Global Settings") + doc.dt = doctype + doc.type = "Quick Filters" + doc.json = json.dumps(quick_filters) + doc.insert() + + +def update_in_standard_filter(fieldname, doctype, value): + if property_name := frappe.db.exists( + "Property Setter", + {"doc_type": doctype, "field_name": fieldname, "property": "in_standard_filter"}, + ): + frappe.db.set_value("Property Setter", property_name, "value", value) + else: + make_property_setter( + doctype, + fieldname, + "in_standard_filter", + value, + "Check", + validate_fields_for_doctype=False, + ) + + +@frappe.whitelist() +def get_data( + doctype: str, + filters: dict, + order_by: str, + page_length: int = 20, + page_length_count: int = 20, + column_field: str | None = None, + title_field: str | None = None, + columns: str | list | None = None, + rows: str | list | None = None, + kanban_columns: str | list | None = None, + kanban_fields: str | list | None = None, + view: str | dict | None = None, + default_filters: dict | None = None, +): + custom_view = False + filters = frappe._dict(filters) + rows = frappe.parse_json(rows or "[]") + columns = frappe.parse_json(columns or "[]") + kanban_fields = frappe.parse_json(kanban_fields or "[]") + kanban_columns = frappe.parse_json(kanban_columns or "[]") + + custom_view_name = view.get("custom_view_name") if view else None + view_type = view.get("view_type") if view else None + group_by_field = view.get("group_by_field") if view else None + + for key in filters: + value = filters[key] + if isinstance(value, list): + if "@me" in value: + value[value.index("@me")] = frappe.session.user + elif "%@me%" in value: + index = [i for i, v in enumerate(value) if v == "%@me%"] + for i in index: + value[i] = "%" + frappe.session.user + "%" + elif value == "@me": + filters[key] = frappe.session.user + + if default_filters: + default_filters = frappe.parse_json(default_filters) + filters.update(default_filters) + + is_default = True + data = [] + _list = get_controller(doctype) + default_rows = [] + default_column_keys = [] + if hasattr(_list, "default_list_data"): + default_list_data = _list.default_list_data() + default_rows = default_list_data.get("rows") + default_column_keys = [column.get("key") for column in default_list_data.get("columns", [])] + + meta = frappe.get_meta(doctype) + + if view_type != "kanban": + if columns or rows: + custom_view = True + is_default = False + columns = frappe.parse_json(columns) + rows = frappe.parse_json(rows) + + if not columns: + columns = [ + {"label": "Name", "type": "Data", "key": "name", "width": "16rem"}, + {"label": "Last Modified", "type": "Datetime", "key": "modified", "width": "8rem"}, + ] + + if not rows: + rows = ["name"] + + default_view_filters = { + "dt": doctype, + "type": view_type or "list", + "is_standard": 1, + "user": frappe.session.user, + } + + if not custom_view and frappe.db.exists("CRM View Settings", default_view_filters): + list_view_settings = frappe.get_doc("CRM View Settings", default_view_filters) + columns = frappe.parse_json(list_view_settings.columns) + rows = frappe.parse_json(list_view_settings.rows) + is_default = False + elif not custom_view or (is_default and hasattr(_list, "default_list_data")): + rows = default_rows + columns = _list.default_list_data().get("columns") + + visible_columns = [] + for column in columns: + key = column.get("key") + # hidden fields are dropped unless the doctype lists them in its own defaults + column_meta = meta.get_field(key) + if column_meta and column_meta.get("hidden") and key not in default_column_keys: + continue + + if key not in rows: + rows.append(key) + column["label"] = _(column.get("label")) + + if key == "_liked_by" and column.get("width") == "10rem": + column["width"] = "50px" + + visible_columns.append(column) + columns = visible_columns + + # check if rows has group_by_field if not add it + if group_by_field and group_by_field not in rows: + rows.append(group_by_field) + + if meta.track_seen and "_seen" not in rows: + rows.append("_seen") + + data = ( + frappe.get_list( + doctype, + fields=rows, + filters=filters, + order_by=order_by, + page_length=page_length, + ) + or [] + ) + data = parse_list_data(data, doctype) + + if view_type == "kanban": + if not rows: + rows = default_rows + + if not kanban_columns and column_field: + field_meta = frappe.get_meta(doctype).get_field(column_field) + if field_meta.fieldtype == "Link": + kanban_columns = frappe.get_all( + field_meta.options, + fields=["name"], + order_by="modified asc", + ) + elif field_meta.fieldtype == "Select": + kanban_columns = [{"name": option} for option in field_meta.options.split("\n")] + + if not title_field: + title_field = "name" + if hasattr(_list, "default_kanban_settings"): + title_field = _list.default_kanban_settings().get("title_field") + + if title_field not in rows: + rows.append(title_field) + + if not kanban_fields: + kanban_fields = ["name"] + if hasattr(_list, "default_kanban_settings"): + kanban_fields = json.loads(_list.default_kanban_settings().get("kanban_fields")) + + for field in kanban_fields: + if field not in rows: + rows.append(field) + + for kc in kanban_columns: + # Start with base filters + column_filters = [] + + # Convert and add the main filters first + if filters: + base_filters = convert_filter_to_tuple(doctype, filters) + column_filters.extend(base_filters) + + # Add the column-specific filter + if column_field and kc.get("name"): + column_filters.append([doctype, column_field, "=", kc.get("name")]) + + order = kc.get("order") + if kc.get("delete"): + column_data = [] + else: + page_length = kc.get("page_length", 20) + + if order: + column_data = get_records_based_on_order( + doctype, rows, column_filters, page_length, order + ) + else: + column_data = frappe.get_list( + doctype, + fields=rows, + filters=column_filters, + order_by=order_by, + page_length=page_length, + ) + + all_count = frappe.get_list( + doctype, + filters=column_filters, + fields=[COUNT_NAME], + )[0].total_count + + kc["all_count"] = all_count + kc["count"] = len(column_data) + + if order: + column_data = sorted( + column_data, + key=lambda x: order.index(x.get("name")) if x.get("name") in order else len(order), + ) + + data.append({"column": kc, "fields": kanban_fields, "data": column_data}) + + fields = frappe.get_meta(doctype).fields + fields = [field for field in fields if field.fieldtype not in no_value_fields] + fields = [ + { + "label": _(field.label), + "fieldtype": field.fieldtype, + "fieldname": field.fieldname, + "options": field.options, + } + for field in fields + if field.label and field.fieldname + ] + + std_fields = [ + {"label": "Name", "fieldtype": "Data", "fieldname": "name"}, + {"label": "Created On", "fieldtype": "Datetime", "fieldname": "creation"}, + {"label": "Last Modified", "fieldtype": "Datetime", "fieldname": "modified"}, + { + "label": "Modified By", + "fieldtype": "Link", + "fieldname": "modified_by", + "options": "User", + }, + {"label": "Assigned To", "fieldtype": "Text", "fieldname": "_assign"}, + {"label": "Owner", "fieldtype": "Link", "fieldname": "owner", "options": "User"}, + {"label": "Like", "fieldtype": "Data", "fieldname": "_liked_by"}, + ] + + for field in std_fields: + if field.get("fieldname") not in rows: + rows.append(field.get("fieldname")) + if field not in fields: + field["label"] = _(field["label"]) + fields.append(field) + + if not is_default and custom_view_name: + is_default = frappe.db.get_value("CRM View Settings", custom_view_name, "load_default_columns") + + if group_by_field and view_type == "group_by": + + def get_options(type, options): + if type == "Select": + return [option for option in options.split("\n")] + else: + has_empty_values = any([not d.get(group_by_field) for d in data]) + options = list(set([d.get(group_by_field) for d in data])) + options = [u for u in options if u] + if has_empty_values: + options.append("") + + if order_by and group_by_field in order_by: + order_by_fields = order_by.split(",") + order_by_fields = [ + (field.split(" ")[0], field.split(" ")[1]) for field in order_by_fields + ] + if (group_by_field, "asc") in order_by_fields: + options.sort() + elif (group_by_field, "desc") in order_by_fields: + options.sort(reverse=True) + else: + options.sort() + return options + + for field in fields: + if field.get("fieldname") == group_by_field: + group_by_field = { + "label": field.get("label"), + "fieldname": field.get("fieldname"), + "fieldtype": field.get("fieldtype"), + "options": get_options(field.get("fieldtype"), field.get("options")), + } + + return { + "data": data, + "columns": columns, + "rows": rows, + "fields": fields, + "column_field": column_field, + "title_field": title_field, + "kanban_columns": kanban_columns, + "kanban_fields": kanban_fields, + "group_by_field": group_by_field, + "page_length": page_length, + "page_length_count": page_length_count, + "is_default": is_default, + "views": get_views(doctype), + "total_count": frappe.get_list(doctype, filters=filters, fields=[COUNT_NAME])[0].total_count, + "row_count": len(data), + "form_script": get_form_script(doctype), + "list_script": get_form_script(doctype, "List"), + "view_type": view_type, + } + + +def parse_list_data(data, doctype): + _list = get_controller(doctype) + if hasattr(_list, "parse_list_data"): + data = _list.parse_list_data(data) + return data + + +def convert_filter_to_tuple(doctype, filters): + if isinstance(filters, dict): + filters_items = filters.items() + filters = [] + for key, value in filters_items: + filters.append(make_filter_tuple(doctype, key, value)) + return filters + + +def get_records_based_on_order(doctype, rows, filters, page_length, order): + records = [] + filters = convert_filter_to_tuple(doctype, filters) + in_filters = filters.copy() + in_filters.append([doctype, "name", "in", order[:page_length]]) + records = frappe.get_list( + doctype, + fields=rows, + filters=in_filters, + order_by="creation desc", + page_length=page_length, + ) + + if len(records) < page_length: + not_in_filters = filters.copy() + not_in_filters.append([doctype, "name", "not in", order]) + remaining_records = frappe.get_list( + doctype, + fields=rows, + filters=not_in_filters, + order_by="creation desc", + page_length=page_length - len(records), + ) + for record in remaining_records: + records.append(record) + + return records + + +@frappe.whitelist() +def remove_assignments(doctype: str, name: str, assignees: str | list, ignore_permissions: bool = False): + assignees = frappe.parse_json(assignees) + + if not assignees: + return + + for assign_to in assignees: + set_status( + doctype, + name, + todo=None, + assign_to=assign_to, + status="Cancelled", + ignore_permissions=ignore_permissions, + ) + + +@frappe.whitelist() +def add_seen(doctype: str, name: str): + doc = frappe.get_doc(doctype, name) + doc.check_permission("read") + doc.add_seen() + + +@frappe.whitelist() +def get_assigned_users(doctype: str, name: str | int, default_assigned_to: str | None = None): + assigned_users = frappe.get_all( + "ToDo", + fields=["allocated_to"], + filters={ + "reference_type": doctype, + "reference_name": name, + "status": ("not in", ("Closed", "Cancelled")), + }, + pluck="allocated_to", + ) + + users = list(set(assigned_users)) + + # if users is empty, add default_assigned_to + if not users and default_assigned_to: + users = [default_assigned_to] + return users + + +@frappe.whitelist() +def get_fields(doctype: str, allow_all_fieldtypes: bool = False): + not_allowed_fieldtypes = [*list(frappe.model.no_value_fields), "Read Only"] + if allow_all_fieldtypes: + not_allowed_fieldtypes = [] + fields = frappe.get_meta(doctype).fields + + _fields = [] + + for field in fields: + if field.fieldtype not in not_allowed_fieldtypes and field.fieldname: + _fields.append(field) + + return _fields + + +def getCounts(d, doctype): + d["_email_count"] = ( + frappe.db.count( + "Communication", + filters={ + "reference_doctype": doctype, + "reference_name": d.get("name"), + "communication_type": "Communication", + }, + ) + or 0 + ) + d["_email_count"] = d["_email_count"] + frappe.db.count( + "Communication", + filters={ + "reference_doctype": doctype, + "reference_name": d.get("name"), + "communication_type": "Automated Message", + }, + ) + d["_comment_count"] = frappe.db.count( + "Comment", + filters={"reference_doctype": doctype, "reference_name": d.get("name"), "comment_type": "Comment"}, + ) + d["_task_count"] = frappe.db.count( + "CRM Task", filters={"reference_doctype": doctype, "reference_docname": d.get("name")} + ) + d["_note_count"] = frappe.db.count( + "FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")} + ) + return d + + +@frappe.whitelist() +def get_linked_docs_of_document(doctype: str, docname: str): + try: + doc = frappe.get_doc(doctype, docname) + except frappe.DoesNotExistError: + return [] + + linked_docs = get_linked_docs(doc) + dynamic_linked_docs = get_dynamic_linked_docs(doc) + + linked_docs.extend(dynamic_linked_docs) + linked_docs = list({doc["reference_docname"]: doc for doc in linked_docs}.values()) + + docs_data = [] + for doc in linked_docs: + if not doc.get("reference_doctype") or not doc.get("reference_docname"): + continue + + try: + data = frappe.get_doc(doc["reference_doctype"], doc["reference_docname"]) + except (frappe.DoesNotExistError, frappe.ValidationError): + continue + + title = data.get("title") + if data.doctype == "CRM Call Log": + title = f"Call from {data.get('from')} to {data.get('to')}" + + if data.doctype == "CRM Deal": + title = data.get("organization") + + if data.doctype == "CRM Notification": + title = data.get("message") + + docs_data.append( + { + "doc": data.doctype, + "title": title or data.get("name"), + "reference_docname": doc["reference_docname"], + "reference_doctype": doc["reference_doctype"], + } + ) + return docs_data + + +def remove_doc_link(doctype, docname): + if not doctype or not docname: + return + + try: + linked_doc_data = frappe.get_doc(doctype, docname) + if doctype == "CRM Notification": + delete_notification_type = { + "notification_type_doctype": "", + "notification_type_doc": "", + } + delete_references = { + "reference_doctype": "", + "reference_name": "", + } + + if linked_doc_data.get("notification_type_doctype") == linked_doc_data.get("reference_doctype"): + delete_references.update(delete_notification_type) + + linked_doc_data.update(delete_references) + else: + linked_doc_data.update( + { + "reference_doctype": "", + "reference_docname": "", + } + ) + linked_doc_data.save(ignore_permissions=True) + except (frappe.DoesNotExistError, frappe.ValidationError): + pass + + +def remove_contact_link(doctype, docname): + if not doctype or not docname: + return + + try: + linked_doc_data = frappe.get_doc(doctype, docname) + linked_doc_data.update( + { + "contact": None, + "contacts": [], + } + ) + linked_doc_data.save(ignore_permissions=True) + except (frappe.DoesNotExistError, frappe.ValidationError): + pass + + +@frappe.whitelist() +def remove_linked_doc_reference(items: str | list, remove_contact: bool = False, delete: bool = False): + if isinstance(items, str): + items = frappe.parse_json(items) + + for item in items: + if not item.get("doctype") or not item.get("docname"): + continue + + if not frappe.has_permission(item["doctype"], "write", item["docname"]): + continue + + try: + if remove_contact: + remove_contact_link(item["doctype"], item["docname"]) + else: + remove_doc_link(item["doctype"], item["docname"]) + + if delete: + frappe.delete_doc(item["doctype"], item["docname"]) + except (frappe.DoesNotExistError, frappe.ValidationError): + # Skip if document doesn't exist or has validation errors + continue + + return "success" + + +@frappe.whitelist() +def delete_bulk_docs(doctype: str, items: str | list, delete_linked: bool = False): + from frappe.desk.reportview import delete_bulk + + if not doctype: + frappe.throw(_("Doctype is required")) + + if not items: + frappe.throw(_("Items are required")) + + items = frappe.parse_json(items) + if not isinstance(items, list): + frappe.throw(_("Items must be a list")) + + for doc in items: + try: + if not frappe.db.exists(doctype, doc): + frappe.log_error(f"Document {doctype} {doc} does not exist", "Bulk Delete Error") + continue + + linked_docs = get_linked_docs_of_document(doctype, doc) + for linked_doc in linked_docs: + if not linked_doc.get("reference_doctype") or not linked_doc.get("reference_docname"): + continue + + remove_linked_doc_reference( + [ + { + "doctype": linked_doc["reference_doctype"], + "docname": linked_doc["reference_docname"], + } + ], + remove_contact=doctype == "Contact", + delete=delete_linked, + ) + except Exception as e: + frappe.log_error(f"Error processing linked docs for {doctype} {doc}: {e!s}", "Bulk Delete Error") + + if len(items) > 10: + frappe.enqueue("frappe.desk.reportview.delete_bulk", doctype=doctype, items=items) + else: + delete_bulk(doctype, items) + return "success" diff --git a/crm/api/event.py b/crm/api/event.py new file mode 100644 index 0000000..587247f --- /dev/null +++ b/crm/api/event.py @@ -0,0 +1,372 @@ +""" +Event notification handling for CRM. + +This module handles event notifications for the CRM system, supporting both: +1. Custom event notifications set on individual events +2. Global default notifications from CRM Settings for events without custom notifications + +The notification system processes events that are either: +- Starting from now or in the future (normal case) +- Currently in progress (between starts_on and ends_on) to handle delayed notifications + +Global notifications are configured in CRM Settings under the Calendar tab and are applied +to events that don't have custom notifications set. This ensures all events can receive +reminders even if users don't configure them individually. +""" + +from datetime import datetime, timedelta + +import frappe +from frappe.utils import add_to_date, now_datetime + + +def trigger_offset_event_notifications(): + """Trigger event notifications for offset-based intervals (minutes).""" + _process_event_notifications_by_interval("minutes") + _process_event_notifications_by_interval("hours") + + +def trigger_hourly_event_notifications(): + """Trigger event notifications for hourly intervals.""" + _process_event_notifications_by_interval("hours") + + +def trigger_daily_event_notifications(): + """Trigger event notifications for daily intervals.""" + _process_event_notifications_by_interval("days") + + +def trigger_weekly_event_notifications(): + """Trigger event notifications for weekly intervals.""" + _process_event_notifications_by_interval("weeks") + + +def _process_event_notifications_by_interval(interval): + """ + Process event notifications for a specific interval. + + Args: + interval (str): The interval type ('minutes', 'hours', 'days', 'weeks') + """ + + if frappe.flags.in_import or frappe.flags.in_patch: + return + + current_time = now_datetime() + current_user = frappe.session.user + all_events_data = frappe.db.sql( + """ + SELECT + e.name as event_name, + e.subject, + e.starts_on, + e.ends_on, + e.owner, + e.description, + e.all_day as all_day_event, + en.type as notification_type, + en.before as before_value, + en.time as time_of_day, + en.interval as notification_interval, + ep.email as participant_email, + ep_all.participant_emails_csv, + CASE WHEN en.parent IS NULL THEN 0 ELSE 1 END as has_custom_notifications + FROM `tabEvent` e + LEFT JOIN `tabEvent Notifications` en ON e.name = en.parent AND en.interval = %s + LEFT JOIN `tabEvent Participants` ep ON e.name = ep.parent AND ep.email = %s + LEFT JOIN ( + SELECT parent, GROUP_CONCAT(email) AS participant_emails_csv + FROM `tabEvent Participants` + GROUP BY parent + ) AS ep_all ON ep_all.parent = e.name + WHERE (e.starts_on >= %s OR (%s >= e.starts_on AND %s < e.ends_on)) + AND (e.owner = %s OR ep.email = %s) + AND e.status != 'Cancelled' + ORDER BY e.starts_on, e.name + """, + (interval, current_user, current_time, current_time, current_time, current_user, current_user), + as_dict=True, + ) + + for event_data in all_events_data: + participant_emails_csv = event_data.pop("participant_emails_csv", None) + event_data["event_participants"] = _split_participant_emails(participant_emails_csv) + + notifications = _process_unified_event_data(all_events_data, interval) + + for notification in notifications: + try: + event_start = notification.get("starts_on") + event_end = notification.get("ends_on") + before_value = notification.get("before_value", 1) + + trigger_datetime = _calculate_trigger_datetime( + event_start, + before_value, + interval, + notification.get("all_day_event"), + notification.get("time_of_day"), + ) + + event_is_in_progress = event_start <= current_time < event_end + trigger_time_passed = current_time > trigger_datetime + + if event_is_in_progress and trigger_time_passed: + trigger_window_start = trigger_datetime + trigger_window_end = event_end + else: + window_duration = _get_trigger_window_duration(interval) + trigger_window_start = add_to_date( + trigger_datetime, **{k: -v for k, v in window_duration.items()} + ) + trigger_window_end = add_to_date(trigger_datetime, **window_duration) + + if not (trigger_window_start <= current_time <= trigger_window_end): + continue + + if notification.get("notification_type") == "Email": + _send_email_notification(notification, event_start, before_value, interval) + elif notification.get("notification_type") == "Notification": + _send_system_notification(notification) + + except Exception as e: + frappe.log_error( + f"Error processing {interval} notification for event {notification.get('event_name', 'Unknown')}: {e!s}" + ) + continue + + +def _process_unified_event_data(all_events_data, interval): + """ + Process unified event data that includes both events with and without custom notifications. + + Args: + all_events_data (list): List of event data from the unified query + interval (str): The interval type ('minutes', 'hours', 'days', 'weeks') + + Returns: + list: List of processed notifications ready for sending + """ + notifications = [] + events_without_notifications = [] + + for event_data in all_events_data: + if event_data.get("has_custom_notifications") == 1: + notifications.append(event_data) + else: + event_key = event_data.get("event_name") + if not any(e.get("event_name") == event_key for e in events_without_notifications): + events_without_notifications.append(event_data) + if events_without_notifications: + global_notifications = _apply_global_notifications_to_events(events_without_notifications, interval) + notifications.extend(global_notifications) + + return notifications + + +def _apply_global_notifications_to_events(events_without_notifications, interval): + """ + Apply global CRM Settings notifications to events that don't have custom notifications. + + Args: + events_without_notifications (list): List of events without custom notifications + interval (str): The interval type ('minutes', 'hours', 'days', 'weeks') + + Returns: + list: List of notification dictionaries using global settings + """ + + fcrm_settings = frappe.get_single("FCRM Settings") + global_notifications = [] + if hasattr(fcrm_settings, "event_notifications"): + for notification in fcrm_settings.event_notifications: + if notification.interval == interval: + notification._table_type = "regular" + global_notifications.append(notification) + + if hasattr(fcrm_settings, "all_day_event_notifications"): + for notification in fcrm_settings.all_day_event_notifications: + if notification.interval == interval: + notification._table_type = "all_day" + global_notifications.append(notification) + + if not global_notifications: + return [] + notifications = [] + for event in events_without_notifications: + for global_notification in global_notifications: + if (global_notification._table_type == "all_day" and not event.all_day_event) or ( + global_notification._table_type == "regular" and event.all_day_event + ): + continue + + notification = { + "event_name": event.event_name, + "notification_type": global_notification.type, + "before_value": global_notification.before, + "time_of_day": global_notification.time, + "subject": event.subject, + "starts_on": event.starts_on, + "ends_on": event.ends_on, + "owner": event.owner, + "description": event.description, + "all_day_event": event.all_day_event, + "event_participants": event.get("event_participants", []), + } + notifications.append(notification) + + return notifications + + +def _calculate_trigger_datetime(event_start, before_value, interval, all_day_event, time_of_day): + """ + Calculate when the notification should be triggered based on event start time and interval. + + Args: + event_start (datetime): Event start datetime + before_value (int): How many units before the event + interval (str): The interval type ('minutes', 'hours', 'days', 'weeks') + all_day_event (bool): Whether this is an all-day event + time_of_day (time): Specific time to send notification for all-day events + + Returns: + datetime: When the notification should be triggered + """ + + if all_day_event and time_of_day and interval in ["days", "weeks"]: + if interval == "days": + trigger_date = event_start.date() - timedelta(days=before_value) + elif interval == "weeks": + trigger_date = event_start.date() - timedelta(weeks=before_value) + + trigger_datetime = datetime.combine(trigger_date, time_of_day) + else: + interval_kwargs = _get_interval_kwargs(interval, before_value) + trigger_datetime = add_to_date(event_start, **interval_kwargs) + + return trigger_datetime + + +def _get_interval_kwargs(interval, before_value): + """ + Get the appropriate keyword arguments for add_to_date based on interval type. + + Args: + interval (str): The interval type ('minutes', 'hours', 'days', 'weeks') + before_value (int): How many units before the event + + Returns: + dict: Keyword arguments for add_to_date with negative values + """ + interval_mapping = { + "minutes": {"minutes": -before_value}, + "hours": {"hours": -before_value}, + "days": {"days": -before_value}, + "weeks": {"weeks": -before_value}, + } + + return interval_mapping.get(interval, {"hours": -before_value}) + + +def _get_trigger_window_duration(interval): + """ + Get the trigger window duration based on interval type. + + Args: + interval (str): The interval type ('minutes', 'hours', 'days', 'weeks') + + Returns: + dict: Window duration to be used symmetrically around the trigger time + """ + window_mapping = { + "minutes": {"minutes": 5}, + "hours": {"hours": 1}, + "days": {"hours": 8}, + "weeks": {"days": 4}, + } + + return window_mapping.get(interval, {"hours": 1}) + + +def _split_participant_emails(participant_emails_csv): + """Return a clean list of participant emails from a comma-separated string.""" + + if not participant_emails_csv: + return [] + + return [email.strip() for email in participant_emails_csv.split(",") if email and email.strip()] + + +def _send_email_notification(notification, event_start, before_value, interval): + """Send email notification for an event""" + + try: + recipients = set() + subject = f"Event Reminder: {notification.subject}" + + if notification.owner and notification.owner != "Administrator": + recipients.add(notification.owner) + + participant_emails = notification.get("event_participants") or [] + if participant_emails: + recipients.update(participant_emails) + else: + event_doc = frappe.get_doc("Event", notification.event_name) + for participant in event_doc.get("event_participants", []): + email = getattr(participant, "email", None) + if email: + recipients.add(email) + + recipients = [email for email in recipients if email] + + if not recipients: + return + time_remaining_text = _format_time_remaining(before_value, interval) + + message = f""" +
+

Event Reminder

+

This is a reminder for your upcoming event:

+
+

{notification.subject}

+ {f'

{notification.description}

' if notification.description else ""} +

Start Time: {event_start.strftime("%Y-%m-%d %H:%M:%S")}

+

Time Remaining: {time_remaining_text}

+
+

This is an automated reminder from your calendar system.

+
+ """ + + frappe.sendmail( + recipients=recipients, + subject=subject, + message=message, + reference_doctype="Event", + reference_name=notification.event_name, + now=True, + ) + + except Exception as e: + frappe.log_error(f"Failed to send email for event {notification.event_name}: {e!s}") + + +def _format_time_remaining(before_value, interval): + """ + Format the time remaining message based on the interval and before value. + + Args: + before_value (int): The number of units before the event + interval (str): The interval type ('minutes', 'hours', 'days', 'weeks') + + Returns: + str: Formatted time remaining message + """ + interval_labels = {"minutes": "minute(s)", "hours": "hour(s)", "days": "day(s)", "weeks": "week(s)"} + + interval_label = interval_labels.get(interval, "unit(s)") + return f"{before_value} {interval_label}" + + +def _send_system_notification(notification): + """Send system notification for an event""" + frappe.publish_realtime("event_notification", notification) diff --git a/crm/api/exchange_rate.py b/crm/api/exchange_rate.py new file mode 100644 index 0000000..72f79e5 --- /dev/null +++ b/crm/api/exchange_rate.py @@ -0,0 +1,158 @@ +import frappe +import requests +from frappe import _ + +from crm.fcrm.doctype.fcrm_settings.fcrm_settings import FCRMSettings + + +@frappe.whitelist() +def get_exchange_rate(from_currency: str, to_currency: str, date: str | None = None): + if not date: + date = "latest" + + # "latest" is keyed by today's date so tomorrow's call automatically misses the cache + cache_date = frappe.utils.today() if date == "latest" else date + cache_key = f"exchange_rate_{from_currency}_{to_currency}_{cache_date}" + + cached_rate = frappe.cache().get_value(cache_key) + if cached_rate is not None: + return cached_rate + + rate, api_used = _fetch_exchange_rate(from_currency, to_currency, date) + + if rate is not None: + frappe.cache().set_value(cache_key, rate) + return rate + + _raise_exchange_rate_error(from_currency, to_currency, date, api_used) + + +def _fetch_exchange_rate(from_currency: str, to_currency: str, date: str): + settings = frappe.get_single("FCRM Settings") + provider = settings.service_provider + + # Paid providers — no fallback, fail explicitly + if provider == "exchangerate.host": + return _fetch_from_exchangerate_host(settings, from_currency, to_currency, date), provider + + if provider == "exchangerate-api": + return _fetch_from_exchangerate_api(settings, from_currency, to_currency), provider + + # Free providers — try both as fallbacks, regardless of which is "primary" + if provider == "frankfurter.app": + rate = _fetch_from_frankfurter(from_currency, to_currency, date) + if rate is not None: + return rate, provider + # Frankfurter is down — silently fall back to fawaz + rate = _fetch_from_fawaz_api(from_currency, to_currency, date) + return rate, "fawazahmed-exchange-api" + + if provider == "fawazahmed-exchange-api": + rate = _fetch_from_fawaz_api(from_currency, to_currency, date) + if rate is not None: + return rate, provider + # fawaz is down — silently fall back to frankfurter + rate = _fetch_from_frankfurter(from_currency, to_currency, date) + return rate, "frankfurter" + + # Unknown provider — try both free ones + rate = _fetch_from_frankfurter(from_currency, to_currency, date) + if rate is not None: + return rate, "frankfurter" + return _fetch_from_fawaz_api(from_currency, to_currency, date), "fawazahmed-exchange-api" + + +def _fetch_from_frankfurter(from_currency: str, to_currency: str, date: str): + try: + res = requests.get( + f"https://api.frankfurter.app/{date}?from={from_currency}&to={to_currency}", timeout=5 + ) + if res.ok: + return res.json().get("rates", {}).get(to_currency) + except Exception: + pass + return None + + +def _fetch_from_fawaz_api(from_currency: str, to_currency: str, date: str): + from_lower = from_currency.lower() + to_lower = to_currency.lower() + date_str = "latest" if date == "latest" else date + urls = [ + f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{date_str}/v1/currencies/{from_lower}.json", + f"https://{date_str}.currency-api.pages.dev/v1/currencies/{from_lower}.json", + ] + for url in urls: + try: + res = requests.get(url, timeout=5) + if res.ok: + return res.json()[from_lower][to_lower] + except Exception: + continue + return None + + +def _fetch_from_exchangerate_host(settings: FCRMSettings, from_currency: str, to_currency: str, date: str): + if not settings.access_key: + frappe.throw( + _("Access Key is required for Service Provider: {0}").format( + frappe.bold(settings.service_provider) + ) + ) + params = {"access_key": settings.access_key, "from": from_currency, "to": to_currency, "amount": 1} + if date != "latest": + params["date"] = date + res = requests.get("https://api.exchangerate.host/convert", params=params, timeout=5) + if res.ok: + return res.json()["result"] + return None + + +def _fetch_from_exchangerate_api(settings: FCRMSettings, from_currency: str, to_currency: str): + if not settings.access_key: + frappe.throw( + _("Access Key is required for Service Provider: {0}").format( + frappe.bold(settings.service_provider) + ) + ) + res = requests.get( + f"https://v6.exchangerate-api.com/v6/{settings.access_key}/pair/{from_currency}/{to_currency}", + timeout=5, + ) + if res.ok: + data = res.json() + if data["result"] == "success": + return data["conversion_rate"] + return None + + +def _raise_exchange_rate_error(from_currency: str, to_currency: str, date: str, api_used: str): + frappe.log_error( + title="Exchange Rate Fetch Error", + message=f"Failed to fetch exchange rate from {from_currency} to {to_currency} using {api_used} API.", + ) + + if api_used == "frankfurter": + user = frappe.session.user + is_manager = ( + "System Manager" in frappe.get_roles(user) + or "Sales Manager" in frappe.get_roles(user) + or user == "Administrator" + ) + if not is_manager: + frappe.throw( + _( + "Ask your manager to set up the Exchange Rate Provider, as default provider does not support currency conversion for {0} to {1}." + ).format(from_currency, to_currency) + ) + frappe.throw( + _( + "Setup the Exchange Rate Provider other than 'Frankfurter' in settings, as default provider does not support currency conversion for {0} to {1}." + ).format(from_currency, to_currency) + ) + + frappe.throw( + _( + "Failed to fetch exchange rate from {0} to {1} on {2}. Please check your internet connection or try again later." + ).format(from_currency, to_currency, date) + ) diff --git a/crm/api/form.py b/crm/api/form.py new file mode 100644 index 0000000..17aba59 --- /dev/null +++ b/crm/api/form.py @@ -0,0 +1,638 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""Hybrid forms API. + +Storage/submission/validation is delegated to Frappe's built-in `Web Form` +doctype; this module only: + 1. curates which target fields a CRM form may collect, + 2. gives the CRM builder a small, scoped CRUD surface over Web Form records, + 3. re-applies CRM-specific enrichment (source, organization, primary contact) + on submission, which the framework's generic insert does not do. +""" + +import json + +import frappe +from frappe import _ +from frappe.utils.telemetry import capture + +ALLOWED_DOCTYPES = ("CRM Lead", "CRM Deal") +FORM_SOURCE = "Web Form" +FORM_MODULE = "FCRM" + + +def ensure_form_source() -> str: + """Return the 'Web Form' CRM Lead Source, creating it once if needed.""" + if not frappe.db.exists("CRM Lead Source", FORM_SOURCE): + frappe.get_doc({"doctype": "CRM Lead Source", "source_name": FORM_SOURCE}).insert( + ignore_permissions=True + ) + return FORM_SOURCE + + +# Fieldtypes a form can render/collect. A `Link` renders as a server-populated, +# pick-only dropdown (see crm_form.py); excludes types needing a live widget or file +# upload (Dynamic Link, Table, Attach, …). +SUPPORTED_FIELDTYPES = ( + "Data", + "Small Text", + "Text", + "Long Text", + "Text Editor", + "HTML Editor", + "Markdown Editor", + "Select", + "Link", + "Int", + "Float", + "Currency", + "Percent", + "Check", + "Date", + "Datetime", + "Time", + "Phone", + "Color", +) + +# Never expose these as mappable fields even if their type is supported. +DENIED_FIELDNAMES = ( + "naming_series", + # a mandatory Link the system sets — seeded as a hidden field with a default, not + # offered in the picker. + "status", + "lead_name", + "converted", + "sla_status", + "response_by", + "first_response_time", + "first_responded_on", + "facebook_form_id", + "facebook_lead_id", +) + +LAYOUT_BREAKS = ("Section Break", "Column Break") + +# Starting layout of a brand-new form, per target doctype: labelled sections, +# each a list of columns, each column a list of fieldnames. A sensible +# contact-capture starting point the author can then edit. +SEED_LAYOUT = { + "CRM Lead": [ + { + "label": "Personal Details", + "columns": [["first_name", "email"], ["last_name", "phone"]], + }, + ], + "CRM Deal": [ + { + "label": "Personal Details", + "columns": [["first_name", "email"], ["last_name", "phone"]], + }, + { + "label": "Organization Details", + "columns": [["organization_name"]], + }, + ], +} + + +def _seeded_visible_fieldnames(document_type: str) -> set: + names = set() + for section in SEED_LAYOUT.get(document_type, []): + for col in section["columns"]: + names.update(col) + return names + + +def guest_can_select(doctype: str) -> bool: + """Whether Guest may reference `doctype` in a Link field. Gates on `select` (never + `read`) — Frappe's minimal tier that lists record names without exposing full + documents — so a doctype is linkable only when Guest select is deliberately granted.""" + return bool(doctype) and frappe.has_permission(doctype, ptype="select", user="Guest") + + +def _link_target_doctypes() -> set: + """Doctypes reachable via a Link field on any form-mappable target — the only + doctypes `grant_guest_link_access` is allowed to touch, so the endpoint can't be + used to open Guest select on an arbitrary doctype.""" + targets = set() + for document_type in ALLOWED_DOCTYPES: + for df in frappe.get_meta(document_type).fields: + if df.fieldtype == "Link" and df.options: + targets.add(df.options) + return targets + + +def _mappable_fields(document_type: str) -> list[dict]: + """Fields of a target DocType a form may collect (shared by the picker and by + the brand-new-form seeding).""" + meta = frappe.get_meta(document_type) + fields = [] + for df in meta.fields: + if df.fieldtype not in SUPPORTED_FIELDTYPES: + continue + if not df.fieldname or df.fieldname in DENIED_FIELDNAMES: + continue + if df.hidden or df.read_only: + continue + # a Link is offered even when guests can't select the target yet; the builder + # warns and offers a one-click grant (see grant_guest_link_access). + fields.append( + { + "fieldname": df.fieldname, + "label": df.label or df.fieldname, + "fieldtype": df.fieldtype, + "options": df.options, + "reqd": df.reqd, + "default": df.default, + } + ) + return fields + + +def _default_status(document_type: str) -> str | None: + """The status a new Lead/Deal defaults to — mirrors the doctype controllers so + the hidden 'Status' field is pre-filled with the value the CRM would use.""" + status_dt = "CRM Lead Status" if document_type == "CRM Lead" else "CRM Deal Status" + preferred = "New" if document_type == "CRM Lead" else "Qualification" + if frappe.db.exists(status_dt, preferred): + return preferred + rows = frappe.get_all(status_dt, {"type": "Open"}, pluck="name") + return rows[0] if rows else None + + +def _seed_visible_fields(document_type: str) -> list[dict]: + """Web-form rows for a new form's visible layout: the curated contact set, + arranged into labelled sections and columns (with Section/Column breaks).""" + catalog = {f["fieldname"]: f for f in _mappable_fields(document_type)} + + def _break(fieldtype, i, label=""): + prefix = "section_break" if fieldtype == "Section Break" else "column_break" + return { + "fieldname": f"{prefix}_seed{i}", + "label": label, + "fieldtype": fieldtype, + "options": "", + "reqd": 0, + "placeholder": "", + "field_description": "", + } + + rows = [] + n = 0 + for section in SEED_LAYOUT.get(document_type, []): + n += 1 + rows.append(_break("Section Break", n, section.get("label") or "")) + for ci, col in enumerate(section["columns"]): + if ci > 0: + n += 1 + rows.append(_break("Column Break", n)) + for fn in col: + f = catalog.get(fn) + if not f: + continue + rows.append( + { + "fieldname": f["fieldname"], + "label": f["label"], + "fieldtype": f["fieldtype"], + "options": f["options"], + "reqd": 1 if f["reqd"] else 0, + "placeholder": "", + "field_description": "", + } + ) + return rows + + +def _seed_hidden_fields(document_type: str) -> list[dict]: + """Mandatory fields a public visitor should not fill (e.g. Status, a Link the + system sets). Seeded into the hidden section with a sensible default so the + author sees them and can override the value applied on submission.""" + meta = frappe.get_meta(document_type) + visible = _seeded_visible_fieldnames(document_type) + hidden = [] + for df in meta.fields: + if not df.reqd or df.fieldname in visible: + continue + if df.fieldtype in SUPPORTED_FIELDTYPES and df.fieldname not in DENIED_FIELDNAMES: + continue # a fillable mandatory field belongs in the visible layout + default = _default_status(document_type) if df.fieldname == "status" else (df.default or "") + hidden.append( + { + "fieldname": df.fieldname, + "label": df.label or df.fieldname, + "fieldtype": df.fieldtype, + "options": df.options or "", + "default": default, + } + ) + return hidden + + +def _check_manager(): + """CRM forms are managed by CRM managers, not Website Managers. Gate here and + then write the Web Form with ignore_permissions (the role mismatch, in code).""" + roles = set(frappe.get_roles()) + if not roles & {"System Manager", "Sales Manager"}: + frappe.throw(_("Not permitted"), frappe.PermissionError) + + +@frappe.whitelist() +def get_form_fields(document_type: str) -> list[dict]: + """Mappable fields of a target DocType, for the builder's field picker.""" + if document_type not in ALLOWED_DOCTYPES: + frappe.throw(_("Forms can only map to: {0}").format(", ".join(ALLOWED_DOCTYPES))) + return _mappable_fields(document_type) + + +@frappe.whitelist() +def get_hidden_seed(document_type: str) -> list[dict]: + """The system-managed hidden required fields for a doctype (e.g. Status) with + their default value. Used by the builder to reconcile on a doctype switch.""" + if document_type not in ALLOWED_DOCTYPES: + frappe.throw(_("Forms can only map to: {0}").format(", ".join(ALLOWED_DOCTYPES))) + return _seed_hidden_fields(document_type) + + +@frappe.whitelist() +def link_field_guest_access(doctype: str) -> dict: + """Whether guests can already select `doctype`. Drives the builder's Link-field + notice: if False, the author is warned that the public dropdown will be empty and + is offered a one-click grant (grant_guest_link_access).""" + _check_manager() + return {"doctype": doctype, "guest_can_select": guest_can_select(doctype)} + + +@frappe.whitelist() +def grant_guest_link_access(doctype: str) -> dict: + """Grant Guest `select` on `doctype` so a public Link dropdown can list its records — a + deliberate choice by a form manager (the builder warns that anyone with the form link + will then see them). Limited to doctypes that are Link targets on a CRM form; stored as + a site-level Custom DocPerm.""" + _check_manager() + if doctype not in _link_target_doctypes(): + frappe.throw(_("{0} isn't a linkable field on a CRM form.").format(doctype)) + + if not guest_can_select(doctype): + from frappe.permissions import add_permission + + # a Sales Manager may run the builder but can't normally write Custom DocPerm; do + # this narrow, doctype-scoped grant with permission checks off (as this module + # already does to write the Web Form). + had_flag = frappe.flags.ignore_permissions + frappe.flags.ignore_permissions = True + try: + perm_name = frappe.db.get_value( + "Custom DocPerm", + {"parent": doctype, "role": "Guest", "permlevel": 0, "if_owner": 0}, + ) + if not perm_name: + add_permission(doctype, "Guest", 0, ptype="select") + perm_name = frappe.db.get_value( + "Custom DocPerm", + {"parent": doctype, "role": "Guest", "permlevel": 0, "if_owner": 0}, + ) + # add_permission defaults the row to read/export; a public Link only lists + # names, so trim the Guest row to `select` alone. + perm = frappe.get_doc("Custom DocPerm", perm_name) + perm.update( + { + "select": 1, + "read": 0, + "write": 0, + "create": 0, + "delete": 0, + "submit": 0, + "cancel": 0, + "amend": 0, + "report": 0, + "export": 0, + "import": 0, + "print": 0, + "email": 0, + "share": 0, + } + ) + perm.save(ignore_permissions=True) + finally: + frappe.flags.ignore_permissions = had_flag + frappe.clear_cache(doctype=doctype) + + return {"doctype": doctype, "guest_can_select": guest_can_select(doctype)} + + +@frappe.whitelist() +def list_forms() -> list[dict]: + """CRM forms only (native Web Form records mapped to Lead/Deal).""" + _check_manager() + return frappe.get_all( + "Web Form", + filters={"module": FORM_MODULE, "doc_type": ["in", ALLOWED_DOCTYPES]}, + fields=[ + "name", + "title", + "route", + "doc_type as document_type", + "crm_published as published", + "modified", + ], + order_by="modified desc", + ) + + +@frappe.whitelist() +def get_form_config(name: str) -> dict: + """Full config for the builder, read from the native Web Form record.""" + _check_manager() + doc = _get_crm_form(name) + return { + "name": doc.name, + "title": doc.title, + "route": doc.route, + "document_type": doc.doc_type, + "published": doc.crm_published, + "submit_button_label": doc.button_label or _("Submit"), + "description": doc.introduction_text or "", + "success_message": doc.success_message or "", + "redirect_url": doc.success_url or "", + "allowed_embedding_domains": doc.allowed_embedding_domains or "", + "fields": [ + { + "fieldname": f.fieldname, + "label": f.label, + "fieldtype": f.fieldtype, + "options": f.options, + "reqd": f.reqd, + "placeholder": f.placeholder, + "field_description": f.description, + "depends_on": f.depends_on or "", + "mandatory_depends_on": f.mandatory_depends_on or "", + "read_only_depends_on": f.read_only_depends_on or "", + } + for f in doc.web_form_fields + ], + "hidden_fields": _load_hidden_fields(doc), + } + + +def _load_hidden_fields(doc) -> list[dict]: + try: + return json.loads(doc.get("crm_hidden_defaults") or "[]") + except Exception: + return [] + + +def _assert_hidden_defaults_set(hidden: list[dict]): + """Publishing requires every hidden required field to carry a default — a blank + one would break record creation on submission. Drafts may save without them.""" + missing = [ + h.get("label") or h.get("fieldname") for h in hidden if not str(h.get("default") or "").strip() + ] + if missing: + frappe.throw(_("Set a default value before publishing for: {0}").format(", ".join(missing))) + + +def _validated_visible_fields(document_type: str, fields: list[dict]) -> list[dict]: + """Re-apply the catalog the picker filters by: `accept()` lets a public visitor + write anything listed in `web_form_fields`.""" + meta = frappe.get_meta(document_type) + catalog = {f["fieldname"]: f for f in _mappable_fields(document_type)} + rows = [] + for f in fields: + fieldname = f.get("fieldname") + if f.get("fieldtype") in LAYOUT_BREAKS: + # a break carries no data, but `accept()` still writes any row naming a field + if meta.has_field(fieldname): + frappe.throw(_("{0} can't be used as a layout break").format(fieldname)) + rows.append(f) + continue + allowed = catalog.get(fieldname) + if not allowed: + frappe.throw(_("{0} can't be collected by a form").format(fieldname or _("Unnamed field"))) + rows.append({**f, "fieldtype": allowed["fieldtype"], "options": allowed["options"]}) + return rows + + +def _validated_hidden_fields(document_type: str, hidden: list[dict]) -> list[dict]: + """Same gate for the hidden section: `_apply_hidden_defaults` writes these onto + every submission.""" + catalog = {h["fieldname"]: h for h in _seed_hidden_fields(document_type)} + rows = [] + for h in hidden: + fieldname = h.get("fieldname") + allowed = catalog.get(fieldname) + if not allowed: + frappe.throw(_("{0} can't be set as a hidden field").format(fieldname or _("Unnamed field"))) + rows.append({**h, "fieldtype": allowed["fieldtype"], "options": allowed["options"]}) + return rows + + +@frappe.whitelist() +def save_form(name: str | None, form: dict | str) -> dict: + """Create/update a native Web Form scoped to CRM doctypes.""" + _check_manager() + if isinstance(form, str): + form = json.loads(form or "{}") + + if form.get("document_type") not in ALLOWED_DOCTYPES: + frappe.throw(_("Forms can only map to: {0}").format(", ".join(ALLOWED_DOCTYPES))) + + doc = _get_crm_form(name) if name else frappe.new_doc("Web Form") + + doc.title = form.get("title") + doc.route = form.get("route") + doc.doc_type = form.get("document_type") + doc.introduction_text = form.get("description") + doc.button_label = form.get("submit_button_label") or "Submit" + doc.success_message = form.get("success_message") + # redirect visitors here after a successful submission (native Web Form field); + # blank falls back to showing the success message + doc.success_url = form.get("redirect_url") or "" + doc.allowed_embedding_domains = form.get("allowed_embedding_domains") + # `crm_published` drives the CRM's own branded page at /crm-form/; mirror + # it onto the native `published` so the Desk Web Form view shows the right state. + # (This also makes Frappe serve its generic form at / — an additional, + # unbranded public URL alongside /crm-form/.) + doc.crm_published = 1 if form.get("published") else 0 + doc.published = doc.crm_published + # CRM forms are public, single-purpose lead/deal capture + doc.login_required = 0 + doc.allow_multiple = 1 + doc.is_standard = 0 + doc.module = FORM_MODULE + + # brand-new form → seed a starting contact-capture layout (only on create) + fields = form.get("fields") + if not name and not fields: + fields = _seed_visible_fields(form["document_type"]) + + # only rewrite the layout when fields were actually sent — an update that omits + # `fields` (e.g. a settings-only save) must not wipe the existing layout + if fields is not None: + fields = _validated_visible_fields(form["document_type"], fields) + doc.set("web_form_fields", []) + for i, f in enumerate(fields): + doc.append( + "web_form_fields", + { + "fieldname": f.get("fieldname"), + "label": f.get("label"), + "fieldtype": f.get("fieldtype"), + "options": f.get("options"), + "reqd": 1 if f.get("reqd") else 0, + "placeholder": f.get("placeholder"), + "description": f.get("field_description"), + # conditional-logic rules authored per field in the builder (same as + # the framework's Web Form fields; evaluated client-side) + "depends_on": f.get("depends_on") or "", + "mandatory_depends_on": f.get("mandatory_depends_on") or "", + "read_only_depends_on": f.get("read_only_depends_on") or "", + "idx": i + 1, + }, + ) + + # hidden required fields: mandatory fields kept out of the visible form, with + # the default value applied on submission (see enrich_form_submission). Seeded + # once on create (e.g. Status); thereafter whatever the builder sends wins. + hidden = form.get("hidden_fields") + if hidden is None and not name: + hidden = _seed_hidden_fields(form["document_type"]) + if isinstance(hidden, str): + hidden = json.loads(hidden or "[]") + hidden = _validated_hidden_fields(form["document_type"], hidden or []) + if doc.crm_published: + _assert_hidden_defaults_set(hidden) + doc.crm_hidden_defaults = json.dumps(hidden) if hidden else "" + + doc.save(ignore_permissions=True) + return {"name": doc.name, "route": doc.route} + + +def _get_crm_form(name: str): + doc = frappe.get_doc("Web Form", name) + # scope to CRM's own forms — a Web Form from another app that happens to target + # CRM Lead/Deal must not be readable/mutable/deletable through this API + if doc.module != FORM_MODULE or doc.doc_type not in ALLOWED_DOCTYPES: + frappe.throw(_("Not a CRM form")) + return doc + + +@frappe.whitelist() +def set_published(name: str, published: int) -> None: + """Publish/unpublish from the list, bypassing native Web Form role perms.""" + _check_manager() + doc = _get_crm_form(name) + if int(published): + _assert_hidden_defaults_set(_load_hidden_fields(doc)) + doc.crm_published = 1 if int(published) else 0 + doc.published = doc.crm_published # mirror onto native flag (see save_form) + doc.save(ignore_permissions=True) + + +@frappe.whitelist() +def delete_form(name: str) -> None: + _check_manager() + _get_crm_form(name) + frappe.delete_doc("Web Form", name, ignore_permissions=True) + + +@frappe.whitelist() +def test_submit_form(name: str, values: dict | str) -> dict: + """Dry-run a submission for an author previewing a draft: validate required + fields the same way the live form does, but create no record.""" + _check_manager() + if isinstance(values, str): + values = json.loads(values or "{}") + + doc = _get_crm_form(name) + for f in doc.web_form_fields: + if f.fieldtype in ("Section Break", "Column Break"): + continue + value = values.get(f.fieldname) + if f.reqd and (value is None or value == ""): + frappe.throw(_("{0} is required").format(f.label or f.fieldname)) + return {"test": True} + + +# Public form serving + submission run through the framework's own Web Form engine: +# the CRM page (`www/crm_form.py`) renders the published form and posts to the +# built-in `accept()`, which triggers `enrich_form_submission` below via the +# `in_web_form` flag. No CRM-owned guest endpoint is needed for that path. + + +def enrich_form_submission(doc): + """Called from the CRM Lead/Deal `before_insert`: when the record is created via a + web form, apply the same enrichment the CRM applies on manual creation. + + The framework's Web Form `accept()` just inserts the target doc, so without this + web submissions would miss Source / Organization / primary Contact. `accept()` + sets `frappe.flags.in_web_form`, which is how we scope this to web submissions. + """ + if not frappe.flags.get("in_web_form"): + return + if doc.doctype not in ALLOWED_DOCTYPES: + return + + _apply_hidden_defaults(doc) + + # stamp the source so form records are identifiable/filterable + if doc.meta.has_field("source") and not doc.get("source"): + doc.source = ensure_form_source() + + capture( + "web_form_submitted", + "crm", + properties={"doctype": doc.doctype, "form": frappe.form_dict.get("web_form")}, + ) + + if doc.doctype != "CRM Deal": + return + + from crm.fcrm.doctype.crm_deal.crm_deal import create_contact, create_organization + + # auto-create the linked Organization from the org name (as the CRM does for deals) + if doc.get("organization_name") and not doc.get("organization"): + created = create_organization(doc) + if created: + doc.organization = created + + # auto-create & link a primary Contact from the person's details + if not doc.get("contacts") and ( + doc.get("first_name") or doc.get("last_name") or doc.get("email") or doc.get("mobile_no") + ): + contact = create_contact(doc) + if contact: + doc.append("contacts", {"contact": contact, "is_primary": 1}) + + +def _apply_hidden_defaults(doc): + """Apply the submitting form's hidden-field defaults (e.g. Status) to fields the + visitor didn't fill, so mandatory values are present before the record is saved.""" + web_form = frappe.form_dict.get("web_form") + if not web_form: + return + # `web_form` comes from the (client-controllable) POST body — only trust a CRM + # form that targets this exact doctype, else a submission could pull in another + # form's defaults (or a different doctype's). + form = frappe.db.get_value("Web Form", web_form, ["doc_type", "crm_hidden_defaults"], as_dict=True) + if not form or form.doc_type not in ALLOWED_DOCTYPES or form.doc_type != doc.doctype: + return + raw = form.crm_hidden_defaults + if not raw: + return + try: + hidden = json.loads(raw) + except Exception: + return + for h in hidden: + fieldname = h.get("fieldname") + default = h.get("default") + if ( + fieldname + and default not in (None, "") + and doc.meta.has_field(fieldname) + and not doc.get(fieldname) + ): + doc.set(fieldname, default) diff --git a/crm/api/live_demo.py b/crm/api/live_demo.py new file mode 100644 index 0000000..986a9c8 --- /dev/null +++ b/crm/api/live_demo.py @@ -0,0 +1,34 @@ +# Copyright (c) 2024, Frappe Technologies and contributors +# This file is used to handle live demo site (https://frappecrm-demo.frappe.cloud) related API calls and hooks + +import frappe +from frappe import _ +from frappe.auth import LoginManager + + +@frappe.whitelist(allow_guest=True) +def login(): + if not frappe.conf.demo_username or not frappe.conf.demo_password: + return + frappe.local.response["redirect_to"] = "/crm" + login_manager = LoginManager() + login_manager.authenticate(frappe.conf.demo_username, frappe.conf.demo_password) + login_manager.post_login() + frappe.local.response["type"] = "redirect" + frappe.local.response["location"] = frappe.local.response["redirect_to"] + + +def validate_reset_password(doc, event): + if frappe.conf.demo_username and frappe.session.user == frappe.conf.demo_username: + frappe.throw( + _("Password cannot be reset by Demo User {}").format(frappe.bold(frappe.conf.demo_username)), + frappe.PermissionError, + ) + + +def validate_user(doc, event): + if frappe.conf.demo_username and frappe.session.user == frappe.conf.demo_username and doc.new_password: + frappe.throw( + _("Password cannot be reset by Demo User {}").format(frappe.bold(frappe.conf.demo_username)), + frappe.PermissionError, + ) diff --git a/crm/api/notifications.py b/crm/api/notifications.py new file mode 100644 index 0000000..69629e4 --- /dev/null +++ b/crm/api/notifications.py @@ -0,0 +1,69 @@ +import frappe +from frappe.query_builder import Order + + +@frappe.whitelist() +def get_notifications(): + Notification = frappe.qb.DocType("CRM Notification") + query = ( + frappe.qb.from_(Notification) + .select("*") + .where(Notification.to_user == frappe.session.user) + .orderby("creation", order=Order.desc) + ) + notifications = query.run(as_dict=True) + + _notifications = [] + for notification in notifications: + _notifications.append( + { + "creation": notification.creation, + "from_user": { + "name": notification.from_user, + "full_name": frappe.get_value("User", notification.from_user, "full_name"), + }, + "type": notification.type, + "to_user": notification.to_user, + "read": notification.read, + "hash": get_hash(notification), + "notification_text": notification.notification_text, + "notification_type_doctype": notification.notification_type_doctype, + "notification_type_doc": notification.notification_type_doc, + "reference_doctype": ("deal" if notification.reference_doctype == "CRM Deal" else "lead"), + "reference_name": notification.reference_name, + "route_name": ("Deal" if notification.reference_doctype == "CRM Deal" else "Lead"), + } + ) + + return _notifications + + +@frappe.whitelist() +def mark_as_read(doc: str | None = None): + user = frappe.session.user + filters = {"to_user": user, "read": False} + or_filters = [] + if doc: + or_filters = [ + {"comment": doc}, + {"notification_type_doc": doc}, + ] + for n in frappe.get_all("CRM Notification", filters=filters, or_filters=or_filters): + d = frappe.get_doc("CRM Notification", n.name) + d.read = True + d.save() + + +def get_hash(notification): + _hash = "" + if notification.type == "Mention" and notification.notification_type_doc: + _hash = "#" + notification.notification_type_doc + + if notification.type == "WhatsApp": + _hash = "#whatsapp" + + if notification.type == "Assignment" and notification.notification_type_doctype == "CRM Task": + _hash = "#tasks" + if "has been removed by" in notification.message: + _hash = "" + return _hash diff --git a/crm/api/onboarding.py b/crm/api/onboarding.py new file mode 100644 index 0000000..00d27b0 --- /dev/null +++ b/crm/api/onboarding.py @@ -0,0 +1,24 @@ +import frappe + + +@frappe.whitelist() +def get_first_lead(): + lead = frappe.get_all( + "CRM Lead", + filters={"converted": 0}, + fields=["name"], + order_by="creation", + limit=1, + ) + return lead[0].name if lead else None + + +@frappe.whitelist() +def get_first_deal(): + deal = frappe.get_all( + "CRM Deal", + fields=["name"], + order_by="creation", + limit=1, + ) + return deal[0].name if deal else None diff --git a/crm/api/session.py b/crm/api/session.py new file mode 100644 index 0000000..c67010b --- /dev/null +++ b/crm/api/session.py @@ -0,0 +1,171 @@ +import frappe +from frappe import _ + +CRM_ALLOWED_ROLES = ["System Manager", "Sales Manager", "Sales User"] + + +def get_session_role_flags(): + roles = set(frappe.get_roles()) + + if not roles.intersection(set(CRM_ALLOWED_ROLES)): + frappe.throw(_("You are not permitted to access CRM resources."), frappe.PermissionError) + + return { + "is_system_manager": "System Manager" in roles, + "is_sales_manager": "Sales Manager" in roles and "System Manager" not in roles, + "is_sales_user": "Sales User" in roles + and "Sales Manager" not in roles + and "System Manager" not in roles, + } + + +USER_FIELDS = [ + "name", + "email", + "enabled", + "user_image", + "first_name", + "last_name", + "full_name", + "user_type", + "language", +] + + +@frappe.whitelist() +def get_users(include_all: bool = False): + """Return (users, crm_users) for the CRM frontend. + + By default (`include_all=False`) the User query is filtered at SQL level + to just users with CRM roles — typically a handful of rows. This unblocks + the UI on initial load even on sites with hundreds of thousands of users. + + When `include_all=True` and the session has System Manager, the full + enabled user list is returned. The frontend uses this path for a + non-blocking background fetch that populates name/avatar info for + non-CRM users referenced in CRM activity. Non-System-Manager sessions + cannot escalate by passing this param. + """ + session_roles = get_session_role_flags() + + # Param arrives as a string from the HTTP layer; coerce. + if isinstance(include_all, str): + include_all = include_all.lower() in ("1", "true", "yes") + if not session_roles["is_system_manager"]: + include_all = False + + # Always need the CRM user name set — used both as the filter for the + # fast path and as the membership check on the full path. + crm_user_names = set( + frappe.get_all( + "Has Role", + filters={"parenttype": "User", "role": ["in", CRM_ALLOWED_ROLES]}, + pluck="parent", + distinct=True, + ) + ) + crm_user_names.add("Administrator") + + user_filters = {"enabled": 1} + if not include_all: + user_filters["name"] = ["in", list(crm_user_names)] + + users = frappe.qb.get_query( + "User", + fields=USER_FIELDS, + order_by="full_name asc", + filters=user_filters, + ).run(as_dict=1) + + if not users: + return [], [] + + system_language = frappe.db.get_single_value("System Settings", "language") + session_user = frappe.session.user + + # Has Role lookup — restrict by parent on the fast path (tiny IN list); + # unfiltered scan on the full path because the IN list would otherwise + # carry every enabled user name (see commit dropping IN-list filters). + if include_all: + role_filters = {"parenttype": "User"} + else: + role_filters = {"parenttype": "User", "parent": ["in", list(crm_user_names)]} + role_rows = frappe.get_all("Has Role", filters=role_filters, fields=["parent", "role"]) + roles_by_user = {} + for row in role_rows: + roles_by_user.setdefault(row.parent, []).append(row.role) + + # Telephony agent table is tiny on any real site; full pluck is cheaper + # than serializing an IN list and gives identical results. + telephony_agents = set(frappe.get_all("CRM Telephony Agent", pluck="user")) + + role_priority = ("System Manager", "Sales Manager", "Sales User", "Guest") + crm_users = [] + + for user in users: + if session_user == user.name: + user.session_user = True + + # Administrator has every role implicitly via frappe.get_roles() but + # its Has Role child table is not guaranteed to contain System Manager + # on every install — special-case it to avoid locking the admin out. + if user.name == "Administrator": + user.roles = ["System Manager", "All"] + user.role = "System Manager" + else: + # Mirror frappe.get_roles() which appends implicit "All" and "Guest" + user.roles = [*roles_by_user.get(user.name, []), "All", "Guest"] + user.role = "" + for role in role_priority: + if role in user.roles: + user.role = role + break + + user.is_telephony_agent = user.name in telephony_agents + user.language = user.language or system_language + + if user.role in CRM_ALLOWED_ROLES: + crm_users.append(user) + + if not include_all: + # Fast path — both positions are the CRM users. + return crm_users, crm_users + + return users, crm_users + + +@frappe.whitelist() +def get_user_info(users: str | list): + """Resolve display info for a batch of User names. + + Used by the frontend to fill in name/avatar info for non-CRM users + referenced in CRM activity (comment authors, doc owners, etc.) when + the background full-list fetch has not yet landed. Gated behind a + CRM role; capped at 200 names per call to limit enumeration cost. + """ + get_session_role_flags() + + if isinstance(users, str): + users = frappe.parse_json(users) + if not users: + return [] + + return frappe.get_all( + "User", + filters={"name": ["in", list(users)[:200]]}, + fields=["name", "email", "full_name", "user_image", "user_type"], + ) + + +@frappe.whitelist() +def get_organizations(): + get_session_role_flags() + + organizations = frappe.qb.get_query( + "CRM Organization", + fields=["*"], + order_by="name asc", + distinct=True, + ).run(as_dict=1) + + return organizations diff --git a/crm/api/settings.py b/crm/api/settings.py new file mode 100644 index 0000000..f7e165b --- /dev/null +++ b/crm/api/settings.py @@ -0,0 +1,99 @@ +import frappe + + +@frappe.whitelist() +def create_email_account(data: dict): + service = data.get("service") + service_config = email_service_config.get(service) + if not service_config: + return "Service not supported" + + try: + email_doc = frappe.get_doc( + { + "doctype": "Email Account", + "email_id": data.get("email_id"), + "email_account_name": data.get("email_account_name"), + "service": service, + "enable_incoming": data.get("enable_incoming"), + "enable_outgoing": data.get("enable_outgoing"), + "default_incoming": data.get("default_incoming"), + "default_outgoing": data.get("default_outgoing"), + "email_sync_option": "ALL", + "initial_sync_count": 100, + "create_contact": 1, + "track_email_status": 1, + "use_tls": 1, + "use_imap": 1, + "smtp_port": 587, + **service_config, + } + ) + if service == "Frappe Mail": + email_doc.api_key = data.get("api_key") + email_doc.api_secret = data.get("api_secret") + email_doc.frappe_mail_site = data.get("frappe_mail_site") + email_doc.append_to = "CRM Lead" + else: + email_doc.append("imap_folder", {"append_to": "CRM Lead", "folder_name": "INBOX"}) + email_doc.password = data.get("password") + # validate whether the credentials are correct + email_doc.get_incoming_server() + + # if correct credentials, save the email account + email_doc.save() + except Exception as e: + frappe.throw(str(e)) + + +email_service_config = { + "Frappe Mail": { + "domain": None, + "password": None, + "awaiting_password": 0, + "ascii_encode_password": 0, + "login_id_is_different": 0, + "login_id": None, + "use_imap": 0, + "use_ssl": 0, + "validate_ssl_certificate": 0, + "use_starttls": 0, + "email_server": None, + "incoming_port": 0, + "always_use_account_email_id_as_sender": 1, + "use_tls": 0, + "use_ssl_for_outgoing": 0, + "smtp_server": None, + "smtp_port": None, + "no_smtp_authentication": 0, + }, + "GMail": { + "email_server": "imap.gmail.com", + "use_ssl": 1, + "smtp_server": "smtp.gmail.com", + }, + "Outlook": { + "email_server": "imap-mail.outlook.com", + "use_ssl": 1, + "smtp_server": "smtp-mail.outlook.com", + }, + "Sendgrid": { + "smtp_server": "smtp.sendgrid.net", + "smtp_port": 587, + }, + "SparkPost": { + "smtp_server": "smtp.sparkpostmail.com", + }, + "Yahoo": { + "email_server": "imap.mail.yahoo.com", + "use_ssl": 1, + "smtp_server": "smtp.mail.yahoo.com", + "smtp_port": 587, + }, + "Yandex": { + "email_server": "imap.yandex.com", + "use_ssl": 1, + "smtp_server": "smtp.yandex.com", + "smtp_port": 587, + }, +} diff --git a/crm/api/todo.py b/crm/api/todo.py new file mode 100644 index 0000000..48b8473 --- /dev/null +++ b/crm/api/todo.py @@ -0,0 +1,144 @@ +import frappe +from frappe import _ + +from crm.fcrm.doctype.crm_notification.crm_notification import notify_user + + +def validate(doc, method): + """Require write access when directly assigning a CRM lead or deal.""" + # assign_to and assignment rules insert with ignore_permissions after their + # own permission check. + if ( + doc.is_new() + and doc.reference_type in ["CRM Lead", "CRM Deal"] + and doc.reference_name + and not doc.flags.ignore_permissions + ): + frappe.get_doc(doc.reference_type, doc.reference_name).check_permission("write") + + +def after_insert(doc, method): + if doc.reference_type in ["CRM Lead", "CRM Deal"] and doc.reference_name and doc.allocated_to: + fieldname = "lead_owner" if doc.reference_type == "CRM Lead" else "deal_owner" + # Mirror assign_to: the latest assignment owns the record, overriding any prior owner. + frappe.db.set_value( + doc.reference_type, doc.reference_name, fieldname, doc.allocated_to, update_modified=False + ) + + if doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"] and doc.reference_name and doc.allocated_to: + notify_assigned_user(doc) + + +def on_update(doc, method): + if ( + doc.has_value_changed("status") + and doc.status == "Cancelled" + and doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"] + and doc.reference_name + and doc.allocated_to + ): + notify_assigned_user(doc, is_cancelled=True) + clear_owner_on_unassign(doc) + + +def clear_owner_on_unassign(doc): + # Owner concept only exists for Lead/Deal, not Task. + if doc.reference_type not in ["CRM Lead", "CRM Deal"]: + return + fieldname = "lead_owner" if doc.reference_type == "CRM Lead" else "deal_owner" + # Mirror assign_to: cancelling an assignment clears the owner. Wrinkle (accepted): + # removing one of several manual co-assignees also clears, since owner is single-valued. + frappe.db.set_value(doc.reference_type, doc.reference_name, fieldname, None, update_modified=False) + + +def notify_assigned_user(doc, is_cancelled=False): + _doc = frappe.get_doc(doc.reference_type, doc.reference_name) + owner = frappe.get_cached_value("User", frappe.session.user, "full_name") + notification_text = get_notification_text(owner, doc, _doc, is_cancelled) + + message = ( + _("Your assignment on {0} {1} has been removed by {2}").format( + doc.reference_type, doc.reference_name, owner + ) + if is_cancelled + else _("{0} assigned a {1} {2} to you").format(owner, doc.reference_type, doc.reference_name) + ) + + redirect_to_doctype, redirect_to_name = get_redirect_to_doc(doc) + + notify_user( + { + "owner": frappe.session.user, + "assigned_to": doc.allocated_to, + "notification_type": "Assignment", + "message": message, + "notification_text": notification_text, + "reference_doctype": doc.reference_type, + "reference_docname": doc.reference_name, + "redirect_to_doctype": redirect_to_doctype, + "redirect_to_docname": redirect_to_name, + } + ) + + +def get_notification_text(owner, doc, reference_doc, is_cancelled=False): + name = doc.reference_name + doctype = doc.reference_type + + if doctype.startswith("CRM "): + doctype = doctype[4:].lower() + + if doctype in ["lead", "deal"]: + name = ( + reference_doc.lead_name or name + if doctype == "lead" + else reference_doc.organization or reference_doc.lead_name or name + ) + + if is_cancelled: + return f""" +
+ { _('Your assignment on {0} {1} has been removed by {2}').format( + doctype, + f'{ name }', + f'{ owner }' + ) } +
+ """ + + return f""" +
+ { owner } + { _('assigned a {0} {1} to you').format( + doctype, + f'{ name }' + ) } +
+ """ + + if doctype == "task": + if is_cancelled: + return f""" +
+ { _('Your assignment on task {0} has been removed by {1}').format( + f'{ reference_doc.title }', + f'{ owner }' + ) } +
+ """ + return f""" +
+ { owner } + { _('assigned a new task {0} to you').format( + f'{ reference_doc.title }' + ) } +
+ """ + + +def get_redirect_to_doc(doc): + if doc.reference_type == "CRM Task": + reference_doc = frappe.get_doc(doc.reference_type, doc.reference_name) + return reference_doc.reference_doctype, reference_doc.reference_docname + + return doc.reference_type, doc.reference_name diff --git a/crm/api/user.py b/crm/api/user.py new file mode 100644 index 0000000..56dce53 --- /dev/null +++ b/crm/api/user.py @@ -0,0 +1,238 @@ +import frappe +from frappe import _ +from frappe.auth import LoginAttemptTracker +from frappe.rate_limiter import rate_limit +from frappe.utils.password import check_password, update_password + + +@frappe.whitelist() +@rate_limit(limit=5, seconds=300) # 5 attempts per 5 minutes per user/IP +def change_password(old_password: str, new_password: str): + """ + Change password for the current logged-in user. + Uses Frappe's LoginAttemptTracker for attempt counting/lockout, and rate_limit for API abuse protection. + """ + user = frappe.session.user + if user == "Guest": + frappe.throw(_("You must be logged in to change your password"), frappe.AuthenticationError) + + tracker = LoginAttemptTracker(user) + if not tracker.is_user_allowed(): + frappe.throw(_("Too many failed attempts. Please try again after some time.")) + + if old_password == new_password: + frappe.throw( + _("New password cannot be the same as current password. Please choose a different password.") + ) + + try: + check_password(user, old_password) + except frappe.AuthenticationError: + tracker.add_failure_attempt() + frappe.throw(_("Incorrect current password. Please try again.")) + else: + tracker.add_success_attempt() + + # Validate new password strength (server-side enforcement) + from frappe.core.doctype.user.user import test_password_strength + + result = test_password_strength(new_password) + feedback = result.get("feedback", {}) + if not feedback.get("password_policy_validation_passed", False): + suggestions = feedback.get("suggestions", []) + frappe.throw(_("Password is too weak. {0}").format(" ".join(suggestions) if suggestions else "")) + + update_password(user=user, pwd=new_password, logout_all_sessions=False) + return _("Password Updated Successfully") + + +def needs_password_setup() -> bool: + """Whether the session user has no password of their own and must set one. + + True for users provisioned without a password — the Frappe Cloud site owner, + users created by a script — who are logged into a session they never + authenticated for and so have no way back in once it expires. + + Users who sign in through SSO are excluded: having no password is the + correct state for them, not something to fix. + """ + user = frappe.session.user + + # Checked first: it is the discriminating one, and it short-circuits the + # other queries for everyone who already has a password — which is almost + # everyone, on every CRM page load. + if has_password(user): + return False + + if frappe.get_system_settings("disable_user_pass_login"): + return False + + # Every user carries a `frappe` provider row (Frappe issues one on insert + # so the site can act as an identity provider), so only other providers + # mean the user actually signs in through SSO. + if frappe.db.exists( + "User Social Login", + { + "parenttype": "User", + "parent": user, + "provider": ["!=", "frappe"], + "userid": ["is", "set"], + }, + ): + return False + + return True + + +def has_password(user: str) -> bool: + """Whether a password is stored for the user. + + User passwords are hashed rows in `__Auth` with `encrypted = 0`, so + `get_decrypted_password` (which only looks at encrypted rows) cannot answer + this — hence the direct query. + """ + Auth = frappe.qb.Table("__Auth") + + return bool( + ( + frappe.qb.from_(Auth) + .select(Auth.name) + .where( + (Auth.doctype == "User") + & (Auth.name == user) + & (Auth.fieldname == "password") + & (Auth.encrypted == 0) + ) + .limit(1) + ).run() + ) + + +@frappe.whitelist() +def add_existing_users(users: str | list, role: str = "Sales User"): + """ + Add existing users to the CRM by assigning them a role (Sales User or Sales Manager). + :param users: List of user names to be added + """ + frappe.only_for(["System Manager", "Sales Manager"], True) + is_system_manager = "System Manager" in frappe.get_roles() + + if role == "System Manager" and not is_system_manager: + frappe.throw(_("Only System Managers can assign the System Manager role"), frappe.PermissionError) + + if role == "Sales Manager" and not is_system_manager: + frappe.throw(_("Only System Managers can assign the Sales Manager role"), frappe.PermissionError) + + users = frappe.parse_json(users) + + for user in users: + update_user_role(user, role) + + +@frappe.whitelist() +def update_user_role(user: str, new_role: str): + """ + Update the role of the user to Sales Manager, Sales User, or System Manager. + :param user: The name of the user + :param new_role: The new role to assign (Sales Manager or Sales User) + """ + + frappe.only_for(["System Manager", "Sales Manager"], True) + is_system_manager = "System Manager" in frappe.get_roles() + + if new_role not in ["System Manager", "Sales Manager", "Sales User"]: + frappe.throw(_("Cannot assign this role")) + + user_doc = frappe.get_doc("User", user) + target_roles = [d.role for d in user_doc.roles] + target_is_system_manager = "System Manager" in target_roles + + if new_role == "System Manager" and not is_system_manager: + frappe.throw(_("Only System Managers can assign the System Manager role"), frappe.PermissionError) + + if target_is_system_manager and not is_system_manager: + frappe.throw(_("Only System Managers can modify other System Managers"), frappe.PermissionError) + + if new_role == "Sales Manager" and not is_system_manager: + frappe.throw(_("Only System Managers can assign the Sales Manager role"), frappe.PermissionError) + + if new_role == "System Manager": + user_doc.append_roles("System Manager", "Sales Manager", "Sales User") + user_doc.set("block_modules", []) + if new_role == "Sales Manager": + user_doc.append_roles("Sales Manager", "Sales User") + remove_roles(user_doc, "System Manager") + if new_role == "Sales User": + node = frappe.db.get_value( + "CRM Sales Hierarchy", {"user": user}, ["name", "reports_to"], as_dict=True + ) + if node: + has_reports = frappe.db.exists("CRM Sales Hierarchy", {"reports_to": node.name}) + if has_reports or not node.reports_to: + frappe.throw( + _("Remove this user from the sales hierarchy before changing their role to Sales User") + ) + user_doc.append_roles("Sales User") + remove_roles(user_doc, "Sales Manager", "System Manager") + update_module_in_user(user_doc, "FCRM") + + user_doc.save(ignore_permissions=True) + + +@frappe.whitelist() +def remove_crm_roles_from_user(user: str): + """ + Remove a user means removing Sales User & Sales Manager roles from the user. + :param user: The name of the user to be removed + """ + frappe.only_for(["System Manager", "Sales Manager"], True) + + if user == frappe.session.user: + frappe.throw(_("You cannot remove yourself."), frappe.PermissionError) + + user_doc = frappe.get_doc("User", user) + roles = [d.role for d in user_doc.roles] + + current_user_is_system_manager = "System Manager" in frappe.get_roles() + + if "System Manager" in roles and not current_user_is_system_manager: + frappe.throw(_("Only System Managers can modify other System Managers"), frappe.PermissionError) + + if user_doc.get("role_profiles") or user_doc.get("role_profile_name"): + return frappe.throw( + _("User {0} cannot be removed as it has a Role Profile assigned to it.").format(user) + ) + + if "Sales User" in roles: + remove_roles(user_doc, "Sales User") + if "Sales Manager" in roles: + remove_roles(user_doc, "Sales Manager") + if "System Manager" in roles and current_user_is_system_manager: + remove_roles(user_doc, "System Manager") + update_module_in_user(user_doc, "FCRM") + + user_doc.save(ignore_permissions=True) + + node_name = frappe.db.get_value("CRM Sales Hierarchy", {"user": user}, "name") + if node_name: + frappe.delete_doc("CRM Sales Hierarchy", node_name, ignore_permissions=True) + + frappe.msgprint(_("User {0} has been removed from CRM roles.").format(user)) + + +def remove_roles(self, *roles): + existing_roles = {d.role: d for d in self.get("roles")} + for role in roles: + if role in existing_roles: + self.get("roles").remove(existing_roles[role]) + + +def update_module_in_user(user, module): + block_modules = frappe.get_all( + "Module Def", + fields=["name as module"], + filters={"name": ["!=", module]}, + ) + + if block_modules: + user.set("block_modules", block_modules) diff --git a/crm/api/views.py b/crm/api/views.py new file mode 100644 index 0000000..5f2e5b3 --- /dev/null +++ b/crm/api/views.py @@ -0,0 +1,16 @@ +import frappe +from pypika import Criterion + + +@frappe.whitelist() +def get_views(doctype: str): + View = frappe.qb.DocType("CRM View Settings") + query = ( + frappe.qb.from_(View) + .select("*") + .where(Criterion.any([View.user == "", View.user == frappe.session.user])) + ) + if doctype: + query = query.where(View.dt == doctype) + views = query.run(as_dict=True) + return views diff --git a/crm/api/whatsapp.py b/crm/api/whatsapp.py new file mode 100644 index 0000000..d6a0a35 --- /dev/null +++ b/crm/api/whatsapp.py @@ -0,0 +1,387 @@ +import json + +import frappe +from frappe import _ +from frappe.permissions import add_permission, update_permission_property + +from crm.api.doc import get_assigned_users +from crm.fcrm.doctype.crm_notification.crm_notification import notify_user +from crm.integrations.api import get_contact_lead_or_deal_from_number + +ALLOWED_WHATSAPP_ROLES = ["System Manager", "Sales Manager", "Sales User"] + + +def validate_access(reference_doctype=None, reference_name=None, permtype="read"): + if not any(role in ALLOWED_WHATSAPP_ROLES for role in frappe.get_roles()): + frappe.throw(_("Only sales users can access WhatsApp features."), frappe.PermissionError) + + if reference_doctype and reference_name: + if not frappe.db.exists(reference_doctype, reference_name): + frappe.throw( + _("Reference document {0} {1} does not exist.").format(reference_doctype, reference_name), + frappe.DoesNotExistError, + ) + reference_doc = frappe.get_doc(reference_doctype, reference_name) + if not reference_doc.has_permission(permtype): + frappe.throw( + _("Not permitted to access reference document {0} {1}.").format( + reference_doctype, reference_name + ), + frappe.PermissionError, + ) + return reference_doc + + return None + + +def validate(doc, method): + phone_number = doc.get("from") if doc.type == "Incoming" else doc.get("to") + if phone_number: + try: + name, doctype = get_contact_lead_or_deal_from_number(phone_number) + if doctype and name is not None: + doc.reference_doctype = doctype + doc.reference_name = name + except Exception: + frappe.log_error(frappe.get_traceback(), "CRM WhatsApp: failed to resolve contact from number") + + +def on_update(doc, method): + frappe.publish_realtime( + "whatsapp_message", + { + "reference_doctype": doc.reference_doctype, + "reference_name": doc.reference_name, + }, + ) + + notify_agent(doc) + + +def notify_agent(doc): + if doc.type == "Incoming": + if not doc.reference_doctype or not doc.reference_name: + return + doctype = doc.reference_doctype + if doctype and doctype.startswith("CRM "): + doctype = doctype[4:].lower() + safe_reference_name = frappe.utils.escape_html(doc.reference_name) + notification_text = f""" +
+ {_("You")} + {_("received a whatsapp message in {0}").format(doctype)} + {safe_reference_name} +
+ """ + assigned_users = get_assigned_users(doc.reference_doctype, doc.reference_name) + for user in assigned_users: + notify_user( + { + "owner": doc.owner, + "assigned_to": user, + "notification_type": "WhatsApp", + "message": doc.message, + "notification_text": notification_text, + "reference_doctype": "WhatsApp Message", + "reference_docname": doc.name, + "redirect_to_doctype": doc.reference_doctype, + "redirect_to_docname": doc.reference_name, + } + ) + + +@frappe.whitelist() +def is_whatsapp_enabled(): + if not frappe.db.exists("DocType", "WhatsApp Settings"): + return False + default_outgoing = frappe.get_cached_value( + "WhatsApp Settings", "WhatsApp Settings", "default_outgoing_account" + ) + if not default_outgoing: + return False + status = frappe.get_cached_value("WhatsApp Account", default_outgoing, "status") + return status == "Active" + + +@frappe.whitelist() +def is_whatsapp_installed(): + if not frappe.db.exists("DocType", "WhatsApp Settings"): + return False + return True + + +@frappe.whitelist() +def get_whatsapp_messages(reference_doctype: str, reference_name: str): + reference_doc = validate_access(reference_doctype, reference_name) + # twilio integration app is not compatible with crm app + # crm has its own twilio integration in built + if "twilio_integration" in frappe.get_installed_apps(): + return [] + if not frappe.db.exists("DocType", "WhatsApp Message"): + return [] + messages = [] + + if reference_doctype == "CRM Deal": + lead = reference_doc.get("lead") + if lead: + validate_access("CRM Lead", lead) + messages = frappe.get_all( + "WhatsApp Message", + filters={ + "reference_doctype": "CRM Lead", + "reference_name": lead, + }, + fields=[ + "name", + "type", + "to", + "from", + "content_type", + "message_type", + "attach", + "template", + "use_template", + "message_id", + "is_reply", + "reply_to_message_id", + "creation", + "message", + "status", + "reference_doctype", + "reference_name", + "template_parameters", + "template_header_parameters", + ], + ) + + messages += frappe.get_all( + "WhatsApp Message", + filters={ + "reference_doctype": reference_doctype, + "reference_name": reference_name, + }, + fields=[ + "name", + "type", + "to", + "from", + "content_type", + "message_type", + "attach", + "template", + "use_template", + "message_id", + "is_reply", + "reply_to_message_id", + "creation", + "message", + "status", + "reference_doctype", + "reference_name", + "template_parameters", + "template_header_parameters", + ], + ) + + # Filter messages to get only Template messages + template_messages = [message for message in messages if message["message_type"] == "Template"] + + # Iterate through template messages + for template_message in template_messages: + # Find the template that this message is using + if not frappe.db.exists("WhatsApp Templates", template_message["template"]): + continue + template = frappe.get_doc("WhatsApp Templates", template_message["template"]) + + if template: + template_message["template_name"] = template.template_name + if template_message["template_parameters"]: + parameters = json.loads(template_message["template_parameters"]) + template.template = parse_template_parameters(template.template, parameters) + + template_message["template"] = template.template + if template_message["template_header_parameters"]: + header_parameters = json.loads(template_message["template_header_parameters"]) + template.header = parse_template_parameters(template.header, header_parameters) + template_message["header"] = template.header + template_message["footer"] = template.footer + + # Filter messages to get only reaction messages + reaction_messages = [message for message in messages if message["content_type"] == "reaction"] + reaction_messages.reverse() + + # Iterate through reaction messages + for reaction_message in reaction_messages: + # Find the message that this reaction is reacting to + reacted_message = next( + (m for m in messages if m["message_id"] == reaction_message["reply_to_message_id"]), + None, + ) + + # If the reacted message is found, add the reaction to it + if reacted_message: + reacted_message["reaction"] = reaction_message["message"] + + for message in messages: + from_name = get_from_name(message) if message["from"] else _("You") + message["from_name"] = from_name + # Filter messages to get only replies + reply_messages = [message for message in messages if message["is_reply"]] + + # Iterate through reply messages + for reply_message in reply_messages: + # Find the message that this message is replying to + replied_message = next( + (m for m in messages if m["message_id"] == reply_message["reply_to_message_id"]), + None, + ) + + # If the replied message is found, add the reply details to the reply message + if replied_message: + from_name = get_from_name(reply_message) if replied_message["from"] else _("You") + message = replied_message["message"] + if replied_message["message_type"] == "Template": + message = replied_message["template"] + reply_message["reply_message"] = message + reply_message["header"] = replied_message.get("header") or "" + reply_message["footer"] = replied_message.get("footer") or "" + reply_message["reply_to"] = replied_message["name"] + reply_message["reply_to_type"] = replied_message["type"] + reply_message["reply_to_from"] = from_name + + return [message for message in messages if message["content_type"] != "reaction"] + + +@frappe.whitelist() +def create_whatsapp_message( + reference_doctype: str, + reference_name: str, + message: str, + to: str, + attach: str, + reply_to: str, + content_type: str = "text", +): + validate_access(reference_doctype, reference_name) + doc = frappe.new_doc("WhatsApp Message") + + if reply_to: + if not frappe.db.exists("WhatsApp Message", reply_to): + frappe.throw(_("Referenced WhatsApp message does not exist."), frappe.DoesNotExistError) + reply_doc = frappe.get_doc("WhatsApp Message", reply_to) + if not reply_doc.has_permission("read"): + frappe.throw( + _("Not permitted to access the referenced WhatsApp message."), frappe.PermissionError + ) + validate_access(reply_doc.reference_doctype, reply_doc.reference_name) + doc.update( + { + "is_reply": True, + "reply_to_message_id": reply_doc.message_id, + } + ) + + doc.update( + { + "reference_doctype": reference_doctype, + "reference_name": reference_name, + "message": message or attach, + "to": to, + "attach": attach, + "content_type": content_type, + } + ) + doc.insert(ignore_permissions=True) + return doc.name + + +@frappe.whitelist() +def send_whatsapp_template(reference_doctype: str, reference_name: str, template: str, to: str): + validate_access(reference_doctype, reference_name) + doc = frappe.new_doc("WhatsApp Message") + doc.update( + { + "reference_doctype": reference_doctype, + "reference_name": reference_name, + "message_type": "Template", + "message": "Template message", + "content_type": "text", + "use_template": True, + "template": template, + "to": to, + } + ) + doc.insert(ignore_permissions=True) + return doc.name + + +@frappe.whitelist() +def react_on_whatsapp_message(emoji: str, reply_to_name: str): + validate_access() + if not frappe.db.exists("WhatsApp Message", reply_to_name): + frappe.throw(_("Referenced WhatsApp message does not exist."), frappe.DoesNotExistError) + reply_to_doc = frappe.get_doc("WhatsApp Message", reply_to_name) + + if not reply_to_doc.has_permission("read"): + frappe.throw(_("Not permitted to access the referenced WhatsApp message."), frappe.PermissionError) + + validate_access(reply_to_doc.reference_doctype, reply_to_doc.reference_name) + + to = (reply_to_doc.type == "Incoming" and reply_to_doc.get("from")) or reply_to_doc.to + doc = frappe.new_doc("WhatsApp Message") + doc.update( + { + "reference_doctype": reply_to_doc.reference_doctype, + "reference_name": reply_to_doc.reference_name, + "message": emoji, + "to": to, + "reply_to_message_id": reply_to_doc.message_id, + "content_type": "reaction", + } + ) + doc.insert(ignore_permissions=True) + return doc.name + + +def parse_template_parameters(string, parameters): + for i, parameter in enumerate(parameters, start=1): + placeholder = "{{" + str(i) + "}}" + string = string.replace(placeholder, str(parameter)) + + return string + + +def get_from_name(message): + doc = frappe.get_doc(message["reference_doctype"], message["reference_name"]) + from_name = "" + if message["reference_doctype"] == "CRM Deal": + if doc.get("contacts"): + for c in doc.get("contacts"): + if c.is_primary: + from_name = c.full_name or c.mobile_no + break + else: + from_name = doc.get("lead_name") + else: + from_name = " ".join(name for name in [doc.get("first_name"), doc.get("last_name")] if name) + return from_name + + +def add_roles(): + if "frappe_whatsapp" not in frappe.get_installed_apps(): + return + + role_list = ["Sales Manager", "Sales User"] + doctypes = ["WhatsApp Message", "WhatsApp Templates", "WhatsApp Settings"] + for doctype in doctypes: + for role in role_list: + if frappe.db.exists("Custom DocPerm", {"parent": doctype, "role": role}): + continue + add_permission(doctype, role, 0, "write") + update_permission_property(doctype, role, 0, "create", 1) + update_permission_property(doctype, role, 0, "delete", 1) + update_permission_property(doctype, role, 0, "share", 1) + update_permission_property(doctype, role, 0, "email", 1) + update_permission_property(doctype, role, 0, "print", 1) + update_permission_property(doctype, role, 0, "report", 1) + update_permission_property(doctype, role, 0, "export", 1) diff --git a/crm/config/__init__.py b/crm/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/crowdin.yml b/crm/crowdin.yml new file mode 100644 index 0000000..aebaa70 --- /dev/null +++ b/crm/crowdin.yml @@ -0,0 +1,8 @@ +files: + - source: /crm/locale/main.pot + translation: /crm/locale/%two_letters_code%.po +pull_request_title: "chore: sync translations from crowdin" +pull_request_labels: + - translation +commit_message: "chore: %language% translations" +append_commit_message: false \ No newline at end of file diff --git a/crm/demo/__init__.py b/crm/demo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/demo/activities.py b/crm/demo/activities.py new file mode 100644 index 0000000..eacd371 --- /dev/null +++ b/crm/demo/activities.py @@ -0,0 +1,191 @@ +from datetime import datetime, timedelta + +import frappe + +from crm.demo.utils import ( + build_full_names, + insert_comment, + insert_communication, + insert_version, + resolve_owners, +) + + +def create_demo_activities(lead_names, demo_users): + """Create comments, email communications, and field-change versions for demo leads.""" + session_user, owner_1, owner_2, _ = resolve_owners(demo_users) + _full_names = build_full_names(session_user) + + now = datetime.now() + + comment_names = _create_comments(lead_names, session_user, owner_1, owner_2, _full_names, now) + communication_names = _create_communications(lead_names, session_user, owner_1, _full_names, now) + _create_versions(lead_names, session_user, owner_1, owner_2, now) + + return {"comments": comment_names, "communications": communication_names} + + +def _create_comments(lead_names, session_user, owner_1, owner_2, full_names, now): + comments_data = [ + { + "lead": lead_names[0], + "owner": session_user, + "content": ( + "

Initial discovery call was very productive. Alice's team of ~300 engineers is actively " + "evaluating CRM solutions. She mentioned budget has already been approved — just need " + "to get sign-off from the VP of Sales.

" + ), + "days_ago": 56, # lead[0] deal created at 50 days ago — keep before that + }, + { + "lead": lead_names[1], + "owner": owner_1, + "content": ( + "

Bob mentioned they had a poor experience with their last CRM. He emphasized that " + "onboarding support and data migration are top priorities. Need to loop in the " + "customer success team before the next call.

" + ), + "days_ago": 9, + }, + { + "lead": lead_names[2], + "owner": owner_2, + "content": ( + "

Carol is temporarily unavailable — on leave until end of month. Her colleague " + "Mark is the interim contact. Revisit in 3 weeks with updated pricing deck.

" + ), + "days_ago": 11, + }, + { + "lead": lead_names[3], + "owner": owner_1, + "content": ( + "

Technical demo completed successfully. David's engineering team was impressed with " + "the API and custom field options. He's requesting pricing for 15 seats — send " + "the proposal by Friday.

" + ), + "days_ago": 41, # lead[3] deal created at 37 days ago — keep before that + }, + ] + + return [ + insert_comment( + "CRM Lead", + data["lead"], + data["owner"], + data["content"], + full_names, + now - timedelta(days=data["days_ago"]), + ) + for data in comments_data + ] + + +def _create_communications(lead_names, session_user, owner_1, full_names, now): + # Lead emails used for recipient/sender fields + lead_emails = { + lead_names[0]: "alice.johnson@example.com", + lead_names[1]: "bob.martinez@example.com", + lead_names[3]: "david.lee@example.com", + } + + comms_data = [ + { + "lead": lead_names[0], + "owner": session_user, + "sent_or_received": "Sent", + "subject": "Following up on our discovery call — Acme Corp", + "content": ( + "

Hi Alice,

" + "

It was great speaking with you earlier today. As discussed, I'm sharing our " + "product overview and a link to schedule a full demo with your team.

" + "

Please let me know if you have any questions before we meet.

" + "

Best regards

" + ), + "sender": session_user, + "recipients": lead_emails[lead_names[0]], + "days_ago": 54, # lead[0] deal at 50 days ago — keep before that + }, + { + "lead": lead_names[1], + "owner": owner_1, + "sent_or_received": "Received", + "subject": "Re: CRM Demo Request — Globex Industries", + "content": ( + "

Hi,

" + "

Thanks for reaching out. We'd definitely like to see a live demo, specifically " + "around pipeline management and email integration. Can we schedule something for " + "next Tuesday at 2 PM?

" + "

Best,
Bob Martinez
CEO, Globex Industries

" + ), + "sender": lead_emails[lead_names[1]], + "recipients": owner_1, + "days_ago": 8, + }, + { + "lead": lead_names[3], + "owner": owner_1, + "sent_or_received": "Sent", + "subject": "CRM Pricing Proposal — TechStart Inc (15 seats)", + "content": ( + "

Hi David,

" + "

Great connecting with your team today — really glad the API walkthrough was " + "useful. Attached is our pricing proposal for 15 seats including onboarding and " + "priority support.

" + "

Happy to jump on a call if you have questions.

" + "

Cheers

" + ), + "sender": owner_1, + "recipients": lead_emails[lead_names[3]], + "days_ago": 39, # lead[3] deal at 37 days ago — keep before that + }, + ] + + return [ + insert_communication( + "CRM Lead", data["lead"], data, full_names, now - timedelta(days=data["days_ago"]) + ) + for data in comms_data + ] + + +def _create_versions(lead_names, session_user, owner_1, owner_2, now): + versions_data = [ + { + "lead": lead_names[0], + "owner": session_user, + "changed": [["no_of_employees", "201-500", "501-1000"]], + "days_ago": 52, # lead[0] deal at 50 days ago — keep before that + }, + { + "lead": lead_names[1], + "owner": owner_1, + "changed": [["annual_revenue", None, "10000000"]], + "days_ago": 10, + }, + { + "lead": lead_names[2], + "owner": owner_2, + "changed": [["source", "Email", "Referral"]], + "days_ago": 12, + }, + { + "lead": lead_names[3], + "owner": owner_1, + "changed": [["status", "Contacted", "Qualified"]], + "days_ago": 38, # lead[3] deal at 37 days ago — keep before that + }, + ] + + for data in versions_data: + insert_version( + "CRM Lead", data["lead"], data["owner"], data["changed"], now - timedelta(days=data["days_ago"]) + ) + + +def delete_demo_activities(activity_data): + """Delete communications (comments and versions are cascade-deleted with their lead).""" + communication_names = activity_data.get("communications", []) + for name in communication_names: + if frappe.db.exists("Communication", name): + frappe.delete_doc("Communication", name, ignore_permissions=True, force=True) diff --git a/crm/demo/api.py b/crm/demo/api.py new file mode 100644 index 0000000..6e00e22 --- /dev/null +++ b/crm/demo/api.py @@ -0,0 +1,91 @@ +import json + +import frappe +from frappe.utils.telemetry import capture + +DEMO_STATE_KEY = "crm_demo_data_created" +DEMO_LEADS_KEY = "crm_demo_leads" +DEMO_NOTES_KEY = "crm_demo_notes" +DEMO_TASKS_KEY = "crm_demo_tasks" +DEMO_CALL_LOGS_KEY = "crm_demo_call_logs" +DEMO_ACTIVITIES_KEY = "crm_demo_activities" +DEMO_DEALS_KEY = "crm_demo_deals" + + +def create_demo_data(_args: dict | None = None): + if frappe.db.get_default(DEMO_STATE_KEY): + return + + from crm.demo.activities import create_demo_activities + from crm.demo.call_logs import create_demo_call_logs + from crm.demo.deals import create_demo_deals + from crm.demo.leads import create_demo_leads + from crm.demo.notes import create_demo_notes + from crm.demo.tasks import create_demo_tasks + from crm.demo.users import create_demo_users + + demo_users = create_demo_users() + lead_names = create_demo_leads(demo_users) + note_names = create_demo_notes(lead_names, demo_users) + task_names = create_demo_tasks(lead_names, demo_users) + call_log_names = create_demo_call_logs(lead_names, demo_users) + activity_data = create_demo_activities(lead_names, demo_users) + + from crm.demo.leads import rebackdate_demo_leads + + rebackdate_demo_leads(lead_names, demo_users) + + deal_data = create_demo_deals(lead_names, demo_users) + frappe.db.set_default(DEMO_LEADS_KEY, json.dumps(lead_names)) + frappe.db.set_default(DEMO_NOTES_KEY, json.dumps(note_names)) + frappe.db.set_default(DEMO_TASKS_KEY, json.dumps(task_names)) + frappe.db.set_default(DEMO_CALL_LOGS_KEY, json.dumps(call_log_names)) + frappe.db.set_default(DEMO_ACTIVITIES_KEY, json.dumps(activity_data)) + frappe.db.set_default(DEMO_DEALS_KEY, json.dumps(deal_data)) + frappe.db.set_default(DEMO_STATE_KEY, "1") + + capture("demo_data_created", "crm") + + +@frappe.whitelist() +def clear_demo_data(): + frappe.only_for(["Sales Manager", "System Manager"], True) + + if not frappe.db.get_default(DEMO_STATE_KEY): + return + + from crm.demo.activities import delete_demo_activities + from crm.demo.call_logs import delete_demo_call_logs + from crm.demo.deals import delete_demo_deals + from crm.demo.leads import delete_demo_leads + from crm.demo.notes import delete_demo_notes + from crm.demo.tasks import delete_demo_tasks + from crm.demo.users import DEMO_USER_EMAILS, delete_demo_users + + lead_names = json.loads(frappe.db.get_default(DEMO_LEADS_KEY) or "[]") + note_names = json.loads(frappe.db.get_default(DEMO_NOTES_KEY) or "[]") + task_names = json.loads(frappe.db.get_default(DEMO_TASKS_KEY) or "[]") + call_log_names = json.loads(frappe.db.get_default(DEMO_CALL_LOGS_KEY) or "[]") + activity_data = json.loads(frappe.db.get_default(DEMO_ACTIVITIES_KEY) or "{}") + deal_data = json.loads(frappe.db.get_default(DEMO_DEALS_KEY) or "{}") + delete_demo_deals(deal_data, lead_names) + delete_demo_activities(activity_data) + delete_demo_notes(note_names) + delete_demo_tasks(task_names) + delete_demo_call_logs(call_log_names) + delete_demo_leads(lead_names) + delete_demo_users(DEMO_USER_EMAILS) + frappe.db.set_default(DEMO_LEADS_KEY, None) + frappe.db.set_default(DEMO_NOTES_KEY, None) + frappe.db.set_default(DEMO_TASKS_KEY, None) + frappe.db.set_default(DEMO_CALL_LOGS_KEY, None) + frappe.db.set_default(DEMO_ACTIVITIES_KEY, None) + frappe.db.set_default(DEMO_DEALS_KEY, None) + frappe.db.set_default(DEMO_STATE_KEY, None) + + capture("demo_data_cleared", "crm") + + +@frappe.whitelist() +def get_demo_state(): + return {"demo_data_created": bool(frappe.db.get_default(DEMO_STATE_KEY))} diff --git a/crm/demo/call_logs.py b/crm/demo/call_logs.py new file mode 100644 index 0000000..7e14077 --- /dev/null +++ b/crm/demo/call_logs.py @@ -0,0 +1,122 @@ +import frappe + +from crm.demo.users import DEMO_USERS +from crm.demo.utils import resolve_owners + + +def create_demo_call_logs(lead_names, demo_users): + from datetime import datetime, timedelta + + now = datetime.now() + session_user, owner_1, owner_2, _ = resolve_owners(demo_users) + + # Build rep phone number map from DEMO_USERS mobile numbers + _user_mobile = {u["email"]: u["mobile_no"] for u in DEMO_USERS} + rep_numbers = { + session_user: _user_mobile.get(session_user, "+1 555 100 0001"), + owner_1: _user_mobile.get(owner_1, "+1 555 100 0002"), + owner_2: _user_mobile.get(owner_2, "+1 555 100 0003"), + } + + call_logs_data = [ + { + "type": "Outgoing", + "status": "Completed", + "telephony_medium": "Manual", + "duration": 720, + "from": rep_numbers[session_user], + "to": "+1 555 000 1234", + "caller": session_user, + "start_time": now - timedelta(days=5, hours=2), + "end_time": now - timedelta(days=5, hours=2) + timedelta(seconds=720), + "reference_doctype": "CRM Lead", + "reference_docname": lead_names[0], + }, + { + "type": "Incoming", + "status": "Completed", + "telephony_medium": "Manual", + "duration": 300, + "from": "+1 555 000 1234", + "to": rep_numbers[session_user], + "receiver": session_user, + "start_time": now - timedelta(days=2, hours=1), + "end_time": now - timedelta(days=2, hours=1) + timedelta(seconds=300), + "reference_doctype": "CRM Lead", + "reference_docname": lead_names[0], + }, + { + "type": "Outgoing", + "status": "Completed", + "telephony_medium": "Manual", + "duration": 540, + "from": rep_numbers[owner_1], + "to": "+1 555 000 5678", + "caller": owner_1, + "start_time": now - timedelta(days=4), + "end_time": now - timedelta(days=4) + timedelta(seconds=540), + "reference_doctype": "CRM Lead", + "reference_docname": lead_names[1], + }, + { + "type": "Outgoing", + "status": "No Answer", + "telephony_medium": "Manual", + "duration": 0, + "from": rep_numbers[owner_1], + "to": "+1 555 000 5678", + "caller": owner_1, + "start_time": now - timedelta(days=1), + "end_time": now - timedelta(days=1), + "reference_doctype": "CRM Lead", + "reference_docname": lead_names[1], + }, + { + "type": "Outgoing", + "status": "Completed", + "telephony_medium": "Manual", + "duration": 420, + "from": rep_numbers[owner_2], + "to": "+1 555 000 9012", + "caller": owner_2, + "start_time": now - timedelta(days=3), + "end_time": now - timedelta(days=3) + timedelta(seconds=420), + "reference_doctype": "CRM Lead", + "reference_docname": lead_names[2], + }, + { + "type": "Incoming", + "status": "Completed", + "telephony_medium": "Manual", + "duration": 600, + "from": "+1 555 000 3456", + "to": rep_numbers[owner_1], + "receiver": owner_1, + "start_time": now - timedelta(days=6), + "end_time": now - timedelta(days=6) + timedelta(seconds=600), + "reference_doctype": "CRM Lead", + "reference_docname": lead_names[3], + }, + ] + + created = [] + for data in call_logs_data: + log_owner = data.get("caller") or data.get("receiver") or frappe.session.user + log_ts = data.get("start_time") + log = frappe.get_doc( + {"doctype": "CRM Call Log", "id": frappe.generate_hash(length=10), **data} + ).insert(ignore_permissions=True) + patch = {"owner": log_owner, "modified_by": log_owner} + if log_ts: + patch["creation"] = log_ts + patch["modified"] = log_ts + frappe.db.set_value("CRM Call Log", log.name, patch, update_modified=False) + created.append(log.name) + + return created + + +def delete_demo_call_logs(call_log_names): + for name in call_log_names: + if frappe.db.exists("CRM Call Log", name): + frappe.delete_doc("CRM Call Log", name, ignore_permissions=True, force=True) diff --git a/crm/demo/deals.py b/crm/demo/deals.py new file mode 100644 index 0000000..3bf8e2e --- /dev/null +++ b/crm/demo/deals.py @@ -0,0 +1,348 @@ +from datetime import datetime, timedelta + +import frappe +from frappe.query_builder import DocType + +from crm.demo.utils import ( + backdate, + build_full_names, + fix_auto_records, + insert_comment, + insert_communication, + insert_version, + resolve_owners, +) + + +def create_demo_deals(lead_names, demo_users): + """Convert seven leads into deals and add deal-specific activity.""" + from crm.fcrm.doctype.crm_lead.crm_lead import convert_to_deal + + session_user, owner_1, owner_2, _ = resolve_owners(demo_users) + _full_names = build_full_names(session_user) + + # leads[0] Alice, [3] David, [7] Henry, [8] Iris, [9] Jack → 5 active/won deals + # leads[10] Karen, [11] Leo → 2 lost deals + d_alice = convert_to_deal( + lead=lead_names[0], + deal={"status": "Demo/Making", "deal_value": 120000, "probability": 50, "deal_owner": session_user}, + ) + d_david = convert_to_deal( + lead=lead_names[3], + deal={"status": "Proposal/Quotation", "deal_value": 45000, "probability": 70, "deal_owner": owner_1}, + ) + d_henry = convert_to_deal( + lead=lead_names[7], + deal={"status": "Negotiation", "deal_value": 85000, "probability": 60, "deal_owner": owner_2}, + ) + d_iris = convert_to_deal( + lead=lead_names[8], + deal={"status": "Qualification", "deal_value": 60000, "probability": 35, "deal_owner": session_user}, + ) + d_jack = convert_to_deal( + lead=lead_names[9], + deal={"status": "Won", "deal_value": 175000, "probability": 100, "deal_owner": owner_1}, + ) + d_karen = convert_to_deal( + lead=lead_names[10], + deal={ + "status": "Lost", + "deal_value": 95000, + "probability": 0, + "deal_owner": owner_2, + "lost_reason": "Competition", + "lost_notes": "Prospect chose a competitor offering deeper BI integrations out of the box. Price was not the issue — feature parity was.", + }, + ) + d_leo = convert_to_deal( + lead=lead_names[11], + deal={ + "status": "Lost", + "deal_value": 55000, + "probability": 0, + "deal_owner": session_user, + "lost_reason": "Budget constraints", + "lost_notes": "Q2 budget was cut. Leo said they would revisit in Q4 once headcount hiring is complete. Added to nurture sequence.", + }, + ) + + deal_names_list = [d_alice, d_david, d_henry, d_iris, d_jack, d_karen, d_leo] + # Converted lead indices — must match deal_names_list order + _converted_lead_indices = [0, 3, 7, 8, 9, 10, 11] + # Days ago each deal was created (always after its lead) + _deal_days = [50, 37, 18, 11, 24, 9, 5] + _deal_owners = [session_user, owner_1, owner_2, session_user, owner_1, owner_2, session_user] + # Organization logos keyed by org name (index order: alice, david, henry, iris, jack, karen, leo) + _org_logos = { + "Acme Corp": "/assets/crm/images/demo/acme-corp.png", + "TechStart Inc": "/assets/crm/images/demo/techstart-inc.png", + "PivotTech Solutions": "/assets/crm/images/demo/pivottech-solutions.png", + "ScaleUp Labs": "/assets/crm/images/demo/scaleup-labs.png", + "Meridian Systems": "/assets/crm/images/demo/meridian-systems.png", + "Vertex Analytics": "/assets/crm/images/demo/vertex-analytics.png", + "Forge Digital": "/assets/crm/images/demo/forge-digital.png", + } + now = datetime.now() + + for d_name, days, d_owner, li in zip( + deal_names_list, _deal_days, _deal_owners, _converted_lead_indices, strict=False + ): + ts = now - timedelta(days=days) + backdate("CRM Deal", d_name, d_owner, ts) + org = frappe.db.get_value("CRM Deal", d_name, "organization") + if org: + logo = _org_logos.get(org) + if logo: + frappe.db.set_value("CRM Organization", org, "organization_logo", logo, update_modified=False) + backdate("CRM Organization", org, d_owner, ts) + contacts = frappe.get_all( + "CRM Contacts", filters={"parent": d_name, "parenttype": "CRM Deal"}, pluck="contact" + ) + for contact in contacts: + if contact: + backdate("Contact", contact, d_owner, ts) + backdate("CRM Lead", lead_names[li], d_owner, ts, set_creation=False) + fix_auto_records("CRM Deal", d_name, d_owner, ts) + fix_auto_records("CRM Lead", lead_names[li], d_owner, ts) + + comment_names = _create_deal_comments(deal_names_list, session_user, owner_1, owner_2, _full_names, now) + communication_names = _create_deal_communications( + deal_names_list, session_user, owner_1, _full_names, now + ) + _create_deal_versions(deal_names_list, session_user, owner_1, owner_2, now) + + # Re-backdate modified to last-activity date so active deals sort to the top of the list. + # Index order: alice, david, henry, iris, jack, karen, leo + _deal_last_touched_days = [2, 4, 7, 10, 6, 8, 12] + for d_name, days, d_owner in zip(deal_names_list, _deal_last_touched_days, _deal_owners, strict=False): + backdate("CRM Deal", d_name, d_owner, now - timedelta(days=days), set_creation=False) + + return { + "deals": deal_names_list, + "comments": comment_names, + "communications": communication_names, + } + + +def _create_deal_comments(deal_names, session_user, owner_1, owner_2, full_names, now): + comments_data = [ + { + "deal": deal_names[0], # Alice / Acme Corp + "owner": session_user, + "content": ( + "

Live demo went well — Alice's product team joined and had great questions " + "about workflow automation and bulk import. They want a custom sandbox environment " + "to evaluate with their own data before signing. Following up to arrange access.

" + ), + "days_ago": 3, + }, + { + "deal": deal_names[1], # David / TechStart Inc + "owner": owner_1, + "content": ( + "

David reviewed the proposal with his co-founder. They're happy with the pricing " + "but want a 3-month pilot before committing to annual. Checking with management on " + "whether we can offer a pilot discount.

" + ), + "days_ago": 5, + }, + { + "deal": deal_names[2], # Henry / PivotTech Solutions + "owner": owner_2, + "content": ( + "

Negotiation call with Henry went long but productive. He’s pushing for a 15% " + "discount on the annual plan citing budget constraints. Legal team is now reviewing " + "the contract — decision expected by end of week.

" + ), + "days_ago": 7, + }, + { + "deal": deal_names[3], # Iris / ScaleUp Labs + "owner": session_user, + "content": ( + "

Qualification call completed with Iris and her co-founder. Small but fast-moving " + "team of 8 engineers. Main concerns are API depth and self-serve onboarding. " + "Sending over the developer docs and scheduling a technical deep-dive.

" + ), + "days_ago": 10, + }, + { + "deal": deal_names[4], # Jack / Meridian Systems + "owner": owner_1, + "content": ( + "

Deal closed — Jack signed the contract this morning. Final value $175k annual. " + "Onboarding is scheduled for next Monday. Handoff notes sent to the customer " + "success team. Great result for the quarter!

" + ), + "days_ago": 2, + }, + { + "deal": deal_names[5], # Karen / Vertex Analytics + "owner": owner_2, + "content": ( + "

Karen's team went with a competitor — they had deeper BI integrations that we " + "currently don't support. Not a pricing issue. Flagged to product team for roadmap " + "consideration. Karen asked us to follow up in 6 months.

" + ), + "days_ago": 8, + }, + { + "deal": deal_names[6], # Leo / Forge Digital + "owner": session_user, + "content": ( + "

Budget was cut for the rest of the year — Leo was very apologetic and said the " + "product was exactly what they needed. Re-added to nurture sequence for Q4. " + "Good candidate for a comeback deal.

" + ), + "days_ago": 12, + }, + ] + + return [ + insert_comment( + "CRM Deal", + data["deal"], + data["owner"], + data["content"], + full_names, + now - timedelta(days=data["days_ago"]), + ) + for data in comments_data + ] + + +def _create_deal_communications(deal_names, session_user, owner_1, full_names, now): + # Use a placeholder email for recipients where we don't have a real one handy + comms_data = [ + { + "deal": deal_names[0], + "owner": session_user, + "sent_or_received": "Sent", + "subject": "Sandbox access details — Acme Corp CRM Evaluation", + "content": ( + "

Hi Alice,

" + "

Thanks for joining yesterday's demo — really great session. I've created a " + "dedicated sandbox environment pre-loaded with sample data. Login details and " + "setup instructions are attached.

" + "

Our onboarding specialist Sarah will also be available for a walkthrough " + "session this week — just let us know a convenient time.

" + "

Best regards

" + ), + "sender": session_user, + "recipients": "alice.johnson@example.com", + "days_ago": 2, + }, + { + "deal": deal_names[1], + "owner": owner_1, + "sent_or_received": "Received", + "subject": "Re: CRM Proposal — Pilot terms query", + "content": ( + "

Hi,

" + "

We've reviewed the proposal and the pricing looks good. Before we proceed, " + "could you clarify what's included in the onboarding package? Specifically, we " + "need help migrating ~4,000 contacts from our existing system.

" + "

Also, is there flexibility on the contract start date?

" + "

Thanks,
David Lee
CTO, TechStart Inc

" + ), + "sender": "david.lee@example.com", + "recipients": owner_1, + "days_ago": 4, + }, + ] + + return [ + insert_communication( + "CRM Deal", data["deal"], data, full_names, now - timedelta(days=data["days_ago"]) + ) + for data in comms_data + ] + + +def _create_deal_versions(deal_names, session_user, owner_1, owner_2, now): + versions_data = [ + { + "deal": deal_names[0], # Alice + "owner": session_user, + "changed": [["probability", "30", "50"]], + "days_ago": 4, + }, + { + "deal": deal_names[1], # David + "owner": owner_1, + "changed": [["deal_value", "40000", "45000"]], + "days_ago": 6, + }, + { + "deal": deal_names[2], # Henry + "owner": owner_2, + "changed": [["probability", "50", "60"]], + "days_ago": 8, + }, + { + "deal": deal_names[3], # Iris + "owner": session_user, + "changed": [["probability", "25", "35"]], + "days_ago": 11, + }, + { + "deal": deal_names[4], # Jack + "owner": owner_1, + "changed": [["status", "Ready to Close", "Won"]], + "days_ago": 3, + }, + { + "deal": deal_names[5], # Karen + "owner": owner_2, + "changed": [["status", "Proposal/Quotation", "Lost"]], + "days_ago": 9, + }, + { + "deal": deal_names[6], # Leo + "owner": session_user, + "changed": [["status", "Demo/Making", "Lost"]], + "days_ago": 13, + }, + ] + + for data in versions_data: + insert_version( + "CRM Deal", data["deal"], data["owner"], data["changed"], now - timedelta(days=data["days_ago"]) + ) + + +def delete_demo_deals(deal_data, lead_names): + """ + Delete deals, their linked contacts, organizations, and deal-specific communications. + Comments and Versions are cascade-deleted when the deal is deleted. + """ + communication_names = deal_data.get("communications", []) + deal_names = set(deal_data.get("deals", [])) + + # Find all deals converted from the provided demo leads + if lead_names: + demo_lead_deals = frappe.get_all("CRM Deal", filters={"lead": ["in", lead_names]}, pluck="name") + deal_names.update(demo_lead_deals) + + for name in communication_names: + if frappe.db.exists("Communication", name): + frappe.delete_doc("Communication", name, ignore_permissions=True, force=True) + + for name in deal_names: + if not frappe.db.exists("CRM Deal", name): + continue + # Collect linked contacts and organization before deleting deal + contact_names = frappe.get_all( + "CRM Contacts", filters={"parent": name, "parenttype": "CRM Deal"}, pluck="contact" + ) + org = frappe.db.get_value("CRM Deal", name, "organization") + frappe.delete_doc("CRM Deal", name, ignore_permissions=True, force=True) + for contact in contact_names: + if contact and frappe.db.exists("Contact", contact): + for child_doctype in ("Contact Email", "Contact Phone", "Dynamic Link"): + Child = DocType(child_doctype) + frappe.qb.from_(Child).delete().where(Child.parent == contact).run() + Contact = DocType("Contact") + frappe.qb.from_(Contact).delete().where(Contact.name == contact).run() + if org and frappe.db.exists("CRM Organization", org): + frappe.delete_doc("CRM Organization", org, ignore_permissions=True, force=True) diff --git a/crm/demo/leads.py b/crm/demo/leads.py new file mode 100644 index 0000000..0d90f6e --- /dev/null +++ b/crm/demo/leads.py @@ -0,0 +1,253 @@ +from datetime import datetime, timedelta + +import frappe + +from crm.demo.utils import backdate, fix_auto_records, resolve_owners + +# Days ago each lead was created — index matches leads_data order +_LEAD_DAYS = [58, 52, 47, 43, 38, 34, 28, 22, 17, 30, 13, 8] + +# Last-activity date per lead (used for modified so active leads sort to top). +# Converted leads will be overwritten by create_demo_deals anyway. +# Index: [0]alice [1]bob [2]carol [3]david [4]eve [5]frank [6]grace [7]henry [8]iris [9]jack [10]karen [11]leo +_LEAD_LAST_TOUCHED_DAYS = [50, 8, 11, 37, 38, 34, 28, 18, 11, 24, 9, 5] + + +def create_demo_leads(demo_users): + session_user, owner_1, owner_2, owner_3 = resolve_owners(demo_users) + + leads_data = [ + # [0] Alice — will be converted to a deal + { + "first_name": "Alice", + "last_name": "Johnson", + "email": "alice.johnson@example.com", + "mobile_no": "+1 555 000 1234", + "organization": "Acme Corp", + "website": "https://acmecorp.example.com", + "industry": "Technology", + "job_title": "VP of Engineering", + "status": "Qualified", + "source": "Cold Calling", + "no_of_employees": "201-500", + "annual_revenue": 5000000, + "lead_owner": session_user, + }, + # [1] Bob — stays as a lead + { + "first_name": "Bob", + "last_name": "Martinez", + "email": "bob.martinez@example.com", + "mobile_no": "+1 555 000 5678", + "organization": "Globex Industries", + "website": "https://globexindustries.example.com", + "industry": "Manufacturing", + "job_title": "CEO", + "status": "Contacted", + "source": "Advertisement", + "no_of_employees": "51-200", + "annual_revenue": 8000000, + "lead_owner": owner_1, + }, + # [2] Carol — stays as a lead + { + "first_name": "Carol", + "last_name": "Smith", + "email": "carol.smith@example.com", + "mobile_no": "+1 555 000 9012", + "organization": "BrightPath Ltd", + "website": "https://brightpath.example.com", + "industry": "Consulting", + "job_title": "Head of Operations", + "status": "Nurture", + "source": "Email", + "no_of_employees": "11-50", + "annual_revenue": 1500000, + "lead_owner": owner_2, + }, + # [3] David — will be converted to a deal + { + "first_name": "David", + "last_name": "Lee", + "email": "david.lee@example.com", + "mobile_no": "+1 555 000 3456", + "organization": "TechStart Inc", + "website": "https://techstart.example.com", + "industry": "Technology", + "job_title": "CTO", + "status": "Qualified", + "source": "Reference", + "no_of_employees": "1-10", + "annual_revenue": 500000, + "lead_owner": owner_1, + }, + # [4] Emma — stays as a lead + { + "first_name": "Emma", + "last_name": "Williams", + "email": "emma.williams@example.com", + "mobile_no": "+1 555 000 2345", + "organization": "CloudBase LLC", + "website": "https://cloudbase.example.com", + "industry": "Technology", + "job_title": "Procurement Manager", + "status": "New", + "source": "Website", + "no_of_employees": "51-200", + "annual_revenue": 3000000, + "lead_owner": owner_1, + }, + # [5] Frank — stays as a lead + { + "first_name": "Frank", + "last_name": "Turner", + "email": "frank.turner@example.com", + "mobile_no": "+1 555 000 6789", + "organization": "DataSync Co", + "website": "https://datasync.example.com", + "industry": "Technology", + "job_title": "Head of Sales", + "status": "Nurture", + "source": "Email", + "no_of_employees": "11-50", + "annual_revenue": 2000000, + "lead_owner": owner_2, + }, + # [6] Grace — stays as a lead + { + "first_name": "Grace", + "last_name": "Park", + "email": "grace.park@example.com", + "mobile_no": "+1 555 000 0123", + "organization": "NextWave Corp", + "website": "https://nextwavecorp.example.com", + "industry": "Telecommunications", + "job_title": "COO", + "status": "Contacted", + "source": "Reference", + "no_of_employees": "201-500", + "annual_revenue": 12000000, + "lead_owner": session_user, + }, + # [7] Henry — will be converted to a deal + { + "first_name": "Henry", + "last_name": "Adams", + "email": "henry.adams@example.com", + "mobile_no": "+1 555 000 4567", + "organization": "PivotTech Solutions", + "website": "https://pivottech.example.com", + "industry": "Technology", + "job_title": "CFO", + "status": "Qualified", + "source": "Advertisement", + "no_of_employees": "51-200", + "annual_revenue": 6000000, + "lead_owner": owner_3, + }, + # [8] Iris — will be converted to a deal + { + "first_name": "Iris", + "last_name": "Chen", + "email": "iris.chen@example.com", + "mobile_no": "+1 555 000 7890", + "organization": "ScaleUp Labs", + "website": "https://scaleuplabs.example.com", + "industry": "Technology", + "job_title": "VP Engineering", + "status": "Qualified", + "source": "Cold Calling", + "no_of_employees": "1-10", + "annual_revenue": 750000, + "lead_owner": session_user, + }, + # [9] Jack — will be converted to a deal + { + "first_name": "Jack", + "last_name": "Morrison", + "email": "jack.morrison@example.com", + "mobile_no": "+1 555 000 8901", + "organization": "Meridian Systems", + "website": "https://meridiansystems.example.com", + "industry": "Defense", + "job_title": "CEO", + "status": "Qualified", + "source": "Reference", + "no_of_employees": "501-1000", + "annual_revenue": 25000000, + "lead_owner": owner_1, + }, + # [10] Karen — will be converted to a Lost deal + { + "first_name": "Karen", + "last_name": "White", + "email": "karen.white@example.com", + "mobile_no": "+1 555 000 2222", + "organization": "Vertex Analytics", + "website": "https://vertexanalytics.example.com", + "industry": "Financial Services", + "job_title": "Director of Strategy", + "status": "Qualified", + "source": "Advertisement", + "no_of_employees": "201-500", + "annual_revenue": 9000000, + "lead_owner": owner_2, + }, + # [11] Leo — will be converted to a Lost deal + { + "first_name": "Leo", + "last_name": "Brown", + "email": "leo.brown@example.com", + "mobile_no": "+1 555 000 3333", + "organization": "Forge Digital", + "website": "https://forgedigital.example.com", + "industry": "Advertising", + "job_title": "Head of Product", + "status": "Qualified", + "source": "Cold Calling", + "no_of_employees": "51-200", + "annual_revenue": 4000000, + "lead_owner": session_user, + }, + ] + + now = datetime.now() + created = [] + for data, days in zip(leads_data, _LEAD_DAYS, strict=False): + ts = now - timedelta(days=days) + owner = data["lead_owner"] + lead = frappe.get_doc({"doctype": "CRM Lead", **data}).insert(ignore_permissions=True) + backdate("CRM Lead", lead.name, owner, ts) + fix_auto_records("CRM Lead", lead.name, owner, ts) + created.append(lead.name) + + return created + + +def rebackdate_demo_leads(lead_names, demo_users): + """Re-apply modified timestamps after activity creation resets them.""" + session_user, owner_1, owner_2, owner_3 = resolve_owners(demo_users) + # Must match the lead_owner assignments in create_demo_leads (same index order) + _owners = [ + session_user, + owner_1, + owner_2, + owner_1, + owner_1, + owner_2, + session_user, + owner_3, + session_user, + owner_1, + owner_2, + session_user, + ] + now = datetime.now() + for name, days, owner in zip(lead_names, _LEAD_LAST_TOUCHED_DAYS, _owners, strict=False): + backdate("CRM Lead", name, owner, now - timedelta(days=days), set_creation=False) + + +def delete_demo_leads(lead_names): + for name in lead_names: + if frappe.db.exists("CRM Lead", name): + frappe.delete_doc("CRM Lead", name, ignore_permissions=True, force=True) diff --git a/crm/demo/notes.py b/crm/demo/notes.py new file mode 100644 index 0000000..4a71e48 --- /dev/null +++ b/crm/demo/notes.py @@ -0,0 +1,86 @@ +from datetime import datetime, timedelta + +import frappe + +from crm.demo.utils import backdate, resolve_owners + + +def create_demo_notes(lead_names, demo_users): + session_user, owner_1, owner_2, _ = resolve_owners(demo_users) + now = datetime.now() + + # Two notes per lead — a call summary and a follow-up observation + notes_data = [ + { + "title": "Initial Discovery Call", + "content": "

Had a productive intro call. They're evaluating solutions for Q3. Key stakeholders include the CTO and VP of Sales. Budget is pre-approved. Follow up with a tailored proposal.

", + "reference_doctype": "CRM Lead", + "reference_docname": lead_names[0], + "owner": session_user, + "days_ago": 56, + }, + { + "title": "Pain Points Identified", + "content": "

Current system lacks reporting and integrations. Team size is growing fast — 40% headcount increase this year. Decision timeline is 6–8 weeks.

", + "reference_doctype": "CRM Lead", + "reference_docname": lead_names[0], + "owner": session_user, + "days_ago": 51, + }, + { + "title": "Demo Feedback", + "content": "

Very positive response to the pipeline view. Asked about custom fields and API access. Needs sign-off from CFO before moving forward.

", + "reference_doctype": "CRM Lead", + "reference_docname": lead_names[1], + "owner": owner_1, + "days_ago": 46, + }, + { + "title": "Competitor Mentioned", + "content": "

Currently trialing a competitor. Our differentiators: better UX, open source, and lower TCO. Send a comparison doc before next call.

", + "reference_doctype": "CRM Lead", + "reference_docname": lead_names[1], + "owner": owner_1, + "days_ago": 39, + }, + { + "title": "Requirements Gathered", + "content": "

Needs CRM + email integration + team collaboration. Currently using spreadsheets. Strong motivation to move to a structured system this quarter.

", + "reference_doctype": "CRM Lead", + "reference_docname": lead_names[2], + "owner": owner_2, + "days_ago": 44, + }, + { + "title": "Pricing Discussion", + "content": "

Walked through pricing tiers. They prefer an annual plan. Requested a 2-week trial extension. Champion is very engaged — internal buy-in looks strong.

", + "reference_doctype": "CRM Lead", + "reference_docname": lead_names[3], + "owner": owner_1, + "days_ago": 40, + }, + ] + + created = [] + for data in notes_data: + ts = now - timedelta(days=data["days_ago"]) + owner = data["owner"] + note = frappe.get_doc( + { + "doctype": "FCRM Note", + "title": data["title"], + "content": data["content"], + "reference_doctype": data["reference_doctype"], + "reference_docname": data["reference_docname"], + } + ).insert(ignore_permissions=True) + backdate("FCRM Note", note.name, owner, ts) + created.append(note.name) + + return created + + +def delete_demo_notes(note_names): + for name in note_names: + if frappe.db.exists("FCRM Note", name): + frappe.delete_doc("FCRM Note", name, ignore_permissions=True, force=True) diff --git a/crm/demo/tasks.py b/crm/demo/tasks.py new file mode 100644 index 0000000..4fa18c5 --- /dev/null +++ b/crm/demo/tasks.py @@ -0,0 +1,96 @@ +import frappe + +from crm.demo.utils import backdate, resolve_owners + + +def create_demo_tasks(lead_names, demo_users): + from datetime import date, datetime, timedelta + + today = date.today() + now = datetime.now() + session_user, owner_1, owner_2, _ = resolve_owners(demo_users) + + tasks_data = [ + { + "title": "Send proposal document", + "priority": "High", + "status": "Todo", + "assigned_to": session_user, + "due_date": today + timedelta(days=2), + "reference_doctype": "CRM Lead", + "reference_docname": lead_names[0], + "description": "Prepare and send a tailored proposal covering pricing, integrations, and onboarding timeline.", + "days_ago": 1, + }, + { + "title": "Schedule technical demo", + "priority": "Medium", + "status": "In Progress", + "assigned_to": owner_1, + "due_date": today + timedelta(days=5), + "reference_doctype": "CRM Lead", + "reference_docname": lead_names[0], + "description": "Coordinate with the CTO to set up a 1-hour technical deep-dive session.", + "days_ago": 3, + }, + { + "title": "Follow up on trial access", + "priority": "High", + "status": "Todo", + "assigned_to": owner_1, + "due_date": today + timedelta(days=1), + "reference_doctype": "CRM Lead", + "reference_docname": lead_names[1], + "description": "Check in on trial progress and address any blockers before the next call.", + "days_ago": 2, + }, + { + "title": "Send competitor comparison doc", + "priority": "Medium", + "status": "Done", + "assigned_to": owner_2, + "due_date": today - timedelta(days=2), + "reference_doctype": "CRM Lead", + "reference_docname": lead_names[1], + "description": "Share the feature comparison sheet highlighting our advantages over the competitor they're trialing.", + "days_ago": 6, + }, + { + "title": "Set up trial environment", + "priority": "High", + "status": "In Progress", + "assigned_to": owner_2, + "due_date": today + timedelta(days=3), + "reference_doctype": "CRM Lead", + "reference_docname": lead_names[2], + "description": "Provision a trial instance with sample data pre-loaded for their team to evaluate.", + "days_ago": 3, + }, + { + "title": "Confirm annual plan details", + "priority": "Low", + "status": "Backlog", + "assigned_to": session_user, + "due_date": today + timedelta(days=10), + "reference_doctype": "CRM Lead", + "reference_docname": lead_names[3], + "description": "Finalize seat count and send updated quote for annual subscription.", + "days_ago": 1, + }, + ] + + created = [] + for data in tasks_data: + owner = data["assigned_to"] + ts = now - timedelta(days=data.pop("days_ago")) + task = frappe.get_doc({"doctype": "CRM Task", **data}).insert(ignore_permissions=True) + backdate("CRM Task", task.name, owner, ts) + created.append(task.name) + + return created + + +def delete_demo_tasks(task_names): + for name in task_names: + if frappe.db.exists("CRM Task", name): + frappe.delete_doc("CRM Task", name, ignore_permissions=True, force=True) diff --git a/crm/demo/users.py b/crm/demo/users.py new file mode 100644 index 0000000..a62de40 --- /dev/null +++ b/crm/demo/users.py @@ -0,0 +1,89 @@ +from datetime import datetime, timedelta + +import frappe +from frappe.query_builder import DocType + +DEMO_USERS = [ + { + "email": "sarah.demo@example.com", + "first_name": "Sarah", + "last_name": "Connor", + "mobile_no": "+1 555 100 0002", + "roles": ["Sales Manager", "Sales User"], + "avatar": "/assets/crm/images/demo/sarah-connor.png", + }, + { + "email": "john.demo@example.com", + "first_name": "John", + "last_name": "Parker", + "mobile_no": "+1 555 100 0003", + "roles": ["Sales User"], + "avatar": "/assets/crm/images/demo/john-parker.png", + }, + { + "email": "emily.demo@example.com", + "first_name": "Emily", + "last_name": "Chen", + "mobile_no": "+1 555 100 0004", + "roles": ["Sales User"], + "avatar": "/assets/crm/images/demo/emily-chen.png", + }, +] + +DEMO_USER_EMAILS = [u["email"] for u in DEMO_USERS] + + +def create_demo_users(): + for user_data in DEMO_USERS: + if not frappe.db.exists("User", user_data["email"]): + frappe.get_doc( + { + "doctype": "User", + "email": user_data["email"], + "first_name": user_data["first_name"], + "last_name": user_data["last_name"], + "send_welcome_email": 0, + "user_type": "System User", + "mobile_no": user_data["mobile_no"], + "user_image": user_data["avatar"], + "roles": [{"role": r} for r in user_data["roles"]], + } + ).insert(ignore_permissions=True) + + # Backdate auto-created contacts and set their image + _ts = datetime.now() - timedelta(days=70) + _avatar_by_email = {u["email"]: u["avatar"] for u in DEMO_USERS} + contact_names = frappe.get_all( + "Contact", filters={"user": ["in", DEMO_USER_EMAILS]}, fields=["name", "user"] + ) + for row in contact_names: + frappe.db.set_value( + "Contact", + row.name, + {"image": _avatar_by_email.get(row.user, ""), "creation": _ts, "modified": _ts}, + update_modified=False, + ) + + return DEMO_USER_EMAILS + + +def delete_demo_users(demo_user_emails): + # delete notifications first — they link to users and block deletion + Notification = DocType("CRM Notification") + frappe.qb.from_(Notification).delete().where( + (Notification.from_user.isin(demo_user_emails)) | (Notification.to_user.isin(demo_user_emails)) + ).run() + + # collect contact names before deleting users — User.on_trash NULLs contact.user + contact_names = frappe.get_all("Contact", filters={"user": ["in", demo_user_emails]}, pluck="name") + + for email in demo_user_emails: + if frappe.db.exists("User", email): + frappe.delete_doc("User", email, ignore_permissions=True, force=True) + + if contact_names: + for child_doctype in ("Contact Email", "Contact Phone", "Dynamic Link"): + Child = DocType(child_doctype) + frappe.qb.from_(Child).delete().where(Child.parent.isin(contact_names)).run() + Contact = DocType("Contact") + frappe.qb.from_(Contact).delete().where(Contact.name.isin(contact_names)).run() diff --git a/crm/demo/utils.py b/crm/demo/utils.py new file mode 100644 index 0000000..ca28280 --- /dev/null +++ b/crm/demo/utils.py @@ -0,0 +1,117 @@ +import json + +import frappe +from frappe.query_builder import DocType + + +def resolve_owners(demo_users): + """Return (session_user, owner_1, owner_2, owner_3) each falling back to session_user.""" + s = frappe.session.user + if s in ("Administrator", "Guest"): + s = demo_users[0] if demo_users else s + owners = [demo_users[i] if i < len(demo_users) else s for i in range(3)] + return (s, *owners) + + +def build_full_names(session_user): + """Return {email: full_name} mapping for all demo users plus session_user.""" + from crm.demo.users import DEMO_USERS + + names = {u["email"]: f"{u['first_name']} {u['last_name']}" for u in DEMO_USERS} + names[session_user] = frappe.utils.get_fullname(session_user) + return names + + +def backdate(doctype, name, owner, ts, *, set_creation=True): + """Set owner, modified_by, modified (and optionally creation) without triggering hooks.""" + fields = {"owner": owner, "modified_by": owner, "modified": ts} + if set_creation: + fields["creation"] = ts + frappe.db.set_value(doctype, name, fields, update_modified=False) + + +def fix_auto_records(doctype, docname, owner, ts): + """Backdate the Version and assignment Comment auto-created with owner='Administrator' on insert.""" + Version = DocType("Version") + ( + frappe.qb.update(Version) + .set(Version.owner, owner) + .set(Version.modified_by, owner) + .set(Version.creation, ts) + .set(Version.modified, ts) + .where( + (Version.ref_doctype == doctype) + & (Version.docname == docname) + & (Version.owner == "Administrator") + ) + .run() + ) + Comment = DocType("Comment") + ( + frappe.qb.update(Comment) + .set(Comment.owner, owner) + .set(Comment.modified_by, owner) + .set(Comment.comment_email, owner) + .set(Comment.creation, ts) + .set(Comment.modified, ts) + .where( + (Comment.reference_doctype == doctype) + & (Comment.reference_name == docname) + & (Comment.owner == "Administrator") + ) + .run() + ) + + +def insert_comment(reference_doctype, reference_name, owner, content, full_names, ts): + """Insert a Comment, backdate it, and return its name.""" + comment = frappe.get_doc( + { + "doctype": "Comment", + "comment_type": "Comment", + "reference_doctype": reference_doctype, + "reference_name": reference_name, + "content": content, + "comment_email": owner, + "comment_by": full_names.get(owner, owner), + } + ).insert(ignore_permissions=True) + backdate("Comment", comment.name, owner, ts) + return comment.name + + +def insert_communication(reference_doctype, reference_name, data, full_names, ts): + """Insert an email Communication, backdate it, and return its name.""" + comm = frappe.get_doc( + { + "doctype": "Communication", + "communication_type": "Communication", + "communication_medium": "Email", + "status": "Linked", + "sent_or_received": data["sent_or_received"], + "reference_doctype": reference_doctype, + "reference_name": reference_name, + "subject": data["subject"], + "content": data["content"], + "sender": data["sender"], + "sender_full_name": full_names.get(data["sender"], data["sender"]), + "recipients": data["recipients"], + "communication_date": ts, + } + ).insert(ignore_permissions=True) + backdate("Communication", comm.name, data["owner"], ts) + return comm.name + + +def insert_version(ref_doctype, docname, owner, changed, ts): + """Insert a Version record, backdate it, and return its name.""" + version = frappe.get_doc( + { + "doctype": "Version", + "ref_doctype": ref_doctype, + "docname": docname, + "data": json.dumps({"changed": changed}), + } + ).insert(ignore_permissions=True) + backdate("Version", version.name, owner, ts) + return version.name diff --git a/crm/domain_enrichment/README.md b/crm/domain_enrichment/README.md new file mode 100644 index 0000000..945a7a2 --- /dev/null +++ b/crm/domain_enrichment/README.md @@ -0,0 +1,316 @@ +# Domain Enrichment + +Auto-enrich a CRM Lead, Deal or Organization from its **website** — no paid data +providers. Given a domain, the engine crawls the public site, runs **admin-tunable, +rule-based** extractors (company name, description, logo, industry, +emails, phones, social links), records everything with full provenance, and fills +the mapped CRM fields according to configurable write policies. + +The guiding principle: **config is data, the engine is code.** Everything a +sales-ops admin would tune — keywords, social patterns, +field mappings, crawl limits, enabled sources, the SSRF allow/block lists — lives +in **doctypes you edit in the desk**, never in Python. The engine knows no keywords +itself; it loads rules and executes them. + +--- + +## What & why + +- **Domain-based.** The only input is a website URL. No API keys, no per-record + cost, no third-party enrichment vendor. +- **Rule-based / public-data.** Industry and social profiles + are all matched by **rules an admin owns** (`CRM Enrichment Rule`). + The mechanics (crawling, HTML parsing, the favicon scorer, JSON-LD parsing, + readability diagnosis, email/phone regexes) are pure code because they are + algorithms, not tunable knowledge. +- **Provenance preserved.** Every extracted value records `{value, source, method}` + — which page it came from and how it was found — so a reviewer can judge how much + to trust it. The full provenance JSON is stored on each run. +- **Self-contained.** The only outbound traffic is fetching the target site itself — + no third-party APIs. +- **Framework-native.** Outbound HTTP uses `frappe.utils.get_request_session`; + background work uses `frappe.enqueue`; progress streams over + `frappe.publish_realtime`. + +--- + +## Architecture + +``` +DESK ADMIN (data) ENGINE (code, rule-agnostic) RESULT (data) + CRM Enrichment Settings (Single) ─┐ crm/domain_enrichment/ + CRM Enrichment Rule (+Pattern) ───┼─►get_config()──► config.py CRM Enrichment Run + CRM Enrichment Field Mapping ─────┘ http.py (session+SSRF+cap) (Dynamic Link to + crawler.py (BFS; caps from cfg) Lead/Deal/Org) + │ extractors.py (rule executors) ▲ + ▼ pipeline.py → EnrichmentResult │ + bench migrate syncs api.py (@whitelist) / tasks.py mapper.py writes + native fields onto (enqueue + realtime) mapped fields onto + Lead/Deal/Organization the record (policy) +``` + +### Module map (`crm/domain_enrichment/`) + +| File | Responsibility | +|---|---| +| `config.py` | `get_config()` → `EnrichmentConfig` (settings + `Rule`/`Mapping` objects), built on demand (not cached). `get_settings()` / `auto_enrich_enabled_for()` are the cheap Settings-only reads for hot paths. | +| `http.py` | `fetch(url, cfg)` on the framework session; the **SSRF guard** (`validate_url`); byte cap; HTML-only filter; never-raise `(status, html, error, final_url)` contract. | +| `crawler.py` | Same-domain, depth-limited BFS. Caps / link-priority order / skip patterns from config. | +| `extractors.py` | **Generic rule executor** (`apply_keyword_rules`) + the pure mechanics. No literal keyword tables. | +| `result.py` | `EnrichmentResult` and its `{value, source, method}` provenance schema. Pure dataclasses, no framework import. | +| `pipeline.py` | `run(website, cfg, progress)` orchestrates crawl → extract → assemble `EnrichmentResult`. Never writes to the DB. | +| `mapper.py` | `apply_to_document(doc, result, cfg)` — the **single** result→CRM-field authority, driven by Field Mapping records + write policies. | +| `tasks.py` | `run_enrichment` (enqueued worker) + `write_run` (the **single** run-history writer). Streams realtime progress; never raises. | +| `api.py` | Whitelisted `enrich` (enqueue) + `retry` (re-run from a Run). Type-annotated; permission- and rate-limited. | +| `cross_record.py` | `copy_enrichment_from_organization` — the one link-time Org→Lead/Deal copy. | +| `install.py` | Idempotent seeder: translates the original constant tables into Rule + Field Mapping records. | + +--- + +## Configuration guide + +Three doctypes hold all the tunable knowledge. `get_config()` reads them on demand +(no cache), so an edit takes effect on the very next enrichment without a restart. + +### 1. CRM Enrichment Settings (Single) + +| Field | Meaning | +|---|---| +| `enabled` | Master on/off for the whole feature, across Lead, Deal and Organization. | +| `auto_enrich` | When `1`, a newly-created CRM **Lead, Deal or Organization** that has a website is enriched automatically in a background job (`after_insert`). A new Deal with a new Organization enriches both (each crawls and writes its own fields). Default `0` — enrichment is otherwise manual via the Enrich button. | +| `max_pages` / `max_depth` | Crawl breadth/depth caps (BFS). | +| `request_timeout` | Per-request timeout (seconds). | +| `max_download_bytes` | Hard cap on bytes read per page (streamed). | +| `retry_count` | Transient-error retries on the session. | +| `user_agent` | Crawler User-Agent header. | +| `allowed_domains` / `blocked_domains` (child) | SSRF allow/block lists (subdomain-aware). A blocked host is always rejected; if an allow list exists, only listed hosts pass. | +| `link_priority_order` (child) | `keyword` + `weight`: a crawl-ordering hint — links whose URL/anchor contain these are fetched first (higher weight = sooner). Defaults are seeded on install. | +| `skip_patterns` (child) | URL substrings/regexes to never crawl. | + +If the Single has never been saved, the engine falls back to sane defaults +(`config.DEFAULT_SETTINGS`). + +### 2. CRM Enrichment Rule (+ CRM Enrichment Rule Pattern) + +The heart of the knowledge base. One record = one rule. + +| Field | Meaning | +|---|---| +| `rule_name` | Unique name (the record's id). | +| `rule_type` | `Industry` / `Social`. | +| `enabled` | Disabled rules are skipped by the config builder. | +| `target_value` | The label a match emits (tech name, network). Ignored for `Industry`. | +| `industry` | (Industry rules only) the linked `CRM Industry` a match classifies to. | +| `weight` | Multiplier on the hit count (industry scoring). | +| `match_scope` | `Headline` / `Full Text` / `HTML` / `Headers` / `URL` — which slice of the page the rule matches. | +| `patterns` (child) | One or more `pattern` strings; `is_regex` toggles substring vs. regex. Substrings are matched case-insensitively. | + +### 3. CRM Enrichment Field Mapping + +The single source of result→field truth. One record = one field written. + +| Field | Meaning | +|---|---| +| `enabled` | Disabled mappings are skipped. | +| `source_key` | One of the 13 frozen result keys (below). | +| `target_doctype` | `CRM Lead` / `CRM Deal` / `CRM Organization`. | +| `target_fieldname` | The field on that doctype to fill (validated to exist). | +| `write_policy` | `Fill if empty` / `Always refresh` / `Override defaults` (below). | +| `default_values` | (Override defaults) newline-separated values treated as "unset". | +| `create_missing_link` | (Link fields) auto-create the linked master if missing (e.g. a new `CRM Industry`). | + +**The 13 `source_key`s:** `company_name`, `description`, `logo`, `industry`, +`primary_email`, `primary_phone`, `secondary_phone`, `linkedin`, +`twitter`, `github`, `facebook`, `instagram`, `youtube`. + +**Write policies:** +- **Fill if empty** — write only when the target field is currently empty. +- **Always refresh** — overwrite (or clear) with the fresh value, even if the field + had a previous value. Used for fully engine-derived fields that should always + reflect the latest crawl. +- **Override defaults** — treat the listed `default_values` (plus empty) as "unset" + and fill over them, but respect any real user value. Useful for a field that ships + with a meaningless placeholder default that enrichment should replace. + +### Worked examples + +**Add a new industry.** Create a `CRM Industry` (or let `create_missing_link` do it), +then a `CRM Enrichment Rule`: `rule_type = Industry`, `industry = `, +`match_scope = Headline`, and add patterns (e.g. `biotech`, `genomics`). Save — the +classifier picks it up on the next run. + +**Add a social network.** New `CRM Enrichment Rule`: `rule_type = Social`, +`target_value = "mastodon"`, `match_scope = HTML`, and a regex pattern +(`is_regex = 1`) such as `@[A-Za-z0-9_]+@mastodon\.social`. + +**Remap a result field / change a write policy.** Edit the relevant +`CRM Enrichment Field Mapping` — change `target_fieldname`, or flip `write_policy` +from `Fill if empty` to `Always refresh`. To stop a field being written, untick +`enabled` on its mapping. + +**Tune crawl limits / SSRF.** Raise `max_pages`/`max_depth` for deeper crawls; add a +`skip_patterns` row (e.g. `/blog/`) to skip noisy sections; add a `blocked_domains` +row to hard-block a host, or `allowed_domains` rows to restrict crawling to an +allow-list only. + +--- + +## How a run works (end to end) + +1. **Trigger** — the SPA `EnrichFromWebsite` button (or the desk form button) calls + the whitelisted `api.enrich(reference_doctype, reference_name)`. +2. **Validate + enqueue** — `enrich` checks the doctype is enabled, the user has + `write` permission, and the record has a `website`, then `frappe.enqueue`s + `tasks.run_enrichment` on the `long` queue with a per-doc `job_id` and + `deduplicate=True` (a second click while one is in-flight is a no-op). +3. **Crawl** — `pipeline.run` builds the session, runs the BFS crawler (SSRF-guarded + `http.fetch` per page), and emits progress callbacks. +4. **Rule execution** — the extractors run the config rules: industry classification, + social profiles, plus the mechanics (company info, emails, phones). +5. **Map** — `mapper.apply_to_document` writes the mapped fields onto the origin doc + per each mapping's write policy; the doc is saved (normal, permission-respecting). +6. **Run record** — `tasks.write_run` persists exactly one `CRM Enrichment Run` with + the summary fields + full `raw_json` provenance. +7. **Realtime** — progress and the terminal result stream to the triggering user over + the `domain_enrichment_progress` event. + +Progress event payload (mirrors `crm/api/whatsapp.py`): + +```json +{"reference_doctype": "...", "reference_name": "...", "step": 3, "total": 9, + "message": "...", "status": "running|completed|error", "payload": {}} +``` + +The terminal `completed` event's `payload` carries `{filled_fields, notes, **result.flat()}`. + +### Run lifecycle (state machine) + +``` + enqueue (dedupe) worker start all steps ok + [Queued] ───────────► [Running] ─────────────────────────────► [Completed] + │ + │ exception / blocked / unreachable + ▼ + [Failed] (reason in notes; frappe.log_error) +``` + +`tasks.run_enrichment` never raises to the worker: on any exception the Run is +written as `Failed` (with the traceback truncated into `notes`), the error is logged +via `frappe.log_error`, and an `error` event is published. + +--- + +## Cross-record behavior + +There is exactly **one** cross-record copy: +`cross_record.copy_enrichment_from_organization(target_doc)`. When a **Lead** or +**Deal** links an already-enriched **Organization**, the Organization's stored +enriched fields are copied onto the **empty** fields of the Lead/Deal +(fill-empty semantics, even for mappings normally configured "Always refresh"), so +user-entered data is never touched. + +- **Lead seam:** `CRMLead.create_organization` (existing-org branch). +- **Deal seam:** `CRMDeal.validate` when `organization` is set/changed. +- An Organization is "enriched" if it has a **completed** `CRM Enrichment Run`, or + (fallback) any populated native enriched field. + +**No background fan-out.** The copy is synchronous, in-request, Organization → +Lead/Deal only. There is no job, no propagation to other linked records, and no +write back to the Organization. The field-writing reuses `mapper.apply_to_document` +(the single authority) — no second copy implementation exists. + +--- + +## SSRF Guard + +- **SSRF guard (`http.validate_url`).** Mandatory and not provided by the framework. + It rejects non-`http(s)` schemes, resolves the hostname, and rejects any URL that + resolves to a loopback / private / link-local / reserved / multicast / unspecified + address — including the cloud-metadata endpoint `169.254.169.254`. It honors the + `blocked_domains` / `allowed_domains` lists and **re-validates after every + redirect** (redirects are followed manually). The private-network rejection is + **unconditional** — there is deliberately no setting to disable it (a crawler that + fetches user-supplied URLs must never be turnable into an internal-network probe). +- **DNS-rebinding TOCTOU — closed by IP pinning.** The guard validates the resolved + addresses and the connection is then made to one of those exact addresses + (`http._pinned_get`): the URL netloc is rewritten to the validated IP while the + Host header, TLS SNI and certificate verification stay on the original hostname + (urllib3 `server_hostname`). A host that alternates DNS answers (short TTL) can no + longer pass validation with a public IP and serve the request from a private one. + Each redirect hop is re-validated and re-pinned. +- **Permissions.** `api.enrich` enforces the Settings allow-list and + `doc.check_permission("write")` — a user who cannot edit the record cannot enrich + it. The worker save is a normal permission-respecting save. `create_missing_link` + auto-creation is permission-respecting too: `_ensure_link_target` only creates a + master (`CRM Industry`) when the enriching user has create rights on it, so a + scraped value can never escalate into an insert the user couldn't do by hand. The + only `ignore_permissions` write is the engine-owned `CRM Enrichment Run` audit log + — never a cross-record write to data the user can't edit. +- **Rate limiting.** Every entry point that triggers a fetch — `enrich` and + `retry` — is decorated with `rate_limit(limit=ENRICH_RATE_LIMIT, seconds=60)` + (per user, per route). Both only enqueue a per-doc-deduplicated job. + +--- + +## Extending in code + +- **Add an extractor mechanic** (e.g. a new heuristic): add a pure function to + `extractors.py`, call it from `pipeline.run`, and surface its output on + `EnrichmentResult` (`result.py`). Keep tunable knowledge in rules, not constants. +- **Add a new `rule_type`:** add the option to the `CRM Enrichment Rule` Select, add + a generic executor (or reuse `apply_keyword_rules`) in + `extractors.py`, and wire it into `pipeline.run` via `cfg.rules("")`. Keep the + rule vocabulary small (substring/regex + scope + weight) — do **not** grow a DSL. +- **Add a new `source_key`:** extend `mapper.get_value_for_source_key`, add the option + to the Field Mapping Select, and (if it needs new data) populate it on + `EnrichmentResult`. + +--- + +## Limitations & future + +- **JS-rendered sites.** Enrichment reads server-delivered HTML only (`requests` + + BeautifulSoup), so a fully client-rendered site yields only `` metadata; the + run records the "main content is JavaScript-rendered" note (`READABILITY_MESSAGES["empty"]`) + and fields may be blank. A headless-Chromium render fallback was prototyped but + **intentionally removed** — the per-worker Chromium memory footprint isn't worth it + for a fallback. (The implementation is preserved locally in the gitignored + `CHROMIUM_FALLBACK.local.md` if it ever needs to be reintroduced.) +- **Logo vs image.** The `logo` is the company's **link icon** (`extract_logo`): the + best declared `` — scalable SVG > largest raster / apple-touch — with + `/favicon.ico` as the fallback. It's the crisp, square brand mark suited to an avatar. + The larger social/brand image (`extract_image`: JSON-LD `Organization.logo` → + `og:image`/`twitter:image`) is captured separately as `image` in the run JSON — it is + **not** used as the logo (`og:image` is usually a wide banner) and isn't mapped to a + record field by default. +- **Scheduled re-enrichment — _future feature, not implemented._** Triggering is + either manual (Enrich button) or automatic on Lead/Deal/Organization creation (`auto_enrich`); + there is no *scheduled* re-enrichment of existing records. A future version could enqueue periodic re-enrichment of + stale records (e.g. via a scheduled job that re-runs `tasks.run_enrichment` for + records whose last `CRM Enrichment Run` is older than N days). +- **TLS fingerprinting.** The fetcher uses `requests`, so its TLS ClientHello + fingerprints (JA3/JA4) as a non-browser client. Bot walls that fingerprint TLS + (Cloudflare, Akamai, DataDome) can still block it even though `http.build_session` + sends browser-like headers. Real evasion needs a TLS-impersonating client + (`curl_cffi`) or a headless browser — deliberately out of scope here. + +--- + +## Tests + +Framework-native tests live in `crm/domain_enrichment/tests/` and are discoverable by +`bench run-tests --app crm`: + +- **Pure unit (`frappe.tests.UnitTestCase`, no DB, offline fixtures):** + `test_crawler.py`, `test_extractors.py`, `test_pipeline.py`, `test_ssrf.py`. The + rule executors are exercised with in-memory `Rule` objects (`tests/fixtures.py`); + the SSRF guard mocks `socket.getaddrinfo` so DNS is deterministic and offline. +- **Integration (`frappe.tests.IntegrationTestCase`, real test DB):** + `test_integration.py` — seeder idempotency, mapper write-policies, the Run writer, + API permission/allow-list enforcement, and the link-time Org→Lead/Deal copy. The + crawl is monkeypatched to a canned `EnrichmentResult`, so the suite is network-free. + +```bash +bench --site run-tests --app crm --module crm.domain_enrichment.tests.test_extractors +bench --site run-tests --app crm --module crm.domain_enrichment.tests.test_integration +``` diff --git a/crm/domain_enrichment/__init__.py b/crm/domain_enrichment/__init__.py new file mode 100644 index 0000000..a44016c --- /dev/null +++ b/crm/domain_enrichment/__init__.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""Domain Enrichment engine. + +Config-driven, Frappe-idiomatic feature that enriches CRM Lead/Deal/Organization +records from their website domain. Configuration (rules, field mappings, crawl +limits, sources) lives in desk doctypes; this package is the rule-agnostic engine. +""" diff --git a/crm/domain_enrichment/api.py b/crm/domain_enrichment/api.py new file mode 100644 index 0000000..ec1acb7 --- /dev/null +++ b/crm/domain_enrichment/api.py @@ -0,0 +1,88 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""Whitelisted entry points: ``enrich`` (enqueue a full run for a record) and +``retry`` (re-run one from its Run history). + +Both are type-annotated -- hooks.py sets ``require_type_annotated_api_methods``. +Security: each enforces the doctype allow-list (from Settings) + a ``write`` +permission check, and is rate-limited per user (see ``ENRICH_RATE_LIMIT``). +""" + +from __future__ import annotations + +import frappe +from frappe import _ +from frappe.rate_limiter import rate_limit + +from .config import ENRICHABLE_DOCTYPES, EnrichmentConfig, get_config +from .tasks import enqueue_enrichment + +# Per-user, per-minute cap on each enrich entry point (each gets its own bucket). +# Both only enqueue a per-doc-deduplicated job -- 10/min is far above any real human +# burst while capping scripted queue-flooding. +ENRICH_RATE_LIMIT = 10 + + +def _enabled_doctypes(cfg: EnrichmentConfig) -> list[str]: + """The doctypes enrichment is enabled for, per Settings (allow-list).""" + if not cfg.setting("enabled"): + return [] + return list(ENRICHABLE_DOCTYPES) + + +def _enqueue_run(cfg, reference_doctype: str, reference_name: str, website: str) -> dict: + """Validate + enqueue one enrichment run for a target record. Shared by + ``enrich`` (initial trigger from the record) and ``retry`` (re-run from a Run). + + Caller must have already resolved the website. Enforces the Settings allow-list + and ``write`` permission on the target. Uses the per-doc ``job_id`` + + ``deduplicate`` so a second trigger while one is in-flight is a no-op. + """ + if reference_doctype not in _enabled_doctypes(cfg): + frappe.throw( + _("Enrichment is not enabled for {0}.").format(reference_doctype), + frappe.ValidationError, + ) + + doc = frappe.get_doc(reference_doctype, reference_name) + doc.check_permission("write") + + website = (website or "").strip() + if not website: + frappe.throw(_("Set a website on this record before enriching."), frappe.ValidationError) + + return enqueue_enrichment(reference_doctype, reference_name, website, frappe.session.user) + + +@frappe.whitelist() +@rate_limit(limit=ENRICH_RATE_LIMIT, seconds=60) +def enrich(reference_doctype: str, reference_name: str) -> dict: + """Enqueue a full enrichment run for one CRM record, using the record's own + ``website`` field. The initial trigger (the "Enrich from Website" button). + + Returns ``{queued: bool, job_id: str, website: str}``. + """ + cfg = get_config() + doc = frappe.get_doc(reference_doctype, reference_name) + website = (doc.get("website") or "").strip() + return _enqueue_run(cfg, reference_doctype, reference_name, website) + + +@frappe.whitelist() +@rate_limit(limit=ENRICH_RATE_LIMIT, seconds=60) +def retry(run: str) -> dict: + """Re-run the enrichment recorded by a ``CRM Enrichment Run`` (the desk "Retry" + button on each run). Re-enriches the run's linked record, preferring the record's + current ``website`` and falling back to the website this run originally scraped. + + Returns ``{queued: bool, job_id: str, website: str}``. + """ + run_doc = frappe.get_doc("CRM Enrichment Run", run) + if not run_doc.reference_doctype or not run_doc.reference_name: + frappe.throw(_("This run has no linked record to re-enrich."), frappe.ValidationError) + + cfg = get_config() + target = frappe.get_doc(run_doc.reference_doctype, run_doc.reference_name) + website = (target.get("website") or "").strip() or (run_doc.source_website or "").strip() + return _enqueue_run(cfg, run_doc.reference_doctype, run_doc.reference_name, website) diff --git a/crm/domain_enrichment/config.py b/crm/domain_enrichment/config.py new file mode 100644 index 0000000..3f4e5c8 --- /dev/null +++ b/crm/domain_enrichment/config.py @@ -0,0 +1,261 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""Loads enrichment configuration (Settings + Rules + Field Mappings) from the desk. + +The engine is rule-agnostic: it loads admin-edited config here and executes it. +``get_config()`` assembles an ``EnrichmentConfig`` from the config doctypes on demand +(not cached -- see the note on ``get_config``). Hot paths that only need the Settings +toggles use ``get_settings`` / ``auto_enrich_enabled_for`` instead of the full build. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +import frappe + +# The doctypes enrichment runs on. Single source of truth shared by the manual (api) +# and auto-enrich (tasks) paths, and the allow-list the whitelisted API checks a +# caller-supplied doctype against. +ENRICHABLE_DOCTYPES = ("CRM Lead", "CRM Deal", "CRM Organization") + + +# Sensible fallbacks applied when the Single doctype has not been saved yet (the +# JSON field defaults only populate a freshly-created row, which may not exist). +DEFAULT_SETTINGS = { + "enabled": 1, + "auto_enrich": 0, + "max_pages": 10, + "max_depth": 2, + "use_sitemap": 1, + "request_timeout": 10, + "max_download_bytes": 3_000_000, + "retry_count": 2, + "user_agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" + ), +} + + +def _setting(doc, key): + """One Settings field, falling back to DEFAULT_SETTINGS when unset.""" + val = doc.get(key) + return DEFAULT_SETTINGS.get(key) if val in (None, "") else val + + +def get_settings(): + """The CRM Enrichment Settings Single, framework-cached via ``get_cached_doc``. + + Cheap: reads only the Single, never assembling Rules/Mappings. For hot paths -- + the per-insert auto-enrich flag check and the enqueue timeout -- that must not + build the full config on every record creation. + """ + return frappe.get_cached_doc("CRM Enrichment Settings") + + +def auto_enrich_enabled_for(doctype: str) -> bool: + """True if auto-enrich-on-create should fire for ``doctype``. A cheap, + Settings-only check (feature enabled + auto_enrich on + an enrichable doctype) -- + no Rules/Mappings assembled.""" + s = get_settings() + return bool(_setting(s, "enabled") and _setting(s, "auto_enrich") and doctype in ENRICHABLE_DOCTYPES) + + +# Industry classifier thresholds. These are mechanics (how confident the winner +# must be), not tunable knowledge, so they stay constants. If an admin ever needs +# to tune them they can be promoted to Settings fields without touching callers. +INDUSTRY_MIN_SCORE = 2 # at least one headline keyword hit (weighted) +INDUSTRY_MIN_CONFIDENCE = 0.34 # the winner must clearly lead + +# Default link-priority ordering (BFS) -- seeded into Settings.link_priority_order +# on install, and used as a fallback when that table is empty. +DEFAULT_LINK_PRIORITY = [ + ("about", 1.0), + ("contact", 1.0), + ("team", 1.0), + ("leadership", 1.0), + ("careers", 1.0), + ("company", 1.0), + ("people", 1.0), + ("founders", 1.0), + ("management", 1.0), + ("jobs", 1.0), +] + + +@dataclass +class Rule: + """A single config-driven rule with its compiled patterns. + + ``patterns`` is a list of (compiled_regex, raw_pattern) tuples. Substring + patterns (is_regex=0) are compiled as escaped, case-insensitive regexes so the + executors can use one uniform match path. + """ + + rule_type: str + target_value: str = "" + industry: str = "" + weight: float = 1.0 + match_scope: str = "Full Text" + patterns: list = field(default_factory=list) + + @property + def label(self) -> str: + """The value a match yields: industry name for Industry rules, else + target_value (tech name / network).""" + return self.industry if self.rule_type == "Industry" else self.target_value + + def matches(self, text: str) -> int: + """Total number of pattern hits across this rule's patterns in ``text``.""" + if not text: + return 0 + return sum(len(rx.findall(text)) for rx, _raw in self.patterns) + + +@dataclass +class Mapping: + source_key: str + target_doctype: str + target_fieldname: str + write_policy: str = "Fill if empty" + create_missing_link: int = 0 + default_values: str = "" + + +@dataclass +class EnrichmentConfig: + settings: dict = field(default_factory=dict) + rules_by_type: dict = field(default_factory=dict) # rule_type -> list[Rule] + mappings_by_doctype: dict = field(default_factory=dict) # doctype -> list[Mapping] + link_priority: list = field(default_factory=list) # list[(keyword, weight)] + skip_patterns: list = field(default_factory=list) # list[str] + allowed_domains: list = field(default_factory=list) + blocked_domains: list = field(default_factory=list) + + # Convenience accessors with fallbacks --------------------------------- # + def setting(self, key, default=None): + val = self.settings.get(key) + if val in (None, ""): + return DEFAULT_SETTINGS.get(key, default) + return val + + def rules(self, rule_type: str) -> list: + return self.rules_by_type.get(rule_type, []) + + +def _compile_pattern(pattern: str, is_regex: int): + try: + if is_regex: + return re.compile(pattern, re.IGNORECASE) + # Word-boundary-anchor substring patterns: an un-anchored short keyword like + # "erp" or "api" matches inside completely unrelated words ("waterproofing", + # "capital"), causing false industry-classification hits. An admin who wants + # unanchored substring matching can still opt in via is_regex=1. + return re.compile(rf"\b{re.escape(pattern)}\b", re.IGNORECASE) + except re.error: + # A malformed admin-entered regex must not break the whole config build. + frappe.log_error( + title="Domain Enrichment: invalid rule pattern", + message=f"Could not compile pattern: {pattern!r}", + ) + return None + + +def _build_rules() -> dict: + rules_by_type: dict = {} + names = frappe.get_all("CRM Enrichment Rule", filters={"enabled": 1}, pluck="name") + for name in names: + doc = frappe.get_doc("CRM Enrichment Rule", name) + compiled = [] + for pat in doc.patterns or []: + rx = _compile_pattern(pat.pattern, pat.is_regex) + if rx is not None: + compiled.append((rx, pat.pattern)) + if not compiled: + continue + rule = Rule( + rule_type=doc.rule_type, + target_value=doc.target_value or "", + industry=doc.industry or "", + weight=doc.weight or 1.0, + match_scope=doc.match_scope or "Full Text", + patterns=compiled, + ) + rules_by_type.setdefault(doc.rule_type, []).append(rule) + return rules_by_type + + +def _build_mappings() -> dict: + mappings_by_doctype: dict = {} + rows = frappe.get_all( + "CRM Enrichment Field Mapping", + filters={"enabled": 1}, + fields=[ + "source_key", + "target_doctype", + "target_fieldname", + "write_policy", + "create_missing_link", + "default_values", + ], + ) + for row in rows: + mapping = Mapping( + source_key=row.source_key, + target_doctype=row.target_doctype, + target_fieldname=row.target_fieldname, + write_policy=row.write_policy or "Fill if empty", + create_missing_link=row.create_missing_link or 0, + default_values=row.default_values or "", + ) + mappings_by_doctype.setdefault(row.target_doctype, []).append(mapping) + return mappings_by_doctype + + +def _build_settings(settings_doc) -> dict: + out = {} + for key in DEFAULT_SETTINGS: + out[key] = settings_doc.get(key) + return out + + +def _build_config() -> EnrichmentConfig: + settings_doc = frappe.get_cached_doc("CRM Enrichment Settings") + + link_priority = [ + (kw.keyword.lower(), kw.weight or 1.0) + for kw in (settings_doc.link_priority_order or []) + if kw.keyword + ] or list(DEFAULT_LINK_PRIORITY) + + skip_patterns = [sp.pattern for sp in (settings_doc.skip_patterns or []) if sp.pattern] + + allowed_domains = [d.domain.lower().strip() for d in (settings_doc.allowed_domains or []) if d.domain] + blocked_domains = [d.domain.lower().strip() for d in (settings_doc.blocked_domains or []) if d.domain] + + return EnrichmentConfig( + settings=_build_settings(settings_doc), + rules_by_type=_build_rules(), + mappings_by_doctype=_build_mappings(), + link_priority=link_priority, + skip_patterns=skip_patterns, + allowed_domains=allowed_domains, + blocked_domains=blocked_domains, + ) + + +def get_config() -> EnrichmentConfig: + """Assemble the full enrichment config (Settings + Rules + Field Mappings) fresh. + + Deliberately NOT cached. It is built at most a couple of times per enrichment run, + and a run is a multi-second, network-bound crawl -- so the handful of small queries + here is noise. A cache, by contrast, buys nothing measurable and adds a real + stale-config surface (any write that bypasses ``on_update`` -- a bulk ``db_set``, + direct SQL -- would serve stale rules until a restart). The per-insert hot path does + NOT call this; it uses ``auto_enrich_enabled_for`` (Settings-only). Do not re-add a + cache here without a measured hot-path caller that justifies it. + """ + return _build_config() diff --git a/crm/domain_enrichment/crawler.py b/crm/domain_enrichment/crawler.py new file mode 100644 index 0000000..cccc2f4 --- /dev/null +++ b/crm/domain_enrichment/crawler.py @@ -0,0 +1,449 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""Same-domain, depth-limited BFS crawler. Caps, priorities and skip-patterns +come from config (Settings child tables), not module constants. + +Exposes: + crawl() -> ranked list of (CrawledPage, soup) tuples, homepage first + crawl_page() -> fetch + parse a single page into a CrawledPage + extract_content() -> pull title/headings/visible text from soup +""" + +from __future__ import annotations + +import re +from collections import deque +from functools import lru_cache +from urllib.parse import urldefrag, urljoin, urlparse +from urllib.robotparser import RobotFileParser +from xml.etree import ElementTree as ET + +import tldextract +from bs4 import BeautifulSoup + +from .http import build_session, fetch +from .result import CrawledPage + +SKIP_EXTENSIONS = ( + ".pdf", + ".jpg", + ".jpeg", + ".png", + ".gif", + ".svg", + ".webp", + ".ico", + ".css", + ".js", + ".zip", + ".gz", + ".mp4", + ".mp3", + ".avi", + ".woff", + ".woff2", + ".ttf", + ".eot", + ".xml", + ".json", + ".rss", +) +SKIP_SCHEMES = ("mailto:", "tel:", "javascript:", "data:", "#") + + +def normalize_url(url: str) -> str: + """Query strings are dropped, not canonicalized -- almost always tracking + noise, not a distinct page identity, for the pages this crawler cares about.""" + url, _frag = urldefrag(url) + parsed = urlparse(url) + if parsed.query: + url = parsed._replace(query="").geturl() + if url.endswith("/") and len(urlparse(url).path) > 1: + url = url.rstrip("/") + return url + + +@lru_cache(maxsize=1) +def _tld_extractor() -> tldextract.TLDExtract: + """Pinned to the bundled PSL snapshot (no network fetch); private suffixes like + ``github.io``/``vercel.app`` stay registrable so distinct tenants count as distinct sites.""" + return tldextract.TLDExtract( + suffix_list_urls=(), + cache_dir=None, + include_psl_private_domains=True, + ) + + +def registrable_domain(netloc: str) -> str: + """``www.acme.co.uk`` -> ``acme.co.uk`` -- avoids a naive last-two-labels rule + collapsing all of ``*.co.uk`` into one same-site bucket.""" + netloc = netloc.lower().split(":")[0] + extracted = _tld_extractor()(netloc) + if extracted.domain and extracted.suffix: + return f"{extracted.domain}.{extracted.suffix}" + return extracted.domain or netloc + + +def same_site(url: str, base_netloc: str) -> bool: + try: + return registrable_domain(urlparse(url).netloc) == registrable_domain(base_netloc) + except Exception: + return False + + +def _link_priority(url: str, anchor_text: str, link_priority: list) -> float: + """Lower number = crawl sooner.""" + path = urlparse(url).path.rstrip("/") + leaf = path.rsplit("/", 1)[-1] if path else "" + haystack = (leaf + " " + (anchor_text or "")).lower() + best = None + for idx, (kw, weight) in enumerate(link_priority): + if kw and kw in haystack: + # Higher weight -> earlier; ties broken by config order. + score = idx - (weight or 1.0) + if best is None or score < best: + best = score + return best if best is not None else len(link_priority) + 1 + + +def _is_crawlable(url: str, skip_patterns: list) -> bool: + low = url.lower() + if any(low.startswith(s) for s in SKIP_SCHEMES): + return False + path = urlparse(url).path.lower() + if path.endswith(SKIP_EXTENSIONS): + return False + if urlparse(url).scheme not in ("http", "https"): + return False + for pat in skip_patterns: + try: + if re.search(pat, url, re.IGNORECASE): + return False + except re.error: + if pat.lower() in low: + return False + return True + + +def extract_content(soup: BeautifulSoup) -> dict: + """Mutates ``soup`` (decomposes script/style) -- pass a throwaway copy if the + caller needs the original DOM afterwards.""" + title = "" + if soup.title and soup.title.string: + title = soup.title.string.strip() + + headings = [] + for tag in soup.find_all(["h1", "h2", "h3"]): + txt = tag.get_text(" ", strip=True) + if txt: + headings.append(txt) + + for node in soup(["script", "style", "noscript", "template", "svg"]): + node.decompose() + text = soup.get_text(" ", strip=True) + text = " ".join(text.split()) + + return {"title": title, "headings": headings, "text": text} + + +def _links_on_page(soup, page_url, base_netloc, skip_patterns): + for a in soup.find_all("a", href=True): + href = a["href"].strip() + if not href or any(href.lower().startswith(s) for s in SKIP_SCHEMES): + continue + absolute = normalize_url(urljoin(page_url, href)) + if not _is_crawlable(absolute, skip_patterns): + continue + if not same_site(absolute, base_netloc): + continue + yield absolute, a.get_text(" ", strip=True) + + +def _parse_and_fill(page, html): + """``extract_content`` runs on a throwaway parse so the returned soup stays unmutated.""" + soup = BeautifulSoup(html, "html.parser") + content = extract_content(BeautifulSoup(html, "html.parser")) + page.title = content["title"] + page.headings = content["headings"] + page.text = content["text"] + return soup + + +def crawl_page(url, cfg, session=None): + status, html, error, final_url = fetch(url, cfg, session=session) + # Resolved URL: links and provenance match the host that actually served the body. + page = CrawledPage(url=final_url or url, status_code=status, html=html or "", error=error) + soup = _parse_and_fill(page, html) if (html and not error) else None + return page, soup + + +ABOUT_PROBE_PATHS = ("/about", "/about-us", "/company", "/our-story") +ABOUT_MIN_TEXT = 200 + + +def load_robots(start_url, cfg, session=None): + """Returns ``None`` when absent/unreadable (convention: allow all).""" + try: + parsed = urlparse(normalize_url(start_url)) + if not parsed.netloc: + return None + robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt" + own = session is None + session = session or build_session(cfg) + try: + status, body, error, _final = fetch(robots_url, cfg, session=session, html_only=False) + finally: + if own: + session.close() + if error or status != 200 or not body: + return None + rp = RobotFileParser() + rp.parse(body.splitlines()) + return rp + except Exception: + return None + + +def _robots_allows(robots, user_agent, url): + if robots is None: + return True + try: + return robots.can_fetch(user_agent or "*", url) + except Exception: + return True + + +def probe_about_pages(home_url, cfg, session=None, skip_urls=()): + skip = {normalize_url(u) for u in skip_urls} + user_agent = cfg.setting("user_agent") + own_session = session is None + session = session or build_session(cfg) + robots = load_robots(home_url, cfg, session) + try: + for path in ABOUT_PROBE_PATHS: + url = normalize_url(urljoin(home_url, path)) + if url in skip: + continue + skip.add(url) + if not _robots_allows(robots, user_agent, url): + continue + page, soup = crawl_page(url, cfg, session=session) + if ( + soup is not None + and page.status_code == 200 + and not page.error + and len(page.text) >= ABOUT_MIN_TEXT + ): + return [(page, soup)] + finally: + if own_session: + session.close() + return [] + + +# --------------------------------------------------------------------------- # +# Sitemap discovery +# --------------------------------------------------------------------------- # +# Additive on top of link-following BFS: seeds pages the sitemap author flagged. +SITEMAP_MAX_SITEMAPS = 5 # sitemap files fetched; guards pathological sitemap indexes +SITEMAP_MAX_URLS = 200 # total entries collected across all fetched sitemaps + + +def _local_tag(tag: str) -> str: + """Strip the XML namespace off a tag: '{http://.../0.9}urlset' -> 'urlset'.""" + return tag.rsplit("}", 1)[-1] if tag else tag + + +def _parse_sitemap(xml_text: str): + """Returns ('urlset' | 'sitemapindex' | None, [loc, ...]); never raises. + + Rejects any DOCTYPE before parsing as an XML entity-expansion guard -- a + legitimate sitemap never has one. + """ + if "= SITEMAP_MAX_SITEMAPS: + break + to_fetch.append(loc) + sitemaps_queued += 1 + + elif kind == "urlset": + for loc in locs: + norm = normalize_url(loc) + if norm in seen_urls: + continue + seen_urls.add(norm) + urls.append(norm) + if len(urls) >= SITEMAP_MAX_URLS: + break + + return urls + except Exception: + return [] + + +def crawl(start_url, cfg, session=None, progress=None): + max_pages = int(cfg.setting("max_pages")) + max_depth = int(cfg.setting("max_depth")) + link_priority = cfg.link_priority + skip_patterns = cfg.skip_patterns + + user_agent = cfg.setting("user_agent") + own_session = session is None + session = session or build_session(cfg) + start_url = normalize_url(start_url) + base_netloc = urlparse(start_url).netloc + + # robots.txt only gates discovered links; skip the fetch for homepage-only runs. + crawl_beyond_homepage = max_depth >= 1 and max_pages > 1 + robots = load_robots(start_url, cfg, session) if crawl_beyond_homepage else None + + visited = set() + # Resolved (post-redirect) URLs already crawled -- catches a duplicate the + # pre-fetch `visited` check can't (e.g. /contact.html -> /contact). + visited_resolved = set() + results = [] + # Queue holds (url, depth, anchor_text). + queue = deque([(start_url, 0, "")]) + + # Sitemap-seeded links join at depth 1, never 0, so the homepage still wins + # the first pop. Additive only -- a site with no sitemap is unaffected. + if crawl_beyond_homepage and cfg.setting("use_sitemap", True): + queued_from_sitemap = set() + for loc in discover_sitemap_urls(start_url, cfg, session, robots): + link = normalize_url(loc) + if link == start_url or link in queued_from_sitemap: + continue + if not same_site(link, base_netloc) or not _is_crawlable(link, skip_patterns): + continue + if not _robots_allows(robots, user_agent, link): + continue + queued_from_sitemap.add(link) + queue.append((link, 1, "")) + + try: + while queue and len(results) < max_pages: + # Pick the highest-priority queued URL (homepage first, then by keyword). + queue = deque( + sorted( + queue, + key=lambda item: ( + 0 if item[1] == 0 else 1, + _link_priority(item[0], item[2], link_priority), + ), + ) + ) + url, depth, _anchor = queue.popleft() + url = normalize_url(url) + if url in visited: + continue + visited.add(url) + + if progress: + progress(f"Crawling {url}") + page, soup = crawl_page(url, cfg, session=session) + resolved = normalize_url(page.url) if page.url else "" + + if not results: + # Homepage: can't be a duplicate yet. Adopt its resolved host as the + # same-site base if it redirected elsewhere. + if resolved: + base_netloc = urlparse(page.url).netloc or base_netloc + else: + # A redirect can land here under a different requested URL (e.g. + # /contact.html -> /contact) -- drop the duplicate, no budget spent. + if resolved and resolved in visited_resolved: + continue + if soup is not None and page.url and not same_site(page.url, base_netloc): + # Same-site link redirected off-domain; fetch() only checks SSRF + # per hop, not same-site scope. Clear every parsed field (not just + # html/text) so nothing -- including title/headings, already + # filled in by crawl_page() -- leaks into extraction. + page.error = page.error or f"redirected off-site to {urlparse(page.url).netloc}" + page.html = "" + page.text = "" + page.title = "" + page.headings = [] + soup = None + + if resolved: + visited_resolved.add(resolved) + + results.append((page, soup)) + + if soup is None or depth >= max_depth: + continue + + # Resolve links against the page's resolved URL, not the requested one. + seen_here = set() + for link, anchor in _links_on_page(soup, page.url, base_netloc, skip_patterns): + if link in visited or link in seen_here: + continue + if not _robots_allows(robots, user_agent, link): + continue + seen_here.add(link) + queue.append((link, depth + 1, anchor)) + finally: + if own_session: + session.close() + + return results diff --git a/crm/domain_enrichment/cross_record.py b/crm/domain_enrichment/cross_record.py new file mode 100644 index 0000000..1c4b0ab --- /dev/null +++ b/crm/domain_enrichment/cross_record.py @@ -0,0 +1,112 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""Link-time Organization -> Lead/Deal enrichment copy. + +The ONLY cross-record behaviour in Domain Enrichment: when a Lead or Deal links an +already-enriched ``CRM Organization``, synchronously copy that Organization's stored +enriched fields onto the *empty* fields of the Lead/Deal (fill-empty). The direction +is strictly Organization -> Lead/Deal, in-request, on the origin save. There is no +background job, no fan-out, and no write back to the Organization. + +The actual field writing reuses :func:`crm.domain_enrichment.mapper.apply_to_document` +-- the single result->field authority -- by wrapping the Organization's stored values +in an :class:`~crm.domain_enrichment.result.EnrichmentResult`. No crawling happens +here; this is a pure record-to-record copy. +""" + +from __future__ import annotations + +import frappe + +from crm.domain_enrichment import mapper +from crm.domain_enrichment.config import get_config +from crm.domain_enrichment.result import ( + EnrichmentResult, + Field, + SocialProfile, +) + +# Native enriched fields stored on a CRM Organization. If at least one of these is +# populated (or the org has a completed enrichment run) we treat the org as enriched +# and worth copying from. Empty source values never overwrite anything (mapper guards). +_ORG_ENRICHED_FIELDS = ( + "company_description", + "organization_logo", + "industry", + "linkedin", + "twitter", + "facebook", +) + + +def _is_org_enriched(org) -> bool: + """Has this Organization actually been enriched? + + Cleanest signal: a completed ``CRM Enrichment Run`` for the org. We also accept + any populated native enriched field as a fallback (an org may carry enriched data + without a run row, e.g. seeded/imported data). + """ + if frappe.db.exists( + "CRM Enrichment Run", + { + "reference_doctype": "CRM Organization", + "reference_name": org.name, + "status": "Completed", + }, + ): + return True + return any(org.get(fieldname) for fieldname in _ORG_ENRICHED_FIELDS) + + +def _result_from_organization(org) -> EnrichmentResult: + """Wrap an Organization's stored enriched fields in an EnrichmentResult so the + shared mapper can copy them onto the target via the same Field Mapping records.""" + result = EnrichmentResult(website=org.get("website") or "") + result.company_name = Field(value=org.get("organization_name") or "") + result.description = Field(value=org.get("company_description") or "") + result.logo = Field(value=org.get("organization_logo") or "") + result.industry = Field(value=org.get("industry") or "") + + for network in ("linkedin", "twitter", "facebook"): + value = org.get(network) + if value: + result.social_profiles[network] = SocialProfile(value=value) + + return result + + +def copy_enrichment_from_organization(target_doc) -> list[str]: + """Copy enriched fields from the linked CRM Organization onto ``target_doc``. + + ``target_doc`` is a CRM Lead or CRM Deal that has just linked an Organization + (Lead: ``organization`` is free-text resolved to an org name; Deal: ``organization`` + is a Link to CRM Organization). If that Organization is actually enriched, its + stored enriched fields are copied onto the *empty* fields of ``target_doc`` using + the shared mapper with fill-empty semantics, so user-entered data is never touched. + + Best-effort: never raises, never blocks the Lead/Deal save. Returns the list of + filled field labels (empty when nothing was copied). + """ + try: + org_name = target_doc.get("organization") + if not org_name or not frappe.db.exists("CRM Organization", org_name): + return [] + + org = frappe.get_cached_doc("CRM Organization", org_name) + if not _is_org_enriched(org): + return [] + + cfg = get_config() + result = _result_from_organization(org) + # Fill-empty only: force fill-empty semantics for every mapping (even ones + # configured "Always refresh") so the link-time copy never + # overwrites or clears user-entered data. apply_to_document mutates target_doc + # in place and does NOT save (the caller's save persists it). + return mapper.apply_to_document(target_doc, result, cfg, fill_empty_only=True) + except Exception: + frappe.log_error( + title="Domain Enrichment: Org -> Lead/Deal copy failed", + message=f"{target_doc.doctype} {target_doc.name}: {frappe.get_traceback()}", + ) + return [] diff --git a/crm/domain_enrichment/doctype/__init__.py b/crm/domain_enrichment/doctype/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/domain_enrichment/doctype/crm_enrichment_domain/__init__.py b/crm/domain_enrichment/doctype/crm_enrichment_domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/domain_enrichment/doctype/crm_enrichment_domain/crm_enrichment_domain.json b/crm/domain_enrichment/doctype/crm_enrichment_domain/crm_enrichment_domain.json new file mode 100644 index 0000000..2b85635 --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_domain/crm_enrichment_domain.json @@ -0,0 +1,33 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-06-17 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "domain" + ], + "fields": [ + { + "fieldname": "domain", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Domain", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-06-17 00:00:00.000000", + "modified_by": "Administrator", + "module": "Domain Enrichment", + "name": "CRM Enrichment Domain", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/domain_enrichment/doctype/crm_enrichment_domain/crm_enrichment_domain.py b/crm/domain_enrichment/doctype/crm_enrichment_domain/crm_enrichment_domain.py new file mode 100644 index 0000000..ef6fddc --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_domain/crm_enrichment_domain.py @@ -0,0 +1,23 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMEnrichmentDomain(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + domain: DF.Data + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + # end: auto-generated types + + pass diff --git a/crm/domain_enrichment/doctype/crm_enrichment_field_mapping/__init__.py b/crm/domain_enrichment/doctype/crm_enrichment_field_mapping/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/domain_enrichment/doctype/crm_enrichment_field_mapping/crm_enrichment_field_mapping.json b/crm/domain_enrichment/doctype/crm_enrichment_field_mapping/crm_enrichment_field_mapping.json new file mode 100644 index 0000000..192f5c1 --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_field_mapping/crm_enrichment_field_mapping.json @@ -0,0 +1,125 @@ +{ + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "creation": "2026-06-17 00:00:00.000000", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "enabled", + "source_key", + "column_break_main", + "target_doctype", + "target_fieldname", + "policy_section", + "write_policy", + "create_missing_link", + "default_values" + ], + "fields": [ + { + "default": "1", + "fieldname": "enabled", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Enabled" + }, + { + "description": "The enrichment result key to read the value from.", + "fieldname": "source_key", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Source Key", + "options": "company_name\ndescription\nlogo\nindustry\nprimary_email\nprimary_phone\nsecondary_phone\nlinkedin\ntwitter\ngithub\nfacebook\ninstagram\nyoutube", + "reqd": 1 + }, + { + "fieldname": "column_break_main", + "fieldtype": "Column Break" + }, + { + "fieldname": "target_doctype", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Target Doctype", + "options": "DocType", + "reqd": 1 + }, + { + "description": "The fieldname on the target doctype to write the value to.", + "fieldname": "target_fieldname", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Target Fieldname", + "reqd": 1 + }, + { + "fieldname": "policy_section", + "fieldtype": "Section Break", + "label": "Write Policy" + }, + { + "default": "Fill if empty", + "description": "Fill if empty: only write when the field is blank. Always refresh: overwrite on every run (for fully enrichment-derived fields). Override defaults: write even when the current value is a known default (see Default Values).", + "fieldname": "write_policy", + "fieldtype": "Select", + "label": "Write Policy", + "options": "Fill if empty\nAlways refresh\nOverride defaults", + "reqd": 1 + }, + { + "default": "0", + "description": "If the value names a missing linked record (e.g. a CRM Industry), create it. Opt-in only.", + "fieldname": "create_missing_link", + "fieldtype": "Check", + "label": "Create Missing Link" + }, + { + "depends_on": "eval:doc.write_policy=='Override defaults'", + "description": "Newline-separated values treated as \"unset\" defaults that may be overwritten, in addition to a truly empty field.", + "fieldname": "default_values", + "fieldtype": "Small Text", + "label": "Default Values" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-06-17 00:00:00.000000", + "modified_by": "Administrator", + "module": "Domain Enrichment", + "name": "CRM Enrichment Field Mapping", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/domain_enrichment/doctype/crm_enrichment_field_mapping/crm_enrichment_field_mapping.py b/crm/domain_enrichment/doctype/crm_enrichment_field_mapping/crm_enrichment_field_mapping.py new file mode 100644 index 0000000..917451b --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_field_mapping/crm_enrichment_field_mapping.py @@ -0,0 +1,48 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + +# Field Mapping only targets the three app-owned CRM doctypes that can be enriched. +ALLOWED_TARGET_DOCTYPES = ("CRM Lead", "CRM Deal", "CRM Organization") + + +class CRMEnrichmentFieldMapping(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + create_missing_link: DF.Check + default_values: DF.SmallText | None + enabled: DF.Check + source_key: DF.Literal[ + "company_name", + "description", + "logo", + "industry", + "primary_email", + "primary_phone", + "secondary_phone", + "linkedin", + "twitter", + "github", + "facebook", + "instagram", + "youtube", + ] + target_doctype: DF.Link + target_fieldname: DF.Data + write_policy: DF.Literal["Fill if empty", "Always refresh", "Override defaults"] + # end: auto-generated types + + def validate(self): + if self.target_doctype not in ALLOWED_TARGET_DOCTYPES: + frappe.throw(_("Target Doctype must be one of: {0}").format(", ".join(ALLOWED_TARGET_DOCTYPES))) + if not frappe.get_meta(self.target_doctype).has_field(self.target_fieldname): + frappe.throw(_("{0} has no field {1}").format(self.target_doctype, self.target_fieldname)) diff --git a/crm/domain_enrichment/doctype/crm_enrichment_link_priority/__init__.py b/crm/domain_enrichment/doctype/crm_enrichment_link_priority/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/domain_enrichment/doctype/crm_enrichment_link_priority/crm_enrichment_link_priority.json b/crm/domain_enrichment/doctype/crm_enrichment_link_priority/crm_enrichment_link_priority.json new file mode 100644 index 0000000..b69603c --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_link_priority/crm_enrichment_link_priority.json @@ -0,0 +1,42 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-06-17 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "keyword", + "weight" + ], + "fields": [ + { + "description": "Substring matched against a candidate URL/anchor to prioritise crawling it (e.g. \"about\", \"team\", \"contact\").", + "fieldname": "keyword", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Keyword", + "reqd": 1 + }, + { + "default": "1", + "fieldname": "weight", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Weight" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-06-17 00:00:00.000000", + "modified_by": "Administrator", + "module": "Domain Enrichment", + "name": "CRM Enrichment Link Priority", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/domain_enrichment/doctype/crm_enrichment_link_priority/crm_enrichment_link_priority.py b/crm/domain_enrichment/doctype/crm_enrichment_link_priority/crm_enrichment_link_priority.py new file mode 100644 index 0000000..591f9c2 --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_link_priority/crm_enrichment_link_priority.py @@ -0,0 +1,24 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMEnrichmentLinkPriority(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + keyword: DF.Data + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + weight: DF.Float + # end: auto-generated types + + pass diff --git a/crm/domain_enrichment/doctype/crm_enrichment_rule/__init__.py b/crm/domain_enrichment/doctype/crm_enrichment_rule/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/domain_enrichment/doctype/crm_enrichment_rule/crm_enrichment_rule.js b/crm/domain_enrichment/doctype/crm_enrichment_rule/crm_enrichment_rule.js new file mode 100644 index 0000000..537cac3 --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_rule/crm_enrichment_rule.js @@ -0,0 +1,8 @@ +// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Enrichment Rule", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/domain_enrichment/doctype/crm_enrichment_rule/crm_enrichment_rule.json b/crm/domain_enrichment/doctype/crm_enrichment_rule/crm_enrichment_rule.json new file mode 100644 index 0000000..ecbaca3 --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_rule/crm_enrichment_rule.json @@ -0,0 +1,131 @@ +{ + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "autoname": "{rule_type}.-.####", + "creation": "2026-06-17 00:00:00", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "rule_name", + "rule_type", + "enabled", + "column_break_main", + "target_value", + "industry", + "weight", + "match_scope", + "patterns_section", + "patterns" + ], + "fields": [ + { + "fieldname": "rule_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Rule Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "rule_type", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Rule Type", + "options": "Industry\nSocial", + "reqd": 1 + }, + { + "default": "1", + "fieldname": "enabled", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Enabled" + }, + { + "fieldname": "column_break_main", + "fieldtype": "Column Break" + }, + { + "description": "The value this rule resolves to when it matches (e.g. the social network \"linkedin\"). For Industry rules, use the Industry link instead.", + "fieldname": "target_value", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Target Value" + }, + { + "depends_on": "eval:doc.rule_type=='Industry'", + "fieldname": "industry", + "fieldtype": "Link", + "label": "Industry", + "options": "CRM Industry" + }, + { + "default": "1", + "description": "Score contributed per matched pattern (Industry classification scoring).", + "fieldname": "weight", + "fieldtype": "Float", + "label": "Weight" + }, + { + "default": "Full Text", + "description": "Where the patterns are matched.", + "fieldname": "match_scope", + "fieldtype": "Select", + "label": "Match Scope", + "options": "Headline\nFull Text\nHTML\nHeaders\nURL" + }, + { + "fieldname": "patterns_section", + "fieldtype": "Section Break", + "label": "Patterns" + }, + { + "fieldname": "patterns", + "fieldtype": "Table", + "label": "Patterns", + "options": "CRM Enrichment Rule Pattern" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-06-19 14:45:44.721670", + "modified_by": "Administrator", + "module": "Domain Enrichment", + "name": "CRM Enrichment Rule", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/domain_enrichment/doctype/crm_enrichment_rule/crm_enrichment_rule.py b/crm/domain_enrichment/doctype/crm_enrichment_rule/crm_enrichment_rule.py new file mode 100644 index 0000000..3dde178 --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_rule/crm_enrichment_rule.py @@ -0,0 +1,31 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMEnrichmentRule(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + from crm.domain_enrichment.doctype.crm_enrichment_rule_pattern.crm_enrichment_rule_pattern import ( + CRMEnrichmentRulePattern, + ) + + enabled: DF.Check + industry: DF.Link | None + match_scope: DF.Literal["Headline", "Full Text", "HTML", "Headers", "URL"] + patterns: DF.Table[CRMEnrichmentRulePattern] + rule_name: DF.Data + rule_type: DF.Literal["Industry", "Social"] + target_value: DF.Data | None + weight: DF.Float + # end: auto-generated types + + pass diff --git a/crm/domain_enrichment/doctype/crm_enrichment_rule/test_crm_enrichment_rule.py b/crm/domain_enrichment/doctype/crm_enrichment_rule/test_crm_enrichment_rule.py new file mode 100644 index 0000000..7e0aa56 --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_rule/test_crm_enrichment_rule.py @@ -0,0 +1,20 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class IntegrationTestCRMEnrichmentRule(IntegrationTestCase): + """ + Integration tests for CRMEnrichmentRule. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/crm/domain_enrichment/doctype/crm_enrichment_rule_pattern/__init__.py b/crm/domain_enrichment/doctype/crm_enrichment_rule_pattern/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/domain_enrichment/doctype/crm_enrichment_rule_pattern/crm_enrichment_rule_pattern.json b/crm/domain_enrichment/doctype/crm_enrichment_rule_pattern/crm_enrichment_rule_pattern.json new file mode 100644 index 0000000..c489c24 --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_rule_pattern/crm_enrichment_rule_pattern.json @@ -0,0 +1,43 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-06-17 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "pattern", + "is_regex" + ], + "fields": [ + { + "description": "A substring (default) or a regular expression matched against the rule's match scope.", + "fieldname": "pattern", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Pattern", + "reqd": 1 + }, + { + "default": "0", + "description": "Treat the pattern as a regular expression instead of a plain substring.", + "fieldname": "is_regex", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Is Regex" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-06-17 00:00:00.000000", + "modified_by": "Administrator", + "module": "Domain Enrichment", + "name": "CRM Enrichment Rule Pattern", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/domain_enrichment/doctype/crm_enrichment_rule_pattern/crm_enrichment_rule_pattern.py b/crm/domain_enrichment/doctype/crm_enrichment_rule_pattern/crm_enrichment_rule_pattern.py new file mode 100644 index 0000000..0cc0c4e --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_rule_pattern/crm_enrichment_rule_pattern.py @@ -0,0 +1,24 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMEnrichmentRulePattern(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + is_regex: DF.Check + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + pattern: DF.Data + # end: auto-generated types + + pass diff --git a/crm/domain_enrichment/doctype/crm_enrichment_run/__init__.py b/crm/domain_enrichment/doctype/crm_enrichment_run/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/domain_enrichment/doctype/crm_enrichment_run/crm_enrichment_run.js b/crm/domain_enrichment/doctype/crm_enrichment_run/crm_enrichment_run.js new file mode 100644 index 0000000..e4abe50 --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_run/crm_enrichment_run.js @@ -0,0 +1,64 @@ +// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// CRM Enrichment Run is a read-only audit log -- it is never created by hand. +// Each run gets a "Retry" button that re-runs the enrichment for its linked +// Lead / Deal / Organization. A retry produces a NEW run row (the log is +// append-only), so on completion we point the user at the fresh run. + +frappe.ui.form.on("CRM Enrichment Run", { + refresh(frm) { + if (frm.is_new()) return; + if (!frm.doc.reference_doctype || !frm.doc.reference_name) return; + + frm.add_custom_button(__("Retry Enrichment"), () => { + crm_enrichment_run_retry(frm); + }); + }, +}); + +function crm_enrichment_run_retry(frm) { + const total = 7; // pipeline.PROGRESS_STEPS + frappe.show_progress(__("Domain Enrichment"), 0, total, __("Queued…")); + + const event = "domain_enrichment_progress"; + const handler = (data) => { + if ( + !data || + data.reference_doctype !== frm.doc.reference_doctype || + data.reference_name !== frm.doc.reference_name + ) { + return; // not for this run's record + } + + frappe.show_progress(__("Domain Enrichment"), data.step, data.total, data.message); + + if (data.status === "completed") { + frappe.realtime.off(event, handler); + frappe.hide_progress(); + frappe.show_alert( + { message: __("Enrichment complete — a new run was created"), indicator: "green" }, + 7 + ); + } else if (data.status === "error") { + frappe.realtime.off(event, handler); + frappe.hide_progress(); + frappe.msgprint({ + title: __("Enrichment Failed"), + message: data.message || __("See Error Log."), + indicator: "red", + }); + } + }; + + frappe.realtime.on(event, handler); + + frappe.call({ + method: "crm.domain_enrichment.api.retry", + args: { run: frm.doc.name }, + error: () => { + frappe.realtime.off(event, handler); + frappe.hide_progress(); + }, + }); +} diff --git a/crm/domain_enrichment/doctype/crm_enrichment_run/crm_enrichment_run.json b/crm/domain_enrichment/doctype/crm_enrichment_run/crm_enrichment_run.json new file mode 100644 index 0000000..8b2df78 --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_run/crm_enrichment_run.json @@ -0,0 +1,198 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "hash", + "creation": "2026-06-17 00:00:00.000000", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "reference_doctype", + "reference_name", + "column_break_ref", + "status", + "source_website", + "timing_section", + "started_on", + "finished_on", + "summary_section", + "company_name", + "industry", + "industry_confidence", + "column_break_summary", + "emails_found", + "phones_found", + "social_profiles", + "raw_section", + "raw_json", + "notes" + ], + "fields": [ + { + "fieldname": "reference_doctype", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Reference Doctype", + "options": "DocType", + "read_only": 1 + }, + { + "fieldname": "reference_name", + "fieldtype": "Dynamic Link", + "in_standard_filter": 1, + "label": "Reference Name", + "options": "reference_doctype", + "read_only": 1 + }, + { + "fieldname": "column_break_ref", + "fieldtype": "Column Break" + }, + { + "default": "Queued", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "Queued\nRunning\nCompleted\nFailed", + "read_only": 1 + }, + { + "fieldname": "source_website", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Source Website", + "read_only": 1 + }, + { + "fieldname": "timing_section", + "fieldtype": "Section Break", + "label": "Timing" + }, + { + "fieldname": "started_on", + "fieldtype": "Datetime", + "label": "Started On", + "read_only": 1 + }, + { + "fieldname": "finished_on", + "fieldtype": "Datetime", + "label": "Finished On", + "read_only": 1 + }, + { + "fieldname": "summary_section", + "fieldtype": "Section Break", + "label": "Summary" + }, + { + "fieldname": "company_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Company Name", + "read_only": 1 + }, + { + "fieldname": "industry", + "fieldtype": "Data", + "label": "Industry", + "read_only": 1 + }, + { + "fieldname": "industry_confidence", + "fieldtype": "Float", + "label": "Industry Confidence", + "read_only": 1 + }, + { + "fieldname": "column_break_summary", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "emails_found", + "fieldtype": "Int", + "label": "Emails Found", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "phones_found", + "fieldtype": "Int", + "label": "Phones Found", + "read_only": 1 + }, + { + "fieldname": "social_profiles", + "fieldtype": "Small Text", + "label": "Social Profiles", + "read_only": 1 + }, + { + "fieldname": "raw_section", + "fieldtype": "Section Break", + "label": "Raw" + }, + { + "fieldname": "raw_json", + "fieldtype": "Code", + "label": "Raw JSON", + "options": "JSON", + "read_only": 1 + }, + { + "fieldname": "notes", + "fieldtype": "Small Text", + "label": "Notes", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-06-17 00:00:00.000000", + "modified_by": "Administrator", + "module": "Domain Enrichment", + "name": "CRM Enrichment Run", + "owner": "Administrator", + "permissions": [ + { + "create": 0, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "source_website" +} diff --git a/crm/domain_enrichment/doctype/crm_enrichment_run/crm_enrichment_run.py b/crm/domain_enrichment/doctype/crm_enrichment_run/crm_enrichment_run.py new file mode 100644 index 0000000..f023128 --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_run/crm_enrichment_run.py @@ -0,0 +1,33 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMEnrichmentRun(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + company_name: DF.Data | None + emails_found: DF.Int + finished_on: DF.Datetime | None + industry: DF.Data | None + industry_confidence: DF.Float + notes: DF.SmallText | None + phones_found: DF.Int + raw_json: DF.Code | None + reference_doctype: DF.Link | None + reference_name: DF.DynamicLink | None + social_profiles: DF.SmallText | None + source_website: DF.Data | None + started_on: DF.Datetime | None + status: DF.Literal["Queued", "Running", "Completed", "Failed"] + # end: auto-generated types + + pass diff --git a/crm/domain_enrichment/doctype/crm_enrichment_settings/__init__.py b/crm/domain_enrichment/doctype/crm_enrichment_settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/domain_enrichment/doctype/crm_enrichment_settings/crm_enrichment_settings.json b/crm/domain_enrichment/doctype/crm_enrichment_settings/crm_enrichment_settings.json new file mode 100644 index 0000000..2e2c953 --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_settings/crm_enrichment_settings.json @@ -0,0 +1,200 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-06-17 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "general_tab", + "enabled", + "automation_section", + "auto_enrich", + "crawl_tab", + "max_pages", + "max_depth", + "use_sitemap", + "request_timeout", + "column_break_crawl", + "max_download_bytes", + "retry_count", + "user_agent", + "tuning_section", + "link_priority_order", + "column_break_tuning", + "skip_patterns", + "ssrf_guard_tab", + "allowed_domains_section", + "allowed_domains", + "blocked_domains_section", + "blocked_domains" + ], + "fields": [ + { + "fieldname": "general_tab", + "fieldtype": "Tab Break", + "label": "General" + }, + { + "default": "1", + "description": "Master switch for the Domain Enrichment feature.", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "fieldname": "automation_section", + "fieldtype": "Section Break", + "label": "Automation" + }, + { + "default": "0", + "description": "Automatically enrich a new CRM Lead, Deal or Organization in the background as soon as it is created (requires a website on the record). Only applies to the doctypes enabled above. When off, enrichment is triggered manually with the Enrich button.", + "fieldname": "auto_enrich", + "fieldtype": "Check", + "label": "Auto-enrich new records" + }, + { + "fieldname": "crawl_tab", + "fieldtype": "Tab Break", + "label": "Crawl" + }, + { + "default": "10", + "description": "Maximum number of pages crawled per enrichment.", + "fieldname": "max_pages", + "fieldtype": "Int", + "label": "Max Pages" + }, + { + "default": "2", + "description": "Maximum link depth followed from the homepage.", + "fieldname": "max_depth", + "fieldtype": "Int", + "label": "Max Depth" + }, + { + "default": "10", + "description": "Per-request timeout in seconds.", + "fieldname": "request_timeout", + "fieldtype": "Int", + "label": "Request Timeout (s)" + }, + { + "default": "1", + "description": "Seed the crawl queue from the site's sitemap (declared in robots.txt, or the conventional /sitemap.xml) so pages not reachable within Max Depth still get a chance to be crawled. This is an addition on top of ordinary link-following, not a replacement -- a site with no sitemap falls straight through to link-following with no error.", + "fieldname": "use_sitemap", + "fieldtype": "Check", + "label": "Use Sitemap" + }, + { + "fieldname": "column_break_crawl", + "fieldtype": "Column Break" + }, + { + "default": "3000000", + "description": "Maximum bytes downloaded per page (the stream is capped).", + "fieldname": "max_download_bytes", + "fieldtype": "Int", + "label": "Max Download Bytes" + }, + { + "default": "2", + "description": "Number of retries on a failed request.", + "fieldname": "retry_count", + "fieldtype": "Int", + "label": "Retry Count" + }, + { + "default": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + "fieldname": "user_agent", + "fieldtype": "Data", + "label": "User Agent" + }, + { + "fieldname": "tuning_section", + "fieldtype": "Section Break", + "label": "Crawl Tuning" + }, + { + "description": "Crawl-ordering hints (not extraction). Links whose URL path or anchor text contain one of these are fetched first; higher weight = sooner. Lets the crawler spend its page budget on company pages (about/contact/team) before the cap. Defaults are seeded; edit or remove as needed.", + "fieldname": "link_priority_order", + "fieldtype": "Table", + "label": "Link Priority Order", + "options": "CRM Enrichment Link Priority" + }, + { + "fieldname": "column_break_tuning", + "fieldtype": "Column Break" + }, + { + "description": "Substrings that exclude a candidate link from the crawl.", + "fieldname": "skip_patterns", + "fieldtype": "Table", + "label": "Skip Patterns", + "options": "CRM Enrichment Skip Pattern" + }, + { + "fieldname": "ssrf_guard_tab", + "fieldtype": "Tab Break", + "label": "SSRF Guard" + }, + { + "fieldname": "allowed_domains_section", + "fieldtype": "Section Break", + "label": "Allowed Domains" + }, + { + "description": "Allowlist (subdomain-aware). If you add any domains here, the crawler may ONLY visit those domains and their subdomains — everything else is rejected by the SSRF guard. Leave empty (the normal case) to allow any public site; this is for locked-down deployments that should reach only specific domains.", + "fieldname": "allowed_domains", + "fieldtype": "Table", + "label": "Allowed Domains", + "options": "CRM Enrichment Domain" + }, + { + "fieldname": "blocked_domains_section", + "fieldtype": "Section Break", + "label": "Blocked Domains" + }, + { + "description": "Denylist (subdomain-aware). A host that equals or is a subdomain of any entry is always rejected by the SSRF guard — e.g. blocking \"acme.com\" also blocks \"shop.acme.com\". Use it to permanently exclude internal domains, known-bad hosts, or link shorteners. Always wins over the allowlist.", + "fieldname": "blocked_domains", + "fieldtype": "Table", + "label": "Blocked Domains", + "options": "CRM Enrichment Domain" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2026-07-14 00:00:00.000000", + "modified_by": "Administrator", + "module": "Domain Enrichment", + "name": "CRM Enrichment Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/domain_enrichment/doctype/crm_enrichment_settings/crm_enrichment_settings.py b/crm/domain_enrichment/doctype/crm_enrichment_settings/crm_enrichment_settings.py new file mode 100644 index 0000000..456a972 --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_settings/crm_enrichment_settings.py @@ -0,0 +1,41 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMEnrichmentSettings(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + from crm.domain_enrichment.doctype.crm_enrichment_domain.crm_enrichment_domain import ( + CRMEnrichmentDomain, + ) + from crm.domain_enrichment.doctype.crm_enrichment_link_priority.crm_enrichment_link_priority import ( + CRMEnrichmentLinkPriority, + ) + from crm.domain_enrichment.doctype.crm_enrichment_skip_pattern.crm_enrichment_skip_pattern import ( + CRMEnrichmentSkipPattern, + ) + + allowed_domains: DF.Table[CRMEnrichmentDomain] + auto_enrich: DF.Check + blocked_domains: DF.Table[CRMEnrichmentDomain] + enabled: DF.Check + link_priority_order: DF.Table[CRMEnrichmentLinkPriority] + max_depth: DF.Int + max_download_bytes: DF.Int + max_pages: DF.Int + request_timeout: DF.Int + retry_count: DF.Int + skip_patterns: DF.Table[CRMEnrichmentSkipPattern] + user_agent: DF.Data | None + # end: auto-generated types + + pass diff --git a/crm/domain_enrichment/doctype/crm_enrichment_skip_pattern/__init__.py b/crm/domain_enrichment/doctype/crm_enrichment_skip_pattern/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/domain_enrichment/doctype/crm_enrichment_skip_pattern/crm_enrichment_skip_pattern.json b/crm/domain_enrichment/doctype/crm_enrichment_skip_pattern/crm_enrichment_skip_pattern.json new file mode 100644 index 0000000..f6984ff --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_skip_pattern/crm_enrichment_skip_pattern.json @@ -0,0 +1,34 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-06-17 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "pattern" + ], + "fields": [ + { + "description": "Substring; a candidate URL containing it is skipped during the crawl (e.g. \"/blog/\", \".pdf\", \"?\").", + "fieldname": "pattern", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Pattern", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-06-17 00:00:00.000000", + "modified_by": "Administrator", + "module": "Domain Enrichment", + "name": "CRM Enrichment Skip Pattern", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/domain_enrichment/doctype/crm_enrichment_skip_pattern/crm_enrichment_skip_pattern.py b/crm/domain_enrichment/doctype/crm_enrichment_skip_pattern/crm_enrichment_skip_pattern.py new file mode 100644 index 0000000..7a5d8b1 --- /dev/null +++ b/crm/domain_enrichment/doctype/crm_enrichment_skip_pattern/crm_enrichment_skip_pattern.py @@ -0,0 +1,23 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMEnrichmentSkipPattern(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + pattern: DF.Data + # end: auto-generated types + + pass diff --git a/crm/domain_enrichment/extractors.py b/crm/domain_enrichment/extractors.py new file mode 100644 index 0000000..11aa2dc --- /dev/null +++ b/crm/domain_enrichment/extractors.py @@ -0,0 +1,700 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""Generic, config-driven extractors. + +No literal keyword/industry/social tables here: classifiers iterate over +``cfg.rules_by_type[...]``, honoring each rule's ``match_scope``, ``weight`` and +compiled patterns. JSON-LD parsing, the favicon scorer, readability diagnosis, +email/phone regex, and company-name cleaning stay as plain mechanics -- not +tunable knowledge, so not pushed into doctypes. +""" + +from __future__ import annotations + +import json +import re +from urllib.parse import urljoin, urlparse + +from .config import INDUSTRY_MIN_CONFIDENCE, INDUSTRY_MIN_SCORE +from .result import ( + Email, + Field, + Method, + Phone, + SocialProfile, +) + +# --------------------------------------------------------------------------- # +# Regexes (mechanics) +# --------------------------------------------------------------------------- # +EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") + +PHONE_RE = re.compile( + r"""(? dict: + """Returns ``{label: weighted_score}`` -- ``label`` is the rule's industry + (Industry rules) or target_value (everything else).""" + scores: dict = {} + for rule in rules: + text = text_by_scope.get(rule.match_scope, "") + hits = rule.matches(text) + if hits: + scores[rule.label] = scores.get(rule.label, 0.0) + hits * (rule.weight or 1.0) + return scores + + +# --------------------------------------------------------------------------- # +# JSON-LD (mechanics) +# --------------------------------------------------------------------------- # +def parse_json_ld(soup): + blocks = [] + for tag in soup.find_all("script", attrs={"type": "application/ld+json"}): + raw = tag.string or tag.get_text() or "" + if not raw.strip(): + continue + try: + data = json.loads(raw) + except (ValueError, TypeError): + continue + if isinstance(data, dict): + if "@graph" in data and isinstance(data["@graph"], list): + blocks.extend([d for d in data["@graph"] if isinstance(d, dict)]) + else: + blocks.append(data) + elif isinstance(data, list): + blocks.extend([d for d in data if isinstance(d, dict)]) + return blocks + + +def _ld_type_matches(block, wanted): + t = block.get("@type", "") + types = t if isinstance(t, list) else [t] + return any(str(x).lower() in wanted for x in types) + + +# --------------------------------------------------------------------------- # +# Readability diagnosis (mechanics) +# --------------------------------------------------------------------------- # +WAF_MARKERS_STRONG = ( + "_incapsula_resource", + "incapsula", + "cf-browser-verification", + "just a moment", + "attention required", + "ddos protection", + "are you a human", +) +WAF_MARKERS_WEAK = ( + "captcha", + "please enable javascript", + "enable cookies to continue", + "access denied", +) +WAF_WEAK_TEXT_THRESHOLD = 1000 + +READABILITY_MESSAGES = { + "unreachable": "Couldn't reach this website (it timed out or refused the connection).", + "blocked": ( + "This website is behind bot protection, so its content can't be read. " + "Try the company's corporate domain (e.g. acme.com vs shop.acme.com)." + ), + "empty": ( + "This site's main content is JavaScript-rendered; only page metadata " + "(title/description/logo) could be read, so some fields may be blank." + ), +} + + +def diagnose_readability(pages): + """Judge the homepage: '' (readable), 'blocked', 'empty', or 'unreachable'.""" + if not pages: + return "unreachable" + home = pages[0] + if home.error or not home.status_code: + return "unreachable" + if home.status_code in (401, 403, 429): + return "blocked" + if home.status_code >= 500: + return "unreachable" + blob = (home.html or "")[:5000].lower() + text_len = len(home.text or "") + if any(marker in blob for marker in WAF_MARKERS_STRONG): + return "blocked" + if text_len < WAF_WEAK_TEXT_THRESHOLD and any(marker in blob for marker in WAF_MARKERS_WEAK): + return "blocked" + if text_len < 200: + return "empty" + return "" + + +# --------------------------------------------------------------------------- # +# Company information (mechanics) +# --------------------------------------------------------------------------- # +def _meta_with_method(soup, *names): + for name in names: + tag = soup.find("meta", attrs={"property": name}) or soup.find("meta", attrs={"name": name}) + if tag and tag.get("content"): + return tag["content"].strip(), name + return "", "" + + +# Below this, a description tag is a stub (a bare company name) rather than copy. +_MIN_META_DESC_LEN = 30 + + +def _is_description_copy(content): + """False for a stub or a verification token. Scripts that do not space + their words (CJK, Thai) are never ASCII-only, so they still count as copy.""" + if len(content) < _MIN_META_DESC_LEN: + return False + return " " in content or not content.isascii() + + +def _meta_description(soup): + fallback = "" + for name in ("og:description", "description"): + content, _name = _meta_with_method(soup, name) + if not content: + continue + if _is_description_copy(content): + return content + fallback = fallback or content + return fallback + + +# A company name is usually the first chunk of a page , before a separator. +_NAME_SPLIT_RE = re.compile(r"\s*\|\s*|\s+[-–—•·]\s+|\s*:\s") +_NAME_TAGLINE_RE = re.compile( + r"\s+(?:has|have|is|are|was|were|offers?|provides?|delivers?|helps?|" + r"enables?|builds?|makes?|creates?|specializes?|brings?|" + r"empowers?|powers?|leads?|connects?)\b", + re.I, +) +_NAME_MAX_WORDS = 6 + + +def _domain_brand(url): + netloc = (urlparse(url or "").netloc or "").lower().split(":")[0] + if netloc.startswith("www."): + netloc = netloc[4:] + labels = [label for label in netloc.split(".") if label] + return labels[-2] if len(labels) >= 2 else (labels[0] if labels else "") + + +def _compact(text): + return re.sub(r"[^a-z0-9]", "", (text or "").lower()) + + +def _clean_company_name(raw, site_url=""): + """Trim a page title / og:title down to just the company name.""" + raw = (raw or "").strip() + if not raw: + return "" + chunks = [c.strip() for c in _NAME_SPLIT_RE.split(raw) if c.strip()] + name = chunks[0] if chunks else raw + + brand = _compact(_domain_brand(site_url)) if site_url else "" + if brand and len(chunks) > 1: + for chunk in chunks: + c = _compact(chunk) + if c and (c == brand or (len(min(c, brand, key=len)) >= 4 and (c in brand or brand in c))): + name = chunk + break + + m = _NAME_TAGLINE_RE.search(name) + if m and m.start() > 0: + name = name[: m.start()].strip() + if len(name.split()) > _NAME_MAX_WORDS: + name = name.split(",")[0].strip() + if len(name.split()) > _NAME_MAX_WORDS: + name = " ".join(name.split()[:_NAME_MAX_WORDS]) + return name or raw + + +# apple-touch-icons are 180px by convention when no explicit size is declared. +_APPLE_TOUCH_PX = 180 + + +def _safe_url(base_url, raw): + """Resolve ``raw`` against ``base_url``; return it only if it's a non-empty + ``http(s)`` URL. Rejects ``javascript:``/``data:`` schemes a crawled site could + inject into a stored logo/image/icon field (stored-XSS defense).""" + raw = (raw or "").strip() + if not raw: + return "" + # urljoin() leaves javascript:/data: schemes as-is, so check the raw scheme + # first, then re-check the resolved URL for relative inputs. + scheme = urlparse(raw).scheme.lower() + if scheme and scheme not in ("http", "https"): + return "" + resolved = urljoin(base_url, raw) + return resolved if urlparse(resolved).scheme in ("http", "https") else "" + + +def _best_icon(soup, base_url): + """Best ``<link rel=icon>`` candidate: scalable SVG beats any raster, then + largest declared size wins.""" + candidates = [] + for tag in soup.find_all("link", href=True): + rel = tag.get("rel") or [] + rel = " ".join(rel).lower() if isinstance(rel, list) else str(rel).lower() + if "icon" not in rel: + continue + href = tag["href"].strip() + safe = _safe_url(base_url, href) + if not safe: + continue + is_svg = ( + "svg" in (tag.get("type") or "").lower() + or href.lower().split("?")[0].endswith(".svg") + or "mask-icon" in rel + ) + m = re.search(r"(\d+)x\d+", tag.get("sizes", "") or "") + px = int(m.group(1)) if m else (_APPLE_TOUCH_PX if "apple-touch" in rel else 0) + candidates.append(((1 if is_svg else 0, px), safe)) + if not candidates: + return "" + candidates.sort(key=lambda c: c[0], reverse=True) + return candidates[0][1] + + +def extract_logo(soup, base_url): + """The company's link icon -- a crisp, square brand mark, not the wider + social-share image (``og:image`` is usually a banner; see :func:`extract_image`). + Priority: SVG > largest declared raster/apple-touch > ``/favicon.ico`` fallback. + """ + icon_url = _best_icon(soup, base_url) + if icon_url: + return Field(icon_url, base_url, Method.FAVICON) + + parsed = urlparse(base_url) + if parsed.scheme and parsed.netloc: + return Field(f"{parsed.scheme}://{parsed.netloc}/favicon.ico", base_url, Method.FAVICON) + return Field() + + +def extract_image(soup, base_url): + """The larger brand/social image (JSON-LD ``Organization.logo`` -> ``og:image``/ + ``twitter:image``), NOT the company logo -- see :func:`extract_logo`. Returns a + :class:`Field`, empty if none declared.""" + for block in parse_json_ld(soup): + if not _ld_type_matches(block, _ORG_TYPES): + continue + logo = block.get("logo") + url = logo.get("url") if isinstance(logo, dict) else (logo if isinstance(logo, str) else "") + safe = _safe_url(base_url, url) + if safe: + return Field(safe, base_url, Method.JSON_LD) + + og_content, _name = _meta_with_method(soup, "og:image", "og:image:url", "twitter:image") + safe = _safe_url(base_url, og_content) + if safe: + return Field(safe, base_url, Method.META_TAG) + return Field() + + +# schema.org @types that denote a company/organization. +_ORG_TYPES = { + "organization", + "corporation", + "localbusiness", + "ngo", + "educationalorganization", + "softwareapplication", +} + +# Full-segment match, not a prefix: "about-our-reporting" (an ESG page) must not +# qualify just because it starts with "about-". +_ABOUT_SEGMENT_RE = re.compile( + r"^(about|about-us|about_us|aboutus|company|who-we-are|our-story|overview|mission)$", + re.I, +) +_NON_ABOUT_WORDS = ( + "privacy", + "career", + "job", + "partner", + "webinar", + "policy", + "policies", + "accessibility", + "conduct", + "terms", + "cookie", + "legal", + "press", + "news", + "blog", + "event", + "investor", + "support", + "contact", + "sitemap", + "login", + "award", +) +_COMMERCE_RE = re.compile(r"^\s*(shop|buy|order|browse)\b|add to cart|shop now|buy now", re.I) + + +def _last_path_segment(url): + path = urlparse(url).path.rstrip("/") + return path.rsplit("/", 1)[-1].lower() if path else "" + + +def _is_about_page(url): + seg = _last_path_segment(url) + if not seg or any(w in seg for w in _NON_ABOUT_WORDS): + return False + return bool(_ABOUT_SEGMENT_RE.match(seg)) + + +def extract_company_info(homepage, soup): + url = homepage.url + info = { + "company_name": Field(), + "description": Field(), + "logo": Field(), + "image": Field(), + "social_links": [], + } + + for block in parse_json_ld(soup): + if not _ld_type_matches(block, _ORG_TYPES): + continue + if not info["company_name"].value and block.get("name"): + info["company_name"] = Field(block["name"].strip(), url, Method.JSON_LD) + if not info["description"].value and block.get("description"): + info["description"] = Field(block["description"].strip(), url, Method.JSON_LD) + same_as = block.get("sameAs") + if isinstance(same_as, list): + info["social_links"].extend([s for s in same_as if isinstance(s, str)]) + elif isinstance(same_as, str): + info["social_links"].append(same_as) + break # first matching org block wins + + if not info["company_name"].value: + content, _name = _meta_with_method(soup, "og:site_name", "og:title") + if content: + info["company_name"] = Field(_clean_company_name(content, url), url, Method.META_TAG) + elif homepage.title: + info["company_name"] = Field(_clean_company_name(homepage.title, url), url, Method.TITLE_TAG) + + if not info["description"].value: + content = _meta_description(soup) + if content: + info["description"] = Field(content, url, Method.META_TAG) + + info["logo"] = extract_logo(soup, url) + info["image"] = extract_image(soup, url) + + return info + + +_NON_CONTENT_PARENTS = {"nav", "header", "footer", "aside", "form"} +_MIN_PARAGRAPH_LEN = 80 +_MAX_PARAGRAPHS_SCANNED = 6 + +# Company copy speaks for itself ("we"/"our"). A staff profile is written in the +# third person: a career phrase, a job title plus a pronoun, or repeated pronouns. +_FIRST_PERSON_RE = re.compile(r"\b(?:we|our|ours|us)\b", re.I) +_PRONOUN_RE = re.compile(r"\b(?:he|she|his|her)\b", re.I) +_CAREER_RE = re.compile(r"\bbegan (?:his|her) career\b|\bprior to (?:joining|founding)\b", re.I) +_TITLE_RE = re.compile( + r"\bis the (?:founder|co-founder|ceo|cto|cfo|chief|managing director|president)\b", re.I +) +_CTA_RE = re.compile( + r"^\s*(?:explore|discover|learn|find out|contact|call|browse|shop|buy|order|" + r"read more|get in touch|see|check out|sign up|subscribe|join)\b", + re.I, +) + + +def _company_name_hits(text, company_name): + name = (company_name or "").strip() + if not name: + return 0 + return len(re.findall(rf"\b{re.escape(name)}\b", text, re.IGNORECASE)) + + +def _is_staff_profile(text): + if _CAREER_RE.search(text): + return True + if _FIRST_PERSON_RE.search(text): + return False + pronouns = len(_PRONOUN_RE.findall(text)) + return pronouns >= 2 or (pronouns >= 1 and bool(_TITLE_RE.search(text))) + + +def _describes_company(text, company_name): + """True when the paragraph talks about the company, not something else.""" + return bool(_FIRST_PERSON_RE.search(text)) or _company_name_hits(text, company_name) > 0 + + +def first_paragraph(soup, industry_rules=None, company_name=""): + """Best substantial body paragraph on a page. + + Reads up to ``_MAX_PARAGRAPHS_SCANNED`` qualifying ``<p>`` tags, drops the ones + that describe a person or tell the reader what to do next, and ranks the rest by + Industry-rule keyword hits (the same corpus ``classify_industry`` uses). Talking + about the company and naming it only break ties, never outrank keyword hits. If + every paragraph was dropped they are all reconsidered, so a page of nothing but + staff profiles still returns something. Read-only -- does not mutate ``soup``. + """ + candidates = [] + for p in soup.find_all("p"): + if any(parent.name in _NON_CONTENT_PARENTS for parent in p.parents): + continue + text = " ".join((p.get_text(" ") or "").split()) + if len(text) < _MIN_PARAGRAPH_LEN or " " not in text: + continue + candidates.append(text) + if len(candidates) >= _MAX_PARAGRAPHS_SCANNED: + break + + if not candidates: + return "" + candidates = [ + text for text in candidates if not (_is_staff_profile(text) or _CTA_RE.match(text)) + ] or candidates + + # max() keeps the first-encountered element on ties, so document order still + # wins when nothing scores higher (covers the "all zero hits" case for free). + # The trailing slots can only break ties, never add to the industry-hit count. + return max( + candidates, + key=lambda text: ( + sum(rule.matches(text) for rule in industry_rules or []), + _describes_company(text, company_name), + _company_name_hits(text, company_name), + ), + ) + + +def select_description(pages, soups_by_url, industry_rules=None, company_name=""): + """Pick the best *company* description across crawled pages. + + Priority: JSON-LD ``Organization.description`` -> About-page body paragraph -> + head ``og:description``/``<meta description>`` -> home-page body paragraph + (last resort). ``industry_rules``/``company_name`` steer the body-paragraph + fallbacks (see ``first_paragraph``). + """ + home_url = pages[0].url if pages else "" + best_key = None + best_field = None + + def consider(field_obj, score): + nonlocal best_key, best_field + if not field_obj.value: + return + key = (score, min(len(field_obj.value), 300)) + if best_key is None or key > best_key: + best_key, best_field = key, field_obj + + for page in pages: + soup = soups_by_url.get(page.url) + if soup is None: + continue + + is_home = page.url == home_url + is_about = _is_about_page(page.url) + + for block in parse_json_ld(soup): + if _ld_type_matches(block, _ORG_TYPES) and block.get("description"): + consider(Field(block["description"].strip(), page.url, Method.JSON_LD), 6) + break + + if is_about: + consider( + Field(first_paragraph(soup, industry_rules, company_name), page.url, Method.BODY_TEXT), 5 + ) + + if is_home or is_about: + content = _meta_description(soup) + if content: + score = 2 if (is_home and _COMMERCE_RE.search(content)) else 4 + consider(Field(content, page.url, Method.META_TAG), score) + + if is_home: + consider( + Field(first_paragraph(soup, industry_rules, company_name), page.url, Method.BODY_TEXT), 1 + ) + + return best_field or Field() + + +# --------------------------------------------------------------------------- # +# Contacts: emails, phones (mechanics) +# --------------------------------------------------------------------------- # +def _clean_email(email): + email = email.strip().strip(".").lower() + if any(b in email for b in EMAIL_BLOCKLIST_SUBSTR): + return None + return email + + +def extract_emails(pages): + found = {} + for page in pages: + if not page.html: + continue + for match in EMAIL_RE.findall(page.html): + cleaned = _clean_email(match) + if cleaned and cleaned not in found: + found[cleaned] = Email(value=cleaned, source=page.url) + return list(found.values()) + + +# TODO: Need to make this robust by adding proper mechanics or using a +# thirdparty library like google/libphonenumbers +def _valid_phone(raw): + # Requires an explicit "+" country-code prefix: a bare digit string is either + # ambiguous local formatting or noise (stats, reference numbers) that happens + # to look phone-shaped -- precision over recall. + if not raw.strip().startswith("+"): + return False + digits = re.sub(r"\D", "", raw) + return 7 <= len(digits) <= 15 + + +def extract_phones(pages): + found = {} + for page in pages: + if not page.html: + continue + for m in re.findall(r"tel:([+0-9()\s.\-]{7,})", page.html): + raw = m.strip() + digits = re.sub(r"\D", "", raw) + if _valid_phone(raw) and digits not in found: + found[digits] = Phone( + value="+" + digits if raw.strip().startswith("+") else digits, + raw=raw, + source=page.url, + method=Method.TEL_LINK, + ) + for match in PHONE_RE.finditer(page.text or ""): + raw = match.group(0).strip() + if not raw: + continue + digits = re.sub(r"\D", "", raw) + if _valid_phone(raw) and digits not in found: + value = ("+" + digits) if raw.startswith("+") else digits + found[digits] = Phone(value=value, raw=raw, source=page.url, method=Method.REGEX) + return list(found.values()) + + +# --------------------------------------------------------------------------- # +# Social profiles (config-driven via Social rules) +# --------------------------------------------------------------------------- # +def extract_social_profiles(pages, soups_by_url, social_rules, extra_links=None): + """Return dict network -> SocialProfile(value, source, method); each Social + rule's ``target_value`` is the network name.""" + profiles = {rule.target_value: SocialProfile() for rule in social_rules} + + home_url = pages[0].url if pages else "" + candidates = [(link, home_url, Method.JSON_LD) for link in (extra_links or [])] + for page in pages: + soup = soups_by_url.get(page.url) + if soup is None: + continue + for a in soup.find_all("a", href=True): + candidates.append((a["href"].strip(), page.url, Method.SOCIAL_RULE)) + + for href, source, method in candidates: + if not href or SOCIAL_NEGATIVE.search(href): + continue + url = urljoin(source, href.strip()) + for rule in social_rules: + network = rule.target_value + if profiles[network].value: + continue + if any(rx.search(url) for rx, _raw in rule.patterns): + profiles[network] = SocialProfile(value=url, source=source, method=method) + return profiles + + +# --------------------------------------------------------------------------- # +# Industry classification (config-driven via Industry rules) +# --------------------------------------------------------------------------- # +def classify_industry(pages, company_info, industry_rules): + """Rule-based industry from the homepage's (and, when crawled, the About page's) + headline corpus. Industry rules are typically seeded with ``match_scope='Headline'`` + (company name/description/title/headings, never body/footer). The homepage alone + is often thin (stylized brand copy, no meta description), so a crawled About + page's title/headings are folded in too. Returns (industry, confidence 0-1), or + ("", 0.0) when the signal is too weak. + """ + if not industry_rules: + return "", 0.0 + + def _val(key): + f = company_info.get(key) + return getattr(f, "value", f) or "" + + headline_parts = [_val("company_name"), _val("description")] + if pages: + home = pages[0] + headline_parts.append(home.title or "") + headline_parts.append(" ".join(home.headings)) + about = next((p for p in pages[1:] if _is_about_page(p.url)), None) + if about: + headline_parts.append(about.title or "") + headline_parts.append(" ".join(about.headings)) + headline = " ".join(p for p in headline_parts if p).lower() + + # Same corpus under multiple scope keys so admins can re-scope rules without + # code changes; default seed uses Headline. + full_text = " ".join((p.text or "") for p in pages).lower() + text_by_scope = { + SCOPE_HEADLINE: headline, + SCOPE_FULL_TEXT: full_text, + SCOPE_HTML: full_text, + } + + scores = apply_keyword_rules(text_by_scope, industry_rules) + total = sum(scores.values()) + if not total: + return "", 0.0 + best = max(scores, key=scores.get) + if scores[best] < INDUSTRY_MIN_SCORE: + return "", 0.0 + confidence = scores[best] / total + if confidence < INDUSTRY_MIN_CONFIDENCE: + return "", 0.0 + return best, round(confidence, 2) diff --git a/crm/domain_enrichment/http.py b/crm/domain_enrichment/http.py new file mode 100644 index 0000000..ee3617a --- /dev/null +++ b/crm/domain_enrichment/http.py @@ -0,0 +1,382 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""HTTP fetch layer for the crawler, built on framework helpers, with an SSRF guard. + +Built on ``frappe.utils.get_request_session`` (so retry/adapter behavior is the +framework's), this module adds the crawler-specific behavior the framework does NOT +provide: + +* a hard download cap (``max_download_bytes``) via streamed reads, +* an HTML-only content-type filter, +* the never-raise ``(status_code, html, error)`` contract, and +* a mandatory **SSRF guard** that resolves the hostname and rejects loopback / + private / link-local / reserved addresses, honors the Settings allow/block lists, + and **re-validates the resolved IP after every redirect** (it does not blindly + follow redirects). Every connection is **pinned to the IP the guard validated** + (Host header and TLS SNI/verification stay on the original hostname), so a + DNS-rebinding host cannot pass validation with one address and connect to another. + +Limits (timeout, byte cap, retries, user-agent) come from config, not constants. +""" + +from __future__ import annotations + +import ipaddress +import re +import socket +from urllib.parse import urlparse + +import frappe +from frappe.utils import get_request_session +from requests.adapters import HTTPAdapter, Retry + +HTML_CONTENT_TYPES = ("text/html", "application/xhtml") +RETRY_STATUS_FORCELIST = (429, 500, 502, 503, 504) +# Cap redirect chases ourselves (we follow manually to re-check each hop). +MAX_REDIRECTS = 5 +# Default crawler UA: a current desktop-Chrome string so bot walls that reject bare +# clients serve HTML. Overridable via the CRM Enrichment Settings ``user_agent`` field. +DEFAULT_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" +) + + +class SSRFError(Exception): + """Raised internally when a URL fails the SSRF guard. Never escapes ``fetch``.""" + + +# --------------------------------------------------------------------------- # +# SSRF guard +# --------------------------------------------------------------------------- # +def _domain_in_list(host: str, domains: list) -> bool: + """True if ``host`` equals or is a subdomain of any entry in ``domains``. + + The trailing dot of a fully-qualified name (``evil.com.``) is stripped from both + sides -- it resolves identically in DNS, so leaving it on would let ``evil.com.`` + slip past a ``evil.com`` block/allow rule. + """ + host = (host or "").lower().split(":")[0].rstrip(".") + for d in domains: + d = (d or "").lower().strip().rstrip(".") + if not d: + continue + if host == d or host.endswith("." + d): + return True + return False + + +def _is_blocked_ip(ip: str) -> bool: + """True if ``ip`` is not a public (globally-routable) address. + + Mirrors the framework's own SSRF check (``make_safe_get_request`` in + frappe.utils.safe_exec): ``not is_global`` rejects loopback, private, link-local, + reserved and unspecified addresses -- and also shared/CGNAT ranges (100.64.0.0/10, + 0.0.0.0/8) that an explicit flag enumeration tends to miss. ``is_global`` does NOT + cover multicast, so keep an explicit reject for it (the framework helper misses it). + """ + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return True # unparseable -> reject + return not addr.is_global or addr.is_multicast + + +def _resolve_ips(host: str) -> list: + """Resolve ``host`` to its IP strings via ``socket.getaddrinfo``.""" + infos = socket.getaddrinfo(host, None) + # index 4 is sockaddr; element 0 is the IP for both AF_INET / AF_INET6. + return list({info[4][0] for info in infos}) + + +def validate_url(url: str, cfg) -> str: + """SSRF guard entry point. Returns the validated URL or raises ``SSRFError``. + + Validates the scheme, then resolves the hostname and rejects any URL that + resolves to a loopback/private/link-local/reserved address. Honors the + Settings allow/block domain lists: a blocked domain is always rejected; if an + allow list exists the host must be on it. The private-network rejection is + unconditional -- there is no setting to disable it (that would be a pure SSRF + footgun for a crawler that fetches user-supplied URLs). + """ + _validated_ips(url, cfg) + return url + + +def _validated_ips(url: str, cfg) -> list: + """Run the full SSRF checks for ``url`` and return the resolved, validated IPs. + + The caller connects to one of these exact addresses (see ``_pinned_get``), so + the IP that was checked is the IP that serves the request. Raises ``SSRFError`` + on any rejection. + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise SSRFError(f"unsupported URL scheme: {parsed.scheme or '(none)'}") + + # Strip the trailing dot of a fully-qualified name at the source, so the SAME + # canonical host feeds the allow/block check AND DNS resolution -- ``evil.com.`` + # resolves identically to ``evil.com`` and must not bypass either. + host = (parsed.hostname or "").strip().rstrip(".") + if not host: + raise SSRFError("URL has no host") + + blocked = cfg.blocked_domains if cfg else [] + allowed = cfg.allowed_domains if cfg else [] + if _domain_in_list(host, blocked): + raise SSRFError(f"host is on the blocked-domains list: {host}") + if allowed and not _domain_in_list(host, allowed): + raise SSRFError(f"host is not on the allowed-domains list: {host}") + + try: + ips = _resolve_ips(host) + except socket.gaierror as exc: + raise SSRFError(f"could not resolve host {host}: {exc}") from exc + if not ips: + raise SSRFError(f"could not resolve host {host}") + for ip in ips: + if _is_blocked_ip(ip): + raise SSRFError(f"host {host} resolves to a non-public address: {ip}") + + # Deterministic order, IPv4 first: the caller pins the first entry, and unlike + # requests' resolver it won't fall back across address families, so an IPv6 + # answer must not shadow a reachable IPv4 one on v4-only networks. + return sorted(ips, key=lambda ip: (":" in ip, ip)) + + +# --------------------------------------------------------------------------- # +# Session +# --------------------------------------------------------------------------- # +def build_session(cfg=None): + """Build the crawler session on the framework helper. + + Sets a browser-like header set (User-Agent from Settings + Accept / Sec-Fetch / + client-hint headers a real browser sends) so servers that reject bare clients + still serve HTML, and customizes the retry status_forcelist for 429/5xx. Note: + this only spoofs headers -- the TLS fingerprint is still that of ``requests`` + (see the TLS note in CONTEXT.md). + """ + retries = int(cfg.setting("retry_count")) if cfg else 2 + user_agent = cfg.setting("user_agent") if cfg else DEFAULT_USER_AGENT + session = get_request_session(max_retries=retries) + # get_request_session only forces 500; widen to the usual transient set. + try: + adapter = HTTPAdapter(max_retries=_retry_policy(retries)) + session.mount("http://", adapter) + session.mount("https://", adapter) + except Exception: # pragma: no cover - keep the framework default on any issue + pass + session.headers.update( + { + "User-Agent": user_agent, + "Accept": ( + "text/html,application/xhtml+xml,application/xml;q=0.9," + "image/avif,image/webp,image/apng,*/*;q=0.8" + ), + "Accept-Language": "en-US,en;q=0.9", + # Browser-ish request context so bot walls that check these serve HTML. + # (Accept-Encoding is left to requests so it only advertises codecs it can + # actually decode.) + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Sec-CH-UA": '"Chromium";v="125", "Not.A/Brand";v="24", "Google Chrome";v="125"', + "Sec-CH-UA-Mobile": "?0", + "Sec-CH-UA-Platform": '"Windows"', + } + ) + return session + + +def _retry_policy(retries: int) -> Retry: + return Retry( + total=retries, + backoff_factor=0.3, + status_forcelist=RETRY_STATUS_FORCELIST, + allowed_methods=frozenset(["GET", "HEAD"]), + ) + + +# --------------------------------------------------------------------------- # +# Pinned connections (DNS-rebinding defense) +# --------------------------------------------------------------------------- # +class _PinnedIPAdapter(HTTPAdapter): + """Transport adapter for URLs whose netloc was rewritten to a pre-validated IP. + + ``server_hostname`` keeps TLS SNI on the original hostname, and urllib3 also + matches the certificate against it (it falls back to ``server_hostname`` when + ``assert_hostname`` is unset), so pinning does not weaken TLS verification. + urllib3 strips ``server_hostname`` for plain-HTTP pools, so the same adapter + serves both schemes. + """ + + def __init__(self, hostname: str, **kwargs): + self._server_hostname = hostname + super().__init__(**kwargs) + + def init_poolmanager(self, *args, **kwargs): + kwargs["server_hostname"] = self._server_hostname + super().init_poolmanager(*args, **kwargs) + + +def _pinned_adapter(session, hostname: str, retries: int) -> _PinnedIPAdapter: + """Per-hostname adapter cache on the session, so keep-alive pooling survives + across the pages of a crawl instead of reconnecting for every request.""" + cache = getattr(session, "_pinned_adapters", None) + if cache is None: + cache = {} + session._pinned_adapters = cache + adapter = cache.get(hostname) + if adapter is None: + adapter = _PinnedIPAdapter(hostname, max_retries=_retry_policy(retries)) + cache[hostname] = adapter + return adapter + + +def _pinned_get(session, url: str, pinned_ip: str, timeout: int, retries: int): + """GET ``url`` connecting to ``pinned_ip`` -- an address the SSRF guard just + validated -- so a DNS answer that changes between validation and connection + (rebinding) cannot steer the socket somewhere else. Only the socket target is + rewritten: the Host header, TLS SNI and certificate verification all stay on + the original hostname. + """ + import requests + + parsed = urlparse(url) + host = parsed.hostname or "" + ip_netloc = f"[{pinned_ip}]" if ":" in pinned_ip else pinned_ip + if parsed.port: + ip_netloc = f"{ip_netloc}:{parsed.port}" + host_header = f"{host}:{parsed.port}" if parsed.port else host + + prepared = session.prepare_request( + requests.Request( + "GET", + parsed._replace(netloc=ip_netloc).geturl(), + headers={"Host": host_header}, + ) + ) + return _pinned_adapter(session, host, retries).send( + prepared, + timeout=timeout, + stream=True, + verify=session.verify, + cert=session.cert, + ) + + +# --------------------------------------------------------------------------- # +# Fetch +# --------------------------------------------------------------------------- # +_META_CHARSET_RE = re.compile(rb"""charset=["']?\s*([a-zA-Z0-9_\-]+)""", re.IGNORECASE) + + +def _sniff_html_charset(raw: bytes) -> str | None: + """Charset declared in a <meta> tag (handles both `<meta charset=...>` and + `<meta http-equiv content="...; charset=...">`); None if absent.""" + match = _META_CHARSET_RE.search(raw[:4096]) + if match: + try: + return match.group(1).decode("ascii") + except (UnicodeDecodeError, AttributeError): + return None + return None + + +def _read_capped(resp, max_bytes: int) -> str: + chunks = [] + total = 0 + for chunk in resp.iter_content(chunk_size=16_384, decode_unicode=False): + if not chunk: + continue + total += len(chunk) + chunks.append(chunk) + if total >= max_bytes: + break + raw = b"".join(chunks) + # requests defaults text/* without an explicit charset to ISO-8859-1, which + # mojibakes UTF-8 pages that only declare their charset in a <meta> tag. When the + # header carries no charset, trust the document's own declaration instead. + if "charset=" in resp.headers.get("Content-Type", "").lower(): + encoding = resp.encoding or "utf-8" + else: + encoding = _sniff_html_charset(raw) or "utf-8" + try: + return raw.decode(encoding, errors="replace") + except (LookupError, TypeError): + return raw.decode("utf-8", errors="replace") + + +def fetch(url: str, cfg, session=None, html_only: bool = True): + """Fetch a URL and return ``(status_code, html, error, final_url)``. + + Never raises -- any failure (SSRF rejection, timeout, non-HTML, oversized) is + reported as a non-empty ``error`` string with ``status_code`` 0 (or the actual + status for content-type skips). Redirects are followed manually so the resolved + IP of every hop is re-validated by the SSRF guard; ``final_url`` is the URL that + actually served the body (post-redirect), so callers resolve relative links and + record provenance against the right host. Pass ``html_only=False`` to accept + non-HTML bodies (e.g. ``text/plain`` robots.txt). + + SSRF note: each hop connects to the exact IP the guard validated for it + (``_pinned_get``), so a DNS-rebinding host (short TTL, alternating answers) + cannot pass validation with a public address and serve the request from a + private one. + """ + import requests + + timeout = int(cfg.setting("request_timeout")) if cfg else 10 + max_bytes = int(cfg.setting("max_download_bytes")) if cfg else 3_000_000 + retries = int(cfg.setting("retry_count")) if cfg else 2 + + own_session = session is None + session = session or build_session(cfg) + current = url + try: + for _hop in range(MAX_REDIRECTS + 1): + try: + ips = _validated_ips(current, cfg) + except SSRFError as exc: + return 0, "", f"blocked by SSRF guard: {exc}", current + + # Redirects are followed manually (each hop re-validated and re-pinned). + resp = _pinned_get(session, current, ips[0], timeout, retries) + + if resp.is_redirect or resp.is_permanent_redirect: + location = resp.headers.get("Location") + resp.close() + if not location: + return resp.status_code, "", "redirect without Location header", current + current = requests.compat.urljoin(current, location) + continue + + content_type = resp.headers.get("Content-Type", "").lower() + if html_only and content_type and not any(ct in content_type for ct in HTML_CONTENT_TYPES): + status = resp.status_code + resp.close() + return status, "", f"skipped non-HTML content-type: {content_type}", current + + html = _read_capped(resp, max_bytes) + status = resp.status_code + resp.close() + return status, html, "", current + + return 0, "", "too many redirects", current + except requests.exceptions.Timeout: + return 0, "", f"timeout after {timeout}s", current + except requests.exceptions.TooManyRedirects: + return 0, "", "too many redirects", current + except requests.exceptions.SSLError as exc: + return 0, "", f"ssl error: {exc}", current + except requests.exceptions.ConnectionError as exc: + return 0, "", f"connection error: {exc}", current + except requests.exceptions.RequestException as exc: + return 0, "", f"request error: {exc}", current + except Exception as exc: # pragma: no cover - last-resort guard + return 0, "", f"unexpected error: {exc}", current + finally: + if own_session: + session.close() diff --git a/crm/domain_enrichment/install.py b/crm/domain_enrichment/install.py new file mode 100644 index 0000000..eb5010c --- /dev/null +++ b/crm/domain_enrichment/install.py @@ -0,0 +1,397 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""One-time code-to-data migration of the enrichment knowledge base. + +The POC baked its keyword / signature / mapping tables into Python constants. +This seeder translates them into editable desk records (CRM Enrichment Rule + +Pattern, CRM Enrichment Field Mapping) so a sales-ops admin owns the knowledge. + +Everything here is idempotent (skip-if-exists) and wired into both ``after_install`` +and ``after_migrate`` so a fresh install and every migrate land the defaults +without clobbering admin edits. +""" + +import frappe + +# --------------------------------------------------------------------------- # +# Rule knowledge (recovered from the POC's extractors.py constant tables) +# --------------------------------------------------------------------------- # + +# industry -> keywords (matched against headline text, weight 1 each) +INDUSTRY_KEYWORDS = { + "ERP": [ + "erp", + "enterprise resource planning", + "procurement", + "supply chain management", + "inventory management", + ], + "CRM": [ + "crm", + "customer relationship management", + "sales pipeline", + "lead management", + "contact management", + ], + "Ecommerce": [ + "ecommerce", + "e-commerce", + "online shopping", + "online store", + "marketplace", + "shopping cart", + "add to cart", + "add to bag", + "storefront", + "checkout", + "shop", + "free shipping", + "free returns", + "new arrivals", + "best sellers", + "bestsellers", + "size guide", + "apparel", + "clothing brand", + "direct-to-consumer", + "quick commerce", + "q-commerce", + "online grocery", + "online groceries", + "grocery delivery", + "grocery app", + "delivery app", + "online shop", + ], + "SaaS": ["saas", "software as a service", "subscription software", "cloud platform", "free trial"], + "Technology": [ + "software", + "developer", + "developers", + "api", + "open source", + "cloud computing", + "it services", + "information technology", + "artificial intelligence", + "machine learning", + ], + "Semiconductors": [ + "semiconductor", + "gpu", + "graphics card", + "processor", + "chipset", + "supercomputer", + "microprocessor", + ], + "Manufacturing": [ + "manufacturing", + "factory", + "production line", + "assembly line", + "fabrication", + "industrial equipment", + "3d printing", + "additive manufacturing", + "cnc machining", + "rapid prototyping", + "injection molding", + "prototyping", + ], + "Healthcare": [ + "healthcare", + "health care", + "clinic", + "hospital", + "patient care", + "medical", + "telehealth", + "pharmaceutical", + "pharma", + ], + "Education": [ + "education", + "e-learning", + "students", + "curriculum", + "university", + "online courses", + "courses", + "course", + "edtech", + "learning platform", + ], + "Consulting": [ + "consulting", + "advisory services", + "consultants", + "professional services", + "strategy consulting", + ], + "Marketing": [ + "marketing agency", + "digital marketing", + "advertising agency", + "seo services", + "ad campaigns", + "media buying", + ], + "Finance": [ + "fintech", + "banking", + "payments", + "lending", + "insurance", + "financial services", + "trading", + "wealth management", + ], + "Logistics": [ + "logistics", + "freight", + "warehousing", + "fulfillment", + "shipping carrier", + "last-mile delivery", + ], + "Food & Beverage": [ + "food and beverage", + "beverage", + "beverages", + "snacks", + "snack", + "packaged food", + "fmcg", + "consumer goods", + "sparkling water", + "iced tea", + "energy drink", + "soft drink", + "drink", + "drinks", + "soda", + "kombucha", + "cereal", + "coffee", + "chocolate", + "protein bar", + ], + "Retail": ["retailer", "department store", "brick and mortar"], + "Media": [ + "entertainment", + "streaming service", + "video game", + "game studio", + "publishing house", + "news media", + "broadcasting", + "journalism", + "journalists", + "media company", + "newsroom", + "newsletter", + "reported articles", + "investigative", + "editorial", + "magazine", + "podcast", + "reporting on", + ], + "Real Estate": [ + "real estate", + "property management", + "commercial property", + "interior design", + "home design", + "home improvement", + "remodeling", + "decorating", + "home renovation", + ], + "Travel": ["hospitality", "tourism", "flight booking", "travel agency"], + "Automotive": ["automotive", "electric vehicle", "car manufacturer"], + "Telecom": ["telecommunications", "broadband", "network operator"], + "Energy": ["renewable energy", "solar power", "oil and gas"], + "Legal": [ + "law firm", + "legal services", + "litigation", + "legal help", + "legal documents", + "legal advice", + "attorney", + "attorneys", + "lawyer", + "lawyers", + ], + "Nonprofit": ["nonprofit", "non-profit", "ngo", "charity"], +} + +# social network -> regex (matched against HTML; stored as regex patterns) +SOCIAL_PATTERNS = { + "linkedin": [r"linkedin\.com/(company|in|school)/"], + "twitter": [r"(twitter\.com|x\.com)/[A-Za-z0-9_]+"], + "github": [r"github\.com/[A-Za-z0-9_.-]+"], + "facebook": [r"facebook\.com/[A-Za-z0-9_.\-/]+"], + "instagram": [r"instagram\.com/[A-Za-z0-9_.]+"], + "youtube": [r"youtube\.com/(channel/|c/|user/|@)[A-Za-z0-9_.\-]+"], +} + +# --------------------------------------------------------------------------- # +# Field-mapping knowledge (recovered from the POC's api/website_intelligence.py) +# Translates FIELD_MAP / OVERRIDABLE_DEFAULTS / ALWAYS_REFRESH into records. +# (source_key, target_doctype, target_fieldname, write_policy, default_values, create_missing_link) +# --------------------------------------------------------------------------- # +_LEAD = "CRM Lead" +_DEAL = "CRM Deal" +_ORG = "CRM Organization" + +# Per-doctype the org "name" lands on a different field: Lead.organization, +# Deal.organization_name (free text), Org.organization_name. +FIELD_MAPPINGS = [ + # company name + ("company_name", _LEAD, "organization", "Fill if empty", None, 0), + ("company_name", _DEAL, "organization_name", "Fill if empty", None, 0), + ("company_name", _ORG, "organization_name", "Fill if empty", None, 0), + # logo / description (shared) + *[ + (k, dt, fn, "Fill if empty", None, 0) + for dt in (_LEAD, _DEAL, _ORG) + for k, fn in (("logo", "organization_logo"), ("description", "company_description")) + ], + # industry — link, opt-in auto-create of missing CRM Industry + *[("industry", dt, "industry", "Fill if empty", None, 1) for dt in (_LEAD, _DEAL, _ORG)], + # social links (shared, fill-empty) + *[ + (k, dt, fn, "Fill if empty", None, 0) + for dt in (_LEAD, _DEAL, _ORG) + for k, fn in (("linkedin", "linkedin"), ("twitter", "twitter"), ("facebook", "facebook")) + ], +] + + +def seed_default_rules_and_mappings(): + """Idempotently create the default enrichment rules + field mappings. + + Safe to run repeatedly (after_install + after_migrate): each record is keyed + on a stable name and skipped if it already exists, so admin edits are never + clobbered. + """ + _seed_industry_rules() + _seed_social_rules() + _seed_field_mappings() + _seed_settings_defaults() + + +def _seed_settings_defaults(): + """Embed the default Link Priority Order rows into Settings. + + Idempotent: only populated when the table is empty, so admin edits/removals are + never clobbered. The default list lives in ``config.DEFAULT_LINK_PRIORITY``. + """ + from crm.domain_enrichment.config import DEFAULT_LINK_PRIORITY + + settings = frappe.get_single("CRM Enrichment Settings") + if settings.link_priority_order: + return + for keyword, weight in DEFAULT_LINK_PRIORITY: + settings.append("link_priority_order", {"keyword": keyword, "weight": weight}) + settings.save(ignore_permissions=True) + + +def _ensure_industry(industry: str): + if not frappe.db.exists("CRM Industry", industry): + frappe.get_doc({"doctype": "CRM Industry", "industry": industry}).insert(ignore_permissions=True) + + +def _make_rule( + rule_name, + rule_type, + patterns, + *, + target_value=None, + industry=None, + weight=1, + match_scope="Full Text", + is_regex=False, +): + # Match on the rule_name field, not the document name: the autoname + # ("{rule_type}.-.####") means name != rule_name, so a bare-string exists() + # never matches a seeded rule and re-seeding would collide with the unique + # rule_name constraint (breaking bench migrate). rule_name stays out of the + # name because it is free text (colons/spaces) that a naming series rejects. + if frappe.db.exists("CRM Enrichment Rule", {"rule_name": rule_name}): + return + doc = frappe.get_doc( + { + "doctype": "CRM Enrichment Rule", + "rule_name": rule_name, + "rule_type": rule_type, + "target_value": target_value, + "industry": industry, + "weight": weight, + "match_scope": match_scope, + "enabled": 1, + "patterns": [{"pattern": p, "is_regex": 1 if is_regex else 0} for p in patterns], + } + ) + doc.insert(ignore_permissions=True) + + +def _seed_industry_rules(): + for industry, keywords in INDUSTRY_KEYWORDS.items(): + _ensure_industry(industry) + _make_rule( + f"Industry: {industry}", + "Industry", + keywords, + industry=industry, + match_scope="Headline", + ) + + +def _seed_social_rules(): + for network, patterns in SOCIAL_PATTERNS.items(): + _make_rule( + f"Social: {network}", + "Social", + patterns, + target_value=network, + match_scope="HTML", + is_regex=True, + ) + + +def _seed_field_mappings(): + for ( + source_key, + target_doctype, + target_fieldname, + write_policy, + default_values, + create_link, + ) in FIELD_MAPPINGS: + exists = frappe.db.exists( + "CRM Enrichment Field Mapping", + { + "source_key": source_key, + "target_doctype": target_doctype, + "target_fieldname": target_fieldname, + }, + ) + if exists: + continue + frappe.get_doc( + { + "doctype": "CRM Enrichment Field Mapping", + "enabled": 1, + "source_key": source_key, + "target_doctype": target_doctype, + "target_fieldname": target_fieldname, + "write_policy": write_policy, + "default_values": default_values, + "create_missing_link": create_link, + } + ).insert(ignore_permissions=True) diff --git a/crm/domain_enrichment/mapper.py b/crm/domain_enrichment/mapper.py new file mode 100644 index 0000000..646aba8 --- /dev/null +++ b/crm/domain_enrichment/mapper.py @@ -0,0 +1,255 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""Single source of result -> CRM field logic, driven by Field Mapping records. + +``apply_to_document`` is the ONLY place that translates an ``EnrichmentResult`` +into CRM document fields. Phase 5's link-time Organization -> Lead/Deal copy reuses +it, so all the write-policy / has-field / never-overwrite guards live here once. + +The mapping itself is data: each ``CRM Enrichment Field Mapping`` record names a +``source_key`` (one of the 13 frozen in Phase 2), a ``target_fieldname``, a +``write_policy`` (Fill if empty / Always refresh / Override defaults) and optional +``default_values`` / ``create_missing_link``. This module knows how to resolve a +``source_key`` against the result and how to apply each policy -- nothing else. +""" + +from __future__ import annotations + +from urllib.parse import urlparse + +import frappe +from frappe import _ + +from .crawler import registrable_domain + + +def _source_key_labels() -> dict: + """Human labels for the realtime "filled: ..." toast, keyed by source_key. Falls + back to the field's meta label, then the fieldname, when not listed here. Built + at call time so ``_()`` translates in the requesting user's language -- a + module-level dict would evaluate at import and cache one language for all + sites/users (semgrep: frappe-breaks-multitenancy).""" + return { + "company_name": _("Organization Name"), + "description": _("Company Description"), + "logo": _("Logo"), + "industry": _("Industry"), + "primary_email": _("Email"), + "primary_phone": _("Phone"), + "secondary_phone": _("Phone"), + "linkedin": _("LinkedIn"), + "twitter": _("X (Twitter)"), + "github": _("GitHub"), + "facebook": _("Facebook"), + "instagram": _("Instagram"), + "youtube": _("YouTube"), + } + + +# source_keys backed by a social_profiles[<network>] entry on the result. +_SOCIAL_KEYS = ("linkedin", "twitter", "github", "facebook", "instagram", "youtube") + +# Write-policy constants (must match the CRM Enrichment Field Mapping Select options). +POLICY_FILL_IF_EMPTY = "Fill if empty" +POLICY_ALWAYS_REFRESH = "Always refresh" +POLICY_OVERRIDE_DEFAULTS = "Override defaults" + + +def _first_email(result) -> str: + """Prefer an email on the company's own registrable domain, else the first. + + Mirrors the POC behaviour: a sales address on the company's own domain is a + better company contact than a random third-party address picked up elsewhere. + """ + if not result.emails: + return "" + registrable = registrable_domain(urlparse(result.website or "").netloc) + if registrable: + for e in result.emails: + domain = e.value.lower().split("@")[-1] + # Exact registrable domain or a subdomain of it -- not a lookalike like + # "notacme.com" endswith "acme.com". + if domain == registrable or domain.endswith("." + registrable): + return e.value + return result.emails[0].value + + +def _phone_at(result, index: int) -> str: + """The raw (else cleaned) phone at ``index``; falls back to the primary.""" + if not result.phones: + return "" + if index >= len(result.phones): + index = 0 + p = result.phones[index] + return p.raw or p.value + + +def get_value_for_source_key(result, source_key: str): + """Resolve one of the 13 frozen source_keys against an ``EnrichmentResult``. + + Returns a plain scalar (``""`` means "nothing to fill"). Scalar provenance + fields expose ``.value``; emails/phones/contacts are read off the result's + lists; social profiles off ``result.social_profiles[<network>]``. + """ + if source_key == "company_name": + return result.company_name.value or "" + if source_key == "description": + return result.description.value or "" + if source_key == "logo": + return result.logo.value or "" + if source_key == "image": + return result.image.value or "" + if source_key == "industry": + return result.industry.value or "" + if source_key == "primary_email": + return _first_email(result) + if source_key == "primary_phone": + return _phone_at(result, 0) + if source_key == "secondary_phone": + return _phone_at(result, 1) + if source_key in _SOCIAL_KEYS: + profile = result.social_profiles.get(source_key) + return profile.value if profile else "" + return "" + + +def _ensure_link_target(target_doctype: str, target_fieldname: str, value: str): + """For a Link field with ``create_missing_link`` set, auto-create the linked + master if it is missing. Explicit, opt-in -- never a hidden side effect, and + permission-respecting: the master is only created if the enriching user could + create it themselves (no ``ignore_permissions`` escalation from scraped data). + + Returns the value to write (the linked name) or ``None`` to skip the field -- + when the master is missing and the user lacks create rights, or on any error + (the field is skipped, the run is not aborted). + """ + meta = frappe.get_meta(target_doctype) + df = meta.get_field(target_fieldname) + if not df or df.fieldtype != "Link" or not df.options: + # Not a Link (or unknown field) -- nothing to create; write as-is. + return value + link_doctype = df.options + if frappe.db.exists(link_doctype, value): + return value + # Auto-create respects the enriching user's rights: the worker runs as the user + # who triggered enrichment (frappe.set_user in execute_job), so a user who cannot + # create the master must not be able to insert one via a crafted website. Skip the + # field rather than write a dangling link. + if not frappe.has_permission(link_doctype, ptype="create"): + return None + try: + link_meta = frappe.get_meta(link_doctype) + new_doc = {"doctype": link_doctype} + # Populate the field the master autonames from (e.g. CRM Industry.industry), + # falling back to the title field, so the created record's name == value. + autoname = link_meta.autoname or "" + if autoname.startswith("field:"): + new_doc[autoname.split(":", 1)[1]] = value + elif link_meta.get_title_field(): + new_doc[link_meta.get_title_field()] = value + else: + new_doc["__newname"] = value + # Permission-respecting insert -- the create right was checked above. + frappe.get_doc(new_doc).insert() + return value + except Exception: + frappe.log_error( + title="Domain Enrichment: could not create link master", + message=f"{link_doctype}={value!r} for {target_doctype}.{target_fieldname}", + ) + return None + + +def _label_for(doc, source_key: str, target_fieldname: str) -> str: + label = _source_key_labels().get(source_key) + if label: + return label + df = doc.meta.get_field(target_fieldname) + return df.label if df and df.label else target_fieldname + + +def _overridable_defaults(mapping) -> set: + """The set of "really unset" default values for an Override-defaults mapping + (newline-separated in ``default_values``), e.g. {"", "1-10"}.""" + defaults = {""} + for line in (mapping.default_values or "").splitlines(): + token = line.strip() + if token: + defaults.add(token) + return defaults + + +def apply_to_document(doc, result, cfg, fill_empty_only: bool = False) -> list[str]: + """Populate mappable fields on a Lead / Deal / Organization from ``result``. + + Driven by ``cfg.mappings_by_doctype[doc.doctype]`` (the enabled Field Mapping + records). For each mapping the value is resolved by ``source_key`` and written + according to ``write_policy``: + + * **Fill if empty** -- set only when the doc field is currently empty. + * **Always refresh** -- overwrite with a fresh non-empty value on re-enrichment; + never clears a stored value to empty (enrichment must not destroy data). + * **Override defaults** -- treat the mapping's ``default_values`` as empty and + fill over them, else respect a real user value. + + When ``fill_empty_only`` is True every mapping is forced to fill-empty semantics + regardless of its configured ``write_policy`` (and empty source values are never + written). This is the link-time Organization -> Lead/Deal copy path (Phase 5), + which must never overwrite or clear user-entered data. + + Never overwrites real user-entered data except per the policies above. Does NOT + save the document -- the caller controls persistence/permissions. Returns the + human labels of the fields that were changed. + """ + filled: list[str] = [] + mappings = cfg.mappings_by_doctype.get(doc.doctype, []) + + for mapping in mappings: + fieldname = mapping.target_fieldname + if not doc.meta.has_field(fieldname): + continue + value = get_value_for_source_key(result, mapping.source_key) + label = _label_for(doc, mapping.source_key, fieldname) + current = doc.get(fieldname) + policy = POLICY_FILL_IF_EMPTY if fill_empty_only else (mapping.write_policy or POLICY_FILL_IF_EMPTY) + + if policy == POLICY_ALWAYS_REFRESH: + # Overwrite with a fresh value, but never clear a stored value to empty: + # enrichment must not destroy data a site temporarily stopped exposing. + if not value or current == value: + continue + if mapping.create_missing_link: + value = _ensure_link_target(doc.doctype, fieldname, value) + if value is None: + continue + doc.set(fieldname, value) + if label not in filled: + filled.append(label) + continue + + # Fill-if-empty / Override-defaults both require a non-empty fresh value. + if not value: + continue + + if policy == POLICY_OVERRIDE_DEFAULTS: + overridable = _overridable_defaults(mapping) + if current and current not in overridable: + continue # a real user value -- respect it + else: # Fill if empty + if current: + continue + + if current == value: + continue + + if mapping.create_missing_link: + value = _ensure_link_target(doc.doctype, fieldname, value) + if value is None: + continue + + doc.set(fieldname, value) + if label not in filled: + filled.append(label) + + return filled diff --git a/crm/domain_enrichment/pipeline.py b/crm/domain_enrichment/pipeline.py new file mode 100644 index 0000000..cc1ce49 --- /dev/null +++ b/crm/domain_enrichment/pipeline.py @@ -0,0 +1,144 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""Orchestration: load config, crawl, run extractors, run enabled sources, assemble +an ``EnrichmentResult``. + +``run()`` accepts an optional ``progress`` callback ``fn(step_index, message)`` so +Phase 4's ``tasks.py`` can surface the progress steps over realtime without the +engine depending on Frappe. The pipeline never writes to the DB or CRM docs (that +is Phase 4) and never raises for fetch/source failures -- they are captured as +errors/notes on the result. +""" + +from __future__ import annotations + +from urllib.parse import urlparse + +from . import extractors +from .config import EnrichmentConfig, get_config +from .crawler import crawl, probe_about_pages +from .http import build_session +from .result import EnrichmentResult, Field, Method + +PROGRESS_STEPS = [ + "Discovering pages", + "Crawling website", + "Extracting company information", + "Extracting contacts", + "Classifying industry", + "Saving results", + "Completed", +] + + +def _normalize_website(url: str) -> str: + url = (url or "").strip() + if not url: + raise ValueError("website URL is required") + if not urlparse(url).scheme: + url = "https://" + url + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ValueError(f"unsupported URL scheme: {parsed.scheme}") + if not parsed.netloc: + raise ValueError(f"invalid URL: {url}") + return url + + +def run(website: str, cfg: EnrichmentConfig = None, progress=None) -> EnrichmentResult: + """Run the full enrichment engine for ``website`` and return the result. + + :param website: the site to enrich (scheme optional; https assumed). + :param cfg: an :class:`EnrichmentConfig`; loaded via ``get_config()`` if omitted. + :param progress: optional ``fn(step_index, message)`` callback (0-based index + into ``PROGRESS_STEPS``); exceptions in it are swallowed. + """ + website = _normalize_website(website) + cfg = cfg or get_config() + result = EnrichmentResult(website=website) + + def emit(step_index, message=""): + if progress: + try: + progress(step_index, message or PROGRESS_STEPS[step_index]) + except Exception: + pass # progress reporting must never break enrichment + + session = build_session(cfg) + try: + # 1 + 2. Discover + crawl (BFS does both; reported together). + emit(0) + crawled = crawl(website, cfg, session=session, progress=lambda msg: emit(1, msg)) + pages = [page for page, _soup in crawled] + soups_by_url = {page.url: soup for page, soup in crawled} + result.pages_crawled = [{"url": p.url, "status": p.status_code, "error": p.error} for p in pages] + result.errors = [{"url": p.url, "error": p.error} for p in pages if p.error] + + # Short-circuit on a blocked / unreachable homepage with a clear reason. + reason = extractors.diagnose_readability(pages) + if reason in ("blocked", "unreachable"): + result.notes.append(extractors.READABILITY_MESSAGES[reason]) + emit(6) + return result + if reason == "empty": + # JS-rendered body: <head> metadata may still be usable -- fall through. + result.notes.append(extractors.READABILITY_MESSAGES["empty"]) + + homepage = pages[0] + home_soup = soups_by_url.get(homepage.url) + + # If the BFS didn't surface an About page, probe the common paths -- it's the + # best source of a company description (and helps industry/contacts too). Only + # when the page budget allows more fetches. + max_pages = int(cfg.setting("max_pages", 10) or 10) + if home_soup and len(pages) < max_pages and not any(extractors._is_about_page(p.url) for p in pages): + for page, soup in probe_about_pages( + homepage.url, cfg, session=session, skip_urls=[p.url for p in pages] + ): + pages.append(page) + soups_by_url[page.url] = soup + result.pages_crawled.append( + {"url": page.url, "status": page.status_code, "error": page.error} + ) + + # 3. Company information (each field carries provenance). + emit(2) + company = extractors.extract_company_info(homepage, home_soup) if home_soup else {} + result.company_name = company.get("company_name") or Field() + result.logo = company.get("logo") or Field() + result.image = company.get("image") or Field() + result.description = ( + extractors.select_description( + pages, + soups_by_url, + industry_rules=cfg.rules("Industry"), + company_name=result.company_name.value, + ) + or company.get("description") + or Field() + ) + + # 4. Contacts: emails, phones, social. + emit(3) + result.emails = extractors.extract_emails(pages) + result.phones = extractors.extract_phones(pages) + result.social_profiles = extractors.extract_social_profiles( + pages, + soups_by_url, + cfg.rules("Social"), + extra_links=company.get("social_links"), + ) + + # 5. Industry. + emit(4) + industry, confidence = extractors.classify_industry(pages, company, cfg.rules("Industry")) + result.industry = Field(industry, website, Method.KEYWORD_CLASSIFIER) if industry else Field() + result.industry_confidence = confidence + + # 6 + 7. Done (saving handled by the caller in Phase 4). + emit(5) + emit(6) + return result + finally: + session.close() diff --git a/crm/domain_enrichment/result.py b/crm/domain_enrichment/result.py new file mode 100644 index 0000000..3bb6a48 --- /dev/null +++ b/crm/domain_enrichment/result.py @@ -0,0 +1,150 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""EnrichmentResult schema. Preserves the {value, source, method} provenance shape. + +Every piece of evidence keeps a ``source`` URL so any field can be traced back to +the page it was extracted from. Dataclasses serialize to plain dicts/JSON via +``to_dict()`` so the result can be returned by the engine and stored by the mapper / +run-writer (Phase 4) without any framework coupling. This module imports no framework +services on purpose -- it is the pure, unit-testable result container. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field + + +# Canonical extraction-method labels -- every extracted value records which one +# produced it, so a reviewer can judge how much to trust it. +class Method: + JSON_LD = "JSON-LD" + META_TAG = "Meta Tag" + TITLE_TAG = "Title Tag" + BODY_TEXT = "Body Text" + FAVICON = "Favicon" + REGEX = "Regex" + TEL_LINK = "tel: link" + TEXT_HEURISTIC = "Text Heuristic" + KEYWORD_CLASSIFIER = "Keyword Classifier" + SOCIAL_RULE = "Social Rule" + + +@dataclass +class Email: + value: str + source: str = "" + method: str = Method.REGEX + + def to_dict(self): + return {"value": self.value, "source": self.source, "method": self.method} + + +@dataclass +class Phone: + value: str # normalized / cleaned + raw: str = "" # exactly as found on the page + source: str = "" + method: str = Method.REGEX + + def to_dict(self): + return { + "value": self.value, + "raw": self.raw, + "source": self.source, + "method": self.method, + } + + +@dataclass +class Field: + """A scalar value plus where/how it came from (company_name, description, + logo, industry, ...).""" + + value: object = "" + source: str = "" + method: str = "" + + def to_dict(self): + return {"value": self.value, "source": self.source, "method": self.method} + + +@dataclass +class SocialProfile: + value: str = "" + source: str = "" + method: str = Method.SOCIAL_RULE + + def to_dict(self): + return {"value": self.value, "source": self.source, "method": self.method} + + +@dataclass +class EnrichmentResult: + website: str = "" + # Scalar fields carry their own provenance via Field. + company_name: Field = field(default_factory=Field) + description: Field = field(default_factory=Field) + logo: Field = field(default_factory=Field) # the company link icon (favicon / apple-touch) + image: Field = field(default_factory=Field) # larger social/JSON-LD image; kept, not the logo + industry: Field = field(default_factory=Field) + industry_confidence: float = 0.0 + + emails: list = field(default_factory=list) # list[Email] + phones: list = field(default_factory=list) # list[Phone] + social_profiles: dict = field(default_factory=dict) # network -> SocialProfile + + # Diagnostics -- not part of the required schema but invaluable for debugging. + pages_crawled: list = field(default_factory=list) + errors: list = field(default_factory=list) + notes: list = field(default_factory=list) # human-readable warnings + + def to_dict(self): + """Render the canonical JSON schema. Every field is explainable: scalar + fields expose {value, source, method}; list items embed source + method; + social profiles are {value, source, method} per network.""" + return { + "company_name": self.company_name.to_dict(), + "description": self.description.to_dict(), + "logo": self.logo.to_dict(), + "image": self.image.to_dict(), + "industry": self.industry.to_dict(), + "industry_confidence": round(self.industry_confidence, 2), + "emails": [e.to_dict() for e in self.emails], + "phones": [p.to_dict() for p in self.phones], + "social_profiles": {k: v.to_dict() for k, v in self.social_profiles.items()}, + "_meta": { + "website": self.website, + "pages_crawled": self.pages_crawled, + "errors": self.errors, + "notes": self.notes, + }, + } + + def flat(self): + """Convenience: collapse to plain scalar values (no provenance), useful + for storing the headline fields on a Frappe document.""" + return { + "company_name": self.company_name.value, + "description": self.description.value, + "logo": self.logo.value, + "industry": self.industry.value, + "industry_confidence": round(self.industry_confidence, 2), + "social_profiles": {k: v.value for k, v in self.social_profiles.items()}, + } + + +@dataclass +class CrawledPage: + """A single fetched page and its parsed artifacts.""" + + url: str + status_code: int = 0 + html: str = "" + text: str = "" + title: str = "" + headings: list = field(default_factory=list) + error: str = "" + + def to_dict(self): + return asdict(self) diff --git a/crm/domain_enrichment/tasks.py b/crm/domain_enrichment/tasks.py new file mode 100644 index 0000000..be4717f --- /dev/null +++ b/crm/domain_enrichment/tasks.py @@ -0,0 +1,265 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""Background worker + the single run-writer. + +``run_enrichment`` is the enqueued job: it runs the pipeline (streaming progress +over realtime), writes a ``CRM Enrichment Run`` history record, applies the mapper +to the origin document, and publishes a terminal event. It NEVER raises to the +worker -- every failure is recorded on the Run, logged via ``frappe.log_error`` and +reported over realtime. + +``write_run`` is the ONLY place that persists run history, so the storage choice +(standalone Run doctype today) stays swappable. +""" + +from __future__ import annotations + +import frappe +from frappe.utils.telemetry import capture + +from .config import _setting, auto_enrich_enabled_for, get_config, get_settings +from .mapper import apply_to_document +from .pipeline import PROGRESS_STEPS +from .pipeline import run as run_pipeline + +# Realtime event the frontend (Phase 6) subscribes to. +PROGRESS_EVENT = "domain_enrichment_progress" + +# Total number of pipeline progress steps (mirrors pipeline.PROGRESS_STEPS). +TOTAL_STEPS = len(PROGRESS_STEPS) + + +def _publish(reference_doctype, reference_name, status, message="", step=0, payload=None, user=None): + """Emit a single progress/terminal event. Mirrors crm/api/whatsapp.py shape: + always carries the reference, plus step/total/message/status and an optional + payload (terminal events put filled_fields/notes/result.flat() in the payload). + Never raises -- realtime is best-effort.""" + try: + frappe.publish_realtime( + PROGRESS_EVENT, + { + "reference_doctype": reference_doctype, + "reference_name": reference_name, + "step": step, + "total": TOTAL_STEPS, + "message": message, + "status": status, + "payload": payload or {}, + }, + user=user, + ) + except Exception: + pass + + +def write_run( + reference_doctype: str, + reference_name: str, + website: str, + status: str, + result=None, + started_on=None, + notes: str = "", +): + """Persist exactly one ``CRM Enrichment Run`` from an ``EnrichmentResult``. + + The single point of run-history writing -- storage stays swappable behind it. + When ``result`` is given the summary fields + ``raw_json`` (full ``to_dict()``) + are populated; otherwise a bare Run (status/website) is written. + """ + doc = frappe.new_doc("CRM Enrichment Run") + doc.reference_doctype = reference_doctype + doc.reference_name = reference_name + doc.source_website = website + doc.status = status + doc.started_on = started_on or frappe.utils.now_datetime() + if status in ("Completed", "Failed"): + doc.finished_on = frappe.utils.now_datetime() + if notes: + doc.notes = notes + + if result is not None: + # extract_social_profiles pre-seeds an empty entry per configured rule; only + # summarise the networks that were actually found. + social = ", ".join(sorted(k for k, v in result.social_profiles.items() if v.value)) + doc.company_name = result.company_name.value or "" + doc.industry = result.industry.value or "" + doc.industry_confidence = result.industry_confidence or 0 + doc.emails_found = len(result.emails) + doc.phones_found = len(result.phones) + doc.social_profiles = social + doc.raw_json = frappe.as_json(result.to_dict()) + if not notes and result.notes: + doc.notes = "\n".join(result.notes) + + doc.insert(ignore_permissions=True) + return doc.name + + +def enqueue_enrichment( + reference_doctype: str, reference_name: str, website: str, user: str, trigger: str = "manual" +) -> dict: + """Enqueue one ``run_enrichment`` job (long queue, per-doc ``job_id`` + + ``deduplicate``, after commit). The single place the job is enqueued -- shared by + the manual (``api.enrich``/``api.retry``) and auto (``after_insert``) paths so a + manual click and an auto-fire never double-run, and the enqueue options stay in + one spot. ``enqueue_after_commit`` matters for the ``after_insert`` caller, whose + transaction has not committed yet; it is harmless for the already-saved paths. + + Reads only the Settings Single for the timeout bound (cheap, framework-cached) -- + it never assembles the full config. + """ + s = get_settings() + timeout = int(_setting(s, "request_timeout")) * int(_setting(s, "max_pages")) + 60 + job_id = f"domain-enrich-{reference_doctype}-{reference_name}" + frappe.enqueue( + "crm.domain_enrichment.tasks.run_enrichment", + queue="long", + timeout=timeout, + job_id=job_id, + deduplicate=True, + enqueue_after_commit=True, + reference_doctype=reference_doctype, + reference_name=reference_name, + website=website, + user=user, + trigger=trigger, + ) + return {"queued": True, "job_id": job_id, "website": website} + + +def auto_enrich_on_create(doc, method=None): + """Auto-enqueue enrichment for a new CRM record. + + Called from the ``after_insert`` of the CRM Lead / CRM Deal / CRM Organization + controllers (the trigger stays visible where the record lives, rather than as a + doc_events hook). Best-effort and never raises into the save. Fires only when the + feature is enabled, ``auto_enrich`` is on, the doctype is enabled, and the record + has a website. Reuses the same per-doc ``job_id`` + + ``deduplicate`` as the manual ``api.enrich`` path, so a manual click and the + auto-fire never double-run. A new Deal created with a website is therefore enriched + alongside its Organization -- each crawls independently and writes its own fields. + """ + try: + # Cheap Settings-only gate -- runs on every Lead/Deal/Org insert, so it must + # not assemble the full config (Rules/Mappings). The worker builds that later. + if not auto_enrich_enabled_for(doc.doctype): + return + + website = (doc.get("website") or "").strip() + if not website: + return + + enqueue_enrichment(doc.doctype, doc.name, website, frappe.session.user, trigger="auto") + except Exception: + frappe.log_error(title="Domain Enrichment: auto_enrich_on_create failed") + + +def run_enrichment( + reference_doctype: str, + reference_name: str, + website: str, + user: str | None = None, + trigger: str = "manual", +): + """Enqueued worker: crawl, map onto the origin doc, write a Run, stream progress. + + Never raises to the worker. On success the mapped origin doc is saved (a normal + permission-respecting save -- it is the doc the user triggered enrichment on; no + related records are touched, that is Phase 5). On any exception the Run is marked + Failed, logged, and an error event is published. + """ + user = user or frappe.session.user + started_on = frappe.utils.now_datetime() + + def progress(step_index, message=""): + _publish( + reference_doctype, + reference_name, + status="running", + message=message, + step=step_index, + user=user, + ) + + try: + cfg = get_config() + _publish(reference_doctype, reference_name, status="running", message="Starting", step=0, user=user) + + result = run_pipeline(website, cfg=cfg, progress=progress) + + doc = frappe.get_doc(reference_doctype, reference_name) + doc.check_permission("write") + filled_fields = apply_to_document(doc, result, cfg) + if filled_fields: + doc.save() + + write_run( + reference_doctype, + reference_name, + website, + status="Completed", + result=result, + started_on=started_on, + ) + + _publish( + reference_doctype, + reference_name, + status="completed", + message="Completed", + step=TOTAL_STEPS - 1, + payload={ + "filled_fields": filled_fields, + "notes": result.notes, + **result.flat(), + }, + user=user, + ) + capture( + "enrichment_run_completed", + "crm", + properties={ + "doctype": reference_doctype, + "trigger": trigger, + "status": "success", + }, + ) + except Exception: + # Discard any partial writes (e.g. a doc.save() that fired side effects before a + # later step threw). execute_job commits when this function returns normally, so + # without the rollback those partials would be committed alongside the Failed run. + frappe.db.rollback() + frappe.log_error( + title="Domain Enrichment: run_enrichment failed", + message=frappe.get_traceback(), + ) + try: + write_run( + reference_doctype, + reference_name, + website, + status="Failed", + started_on=started_on, + notes=frappe.utils.cstr(frappe.get_traceback())[:1000], + ) + except Exception: + frappe.log_error(title="Domain Enrichment: could not write Failed run") + _publish( + reference_doctype, + reference_name, + status="error", + message=frappe._("Enrichment failed. Check the error log."), + step=0, + user=user, + ) + capture( + "enrichment_run_completed", + "crm", + properties={ + "doctype": reference_doctype, + "trigger": trigger, + "status": "failure", + }, + ) diff --git a/crm/domain_enrichment/tests/__init__.py b/crm/domain_enrichment/tests/__init__.py new file mode 100644 index 0000000..ef15b10 --- /dev/null +++ b/crm/domain_enrichment/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""Tests for the domain enrichment engine (added in Phase 7).""" diff --git a/crm/domain_enrichment/tests/fixtures.py b/crm/domain_enrichment/tests/fixtures.py new file mode 100644 index 0000000..916e5d3 --- /dev/null +++ b/crm/domain_enrichment/tests/fixtures.py @@ -0,0 +1,319 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""Offline HTML fixtures + in-memory config builders for network-free tests. + +The HTML fixtures are deterministic snapshots used by the pure unit tests so the +crawler/extractor mechanics can be exercised without touching the network. The +config builders assemble ``Rule`` / ``Mapping`` / ``EnrichmentConfig`` objects in +memory (no DB) so the rule executors can be tested rule-driven, exactly as the +engine would see them after ``get_config()`` — but without hitting Frappe. +""" + +from __future__ import annotations + +import re + +from bs4 import BeautifulSoup + +from crm.domain_enrichment.config import EnrichmentConfig, Mapping, Rule +from crm.domain_enrichment.crawler import extract_content +from crm.domain_enrichment.result import CrawledPage + +# --------------------------------------------------------------------------- # +# HTML fixtures +# --------------------------------------------------------------------------- # +HOMEPAGE = """ +<!doctype html> +<html> +<head> + <title>Acme Analytics | AI powered SaaS + + + + + + + + + +

AI powered analytics

+

We are hiring across engineering

+ +

Contact us at hello@acme.example or call us.

+

Our subscription pricing plans start free. Customer relationship tooling.

+ + +""" + +ABOUT = """ + +About Acme + +

About Acme

+

We raised a Series A round and are backed by great investors.

+
+
Jane Doe
CEO & Co-Founder
+
John Smith
CTO
+
Senior Vice President
VP
+
About Acme
Director
+
+

Reach finance at billing@acme.example.

+ +""" + +CONTACT = """ + +Contact Acme + +

Contact

+

Head office: 100 Market St, San Francisco, CA, 94103, United States

+

Email: sales@acme.example

+

Phone: +1 (415) 555-0199

+ +""" + +WORDPRESS = """ + + +""" + +EMPTY = "" + +# A JavaScript-only SPA shell (e.g. a Vite/React build). The body is an empty mount +# point; everything meaningful is rendered client-side, so requests + BeautifulSoup +# see only the build-tool-default title and favicon -- no description, og, or JSON-LD. +JS_SPA_SHELL = """ + + + + + + + Vite App + + + + + +
+ + +""" + +BROKEN_JSON_LD = """ + + +Broken Co +

Broken

+""" + +# --------------------------------------------------------------------------- # +# Sitemap fixtures +# --------------------------------------------------------------------------- # +URLSET_XML = """ + + https://acme.example/about + https://acme.example/contact + https://acme.example/blog/post-1 +""" + +SITEMAP_INDEX_XML = """ + + https://acme.example/sitemap-pages.xml + https://acme.example/sitemap-blog.xml +""" + +# A sitemap trying to smuggle a DTD entity-expansion payload -- must be rejected +# outright by _parse_sitemap before ET.fromstring ever sees it. +MALICIOUS_DOCTYPE_XML = """ +]> + + https://acme.example/&xxe; +""" + + +def fake_fetch(responses): + """Build a ``fetch(url, cfg, session=None, html_only=True)`` stub keyed by exact URL. + + ``responses`` maps url -> (status, body) for a 200-shaped response. A URL absent + from the map behaves like a 404 (mirrors a missing sitemap/robots.txt), so tests + only need to declare the endpoints they care about. + """ + + def fake(url, cfg, session=None, html_only=True): + entry = responses.get(url) + if entry is None: + return 404, "", "not found", url + status, body = entry + return status, body, "", url + + return fake + + +def fake_fetch_with_redirects(responses, redirects): + """Like ``fake_fetch``, but ``redirects`` maps a requested URL to the resolved + (post-redirect) URL returned as ``final_url`` -- simulates a 301/302 hop without + modeling the actual redirect chain, since callers only ever look at the result. + """ + base = fake_fetch(responses) + + def fake(url, cfg, session=None, html_only=True): + status, body, error, final_url = base(url, cfg, session=session, html_only=html_only) + return status, body, error, redirects.get(url, final_url) + + return fake + + +# --------------------------------------------------------------------------- # +# Page helper +# --------------------------------------------------------------------------- # +def make_page(url, html, status_code=200): + """Build a (CrawledPage, soup) pair from raw HTML, exactly as the crawler does. + + ``extract_content`` mutates its soup, so it is given a throwaway parse and the + returned ``soup`` is a fresh, unmutated DOM (mirrors ``crawler.crawl_page``). + """ + soup = BeautifulSoup(html, "html.parser") + content = extract_content(BeautifulSoup(html, "html.parser")) + page = CrawledPage( + url=url, + status_code=status_code, + html=html, + text=content["text"], + title=content["title"], + headings=content["headings"], + ) + return page, soup + + +# --------------------------------------------------------------------------- # +# In-memory rule builders (no DB) — mirror what config._build_rules() produces +# --------------------------------------------------------------------------- # +def _compile(pattern, is_regex): + if is_regex: + return re.compile(pattern, re.IGNORECASE) + return re.compile(re.escape(pattern), re.IGNORECASE) + + +def keyword_rule(rule_type, patterns, *, target_value="", industry="", weight=1.0, match_scope="Full Text"): + """Build a substring keyword Rule directly (is_regex=0 for each pattern).""" + compiled = [(_compile(p, False), p) for p in patterns] + return Rule( + rule_type=rule_type, + target_value=target_value, + industry=industry, + weight=weight, + match_scope=match_scope, + patterns=compiled, + ) + + +def regex_rule(rule_type, patterns, *, target_value="", weight=1.0, match_scope="HTML"): + """Build a regex Rule directly (is_regex=1 for each pattern).""" + compiled = [(_compile(p, True), p) for p in patterns] + return Rule( + rule_type=rule_type, + target_value=target_value, + weight=weight, + match_scope=match_scope, + patterns=compiled, + ) + + +def industry_rules(): + """A small set of Industry rules (Headline scope) covering the fixtures.""" + return [ + keyword_rule( + "Industry", + ["saas", "software as a service", "subscription"], + industry="SaaS", + match_scope="Headline", + ), + keyword_rule( + "Industry", + ["crm", "customer relationship"], + industry="CRM", + match_scope="Headline", + ), + keyword_rule( + "Industry", + ["shop", "free shipping", "apparel", "ecommerce"], + industry="Ecommerce", + match_scope="Headline", + ), + keyword_rule( + "Industry", + ["beverage", "sparkling water", "iced tea", "cereal"], + industry="Food & Beverage", + match_scope="Headline", + ), + keyword_rule( + "Industry", + ["3d printing", "additive manufacturing", "manufacturing"], + industry="Manufacturing", + match_scope="Headline", + ), + keyword_rule( + "Industry", + ["legal help", "legal documents", "legal advice", "law firm"], + industry="Legal", + match_scope="Headline", + ), + keyword_rule( + "Industry", + ["media company", "journalists", "newsroom"], + industry="Media", + match_scope="Headline", + ), + ] + + +def social_rules(): + return [ + regex_rule("Social", [r"linkedin\.com/(company|in|school)/"], target_value="linkedin"), + regex_rule("Social", [r"(twitter\.com|x\.com)/[A-Za-z0-9_]+"], target_value="twitter"), + regex_rule("Social", [r"github\.com/[A-Za-z0-9_.-]+"], target_value="github"), + regex_rule("Social", [r"facebook\.com/[A-Za-z0-9_.\-/]+"], target_value="facebook"), + regex_rule("Social", [r"instagram\.com/[A-Za-z0-9_.]+"], target_value="instagram"), + ] + + +def make_config(settings=None, **kwargs) -> EnrichmentConfig: + """Assemble an in-memory ``EnrichmentConfig`` for the SSRF / engine tests.""" + cfg = EnrichmentConfig(settings=settings or {}) + for key, value in kwargs.items(): + setattr(cfg, key, value) + return cfg + + +def make_mapping(source_key, target_doctype, target_fieldname, write_policy="Fill if empty", **kwargs): + return Mapping( + source_key=source_key, + target_doctype=target_doctype, + target_fieldname=target_fieldname, + write_policy=write_policy, + create_missing_link=kwargs.get("create_missing_link", 0), + default_values=kwargs.get("default_values", ""), + ) diff --git a/crm/domain_enrichment/tests/test_config.py b/crm/domain_enrichment/tests/test_config.py new file mode 100644 index 0000000..076feb4 --- /dev/null +++ b/crm/domain_enrichment/tests/test_config.py @@ -0,0 +1,42 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""Pure unit tests for config-layer pattern compilation (``config._compile_pattern``). + +Fully offline: no DB, no network. Covers the word-boundary fix -- an un-anchored +short substring keyword like "erp" or "api" previously matched inside unrelated +words ("waterproofing", "capital"), causing false industry-classification hits. +""" + +from __future__ import annotations + +from frappe.tests import UnitTestCase + +from crm.domain_enrichment.config import _compile_pattern + + +class CompilePatternTest(UnitTestCase): + def test_substring_pattern_does_not_match_inside_unrelated_word(self): + erp_rx = _compile_pattern("erp", is_regex=0) + self.assertIsNone(erp_rx.search("Industrial Adhesives, Paints and Waterproofing Solutions")) + self.assertIsNone(erp_rx.search("enterprise value creation")) + + def test_substring_pattern_still_matches_standalone_word(self): + erp_rx = _compile_pattern("erp", is_regex=0) + self.assertIsNotNone(erp_rx.search("Our ERP rollout finished on schedule")) + + def test_api_pattern_does_not_match_inside_capital_or_rapid(self): + api_rx = _compile_pattern("api", is_regex=0) + self.assertIsNone(api_rx.search("venture capital and rapid growth")) + self.assertIsNotNone(api_rx.search("a public REST API for developers")) + + def test_multi_word_phrase_pattern_still_matches(self): + rx = _compile_pattern("enterprise resource planning", is_regex=0) + self.assertIsNotNone(rx.search("our Enterprise Resource Planning suite")) + + def test_is_regex_patterns_are_left_unanchored(self): + # An admin-authored regex (is_regex=1) is compiled as-is -- no word-boundary + # wrapping, so existing admin patterns (e.g. social-profile URL regexes) keep + # their exact intended behavior. + rx = _compile_pattern(r"linkedin\.com/(company|in|school)/", is_regex=1) + self.assertIsNotNone(rx.search("https://www.linkedin.com/company/acme")) diff --git a/crm/domain_enrichment/tests/test_crawler.py b/crm/domain_enrichment/tests/test_crawler.py new file mode 100644 index 0000000..a99948d --- /dev/null +++ b/crm/domain_enrichment/tests/test_crawler.py @@ -0,0 +1,467 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""Pure unit tests for the crawler mechanics (no network, no DB). + +Covers URL normalization, the same-site/registrable-domain check, link-priority +ordering (config-driven keywords), the crawlable filter (asset/scheme/skip-pattern +rejection) and ``extract_content`` (title/headings/visible text). +""" + +from __future__ import annotations + +from unittest import mock + +from frappe.tests import UnitTestCase + +from crm.domain_enrichment import crawler +from crm.domain_enrichment.crawler import ( + _is_crawlable, + _link_priority, + extract_content, + normalize_url, + registrable_domain, + same_site, +) +from crm.domain_enrichment.tests import fixtures +from crm.domain_enrichment.tests.fixtures import make_config + +# The default BFS priority keywords used by the engine when Settings has none. +PRIORITY = [ + ("about", 1.0), + ("contact", 1.0), + ("team", 1.0), + ("careers", 1.0), + ("company", 1.0), +] +SKIP_PATTERNS = [r"/wp-admin", r"\?replytocom="] + + +class NormalizeUrlTest(UnitTestCase): + def test_strips_fragment(self): + self.assertEqual(normalize_url("https://x.com/about/#team"), "https://x.com/about") + + def test_strips_trailing_slash_on_path(self): + self.assertEqual(normalize_url("https://x.com/about/"), "https://x.com/about") + + def test_keeps_root_slash(self): + # A bare root "/" must not be stripped (path length 1). + self.assertEqual(normalize_url("https://x.com/"), "https://x.com/") + + def test_strips_query_string(self): + # Query-string variants of the same page (tracking params, product-variant + # params) must collapse to one dedupe key -- otherwise the same page gets + # queued and fetched multiple times under different query strings. + self.assertEqual(normalize_url("https://x.com/w/book/1?ean=123"), "https://x.com/w/book/1") + self.assertEqual( + normalize_url("https://x.com/w/book/1?terms=free-shipping"), "https://x.com/w/book/1" + ) + self.assertEqual(normalize_url("https://x.com/w/book/1"), "https://x.com/w/book/1") + + +class RegistrableDomainTest(UnitTestCase): + def test_strips_www_and_port(self): + self.assertEqual(registrable_domain("www.example.com:443"), "example.com") + + def test_deep_subdomains_collapse_to_registrable(self): + self.assertEqual(registrable_domain("blog.shop.example.com"), "example.com") + + def test_single_label(self): + self.assertEqual(registrable_domain("localhost"), "localhost") + + def test_bare_ip_returns_itself(self): + self.assertEqual(registrable_domain("10.0.0.5"), "10.0.0.5") + + def test_multi_label_public_suffix_keeps_registrable_label(self): + # The bug this guards: a last-two-labels rule returns "co.uk" here, so every + # *.co.uk site collapses into one same-site bucket. The PSL keeps the SLD. + self.assertEqual(registrable_domain("acme.co.uk"), "acme.co.uk") + self.assertEqual(registrable_domain("www.shop.acme.com.au"), "acme.com.au") + + def test_distinct_orgs_under_a_public_suffix_are_distinct(self): + self.assertNotEqual(registrable_domain("acme.co.uk"), registrable_domain("evil.co.uk")) + + def test_private_hosting_suffix_is_registrable(self): + # github.io is a PSL *private* suffix: two Pages tenants are different sites. + self.assertEqual(registrable_domain("acme.github.io"), "acme.github.io") + self.assertNotEqual(registrable_domain("acme.github.io"), registrable_domain("other.github.io")) + + +class SameSiteTest(UnitTestCase): + def test_ignores_www_and_subdomains(self): + self.assertTrue(same_site("https://www.x.com/a", "x.com")) + self.assertTrue(same_site("https://blog.x.com/a", "x.com")) + + def test_rejects_unrelated_org_sharing_a_public_suffix(self): + # Would be a false "same site" under the naive last-two-labels rule. + self.assertFalse(same_site("https://evil.co.uk/x", "acme.co.uk")) + self.assertFalse(same_site("https://other.github.io/x", "acme.github.io")) + + def test_rejects_other_domain(self): + self.assertFalse(same_site("https://other.com/a", "x.com")) + + +class LinkPriorityTest(UnitTestCase): + def test_priority_pages_rank_before_generic(self): + about = _link_priority("https://x.com/about", "About", PRIORITY) + blog = _link_priority("https://x.com/blog/post", "Read the post", PRIORITY) + self.assertLess(about, blog) + + def test_anchor_text_counts(self): + # A generic URL with a priority keyword in the anchor still ranks high. + hit = _link_priority("https://x.com/p/42", "Our team", PRIORITY) + miss = _link_priority("https://x.com/p/43", "Read more", PRIORITY) + self.assertLess(hit, miss) + + def test_non_priority_sorts_last(self): + generic = _link_priority("https://x.com/random", "", PRIORITY) + self.assertEqual(generic, len(PRIORITY) + 1) + + def test_ancestor_segment_does_not_leak_priority_to_unrelated_leaf(self): + # A news article nested under "/about-us/..." must not inherit "about-us"'s + # priority from an ancestor path segment alone -- only the leaf segment counts. + news_article = _link_priority("https://x.com/about-us/corporate-news/2023/some-award", "", PRIORITY) + generic = _link_priority("https://x.com/blog/post", "", PRIORITY) + about_page = _link_priority("https://x.com/about-us", "", PRIORITY) + self.assertEqual(news_article, generic) + self.assertLess(about_page, news_article) + + +class IsCrawlableTest(UnitTestCase): + def test_rejects_non_http_schemes(self): + self.assertFalse(_is_crawlable("mailto:a@b.com", [])) + self.assertFalse(_is_crawlable("tel:+15555550142", [])) + self.assertFalse(_is_crawlable("javascript:void(0)", [])) + + def test_rejects_asset_extensions(self): + self.assertFalse(_is_crawlable("https://x.com/file.pdf", [])) + self.assertFalse(_is_crawlable("https://x.com/logo.png", [])) + self.assertFalse(_is_crawlable("https://x.com/app.js", [])) + + def test_allows_normal_page(self): + self.assertTrue(_is_crawlable("https://x.com/about", [])) + + def test_rejects_configured_skip_pattern(self): + self.assertFalse(_is_crawlable("https://x.com/wp-admin/edit", SKIP_PATTERNS)) + self.assertFalse(_is_crawlable("https://x.com/post?replytocom=5", SKIP_PATTERNS)) + self.assertTrue(_is_crawlable("https://x.com/about", SKIP_PATTERNS)) + + def test_malformed_skip_pattern_treated_as_substring(self): + # An invalid regex must not crash; it falls back to substring match. + self.assertFalse(_is_crawlable("https://x.com/[bad", ["[bad"])) + + +class ExtractContentTest(UnitTestCase): + def test_pulls_title_headings_and_text(self): + _page, soup = fixtures.make_page("https://acme.example", fixtures.HOMEPAGE) + content = extract_content(soup) + self.assertEqual(content["title"], "Acme Analytics | AI powered SaaS") + self.assertIn("AI powered analytics", content["headings"]) + self.assertIn("hello@acme.example", content["text"]) + + def test_strips_script_and_style(self): + html = ( + "

Visible

" + ) + _page, soup = fixtures.make_page("https://x.com", html) + content = extract_content(soup) + self.assertIn("Visible", content["text"]) + self.assertNotIn("var x", content["text"]) + + +class ParseSitemapTest(UnitTestCase): + def test_parses_urlset(self): + kind, locs = crawler._parse_sitemap(fixtures.URLSET_XML) + self.assertEqual(kind, "urlset") + self.assertIn("https://acme.example/about", locs) + self.assertIn("https://acme.example/contact", locs) + self.assertIn("https://acme.example/blog/post-1", locs) + + def test_parses_sitemapindex(self): + kind, locs = crawler._parse_sitemap(fixtures.SITEMAP_INDEX_XML) + self.assertEqual(kind, "sitemapindex") + self.assertEqual( + locs, + ["https://acme.example/sitemap-pages.xml", "https://acme.example/sitemap-blog.xml"], + ) + + def test_malformed_xml_returns_none_kind(self): + kind, locs = crawler._parse_sitemap("unclosed") + self.assertIsNone(kind) + self.assertEqual(locs, []) + + def test_doctype_rejected_before_parsing(self): + # Must never reach ET.fromstring with a DTD payload. + kind, locs = crawler._parse_sitemap(fixtures.MALICIOUS_DOCTYPE_XML) + self.assertIsNone(kind) + self.assertEqual(locs, []) + + def test_unrelated_root_element_ignored(self): + kind, locs = crawler._parse_sitemap("") + self.assertIsNone(kind) + self.assertEqual(locs, []) + + +class DiscoverSitemapUrlsTest(UnitTestCase): + def setUp(self): + self.cfg = make_config() + + def test_uses_robots_declared_sitemap(self): + robots = mock.Mock() + robots.site_maps.return_value = ["https://acme.example/my-sitemap.xml"] + responses = {"https://acme.example/my-sitemap.xml": (200, fixtures.URLSET_XML)} + with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch(responses)): + urls = crawler.discover_sitemap_urls("https://acme.example", self.cfg, None, robots) + self.assertIn("https://acme.example/about", urls) + self.assertIn("https://acme.example/contact", urls) + + def test_falls_back_to_conventional_path_when_robots_declares_none(self): + robots = mock.Mock() + robots.site_maps.return_value = None + responses = {"https://acme.example/sitemap.xml": (200, fixtures.URLSET_XML)} + with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch(responses)): + urls = crawler.discover_sitemap_urls("https://acme.example", self.cfg, None, robots) + self.assertIn("https://acme.example/about", urls) + + def test_falls_back_when_robots_is_none(self): + responses = {"https://acme.example/sitemap.xml": (200, fixtures.URLSET_XML)} + with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch(responses)): + urls = crawler.discover_sitemap_urls("https://acme.example", self.cfg, None, None) + self.assertIn("https://acme.example/about", urls) + + def test_missing_sitemap_returns_empty_without_raising(self): + with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch({})): + urls = crawler.discover_sitemap_urls("https://acme.example", self.cfg, None, None) + self.assertEqual(urls, []) + + def test_malformed_xml_returns_empty(self): + responses = {"https://acme.example/sitemap.xml": (200, "not xml at all")} + with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch(responses)): + urls = crawler.discover_sitemap_urls("https://acme.example", self.cfg, None, None) + self.assertEqual(urls, []) + + def test_follows_one_level_of_sitemap_index(self): + responses = { + "https://acme.example/sitemap.xml": (200, fixtures.SITEMAP_INDEX_XML), + "https://acme.example/sitemap-pages.xml": (200, fixtures.URLSET_XML), + "https://acme.example/sitemap-blog.xml": ( + 200, + '' + "https://acme.example/blog/hello", + ), + } + with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch(responses)): + urls = crawler.discover_sitemap_urls("https://acme.example", self.cfg, None, None) + self.assertIn("https://acme.example/about", urls) + self.assertIn("https://acme.example/blog/hello", urls) + + def test_rejects_cross_site_sitemap(self): + # The sitemap FILE lives on a different site than the one we're enriching -- + # must never be fetched, regardless of what its entries claim. Uses + # real-style ".com" hosts (not "*.example") so registrable_domain() actually + # differentiates them -- ".example" isn't in the bundled Public Suffix List, + # so acme.example/evil.example would collapse to the same registrable domain. + robots = mock.Mock() + robots.site_maps.return_value = ["https://evil-corp.com/sitemap.xml"] + responses = { + "https://evil-corp.com/sitemap.xml": ( + 200, + '' + "https://evil-corp.com/about", + ) + } + with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch(responses)): + urls = crawler.discover_sitemap_urls("https://acme-corp.com", self.cfg, None, robots) + self.assertEqual(urls, []) + + +class CrawlSitemapSeedingTest(UnitTestCase): + """End-to-end: crawl() seeded from a robots.txt-declared sitemap. + + Sitemap discovery must be a pure addition on top of the ordinary BFS: it should + surface a page that isn't linked from the homepage, without displacing the + homepage as the first crawled result, and it must disappear cleanly (no error) + when disabled or when the site has no sitemap at all. + """ + + HOMEPAGE_HTML = 'AcmeContact' + CONTACT_HTML = "Contact us" + HIDDEN_ABOUT_HTML = "Hidden About

About

" + ROBOTS_TXT = "Sitemap: https://acme.example/sitemap.xml" + SITEMAP_XML = ( + '' + "https://acme.example/hidden-about" + ) + + def _responses(self, **overrides): + base = { + "https://acme.example": (200, self.HOMEPAGE_HTML), + "https://acme.example/robots.txt": (200, self.ROBOTS_TXT), + "https://acme.example/sitemap.xml": (200, self.SITEMAP_XML), + "https://acme.example/contact": (200, self.CONTACT_HTML), + "https://acme.example/hidden-about": (200, self.HIDDEN_ABOUT_HTML), + } + base.update(overrides) + return base + + def test_sitemap_only_page_is_crawled_homepage_stays_first(self): + cfg = make_config( + settings={"max_pages": 10, "max_depth": 1, "use_sitemap": 1}, + link_priority=PRIORITY, + skip_patterns=[], + ) + with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch(self._responses())): + results = crawler.crawl("https://acme.example", cfg) + urls = [page.url for page, _soup in results] + self.assertEqual(urls[0], "https://acme.example") + self.assertIn("https://acme.example/hidden-about", urls) + self.assertIn("https://acme.example/contact", urls) + + def test_use_sitemap_disabled_never_fetches_sitemap(self): + cfg = make_config( + settings={"max_pages": 10, "max_depth": 1, "use_sitemap": 0}, + link_priority=PRIORITY, + skip_patterns=[], + ) + with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch(self._responses())): + results = crawler.crawl("https://acme.example", cfg) + urls = [page.url for page, _soup in results] + self.assertEqual(urls[0], "https://acme.example") + self.assertNotIn("https://acme.example/hidden-about", urls) + self.assertIn("https://acme.example/contact", urls) + + def test_no_sitemap_falls_through_to_plain_link_following(self): + # robots.txt exists but declares no sitemap, and /sitemap.xml 404s -- the + # crawl must proceed exactly as ordinary link-following, no error surfaced. + responses = self._responses(**{"https://acme.example/robots.txt": (200, "User-agent: *")}) + del responses["https://acme.example/sitemap.xml"] + cfg = make_config( + settings={"max_pages": 10, "max_depth": 1, "use_sitemap": 1}, + link_priority=PRIORITY, + skip_patterns=[], + ) + with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch(responses)): + results = crawler.crawl("https://acme.example", cfg) + urls = [page.url for page, _soup in results] + self.assertEqual(urls[0], "https://acme.example") + self.assertIn("https://acme.example/contact", urls) + self.assertNotIn("https://acme.example/hidden-about", urls) + self.assertEqual(len(results), 2) # homepage + contact only, no crash, no phantom pages + + +class RedirectDedupTest(UnitTestCase): + """A redirect can land two different *requested* URLs on the same resolved page + (e.g. /contact.html -> /contact) -- the pre-fetch `visited` check can't catch + this since the requested URLs genuinely differ. crawl() must collapse the + duplicate after the fact and not let it consume a page-budget slot. + """ + + def test_redirect_collapsed_duplicate_does_not_consume_budget(self): + homepage_html = ( + 'Contact' + 'Contact (alt)' + 'Team' + ) + responses = { + "https://acme.example": (200, homepage_html), + "https://acme.example/contact": (200, "Contact page"), + "https://acme.example/contact.html": (200, "Contact page"), + "https://acme.example/team": (200, "Team page"), + } + # /contact.html resolves (redirects) to the same page as /contact. + redirects = {"https://acme.example/contact.html": "https://acme.example/contact"} + cfg = make_config( + settings={"max_pages": 10, "max_depth": 1, "use_sitemap": 0}, + link_priority=PRIORITY, + skip_patterns=[], + ) + with mock.patch.object( + crawler, "fetch", side_effect=fixtures.fake_fetch_with_redirects(responses, redirects) + ): + results = crawler.crawl("https://acme.example", cfg) + urls = [page.url for page, _soup in results] + self.assertEqual(urls.count("https://acme.example/contact"), 1) + # The freed budget slot means /team -- otherwise starved by the wasted + # duplicate fetch -- still gets crawled. + self.assertIn("https://acme.example/team", urls) + + +class OffSiteRedirectScopeTest(UnitTestCase): + """A same-site link that redirects off-domain must not be treated as an + in-scope crawled page: fetch()'s redirect loop only re-validates SSRF safety + per hop, never registrable-domain scope. + """ + + def test_offsite_redirect_marked_out_of_scope(self): + homepage_html = ( + 'IRTeam' + ) + responses = { + "https://jpm-test.com": (200, homepage_html), + "https://jpm-test.com/investor-relations": (200, "redirected content"), + "https://jpm-test.com/team": (200, "Team page"), + } + redirects = { + "https://jpm-test.com/investor-relations": "https://reports.jpmchase-test.com/ir/2022.htm" + } + cfg = make_config( + settings={"max_pages": 10, "max_depth": 1, "use_sitemap": 0}, + link_priority=PRIORITY, + skip_patterns=[], + ) + with mock.patch.object( + crawler, "fetch", side_effect=fixtures.fake_fetch_with_redirects(responses, redirects) + ): + results = crawler.crawl("https://jpm-test.com", cfg) + offsite = next( + page for page, _soup in results if page.url == "https://reports.jpmchase-test.com/ir/2022.htm" + ) + self.assertTrue(offsite.error) + self.assertEqual(offsite.html, "") + self.assertEqual(offsite.text, "") + + def test_offsite_redirect_clears_title_and_headings(self): + # title/headings are parsed by crawl_page() before the off-site check runs, + # so clearing html/text/soup alone isn't enough -- extractors that read + # title/headings straight off the CrawledPage (e.g. classify_industry's + # About-page lookup) would otherwise still see off-site content. + homepage_html = 'About' + responses = { + "https://jpm-test.com": (200, homepage_html), + "https://jpm-test.com/about": ( + 200, + "Global Widget Manufacturing Co" + "

Leading widget manufacturer since 1900

", + ), + } + redirects = {"https://jpm-test.com/about": "https://widgetco-test.com/about"} + cfg = make_config( + settings={"max_pages": 10, "max_depth": 1, "use_sitemap": 0}, + link_priority=PRIORITY, + skip_patterns=[], + ) + with mock.patch.object( + crawler, "fetch", side_effect=fixtures.fake_fetch_with_redirects(responses, redirects) + ): + results = crawler.crawl("https://jpm-test.com", cfg) + offsite = next(page for page, _soup in results if page.url == "https://widgetco-test.com/about") + self.assertTrue(offsite.error) + self.assertEqual(offsite.title, "") + self.assertEqual(offsite.headings, []) + + def test_homepage_redirect_is_still_adopted_not_flagged(self): + # The existing "homepage moved to a new host" behavior must be unaffected -- + # only NON-homepage pages get the off-site-scope check. + responses = {"https://old-home-test.com": (200, "hi")} + redirects = {"https://old-home-test.com": "https://new-home-test.com"} + cfg = make_config( + settings={"max_pages": 1, "max_depth": 0, "use_sitemap": 0}, + link_priority=PRIORITY, + skip_patterns=[], + ) + with mock.patch.object( + crawler, "fetch", side_effect=fixtures.fake_fetch_with_redirects(responses, redirects) + ): + results = crawler.crawl("https://old-home-test.com", cfg) + self.assertEqual(results[0][0].url, "https://new-home-test.com") + self.assertFalse(results[0][0].error) diff --git a/crm/domain_enrichment/tests/test_extractors.py b/crm/domain_enrichment/tests/test_extractors.py new file mode 100644 index 0000000..6a575d5 --- /dev/null +++ b/crm/domain_enrichment/tests/test_extractors.py @@ -0,0 +1,862 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""Pure, network-free unit tests for the config-driven extractors. + +The classifiers (industry / social) are exercised with **in-memory Rule objects** +built directly (see ``tests.fixtures``), never the DB — this is the rule-driven +rewrite of the POC's extractor tests. The mechanics (company-name cleaning, favicon +scorer, JSON-LD parse, email/phone extraction, readability diagnosis) are tested +standalone. +""" + +from __future__ import annotations + +from frappe.tests import UnitTestCase + +from crm.domain_enrichment import extractors +from crm.domain_enrichment.result import CrawledPage, Method +from crm.domain_enrichment.tests import fixtures + + +def _pages(*specs): + """specs: (url, html) tuples -> list[CrawledPage], soups_by_url dict.""" + pages, soups = [], {} + for url, html in specs: + page, soup = fixtures.make_page(url, html) + pages.append(page) + soups[url] = soup + return pages, soups + + +# --------------------------------------------------------------------------- # +# Company information (mechanics) +# --------------------------------------------------------------------------- # +class CompanyInfoTest(UnitTestCase): + def setUp(self): + self.page, self.soup = fixtures.make_page("https://acme.example", fixtures.HOMEPAGE) + + def test_prefers_json_ld_for_name(self): + info = extractors.extract_company_info(self.page, self.soup) + self.assertEqual(info["company_name"].value, "Acme Analytics Inc.") + self.assertEqual(info["company_name"].method, Method.JSON_LD) + self.assertEqual(info["company_name"].source, "https://acme.example") + + def test_description_from_json_ld(self): + info = extractors.extract_company_info(self.page, self.soup) + self.assertIn("analytics", info["description"].value.lower()) + + def test_logo_is_link_icon_not_json_ld_image(self): + # The company logo is the link icon (HOMEPAGE declares none -> favicon.ico + # fallback), NOT the JSON-LD Organization.logo. That image is kept separately. + info = extractors.extract_company_info(self.page, self.soup) + self.assertEqual(info["logo"].method, Method.FAVICON) + self.assertEqual(info["logo"].value, "https://acme.example/favicon.ico") + self.assertEqual(info["image"].method, Method.JSON_LD) + self.assertEqual(info["image"].value, "https://acme.example/ld-logo.png") + + def test_logo_prefers_declared_apple_touch_icon(self): + page, soup = fixtures.make_page( + "https://x.example", + "" + "" + "" + "", + ) + info = extractors.extract_company_info(page, soup) + self.assertEqual(info["logo"].value, "https://x.example/touch.png") + + def test_meta_fallback_when_no_json_ld(self): + page, soup = fixtures.make_page( + "https://x.example", + "Foo", + ) + info = extractors.extract_company_info(page, soup) + self.assertEqual(info["company_name"].value, "Foo Inc") + self.assertEqual(info["company_name"].method, Method.META_TAG) + + def test_broken_json_ld_does_not_crash(self): + page, soup = fixtures.make_page("https://b.example", fixtures.BROKEN_JSON_LD) + info = extractors.extract_company_info(page, soup) + self.assertEqual(info["company_name"].method, Method.TITLE_TAG) + + def test_degenerate_og_description_yields_to_plain_description(self): + # Broken markup can park a site-verification token on og:description while the + # plain description tag carries the real copy. + page, soup = fixtures.make_page( + "https://x.example", + "" + "" + "" + "", + ) + info = extractors.extract_company_info(page, soup) + self.assertTrue(info["description"].value.startswith("Acme Organics is a manufacturer")) + + def test_og_description_still_preferred_when_usable(self): + page, soup = fixtures.make_page( + "https://x.example", + "" + "" + "" + "", + ) + info = extractors.extract_company_info(page, soup) + self.assertEqual(info["description"].value, "Acme builds developer tools for teams.") + + def test_degenerate_description_still_used_when_nothing_better(self): + page, soup = fixtures.make_page( + "https://x.example", + "", + ) + info = extractors.extract_company_info(page, soup) + self.assertEqual(info["description"].value, "Acme GmbH") + + def test_unspaced_script_description_beats_token(self): + # Japanese copy has no ASCII spaces, so a space check alone would mistake it + # for a token and keep the og:description token instead. + copy = "株式会社アクメは東京を拠点に産業用ロボットの設計・製造・販売を手がける専門メーカーです。" + page, soup = fixtures.make_page( + "https://x.example", + "" + "" + f"" + "", + ) + info = extractors.extract_company_info(page, soup) + self.assertEqual(info["description"].value, copy) + + +class LogoResolutionTest(UnitTestCase): + """extract_logo returns the link icon only; the larger social/JSON-LD image is + captured by extract_image, never used as the logo.""" + + def test_scalable_svg_beats_small_png(self): + _p, soup = fixtures.make_page( + "https://x.example", + "" + "" + "" + "", + ) + logo = extractors.extract_logo(soup, "https://x.example") + self.assertEqual(logo.value, "https://x.example/logo.svg") + self.assertEqual(logo.method, Method.FAVICON) + + def test_logo_is_link_icon_even_when_og_image_exists(self): + # A declared link icon is the logo; the bigger og:image is NOT the logo -- it + # is captured as the image instead. + _p, soup = fixtures.make_page( + "https://x.example", + "" + "" + "" + "", + ) + logo = extractors.extract_logo(soup, "https://x.example") + self.assertEqual(logo.value, "https://x.example/fav.ico") + self.assertEqual(logo.method, Method.FAVICON) + image = extractors.extract_image(soup, "https://x.example") + self.assertEqual(image.value, "https://x.example/social-1200.png") + self.assertEqual(image.method, Method.META_TAG) + + def test_falls_back_to_favicon_ico_ignoring_og_image(self): + # No link icon declared -> favicon.ico, even though an og:image exists. + _p, soup = fixtures.make_page( + "https://x.example", + "" + "" + "", + ) + logo = extractors.extract_logo(soup, "https://x.example") + self.assertEqual(logo.value, "https://x.example/favicon.ico") + self.assertEqual(logo.method, Method.FAVICON) + + def test_image_prefers_json_ld_logo_over_og_image(self): + # extract_image keeps the old "best big image" priority: curated JSON-LD logo + # outranks a social-share og:image. + _p, soup = fixtures.make_page( + "https://x.example", + "" + '' + "" + "", + ) + image = extractors.extract_image(soup, "https://x.example") + self.assertEqual(image.value, "https://x.example/brand.png") + self.assertEqual(image.method, Method.JSON_LD) + + def test_image_empty_when_none_declared(self): + _p, soup = fixtures.make_page("https://x.example", "") + self.assertEqual(extractors.extract_image(soup, "https://x.example").value, "") + + def test_falls_back_to_favicon_ico(self): + _p, soup = fixtures.make_page("https://x.example", "") + logo = extractors.extract_logo(soup, "https://x.example") + self.assertEqual(logo.value, "https://x.example/favicon.ico") + self.assertEqual(logo.method, Method.FAVICON) + + def test_rejects_unsafe_scheme_in_icon(self): + # A crawled site injecting a javascript: icon href must not be stored as the + # logo -- it is skipped and the safe favicon.ico fallback is used. + _p, soup = fixtures.make_page( + "https://x.example", + "", + ) + logo = extractors.extract_logo(soup, "https://x.example") + self.assertEqual(logo.value, "https://x.example/favicon.ico") + + def test_rejects_unsafe_scheme_in_image(self): + # javascript:/data: in JSON-LD logo or og:image is dropped, never stored. + _p, soup = fixtures.make_page( + "https://x.example", + "" + '' + "" + "", + ) + self.assertEqual(extractors.extract_image(soup, "https://x.example").value, "") + + +class CompanyNameCleaningTest(UnitTestCase): + def test_strips_tagline_after_separator(self): + self.assertEqual(extractors._clean_company_name("PostHog – dev tools"), "PostHog") + + def test_trims_descriptive_sentence_title(self): + self.assertEqual( + extractors._clean_company_name( + "ITC Ltd has diversified presence in FMCG, Paperboards & Packaging" + ), + "ITC Ltd", + ) + self.assertEqual(extractors._clean_company_name("Acme Corp provides cloud security"), "Acme Corp") + + def test_brand_at_end_matches_domain(self): + self.assertEqual( + extractors._clean_company_name( + "Innovative IT & Software Development Services | 8848 Digital", + "https://8848digital.com/", + ), + "8848 Digital", + ) + + def test_without_domain_hint_falls_back_to_first_chunk(self): + self.assertEqual( + extractors._clean_company_name("Innovative IT & Software Development Services | 8848 Digital"), + "Innovative IT & Software Development Services", + ) + + +class JsonLdTest(UnitTestCase): + def test_parses_graph_blocks(self): + _page, soup = fixtures.make_page( + "https://x.com", + '", + ) + blocks = extractors.parse_json_ld(soup) + self.assertEqual(len(blocks), 2) + self.assertEqual(blocks[0]["name"], "A") + + def test_ignores_invalid_json(self): + _page, soup = fixtures.make_page("https://x.com", fixtures.BROKEN_JSON_LD) + self.assertEqual(extractors.parse_json_ld(soup), []) + + +class DescriptionSelectionTest(UnitTestCase): + def test_prefers_about_over_product_homepage(self): + pages, soups = _pages( + ( + "https://shop.example", + "", + ), + ( + "https://shop.example/about", + "", + ), + ) + desc = extractors.select_description(pages, soups) + self.assertEqual(desc.value, "Acme builds developer tools for teams.") + self.assertEqual(desc.source, "https://shop.example/about") + + def test_falls_back_to_homepage(self): + pages, soups = _pages( + ( + "https://x.example", + "", + ), + ) + desc = extractors.select_description(pages, soups) + self.assertEqual(desc.value, "We make great software.") + + def test_json_ld_is_authoritative(self): + pages, soups = _pages( + ( + "https://s.example", + '', + ), + ( + "https://s.example/about", + "", + ), + ) + desc = extractors.select_description(pages, soups) + self.assertEqual(desc.method, Method.JSON_LD) + + def test_about_body_paragraph_when_no_meta(self): + # No description metadata anywhere -> derive it from the About page's body copy. + about_body = ( + "

Acme Robotics designs autonomous warehouse robots that help " + "retailers fulfil orders faster and at lower cost.

" + ) + pages, soups = _pages( + ("https://acme.example", "Acme"), + ("https://acme.example/about", f"{about_body}"), + ) + desc = extractors.select_description(pages, soups) + self.assertIn("autonomous warehouse robots", desc.value) + self.assertEqual(desc.method, Method.BODY_TEXT) + self.assertEqual(desc.source, "https://acme.example/about") + + def test_head_meta_beats_home_body_paragraph(self): + # With an og:description present, head meta wins over a homepage body paragraph. + pages, soups = _pages( + ( + "https://acme.example", + "" + "

Some long homepage marketing paragraph that is " + "definitely over the minimum length threshold for a body paragraph.

" + "
", + ), + ) + desc = extractors.select_description(pages, soups) + self.assertEqual(desc.value, "Acme makes developer tools.") + self.assertEqual(desc.method, Method.META_TAG) + + def test_industry_keyword_density_steers_about_paragraph_choice(self): + finance_rules = [ + fixtures.keyword_rule("Industry", ["financial institution", "banking"], industry="Finance") + ] + pages, soups = _pages( + ( + "https://acmebank.example", + "Acme Bank", + ), + ( + "https://acmebank.example/about", + "
" + "

Putting our long-tenured investment teams on the line to earn the " + "trust of institutional investors across every market we serve.

" + "

Acme Bank is a full-service financial institution serving corporations, " + "institutions and private clients across commercial and investment banking.

" + "
", + ), + ) + desc = extractors.select_description(pages, soups, industry_rules=finance_rules) + self.assertTrue(desc.value.startswith("Acme Bank is a full-service")) + + +class FirstParagraphTest(UnitTestCase): + def test_skips_nav_and_returns_first_substantial_paragraph(self): + html = ( + "" + "

Short.

Globex Corp is a logistics platform that moves freight " + "across the country for enterprise shippers.

" + ) + _page, soup = fixtures.make_page("https://globex.example", html) + result = extractors.first_paragraph(soup) + self.assertIn("logistics platform", result) + self.assertNotIn("Login", result) # nav paragraph skipped despite being long + + def test_returns_empty_when_no_substantial_paragraph(self): + _page, soup = fixtures.make_page("https://x.example", "

Hi.

") + self.assertEqual(extractors.first_paragraph(soup), "") + + def test_prefers_paragraph_with_more_keyword_hits_over_first_qualifying_one(self): + # A narrow pull-quote with zero industry-keyword hits precedes a genuine + # descriptive paragraph -- the keyword-denser paragraph should win. + finance_rules = [ + fixtures.keyword_rule("Industry", ["financial institution", "banking"], industry="Finance") + ] + html = ( + "
" + "

Putting our long-tenured investment teams on the line to earn the " + "trust of institutional investors across every market we serve.

" + "

Acme Bank is a full-service financial institution serving corporations, " + "institutions and private clients across commercial and investment banking.

" + "
" + ) + _page, soup = fixtures.make_page("https://acmebank.example", html) + result = extractors.first_paragraph(soup, industry_rules=finance_rules) + self.assertTrue(result.startswith("Acme Bank is a full-service")) + + def test_falls_back_to_first_paragraph_when_no_keyword_hits(self): + finance_rules = [ + fixtures.keyword_rule("Industry", ["financial institution", "banking"], industry="Finance") + ] + html = ( + "
" + "

Putting our long-tenured investment teams on the line to earn the " + "trust of institutional investors across every market we serve.

" + "
" + ) + _page, soup = fixtures.make_page("https://acmebank.example", html) + result = extractors.first_paragraph(soup, industry_rules=finance_rules) + self.assertTrue(result.startswith("Putting our long-tenured")) + + def test_falls_back_to_first_paragraph_when_no_industry_rules_given(self): + # No rules configured at all (industry_rules=None or []) -- with nothing else + # to separate the candidates, document order still decides. + html = ( + "
" + "

Putting our long-tenured investment teams on the line to earn the " + "trust of institutional investors across every market we serve.

" + "

Acme Bank is a full-service financial institution serving corporations, " + "institutions and private clients across commercial and investment banking.

" + "
" + ) + _page, soup = fixtures.make_page("https://acmebank.example", html) + self.assertTrue(extractors.first_paragraph(soup).startswith("Putting our long-tenured")) + self.assertTrue( + extractors.first_paragraph(soup, industry_rules=[]).startswith("Putting our long-tenured") + ) + self.assertTrue( + extractors.first_paragraph(soup, industry_rules=None).startswith("Putting our long-tenured") + ) + + def test_company_name_breaks_tie_between_equal_keyword_hits(self): + # Both paragraphs mention "banking" exactly once (tied on industry hits) -- + # only the one that also names the company should win. + finance_rules = [fixtures.keyword_rule("Industry", ["banking"], industry="Finance")] + html = ( + "
" + "

Our roots trace back decades of steady growth in commercial banking " + "and long-term client relationships across every region we serve.

" + "

Acme Bank has led the market in retail banking with award-winning " + "service for individuals, families and small businesses nationwide.

" + "
" + ) + _page, soup = fixtures.make_page("https://acmebank.example", html) + result = extractors.first_paragraph(soup, industry_rules=finance_rules, company_name="Acme Bank") + self.assertTrue(result.startswith("Acme Bank has led the market")) + + def test_leadership_bio_never_wins_over_company_description(self): + # An About page's founder bio is dense in industry keywords (it lists former + # employers and sectors), so keyword scoring alone picks it over the real + # description. Bios must be rejected outright. + finance_rules = [ + fixtures.keyword_rule("Industry", ["insurance", "asset management"], industry="Finance") + ] + html = ( + "
" + "

Acme Capital is a registered portfolio manager focused on uncovering " + "long-term opportunities for clients across public equity markets.

" + "

Priya Nair is the Founder and Chief Investment Officer of Acme Capital, " + "where she previously led insurance and asset management mandates.

" + "
" + ) + _page, soup = fixtures.make_page("https://acmecapital.example", html) + result = extractors.first_paragraph(soup, industry_rules=finance_rules) + self.assertTrue(result.startswith("Acme Capital is a registered portfolio manager")) + + def test_call_to_action_paragraph_never_wins(self): + finance_rules = [fixtures.keyword_rule("Industry", ["hvac"], industry="Construction")] + html = ( + "
" + "

Kishika Temperatures has been a leader in air conditioning maintenance " + "and HVAC solutions for over 30 years, serving homes across the region.

" + "

Explore our services to see why our HVAC and HVAC-adjacent teams are the " + "preferred choice for leading developers, hotels and residential projects.

" + "
" + ) + _page, soup = fixtures.make_page("https://kishika.example", html) + result = extractors.first_paragraph(soup, industry_rules=finance_rules) + self.assertTrue(result.startswith("Kishika Temperatures has been a leader")) + + def test_overview_mentioning_founder_once_is_not_a_profile(self): + # A company overview may name its founder with a single pronoun. That is + # not a staff profile and must still win over a generic sector essay. + html = ( + "
" + "

Over the past decade, advances in warehouse robotics have reshaped how " + "goods move between suppliers and consumers around the world.

" + "

Acme Robotics builds warehouse automation for retailers. Founded by " + "Jane Doe in 2015, she still leads our engineering team today.

" + "
" + ) + _page, soup = fixtures.make_page("https://acmerobotics.example", html) + self.assertTrue(extractors.first_paragraph(soup).startswith("Acme Robotics builds")) + + def test_company_described_as_founder_of_something_is_not_a_profile(self): + html = ( + "
" + "

Over the past decade, advances in open hardware have reshaped how " + "engineers share designs and collaborate across company lines.

" + "

Acme Labs is the founder of the Open Hardware Alliance, and we design " + "reference boards used by hundreds of member companies worldwide.

" + "
" + ) + _page, soup = fixtures.make_page("https://acmelabs.example", html) + self.assertTrue(extractors.first_paragraph(soup).startswith("Acme Labs is the founder")) + + def test_overview_speaking_for_the_company_is_never_a_profile(self): + # Two pronouns in copy that says "our" is still the company talking about + # itself, not a staff profile. + html = ( + "
" + "

Over the past decade, advances in warehouse robotics have reshaped how " + "goods move between suppliers and consumers around the world.

" + "

Acme Robotics builds warehouse automation. Jane Doe founded it in 2015; " + "she leads our engineering team and her designs power every product.

" + "
" + ) + _page, soup = fixtures.make_page("https://acmerobotics.example", html) + self.assertTrue(extractors.first_paragraph(soup).startswith("Acme Robotics builds")) + + def test_repeated_pronouns_mark_a_profile(self): + html = ( + "
" + "

Priya Nair joined the firm in 2010. She oversees every client mandate " + "and her research has been cited across the industry for years.

" + "

Acme Capital is a registered portfolio manager focused on uncovering " + "long-term opportunities for clients across public equity markets.

" + "
" + ) + _page, soup = fixtures.make_page("https://acmecapital.example", html) + self.assertTrue(extractors.first_paragraph(soup).startswith("Acme Capital is a registered")) + + def test_falls_back_to_rejected_paragraphs_when_nothing_else_qualifies(self): + # A page of nothing but bios must still yield a paragraph -- rejecting every + # candidate should not silently drop the description. + html = ( + "
" + "

Priya Nair is the Founder of Acme Capital, where she leads the firm's " + "investment committee and oversees portfolio construction end to end.

" + "
" + ) + _page, soup = fixtures.make_page("https://acmecapital.example", html) + self.assertTrue(extractors.first_paragraph(soup).startswith("Priya Nair is the Founder")) + + def test_speaking_for_the_company_breaks_a_zero_hit_tie(self): + # Neither paragraph scores an industry hit: a generic essay about the sector + # sits first, the company's own copy second. Document order must not decide. + finance_rules = [fixtures.keyword_rule("Industry", ["banking"], industry="Finance")] + html = ( + "
" + "

Over the past decade, advances in concrete technology have allowed " + "engineers to design mega structures that redefine infrastructure.

" + "

Capital RMC, established in 2016, stands at the forefront of the " + "construction sector, and we deliver world-class ready-mix concrete.

" + "
" + ) + _page, soup = fixtures.make_page("https://capitalrmc.example", html) + result = extractors.first_paragraph(soup, industry_rules=finance_rules) + self.assertTrue(result.startswith("Capital RMC, established in 2016")) + + def test_speaking_for_the_company_cannot_outrank_more_industry_hits(self): + finance_rules = [ + fixtures.keyword_rule("Industry", ["financial institution", "banking"], industry="Finance") + ] + html = ( + "
" + "

A full-service financial institution built on decades of trusted " + "commercial and retail banking relationships across every market.

" + "

We are proud to serve local businesses across the region with " + "award-winning service and community-first values that endure.

" + "
" + ) + _page, soup = fixtures.make_page("https://acmebank.example", html) + result = extractors.first_paragraph(soup, industry_rules=finance_rules) + self.assertTrue(result.startswith("A full-service financial institution")) + + def test_company_name_mentions_cannot_outrank_more_industry_hits(self): + # The first paragraph scores 2 industry hits and never names the company; the + # second scores only 1 industry hit despite repeating the company name three + # times. Industry hits must still decide -- company name is tiebreak-only. + finance_rules = [ + fixtures.keyword_rule("Industry", ["financial institution", "banking"], industry="Finance") + ] + html = ( + "
" + "

A full-service financial institution built on decades of trusted " + "commercial and retail banking relationships across every market.

" + "

Acme Bank, Acme Bank, Acme Bank -- proud to serve local businesses " + "with award-winning retail banking and community-first values.

" + "
" + ) + _page, soup = fixtures.make_page("https://acmebank.example", html) + result = extractors.first_paragraph(soup, industry_rules=finance_rules, company_name="Acme Bank") + self.assertTrue(result.startswith("A full-service financial institution")) + + +class AboutPageDetectionTest(UnitTestCase): + def test_canonical_about_slugs_match(self): + for path in ( + "/about", + "/about-us", + "/about_us", + "/aboutus", + "/company", + "/our-story", + "/en/about-us", + ): + self.assertTrue(extractors._is_about_page(f"https://x.example{path}"), path) + + def test_prefix_match_no_longer_qualifies(self): + # Regression guard: "about-our-reporting" (an ESG page) starts with "about-" + # but is not itself a canonical About-page slug. + self.assertFalse(extractors._is_about_page("https://x.example/esg-topics/about-our-reporting")) + self.assertFalse(extractors._is_about_page("https://x.example/about-cookies")) + + def test_non_about_words_still_rejected(self): + self.assertFalse(extractors._is_about_page("https://x.example/about/careers")) + self.assertFalse(extractors._is_about_page("https://x.example/about-us/contact")) + + +# --------------------------------------------------------------------------- # +# Contacts: emails + phones (mechanics) +# --------------------------------------------------------------------------- # +class EmailExtractionTest(UnitTestCase): + def setUp(self): + self.pages, _ = _pages( + ("https://acme.example", fixtures.HOMEPAGE), + ("https://acme.example/about", fixtures.ABOUT), + ("https://acme.example/contact", fixtures.CONTACT), + ) + + def test_emails_deduped_and_sourced(self): + emails = extractors.extract_emails(self.pages) + values = {e.value for e in emails} + self.assertIn("hello@acme.example", values) + self.assertIn("sales@acme.example", values) + self.assertEqual(len(values), len(emails)) # deduped + + def test_blocklisted_noise_emails_rejected(self): + pages, _ = _pages( + ("https://x.com", "logo@2x.png test@example.com real@x.com"), + ) + values = {e.value for e in extractors.extract_emails(pages)} + self.assertIn("real@x.com", values) + self.assertNotIn("test@example.com", values) + + +class PhoneExtractionTest(UnitTestCase): + def setUp(self): + self.pages, _ = _pages( + ("https://acme.example", fixtures.HOMEPAGE), + ("https://acme.example/contact", fixtures.CONTACT), + ) + + def test_tel_link_marked(self): + phones = extractors.extract_phones(self.pages) + self.assertIn(Method.TEL_LINK, {p.method for p in phones}) + self.assertTrue(any("4155550142" in p.value.replace("+", "") for p in phones)) + + def test_international_number_captured(self): + pages, _ = _pages( + ("https://x.example/contact", "

Call us: +612 9003 8888

") + ) + phones = extractors.extract_phones(pages) + self.assertTrue(any("61290038888" in p.value.replace("+", "") for p in phones)) + + def test_number_without_country_code_rejected(self): + # Deliberate precision-over-recall call: a number with no "+" is either a + # local/ambiguous format or noise (reference numbers, stats) that happens to + # fall in a phone-shaped digit range -- only accept explicit international + # numbers, even though this means dropping some real local-format numbers. + pages, _ = _pages( + ( + "https://x.example/contact", + "

Local line: 2165703. Toll-free: 1800-266-6066.

" + 'Call', + ) + ) + phones = extractors.extract_phones(pages) + self.assertEqual(phones, []) + + +# --------------------------------------------------------------------------- # +# Social profiles (config-driven via Social rules) +# --------------------------------------------------------------------------- # +class SocialTest(UnitTestCase): + def test_detects_networks_and_skips_share_links(self): + page, soup = fixtures.make_page("https://acme.example", fixtures.HOMEPAGE) + company = extractors.extract_company_info(page, soup) + profiles = extractors.extract_social_profiles( + [page], {page.url: soup}, fixtures.social_rules(), extra_links=company["social_links"] + ) + self.assertTrue(profiles["linkedin"].value) + self.assertTrue(profiles["github"].value) + self.assertTrue(profiles["instagram"].value) + # JSON-LD sameAs links win and are marked as such. + self.assertEqual(profiles["linkedin"].method, Method.JSON_LD) + # A facebook 'sharer' link must be ignored. + self.assertEqual(profiles["facebook"].value, "") + + def test_protocol_relative_links_made_absolute(self): + page, soup = fixtures.make_page( + "https://bluedart.com", + "li" + "tw", + ) + profiles = extractors.extract_social_profiles([page], {page.url: soup}, fixtures.social_rules()) + self.assertEqual(profiles["linkedin"].value, "https://www.linkedin.com/company/bluedart/") + self.assertEqual(profiles["twitter"].value, "https://twitter.com/BlueDart_") + + +# --------------------------------------------------------------------------- # +# Industry classification (config-driven via Industry rules) +# --------------------------------------------------------------------------- # +class IndustryTest(UnitTestCase): + def _classify(self, name, description, extra_html=""): + page, _ = fixtures.make_page( + "https://x.example", + f"{name}{extra_html}", + ) + company = {"company_name": name, "description": description} + return extractors.classify_industry([page], company, fixtures.industry_rules()) + + def test_classifies_saas_or_crm_with_confidence(self): + page, soup = fixtures.make_page("https://acme.example", fixtures.HOMEPAGE) + company = extractors.extract_company_info(page, soup) + industry, conf = extractors.classify_industry([page], company, fixtures.industry_rules()) + self.assertIn(industry, ("SaaS", "CRM")) + self.assertGreater(conf, 0.0) + self.assertLessEqual(conf, 1.0) + + def test_empty_site_returns_no_industry(self): + page, _ = fixtures.make_page("https://e.example", fixtures.EMPTY) + industry, conf = extractors.classify_industry([page], {}, fixtures.industry_rules()) + self.assertEqual(industry, "") + self.assertEqual(conf, 0.0) + + def test_no_rules_returns_empty(self): + page, _ = fixtures.make_page("https://e.example", fixtures.HOMEPAGE) + self.assertEqual(extractors.classify_industry([page], {}, []), ("", 0.0)) + + def test_d2c_brand_maps_to_ecommerce(self): + self.assertEqual(self._classify("Gymshark", "Shop gym clothing and workout apparel.")[0], "Ecommerce") + + def test_beverage_brand_maps_to_food_and_beverage(self): + self.assertEqual( + self._classify("Liquid Death", "A beverage company with sparkling water and iced tea.")[0], + "Food & Beverage", + ) + + def test_legal_help_beats_stray_keyword(self): + self.assertEqual( + self._classify( + "Rocket Lawyer", "Rocket Lawyer makes legal help simple — legal documents and advice." + )[0], + "Legal", + ) + + def test_3d_printing_maps_to_manufacturing(self): + # A "shop" nav link must not beat the core business signal in the headline. + self.assertEqual( + self._classify( + "Desktop Metal", + "Metal 3D printing and additive manufacturing for engineers.", + "", + )[0], + "Manufacturing", + ) + + def test_about_page_headline_supplements_thin_homepage(self): + # A homepage with no meta description and abstract brand-voice headings + # carries no classification signal on its own -- a crawled About page's + # title/headings should still let the classifier find a match. + finance_rules = [ + fixtures.keyword_rule( + "Industry", + ["banking", "financial services"], + industry="Finance", + match_scope="Headline", + ) + ] + home, _ = fixtures.make_page( + "https://acmebank.example", + "Acme | Official Website" + "

Acme home

What problem can we solve together?

", + ) + about, _ = fixtures.make_page( + "https://acmebank.example/about", + "About Acme" + "

A leader in financial services and banking

", + ) + company = {"company_name": "Acme", "description": ""} + industry, conf = extractors.classify_industry([home, about], company, finance_rules) + self.assertEqual(industry, "Finance") + self.assertGreater(conf, 0.0) + + def test_homepage_only_still_returns_nothing_when_thin(self): + finance_rules = [ + fixtures.keyword_rule( + "Industry", + ["banking", "financial services"], + industry="Finance", + match_scope="Headline", + ) + ] + home, _ = fixtures.make_page( + "https://acmebank.example", + "Acme | Official Website" + "

Acme home

What problem can we solve together?

", + ) + company = {"company_name": "Acme", "description": ""} + industry, conf = extractors.classify_industry([home], company, finance_rules) + self.assertEqual(industry, "") + self.assertEqual(conf, 0.0) + + +# --------------------------------------------------------------------------- # +# Generic rule executors directly +# --------------------------------------------------------------------------- # +class RuleExecutorTest(UnitTestCase): + def test_apply_keyword_rules_weights_and_sums(self): + rule = fixtures.keyword_rule( + "Industry", ["saas", "subscription"], industry="SaaS", weight=2.0, match_scope="Headline" + ) + scores = extractors.apply_keyword_rules({"Headline": "a saas subscription saas"}, [rule]) + # 3 hits * weight 2.0 = 6.0 + self.assertEqual(scores["SaaS"], 6.0) + + +# --------------------------------------------------------------------------- # +# Readability diagnosis (mechanics) +# --------------------------------------------------------------------------- # +class ReadabilityTest(UnitTestCase): + def test_blocked_waf_challenge(self): + page, _ = fixtures.make_page( + "https://www.pepsi.com", + '', + ) + self.assertEqual(extractors.diagnose_readability([page]), "blocked") + + def test_empty_js_only_shell(self): + page, _ = fixtures.make_page( + "https://spa.example", "
" + ) + self.assertEqual(extractors.diagnose_readability([page]), "empty") + + def test_unreachable_when_failed(self): + page = CrawledPage(url="https://x.example", status_code=0, error="timeout after 10s") + self.assertEqual(extractors.diagnose_readability([page]), "unreachable") + + def test_http_403_is_blocked(self): + page = CrawledPage( + url="https://www.pepperfry.com", + status_code=403, + html="Access Denied", + text="Access Denied", + title="Access Denied", + ) + self.assertEqual(extractors.diagnose_readability([page]), "blocked") + + def test_readable_site_returns_empty_reason(self): + page, _ = fixtures.make_page("https://acme.example", fixtures.HOMEPAGE) + self.assertEqual(extractors.diagnose_readability([page]), "") diff --git a/crm/domain_enrichment/tests/test_integration.py b/crm/domain_enrichment/tests/test_integration.py new file mode 100644 index 0000000..7dd992e --- /dev/null +++ b/crm/domain_enrichment/tests/test_integration.py @@ -0,0 +1,301 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""IntegrationTestCase tests for the Frappe layer (real test DB). + +Covers: seeder idempotency, the mapper write-policies against real CRM +Lead/Deal/Organization docs, the single Run writer, API permission/allow-list +enforcement + rate-limit, and the link-time Organization -> Lead/Deal copy. + +All tests are network-free: wherever a crawl would happen the pipeline / fetch is +monkeypatched to return a canned EnrichmentResult, so nothing leaves the box. +""" + +from __future__ import annotations + +from unittest import mock + +import frappe +from frappe.tests import IntegrationTestCase + +from crm.domain_enrichment import config, install, tasks +from crm.domain_enrichment.config import get_config +from crm.domain_enrichment.cross_record import copy_enrichment_from_organization +from crm.domain_enrichment.mapper import apply_to_document +from crm.domain_enrichment.result import EnrichmentResult, Field, SocialProfile +from crm.domain_enrichment.tests import fixtures + + +def canned_result(website="https://acme.example") -> EnrichmentResult: + """A fully-populated EnrichmentResult — no network, deterministic.""" + r = EnrichmentResult(website=website) + r.company_name = Field("Acme Analytics Inc.", website, "JSON-LD") + r.description = Field("AI powered analytics SaaS for product teams.", website, "Meta Tag") + r.logo = Field("https://acme.example/favicon.ico", website, "Favicon") + r.industry = Field("SaaS", website, "Keyword Classifier") + r.industry_confidence = 0.8 + r.social_profiles = { + "linkedin": SocialProfile("https://www.linkedin.com/company/acme", website), + "twitter": SocialProfile("https://twitter.com/acme", website), + } + return r + + +class SeederIdempotencyTest(IntegrationTestCase): + def tearDown(self): + frappe.db.rollback() + + def test_seeder_is_a_no_op_on_second_run(self): + # The seeder runs on install/migrate, so records already exist. Running it + # again must not create duplicate Rule / Field Mapping rows. + install.seed_default_rules_and_mappings() + rules_before = frappe.db.count("CRM Enrichment Rule") + mappings_before = frappe.db.count("CRM Enrichment Field Mapping") + + install.seed_default_rules_and_mappings() + + self.assertEqual(frappe.db.count("CRM Enrichment Rule"), rules_before) + self.assertEqual(frappe.db.count("CRM Enrichment Field Mapping"), mappings_before) + self.assertGreater(rules_before, 0) + self.assertGreater(mappings_before, 0) + + +class MapperWritePolicyTest(IntegrationTestCase): + def setUp(self): + self.cfg = get_config() + self.result = canned_result() + + def tearDown(self): + frappe.db.rollback() + + def _new_org(self, **kwargs): + doc = frappe.new_doc("CRM Organization") + doc.organization_name = kwargs.pop("organization_name", "Mapper Test Org") + for k, v in kwargs.items(): + doc.set(k, v) + return doc + + def test_fill_if_empty_fills_only_empty_fields(self): + org = self._new_org(company_description="") + apply_to_document(org, self.result, self.cfg) + self.assertEqual(org.company_description, "AI powered analytics SaaS for product teams.") + + def test_fill_if_empty_never_clobbers_user_data(self): + org = self._new_org(company_description="Hand-written by sales.") + apply_to_document(org, self.result, self.cfg) + self.assertEqual(org.company_description, "Hand-written by sales.") + + def test_create_missing_link_creates_industry(self): + industry = "Quantum Widgets " + frappe.generate_hash(length=6) + self.assertFalse(frappe.db.exists("CRM Industry", industry)) + result = canned_result() + result.industry = Field(industry, "https://x.example", "Keyword Classifier") + org = self._new_org(industry="") + apply_to_document(org, result, self.cfg) + self.assertTrue(frappe.db.exists("CRM Industry", industry)) + self.assertEqual(org.industry, industry) + + def test_create_missing_link_respects_user_create_permission(self): + # A user without CRM Industry create rights must not be able to auto-create a + # master from a scraped value: the master is not inserted and the field is + # skipped (no dangling link), rather than an ignore_permissions escalation. + industry = "Contraband Industry " + frappe.generate_hash(length=6) + result = canned_result() + result.industry = Field(industry, "https://x.example", "Keyword Classifier") + org = self._new_org(industry="") + + user = _make_minimal_user() + self.addCleanup(frappe.set_user, "Administrator") + frappe.set_user(user) + apply_to_document(org, result, self.cfg) + + self.assertFalse(frappe.db.exists("CRM Industry", industry)) + self.assertFalse(org.industry) + + +class RunWriterTest(IntegrationTestCase): + def tearDown(self): + frappe.db.rollback() + + def test_writes_exactly_one_run_with_summary(self): + org = frappe.new_doc("CRM Organization") + org.organization_name = "Run Writer Org " + frappe.generate_hash(length=4) + org.website = "https://acme.example" + org.insert() + + before = frappe.db.count("CRM Enrichment Run", {"reference_name": org.name}) + run_name = tasks.write_run( + "CRM Organization", + org.name, + "https://acme.example", + status="Completed", + result=canned_result(), + ) + after = frappe.db.count("CRM Enrichment Run", {"reference_name": org.name}) + self.assertEqual(after - before, 1) + + run = frappe.get_doc("CRM Enrichment Run", run_name) + self.assertEqual(run.status, "Completed") + self.assertEqual(run.company_name, "Acme Analytics Inc.") + self.assertEqual(run.industry, "SaaS") + self.assertTrue(run.finished_on) + self.assertIn("Acme Analytics", run.raw_json) + + +class ApiPermissionTest(IntegrationTestCase): + def tearDown(self): + frappe.db.rollback() + frappe.set_user("Administrator") + + def test_enrich_throws_on_disallowed_doctype(self): + from crm.domain_enrichment.api import enrich + + with self.assertRaises(frappe.ValidationError): + enrich("ToDo", "anything") + + def test_enrich_requires_write_permission(self): + from crm.domain_enrichment.api import enrich + + org = frappe.new_doc("CRM Organization") + org.organization_name = "Perm Org " + frappe.generate_hash(length=4) + org.website = "https://acme.example" + org.insert() + + # A user with no role on CRM Organization must be blocked by check_permission. + user = _make_minimal_user() + frappe.set_user(user) + with self.assertRaises(frappe.PermissionError): + enrich("CRM Organization", org.name) + + def test_enrich_routes_are_rate_limited(self): + # enrich / retry each carry rate_limit(limit=ENRICH_RATE_LIMIT). + from crm.domain_enrichment import api + + for fn in (api.enrich, api.retry): + self.assertTrue(hasattr(fn, "__wrapped__"), fn.__name__) + + +class CrossRecordCopyTest(IntegrationTestCase): + def setUp(self): + self.org = frappe.new_doc("CRM Organization") + self.org.organization_name = "Enriched Org " + frappe.generate_hash(length=4) + self.org.website = "https://acme.example" + self.org.company_description = "AI powered analytics SaaS." + self.org.linkedin = "https://www.linkedin.com/company/acme" + self.org.insert() + # Mark it enriched via a completed Run (the cleanest "is enriched" signal). + tasks.write_run( + "CRM Organization", + self.org.name, + self.org.website, + status="Completed", + result=canned_result(), + ) + + def tearDown(self): + frappe.db.rollback() + + def test_copies_empty_fields_from_enriched_org(self): + deal = frappe.new_doc("CRM Deal") + deal.organization = self.org.name + filled = copy_enrichment_from_organization(deal) + self.assertTrue(filled) + self.assertEqual(deal.company_description, "AI powered analytics SaaS.") + self.assertEqual(deal.linkedin, "https://www.linkedin.com/company/acme") + + def test_does_not_clobber_user_set_fields(self): + deal = frappe.new_doc("CRM Deal") + deal.organization = self.org.name + deal.company_description = "User wrote this." + copy_enrichment_from_organization(deal) + self.assertEqual(deal.company_description, "User wrote this.") + + def test_non_enriched_org_is_a_noop(self): + plain = frappe.new_doc("CRM Organization") + plain.organization_name = "Plain Org " + frappe.generate_hash(length=4) + plain.website = "https://plain.example" + plain.insert() + + deal = frappe.new_doc("CRM Deal") + deal.organization = plain.name + filled = copy_enrichment_from_organization(deal) + self.assertEqual(filled, []) + self.assertFalse(deal.company_description) + + +class AutoEnrichOnCreateTest(IntegrationTestCase): + """The after_insert auto-enrich hook (Lead, Deal, Organization), gated by Settings.""" + + def _settings(self, **overrides): + s = { + "enabled": 1, + "auto_enrich": 1, + "request_timeout": 10, + "max_pages": 10, + } + s.update(overrides) + return frappe._dict(s) + + def _doc(self, doctype, website="https://acme.example", name="X1"): + return frappe._dict(doctype=doctype, name=name, website=website) + + def _run(self, doc, settings): + # auto_enrich_on_create now does a Settings-only gate (config.get_settings), and + # enqueue_enrichment reads its timeout the same way -- patch both to the fake + # Settings and assert on frappe.enqueue. + with ( + mock.patch.object(config, "get_settings", return_value=settings), + mock.patch.object(tasks, "get_settings", return_value=settings), + mock.patch.object(tasks.frappe, "enqueue") as enq, + ): + tasks.auto_enrich_on_create(doc) + return enq + + def test_enqueues_for_new_lead(self): + enq = self._run(self._doc("CRM Lead", name="L1"), self._settings()) + enq.assert_called_once() + kwargs = enq.call_args.kwargs + self.assertEqual(kwargs["reference_doctype"], "CRM Lead") + self.assertEqual(kwargs["job_id"], "domain-enrich-CRM Lead-L1") + + def test_enqueues_for_new_deal(self): + enq = self._run(self._doc("CRM Deal", name="D1"), self._settings()) + enq.assert_called_once() + kwargs = enq.call_args.kwargs + self.assertEqual(kwargs["reference_doctype"], "CRM Deal") + self.assertEqual(kwargs["job_id"], "domain-enrich-CRM Deal-D1") + self.assertTrue(kwargs["deduplicate"]) + + def test_enqueues_for_new_organization(self): + enq = self._run(self._doc("CRM Organization", name="O1"), self._settings()) + enq.assert_called_once() + self.assertEqual(enq.call_args.kwargs["reference_doctype"], "CRM Organization") + + def test_skips_when_auto_enrich_off(self): + enq = self._run(self._doc("CRM Deal"), self._settings(auto_enrich=0)) + enq.assert_not_called() + + def test_skips_when_feature_disabled(self): + enq = self._run(self._doc("CRM Deal"), self._settings(enabled=0)) + enq.assert_not_called() + + def test_skips_doctype_outside_the_allow_list(self): + enq = self._run(self._doc("CRM Task"), self._settings()) + enq.assert_not_called() + + def test_skips_without_website(self): + enq = self._run(self._doc("CRM Deal", website=""), self._settings()) + enq.assert_not_called() + + +def _make_minimal_user(): + """Create (or reuse) a user with no CRM Organization role for permission tests.""" + email = "de-noperm@example.com" + if not frappe.db.exists("User", email): + user = frappe.new_doc("User") + user.email = email + user.first_name = "No" + user.last_name = "Perm" + user.send_welcome_email = 0 + user.insert(ignore_permissions=True) + return email diff --git a/crm/domain_enrichment/tests/test_pipeline.py b/crm/domain_enrichment/tests/test_pipeline.py new file mode 100644 index 0000000..30d0ba1 --- /dev/null +++ b/crm/domain_enrichment/tests/test_pipeline.py @@ -0,0 +1,193 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""Pure, network-free pipeline tests. + +The crawl is stubbed (``pipeline.crawl`` patched to return canned offline pages), so +the whole pipeline runs with zero network. Asserts the assembled ``EnrichmentResult``, +the explainable provenance schema (``to_dict``/``flat``) the mapper depends on, and +the progress-step contract. +""" + +from __future__ import annotations + +from unittest import mock + +from frappe.tests import UnitTestCase + +from crm.domain_enrichment import pipeline as pipeline_mod +from crm.domain_enrichment.pipeline import PROGRESS_STEPS, _normalize_website +from crm.domain_enrichment.pipeline import run as run_pipeline +from crm.domain_enrichment.result import EnrichmentResult +from crm.domain_enrichment.tests import fixtures + +# The schema the CRM field mapper relies on. If extractors change the shape these +# assertions fail loudly instead of the mapper silently reading missing keys. +EXPECTED_TOP_KEYS = { + "company_name", + "description", + "logo", + "image", + "industry", + "industry_confidence", + "emails", + "phones", + "social_profiles", + "_meta", +} +EXPECTED_FLAT_KEYS = { + "company_name", + "description", + "logo", + "industry", + "industry_confidence", + "social_profiles", +} +SCALAR_SUBKEYS = {"value", "source", "method"} + + +def _full_config(): + """An in-memory config wired with all the rule types the fixtures exercise.""" + return fixtures.make_config( + settings={"max_pages": 5, "max_depth": 1, "request_timeout": 5}, + rules_by_type={ + "Industry": fixtures.industry_rules(), + "Social": fixtures.social_rules(), + }, + ) + + +def _canned_crawl(*_args, **_kwargs): + """Return canned (CrawledPage, soup) tuples instead of crawling the network.""" + return [ + fixtures.make_page("https://acme.example", fixtures.HOMEPAGE), + fixtures.make_page("https://acme.example/about", fixtures.ABOUT), + fixtures.make_page("https://acme.example/contact", fixtures.CONTACT), + ] + + +class NormalizeWebsiteTest(UnitTestCase): + def test_adds_https_scheme(self): + self.assertEqual(_normalize_website("frappe.io"), "https://frappe.io") + + def test_rejects_empty(self): + with self.assertRaises(ValueError): + _normalize_website("") + + def test_rejects_bad_scheme(self): + with self.assertRaises(ValueError): + _normalize_website("ftp://x.com") + + +class PipelineRunTest(UnitTestCase): + def setUp(self): + self.cfg = _full_config() + + def test_full_result_is_explainable(self): + steps = [] + with mock.patch.object(pipeline_mod, "crawl", _canned_crawl): + result = run_pipeline( + "https://acme.example", + cfg=self.cfg, + progress=lambda i, m: steps.append((i, m)), + ) + out = result.to_dict() + + self.assertEqual(out["company_name"]["value"], "Acme Analytics Inc.") + self.assertTrue(out["company_name"]["source"]) + self.assertTrue(out["company_name"]["method"]) + + # Every list item carries provenance. + for item in out["emails"] + out["phones"]: + self.assertTrue(item["source"], f"missing source: {item}") + self.assertTrue(item["method"], f"missing method: {item}") + + # Progress emitted, ending at the final step. + emitted = [i for i, _ in steps] + self.assertEqual(emitted[-1], len(PROGRESS_STEPS) - 1) + + def test_flat_view(self): + with mock.patch.object(pipeline_mod, "crawl", _canned_crawl): + result = run_pipeline("https://acme.example", cfg=self.cfg) + flat = result.flat() + self.assertEqual(flat["company_name"], "Acme Analytics Inc.") + self.assertIsInstance(flat["social_profiles"], dict) + + def test_unreachable_site_returns_empty_without_raising(self): + from crm.domain_enrichment.result import CrawledPage + + def broken_crawl(*_a, **_k): + return [(CrawledPage(url="https://x.example", status_code=0, error="timeout after 5s"), None)] + + with mock.patch.object(pipeline_mod, "crawl", broken_crawl): + result = run_pipeline("https://x.example", cfg=self.cfg) + out = result.to_dict() + self.assertEqual(out["company_name"]["value"], "") + self.assertTrue(out["_meta"]["errors"]) + self.assertTrue(result.notes) # carries the "couldn't reach" note + + +class JsSpaSiteTest(UnitTestCase): + """Enriching a JavaScript-only SPA (empty body, client-rendered content). + + Without a render step the engine sees only the build-tool-default `` metadata + and an empty ``. It must (a) flag the page as JS-rendered, (b) not fabricate + a description/industry/contacts, and (c) still surface whatever metadata exists + (title-tag company name + favicon), all without raising. + """ + + def _spa_crawl(self, *_a, **_k): + return [fixtures.make_page("https://spa.example", fixtures.JS_SPA_SHELL)] + + def test_js_only_site_yields_metadata_only(self): + cfg = _full_config() + with ( + mock.patch.object(pipeline_mod, "crawl", self._spa_crawl), + # No About page is crawled; the probe must not hit the real network. + mock.patch.object(pipeline_mod, "probe_about_pages", return_value=[]), + ): + result = run_pipeline("https://spa.example", cfg=cfg) + out = result.to_dict() + + # Flagged as JS-rendered, and nothing fabricated. + self.assertTrue(any("JavaScript-rendered" in n for n in result.notes)) + self.assertEqual(out["description"]["value"], "") + self.assertEqual(out["industry"]["value"], "") + self.assertEqual(out["emails"], []) + self.assertEqual(out["phones"], []) + self.assertTrue(all(p.value == "" for p in result.social_profiles.values())) + + # Only the metadata the shell actually exposes is surfaced. + self.assertEqual(out["company_name"]["value"], "Vite App") + self.assertEqual(out["company_name"]["method"], "Title Tag") + self.assertTrue(out["logo"]["value"].endswith("/vite.svg")) + + def test_readability_diagnosed_empty(self): + from crm.domain_enrichment import extractors + + page, _soup = fixtures.make_page("https://spa.example", fixtures.JS_SPA_SHELL) + self.assertEqual(extractors.diagnose_readability([page]), "empty") + + +class SchemaStabilityTest(UnitTestCase): + """The result schema must be identical regardless of what a site exposes.""" + + def _assert_invariants(self, out, flat): + self.assertEqual(set(out), EXPECTED_TOP_KEYS) + self.assertEqual(set(flat), EXPECTED_FLAT_KEYS) + for key in ("company_name", "description", "logo", "industry"): + self.assertEqual(set(out[key]), SCALAR_SUBKEYS) + + def test_empty_result_has_full_schema(self): + result = EnrichmentResult() + self._assert_invariants(result.to_dict(), result.flat()) + + def test_populated_result_has_same_schema(self): + with mock.patch.object(pipeline_mod, "crawl", _canned_crawl): + result = run_pipeline("https://acme.example", cfg=_full_config()) + out = result.to_dict() + self._assert_invariants(out, result.flat()) + for e in out["emails"]: + self.assertEqual(set(e), {"value", "source", "method"}) + for p in out["phones"]: + self.assertEqual(set(p), {"value", "raw", "source", "method"}) diff --git a/crm/domain_enrichment/tests/test_ssrf.py b/crm/domain_enrichment/tests/test_ssrf.py new file mode 100644 index 0000000..3a6b2e0 --- /dev/null +++ b/crm/domain_enrichment/tests/test_ssrf.py @@ -0,0 +1,214 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +"""Pure unit tests for the SSRF guard (``http.validate_url`` / ``http._is_blocked_ip``). + +These are fully offline: ``socket.getaddrinfo`` is monkeypatched so DNS resolution +is deterministic and no network call is ever made. The guard must reject loopback, +link-local, private and reserved addresses (incl. the cloud-metadata endpoint +169.254.169.254), reject non-http(s) schemes, honor allow/block domain lists, and +allow a normal public host. The private-network rejection is unconditional -- +there is no setting to disable it. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from unittest import mock + +from frappe.tests import UnitTestCase + +from crm.domain_enrichment import http +from crm.domain_enrichment.http import SSRFError, validate_url +from crm.domain_enrichment.tests.fixtures import make_config + + +@contextmanager +def _resolves_to(ip_map): + """Patch socket.getaddrinfo so a host resolves to the given IP(s). + + ``ip_map`` maps hostname -> ip string (or list of ip strings). An unknown host + raises gaierror, mimicking a real resolver failure. + """ + import socket + + def fake_getaddrinfo(host, *args, **kwargs): + if host not in ip_map: + raise socket.gaierror(f"unknown host {host}") + ips = ip_map[host] + if isinstance(ips, str): + ips = [ips] + # getaddrinfo returns 5-tuples; element 4 is the sockaddr (ip, port, ...). + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 0)) for ip in ips] + + with mock.patch.object(http.socket, "getaddrinfo", side_effect=fake_getaddrinfo): + yield + + +class SSRFGuardTest(UnitTestCase): + def setUp(self): + self.cfg = make_config() # default: no allow/block lists + + # -- rejection of internal / dangerous addresses ----------------------- # + def test_rejects_loopback_127(self): + with _resolves_to({"evil.example": "127.0.0.1"}): + with self.assertRaises(SSRFError): + validate_url("http://evil.example/", self.cfg) + + def test_rejects_cloud_metadata_link_local(self): + # 169.254.169.254 is the AWS/GCP metadata endpoint — a classic SSRF target. + with _resolves_to({"metadata.example": "169.254.169.254"}): + with self.assertRaises(SSRFError): + validate_url("http://metadata.example/latest/meta-data/", self.cfg) + + def test_rejects_private_10_range(self): + with _resolves_to({"intranet.example": "10.0.0.5"}): + with self.assertRaises(SSRFError): + validate_url("http://intranet.example/", self.cfg) + + def test_rejects_localhost(self): + with _resolves_to({"localhost": "127.0.0.1"}): + with self.assertRaises(SSRFError): + validate_url("http://localhost:8000/", self.cfg) + + def test_rejects_ipv6_loopback(self): + with _resolves_to({"v6.example": "::1"}): + with self.assertRaises(SSRFError): + validate_url("http://v6.example/", self.cfg) + + # -- scheme validation ------------------------------------------------- # + def test_rejects_non_http_scheme(self): + with self.assertRaises(SSRFError): + validate_url("ftp://example.com/x", self.cfg) + with self.assertRaises(SSRFError): + validate_url("file:///etc/passwd", self.cfg) + with self.assertRaises(SSRFError): + validate_url("gopher://example.com/", self.cfg) + + def test_rejects_url_without_host(self): + with self.assertRaises(SSRFError): + validate_url("http:///nohost", self.cfg) + + def test_rejects_unresolvable_host(self): + with _resolves_to({}): # nothing resolves + with self.assertRaises(SSRFError): + validate_url("http://does-not-resolve.example/", self.cfg) + + # -- the happy path ---------------------------------------------------- # + def test_allows_public_host(self): + with _resolves_to({"frappe.io": "104.21.0.1"}): + self.assertEqual(validate_url("https://frappe.io/", self.cfg), "https://frappe.io/") + + def test_rejects_when_any_resolved_ip_is_private(self): + # DNS-rebinding style: one public + one private IP -> must reject. + with _resolves_to({"mixed.example": ["8.8.8.8", "10.1.2.3"]}): + with self.assertRaises(SSRFError): + validate_url("http://mixed.example/", self.cfg) + + # -- the private-network guard is unconditional ------------------------ # + def test_private_network_guard_cannot_be_disabled(self): + # There is no setting to allow private networks -- a would-be bypass flag + # is inert, the guard still rejects a private address. + cfg = make_config(settings={"allow_private_networks": 1}) + with _resolves_to({"intranet.example": "10.0.0.5"}): + with self.assertRaises(SSRFError): + validate_url("http://intranet.example/", cfg) + + # -- allow / block lists ----------------------------------------------- # + def test_blocked_domain_is_always_rejected(self): + cfg = make_config(blocked_domains=["evil.com"]) + with _resolves_to({"evil.com": "8.8.8.8"}): + with self.assertRaises(SSRFError): + validate_url("http://evil.com/", cfg) + # Subdomains of a blocked domain are blocked too. + with _resolves_to({"sub.evil.com": "8.8.8.8"}): + with self.assertRaises(SSRFError): + validate_url("http://sub.evil.com/", cfg) + + def test_trailing_dot_fqdn_does_not_bypass_block_list(self): + # evil.com. resolves the same as evil.com in DNS, so the trailing dot must not + # slip past the block rule (the block check fires before resolution). + cfg = make_config(blocked_domains=["evil.com"]) + with self.assertRaises(SSRFError): + validate_url("http://evil.com./", cfg) + with self.assertRaises(SSRFError): + validate_url("http://sub.evil.com./", cfg) + # And a block entry that itself carries a trailing dot still matches. + self.assertTrue(http._domain_in_list("evil.com", ["evil.com."])) + + def test_trailing_dot_host_is_normalized_for_allow_list_and_resolution(self): + # The stripped host feeds both the allow-list check and DNS resolution: the + # resolver only knows "good.com" (no dot), so passing "good.com." proves the + # trailing dot was stripped before both steps. + cfg = make_config(allowed_domains=["good.com"]) + with _resolves_to({"good.com": "8.8.8.8"}): + self.assertEqual(validate_url("http://good.com./", cfg), "http://good.com./") + + def test_allow_list_restricts_to_listed_hosts(self): + cfg = make_config(allowed_domains=["good.com"]) + with _resolves_to({"good.com": "8.8.8.8", "other.com": "8.8.8.8"}): + self.assertEqual(validate_url("http://good.com/", cfg), "http://good.com/") + with self.assertRaises(SSRFError): + validate_url("http://other.com/", cfg) + + # -- direct IP-classification unit ------------------------------------- # + def test_is_blocked_ip_classification(self): + for ip in ("127.0.0.1", "10.0.0.5", "172.16.0.1", "192.168.1.1", "169.254.169.254", "::1", "0.0.0.0"): + self.assertTrue(http._is_blocked_ip(ip), ip) + for ip in ("8.8.8.8", "104.21.0.1", "1.1.1.1"): + self.assertFalse(http._is_blocked_ip(ip), ip) + # Unparseable -> rejected. + self.assertTrue(http._is_blocked_ip("not-an-ip")) + + +class PinnedConnectionTest(UnitTestCase): + """DNS-rebinding defense: the socket must go to the IP the guard validated. + + ``_pinned_get`` rewrites the URL netloc to the validated IP and keeps the + original hostname in the Host header / TLS SNI, so a resolver answer that + changes after validation can no longer steer the connection. + """ + + def _capture_send(self, captured): + import requests + + def fake_send(adapter, request, **kwargs): + captured["url"] = request.url + captured["host_header"] = request.headers.get("Host") + raise requests.exceptions.ConnectionError("offline test: no socket") + + return mock.patch.object(http._PinnedIPAdapter, "send", autospec=True, side_effect=fake_send) + + def test_pinned_get_connects_to_ip_with_hostname_host_header(self): + import requests + + captured = {} + with self._capture_send(captured), self.assertRaises(requests.exceptions.ConnectionError): + http._pinned_get(requests.Session(), "https://example.com/about", "93.184.216.34", 5, 2) + self.assertEqual(captured["url"], "https://93.184.216.34/about") + self.assertEqual(captured["host_header"], "example.com") + + def test_pinned_get_brackets_ipv6_and_keeps_explicit_port(self): + import requests + + captured = {} + with self._capture_send(captured), self.assertRaises(requests.exceptions.ConnectionError): + http._pinned_get(requests.Session(), "https://example.com:8443/x", "2606:2800:220:1::", 5, 2) + self.assertEqual(captured["url"], "https://[2606:2800:220:1::]:8443/x") + self.assertEqual(captured["host_header"], "example.com:8443") + + def test_pinned_adapter_keeps_tls_verification_on_hostname(self): + adapter = http._PinnedIPAdapter("example.com") + # urllib3 uses server_hostname for SNI and falls back to it for certificate + # matching, so verification stays against the hostname, not the IP literal. + self.assertEqual(adapter.poolmanager.connection_pool_kw.get("server_hostname"), "example.com") + + def test_fetch_connects_to_the_validated_ip(self): + import requests + + captured = {} + with _resolves_to({"rebind.example": "8.8.8.8"}), self._capture_send(captured): + status, _html, error, _final = http.fetch("http://rebind.example/", make_config()) + self.assertEqual(captured["url"], "http://8.8.8.8/") + self.assertEqual(status, 0) + self.assertIn("connection error", error) diff --git a/crm/extends/__init__.py b/crm/extends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/extends/notification_log.py b/crm/extends/notification_log.py new file mode 100644 index 0000000..9b19c3a --- /dev/null +++ b/crm/extends/notification_log.py @@ -0,0 +1,49 @@ +import frappe + +# CRM doctype -> frontend list route. Records live at /crm//. +CRM_ROUTES = { + "CRM Lead": "leads", + "CRM Deal": "deals", +} + + +def before_insert(doc, method=None): + """Point CRM notification emails at the CRM UI instead of the Desk form. + + Notification Log's email builder uses `doc.link` when set, otherwise it + falls back to a Desk (`/app/...`) form URL. CRM users often have Desk + access restricted, so build a CRM frontend link for CRM documents. + See https://github.com/frappe/crm/issues/705. + """ + if doc.link: + return + + route = get_crm_route(doc.document_type, doc.document_name) + if route: + doc.link = frappe.utils.get_url(route) + + +def get_crm_route(doctype, name): + """Frontend path for a CRM record, or None for non-CRM documents. + + Tasks have no standalone page in the CRM UI, so link to their parent + Lead/Deal's Tasks tab, mirroring the in-app notification behaviour. + """ + if not name: + return None + + suffix = "" + if doctype == "CRM Task": + parent = frappe.db.get_value( + "CRM Task", name, ["reference_doctype", "reference_docname"], as_dict=True + ) + if not parent or not parent.reference_docname: + return None + doctype, name = parent.reference_doctype, parent.reference_docname + suffix = "#tasks" + + list_route = CRM_ROUTES.get(doctype) + if not list_route: + return None + + return f"/crm/{list_route}/{name}{suffix}" diff --git a/crm/fcrm/__init__.py b/crm/fcrm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/__init__.py b/crm/fcrm/doctype/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_call_log/__init__.py b/crm/fcrm/doctype/crm_call_log/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_call_log/crm_call_log.js b/crm/fcrm/doctype/crm_call_log/crm_call_log.js new file mode 100644 index 0000000..de12b62 --- /dev/null +++ b/crm/fcrm/doctype/crm_call_log/crm_call_log.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Call Log", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_call_log/crm_call_log.json b/crm/fcrm/doctype/crm_call_log/crm_call_log.json new file mode 100644 index 0000000..58a88ac --- /dev/null +++ b/crm/fcrm/doctype/crm_call_log/crm_call_log.json @@ -0,0 +1,212 @@ +{ + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "autoname": "field:id", + "creation": "2023-08-28 00:23:36.229137", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "telephony_medium", + "section_break_gyqe", + "id", + "from", + "status", + "duration", + "medium", + "start_time", + "reference_doctype", + "reference_docname", + "column_break_ufnp", + "to", + "type", + "receiver", + "caller", + "recording_url", + "end_time", + "note", + "section_break_kebz", + "links" + ], + "fields": [ + { + "fieldname": "id", + "fieldtype": "Data", + "label": "ID", + "unique": 1 + }, + { + "fieldname": "from", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "From Number", + "options": "Phone", + "reqd": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "in_standard_filter": 1, + "label": "Status", + "options": "Initiated\nRinging\nIn Progress\nCompleted\nFailed\nBusy\nNo Answer\nQueued\nCanceled", + "reqd": 1 + }, + { + "fieldname": "start_time", + "fieldtype": "Datetime", + "label": "Start Time" + }, + { + "fieldname": "medium", + "fieldtype": "Data", + "label": "Medium" + }, + { + "fieldname": "column_break_ufnp", + "fieldtype": "Column Break" + }, + { + "fieldname": "type", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Type", + "options": "Incoming\nOutgoing", + "reqd": 1 + }, + { + "fieldname": "to", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "To Number", + "options": "Phone", + "reqd": 1 + }, + { + "fieldname": "duration", + "fieldtype": "Duration", + "in_list_view": 1, + "label": "Duration" + }, + { + "fieldname": "recording_url", + "fieldtype": "Small Text", + "label": "Recording URL" + }, + { + "fieldname": "end_time", + "fieldtype": "Datetime", + "label": "End Time" + }, + { + "fieldname": "note", + "fieldtype": "Link", + "label": "Note", + "options": "FCRM Note" + }, + { + "depends_on": "eval:doc.type == 'Incoming'", + "fieldname": "receiver", + "fieldtype": "Link", + "label": "Call Received By", + "options": "User" + }, + { + "depends_on": "eval:doc.type == 'Outgoing'", + "fieldname": "caller", + "fieldtype": "Link", + "label": "Caller", + "options": "User" + }, + { + "default": "CRM Lead", + "fieldname": "reference_doctype", + "fieldtype": "Link", + "label": "Reference Document Type", + "options": "DocType" + }, + { + "fieldname": "reference_docname", + "fieldtype": "Dynamic Link", + "label": "Reference Name", + "options": "reference_doctype" + }, + { + "fieldname": "section_break_kebz", + "fieldtype": "Section Break" + }, + { + "fieldname": "links", + "fieldtype": "Table", + "label": "Links", + "options": "Dynamic Link" + }, + { + "fieldname": "telephony_medium", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Telephony Medium", + "options": "\nManual\nTwilio\nExotel", + "read_only": 1 + }, + { + "fieldname": "section_break_gyqe", + "fieldtype": "Section Break" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-07-30 12:31:44.974374", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Call Log", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_call_log/crm_call_log.py b/crm/fcrm/doctype/crm_call_log/crm_call_log.py new file mode 100644 index 0000000..aad675d --- /dev/null +++ b/crm/fcrm/doctype/crm_call_log/crm_call_log.py @@ -0,0 +1,303 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _, generate_hash +from frappe.model.document import Document + +from crm.integrations.api import get_contact_by_phone_number +from crm.utils import seconds_to_duration + + +class CRMCallLog(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.core.doctype.dynamic_link.dynamic_link import DynamicLink + from frappe.types import DF + + caller: DF.Link | None + duration: DF.Duration | None + end_time: DF.Datetime | None + id: DF.Data | None + links: DF.Table[DynamicLink] + medium: DF.Data | None + note: DF.Link | None + receiver: DF.Link | None + recording_url: DF.SmallText | None + reference_docname: DF.DynamicLink | None + reference_doctype: DF.Link | None + start_time: DF.Datetime | None + status: DF.Literal[ + "Initiated", + "Ringing", + "In Progress", + "Completed", + "Failed", + "Busy", + "No Answer", + "Queued", + "Canceled", + ] + telephony_medium: DF.Literal["", "Manual", "Twilio", "Exotel"] + to: DF.Data + type: DF.Literal["Incoming", "Outgoing"] + # end: auto-generated types + + def before_insert(self): + if not self.id: + self.id = generate_hash(length=12) + if not self.telephony_medium: + self.telephony_medium = "Manual" + + @staticmethod + def default_list_data(): + columns = [ + { + "label": "Caller", + "type": "Link", + "key": "caller", + "options": "User", + "width": "9rem", + }, + { + "label": "Receiver", + "type": "Link", + "key": "receiver", + "options": "User", + "width": "9rem", + }, + { + "label": "Type", + "type": "Select", + "key": "type", + "width": "9rem", + }, + { + "label": "Status", + "type": "Select", + "key": "status", + "width": "9rem", + }, + { + "label": "Duration", + "type": "Duration", + "key": "duration", + "width": "6rem", + }, + { + "label": "From (number)", + "type": "Data", + "key": "from", + "width": "9rem", + }, + { + "label": "To (number)", + "type": "Data", + "key": "to", + "width": "9rem", + }, + { + "label": "Created On", + "type": "Datetime", + "key": "creation", + "width": "8rem", + }, + ] + rows = [ + "name", + "caller", + "receiver", + "type", + "status", + "duration", + "from", + "to", + "note", + "recording_url", + "reference_doctype", + "reference_docname", + "creation", + ] + return {"columns": columns, "rows": rows} + + def parse_list_data(calls): + return [parse_call_log(call) for call in calls] if calls else [] + + def has_link(self, doctype, name): + for link in self.links: + if link.link_doctype == doctype and link.link_name == name: + return True + + def link_with_reference_doc(self, reference_doctype, reference_name): + if self.has_link(reference_doctype, reference_name): + return + + self.append("links", {"link_doctype": reference_doctype, "link_name": reference_name}) + + def as_dict(self, *args, **kwargs): + d = super().as_dict(*args, **kwargs) + if d.get("recording_url"): + d["recording_url_path"] = ( + f"/api/method/crm.integrations.api.get_recording_url?call_log_name={d.get('name')}" + ) + return d + + +def parse_call_log(call): + call["show_recording"] = False + call["_duration"] = seconds_to_duration(call.get("duration")) + if call.get("type") == "Incoming": + call["activity_type"] = "incoming_call" + contact = get_contact_by_phone_number(call.get("from")) + receiver = ( + frappe.db.get_values("User", call.get("receiver"), ["full_name", "user_image"])[0] + if call.get("receiver") + else [None, None] + ) + call["_caller"] = { + "label": contact.get("full_name", "Unknown"), + "image": contact.get("image"), + } + call["_receiver"] = { + "label": receiver[0], + "image": receiver[1], + } + elif call.get("type") == "Outgoing": + call["activity_type"] = "outgoing_call" + contact = get_contact_by_phone_number(call.get("to")) + caller = ( + frappe.db.get_values("User", call.get("caller"), ["full_name", "user_image"])[0] + if call.get("caller") + else [None, None] + ) + call["_caller"] = { + "label": caller[0], + "image": caller[1], + } + call["_receiver"] = { + "label": contact.get("full_name", "Unknown"), + "image": contact.get("image"), + } + + return call + + +@frappe.whitelist() +def get_call_log(name: str): + if not frappe.has_permission("CRM Call Log", "read", name): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + call = frappe.get_cached_doc( + "CRM Call Log", + name, + fields=[ + "name", + "caller", + "receiver", + "duration", + "type", + "status", + "from", + "to", + "note", + "recording_url", + "recording_url_path", + "reference_doctype", + "reference_docname", + "creation", + ], + ).as_dict() + + call = parse_call_log(call) + + note_names = [call["note"]] if call.get("note") else [] + task_names = [] + references = {} + + if call.get("reference_doctype") in ("CRM Lead", "CRM Deal") and call.get("reference_docname"): + references[call["reference_doctype"]] = call["reference_docname"] + + for link in call.get("links") or []: + link_doctype, link_name = link.get("link_doctype"), link.get("link_name") + if link_doctype == "CRM Task": + task_names.append(link_name) + elif link_doctype == "FCRM Note": + note_names.append(link_name) + elif link_doctype in ("CRM Lead", "CRM Deal"): + references[link_doctype] = link_name + + call["_notes"] = get_permitted_docs("FCRM Note", note_names) + call["_tasks"] = get_permitted_docs("CRM Task", task_names) + + lead, deal = references.get("CRM Lead"), references.get("CRM Deal") + if lead and frappe.has_permission("CRM Lead", "read", lead): + call["_lead"] = lead + if deal and frappe.has_permission("CRM Deal", "read", deal): + call["_deal"] = deal + + return call + + +def get_permitted_docs(doctype: str, names: list[str]) -> list[dict]: + if not names or not frappe.has_permission(doctype, "read"): + return [] + + docs = frappe.get_list(doctype, filters={"name": ("in", names)}, fields=["*"], limit=len(names)) + return sorted(docs, key=lambda doc: names.index(str(doc.name))) + + +@frappe.whitelist() +def create_lead_from_call_log(call_log: str | dict, lead_details: str | dict | None = None): + call_log_data = frappe.parse_json(call_log or {}) + + if isinstance(call_log_data, str): + call_log_name = call_log_data + elif isinstance(call_log_data, dict): + call_log_name = call_log_data.get("name") + else: + call_log_name = None + + if not call_log_name: + frappe.throw(_("A valid call log is required."), frappe.ValidationError) + + call_doc = frappe.get_doc("CRM Call Log", call_log_name) + + if not call_doc.has_permission("write"): + frappe.throw(_("You are not permitted to update this call log."), frappe.PermissionError) + + if not frappe.has_permission("CRM Lead", "create"): + frappe.throw(_("You are not permitted to create leads."), frappe.PermissionError) + + lead_details_data = frappe.parse_json(lead_details or {}) + if lead_details_data and not isinstance(lead_details_data, dict): + frappe.throw(_("Invalid lead details supplied."), frappe.ValidationError) + + lead = frappe.new_doc("CRM Lead") + meta = frappe.get_meta("CRM Lead") + valid_fieldnames = [df.fieldname for df in meta.fields] + + sanitized_details = { + key: value for key, value in (lead_details_data or {}).items() if key in valid_fieldnames + } + + if "lead_owner" in valid_fieldnames and not sanitized_details.get("lead_owner"): + sanitized_details["lead_owner"] = frappe.session.user + + if "mobile_no" in valid_fieldnames and not sanitized_details.get("mobile_no"): + sanitized_details["mobile_no"] = call_doc.get("from") or "" + + if "first_name" in valid_fieldnames and not sanitized_details.get("first_name"): + reference_label = sanitized_details.get("mobile_no") or call_doc.name + sanitized_details["first_name"] = _("Lead from call {0}").format(reference_label) + + lead.update(sanitized_details) + lead.insert() + + call_doc.link_with_reference_doc("CRM Lead", lead.name) + call_doc.save() + + return lead.name diff --git a/crm/fcrm/doctype/crm_call_log/test_crm_call_log.py b/crm/fcrm/doctype/crm_call_log/test_crm_call_log.py new file mode 100644 index 0000000..4662e23 --- /dev/null +++ b/crm/fcrm/doctype/crm_call_log/test_crm_call_log.py @@ -0,0 +1,562 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +from unittest.mock import MagicMock, patch + +import frappe +from frappe.tests import IntegrationTestCase + +from crm.fcrm.doctype.crm_call_log.crm_call_log import ( + create_lead_from_call_log, + get_call_log, + parse_call_log, +) +from crm.integrations.api import _get_recording_credentials + + +class TestCRMCallLog(IntegrationTestCase): + def tearDown(self): + frappe.db.rollback() + + def test_call_log_creation_incoming(self): + """Test creating an incoming call log""" + call = create_test_call_log( + type="Incoming", + status="Completed", + caller=None, + receiver="Administrator", + ) + + self.assertEqual(call.type, "Incoming") + self.assertEqual(call.status, "Completed") + self.assertEqual(call.receiver, "Administrator") + + def test_call_log_creation_outgoing(self): + """Test creating an outgoing call log""" + call = create_test_call_log( + type="Outgoing", + status="Completed", + caller="Administrator", + receiver=None, + ) + + self.assertEqual(call.type, "Outgoing") + self.assertEqual(call.status, "Completed") + self.assertEqual(call.caller, "Administrator") + + def test_call_log_with_duration(self): + """Test call log with duration field""" + call = create_test_call_log( + type="Incoming", + status="Completed", + duration=120, # 2 minutes + ) + + self.assertEqual(call.duration, 120) + + def test_call_log_with_recording_url(self): + """Test call log with recording URL""" + recording_url = "https://example.com/recording.mp3" + call = create_test_call_log( + type="Outgoing", + status="Completed", + recording_url=recording_url, + ) + + self.assertEqual(call.recording_url, recording_url) + + def test_has_link_method(self): + """Test has_link method to check if document link exists""" + # Create a lead for linking + lead = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Lead", + "lead_owner": "Administrator", + } + ).insert() + + call = create_test_call_log() + + # Initially no link should exist + self.assertFalse(call.has_link("CRM Lead", lead.name)) + + # Add link + call.link_with_reference_doc("CRM Lead", lead.name) + + # Now link should exist + self.assertTrue(call.has_link("CRM Lead", lead.name)) + + def test_link_with_reference_doc(self): + """Test linking call log with reference document""" + # Create a deal for linking + org = frappe.get_doc( + { + "doctype": "CRM Organization", + "organization_name": "Test Org for Call Log", + } + ).insert() + + deal = frappe.get_doc( + { + "doctype": "CRM Deal", + "organization": org.name, + "deal_owner": "Administrator", + } + ).insert() + + call = create_test_call_log() + + # Link with deal + call.link_with_reference_doc("CRM Deal", deal.name) + call.save() + + # Verify link was created + self.assertTrue(call.has_link("CRM Deal", deal.name)) + self.assertEqual(len(call.links), 1) + self.assertEqual(call.links[0].link_doctype, "CRM Deal") + self.assertEqual(call.links[0].link_name, deal.name) + + def test_link_with_reference_doc_duplicate_prevention(self): + """Test that linking same document twice doesn't create duplicate links""" + lead = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Lead", + "lead_owner": "Administrator", + } + ).insert() + + call = create_test_call_log() + + # Link twice + call.link_with_reference_doc("CRM Lead", lead.name) + call.link_with_reference_doc("CRM Lead", lead.name) + call.save() + + # Should only have one link + self.assertEqual(len(call.links), 1) + + def test_default_list_data_returns_actual_logs(self): + """Test default_list_data returns actual call log data""" + from crm.fcrm.doctype.crm_call_log.crm_call_log import CRMCallLog + + # Create test call logs with valid users + create_test_call_log( + type="Incoming", + caller=None, + receiver="Administrator", + status="Completed", + ) + create_test_call_log( + type="Outgoing", + caller="Administrator", + receiver=None, + status="Failed", + ) + + data = CRMCallLog.default_list_data() + + # Verify structure + self.assertIn("columns", data) + self.assertIn("rows", data) + self.assertIsInstance(data["columns"], list) + self.assertIsInstance(data["rows"], list) + + # Verify columns contain required fields + self.assertTrue(len(data["columns"]) > 0) + column_keys = [col["key"] for col in data["columns"]] + self.assertIn("caller", column_keys) + self.assertIn("receiver", column_keys) + self.assertIn("type", column_keys) + self.assertIn("status", column_keys) + + # Verify rows contain data fields + self.assertIn("name", data["rows"]) + self.assertIn("caller", data["rows"]) + self.assertIn("receiver", data["rows"]) + self.assertIn("type", data["rows"]) + self.assertIn("status", data["rows"]) + + def test_parse_call_log_incoming(self): + """Test parse_call_log function with incoming call""" + # Create a user for testing + if not frappe.db.exists("User", "test@example.com"): + frappe.get_doc( + { + "doctype": "User", + "email": "test@example.com", + "first_name": "Test", + } + ).insert() + + call_data = { + "type": "Incoming", + "from": "+1234567890", + "to": "+0987654321", + "receiver": "test@example.com", + "duration": 120, + } + + parsed = parse_call_log(call_data) + + # Verify parsed data with actual values + self.assertEqual(parsed["activity_type"], "incoming_call") + self.assertEqual(parsed["_duration"], "2m") + # _caller and _receiver are dicts with label and image + self.assertIsInstance(parsed["_caller"], dict) + self.assertIn("label", parsed["_caller"]) + self.assertEqual(parsed["_caller"]["label"], "Unknown") # Phone number resolves to Unknown + self.assertIsInstance(parsed["_receiver"], dict) + self.assertIn("label", parsed["_receiver"]) + self.assertEqual(parsed["from"], "+1234567890") + self.assertEqual(parsed["to"], "+0987654321") + + def test_parse_call_log_outgoing(self): + """Test parse_call_log function with outgoing call""" + call_data = { + "type": "Outgoing", + "from": "+1234567890", + "to": "+0987654321", + "caller": "Administrator", + "duration": 180, + } + + parsed = parse_call_log(call_data) + + # Verify parsed data with actual values + self.assertEqual(parsed["activity_type"], "outgoing_call") + self.assertEqual(parsed["_duration"], "3m") + # _caller and _receiver are dicts with label and image + self.assertIsInstance(parsed["_caller"], dict) + self.assertEqual(parsed["_caller"]["label"], "Administrator") + self.assertIsInstance(parsed["_receiver"], dict) + self.assertEqual(parsed["_receiver"]["label"], "Unknown") # Phone number resolves to Unknown + self.assertEqual(parsed["from"], "+1234567890") + self.assertEqual(parsed["to"], "+0987654321") + + def test_get_call_log_api(self): + """Test get_call_log API function""" + call = create_test_call_log( + type="Outgoing", + status="Completed", + duration=60, + ) + + # Call the API + result = get_call_log(call.name) + + # Verify result + self.assertEqual(result["name"], call.name) + self.assertEqual(result["type"], "Outgoing") + self.assertEqual(result["status"], "Completed") + self.assertIn("_duration", result) + self.assertIn("_tasks", result) + self.assertIn("_notes", result) + + def test_get_call_log_denies_user_without_read_access(self): + """A logged in user with no call log access must not be able to read one""" + call = create_test_call_log(type="Outgoing", status="Completed") + + if not frappe.db.exists("User", "no-roles-user@example.com"): + frappe.get_doc( + { + "doctype": "User", + "email": "no-roles-user@example.com", + "first_name": "No Roles", + } + ).insert(ignore_permissions=True) + + frappe.set_user("no-roles-user@example.com") + try: + with self.assertRaises(frappe.PermissionError): + get_call_log(call.name) + finally: + frappe.set_user("Administrator") + + def test_get_call_log_hides_lead_user_cannot_read(self): + """A user who can read the call log but not its lead must not receive the lead id""" + lead = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Restricted Lead", + "lead_owner": "Administrator", + } + ).insert() + call = create_test_call_log(reference_doctype="CRM Lead", reference_docname=lead.name) + + if not frappe.db.exists("User", "sales-user@example.com"): + frappe.get_doc( + { + "doctype": "User", + "email": "sales-user@example.com", + "first_name": "Sales", + "roles": [{"role": "Sales User"}], + } + ).insert(ignore_permissions=True) + + frappe.set_user("sales-user@example.com") + try: + result = get_call_log(call.name) + finally: + frappe.set_user("Administrator") + + self.assertEqual(result["name"], call.name) + self.assertNotIn("_lead", result) + + def test_get_call_log_with_reference_lead(self): + """Test get_call_log API with reference to CRM Lead""" + lead = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Lead", + "lead_owner": "Administrator", + } + ).insert() + + call = create_test_call_log( + reference_doctype="CRM Lead", + reference_docname=lead.name, + ) + + result = get_call_log(call.name) + + # Verify lead reference + self.assertEqual(result.get("_lead"), lead.name) + + def test_get_call_log_with_reference_deal(self): + """Test get_call_log API with reference to CRM Deal""" + org = frappe.get_doc( + { + "doctype": "CRM Organization", + "organization_name": "Test Org for Call", + } + ).insert() + + deal = frappe.get_doc( + { + "doctype": "CRM Deal", + "organization": org.name, + "deal_owner": "Administrator", + } + ).insert() + + call = create_test_call_log( + reference_doctype="CRM Deal", + reference_docname=deal.name, + ) + + result = get_call_log(call.name) + + # Verify deal reference + self.assertEqual(result.get("_deal"), deal.name) + + def test_get_call_log_with_linked_task(self): + """Test get_call_log API with linked CRM Task""" + call = create_test_call_log() + + # Create and link a task + task = frappe.get_doc( + { + "doctype": "CRM Task", + "title": "Follow up call", + "assigned_to": "Administrator", + } + ).insert() + + call.link_with_reference_doc("CRM Task", task.name) + call.save() + + result = get_call_log(call.name) + + # Verify task is in results + self.assertEqual(len(result["_tasks"]), 1) + self.assertEqual(result["_tasks"][0]["name"], task.name) + + def test_get_call_log_returns_all_linked_tasks_beyond_default_page_length(self): + """Linked records must not be cut off by the framework's default list page length""" + call = create_test_call_log() + task_count = 25 + + for i in range(task_count): + task = frappe.get_doc( + { + "doctype": "CRM Task", + "title": f"Follow up {i}", + "assigned_to": "Administrator", + } + ).insert() + call.link_with_reference_doc("CRM Task", task.name) + call.save() + + result = get_call_log(call.name) + + self.assertEqual(len(result["_tasks"]), task_count) + + def test_create_lead_from_call_log_basic(self): + """Test creating a lead from call log""" + call = create_test_call_log( + type="Incoming", + from_number="+1234567890", + ) + + # Create lead from call log + lead_name = create_lead_from_call_log( + call_log=frappe.as_json({"name": call.name}), + lead_details=frappe.as_json({"first_name": "John", "last_name": "Doe"}), + ) + + # Verify lead was created + self.assertTrue(frappe.db.exists("CRM Lead", lead_name)) + lead = frappe.get_doc("CRM Lead", lead_name) + self.assertEqual(lead.first_name, "John") + self.assertEqual(lead.last_name, "Doe") + self.assertEqual(lead.mobile_no, "+1234567890") + + # Verify call log was linked + call.reload() + self.assertTrue(call.has_link("CRM Lead", lead_name)) + + def test_create_lead_from_call_log_with_owner(self): + """Test creating lead from call log with specific owner""" + call = create_test_call_log( + type="Outgoing", + to="+9876543210", + ) + + lead_name = create_lead_from_call_log( + call_log=frappe.as_json({"name": call.name}), + lead_details=frappe.as_json({"first_name": "Jane", "lead_owner": "Administrator"}), + ) + + lead = frappe.get_doc("CRM Lead", lead_name) + self.assertEqual(lead.lead_owner, "Administrator") + + def test_create_lead_from_call_log_no_details(self): + """Test creating lead from call log without lead details""" + call = create_test_call_log( + type="Incoming", + from_number="+1112223333", + ) + + lead_name = create_lead_from_call_log(call_log=frappe.as_json({"name": call.name})) + + # Verify lead was created with default name + lead = frappe.get_doc("CRM Lead", lead_name) + self.assertTrue(lead.first_name.startswith("Lead from call")) + self.assertEqual(lead.mobile_no, "+1112223333") + + def test_create_lead_from_call_log_invalid_call_log(self): + """Test that invalid call log throws error""" + with self.assertRaises(frappe.DoesNotExistError): + create_lead_from_call_log(call_log=frappe.as_json({"name": "invalid_name"})) + + def test_create_lead_from_call_log_permission_check(self): + """Test that permission is checked when creating lead from call log""" + create_test_call_log() + + # Verify that empty call log throws error + with self.assertRaises(frappe.ValidationError): + create_lead_from_call_log(call_log=frappe.as_json({})) + + def test_call_log_status_filtering(self): + """Test that call logs can be filtered by status""" + # Create calls with different statuses + completed_call = create_test_call_log(status="Completed", type="Incoming") + failed_call = create_test_call_log(status="Failed", type="Outgoing") + busy_call = create_test_call_log(status="Busy", type="Incoming") + + # Verify statuses are set correctly + self.assertEqual(completed_call.status, "Completed") + self.assertEqual(failed_call.status, "Failed") + self.assertEqual(busy_call.status, "Busy") + + # Test filtering by status + completed_logs = frappe.get_all("CRM Call Log", filters={"status": "Completed"}) + failed_logs = frappe.get_all("CRM Call Log", filters={"status": "Failed"}) + + self.assertGreaterEqual(len(completed_logs), 1) + self.assertGreaterEqual(len(failed_logs), 1) + + # Verify our specific calls are in the right filtered lists + completed_names = [log.name for log in completed_logs] + failed_names = [log.name for log in failed_logs] + + self.assertIn(completed_call.name, completed_names) + self.assertIn(failed_call.name, failed_names) + self.assertNotIn(busy_call.name, completed_names) + self.assertNotIn(busy_call.name, failed_names) + + def test_call_log_with_telephony_medium(self): + """Test call log with different telephony mediums""" + call = create_test_call_log(telephony_medium="Twilio") + self.assertEqual(call.telephony_medium, "Twilio") + + call2 = create_test_call_log(telephony_medium="Exotel") + self.assertEqual(call2.telephony_medium, "Exotel") + + def test_recording_credentials_manual_medium_needs_no_auth(self): + """A manually added recording is fetched as-is, with no provider auth.""" + self.assertIsNone(_get_recording_credentials("Manual")) + + def test_recording_credentials_unknown_medium_returns_none(self): + """An empty/unrecognized medium must not raise; it just skips auth.""" + self.assertIsNone(_get_recording_credentials("")) + self.assertIsNone(_get_recording_credentials(None)) + + def test_recording_credentials_twilio_unconfigured_returns_none(self): + """Twilio without a configured secret falls back to no auth instead of raising. + + Regression: get_password used to raise when the secret was unset, which made + the recording proxy 500 and the UI show "Recording not available". + """ + settings = MagicMock() + settings.api_key = "ACxxxxxxxx" + settings.get_password.return_value = None + with patch("crm.integrations.api.frappe.get_single", return_value=settings): + self.assertIsNone(_get_recording_credentials("Twilio")) + + def test_recording_credentials_twilio_configured_returns_tuple(self): + """Twilio with both key and secret set yields the auth pair.""" + settings = MagicMock() + settings.api_key = "ACxxxxxxxx" + settings.get_password.return_value = "twilio_secret" + with patch("crm.integrations.api.frappe.get_single", return_value=settings): + self.assertEqual(_get_recording_credentials("Twilio"), ("ACxxxxxxxx", "twilio_secret")) + + def test_recording_credentials_exotel_configured_returns_tuple(self): + """Exotel with both key and token set yields the auth pair.""" + settings = MagicMock() + settings.api_key = "exotel_key" + settings.get_password.return_value = "exotel_token" + with patch("crm.integrations.api.frappe.get_single", return_value=settings): + self.assertEqual(_get_recording_credentials("Exotel"), ("exotel_key", "exotel_token")) + + +def create_test_call_log(**kwargs): + """Helper function to create a CRM Call Log for testing""" + import uuid + + # Generate unique ID if not provided (required for autoname) + unique_id = kwargs.pop("id", str(uuid.uuid4())[:10]) + + # Handle special field name mapping + if "from_number" in kwargs: + kwargs["from"] = kwargs.pop("from_number") + + # Set defaults for required fields + data = { + "doctype": "CRM Call Log", + "id": unique_id, + "type": "Incoming", + "status": "Completed", + "to": "+1234567890", + "from": "+0987654321", + } + + # Update with all provided kwargs + data.update(kwargs) + + call_log = frappe.get_doc(data) + call_log.insert() + return call_log diff --git a/crm/fcrm/doctype/crm_communication_status/__init__.py b/crm/fcrm/doctype/crm_communication_status/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_communication_status/crm_communication_status.js b/crm/fcrm/doctype/crm_communication_status/crm_communication_status.js new file mode 100644 index 0000000..33e5fd4 --- /dev/null +++ b/crm/fcrm/doctype/crm_communication_status/crm_communication_status.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Communication Status", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_communication_status/crm_communication_status.json b/crm/fcrm/doctype/crm_communication_status/crm_communication_status.json new file mode 100644 index 0000000..63f43ec --- /dev/null +++ b/crm/fcrm/doctype/crm_communication_status/crm_communication_status.json @@ -0,0 +1,72 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:status", + "creation": "2023-12-13 13:25:07.213100", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "status" + ], + "fields": [ + { + "fieldname": "status", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Status", + "reqd": 1, + "unique": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-01-28 17:10:00.827558", + "modified_by": "shariq@frappe.io", + "module": "FCRM", + "name": "CRM Communication Status", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_communication_status/crm_communication_status.py b/crm/fcrm/doctype/crm_communication_status/crm_communication_status.py new file mode 100644 index 0000000..3cc5efa --- /dev/null +++ b/crm/fcrm/doctype/crm_communication_status/crm_communication_status.py @@ -0,0 +1,20 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMCommunicationStatus(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + status: DF.Data + # end: auto-generated types + + pass diff --git a/crm/fcrm/doctype/crm_communication_status/test_crm_communication_status.py b/crm/fcrm/doctype/crm_communication_status/test_crm_communication_status.py new file mode 100644 index 0000000..bf5b69e --- /dev/null +++ b/crm/fcrm/doctype/crm_communication_status/test_crm_communication_status.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import UnitTestCase + + +class TestCRMCommunicationStatus(UnitTestCase): + pass diff --git a/crm/fcrm/doctype/crm_contacts/__init__.py b/crm/fcrm/doctype/crm_contacts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_contacts/crm_contacts.json b/crm/fcrm/doctype/crm_contacts/crm_contacts.json new file mode 100644 index 0000000..6c2efb4 --- /dev/null +++ b/crm/fcrm/doctype/crm_contacts/crm_contacts.json @@ -0,0 +1,92 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-08-25 19:08:36.356133", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "contact", + "full_name", + "email", + "column_break_uvny", + "gender", + "mobile_no", + "phone", + "is_primary" + ], + "fields": [ + { + "fieldname": "contact", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Contact", + "options": "Contact" + }, + { + "fetch_from": "contact.full_name", + "fieldname": "full_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Full Name", + "read_only": 1 + }, + { + "fetch_from": "contact.email_id", + "fieldname": "email", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Email", + "read_only": 1 + }, + { + "fieldname": "column_break_uvny", + "fieldtype": "Column Break" + }, + { + "fetch_from": "contact.mobile_no", + "fieldname": "mobile_no", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Mobile No.", + "options": "Phone", + "read_only": 1 + }, + { + "fetch_from": "contact.phone", + "fieldname": "phone", + "fieldtype": "Data", + "label": "Phone", + "options": "Phone", + "read_only": 1 + }, + { + "fetch_from": "contact.gender", + "fieldname": "gender", + "fieldtype": "Link", + "label": "Gender", + "options": "Gender", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_primary", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Is Primary" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-02-23 12:25:24.755041", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Contacts", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_contacts/crm_contacts.py b/crm/fcrm/doctype/crm_contacts/crm_contacts.py new file mode 100644 index 0000000..297a75f --- /dev/null +++ b/crm/fcrm/doctype/crm_contacts/crm_contacts.py @@ -0,0 +1,29 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMContacts(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + contact: DF.Link | None + email: DF.Data | None + full_name: DF.Data | None + gender: DF.Link | None + is_primary: DF.Check + mobile_no: DF.Data | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + phone: DF.Data | None + # end: auto-generated types + + pass diff --git a/crm/fcrm/doctype/crm_dashboard/__init__.py b/crm/fcrm/doctype/crm_dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_dashboard/crm_dashboard.js b/crm/fcrm/doctype/crm_dashboard/crm_dashboard.js new file mode 100644 index 0000000..9aee84c --- /dev/null +++ b/crm/fcrm/doctype/crm_dashboard/crm_dashboard.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Dashboard", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_dashboard/crm_dashboard.json b/crm/fcrm/doctype/crm_dashboard/crm_dashboard.json new file mode 100644 index 0000000..25f8786 --- /dev/null +++ b/crm/fcrm/doctype/crm_dashboard/crm_dashboard.json @@ -0,0 +1,102 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:title", + "creation": "2025-07-14 12:19:49.725022", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "title", + "private", + "column_break_exbw", + "user", + "section_break_hfza", + "layout" + ], + "fields": [ + { + "fieldname": "column_break_exbw", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_hfza", + "fieldtype": "Section Break" + }, + { + "default": "[]", + "fieldname": "layout", + "fieldtype": "Code", + "label": "Layout", + "options": "JSON" + }, + { + "fieldname": "title", + "fieldtype": "Data", + "label": "Name", + "unique": 1 + }, + { + "depends_on": "private", + "fieldname": "user", + "fieldtype": "Link", + "label": "User", + "mandatory_depends_on": "private", + "options": "User" + }, + { + "default": "0", + "fieldname": "private", + "fieldtype": "Check", + "label": "Private" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-04-14 12:36:10.831351", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Dashboard", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "title_field": "title" +} diff --git a/crm/fcrm/doctype/crm_dashboard/crm_dashboard.py b/crm/fcrm/doctype/crm_dashboard/crm_dashboard.py new file mode 100644 index 0000000..3bf04e8 --- /dev/null +++ b/crm/fcrm/doctype/crm_dashboard/crm_dashboard.py @@ -0,0 +1,33 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + + +class CRMDashboard(Document): + pass + + +def default_manager_dashboard_layout(): + """ + Returns the default layout for the CRM Manager Dashboard. + """ + return '[{"name":"total_leads","type":"number_chart","tooltip":"Total number of leads","layout":{"x":0,"y":0,"w":4,"h":3,"i":"total_leads"}},{"name":"ongoing_deals","type":"number_chart","tooltip":"Total number of ongoing deals","layout":{"x":8,"y":0,"w":4,"h":3,"i":"ongoing_deals"}},{"name":"won_deals","type":"number_chart","tooltip":"Total number of won deals","layout":{"x":12,"y":0,"w":4,"h":3,"i":"won_deals"}},{"name":"average_won_deal_value","type":"number_chart","tooltip":"Average value of won deals","layout":{"x":16,"y":0,"w":4,"h":3,"i":"average_won_deal_value"}},{"name":"average_deal_value","type":"number_chart","tooltip":"Average deal value of ongoing and won deals","layout":{"x":0,"y":2,"w":4,"h":3,"i":"average_deal_value"}},{"name":"average_time_to_close_a_lead","type":"number_chart","tooltip":"Average time taken to close a lead","layout":{"x":4,"y":0,"w":4,"h":3,"i":"average_time_to_close_a_lead"}},{"name":"average_time_to_close_a_deal","type":"number_chart","layout":{"x":4,"y":2,"w":4,"h":3,"i":"average_time_to_close_a_deal"}},{"name":"spacer","type":"spacer","layout":{"x":8,"y":2,"w":12,"h":3,"i":"spacer"}},{"name":"sales_trend","type":"axis_chart","layout":{"x":0,"y":4,"w":10,"h":9,"i":"sales_trend"}},{"name":"forecasted_revenue","type":"axis_chart","layout":{"x":10,"y":4,"w":10,"h":9,"i":"forecasted_revenue"}},{"name":"funnel_conversion","type":"axis_chart","layout":{"x":0,"y":11,"w":10,"h":9,"i":"funnel_conversion"}},{"name":"deals_by_stage_donut","type":"donut_chart","layout":{"x":10,"y":11,"w":10,"h":9,"i":"deals_by_stage_donut"}},{"name":"lost_deal_reasons","type":"axis_chart","layout":{"x":0,"y":32,"w":20,"h":9,"i":"lost_deal_reasons"}},{"name":"leads_by_source","type":"donut_chart","layout":{"x":0,"y":18,"w":10,"h":9,"i":"leads_by_source"}},{"name":"deals_by_source","type":"donut_chart","layout":{"x":10,"y":18,"w":10,"h":9,"i":"deals_by_source"}},{"name":"deals_by_territory","type":"axis_chart","layout":{"x":0,"y":25,"w":10,"h":9,"i":"deals_by_territory"}},{"name":"deals_by_salesperson","type":"axis_chart","layout":{"x":10,"y":25,"w":10,"h":9,"i":"deals_by_salesperson"}}]' + + +def create_default_manager_dashboard(force=False): + """ + Creates the default CRM Manager Dashboard if it does not exist. + """ + if not frappe.db.exists("CRM Dashboard", "Manager Dashboard"): + doc = frappe.new_doc("CRM Dashboard") + doc.title = "Manager Dashboard" + doc.layout = default_manager_dashboard_layout() + doc.insert(ignore_permissions=True) + else: + doc = frappe.get_doc("CRM Dashboard", "Manager Dashboard") + if force: + doc.layout = default_manager_dashboard_layout() + doc.save(ignore_permissions=True) + return doc.layout diff --git a/crm/fcrm/doctype/crm_dashboard/test_crm_dashboard.py b/crm/fcrm/doctype/crm_dashboard/test_crm_dashboard.py new file mode 100644 index 0000000..4c544a5 --- /dev/null +++ b/crm/fcrm/doctype/crm_dashboard/test_crm_dashboard.py @@ -0,0 +1,29 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class UnitTestCRMDashboard(UnitTestCase): + """ + Unit tests for CRMDashboard. + Use this class for testing individual functions and methods. + """ + + pass + + +class IntegrationTestCRMDashboard(IntegrationTestCase): + """ + Integration tests for CRMDashboard. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/crm/fcrm/doctype/crm_deal/__init__.py b/crm/fcrm/doctype/crm_deal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_deal/api.py b/crm/fcrm/doctype/crm_deal/api.py new file mode 100644 index 0000000..36eb320 --- /dev/null +++ b/crm/fcrm/doctype/crm_deal/api.py @@ -0,0 +1,34 @@ +import frappe +from frappe import _ + + +@frappe.whitelist() +def get_deal_contacts(name: str): + if not frappe.has_permission("CRM Deal", "read", name): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + contacts = frappe.get_all( + "CRM Contacts", + filters={"parenttype": "CRM Deal", "parent": name}, + fields=["contact", "is_primary"], + order_by="is_primary desc, idx asc", + distinct=True, + ) + deal_contacts = [] + for contact in contacts: + if not contact.contact: + continue + + is_primary = contact.is_primary + contact = frappe.get_doc("Contact", contact.contact).as_dict() + + _contact = { + "name": contact.name, + "image": contact.image, + "full_name": contact.full_name, + "email": contact.email_id, + "mobile_no": contact.mobile_no, + "is_primary": is_primary, + } + deal_contacts.append(_contact) + return deal_contacts diff --git a/crm/fcrm/doctype/crm_deal/crm_deal.js b/crm/fcrm/doctype/crm_deal/crm_deal.js new file mode 100644 index 0000000..88eab1a --- /dev/null +++ b/crm/fcrm/doctype/crm_deal/crm_deal.js @@ -0,0 +1,67 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("CRM Deal", { + refresh(frm) { + frm.add_web_link(`/crm/deals/${frm.doc.name}`, __("Open in Portal")); + }, + update_total: function (frm) { + let total = 0; + let total_qty = 0; + let net_total = 0; + frm.doc.products.forEach((d) => { + total += d.amount; + total_qty += d.qty; + net_total += d.net_amount; + }); + + frappe.model.set_value(frm.doctype, frm.docname, "total", total); + frappe.model.set_value( + frm.doctype, + frm.docname, + "net_total", + net_total || total, + ); + }, +}); + +frappe.ui.form.on("CRM Products", { + products_add: function (frm, cdt, cdn) { + frm.trigger("update_total"); + }, + products_remove: function (frm, cdt, cdn) { + frm.trigger("update_total"); + }, + product_code: function (frm, cdt, cdn) { + let d = frappe.get_doc(cdt, cdn); + frappe.model.set_value(cdt, cdn, "product_name", d.product_code); + }, + rate: function (frm, cdt, cdn) { + let d = frappe.get_doc(cdt, cdn); + if (d.rate && d.qty) { + frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty); + } + frm.trigger("update_total"); + }, + qty: function (frm, cdt, cdn) { + let d = frappe.get_doc(cdt, cdn); + if (d.rate && d.qty) { + frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty); + } + frm.trigger("update_total"); + }, + discount_percentage: function (frm, cdt, cdn) { + let d = frappe.get_doc(cdt, cdn); + if (d.discount_percentage && d.amount) { + discount_amount = (d.discount_percentage / 100) * d.amount; + frappe.model.set_value(cdt, cdn, "discount_amount", discount_amount); + frappe.model.set_value( + cdt, + cdn, + "net_amount", + d.amount - discount_amount, + ); + } + frm.trigger("update_total"); + }, +}); diff --git a/crm/fcrm/doctype/crm_deal/crm_deal.json b/crm/fcrm/doctype/crm_deal/crm_deal.json new file mode 100644 index 0000000..65fd52a --- /dev/null +++ b/crm/fcrm/doctype/crm_deal/crm_deal.json @@ -0,0 +1,575 @@ +{ + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "autoname": "naming_series:", + "creation": "2023-11-06 17:56:25.210449", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "organization_tab", + "naming_series", + "organization", + "next_step", + "column_break_ijan", + "status", + "deal_owner", + "section_break_jgpm", + "probability", + "expected_deal_value", + "deal_value", + "column_break_kpxa", + "expected_closure_date", + "closed_date", + "contacts_tab", + "contacts", + "contact", + "lead_details_tab", + "lead", + "source", + "column_break_wsde", + "lead_name", + "organization_details_section", + "organization_name", + "website", + "organization_logo", + "company_description", + "linkedin", + "twitter", + "facebook", + "no_of_employees", + "job_title", + "column_break_xbyf", + "territory", + "currency", + "exchange_rate", + "annual_revenue", + "industry", + "person_section", + "salutation", + "first_name", + "last_name", + "column_break_xjmy", + "email", + "mobile_no", + "phone", + "gender", + "products_tab", + "products", + "section_break_ccbj", + "total", + "column_break_udbq", + "net_total", + "sla_tab", + "sla", + "sla_creation", + "column_break_pfvq", + "sla_status", + "communication_status", + "response_details_section", + "response_by", + "column_break_hpvj", + "first_response_time", + "first_responded_on", + "section_break_mwvg", + "rolling_responses", + "section_break_jgdr", + "last_response_time", + "column_break_evzj", + "last_responded_on", + "log_tab", + "status_change_log", + "lost_details_tab", + "lost_reason", + "lost_notes" + ], + "fields": [ + { + "fieldname": "organization", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Organization", + "options": "CRM Organization" + }, + { + "fieldname": "probability", + "fieldtype": "Percent", + "label": "Probability" + }, + { + "fetch_from": ".annual_revenue", + "fieldname": "annual_revenue", + "fieldtype": "Currency", + "label": "Annual Revenue", + "non_negative": 1, + "options": "currency" + }, + { + "fetch_from": ".website", + "fieldname": "website", + "fieldtype": "Data", + "label": "Website" + }, + { + "fieldname": "next_step", + "fieldtype": "Data", + "label": "Next Step" + }, + { + "fieldname": "lead", + "fieldtype": "Link", + "label": "Lead", + "options": "CRM Lead" + }, + { + "fieldname": "deal_owner", + "fieldtype": "Link", + "label": "Deal Owner", + "options": "User" + }, + { + "default": "CRM-DEAL-.YYYY.-", + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Naming Series", + "options": "CRM-DEAL-.YYYY.-" + }, + { + "fieldname": "contacts_tab", + "fieldtype": "Tab Break", + "label": "Contacts" + }, + { + "fieldname": "email", + "fieldtype": "Data", + "in_standard_filter": 1, + "label": "Primary email", + "options": "Email" + }, + { + "fieldname": "mobile_no", + "fieldtype": "Data", + "label": "Primary mobile no", + "options": "Phone" + }, + { + "fieldname": "status", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "CRM Deal Status", + "reqd": 1, + "search_index": 1 + }, + { + "fieldname": "contacts", + "fieldtype": "Table", + "label": "Contacts", + "options": "CRM Contacts" + }, + { + "fieldname": "organization_tab", + "fieldtype": "Tab Break", + "label": "Organization" + }, + { + "fieldname": "sla_tab", + "fieldtype": "Tab Break", + "label": "SLA", + "read_only": 1 + }, + { + "fieldname": "sla", + "fieldtype": "Link", + "label": "SLA", + "options": "CRM Service Level Agreement" + }, + { + "fieldname": "response_by", + "fieldtype": "Datetime", + "label": "Response By", + "read_only": 1 + }, + { + "fieldname": "column_break_pfvq", + "fieldtype": "Column Break" + }, + { + "fieldname": "sla_status", + "fieldtype": "Select", + "label": "SLA Status", + "options": "\nFirst Response Due\nRolling Response Due\nFailed\nFulfilled", + "read_only": 1 + }, + { + "fieldname": "sla_creation", + "fieldtype": "Datetime", + "label": "SLA Creation", + "read_only": 1 + }, + { + "fieldname": "response_details_section", + "fieldtype": "Section Break", + "label": "Response Details" + }, + { + "fieldname": "column_break_hpvj", + "fieldtype": "Column Break" + }, + { + "fieldname": "first_response_time", + "fieldtype": "Duration", + "label": "First Response Time", + "read_only": 1 + }, + { + "fieldname": "first_responded_on", + "fieldtype": "Datetime", + "label": "First Responded On", + "read_only": 1 + }, + { + "default": "Open", + "fieldname": "communication_status", + "fieldtype": "Link", + "label": "Communication Status", + "options": "CRM Communication Status" + }, + { + "fetch_from": ".territory", + "fieldname": "territory", + "fieldtype": "Link", + "label": "Territory", + "options": "CRM Territory" + }, + { + "fieldname": "source", + "fieldtype": "Link", + "label": "Source", + "options": "CRM Lead Source" + }, + { + "fieldname": "no_of_employees", + "fieldtype": "Select", + "label": "No. of Employees", + "options": "1-10\n11-50\n51-200\n201-500\n501-1000\n1000+" + }, + { + "fieldname": "job_title", + "fieldtype": "Data", + "label": "Job Title" + }, + { + "fieldname": "phone", + "fieldtype": "Data", + "label": "Primary phone", + "options": "Phone" + }, + { + "fieldname": "log_tab", + "fieldtype": "Tab Break", + "label": "Log", + "read_only": 1 + }, + { + "fieldname": "status_change_log", + "fieldtype": "Table", + "label": "Status Change Log", + "options": "CRM Status Change Log" + }, + { + "fieldname": "lead_name", + "fieldtype": "Data", + "label": "Lead Name" + }, + { + "fieldname": "column_break_ijan", + "fieldtype": "Column Break" + }, + { + "fieldname": "lead_details_tab", + "fieldtype": "Tab Break", + "label": "Lead Details" + }, + { + "fieldname": "column_break_wsde", + "fieldtype": "Column Break" + }, + { + "fieldname": "organization_details_section", + "fieldtype": "Section Break", + "label": "Organization Details" + }, + { + "fieldname": "organization_name", + "fieldtype": "Data", + "label": "Organization Name" + }, + { + "fieldname": "column_break_xbyf", + "fieldtype": "Column Break" + }, + { + "fieldname": "industry", + "fieldtype": "Link", + "label": "Industry", + "options": "CRM Industry" + }, + { + "fieldname": "person_section", + "fieldtype": "Section Break", + "label": "Person" + }, + { + "fieldname": "salutation", + "fieldtype": "Link", + "label": "Salutation", + "options": "Salutation" + }, + { + "fieldname": "first_name", + "fieldtype": "Data", + "label": "First name" + }, + { + "fieldname": "last_name", + "fieldtype": "Data", + "label": "Last name" + }, + { + "fieldname": "column_break_xjmy", + "fieldtype": "Column Break" + }, + { + "fieldname": "gender", + "fieldtype": "Link", + "label": "Gender", + "options": "Gender" + }, + { + "fieldname": "contact", + "fieldtype": "Link", + "label": "Contact", + "options": "Contact" + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency" + }, + { + "fieldname": "products_tab", + "fieldtype": "Tab Break", + "label": "Products" + }, + { + "fieldname": "products", + "fieldtype": "Table", + "label": "Products", + "options": "CRM Products" + }, + { + "fieldname": "section_break_ccbj", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_udbq", + "fieldtype": "Column Break" + }, + { + "fieldname": "total", + "fieldtype": "Currency", + "label": "Total", + "non_negative": 1, + "options": "currency", + "read_only": 1 + }, + { + "description": "Total after discount", + "fieldname": "net_total", + "fieldtype": "Currency", + "label": "Net Total", + "non_negative": 1, + "options": "currency", + "read_only": 1 + }, + { + "fieldname": "section_break_jgpm", + "fieldtype": "Section Break" + }, + { + "fieldname": "deal_value", + "fieldtype": "Currency", + "label": "Deal Value", + "non_negative": 1, + "options": "currency" + }, + { + "fieldname": "column_break_kpxa", + "fieldtype": "Column Break" + }, + { + "fieldname": "lost_reason", + "fieldtype": "Link", + "label": "Lost Reason", + "options": "CRM Lost Reason" + }, + { + "fieldname": "lost_notes", + "fieldtype": "Text", + "label": "Lost Notes", + "mandatory_depends_on": "eval: doc.lost_reason == \"Other\"" + }, + { + "default": "1", + "description": "The rate used to convert the deal\u2019s currency to your crm's base currency (set in CRM Settings). It is set once when the currency is first added and doesn't change automatically.", + "fieldname": "exchange_rate", + "fieldtype": "Float", + "label": "Exchange Rate" + }, + { + "fieldname": "expected_deal_value", + "fieldtype": "Currency", + "label": "Expected Deal Value", + "non_negative": 1, + "options": "currency" + }, + { + "fieldname": "expected_closure_date", + "fieldtype": "Date", + "label": "Expected Closure Date" + }, + { + "fieldname": "closed_date", + "fieldtype": "Date", + "label": "Closed Date" + }, + { + "fieldname": "section_break_mwvg", + "fieldtype": "Section Break" + }, + { + "fieldname": "rolling_responses", + "fieldtype": "Table", + "label": "Rolling Responses", + "options": "CRM Rolling Response Time" + }, + { + "fieldname": "section_break_jgdr", + "fieldtype": "Section Break" + }, + { + "fieldname": "last_response_time", + "fieldtype": "Duration", + "label": "Last Response Time", + "read_only": 1 + }, + { + "fieldname": "column_break_evzj", + "fieldtype": "Column Break" + }, + { + "fieldname": "last_responded_on", + "fieldtype": "Datetime", + "label": "Last Responded On", + "read_only": 1 + }, + { + "fieldname": "lost_details_tab", + "fieldtype": "Tab Break", + "label": "Lost Details" + }, + { + "fieldname": "organization_logo", + "fieldtype": "Attach Image", + "label": "Organization Logo", + "no_copy": 1, + "description": "Company logo discovered by Domain Enrichment." + }, + { + "fieldname": "company_description", + "fieldtype": "Small Text", + "label": "Company Description", + "no_copy": 1, + "description": "Company description discovered by Domain Enrichment." + }, + { + "depends_on": "eval:doc.linkedin", + "fieldname": "linkedin", + "fieldtype": "Data", + "label": "LinkedIn", + "no_copy": 1 + }, + { + "depends_on": "eval:doc.twitter", + "fieldname": "twitter", + "fieldtype": "Data", + "label": "X (Twitter)", + "no_copy": 1 + }, + { + "depends_on": "eval:doc.facebook", + "fieldname": "facebook", + "fieldtype": "Data", + "label": "Facebook", + "no_copy": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-04-04 12:34:10.808282", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Deal", + "naming_rule": "By \"Naming Series\" field", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "show_title_field_in_link": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "organization", + "track_changes": 1, + "track_seen": 1 +} diff --git a/crm/fcrm/doctype/crm_deal/crm_deal.py b/crm/fcrm/doctype/crm_deal/crm_deal.py new file mode 100644 index 0000000..66b4bcd --- /dev/null +++ b/crm/fcrm/doctype/crm_deal/crm_deal.py @@ -0,0 +1,498 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.desk.form.assign_to import _add as assign +from frappe.model.document import Document + +from crm.api.exchange_rate import get_exchange_rate +from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla +from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import add_status_change_log +from crm.fcrm.doctype.utils import add_or_remove_lost_reason_section_in_sidepanel + + +class CRMDeal(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + from crm.fcrm.doctype.crm_contacts.crm_contacts import CRMContacts + from crm.fcrm.doctype.crm_products.crm_products import CRMProducts + from crm.fcrm.doctype.crm_rolling_response_time.crm_rolling_response_time import ( + CRMRollingResponseTime, + ) + from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import CRMStatusChangeLog + + annual_revenue: DF.Currency + closed_date: DF.Date | None + communication_status: DF.Link | None + contact: DF.Link | None + contacts: DF.Table[CRMContacts] + currency: DF.Link | None + deal_owner: DF.Link | None + deal_value: DF.Currency + email: DF.Data | None + exchange_rate: DF.Float + expected_closure_date: DF.Date | None + expected_deal_value: DF.Currency + first_name: DF.Data | None + first_responded_on: DF.Datetime | None + first_response_time: DF.Duration | None + gender: DF.Link | None + industry: DF.Link | None + job_title: DF.Data | None + last_name: DF.Data | None + last_responded_on: DF.Datetime | None + last_response_time: DF.Duration | None + lead: DF.Link | None + lead_name: DF.Data | None + lost_notes: DF.Text | None + lost_reason: DF.Link | None + mobile_no: DF.Data | None + naming_series: DF.Literal["CRM-DEAL-.YYYY.-"] + net_total: DF.Currency + next_step: DF.Data | None + no_of_employees: DF.Literal["1-10", "11-50", "51-200", "201-500", "501-1000", "1000+"] + organization: DF.Link | None + organization_name: DF.Data | None + phone: DF.Data | None + probability: DF.Percent + products: DF.Table[CRMProducts] + response_by: DF.Datetime | None + rolling_responses: DF.Table[CRMRollingResponseTime] + salutation: DF.Link | None + sla: DF.Link | None + sla_creation: DF.Datetime | None + sla_status: DF.Literal["", "First Response Due", "Rolling Response Due", "Failed", "Fulfilled"] + source: DF.Link | None + status: DF.Link + status_change_log: DF.Table[CRMStatusChangeLog] + territory: DF.Link | None + total: DF.Currency + website: DF.Data | None + # end: auto-generated types + + def before_insert(self): + # apply CRM enrichment (source/organization/contact) when created via a web form + from crm.api.form import enrich_form_submission + + enrich_form_submission(self) + + def before_validate(self): + self.set_sla() + + def validate(self): + self.validate_status() + self.set_primary_contact() + self.set_primary_email_mobile_no() + if not self.is_new() and self.has_value_changed("deal_owner") and self.deal_owner: + self.share_with_agent(self.deal_owner) + self.assign_agent(self.deal_owner) + if self.has_value_changed("status"): + add_status_change_log(self) + if frappe.db.get_value("CRM Deal Status", self.status, "type") == "Won": + self.closed_date = frappe.utils.nowdate() + self.validate_forecasting_fields() + self.validate_lost_reason() + self.update_exchange_rate() + if self.organization and (self.is_new() or self.has_value_changed("organization")): + self.copy_enrichment_from_organization() + + def after_insert(self): + if self.deal_owner: + if self.deal_owner != frappe.session.user: + self.share_with_agent(self.deal_owner) + self.assign_agent(self.deal_owner) + + # Auto-enrich a new Deal from its website (best-effort, background job). + from crm.domain_enrichment.tasks import auto_enrich_on_create + + auto_enrich_on_create(self) + + def before_save(self): + self.apply_sla() + + def validate_status(self): + if self.is_new() and not self.status: + if frappe.db.exists("CRM Deal Status", "Qualification"): + self.status = "Qualification" + else: + self.status = frappe.get_all("CRM Deal Status", {"type": "Open"}, pluck="name")[0] + + def set_primary_contact(self, contact=None): + if not self.contacts: + return + + if not contact and len(self.contacts) == 1: + self.contacts[0].is_primary = 1 + elif contact: + for d in self.contacts: + if d.contact == contact: + d.is_primary = 1 + else: + d.is_primary = 0 + + def set_primary_email_mobile_no(self): + if not self.contacts: + self.email = "" + self.mobile_no = "" + self.phone = "" + return + + if len([contact for contact in self.contacts if contact.is_primary]) > 1: + frappe.throw(_("Only one {0} can be set as primary.").format(frappe.bold("Contact"))) + + primary_contact_exists = False + for d in self.contacts: + if d.is_primary == 1: + primary_contact_exists = True + self.email = d.email.strip() if d.email else "" + self.mobile_no = d.mobile_no.strip() if d.mobile_no else "" + self.phone = d.phone.strip() if d.phone else "" + break + + if not primary_contact_exists: + self.email = "" + self.mobile_no = "" + self.phone = "" + + def assign_agent(self, agent): + if not agent: + return + + assignees = self.get_assigned_users() + if assignees: + for assignee in assignees: + if agent == assignee: + # the agent is already set as an assignee + return + + assign({"assign_to": [agent], "doctype": "CRM Deal", "name": self.name}, ignore_permissions=True) + + def share_with_agent(self, agent): + if not agent: + return + + docshares = frappe.get_all( + "DocShare", + filters={"share_name": self.name, "share_doctype": self.doctype}, + fields=["name", "user"], + ) + + shared_with = [d.user for d in docshares] + [agent] + + for user in shared_with: + if user == agent and not frappe.db.exists( + "DocShare", + {"user": agent, "share_name": self.name, "share_doctype": self.doctype}, + ): + frappe.share.add_docshare( + self.doctype, + self.name, + agent, + write=1, + flags={"ignore_share_permission": True}, + ) + elif user != agent: + frappe.share.remove( + self.doctype, + self.name, + user, + flags={"ignore_share_permission": True, "ignore_permissions": True}, + ) + + def copy_enrichment_from_organization(self): + """Fill-empty copy of the linked enriched Organization's fields onto this Deal. + + Called from ``validate`` when ``organization`` is set or changed to an + already-enriched CRM Organization. The shared helper mutates ``self`` in place + (fill-empty, so user-entered data is preserved); the in-progress save persists + the changes. Best-effort -- never blocks the Deal save. + """ + from crm.domain_enrichment.cross_record import copy_enrichment_from_organization + + return copy_enrichment_from_organization(self) + + def set_sla(self): + """ + Find an SLA to apply to the deal. + """ + if self.sla: + return + + sla = get_sla(self) + if not sla: + self.first_responded_on = None + self.first_response_time = None + return + self.sla = sla.name + + def apply_sla(self): + """ + Apply SLA if set. + """ + if not self.sla: + return + sla = frappe.get_last_doc("CRM Service Level Agreement", {"name": self.sla}) + if sla: + sla.apply(self) + + def update_closed_date(self): + """ + Update the closed date based on the "Won" status. + """ + if self.status == "Won" and not self.closed_date: + self.closed_date = frappe.utils.nowdate() + + def update_default_probability(self): + """ + Update the default probability based on the status. + """ + if not self.probability or self.probability == 0: + self.probability = frappe.db.get_value("CRM Deal Status", self.status, "probability") or 0 + + def update_expected_deal_value(self): + """ + Update the expected deal value based on the net total or total. + """ + if ( + frappe.db.get_single_value("FCRM Settings", "auto_update_expected_deal_value") + and (self.net_total or self.total) + and self.expected_deal_value + ): + self.expected_deal_value = self.net_total or self.total + + def validate_forecasting_fields(self): + self.update_closed_date() + self.update_default_probability() + self.update_expected_deal_value() + if frappe.db.get_single_value("FCRM Settings", "enable_forecasting"): + if not self.expected_deal_value or self.expected_deal_value == 0: + frappe.throw(_("Expected deal value is required."), frappe.MandatoryError) + if not self.expected_closure_date: + frappe.throw(_("Expected closure date is required."), frappe.MandatoryError) + + def validate_lost_reason(self): + """ + Validate the lost reason if the status is set to "Lost". + """ + if self.status and frappe.get_cached_value("CRM Deal Status", self.status, "type") == "Lost": + if not self.lost_reason: + frappe.throw(_("Please specify a reason for losing the deal."), frappe.ValidationError) + elif self.lost_reason == "Other" and not self.lost_notes: + frappe.throw(_("Please specify the reason for losing the deal."), frappe.ValidationError) + if self.has_value_changed("status"): + add_or_remove_lost_reason_section_in_sidepanel(self) + + def update_exchange_rate(self): + if self.has_value_changed("currency") or not self.exchange_rate: + system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD" + exchange_rate = 1 + if self.currency and self.currency != system_currency: + exchange_rate = get_exchange_rate(self.currency, system_currency) + + self.db_set("exchange_rate", exchange_rate) + + @staticmethod + def default_list_data(): + columns = [ + { + "label": "Organization", + "type": "Link", + "key": "organization", + "options": "CRM Organization", + "width": "11rem", + }, + { + "label": "Annual Revenue", + "type": "Currency", + "key": "annual_revenue", + "align": "right", + "width": "9rem", + }, + { + "label": "Status", + "type": "Link", + "options": "CRM Deal Status", + "key": "status", + "width": "10rem", + }, + { + "label": "Email", + "type": "Data", + "key": "email", + "width": "12rem", + }, + { + "label": "Mobile No.", + "type": "Data", + "key": "mobile_no", + "width": "11rem", + }, + { + "label": "Assigned To", + "type": "Text", + "key": "_assign", + "width": "10rem", + }, + { + "label": "Last Modified", + "type": "Datetime", + "key": "modified", + "width": "8rem", + }, + ] + rows = [ + "name", + "organization", + "annual_revenue", + "status", + "email", + "currency", + "mobile_no", + "deal_owner", + "sla_status", + "response_by", + "first_response_time", + "first_responded_on", + "modified", + "_assign", + ] + return {"columns": columns, "rows": rows} + + @staticmethod + def default_kanban_settings(): + return { + "column_field": "status", + "title_field": "organization", + "kanban_fields": '["annual_revenue", "email", "mobile_no", "_assign", "modified"]', + } + + +@frappe.whitelist() +def add_contact(deal: str, contact: str): + if not frappe.has_permission("CRM Deal", "write", deal): + frappe.throw(_("Not allowed to add contact to Deal"), frappe.PermissionError) + + deal = frappe.get_cached_doc("CRM Deal", deal) + deal.append("contacts", {"contact": contact}) + deal.save() + return True + + +@frappe.whitelist() +def remove_contact(deal: str, contact: str): + if not frappe.has_permission("CRM Deal", "write", deal): + frappe.throw(_("Not allowed to remove contact from Deal"), frappe.PermissionError) + + deal = frappe.get_cached_doc("CRM Deal", deal) + deal.contacts = [d for d in deal.contacts if d.contact != contact] + deal.save() + return True + + +@frappe.whitelist() +def set_primary_contact(deal: str, contact: str): + if not frappe.has_permission("CRM Deal", "write", deal): + frappe.throw(_("Not allowed to set primary contact for Deal"), frappe.PermissionError) + + deal = frappe.get_cached_doc("CRM Deal", deal) + deal.set_primary_contact(contact) + deal.save() + return True + + +def create_organization(doc): + if not doc.get("organization_name"): + return + + existing_organization = frappe.db.exists( + "CRM Organization", {"organization_name": doc.get("organization_name")} + ) + if existing_organization: + return existing_organization + + organization = frappe.new_doc("CRM Organization") + organization.update( + { + "organization_name": doc.get("organization_name"), + "website": doc.get("website"), + "territory": doc.get("territory"), + "industry": doc.get("industry"), + "annual_revenue": doc.get("annual_revenue"), + "no_of_employees": doc.get("no_of_employees"), + } + ) + organization.insert(ignore_permissions=True) + return organization.name + + +def contact_exists(doc): + email_exist = frappe.db.exists("Contact Email", {"email_id": doc.get("email")}) + mobile_exist = frappe.db.exists("Contact Phone", {"phone": doc.get("mobile_no")}) + + doctype = "Contact Email" if email_exist else "Contact Phone" + name = email_exist or mobile_exist + + if name: + return frappe.db.get_value(doctype, name, "parent") + + return False + + +def create_contact(doc): + existing_contact = contact_exists(doc) + if existing_contact: + return existing_contact + + contact = frappe.new_doc("Contact") + contact.update( + { + "first_name": doc.get("first_name"), + "last_name": doc.get("last_name"), + "salutation": doc.get("salutation"), + "company_name": doc.get("organization") or doc.get("organization_name"), + "gender": doc.get("gender"), + } + ) + + if doc.get("email"): + contact.append("email_ids", {"email_id": doc.get("email"), "is_primary": 1}) + + if doc.get("mobile_no"): + contact.append("phone_nos", {"phone": doc.get("mobile_no"), "is_primary_mobile_no": 1}) + + contact.insert(ignore_permissions=True) + contact.reload() # load changes by hooks on contact + + return contact.name + + +@frappe.whitelist() +def create_deal(doc: dict): + deal = frappe.new_doc("CRM Deal") + + contact = doc.get("contact") + if not contact and ( + doc.get("first_name") or doc.get("last_name") or doc.get("email") or doc.get("mobile_no") + ): + contact = create_contact(doc) + + deal.update( + { + "organization": doc.get("organization") or create_organization(doc), + "contacts": [{"contact": contact, "is_primary": 1}] if contact else [], + } + ) + + doc.pop("organization", None) + + deal.update(doc) + + deal.insert(ignore_permissions=True) + return deal.name diff --git a/crm/fcrm/doctype/crm_deal/test_crm_deal.py b/crm/fcrm/doctype/crm_deal/test_crm_deal.py new file mode 100644 index 0000000..d099771 --- /dev/null +++ b/crm/fcrm/doctype/crm_deal/test_crm_deal.py @@ -0,0 +1,571 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.desk.form.assign_to import add as assign_add +from frappe.desk.form.assign_to import remove as assign_remove +from frappe.tests import IntegrationTestCase + +from crm.fcrm.doctype.crm_deal.api import get_deal_contacts +from crm.fcrm.doctype.crm_deal.crm_deal import ( + add_contact, + create_deal, + remove_contact, + set_primary_contact, +) + + +class TestCRMDeal(IntegrationTestCase): + def tearDown(self) -> None: + frappe.set_user("Administrator") + frappe.db.rollback() + + def test_deal_creation_with_organization(self): + """Test creating a deal with organization""" + deal = create_test_deal( + organization="Test Org Inc", + annual_revenue=1000000, + status="Qualification", + ) + + self.assertTrue(deal.name) + self.assertTrue(deal.organization) + self.assertEqual(deal.annual_revenue, 1000000) + + def test_set_primary_contact(self): + """Test setting primary contact from contacts table""" + # Create contacts + contact1 = create_test_contact(first_name="John", last_name="Doe", email="john@example.com") + contact2 = create_test_contact( + first_name="Jane", last_name="Smith", email="jane@example.com", mobile_no="+1234567890" + ) + + # Create deal with two contacts + deal = create_test_deal(organization="Contact Test Org") + deal.append("contacts", {"contact": contact1.name}) + deal.append("contacts", {"contact": contact2.name, "is_primary": 1}) + deal.save() + + # Verify primary contact is set + deal.reload() + primary_contacts = [c for c in deal.contacts if c.is_primary == 1] + self.assertEqual(len(primary_contacts), 1) + self.assertEqual(primary_contacts[0].contact, contact2.name) + + def test_set_primary_email_mobile_no(self): + """Test that email and mobile are set from primary contact""" + # Create contact + contact = create_test_contact( + first_name="Test", + last_name="User", + email="testuser@example.com", + mobile_no="+9876543210", + phone="+1111111111", + ) + + # Create deal with contact + deal = create_test_deal(organization="Email Test Org") + deal.append("contacts", {"contact": contact.name, "is_primary": 1}) + deal.save() + + # Verify email and mobile are set from contact + deal.reload() + self.assertEqual(deal.email, "testuser@example.com") + self.assertEqual(deal.mobile_no, "+9876543210") + self.assertEqual(deal.phone, "+1111111111") + + def test_multiple_primary_contacts_error(self): + """Test that having multiple primary contacts throws error""" + contact1 = create_test_contact(first_name="Primary1", email="p1@example.com") + contact2 = create_test_contact(first_name="Primary2", email="p2@example.com") + + deal = create_test_deal(organization="Multiple Primary Test") + deal.append("contacts", {"contact": contact1.name, "is_primary": 1}) + deal.append("contacts", {"contact": contact2.name, "is_primary": 1}) + + with self.assertRaises(frappe.exceptions.ValidationError) as context: + deal.save() + self.assertIn("Only one", str(context.exception)) + + def test_no_primary_contact_clears_email(self): + """Test that email/mobile on deal (not child table) are cleared when no primary contact""" + contact1 = create_test_contact( + first_name="Primary", email="primary@example.com", mobile_no="+1111111111" + ) + contact2 = create_test_contact(first_name="NonPrimary", email="nonprimary@example.com") + + # Create deal with primary contact - email should be set + deal = create_test_deal( + organization="No Primary Org", + expected_deal_value=1000, + expected_closure_date="2026-12-31", + ) + deal.append("contacts", {"contact": contact1.name, "is_primary": 1}) + deal.save() + deal.reload() + + self.assertEqual(deal.email, "primary@example.com") + self.assertEqual(deal.mobile_no, "+1111111111") + + # Change to non-primary contact - deal email should be cleared + for c in deal.contacts: + c.is_primary = 0 + deal.append("contacts", {"contact": contact2.name, "is_primary": 0}) + deal.save() + deal.reload() + + # Deal-level fields should be cleared since no primary contact + self.assertEqual(deal.email, "") + self.assertEqual(deal.mobile_no, "") + self.assertEqual(deal.phone, "") + + def test_deal_owner_assignment(self): + """Test that deal owner is assigned on creation""" + deal = create_test_deal(organization="Owner Test Org", deal_owner="Administrator") + + # Verify deal owner is assigned + assignees = deal.get_assigned_users() + self.assertIn("Administrator", assignees) + + def test_update_deal_owner(self): + """Test updating deal owner assigns and shares with new owner""" + # Create deal without owner + deal = create_test_deal(organization="Update Owner Org") + self.assertFalse(deal.deal_owner) + + # Update deal owner + deal.deal_owner = "Administrator" + deal.save() + + # Verify assignment and share + deal.reload() + self.assertEqual(deal.deal_owner, "Administrator") + assignees = deal.get_assigned_users() + self.assertIn("Administrator", assignees) + + docshare = frappe.db.exists( + "DocShare", + {"user": "Administrator", "share_name": deal.name, "share_doctype": "CRM Deal"}, + ) + self.assertTrue(docshare) + + # Try to assign same agent again - should not duplicate + initial_count = len(assignees) + deal.assign_agent("Administrator") + assignees_after = deal.get_assigned_users() + self.assertEqual(len(assignees_after), initial_count) + + def test_owner_cleared_on_unassign(self): + """Unassigning the current owner clears deal_owner""" + deal = create_test_deal(organization="Owner Clear Org", deal_owner="crm.user1@example.com") + self.assertEqual(deal.deal_owner, "crm.user1@example.com") + + assign_remove("CRM Deal", deal.name, "crm.user1@example.com") + + self.assertIsNone(frappe.db.get_value("CRM Deal", deal.name, "deal_owner")) + + def test_reassignment_moves_owner(self): + """After the owner is unassigned, assigning a new user makes them the owner""" + deal = create_test_deal(organization="Reassign Org", deal_owner="crm.user1@example.com") + + # Frappe assignment rules unassign before assign in one cycle; mirror that order + assign_remove("CRM Deal", deal.name, "crm.user1@example.com") + self.assertIsNone(frappe.db.get_value("CRM Deal", deal.name, "deal_owner")) + + assign_add({"assign_to": ["crm.user2@example.com"], "doctype": "CRM Deal", "name": deal.name}) + + self.assertEqual(frappe.db.get_value("CRM Deal", deal.name, "deal_owner"), "crm.user2@example.com") + + def test_assignment_overrides_owner(self): + """A new assignment takes ownership even when an owner already exists (newest owns).""" + deal = create_test_deal(organization="Override Org", deal_owner="crm.user1@example.com") + assign_add({"assign_to": ["crm.user2@example.com"], "doctype": "CRM Deal", "name": deal.name}) + self.assertEqual(frappe.db.get_value("CRM Deal", deal.name, "deal_owner"), "crm.user2@example.com") + + def test_removing_any_assignee_clears_owner(self): + """Accepted simplification: cancelling ANY assignment clears the owner, + even when other assignees remain (owner is single-valued, _assign is a list).""" + deal = create_test_deal(organization="Wrinkle Org", deal_owner="crm.user1@example.com") + assign_add({"assign_to": ["crm.user2@example.com"], "doctype": "CRM Deal", "name": deal.name}) + # newest assignment owns + self.assertEqual(frappe.db.get_value("CRM Deal", deal.name, "deal_owner"), "crm.user2@example.com") + + assign_remove("CRM Deal", deal.name, "crm.user1@example.com") # remove a non-owner assignee + self.assertIsNone(frappe.db.get_value("CRM Deal", deal.name, "deal_owner")) + + def test_todo_assignment_requires_deal_access(self): + """A direct ToDo against a deal is rejected when the creator cannot access it""" + deal = create_test_deal(organization="No Access Assign Org", deal_owner="crm.user1@example.com") + + frappe.set_user(create_test_user_without_deal_access()) + with self.assertRaises(frappe.PermissionError): + frappe.get_doc( + { + "doctype": "ToDo", + "description": "Take over", + "reference_type": "CRM Deal", + "reference_name": deal.name, + "allocated_to": frappe.session.user, + } + ).insert() + + self.assertEqual(frappe.db.get_value("CRM Deal", deal.name, "deal_owner"), "crm.user1@example.com") + + def test_owner_can_assign_via_direct_todo(self): + """A user who can access the deal may assign it with a direct ToDo, which moves the owner""" + deal = create_test_deal(organization="Direct Assign Org", deal_owner="crm.user1@example.com") + + frappe.set_user("crm.user1@example.com") + frappe.get_doc( + { + "doctype": "ToDo", + "description": "Hand over", + "reference_type": "CRM Deal", + "reference_name": deal.name, + "allocated_to": "crm.user2@example.com", + } + ).insert() + + self.assertEqual(frappe.db.get_value("CRM Deal", deal.name, "deal_owner"), "crm.user2@example.com") + + def test_task_unassign_does_not_touch_owner(self): + """Cancelling a CRM Task assignment is a no-op for owner fields""" + deal = create_test_deal(organization="Task Org") + task = frappe.get_doc( + { + "doctype": "CRM Task", + "title": "Owner sync task", + "reference_doctype": "CRM Deal", + "reference_docname": deal.name, + } + ).insert() + + assign_add({"assign_to": ["crm.user1@example.com"], "doctype": "CRM Task", "name": task.name}) + # Should not raise + assign_remove("CRM Task", task.name, "crm.user1@example.com") + + def test_add_contact_api(self): + """Test add_contact API function""" + deal = create_test_deal(organization="Add Contact Org") + contact = create_test_contact(first_name="API", last_name="User", email="api@example.com") + + # Add contact using API + result = add_contact(deal.name, contact.name) + self.assertTrue(result) + + # Verify contact was added + deal.reload() + contact_names = [c.contact for c in deal.contacts] + self.assertIn(contact.name, contact_names) + + def test_remove_contact_api(self): + """Test remove_contact API function""" + contact = create_test_contact(first_name="Remove", email="remove@example.com") + deal = create_test_deal(organization="Remove Contact Org") + deal.append("contacts", {"contact": contact.name}) + deal.save() + + # Verify contact exists + deal.reload() + self.assertEqual(len(deal.contacts), 1) + + # Remove contact using API + result = remove_contact(deal.name, contact.name) + self.assertTrue(result) + + # Verify contact was removed + deal.reload() + self.assertEqual(len(deal.contacts), 0) + + def test_set_primary_contact_api(self): + """Test set_primary_contact API function""" + contact1 = create_test_contact(first_name="First", email="first@example.com") + contact2 = create_test_contact(first_name="Second", email="second@example.com") + + deal = create_test_deal(organization="Primary API Org") + deal.append("contacts", {"contact": contact1.name, "is_primary": 1}) + deal.append("contacts", {"contact": contact2.name}) + deal.save() + + # Change primary contact using API + result = set_primary_contact(deal.name, contact2.name) + self.assertTrue(result) + + # Verify primary contact was changed + deal.reload() + for c in deal.contacts: + if c.contact == contact2.name: + self.assertEqual(c.is_primary, 1) + else: + self.assertEqual(c.is_primary, 0) + + def test_get_deal_contacts_orders_primary_first(self): + """Test that get_deal_contacts pins the primary contact to the top + regardless of the order contacts were added in""" + contact1 = create_test_contact(first_name="Alpha", email="alpha@example.com") + contact2 = create_test_contact(first_name="Beta", email="beta@example.com") + contact3 = create_test_contact(first_name="Gamma", email="gamma@example.com") + + deal = create_test_deal(organization="Contact Order Org") + deal.append("contacts", {"contact": contact1.name}) + deal.append("contacts", {"contact": contact2.name}) + deal.append("contacts", {"contact": contact3.name, "is_primary": 1}) + deal.save() + + contacts = get_deal_contacts(deal.name) + + self.assertEqual(contacts[0]["name"], contact3.name) + self.assertEqual(contacts[0]["is_primary"], 1) + self.assertEqual([c["name"] for c in contacts[1:]], [contact1.name, contact2.name]) + + def test_create_deal_api(self): + """Test create_deal API function""" + deal_name = create_deal( + { + "organization_name": "API Deal Org", + "annual_revenue": 500000, + "first_name": "Deal", + "last_name": "Creator", + "email": "dealcreator@example.com", + "mobile_no": "+5555555555", + } + ) + + self.assertTrue(deal_name) + + # Verify deal was created + deal = frappe.get_doc("CRM Deal", deal_name) + self.assertEqual(deal.annual_revenue, 500000) + self.assertTrue(deal.organization) + self.assertTrue(len(deal.contacts) > 0) + + # Verify organization was created + org = frappe.get_doc("CRM Organization", deal.organization) + self.assertEqual(org.organization_name, "API Deal Org") + + # Verify contact was created + contact = frappe.get_doc("Contact", deal.contacts[0].contact) + self.assertEqual(contact.first_name, "Deal") + self.assertEqual(contact.email_id, "dealcreator@example.com") + + def test_create_deal_with_existing_organization(self): + """Test create_deal with existing organization""" + # Create organization first + org = frappe.get_doc( + { + "doctype": "CRM Organization", + "organization_name": "Existing Org", + "annual_revenue": 2000000, + } + ).insert() + + # Create deal with same organization name + deal_name = create_deal( + { + "organization_name": "Existing Org", + "first_name": "Existing", + "email": "existing@example.com", + } + ) + + deal = frappe.get_doc("CRM Deal", deal_name) + self.assertEqual(deal.organization, org.name) + + def test_create_deal_api_propagates_no_of_employees(self): + """Test that no_of_employees is copied onto the organization create_deal creates""" + deal_name = create_deal( + { + "organization_name": "Employees Test Org", + "no_of_employees": "51-200", + "first_name": "Employees", + "email": "employeestest@example.com", + } + ) + + deal = frappe.get_doc("CRM Deal", deal_name) + org = frappe.get_doc("CRM Organization", deal.organization) + self.assertEqual(org.no_of_employees, "51-200") + + def test_create_deal_with_existing_contact(self): + """Test create_deal with existing contact""" + # Create contact first + contact = create_test_contact( + first_name="Existing", last_name="Contact", email="existingc@example.com" + ) + + # Create deal with same email + deal_name = create_deal( + { + "organization_name": "Contact Existing Org", + "first_name": "Existing", + "email": "existingc@example.com", + } + ) + + deal = frappe.get_doc("CRM Deal", deal_name) + self.assertEqual(deal.contacts[0].contact, contact.name) + + def test_validate_lost_reason_required(self): + """Test that lost reason is required when status is Lost""" + # Create Lost status if not exists + if not frappe.db.exists("CRM Deal Status", "Lost"): + frappe.get_doc({"doctype": "CRM Deal Status", "name": "Lost", "type": "Lost"}).insert() + + deal = create_test_deal(organization="Lost Deal Org") + + # Try to set status to Lost without lost_reason + deal.status = "Lost" + with self.assertRaises(frappe.exceptions.ValidationError) as context: + deal.save() + self.assertIn("reason for losing", str(context.exception)) + + def test_validate_lost_reason_other(self): + """Test that lost_notes is required when lost_reason is Other""" + if not frappe.db.exists("CRM Deal Status", "Lost"): + frappe.get_doc({"doctype": "CRM Deal Status", "name": "Lost", "type": "Lost"}).insert() + + if not frappe.db.exists("CRM Lost Reason", "Other"): + frappe.get_doc({"doctype": "CRM Lost Reason", "reason": "Other"}).insert() + + deal = create_test_deal(organization="Lost Notes Org") + deal.status = "Lost" + deal.lost_reason = "Other" + + with self.assertRaises(frappe.exceptions.ValidationError) as context: + deal.save() + self.assertIn("specify the reason", str(context.exception)) + + def test_closed_date_set_on_won(self): + """Test that closed_date is set when status is Won""" + if not frappe.db.exists("CRM Deal Status", "Won"): + frappe.get_doc({"doctype": "CRM Deal Status", "name": "Won", "type": "Won"}).insert() + + deal = create_test_deal( + organization="Won Deal Org", expected_deal_value=10000, expected_closure_date="2026-12-31" + ) + self.assertFalse(deal.closed_date) + + deal.status = "Won" + deal.save() + + deal.reload() + self.assertTrue(deal.closed_date) + + def test_forecasting_fields_validation(self): + """Test forecasting fields validation when enabled""" + # Enable forecasting + settings = frappe.get_single("FCRM Settings") + original_value = settings.enable_forecasting + settings.enable_forecasting = 1 + settings.save() + + try: + # Should fail without expected_deal_value + with self.assertRaises(frappe.exceptions.MandatoryError): + create_test_deal(organization="Forecast Org") + + # Should fail without expected_closure_date + with self.assertRaises(frappe.exceptions.MandatoryError): + create_test_deal(organization="Forecast Org 2", expected_deal_value=5000) + + # Should succeed with both fields + deal = create_test_deal( + organization="Forecast Org 3", + expected_deal_value=5000, + expected_closure_date="2026-12-31", + ) + self.assertTrue(deal.name) + + finally: + # Restore original setting + settings.enable_forecasting = original_value + settings.save() + + def test_single_contact_auto_primary(self): + """Test that single contact is automatically set as primary""" + contact = create_test_contact(first_name="Auto", email="auto@example.com") + deal = create_test_deal(organization="Auto Primary Org") + deal.append("contacts", {"contact": contact.name}) + deal.save() + + deal.reload() + self.assertEqual(deal.contacts[0].is_primary, 1) + + def test_negative_currency_fields_rejected(self): + """Test that Currency fields reject negative values""" + for fieldname in ("annual_revenue", "deal_value", "expected_deal_value", "total", "net_total"): + with self.subTest(fieldname=fieldname), self.assertRaises(frappe.NonNegativeError): + create_test_deal(organization=f"Negative {fieldname}", **{fieldname: -100}) + + +class TestGetDealContacts(IntegrationTestCase): + def tearDown(self) -> None: + frappe.set_user("Administrator") + frappe.db.rollback() + + def test_get_deal_contacts_requires_read_permission(self): + """A user without read access on the deal must not get its contact PII""" + contact = create_test_contact( + first_name="Alice", + last_name="Confidential", + email="alice@example.com", + mobile_no="+919000000000", + ) + deal = create_test_deal(organization="Perm Test Org") + deal.append("contacts", {"contact": contact.name}) + deal.save() + + self.assertEqual(get_deal_contacts(deal.name)[0]["name"], contact.name) + + frappe.set_user(create_test_user_without_deal_access()) + with self.assertRaises(frappe.PermissionError): + get_deal_contacts(deal.name) + + +def create_test_user_without_deal_access(): + """Create (or reuse) a user with no role granting access to CRM Deal""" + email = "deal-noperm@example.com" + if not frappe.db.exists("User", email): + user = frappe.new_doc("User") + user.email = email + user.first_name = "No" + user.last_name = "Perm" + user.send_welcome_email = 0 + user.insert(ignore_permissions=True) + return email + + +def create_test_deal(**kwargs): + """Helper function to create a CRM Deal for testing""" + # Create organization if provided as string + if "organization" in kwargs and isinstance(kwargs["organization"], str): + org_name = kwargs["organization"] + if not frappe.db.exists("CRM Organization", {"organization_name": org_name}): + org = frappe.get_doc({"doctype": "CRM Organization", "organization_name": org_name}).insert() + kwargs["organization"] = org.name + else: + kwargs["organization"] = frappe.db.get_value( + "CRM Organization", {"organization_name": org_name}, "name" + ) + + data = {"doctype": "CRM Deal"} + data.update(kwargs) + return frappe.get_doc(data).insert() + + +def create_test_contact(**kwargs): + """Helper function to create a Contact for testing""" + contact = frappe.get_doc({"doctype": "Contact"}) + contact.update(kwargs) + + if kwargs.get("email"): + contact.append("email_ids", {"email_id": kwargs["email"], "is_primary": 1}) + + if kwargs.get("mobile_no"): + contact.append("phone_nos", {"phone": kwargs["mobile_no"], "is_primary_mobile_no": 1}) + + if kwargs.get("phone"): + contact.append("phone_nos", {"phone": kwargs["phone"], "is_primary_phone": 1}) + + contact.insert(ignore_permissions=True) + return contact diff --git a/crm/fcrm/doctype/crm_deal/test_records.json b/crm/fcrm/doctype/crm_deal/test_records.json new file mode 100644 index 0000000..743fe55 --- /dev/null +++ b/crm/fcrm/doctype/crm_deal/test_records.json @@ -0,0 +1,355 @@ +[ + { + "doctype": "CRM Deal", + "organization": "Acme Corp", + "status": "Qualification", + "deal_value": 50000, + "currency": "USD", + "exchange_rate": 1, + "source": "Website", + "deal_owner": "Administrator", + "probability": 30 + }, + { + "doctype": "CRM Deal", + "organization": "Tech Solutions", + "status": "Qualification", + "deal_value": 75000, + "currency": "USD", + "exchange_rate": 1, + "source": "Referral", + "deal_owner": "Administrator", + "probability": 40 + }, + { + "doctype": "CRM Deal", + "organization": "Global Enterprises", + "status": "Qualification", + "deal_value": 100000, + "currency": "USD", + "exchange_rate": 1, + "source": "Cold Call", + "probability": 25 + }, + { + "doctype": "CRM Deal", + "organization": "StartUp Inc", + "status": "Qualification", + "deal_value": 25000, + "currency": "USD", + "exchange_rate": 1, + "source": "Email Campaign", + "deal_owner": "Administrator", + "probability": 35 + }, + { + "doctype": "CRM Deal", + "organization": "Innovation Labs", + "status": "Qualification", + "deal_value": 80000, + "currency": "USD", + "exchange_rate": 1, + "source": "Social Media", + "probability": 30 + }, + { + "doctype": "CRM Deal", + "organization": "Digital Corp", + "status": "Qualification", + "deal_value": 60000, + "currency": "USD", + "exchange_rate": 1, + "source": "Website", + "deal_owner": "Administrator", + "probability": 40 + }, + { + "doctype": "CRM Deal", + "organization": "Cloud Systems", + "status": "Qualification", + "deal_value": 90000, + "currency": "USD", + "exchange_rate": 1, + "source": "Referral", + "deal_owner": "crm.user1@example.com", + "probability": 35 + }, + { + "doctype": "CRM Deal", + "organization": "Data Inc", + "status": "Qualification", + "deal_value": 45000, + "currency": "USD", + "exchange_rate": 1, + "source": "Cold Call", + "deal_owner": "Administrator", + "probability": 30 + }, + { + "doctype": "CRM Deal", + "organization": "Smart Solutions", + "status": "Qualification", + "deal_value": 70000, + "currency": "USD", + "exchange_rate": 1, + "source": "Email Campaign", + "deal_owner": "crm.user1@example.com", + "probability": 25 + }, + { + "doctype": "CRM Deal", + "organization": "Future Tech", + "status": "Qualification", + "deal_value": 55000, + "currency": "USD", + "exchange_rate": 1, + "source": "Social Media", + "deal_owner": "Administrator", + "probability": 40 + }, + { + "doctype": "CRM Deal", + "organization": "Alpha Industries", + "status": "Qualification", + "deal_value": 85000, + "currency": "USD", + "exchange_rate": 1, + "source": "Website", + "probability": 35 + }, + { + "doctype": "CRM Deal", + "organization": "Beta Corp", + "status": "Qualification", + "deal_value": 65000, + "currency": "USD", + "exchange_rate": 1, + "source": "Referral", + "deal_owner": "Administrator", + "probability": 30 + }, + { + "doctype": "CRM Deal", + "organization": "Gamma Systems", + "status": "Qualification", + "deal_value": 95000, + "currency": "USD", + "exchange_rate": 1, + "source": "Cold Call", + "probability": 25 + }, + { + "doctype": "CRM Deal", + "organization": "Delta Solutions", + "status": "Negotiation", + "deal_value": 120000, + "currency": "USD", + "exchange_rate": 1, + "source": "Email Campaign", + "deal_owner": "Administrator", + "probability": 60 + }, + { + "doctype": "CRM Deal", + "organization": "Epsilon Tech", + "status": "Negotiation", + "deal_value": 110000, + "currency": "USD", + "exchange_rate": 1, + "source": "Social Media", + "probability": 55 + }, + { + "doctype": "CRM Deal", + "organization": "Zeta Corp", + "status": "Negotiation", + "deal_value": 130000, + "currency": "USD", + "exchange_rate": 1, + "source": "Website", + "deal_owner": "Administrator", + "probability": 65 + }, + { + "doctype": "CRM Deal", + "organization": "Eta Systems", + "status": "Negotiation", + "deal_value": 105000, + "currency": "USD", + "exchange_rate": 1, + "source": "Referral", + "probability": 60 + }, + { + "doctype": "CRM Deal", + "organization": "Theta Inc", + "status": "Negotiation", + "deal_value": 115000, + "currency": "USD", + "exchange_rate": 1, + "source": "Cold Call", + "deal_owner": "Administrator", + "probability": 70 + }, + { + "doctype": "CRM Deal", + "organization": "Iota Labs", + "status": "Negotiation", + "deal_value": 125000, + "currency": "USD", + "exchange_rate": 1, + "source": "Email Campaign", + "probability": 55 + }, + { + "doctype": "CRM Deal", + "organization": "Kappa Tech", + "status": "Negotiation", + "deal_value": 140000, + "currency": "USD", + "exchange_rate": 1, + "source": "Social Media", + "deal_owner": "Administrator", + "probability": 65 + }, + { + "doctype": "CRM Deal", + "organization": "Lambda Corp", + "status": "Negotiation", + "deal_value": 135000, + "currency": "USD", + "exchange_rate": 1, + "source": "Website", + "probability": 60 + }, + { + "doctype": "CRM Deal", + "organization": "Mu Systems", + "status": "Won", + "deal_value": 150000, + "currency": "USD", + "exchange_rate": 1, + "source": "Referral", + "deal_owner": "Administrator", + "probability": 100, + "closed_date": "2026-01-15" + }, + { + "doctype": "CRM Deal", + "organization": "Nu Solutions", + "status": "Won", + "deal_value": 160000, + "currency": "USD", + "exchange_rate": 1, + "source": "Cold Call", + "probability": 100, + "closed_date": "2026-01-20" + }, + { + "doctype": "CRM Deal", + "organization": "Xi Tech", + "status": "Won", + "deal_value": 145000, + "currency": "USD", + "exchange_rate": 1, + "source": "Email Campaign", + "deal_owner": "Administrator", + "probability": 100, + "closed_date": "2026-01-25" + }, + { + "doctype": "CRM Deal", + "organization": "Omicron Inc", + "status": "Won", + "deal_value": 170000, + "currency": "USD", + "exchange_rate": 1, + "source": "Social Media", + "probability": 100, + "closed_date": "2026-01-28" + }, + { + "doctype": "CRM Deal", + "organization": "Pi Labs", + "status": "Won", + "deal_value": 155000, + "currency": "USD", + "exchange_rate": 1, + "source": "Website", + "deal_owner": "Administrator", + "probability": 100, + "closed_date": "2026-01-30" + }, + { + "doctype": "CRM Deal", + "organization": "Rho Corp", + "status": "Won", + "deal_value": 165000, + "currency": "USD", + "exchange_rate": 1, + "source": "Referral", + "probability": 100, + "closed_date": "2026-02-01" + }, + { + "doctype": "CRM Deal", + "organization": "Sigma Systems", + "status": "Won", + "deal_value": 175000, + "currency": "USD", + "exchange_rate": 1, + "source": "Cold Call", + "deal_owner": "Administrator", + "probability": 100, + "closed_date": "2026-02-02" + }, + { + "doctype": "CRM Deal", + "organization": "Tau Solutions", + "status": "Won", + "deal_value": 180000, + "currency": "USD", + "exchange_rate": 1, + "source": "Email Campaign", + "probability": 100, + "closed_date": "2026-02-03" + }, + { + "doctype": "CRM Deal", + "organization": "Upsilon Tech", + "status": "Lost", + "deal_value": 200000, + "currency": "USD", + "exchange_rate": 1, + "source": "Social Media", + "deal_owner": "Administrator", + "probability": 0, + "closed_date": "2026-01-18", + "lost_reason": "Price too high" + }, + { + "doctype": "CRM Deal", + "organization": "Phi Corp", + "status": "Lost", + "deal_value": 190000, + "currency": "USD", + "exchange_rate": 1, + "source": "Website", + "probability": 0, + "closed_date": "2026-01-22", + "lost_reason": "Price too high" + }, + { + "doctype": "CRM Deal", + "organization": "Chi Systems", + "status": "Lost", + "deal_value": 210000, + "currency": "USD", + "exchange_rate": 1, + "source": "Referral", + "deal_owner": "Administrator", + "probability": 0, + "closed_date": "2026-01-26", + "lost_reason": "Competition" + } +] diff --git a/crm/fcrm/doctype/crm_deal_status/__init__.py b/crm/fcrm/doctype/crm_deal_status/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_deal_status/crm_deal_status.js b/crm/fcrm/doctype/crm_deal_status/crm_deal_status.js new file mode 100644 index 0000000..5033645 --- /dev/null +++ b/crm/fcrm/doctype/crm_deal_status/crm_deal_status.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Deal Status", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_deal_status/crm_deal_status.json b/crm/fcrm/doctype/crm_deal_status/crm_deal_status.json new file mode 100644 index 0000000..cacff98 --- /dev/null +++ b/crm/fcrm/doctype/crm_deal_status/crm_deal_status.json @@ -0,0 +1,110 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:deal_status", + "creation": "2023-11-29 11:24:55.543387", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "deal_status", + "type", + "position", + "column_break_ojiu", + "probability", + "color" + ], + "fields": [ + { + "default": "gray", + "fieldname": "color", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Color", + "options": "black\ngray\nblue\ngreen\nred\npink\norange\namber\nyellow\ncyan\nteal\nviolet\npurple" + }, + { + "fieldname": "deal_status", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Status", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "position", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Position" + }, + { + "fieldname": "probability", + "fieldtype": "Percent", + "in_list_view": 1, + "label": "Probability" + }, + { + "default": "Open", + "fieldname": "type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Type", + "options": "Open\nOngoing\nOn Hold\nWon\nLost" + }, + { + "fieldname": "column_break_ojiu", + "fieldtype": "Column Break" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-03-05 12:42:47.973700", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Deal Status", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "translated_doctype": 1 +} diff --git a/crm/fcrm/doctype/crm_deal_status/crm_deal_status.py b/crm/fcrm/doctype/crm_deal_status/crm_deal_status.py new file mode 100644 index 0000000..3853c74 --- /dev/null +++ b/crm/fcrm/doctype/crm_deal_status/crm_deal_status.py @@ -0,0 +1,38 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMDealStatus(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + color: DF.Literal[ + "black", + "gray", + "blue", + "green", + "red", + "pink", + "orange", + "amber", + "yellow", + "cyan", + "teal", + "violet", + "purple", + ] + deal_status: DF.Data + position: DF.Int + probability: DF.Percent + type: DF.Literal["Open", "Ongoing", "On Hold", "Won", "Lost"] + # end: auto-generated types + + pass diff --git a/crm/fcrm/doctype/crm_deal_status/test_crm_deal_status.py b/crm/fcrm/doctype/crm_deal_status/test_crm_deal_status.py new file mode 100644 index 0000000..4e60b78 --- /dev/null +++ b/crm/fcrm/doctype/crm_deal_status/test_crm_deal_status.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import UnitTestCase + + +class TestCRMDealStatus(UnitTestCase): + pass diff --git a/crm/fcrm/doctype/crm_deal_status/test_records.json b/crm/fcrm/doctype/crm_deal_status/test_records.json new file mode 100644 index 0000000..35c1db2 --- /dev/null +++ b/crm/fcrm/doctype/crm_deal_status/test_records.json @@ -0,0 +1,26 @@ +[ + { + "doctype": "CRM Deal Status", + "deal_status": "Qualification", + "type": "Open", + "position": 1 + }, + { + "doctype": "CRM Deal Status", + "deal_status": "Negotiation", + "type": "Open", + "position": 2 + }, + { + "doctype": "CRM Deal Status", + "deal_status": "Won", + "type": "Won", + "position": 3 + }, + { + "doctype": "CRM Deal Status", + "deal_status": "Lost", + "type": "Lost", + "position": 4 + } +] diff --git a/crm/fcrm/doctype/crm_dropdown_item/__init__.py b/crm/fcrm/doctype/crm_dropdown_item/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_dropdown_item/crm_dropdown_item.json b/crm/fcrm/doctype/crm_dropdown_item/crm_dropdown_item.json new file mode 100644 index 0000000..9c77f1e --- /dev/null +++ b/crm/fcrm/doctype/crm_dropdown_item/crm_dropdown_item.json @@ -0,0 +1,95 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-12-27 17:42:33.089685", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "name1", + "label", + "type", + "route", + "open_in_new_window", + "hidden", + "is_standard", + "column_break_mvbq", + "icon" + ], + "fields": [ + { + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "mandatory_depends_on": "eval:doc.type == 'Route'" + }, + { + "fieldname": "type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Type", + "options": "Route\nSeparator", + "read_only_depends_on": "eval:doc.is_standard" + }, + { + "depends_on": "eval:doc.type == 'Route'", + "fieldname": "route", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Route", + "mandatory_depends_on": "eval:doc.type == 'Route'" + }, + { + "default": "0", + "fieldname": "hidden", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Hidden" + }, + { + "default": "0", + "fieldname": "is_standard", + "fieldtype": "Check", + "label": "Is Standard", + "read_only": 1 + }, + { + "fieldname": "column_break_mvbq", + "fieldtype": "Column Break" + }, + { + "description": "Add svg code or use feather icons e.g 'settings'", + "fieldname": "icon", + "fieldtype": "Code", + "label": "Icon" + }, + { + "default": "1", + "depends_on": "eval:doc.type == 'Route'", + "fieldname": "open_in_new_window", + "fieldtype": "Check", + "label": "Open in new window" + }, + { + "depends_on": "eval:doc.is_standard", + "fieldname": "name1", + "fieldtype": "Data", + "label": "Name", + "read_only": 1, + "unique": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-02-23 12:22:42.798468", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Dropdown Item", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_dropdown_item/crm_dropdown_item.py b/crm/fcrm/doctype/crm_dropdown_item/crm_dropdown_item.py new file mode 100644 index 0000000..6d13f34 --- /dev/null +++ b/crm/fcrm/doctype/crm_dropdown_item/crm_dropdown_item.py @@ -0,0 +1,30 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMDropdownItem(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + hidden: DF.Check + icon: DF.Code | None + is_standard: DF.Check + label: DF.Data | None + name1: DF.Data | None + open_in_new_window: DF.Check + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + route: DF.Data | None + type: DF.Literal["Route", "Separator"] + # end: auto-generated types + + pass diff --git a/crm/fcrm/doctype/crm_exotel_settings/__init__.py b/crm/fcrm/doctype/crm_exotel_settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.js b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.js new file mode 100644 index 0000000..b7b3339 --- /dev/null +++ b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Exotel Settings", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json new file mode 100644 index 0000000..2f69dcc --- /dev/null +++ b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json @@ -0,0 +1,130 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-01-08 15:55:50.710356", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "enabled", + "column_break_uxtz", + "record_call", + "section_break_kfez", + "account_sid", + "subdomain", + "column_break_qwfn", + "webhook_verify_token", + "section_break_iuct", + "api_key", + "column_break_hyen", + "api_token" + ], + "fields": [ + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "fieldname": "section_break_kfez", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "depends_on": "enabled", + "fieldname": "account_sid", + "fieldtype": "Data", + "label": "Account SID", + "mandatory_depends_on": "enabled" + }, + { + "depends_on": "enabled", + "fieldname": "section_break_iuct", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "fieldname": "column_break_hyen", + "fieldtype": "Column Break" + }, + { + "depends_on": "enabled", + "fieldname": "api_key", + "fieldtype": "Data", + "in_list_view": 1, + "label": "API Key", + "mandatory_depends_on": "enabled" + }, + { + "fieldname": "column_break_uxtz", + "fieldtype": "Column Break" + }, + { + "depends_on": "enabled", + "fieldname": "api_token", + "fieldtype": "Password", + "in_list_view": 1, + "label": "API Token", + "mandatory_depends_on": "enabled" + }, + { + "default": "0", + "depends_on": "enabled", + "fieldname": "record_call", + "fieldtype": "Check", + "label": "Record Outgoing Calls" + }, + { + "fieldname": "column_break_qwfn", + "fieldtype": "Column Break" + }, + { + "depends_on": "enabled", + "fieldname": "webhook_verify_token", + "fieldtype": "Data", + "label": "Webhook Verify Token", + "mandatory_depends_on": "enabled" + }, + { + "depends_on": "enabled", + "fieldname": "subdomain", + "fieldtype": "Data", + "label": "Subdomain", + "mandatory_depends_on": "enabled" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2026-02-23 12:31:01.281017", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Exotel Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.py b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.py new file mode 100644 index 0000000..267ef6f --- /dev/null +++ b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.py @@ -0,0 +1,43 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +import requests +from frappe import _ +from frappe.model.document import Document + + +class CRMExotelSettings(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + account_sid: DF.Data | None + api_key: DF.Data | None + api_token: DF.Password | None + enabled: DF.Check + record_call: DF.Check + subdomain: DF.Data | None + webhook_verify_token: DF.Data | None + # end: auto-generated types + + def validate(self): + self.verify_credentials() + + def verify_credentials(self): + if self.enabled: + response = requests.get( + "https://{subdomain}/v1/Accounts/{sid}".format( + subdomain=self.subdomain, sid=self.account_sid + ), + auth=(self.api_key, self.get_password("api_token")), + ) + if response.status_code != 200: + frappe.throw( + _(f"Please enter valid exotel Account SID, API key & API token: {response.reason}"), + title=_("Invalid credentials"), + ) diff --git a/crm/fcrm/doctype/crm_exotel_settings/test_crm_exotel_settings.py b/crm/fcrm/doctype/crm_exotel_settings/test_crm_exotel_settings.py new file mode 100644 index 0000000..476525f --- /dev/null +++ b/crm/fcrm/doctype/crm_exotel_settings/test_crm_exotel_settings.py @@ -0,0 +1,29 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class UnitTestCRMExotelSettings(UnitTestCase): + """ + Unit tests for CRMExotelSettings. + Use this class for testing individual functions and methods. + """ + + pass + + +class IntegrationTestCRMExotelSettings(IntegrationTestCase): + """ + Integration tests for CRMExotelSettings. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/crm/fcrm/doctype/crm_fields_layout/__init__.py b/crm/fcrm/doctype/crm_fields_layout/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.js b/crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.js new file mode 100644 index 0000000..6a3abeb --- /dev/null +++ b/crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.js @@ -0,0 +1,8 @@ +// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Fields Layout", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.json b/crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.json new file mode 100644 index 0000000..350952c --- /dev/null +++ b/crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.json @@ -0,0 +1,94 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:{dt}-{type}", + "creation": "2024-06-07 16:42:05.495324", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "dt", + "column_break_post", + "type", + "section_break_ttpm", + "layout" + ], + "fields": [ + { + "fieldname": "dt", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Document Type", + "options": "DocType" + }, + { + "fieldname": "type", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Type", + "options": "Quick Entry\nSide Panel\nData Fields\nGrid Row\nRequired Fields" + }, + { + "fieldname": "section_break_ttpm", + "fieldtype": "Section Break" + }, + { + "fieldname": "layout", + "fieldtype": "Code", + "label": "Layout", + "options": "JSON" + }, + { + "fieldname": "column_break_post", + "fieldtype": "Column Break" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-02-23 12:31:51.415568", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Fields Layout", + "naming_rule": "Expression (old style)", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.py b/crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.py new file mode 100644 index 0000000..9fff6e6 --- /dev/null +++ b/crm/fcrm/doctype/crm_fields_layout/crm_fields_layout.py @@ -0,0 +1,259 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import json + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import random_string + + +class CRMFieldsLayout(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + dt: DF.Link | None + layout: DF.Code | None + type: DF.Literal["Quick Entry", "Side Panel", "Data Fields", "Grid Row", "Required Fields"] + # end: auto-generated types + + pass + + +@frappe.whitelist() +def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None): + tabs = [] + layout = None + + if frappe.db.exists("CRM Fields Layout", {"dt": doctype, "type": type}): + layout = frappe.get_doc("CRM Fields Layout", {"dt": doctype, "type": type}) + + if layout and layout.layout: + tabs = json.loads(layout.layout) + + if not tabs and type != "Required Fields": + tabs = get_default_layout(doctype) + + has_tabs = False + if isinstance(tabs, list) and len(tabs) > 0 and isinstance(tabs[0], dict): + has_tabs = any("sections" in tab for tab in tabs) + + if not has_tabs: + tabs = [{"name": "first_tab", "sections": tabs}] + + allowed_fields = [] + for tab in tabs: + for section in tab.get("sections"): + if "columns" not in section: + continue + for column in section.get("columns"): + if not column.get("fields"): + continue + allowed_fields.extend(column.get("fields")) + + fields = frappe.get_meta(doctype).fields + fields = [field for field in fields if field.fieldname in allowed_fields] + + required_fields = [] + + if type == "Required Fields": + required_fields = [ + field for field in frappe.get_meta(doctype, False).fields if field.reqd and not field.default + ] + + for tab in tabs: + for section in tab.get("sections"): + if section.get("columns"): + section["columns"] = [column for column in section.get("columns") if column] + for column in section.get("columns") if section.get("columns") else []: + column["fields"] = [field for field in column.get("fields") if field] + for field in column.get("fields") if column.get("fields") else []: + field = next((f for f in fields if f.fieldname == field), None) + if field: + field = field.as_dict() + handle_perm_level_restrictions(field, doctype, parent_doctype) + column["fields"][column.get("fields").index(field["fieldname"])] = field + + # remove field from required_fields if it is already present + if ( + type == "Required Fields" + and field.reqd + and any(f.get("fieldname") == field.get("fieldname") for f in required_fields) + ): + required_fields = [ + f for f in required_fields if f.get("fieldname") != field.get("fieldname") + ] + + if type == "Required Fields" and required_fields and tabs: + tabs[-1].get("sections").append( + { + "label": "Required Fields", + "name": "required_fields_section_" + str(random_string(4)), + "opened": True, + "hideLabel": True, + "columns": [ + { + "name": "required_fields_column_" + str(random_string(4)), + "fields": [field.as_dict() for field in required_fields], + } + ], + } + ) + + return tabs or [] + + +@frappe.whitelist() +def get_sidepanel_sections(doctype: str): + if not frappe.db.exists("CRM Fields Layout", {"dt": doctype, "type": "Side Panel"}): + return [] + layout = frappe.get_doc("CRM Fields Layout", {"dt": doctype, "type": "Side Panel"}).layout + + if not layout: + return [] + + layout = json.loads(layout) + + not_allowed_fieldtypes = [ + "Tab Break", + "Section Break", + "Column Break", + ] + + fields = frappe.get_meta(doctype).fields + fields = [field for field in fields if field.fieldtype not in not_allowed_fieldtypes] + + for section in layout: + section["name"] = section.get("name") or section.get("label") + for column in section.get("columns") if section.get("columns") else []: + for field in column.get("fields") if column.get("fields") else []: + field_obj = next((f for f in fields if f.fieldname == field), None) + if field_obj: + field_obj = field_obj.as_dict() + handle_perm_level_restrictions(field_obj, doctype) + column["fields"][column.get("fields").index(field)] = get_field_obj(field_obj) + + fields_meta = {} + for field in fields: + fields_meta[field.fieldname] = field + + return layout + + +def handle_perm_level_restrictions(field, doctype, parent_doctype=None): + if field.permlevel == 0: + return + field_has_write_access = field.permlevel in get_permlevel_access("write", doctype, parent_doctype) + field_has_read_access = field.permlevel in get_permlevel_access("read", doctype, parent_doctype) + + if not field_has_write_access and field_has_read_access: + field.read_only = 1 + if not field_has_read_access and not field_has_write_access: + field.hidden = 1 + + +def get_permlevel_access(permission_type="write", doctype=None, parent_doctype=None): + allowed_permlevels = [] + roles = frappe.get_roles() + + meta = frappe.get_meta(doctype) + + if meta.istable and parent_doctype: + meta = frappe.get_meta(parent_doctype) + elif meta.istable and not parent_doctype: + return [1, 0] + + for perm in meta.permissions: + if perm.role in roles and perm.get(permission_type) and perm.permlevel not in allowed_permlevels: + allowed_permlevels.append(perm.permlevel) + + return allowed_permlevels + + +def get_field_obj(field): + field["placeholder"] = field.get("placeholder") or "Add " + field.label + "..." + + if field.fieldtype == "Link": + field["placeholder"] = field.get("placeholder") or "Select " + field.label + "..." + elif field.fieldtype == "Select" and field.options: + field["placeholder"] = field.get("placeholder") or "Select " + field.label + "..." + field["options"] = [{"label": _(option), "value": option} for option in field.options.split("\n")] + + if field.read_only: + field["tooltip"] = "This field is read only and cannot be edited." + + return field + + +@frappe.whitelist() +def save_fields_layout(doctype: str, type: str, layout: str): + if not frappe.has_permission("CRM Fields Layout", "write"): + frappe.throw(_("Not permitted to modify fields layout"), frappe.PermissionError) + + if frappe.db.exists("CRM Fields Layout", {"dt": doctype, "type": type}): + doc = frappe.get_doc("CRM Fields Layout", {"dt": doctype, "type": type}) + else: + doc = frappe.new_doc("CRM Fields Layout") + + doc.update( + { + "dt": doctype, + "type": type, + "layout": layout, + } + ) + doc.save() + + return doc.layout + + +def get_default_layout(doctype: str): + fields = frappe.get_meta(doctype).fields + + tabs = [] + + if fields and fields[0].fieldtype != "Tab Break": + sections = [] + if fields and fields[0].fieldtype != "Section Break": + sections.append( + { + "name": "section_" + str(random_string(4)), + "columns": [{"name": "column_" + str(random_string(4)), "fields": []}], + } + ) + tabs.append({"name": "tab_" + str(random_string(4)), "sections": sections}) + + for field in fields: + if field.fieldtype == "Tab Break": + tabs.append( + { + "name": "tab_" + str(random_string(4)), + "sections": [ + { + "name": "section_" + str(random_string(4)), + "columns": [{"name": "column_" + str(random_string(4)), "fields": []}], + } + ], + } + ) + elif field.fieldtype == "Section Break": + tabs[-1]["sections"].append( + { + "name": "section_" + str(random_string(4)), + "columns": [{"name": "column_" + str(random_string(4)), "fields": []}], + } + ) + elif field.fieldtype == "Column Break": + tabs[-1]["sections"][-1]["columns"].append( + {"name": "column_" + str(random_string(4)), "fields": []} + ) + else: + tabs[-1]["sections"][-1]["columns"][-1]["fields"].append(field.fieldname) + + return tabs diff --git a/crm/fcrm/doctype/crm_fields_layout/test_crm_fields_layout.py b/crm/fcrm/doctype/crm_fields_layout/test_crm_fields_layout.py new file mode 100644 index 0000000..6ea73d0 --- /dev/null +++ b/crm/fcrm/doctype/crm_fields_layout/test_crm_fields_layout.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import UnitTestCase + + +class TestCRMFieldsLayout(UnitTestCase): + pass diff --git a/crm/fcrm/doctype/crm_form_script/__init__.py b/crm/fcrm/doctype/crm_form_script/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_form_script/crm_form_script.js b/crm/fcrm/doctype/crm_form_script/crm_form_script.js new file mode 100644 index 0000000..192b14d --- /dev/null +++ b/crm/fcrm/doctype/crm_form_script/crm_form_script.js @@ -0,0 +1,61 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("CRM Form Script", { + refresh(frm) { + frm.set_query("dt", { + filters: { + istable: 0, + }, + }); + + if (frm.doc.is_standard && !frappe.boot.developer_mode) { + frm.disable_form(); + frappe.show_alert( + __( + "Standard Form Scripts can not be modified, duplicate the Form Script instead.", + ), + ); + } + + if (!frappe.boot.developer_mode) { + frm.toggle_enable("is_standard", 0); + } + + frm.trigger("add_enable_button"); + }, + + add_enable_button(frm) { + frm.add_custom_button( + frm.doc.enabled ? __("Disable") : __("Enable"), + () => { + frm.set_value("enabled", !frm.doc.enabled); + frm.save(); + }, + ); + }, + + view(frm) { + let has_form_boilerplate = frm.doc.script.includes("function setupForm("); + let has_list_boilerplate = frm.doc.script.includes("function setupList("); + + if (frm.doc.view == "Form" && !has_form_boilerplate) { + frm.doc.script = ` +function setupForm({ doc }) { + return { + actions: [], + statuses: [], + } +}`.trim(); + } + if (frm.doc.view == "List" && !has_list_boilerplate) { + frm.doc.script = ` +function setupList({ list }) { + return { + actions: [], + bulk_actions: [], + } +}`.trim(); + } + }, +}); diff --git a/crm/fcrm/doctype/crm_form_script/crm_form_script.json b/crm/fcrm/doctype/crm_form_script/crm_form_script.json new file mode 100644 index 0000000..d0366df --- /dev/null +++ b/crm/fcrm/doctype/crm_form_script/crm_form_script.json @@ -0,0 +1,110 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "prompt", + "creation": "2023-12-28 14:18:09.329868", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "dt", + "view", + "column_break_gboh", + "enabled", + "is_standard", + "section_break_xeox", + "script" + ], + "fields": [ + { + "fieldname": "column_break_gboh", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_xeox", + "fieldtype": "Section Break" + }, + { + "fieldname": "dt", + "fieldtype": "Link", + "in_list_view": 1, + "label": "DocType", + "options": "DocType", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "hidden": 1, + "label": "Enabled" + }, + { + "default": "function setupForm({ doc }) {\n return {\n actions: [],\n }\n}", + "documentation_url": "https://docs.frappe.io/crm/custom-actions", + "fieldname": "script", + "fieldtype": "Code", + "label": "Script", + "options": "JS" + }, + { + "default": "Form", + "fieldname": "view", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Apply To", + "options": "Form\nList", + "set_only_once": 1 + }, + { + "default": "0", + "fieldname": "is_standard", + "fieldtype": "Check", + "label": "Is Standard", + "no_copy": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-02-23 12:31:42.629014", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Form Script", + "naming_rule": "Set by user", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_form_script/crm_form_script.py b/crm/fcrm/doctype/crm_form_script/crm_form_script.py new file mode 100644 index 0000000..7b3e987 --- /dev/null +++ b/crm/fcrm/doctype/crm_form_script/crm_form_script.py @@ -0,0 +1,57 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + + +class CRMFormScript(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + dt: DF.Link + enabled: DF.Check + is_standard: DF.Check + script: DF.Code | None + view: DF.Literal["Form", "List"] + # end: auto-generated types + + def validate(self): + in_user_env = not ( + frappe.flags.in_install + or frappe.flags.in_patch + or frappe.flags.in_test + or frappe.flags.in_fixtures + ) + if in_user_env and not self.is_new() and self.is_standard and not frappe.conf.developer_mode: + # only enabled can be changed for standard form scripts + if self.has_value_changed("enabled"): + enabled_value = self.enabled + self.reload() + self.enabled = enabled_value + else: + frappe.throw(_("You need to be in developer mode to edit a Standard Form Script")) + + +def get_form_script(dt, view="Form"): + """Returns the form script for the given doctype""" + FormScript = frappe.qb.DocType("CRM Form Script") + query = ( + frappe.qb.from_(FormScript) + .select("script") + .where(FormScript.dt == dt) + .where(FormScript.view == view) + .where(FormScript.enabled == 1) + ) + + doc = query.run(as_dict=True) + if doc: + return [d.script for d in doc] if len(doc) > 1 else doc[0].script + else: + return None diff --git a/crm/fcrm/doctype/crm_form_script/test_crm_form_script.py b/crm/fcrm/doctype/crm_form_script/test_crm_form_script.py new file mode 100644 index 0000000..f5e8d98 --- /dev/null +++ b/crm/fcrm/doctype/crm_form_script/test_crm_form_script.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import UnitTestCase + + +class TestCRMFormScript(UnitTestCase): + pass diff --git a/crm/fcrm/doctype/crm_global_settings/__init__.py b/crm/fcrm/doctype/crm_global_settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_global_settings/crm_global_settings.js b/crm/fcrm/doctype/crm_global_settings/crm_global_settings.js new file mode 100644 index 0000000..1a5a5af --- /dev/null +++ b/crm/fcrm/doctype/crm_global_settings/crm_global_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Global Settings", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_global_settings/crm_global_settings.json b/crm/fcrm/doctype/crm_global_settings/crm_global_settings.json new file mode 100644 index 0000000..3a91f6c --- /dev/null +++ b/crm/fcrm/doctype/crm_global_settings/crm_global_settings.json @@ -0,0 +1,97 @@ +{ + "actions": [], + "autoname": "hash", + "creation": "2025-02-28 14:37:10.002433", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "dt", + "column_break_kipp", + "type", + "section_break_vass", + "json" + ], + "fields": [ + { + "default": "DocType", + "fieldname": "dt", + "fieldtype": "Link", + "in_list_view": 1, + "label": "DocType", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "column_break_kipp", + "fieldtype": "Column Break" + }, + { + "fieldname": "type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Type", + "options": "Quick Filters\nSidebar Items", + "reqd": 1 + }, + { + "fieldname": "section_break_vass", + "fieldtype": "Section Break" + }, + { + "fieldname": "json", + "fieldtype": "JSON", + "label": "JSON" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-01-29 16:27:28.090170", + "modified_by": "shariq@frappe.io", + "module": "FCRM", + "name": "CRM Global Settings", + "naming_rule": "Random", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_global_settings/crm_global_settings.py b/crm/fcrm/doctype/crm_global_settings/crm_global_settings.py new file mode 100644 index 0000000..3c690d7 --- /dev/null +++ b/crm/fcrm/doctype/crm_global_settings/crm_global_settings.py @@ -0,0 +1,22 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMGlobalSettings(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + dt: DF.Link + json: DF.JSON | None + type: DF.Literal["Quick Filters", "Sidebar Items"] + # end: auto-generated types + + pass diff --git a/crm/fcrm/doctype/crm_global_settings/test_crm_global_settings.py b/crm/fcrm/doctype/crm_global_settings/test_crm_global_settings.py new file mode 100644 index 0000000..cab7202 --- /dev/null +++ b/crm/fcrm/doctype/crm_global_settings/test_crm_global_settings.py @@ -0,0 +1,29 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class UnitTestCRMGlobalSettings(UnitTestCase): + """ + Unit tests for CRMGlobalSettings. + Use this class for testing individual functions and methods. + """ + + pass + + +class IntegrationTestCRMGlobalSettings(IntegrationTestCase): + """ + Integration tests for CRMGlobalSettings. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/crm/fcrm/doctype/crm_holiday/__init__.py b/crm/fcrm/doctype/crm_holiday/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_holiday/crm_holiday.json b/crm/fcrm/doctype/crm_holiday/crm_holiday.json new file mode 100644 index 0000000..bc8010e --- /dev/null +++ b/crm/fcrm/doctype/crm_holiday/crm_holiday.json @@ -0,0 +1,58 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-12-14 11:16:15.476366", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "date", + "column_break_xzyo", + "weekly_off", + "section_break_zenz", + "description" + ], + "fields": [ + { + "fieldname": "date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Date", + "reqd": 1 + }, + { + "fieldname": "column_break_xzyo", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "weekly_off", + "fieldtype": "Check", + "label": "Weekly Off" + }, + { + "fieldname": "section_break_zenz", + "fieldtype": "Section Break" + }, + { + "fieldname": "description", + "fieldtype": "Text Editor", + "in_list_view": 1, + "label": "Description", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-02-23 12:14:21.746011", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Holiday", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_holiday/crm_holiday.py b/crm/fcrm/doctype/crm_holiday/crm_holiday.py new file mode 100644 index 0000000..f97fa03 --- /dev/null +++ b/crm/fcrm/doctype/crm_holiday/crm_holiday.py @@ -0,0 +1,25 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMHoliday(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + date: DF.Date + description: DF.TextEditor + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + weekly_off: DF.Check + # end: auto-generated types + + pass diff --git a/crm/fcrm/doctype/crm_holiday_list/__init__.py b/crm/fcrm/doctype/crm_holiday_list/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_holiday_list/crm_holiday_list.js b/crm/fcrm/doctype/crm_holiday_list/crm_holiday_list.js new file mode 100644 index 0000000..20917c4 --- /dev/null +++ b/crm/fcrm/doctype/crm_holiday_list/crm_holiday_list.js @@ -0,0 +1,19 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("CRM Holiday List", { + clear_table(frm) { + frm.set_value("holidays", []); + frm.refresh_field("holidays"); + frm.dirty(); + }, + add_to_holidays(frm) { + frm.call("get_weekly_off_dates").then((r) => { + if (r && r.doc && r.doc.holidays) { + frm.doc.holidays = r.doc.holidays; + } + frm.refresh_field("holidays"); + frm.dirty(); + }); + }, +}); diff --git a/crm/fcrm/doctype/crm_holiday_list/crm_holiday_list.json b/crm/fcrm/doctype/crm_holiday_list/crm_holiday_list.json new file mode 100644 index 0000000..bb95d6b --- /dev/null +++ b/crm/fcrm/doctype/crm_holiday_list/crm_holiday_list.json @@ -0,0 +1,138 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:holiday_list_name", + "creation": "2023-12-14 11:09:12.876640", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "holiday_list_name", + "from_date", + "to_date", + "column_break_qwqc", + "total_holidays", + "add_weekly_holidays_section", + "weekly_off", + "add_to_holidays", + "holidays_section", + "holidays", + "clear_table" + ], + "fields": [ + { + "fieldname": "holiday_list_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Holiday List Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "from_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "From Date", + "reqd": 1 + }, + { + "fieldname": "to_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "To Date", + "reqd": 1 + }, + { + "fieldname": "column_break_qwqc", + "fieldtype": "Column Break" + }, + { + "fieldname": "total_holidays", + "fieldtype": "Int", + "label": "Total Holidays" + }, + { + "fieldname": "add_weekly_holidays_section", + "fieldtype": "Section Break", + "label": "Add Weekly Holidays" + }, + { + "fieldname": "weekly_off", + "fieldtype": "Select", + "label": "Weekly Off", + "options": "\nMonday\nTuesday\nWednesday\nThursday\nFriday\nSaturday\nSunday" + }, + { + "fieldname": "add_to_holidays", + "fieldtype": "Button", + "label": "Add to Holidays", + "options": "add_to_holidays" + }, + { + "fieldname": "holidays_section", + "fieldtype": "Section Break", + "label": "Holidays" + }, + { + "fieldname": "clear_table", + "fieldtype": "Button", + "label": "Clear Table", + "options": "clear_table" + }, + { + "fieldname": "holidays", + "fieldtype": "Table", + "label": "Holidays", + "options": "CRM Holiday" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-02-23 12:28:17.390649", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Holiday List", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_holiday_list/crm_holiday_list.py b/crm/fcrm/doctype/crm_holiday_list/crm_holiday_list.py new file mode 100644 index 0000000..a139bd0 --- /dev/null +++ b/crm/fcrm/doctype/crm_holiday_list/crm_holiday_list.py @@ -0,0 +1,87 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + + +import frappe +from frappe import _, throw +from frappe.model.document import Document +from frappe.utils import cint, formatdate, getdate + + +class CRMHolidayList(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + from crm.fcrm.doctype.crm_holiday.crm_holiday import CRMHoliday + + from_date: DF.Date + holiday_list_name: DF.Data + holidays: DF.Table[CRMHoliday] + to_date: DF.Date + total_holidays: DF.Int + weekly_off: DF.Literal[ + "", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" + ] + # end: auto-generated types + + def validate(self): + self.validate_days() + self.total_holidays = len(self.holidays) + + @frappe.whitelist() + def get_weekly_off_dates(self): + self.validate_values() + date_list = self.get_weekly_off_date_list(self.from_date, self.to_date) + last_idx = max( + [cint(d.idx) for d in self.get("holidays")] + or [ + 0, + ] + ) + for i, d in enumerate(date_list): + ch = self.append("holidays", {}) + ch.description = _(self.weekly_off) + ch.date = d + ch.weekly_off = 1 + ch.idx = last_idx + i + 1 + + def validate_values(self): + if not self.weekly_off: + throw(_("Please select weekly off day")) + + def validate_days(self): + if getdate(self.from_date) > getdate(self.to_date): + throw(_("To Date cannot be before From Date")) + + for day in self.get("holidays"): + if not (getdate(self.from_date) <= getdate(day.date) <= getdate(self.to_date)): + frappe.throw( + _("The holiday on {0} is not between From Date and To Date").format(formatdate(day.date)) + ) + + def get_weekly_off_date_list(self, start_date, end_date): + start_date, end_date = getdate(start_date), getdate(end_date) + + import calendar + from datetime import timedelta + + from dateutil import relativedelta + + date_list = [] + existing_date_list = [] + weekday = getattr(calendar, (self.weekly_off).upper()) + reference_date = start_date + relativedelta.relativedelta(weekday=weekday) + + existing_date_list = [getdate(holiday.date) for holiday in self.get("holidays")] + + while reference_date <= end_date: + if reference_date not in existing_date_list: + date_list.append(reference_date) + reference_date += timedelta(days=7) + + return date_list diff --git a/crm/fcrm/doctype/crm_holiday_list/test_crm_holiday_list.py b/crm/fcrm/doctype/crm_holiday_list/test_crm_holiday_list.py new file mode 100644 index 0000000..ee90dc8 --- /dev/null +++ b/crm/fcrm/doctype/crm_holiday_list/test_crm_holiday_list.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import UnitTestCase + + +class TestCRMHolidayList(UnitTestCase): + pass diff --git a/crm/fcrm/doctype/crm_industry/__init__.py b/crm/fcrm/doctype/crm_industry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_industry/crm_industry.js b/crm/fcrm/doctype/crm_industry/crm_industry.js new file mode 100644 index 0000000..1489970 --- /dev/null +++ b/crm/fcrm/doctype/crm_industry/crm_industry.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Industry", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_industry/crm_industry.json b/crm/fcrm/doctype/crm_industry/crm_industry.json new file mode 100644 index 0000000..f328358 --- /dev/null +++ b/crm/fcrm/doctype/crm_industry/crm_industry.json @@ -0,0 +1,74 @@ +{ + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "autoname": "field:industry", + "creation": "2023-07-24 19:40:31.980882", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "industry" + ], + "fields": [ + { + "fieldname": "industry", + "fieldtype": "Data", + "label": "Industry", + "unique": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-03-05 12:44:24.510102", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Industry", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "translated_doctype": 1 +} diff --git a/crm/fcrm/doctype/crm_industry/crm_industry.py b/crm/fcrm/doctype/crm_industry/crm_industry.py new file mode 100644 index 0000000..f367db4 --- /dev/null +++ b/crm/fcrm/doctype/crm_industry/crm_industry.py @@ -0,0 +1,20 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMIndustry(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + industry: DF.Data | None + # end: auto-generated types + + pass diff --git a/crm/fcrm/doctype/crm_industry/test_crm_industry.py b/crm/fcrm/doctype/crm_industry/test_crm_industry.py new file mode 100644 index 0000000..16d87b1 --- /dev/null +++ b/crm/fcrm/doctype/crm_industry/test_crm_industry.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import UnitTestCase + + +class TestCRMIndustry(UnitTestCase): + pass diff --git a/crm/fcrm/doctype/crm_invitation/__init__.py b/crm/fcrm/doctype/crm_invitation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_invitation/crm_invitation.js b/crm/fcrm/doctype/crm_invitation/crm_invitation.js new file mode 100644 index 0000000..f30e469 --- /dev/null +++ b/crm/fcrm/doctype/crm_invitation/crm_invitation.js @@ -0,0 +1,12 @@ +// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("CRM Invitation", { + refresh(frm) { + if (frm.doc.status != "Accepted") { + frm.add_custom_button(__("Accept Invitation"), () => { + return frm.call("accept_invitation"); + }); + } + }, +}); diff --git a/crm/fcrm/doctype/crm_invitation/crm_invitation.json b/crm/fcrm/doctype/crm_invitation/crm_invitation.json new file mode 100644 index 0000000..9c9558e --- /dev/null +++ b/crm/fcrm/doctype/crm_invitation/crm_invitation.json @@ -0,0 +1,113 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-09-03 12:19:18.933810", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "email", + "role", + "key", + "invited_by", + "column_break_dsuz", + "status", + "email_sent_at", + "accepted_at" + ], + "fields": [ + { + "fieldname": "email", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Email", + "reqd": 1 + }, + { + "fieldname": "role", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Role", + "options": "\nSales User\nSales Manager\nSystem Manager", + "reqd": 1 + }, + { + "fieldname": "key", + "fieldtype": "Data", + "label": "Key" + }, + { + "fieldname": "invited_by", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Invited By", + "options": "User" + }, + { + "fieldname": "column_break_dsuz", + "fieldtype": "Column Break" + }, + { + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "\nPending\nAccepted\nExpired" + }, + { + "fieldname": "email_sent_at", + "fieldtype": "Datetime", + "label": "Email Sent At" + }, + { + "fieldname": "accepted_at", + "fieldtype": "Datetime", + "label": "Accepted At" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-02-23 12:15:54.279434", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Invitation", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_invitation/crm_invitation.py b/crm/fcrm/doctype/crm_invitation/crm_invitation.py new file mode 100644 index 0000000..e841059 --- /dev/null +++ b/crm/fcrm/doctype/crm_invitation/crm_invitation.py @@ -0,0 +1,119 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + + +class CRMInvitation(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + accepted_at: DF.Datetime | None + email: DF.Data + email_sent_at: DF.Datetime | None + invited_by: DF.Link | None + key: DF.Data | None + role: DF.Literal["", "Sales User", "Sales Manager", "System Manager"] + status: DF.Literal["", "Pending", "Accepted", "Expired"] + # end: auto-generated types + + def before_insert(self): + frappe.utils.validate_email_address(self.email, True) + + self.key = frappe.generate_hash(length=12) + self.invited_by = frappe.session.user + self.status = "Pending" + + def after_insert(self): + self.invite_via_email() + + def invite_via_email(self): + invite_link = frappe.utils.get_url(f"/api/method/crm.api.accept_invitation?key={self.key}") + if frappe.local.dev_server: + print(f"Invite link for {self.email}: {invite_link}") # nosemgrep + + title = "Frappe CRM" + template = "crm_invitation" + + frappe.sendmail( + recipients=self.email, + subject=f"You have been invited to join {title}", + template=template, + args={"title": title, "invite_link": invite_link}, + now=True, + ) + self.db_set("email_sent_at", frappe.utils.now()) + + @frappe.whitelist() + def accept_invitation(self): + frappe.only_for(["System Manager", "Sales Manager"], True) + if self.accept(): + # the invitee was not around to set a password, mail them a link to do it + frappe.get_doc("User", self.email).send_welcome_mail_to_user() + + def accept(self): + if self.status != "Pending": + frappe.throw(_("Invalid or expired key")) + + user, is_new_user = self.create_user_if_not_exists() + user.append_roles(self.role) + if self.role == "System Manager": + user.append_roles("Sales Manager", "Sales User") + elif self.role == "Sales Manager": + user.append_roles("Sales User") + if self.role == "Sales User": + self.update_module_in_user(user, "FCRM") + user.save(ignore_permissions=True) + + self.status = "Accepted" + self.accepted_at = frappe.utils.now() + self.key = None + self.save(ignore_permissions=True) + + return is_new_user + + def update_module_in_user(self, user, module): + block_modules = frappe.get_all( + "Module Def", + fields=["name as module"], + filters={"name": ["!=", module]}, + ) + + if block_modules: + user.set("block_modules", block_modules) + + def create_user_if_not_exists(self): + if not frappe.db.exists("User", self.email): + first_name = self.email.split("@")[0].title() + user = frappe.get_doc( + doctype="User", + user_type="System User", + email=self.email, + send_welcome_email=0, + first_name=first_name, + default_app="crm", + ).insert(ignore_permissions=True) + return user, True + + return frappe.get_doc("User", self.email), False + + +def expire_invitations(): + """expire invitations after 3 days""" + from frappe.utils import add_days, now + + days = 3 + invitations_to_expire = frappe.db.get_all( + "CRM Invitation", filters={"status": "Pending", "creation": ["<", add_days(now(), -days)]} + ) + for invitation in invitations_to_expire: + invitation = frappe.get_doc("CRM Invitation", invitation.name) + invitation.status = "Expired" + invitation.save(ignore_permissions=True) diff --git a/crm/fcrm/doctype/crm_invitation/test_crm_invitation.py b/crm/fcrm/doctype/crm_invitation/test_crm_invitation.py new file mode 100644 index 0000000..11e46ac --- /dev/null +++ b/crm/fcrm/doctype/crm_invitation/test_crm_invitation.py @@ -0,0 +1,153 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +from unittest.mock import patch + +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import add_days, now + + +class TestCRMInvitation(FrappeTestCase): + def make_invitation(self, email="invitee@example.com", role="Sales User"): + """Create a Pending invitation without actually sending an email.""" + with patch.object(frappe, "sendmail"): + return frappe.get_doc( + doctype="CRM Invitation", + email=email, + role=role, + ).insert(ignore_permissions=True) + + def test_new_invitation_is_pending_with_key(self): + invitation = self.make_invitation() + self.assertEqual(invitation.status, "Pending") + self.assertTrue(invitation.key) + self.assertEqual(invitation.invited_by, frappe.session.user) + + def test_accept_pending_invitation(self): + invitation = self.make_invitation() + + invitation.accept() + + self.assertEqual(invitation.status, "Accepted") + self.assertTrue(invitation.accepted_at) + self.assertTrue(frappe.db.exists("User", invitation.email)) + + def test_accept_clears_key(self): + """The key is wiped after acceptance so the invite link cannot be reused.""" + invitation = self.make_invitation() + + invitation.accept() + + self.assertIsNone(invitation.key) + self.assertFalse(frappe.db.get_value("CRM Invitation", invitation.name, "key")) + + def test_accept_already_accepted_raises(self): + """An already-accepted invitation cannot be accepted again.""" + invitation = self.make_invitation() + invitation.accept() + + invitation.reload() + with self.assertRaises(frappe.ValidationError): + invitation.accept() + + def test_accept_expired_invitation_raises(self): + invitation = self.make_invitation() + invitation.status = "Expired" + invitation.save(ignore_permissions=True) + + with self.assertRaises(frappe.ValidationError): + invitation.accept() + + def test_accept_reports_newly_created_user(self): + invitation = self.make_invitation(email="brand-new@example.com") + + self.assertTrue(invitation.accept()) + self.assertEqual(frappe.db.get_value("User", invitation.email, "default_app"), "crm") + + def test_accept_reports_existing_user(self): + frappe.get_doc( + doctype="User", + user_type="System User", + email="already-there@example.com", + send_welcome_email=0, + first_name="Already There", + ).insert(ignore_permissions=True) + invitation = self.make_invitation(email="already-there@example.com") + + self.assertFalse(invitation.accept()) + + def test_accept_invitation_sends_new_user_to_set_password(self): + """A new user must set a password instead of being logged in directly.""" + from crm.api import accept_invitation + + invitation = self.make_invitation(email="new-invitee@example.com") + + accept_invitation(key=invitation.key) + + self.assertEqual(frappe.local.response["type"], "redirect") + self.assertIn("/update-password?key=", frappe.local.response["location"]) + + def test_accept_invitation_logs_in_existing_user(self): + """An existing user already has a password, so log them straight in.""" + from crm.api import accept_invitation + + frappe.get_doc( + doctype="User", + user_type="System User", + email="existing-invitee@example.com", + send_welcome_email=0, + first_name="Existing Invitee", + ).insert(ignore_permissions=True) + invitation = self.make_invitation(email="existing-invitee@example.com") + + with patch.object(frappe.local, "login_manager", create=True) as login_manager: + accept_invitation(key=invitation.key) + + login_manager.login_as.assert_called_once_with("existing-invitee@example.com") + self.assertEqual(frappe.local.response["location"], "/crm") + + def test_desk_accept_mails_new_user_a_set_password_link(self): + invitation = self.make_invitation(email="desk-invitee@example.com") + + with patch("frappe.core.doctype.user.user.User.send_welcome_mail_to_user") as welcome_mail: + invitation.accept_invitation() + + welcome_mail.assert_called_once() + + def test_desk_accept_does_not_mail_existing_user(self): + frappe.get_doc( + doctype="User", + user_type="System User", + email="desk-existing@example.com", + send_welcome_email=0, + first_name="Desk Existing", + ).insert(ignore_permissions=True) + invitation = self.make_invitation(email="desk-existing@example.com") + + with patch("frappe.core.doctype.user.user.User.send_welcome_mail_to_user") as welcome_mail: + invitation.accept_invitation() + + welcome_mail.assert_not_called() + + def test_expire_invitations_expires_old_pending_invites(self): + from crm.fcrm.doctype.crm_invitation.crm_invitation import expire_invitations + + invitation = self.make_invitation(email="stale@example.com") + frappe.db.set_value( + "CRM Invitation", invitation.name, "creation", add_days(now(), -4), update_modified=False + ) + + expire_invitations() + + self.assertEqual(frappe.db.get_value("CRM Invitation", invitation.name, "status"), "Expired") + + def test_accept_grants_role_to_user(self): + invitation = self.make_invitation(email="manager@example.com", role="Sales Manager") + + invitation.accept() + + user = frappe.get_doc("User", invitation.email) + user_roles = {r.role for r in user.roles} + self.assertIn("Sales Manager", user_roles) + self.assertIn("Sales User", user_roles) diff --git a/crm/fcrm/doctype/crm_lead/__init__.py b/crm/fcrm/doctype/crm_lead/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_lead/crm_lead.js b/crm/fcrm/doctype/crm_lead/crm_lead.js new file mode 100644 index 0000000..4e5fb0b --- /dev/null +++ b/crm/fcrm/doctype/crm_lead/crm_lead.js @@ -0,0 +1,67 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("CRM Lead", { + refresh(frm) { + frm.add_web_link(`/crm/leads/${frm.doc.name}`, __("Open in Portal")); + }, + update_total: function (frm) { + let total = 0; + let total_qty = 0; + let net_total = 0; + frm.doc.products.forEach((d) => { + total += d.amount; + total_qty += d.qty; + net_total += d.net_amount; + }); + + frappe.model.set_value(frm.doctype, frm.docname, "total", total); + frappe.model.set_value( + frm.doctype, + frm.docname, + "net_total", + net_total || total, + ); + }, +}); + +frappe.ui.form.on("CRM Products", { + products_add: function (frm, cdt, cdn) { + frm.trigger("update_total"); + }, + products_remove: function (frm, cdt, cdn) { + frm.trigger("update_total"); + }, + product_code: function (frm, cdt, cdn) { + let d = frappe.get_doc(cdt, cdn); + frappe.model.set_value(cdt, cdn, "product_name", d.product_code); + }, + rate: function (frm, cdt, cdn) { + let d = frappe.get_doc(cdt, cdn); + if (d.rate && d.qty) { + frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty); + } + frm.trigger("update_total"); + }, + qty: function (frm, cdt, cdn) { + let d = frappe.get_doc(cdt, cdn); + if (d.rate && d.qty) { + frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty); + } + frm.trigger("update_total"); + }, + discount_percentage: function (frm, cdt, cdn) { + let d = frappe.get_doc(cdt, cdn); + if (d.discount_percentage && d.amount) { + discount_amount = (d.discount_percentage / 100) * d.amount; + frappe.model.set_value(cdt, cdn, "discount_amount", discount_amount); + frappe.model.set_value( + cdt, + cdn, + "net_amount", + d.amount - discount_amount, + ); + } + frm.trigger("update_total"); + }, +}); diff --git a/crm/fcrm/doctype/crm_lead/crm_lead.json b/crm/fcrm/doctype/crm_lead/crm_lead.json new file mode 100644 index 0000000..ef38f31 --- /dev/null +++ b/crm/fcrm/doctype/crm_lead/crm_lead.json @@ -0,0 +1,525 @@ +{ + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "autoname": "naming_series:", + "creation": "2023-07-24 12:19:39.616298", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "email_append_to": 1, + "engine": "InnoDB", + "field_order": [ + "person_tab", + "salutation", + "first_name", + "last_name", + "column_break_opsm", + "lead_name", + "email", + "mobile_no", + "details", + "organization", + "website", + "organization_logo", + "company_description", + "linkedin", + "twitter", + "facebook", + "territory", + "industry", + "job_title", + "source", + "lead_owner", + "organization_tab", + "section_break_uixv", + "naming_series", + "middle_name", + "gender", + "phone", + "column_break_dbsv", + "status", + "no_of_employees", + "annual_revenue", + "image", + "converted", + "products_tab", + "products", + "section_break_ggwh", + "total", + "column_break_uisv", + "net_total", + "sla_tab", + "sla", + "sla_creation", + "column_break_ffnp", + "sla_status", + "communication_status", + "response_details_section", + "response_by", + "column_break_pweh", + "first_response_time", + "first_responded_on", + "section_break_xnpz", + "rolling_responses", + "section_break_kikl", + "column_break_ygds", + "last_response_time", + "column_break_tcqb", + "last_responded_on", + "log_tab", + "status_change_log", + "syncing_tab", + "facebook_lead_id", + "column_break_ixmu", + "facebook_form_id", + "lost_details_tab", + "lost_reason", + "lost_notes" + ], + "fields": [ + { + "default": "CRM-LEAD-.YYYY.-", + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Series", + "options": "CRM-LEAD-.YYYY.-" + }, + { + "fieldname": "salutation", + "fieldtype": "Link", + "label": "Salutation", + "options": "Salutation" + }, + { + "fieldname": "first_name", + "fieldtype": "Data", + "label": "First Name", + "reqd": 1 + }, + { + "fieldname": "middle_name", + "fieldtype": "Data", + "label": "Middle Name" + }, + { + "fieldname": "last_name", + "fieldtype": "Data", + "label": "Last Name" + }, + { + "fieldname": "gender", + "fieldtype": "Link", + "label": "Gender", + "options": "Gender" + }, + { + "fieldname": "status", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "CRM Lead Status", + "reqd": 1, + "search_index": 1 + }, + { + "fieldname": "email", + "fieldtype": "Data", + "in_standard_filter": 1, + "label": "Email", + "options": "Email", + "search_index": 1 + }, + { + "fieldname": "website", + "fieldtype": "Data", + "label": "Website" + }, + { + "fieldname": "mobile_no", + "fieldtype": "Data", + "label": "Mobile No.", + "options": "Phone" + }, + { + "fieldname": "phone", + "fieldtype": "Data", + "label": "Phone", + "options": "Phone" + }, + { + "fieldname": "section_break_uixv", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_dbsv", + "fieldtype": "Column Break" + }, + { + "fieldname": "no_of_employees", + "fieldtype": "Select", + "label": "No. of Employees", + "options": "1-10\n11-50\n51-200\n201-500\n501-1000\n1000+" + }, + { + "fieldname": "annual_revenue", + "fieldtype": "Currency", + "label": "Annual Revenue", + "non_negative": 1 + }, + { + "fieldname": "lead_owner", + "fieldtype": "Link", + "label": "Lead Owner", + "options": "User" + }, + { + "fieldname": "source", + "fieldtype": "Link", + "label": "Source", + "options": "CRM Lead Source" + }, + { + "fieldname": "industry", + "fieldtype": "Link", + "label": "Industry", + "options": "CRM Industry" + }, + { + "fieldname": "image", + "fieldtype": "Attach Image", + "hidden": 1, + "label": "Image", + "print_hide": 1 + }, + { + "fieldname": "lead_name", + "fieldtype": "Data", + "in_standard_filter": 1, + "label": "Full Name", + "search_index": 1 + }, + { + "fieldname": "job_title", + "fieldtype": "Data", + "label": "Job Title" + }, + { + "fieldname": "organization_tab", + "fieldtype": "Tab Break", + "label": "Others", + "read_only": 1 + }, + { + "fieldname": "organization", + "fieldtype": "Data", + "in_standard_filter": 1, + "label": "Organization" + }, + { + "default": "0", + "fieldname": "converted", + "fieldtype": "Check", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Converted" + }, + { + "fieldname": "person_tab", + "fieldtype": "Tab Break", + "label": "Person" + }, + { + "fieldname": "details", + "fieldtype": "Tab Break", + "label": "Details" + }, + { + "fieldname": "sla_tab", + "fieldtype": "Tab Break", + "label": "SLA", + "read_only": 1 + }, + { + "fieldname": "sla", + "fieldtype": "Link", + "label": "SLA", + "options": "CRM Service Level Agreement" + }, + { + "fieldname": "sla_creation", + "fieldtype": "Datetime", + "label": "SLA Creation", + "read_only": 1 + }, + { + "fieldname": "column_break_ffnp", + "fieldtype": "Column Break" + }, + { + "fieldname": "sla_status", + "fieldtype": "Select", + "label": "SLA Status", + "options": "\nFirst Response Due\nRolling Response Due\nFailed\nFulfilled", + "read_only": 1 + }, + { + "fieldname": "response_details_section", + "fieldtype": "Section Break", + "label": "Response Details" + }, + { + "fieldname": "response_by", + "fieldtype": "Datetime", + "label": "Response By", + "read_only": 1 + }, + { + "fieldname": "column_break_pweh", + "fieldtype": "Column Break" + }, + { + "fieldname": "first_response_time", + "fieldtype": "Duration", + "label": "First Response Time", + "read_only": 1 + }, + { + "fieldname": "first_responded_on", + "fieldtype": "Datetime", + "label": "First Responded On", + "read_only": 1 + }, + { + "default": "Open", + "fieldname": "communication_status", + "fieldtype": "Link", + "label": "Communication Status", + "options": "CRM Communication Status" + }, + { + "fieldname": "territory", + "fieldtype": "Link", + "label": "Territory", + "options": "CRM Territory" + }, + { + "fieldname": "log_tab", + "fieldtype": "Tab Break", + "label": "Log", + "read_only": 1 + }, + { + "fieldname": "status_change_log", + "fieldtype": "Table", + "label": "Status Change Log", + "options": "CRM Status Change Log" + }, + { + "fieldname": "products_tab", + "fieldtype": "Tab Break", + "label": "Products" + }, + { + "fieldname": "products", + "fieldtype": "Table", + "label": "Products", + "options": "CRM Products" + }, + { + "fieldname": "section_break_ggwh", + "fieldtype": "Section Break" + }, + { + "fieldname": "total", + "fieldtype": "Currency", + "label": "Total", + "non_negative": 1, + "options": "currency", + "read_only": 1 + }, + { + "fieldname": "column_break_uisv", + "fieldtype": "Column Break" + }, + { + "description": "Total after discount", + "fieldname": "net_total", + "fieldtype": "Currency", + "label": "Net Total", + "non_negative": 1, + "options": "currency", + "read_only": 1 + }, + { + "fieldname": "syncing_tab", + "fieldtype": "Tab Break", + "label": "Syncing" + }, + { + "fieldname": "facebook_lead_id", + "fieldtype": "Data", + "label": "Facebook Lead ID", + "unique": 1 + }, + { + "fieldname": "column_break_ixmu", + "fieldtype": "Column Break" + }, + { + "fieldname": "facebook_form_id", + "fieldtype": "Data", + "label": "Facebook Form ID" + }, + { + "fieldname": "section_break_kikl", + "fieldtype": "Section Break" + }, + { + "fieldname": "rolling_responses", + "fieldtype": "Table", + "label": "Rolling Responses", + "options": "CRM Rolling Response Time" + }, + { + "fieldname": "section_break_xnpz", + "fieldtype": "Section Break" + }, + { + "fieldname": "last_response_time", + "fieldtype": "Duration", + "label": "Last Response Time", + "read_only": 1 + }, + { + "fieldname": "column_break_tcqb", + "fieldtype": "Column Break" + }, + { + "fieldname": "last_responded_on", + "fieldtype": "Datetime", + "label": "Last Responded On", + "read_only": 1 + }, + { + "fieldname": "column_break_ygds", + "fieldtype": "Column Break", + "read_only": 1 + }, + { + "fieldname": "lost_details_tab", + "fieldtype": "Tab Break", + "label": "Lost Details" + }, + { + "fieldname": "lost_reason", + "fieldtype": "Link", + "label": "Lost Reason", + "options": "CRM Lost Reason" + }, + { + "fieldname": "lost_notes", + "fieldtype": "Text", + "label": "Lost Notes", + "mandatory_depends_on": "eval: doc.lost_reason == \"Other\"" + }, + { + "fieldname": "column_break_opsm", + "fieldtype": "Column Break" + }, + { + "fieldname": "organization_logo", + "fieldtype": "Attach Image", + "label": "Organization Logo", + "no_copy": 1, + "description": "Company logo discovered by Domain Enrichment." + }, + { + "fieldname": "company_description", + "fieldtype": "Small Text", + "label": "Company Description", + "no_copy": 1, + "description": "Company description discovered by Domain Enrichment." + }, + { + "depends_on": "eval:doc.linkedin", + "fieldname": "linkedin", + "fieldtype": "Data", + "label": "LinkedIn", + "no_copy": 1 + }, + { + "depends_on": "eval:doc.twitter", + "fieldname": "twitter", + "fieldtype": "Data", + "label": "X (Twitter)", + "no_copy": 1 + }, + { + "depends_on": "eval:doc.facebook", + "fieldname": "facebook", + "fieldtype": "Data", + "label": "Facebook", + "no_copy": 1 + } + ], + "grid_page_length": 50, + "image_field": "image", + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-04-04 12:33:17.177897", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Lead", + "naming_rule": "By \"Naming Series\" field", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sender_field": "email", + "sender_name_field": "first_name", + "show_title_field_in_link": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "lead_name", + "track_changes": 1, + "track_seen": 1 +} diff --git a/crm/fcrm/doctype/crm_lead/crm_lead.py b/crm/fcrm/doctype/crm_lead/crm_lead.py new file mode 100644 index 0000000..675486f --- /dev/null +++ b/crm/fcrm/doctype/crm_lead/crm_lead.py @@ -0,0 +1,582 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import json + +import frappe +from frappe import _ +from frappe.desk.form.assign_to import _add as assign +from frappe.model.document import Document +from frappe.utils import validate_email_address + +from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla +from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import ( + add_status_change_log, +) +from crm.fcrm.doctype.utils import add_or_remove_lost_reason_section_in_sidepanel + +LEAD_DEAL_FIELD_MAP = {"lead_owner": "deal_owner"} + + +class CRMLead(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + from crm.fcrm.doctype.crm_products.crm_products import CRMProducts + from crm.fcrm.doctype.crm_rolling_response_time.crm_rolling_response_time import ( + CRMRollingResponseTime, + ) + from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import CRMStatusChangeLog + + annual_revenue: DF.Currency + communication_status: DF.Link | None + converted: DF.Check + email: DF.Data | None + facebook_form_id: DF.Data | None + facebook_lead_id: DF.Data | None + first_name: DF.Data + first_responded_on: DF.Datetime | None + first_response_time: DF.Duration | None + gender: DF.Link | None + image: DF.AttachImage | None + industry: DF.Link | None + job_title: DF.Data | None + last_name: DF.Data | None + last_responded_on: DF.Datetime | None + last_response_time: DF.Duration | None + lead_name: DF.Data | None + lead_owner: DF.Link | None + lost_notes: DF.Text | None + lost_reason: DF.Link | None + middle_name: DF.Data | None + mobile_no: DF.Data | None + naming_series: DF.Literal["CRM-LEAD-.YYYY.-"] + net_total: DF.Currency + no_of_employees: DF.Literal["1-10", "11-50", "51-200", "201-500", "501-1000", "1000+"] + organization: DF.Data | None + phone: DF.Data | None + products: DF.Table[CRMProducts] + response_by: DF.Datetime | None + rolling_responses: DF.Table[CRMRollingResponseTime] + salutation: DF.Link | None + sla: DF.Link | None + sla_creation: DF.Datetime | None + sla_status: DF.Literal["", "First Response Due", "Rolling Response Due", "Failed", "Fulfilled"] + source: DF.Link | None + status: DF.Link + status_change_log: DF.Table[CRMStatusChangeLog] + territory: DF.Link | None + total: DF.Currency + website: DF.Data | None + # end: auto-generated types + + def before_insert(self): + # apply CRM enrichment (source/contact) when created via a web form + from crm.api.form import enrich_form_submission + + enrich_form_submission(self) + + def before_validate(self): + self.set_sla() + + def validate(self): + self.validate_status() + self.set_full_name() + self.set_lead_name() + self.set_title() + self.validate_email() + self.validate_lost_reason() + if not self.is_new() and self.has_value_changed("lead_owner") and self.lead_owner: + self.share_with_agent(self.lead_owner) + self.assign_agent(self.lead_owner) + if self.has_value_changed("status"): + add_status_change_log(self) + + def after_insert(self): + if self.lead_owner: + if self.lead_owner != frappe.session.user: + self.share_with_agent(self.lead_owner) + self.assign_agent(self.lead_owner) + + # Auto-enrich a new Lead from its website (best-effort, background job). + from crm.domain_enrichment.tasks import auto_enrich_on_create + + auto_enrich_on_create(self) + + def before_save(self): + self.apply_sla() + + def validate_status(self): + if self.is_new() and not self.status: + if frappe.db.exists("CRM Lead Status", "New"): + self.status = "New" + else: + self.status = frappe.get_all("CRM Lead Status", {"type": "Open"}, pluck="name")[0] + + def set_full_name(self): + if self.first_name: + self.lead_name = " ".join( + name + for name in [ + self.salutation, + self.first_name, + self.middle_name, + self.last_name, + ] + if name + ) + + def set_lead_name(self): + if not self.lead_name: + # Check for leads being created through data import + if not self.organization and not self.email and not self.flags.ignore_mandatory: + frappe.throw(_("A Lead requires either a person's name or an organization's name")) + elif self.organization: + self.lead_name = self.organization + elif self.email: + self.lead_name = self.email.split("@")[0] + else: + self.lead_name = "Unnamed Lead" + + def set_title(self): + self.title = self.organization or self.lead_name + + def validate_email(self): + if self.email: + if not self.flags.ignore_email_validation: + validate_email_address(self.email, throw=True) + + if self.email == self.lead_owner: + frappe.throw(_("Lead Owner cannot be same as the Lead Email Address")) + + def validate_lost_reason(self): + """ + Validate the lost reason if the status is set to "Lost". + """ + if self.status and frappe.get_cached_value("CRM Lead Status", self.status, "type") == "Lost": + if not self.lost_reason: + frappe.throw(_("Please specify a reason for losing the lead."), frappe.ValidationError) + elif self.lost_reason == "Other" and not self.lost_notes: + frappe.throw(_("Please specify the reason for losing the lead."), frappe.ValidationError) + if self.has_value_changed("status"): + add_or_remove_lost_reason_section_in_sidepanel(self) + + def assign_agent(self, agent): + if not agent: + return + + assignees = self.get_assigned_users() + if assignees: + for assignee in assignees: + if agent == assignee: + # the agent is already set as an assignee + return + + assign({"assign_to": [agent], "doctype": "CRM Lead", "name": self.name}, ignore_permissions=True) + + def share_with_agent(self, agent): + if not agent: + return + + docshares = frappe.get_all( + "DocShare", + filters={"share_name": self.name, "share_doctype": self.doctype}, + fields=["name", "user"], + ) + + shared_with = [d.user for d in docshares] + [agent] + + for user in shared_with: + if user == agent and not frappe.db.exists( + "DocShare", + {"user": agent, "share_name": self.name, "share_doctype": self.doctype}, + ): + frappe.share.add_docshare( + self.doctype, + self.name, + agent, + write=1, + flags={"ignore_share_permission": True}, + ) + elif user != agent: + frappe.share.remove( + self.doctype, + self.name, + user, + flags={"ignore_share_permission": True, "ignore_permissions": True}, + ) + + def create_contact(self, existing_contact=None, throw=True): + if not self.lead_name: + self.set_full_name() + self.set_lead_name() + + existing_contact = existing_contact or self.contact_exists(throw) + if existing_contact: + self.update_lead_contact(existing_contact) + return existing_contact + + contact = frappe.new_doc("Contact") + contact.update( + { + "first_name": self.first_name or self.lead_name, + "last_name": self.last_name, + "salutation": self.salutation, + "gender": self.gender, + "designation": self.job_title, + "company_name": self.organization, + "image": self.image or "", + } + ) + + if self.email: + contact.append("email_ids", {"email_id": self.email, "is_primary": 1}) + + if self.phone: + contact.append("phone_nos", {"phone": self.phone, "is_primary_phone": 1}) + + if self.mobile_no: + contact.append("phone_nos", {"phone": self.mobile_no, "is_primary_mobile_no": 1}) + + contact.insert(ignore_permissions=True) + contact.reload() # load changes by hooks on contact + + return contact.name + + def create_organization(self, existing_organization=None): + if not self.organization and not existing_organization: + return + + existing_organization = existing_organization or frappe.db.exists( + "CRM Organization", {"organization_name": self.organization} + ) + if existing_organization: + self.db_set("organization", existing_organization) + self.copy_enrichment_from_organization() + return existing_organization + + organization = frappe.new_doc("CRM Organization") + organization.update( + { + "organization_name": self.organization, + "website": self.website, + "territory": self.territory, + "industry": self.industry, + "annual_revenue": self.annual_revenue, + "no_of_employees": self.no_of_employees, + } + ) + organization.insert(ignore_permissions=True) + return organization.name + + def copy_enrichment_from_organization(self): + """Fill-empty copy of a linked enriched Organization's fields onto this Lead. + + Called when an existing (already-enriched) CRM Organization is linked. The + shared helper mutates ``self`` in place (fill-empty, user data preserved); + any filled native fields are then persisted with ``db_set`` to match this + branch's already-saved flow. Best-effort -- never blocks the Lead save. + """ + from crm.domain_enrichment.cross_record import copy_enrichment_from_organization + + enriched_fields = ( + "organization_logo", + "company_description", + "industry", + "linkedin", + "twitter", + "facebook", + ) + before = {f: self.get(f) for f in enriched_fields if self.meta.has_field(f)} + + filled = copy_enrichment_from_organization(self) + + for fieldname, old_value in before.items(): + new_value = self.get(fieldname) + if new_value != old_value: + self.db_set(fieldname, new_value) + return filled + + def update_lead_contact(self, contact): + contact = frappe.get_cached_doc("Contact", contact) + frappe.db.set_value( + "CRM Lead", + self.name, + { + "salutation": contact.salutation, + "first_name": contact.first_name, + "last_name": contact.last_name, + "email": contact.email_id, + "mobile_no": contact.mobile_no, + }, + ) + + def contact_exists(self, throw=True): + # Match only on email which uniquely identifies a person + if not self.email: + return False + + email_exist = frappe.db.exists("Contact Email", {"email_id": self.email}) + if not email_exist: + return False + + contact = frappe.db.get_value("Contact Email", email_exist, "parent") + + if throw: + frappe.throw( + _("Contact already exists with Email: {0}").format(self.email), + title=_("Contact Already Exists"), + ) + + return contact + + def create_deal(self, contact, organization, deal=None): + new_deal = frappe.new_doc("CRM Deal") + + restricted_fieldtypes = [ + "Tab Break", + "Section Break", + "Column Break", + "HTML", + "Button", + "Attach", + ] + restricted_map_fields = [ + "name", + "naming_series", + "creation", + "owner", + "modified", + "modified_by", + "idx", + "docstatus", + "status", + "email", + "mobile_no", + "phone", + "sla", + "sla_status", + "response_by", + "first_response_time", + "first_responded_on", + "communication_status", + "sla_creation", + "status_change_log", + ] + + for field in self.meta.fields: + if field.fieldtype in restricted_fieldtypes: + continue + if field.fieldname in restricted_map_fields: + continue + + fieldname = get_deal_fieldname(field, new_deal.meta) + + if fieldname: + if fieldname == "organization": + new_deal.update({fieldname: organization}) + else: + new_deal.update({fieldname: self.get(field.fieldname)}) + + new_deal.update( + { + "lead": self.name, + "contacts": [{"contact": contact}], + } + ) + + if self.first_responded_on: + new_deal.update( + { + "sla_creation": self.sla_creation, + "response_by": self.response_by, + "sla_status": self.sla_status, + "communication_status": self.communication_status, + "first_response_time": self.first_response_time, + "first_responded_on": self.first_responded_on, + } + ) + + if deal: + new_deal.update(deal) + + new_deal.insert(ignore_permissions=True) + + for user in self.get_assigned_users(): + if user and user != new_deal.deal_owner: + new_deal.assign_agent(user) + + return new_deal.name + + def set_sla(self): + """ + Find an SLA to apply to the lead. + """ + if self.sla: + return + + sla = get_sla(self) + if not sla: + self.first_responded_on = None + self.first_response_time = None + return + self.sla = sla.name + + def apply_sla(self): + """ + Apply SLA if set. + """ + if not self.sla: + return + sla = frappe.get_last_doc("CRM Service Level Agreement", {"name": self.sla}) + if sla: + sla.apply(self) + + def convert_to_deal(self, deal=None): + return convert_to_deal(lead=self.name, doc=self, deal=deal) + + @staticmethod + def get_non_filterable_fields(): + return ["converted"] + + @staticmethod + def default_list_data(): + columns = [ + { + "label": "Full Name", + "type": "Data", + "key": "lead_name", + "width": "12rem", + }, + { + "label": "Organization", + "type": "Link", + "key": "organization", + "options": "CRM Organization", + "width": "10rem", + }, + { + "label": "Status", + "type": "Link", + "options": "CRM Lead Status", + "key": "status", + "width": "8rem", + }, + { + "label": "Email", + "type": "Data", + "key": "email", + "width": "12rem", + }, + { + "label": "Mobile No.", + "type": "Data", + "key": "mobile_no", + "width": "11rem", + }, + { + "label": "Assigned To", + "type": "Text", + "key": "_assign", + "width": "10rem", + }, + { + "label": "Last Modified", + "type": "Datetime", + "key": "modified", + "width": "8rem", + }, + ] + rows = [ + "name", + "lead_name", + "organization", + "status", + "email", + "mobile_no", + "lead_owner", + "first_name", + "sla_status", + "response_by", + "first_response_time", + "first_responded_on", + "modified", + "_assign", + "image", + ] + return {"columns": columns, "rows": rows} + + @staticmethod + def default_kanban_settings(): + return { + "column_field": "status", + "title_field": "lead_name", + "kanban_fields": '["organization", "email", "mobile_no", "_assign", "modified"]', + } + + +@frappe.whitelist() +def convert_to_deal( + lead: str, + doc: Document | None = None, + deal: str | dict | None = None, + existing_contact: str | None = None, + existing_organization: str | None = None, +): + if not (doc and doc.flags.get("ignore_permissions")) and not frappe.has_permission( + "CRM Lead", "write", lead + ): + frappe.throw(_("Not allowed to convert Lead to Deal"), frappe.PermissionError) + + lead = frappe.get_cached_doc("CRM Lead", lead) + if frappe.get_cached_value("CRM Lead Status", lead.status, "type") == "Lost": + frappe.throw(_("Cannot convert a lead with status {0}").format(lead.status)) + if frappe.db.exists("CRM Lead Status", "Qualified"): + lead.db_set("status", "Qualified") + lead.db_set("converted", 1) + if lead.sla and frappe.db.exists("CRM Communication Status", "Replied"): + lead.db_set("communication_status", "Replied") + contact = lead.create_contact(existing_contact, False) + organization = lead.create_organization(existing_organization) + _deal = lead.create_deal(contact, organization, deal) + return _deal + + +def get_deal_fieldname(field, deal_meta): + mapped_fieldname = LEAD_DEAL_FIELD_MAP.get(field.fieldname) + if mapped_fieldname: + return mapped_fieldname if deal_meta.has_field(mapped_fieldname) else None + if deal_meta.has_field(field.fieldname): + return field.fieldname + if not is_custom_field(field): + return None + return get_matching_custom_deal_field(field, deal_meta) + + +def get_matching_custom_deal_field(field, deal_meta): + matches = [ + deal_field.fieldname for deal_field in deal_meta.fields if is_matching_custom_field(field, deal_field) + ] + return matches[0] if len(matches) == 1 else None + + +def is_matching_custom_field(lead_field, deal_field): + return ( + is_custom_field(deal_field) + and lead_field.label == deal_field.label + and lead_field.fieldtype == deal_field.fieldtype + ) + + +def is_custom_field(field): + return bool( + field.get("is_custom_field") + or field.get("custom") + or (field.fieldname or "").startswith("custom_") + or field.name == f"{field.parent}-{field.fieldname}" + ) diff --git a/crm/fcrm/doctype/crm_lead/test_crm_lead.py b/crm/fcrm/doctype/crm_lead/test_crm_lead.py new file mode 100644 index 0000000..3b8f07d --- /dev/null +++ b/crm/fcrm/doctype/crm_lead/test_crm_lead.py @@ -0,0 +1,632 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +from frappe.desk.form.assign_to import add as assign_add +from frappe.desk.form.assign_to import remove as assign_remove +from frappe.tests import IntegrationTestCase + +from crm.fcrm.doctype.crm_lead.crm_lead import convert_to_deal + + +class TestCRMLead(IntegrationTestCase): + @classmethod + def setUpClass(cls): + """Set up test records once for all tests""" + if not frappe.db.exists("Salutation", "Mr"): + frappe.get_doc({"doctype": "Salutation", "salutation": "Mr"}).insert(ignore_permissions=True) + frappe.db.commit() # nosemgrep + + super().setUpClass() + + @classmethod + def tearDownClass(cls): + """Clean up test records after all tests""" + frappe.db.rollback() + super().tearDownClass() + + def tearDown(self): + frappe.db.rollback() + + def test_lead_creation_with_first_name(self): + """Test creating a lead with first name""" + lead = create_lead( + first_name="John", + last_name="Doe", + email="john.doe@example.com", + mobile_no="+1234567890", + ) + + self.assertTrue(lead.name) + self.assertEqual(lead.first_name, "John") + self.assertEqual(lead.last_name, "Doe") + self.assertEqual(lead.email, "john.doe@example.com") + self.assertEqual(lead.lead_name, "John Doe") + + def test_lead_name_with_salutation(self): + """Test lead name generation with salutation""" + lead = create_lead( + salutation="Mr", + first_name="James", + middle_name="Robert", + last_name="Smith", + email="james.smith@example.com", + ) + + self.assertEqual(lead.lead_name, "Mr James Robert Smith") + self.assertEqual(lead.first_name, "James") + self.assertEqual(lead.middle_name, "Robert") + self.assertEqual(lead.last_name, "Smith") + + def test_invalid_email_validation(self): + """Test that invalid email raises validation error""" + with self.assertRaises(frappe.exceptions.ValidationError): + create_lead( + first_name="Invalid", + email="not-an-email", + ) + + def test_set_lead_name_scenarios(self): + """Test various scenarios for setting lead_name""" + # Test 1: lead_name from organization when no first_name + lead1 = frappe.get_doc({"doctype": "CRM Lead", "organization": "Tech Corp"}) + lead1.flags.ignore_mandatory = True + lead1.insert() + self.assertEqual(lead1.lead_name, "Tech Corp") + self.assertEqual(lead1.title, "Tech Corp") + + # Test 2: lead_name from email prefix when no first_name or organization + lead2 = frappe.get_doc({"doctype": "CRM Lead", "email": "contact@company.com"}) + lead2.flags.ignore_mandatory = True + lead2.insert() + self.assertEqual(lead2.lead_name, "contact") + self.assertEqual(lead2.title, "contact") + + # Test 3: Throws error without first_name, organization, or email + with self.assertRaises(frappe.exceptions.ValidationError) as context: + create_lead() + self.assertIn( + "A Lead requires either a person's name or an organization's name", str(context.exception) + ) + + # Test 4: lead_name set to 'Unnamed Lead' with ignore_mandatory flag + lead3 = frappe.get_doc({"doctype": "CRM Lead"}) + lead3.flags.ignore_mandatory = True + lead3.insert() + self.assertEqual(lead3.lead_name, "Unnamed Lead") + self.assertEqual(lead3.title, "Unnamed Lead") + + def test_lead_title_generation(self): + """Test that title is set correctly""" + lead = create_lead( + first_name="Alice", + organization="Acme Corp", + email="alice@acme.com", + ) + + # Title should be organization if provided, otherwise lead_name + self.assertEqual(lead.title, "Acme Corp") + + lead2 = create_lead( + first_name="Bob", + email="bob@example.com", + ) + + self.assertEqual(lead2.title, "Bob") + + def test_lead_owner_cannot_be_same_as_email(self): + """Test that lead owner cannot be same as lead email address""" + + with self.assertRaises(frappe.exceptions.ValidationError) as context: + create_lead( + first_name="Test", + email="crm.user1@example.com", + lead_owner="crm.user1@example.com", + ) + self.assertIn("Lead Owner cannot be same as the Lead Email Address", str(context.exception)) + + def test_update_lead_owner(self): + """Test that updating lead owner assigns and shares with the new owner""" + # Create a lead without owner + lead = create_lead( + first_name="Owner", + last_name="Test", + email="ownertest@example.com", + ) + + self.assertFalse(lead.lead_owner) + + # Update lead owner + lead.lead_owner = "Administrator" + lead.save() + + # Verify owner was updated + lead.reload() + self.assertEqual(lead.lead_owner, "Administrator") + + # Verify agent was assigned + assignees = lead.get_assigned_users() + self.assertIn("Administrator", assignees) + initial_assignees_count = len(assignees) + + # Verify document was shared with agent + docshare = frappe.db.exists( + "DocShare", + {"user": "Administrator", "share_name": lead.name, "share_doctype": "CRM Lead"}, + ) + self.assertTrue(docshare) + + # Try to assign the same agent again - should not duplicate + lead.assign_agent("Administrator") + assignees_after = lead.get_assigned_users() + self.assertEqual(len(assignees_after), initial_assignees_count) + self.assertIn("Administrator", assignees_after) + + # Share with same agent again - should not duplicate docshare + initial_docshares = frappe.get_all( + "DocShare", + filters={"share_name": lead.name, "share_doctype": "CRM Lead"}, + ) + initial_docshare_count = len(initial_docshares) + lead.share_with_agent("Administrator") + after_docshares = frappe.get_all( + "DocShare", + filters={"share_name": lead.name, "share_doctype": "CRM Lead"}, + ) + self.assertEqual(len(after_docshares), initial_docshare_count) + + lead.lead_owner = "crm.user1@example.com" + lead.save() + lead.reload() + + # Verify new owner is assigned and shared + self.assertEqual(lead.lead_owner, "crm.user1@example.com") + new_docshare = frappe.db.exists( + "DocShare", + {"user": "crm.user1@example.com", "share_name": lead.name, "share_doctype": "CRM Lead"}, + ) + self.assertTrue(new_docshare) + + # Verify old owner's share was removed + old_docshare = frappe.db.exists( + "DocShare", + {"user": "Administrator", "share_name": lead.name, "share_doctype": "CRM Lead"}, + ) + self.assertFalse(old_docshare) + + def test_lead_creation_with_owner(self): + """Test creating a lead with lead owner assigns agent on insert""" + lead = create_lead( + first_name="Owned", + last_name="Lead", + email="ownedlead@example.com", + lead_owner="Administrator", + ) + + # Verify lead was created with owner + self.assertEqual(lead.lead_owner, "Administrator") + + # Verify agent was assigned during after_insert + assignees = lead.get_assigned_users() + self.assertIn("Administrator", assignees) + + def test_create_contact_from_lead(self): + """Test creating a contact from lead data""" + lead = create_lead( + first_name="Michael", + last_name="Jordan", + email="mj@bulls.com", + mobile_no="+1234567890", + phone="+0987654321", + organization="Chicago Bulls", + job_title="Player", + salutation="Mr", + ) + + contact_name = lead.create_contact() + self.assertTrue(contact_name) + + contact = frappe.get_doc("Contact", contact_name) + self.assertEqual(contact.first_name, "Michael") + self.assertEqual(contact.last_name, "Jordan") + self.assertEqual(contact.email_id, "mj@bulls.com") + self.assertEqual(contact.mobile_no, "+1234567890") + self.assertEqual(contact.company_name, "Chicago Bulls") + self.assertEqual(contact.designation, "Player") + + def test_create_organization_from_lead(self): + """Test creating an organization from lead data""" + lead = create_lead( + first_name="Steve", + last_name="Jobs", + email="steve@apple.com", + organization="Apple Inc", + website="https://apple.com", + annual_revenue=1000000, + ) + + org_name = lead.create_organization() + self.assertTrue(org_name) + + org = frappe.get_doc("CRM Organization", org_name) + self.assertEqual(org.organization_name, "Apple Inc") + self.assertEqual(org.website, "https://apple.com") + self.assertEqual(org.annual_revenue, 1000000) + + def test_create_organization_with_existing_org(self): + """Test that existing organization is reused instead of creating duplicate""" + # Create first lead with organization + lead1 = create_lead( + first_name="Person", + last_name="One", + email="person1@example.com", + organization="Existing Corp", + ) + org_name1 = lead1.create_organization() + + # Create second lead with same organization + lead2 = create_lead( + first_name="Person", + last_name="Two", + email="person2@example.com", + organization="Existing Corp", + ) + org_name2 = lead2.create_organization() + + # Should return the same organization + self.assertEqual(org_name1, org_name2) + + def test_contact_exists_with_email(self): + """Test checking if contact already exists with same email""" + lead1 = create_lead( + first_name="John", + last_name="Existing", + email="existing@example.com", + mobile_no="+1111111111", + ) + lead1.create_contact() + + lead2 = create_lead( + first_name="Jane", + last_name="Duplicate", + email="existing@example.com", + mobile_no="+2222222222", + ) + + # Should throw error as contact with same email exists + with self.assertRaises(frappe.exceptions.ValidationError) as context: + lead2.create_contact() + self.assertIn("Contact already exists", str(context.exception)) + + def test_contact_not_reused_when_only_phone_matches(self): + """A different person sharing only a phone must not be reused as the contact""" + lead1 = create_lead( + first_name="Jane", + last_name="Doe", + email="frappe@example.com", + mobile_no="+910000000099", + ) + existing_contact = lead1.create_contact() + + # Different person, no email, but the same mobile number + lead2 = create_lead( + first_name="John", + last_name="Doe", + mobile_no="+910000000099", + ) + contact_name = lead2.create_contact() + + self.assertNotEqual(contact_name, existing_contact) + contact = frappe.get_doc("Contact", contact_name) + self.assertEqual(contact.first_name, "John") + self.assertEqual(contact.last_name, "Doe") + + def test_convert_lead_to_deal(self): + """Test converting a lead to a deal with new contact and organization""" + lead = create_lead( + first_name="Deal", + last_name="Maker", + email="dealmaker@example.com", + mobile_no="+1234567890", + organization="Deal Corp", + annual_revenue=500000, + ) + + # Convert lead to deal + deal_name = lead.convert_to_deal() + self.assertTrue(deal_name) + + # Verify lead is marked as converted + lead.reload() + self.assertEqual(lead.converted, 1) + + # Verify deal was created + deal = frappe.get_doc("CRM Deal", deal_name) + self.assertEqual(deal.first_name, "Deal") + self.assertEqual(deal.last_name, "Maker") + self.assertEqual(deal.lead, lead.name) + self.assertTrue(deal.organization) + + # Verify contact was created + self.assertTrue(len(deal.contacts) > 0) + contact_name = deal.contacts[0].contact + contact = frappe.get_doc("Contact", contact_name) + self.assertEqual(contact.first_name, "Deal") + self.assertEqual(contact.last_name, "Maker") + self.assertEqual(contact.email_id, "dealmaker@example.com") + + # Verify organization was created + org = frappe.get_doc("CRM Organization", deal.organization) + self.assertEqual(org.organization_name, "Deal Corp") + self.assertEqual(org.annual_revenue, 500000) + + def test_convert_lead_with_existing_contact_and_org(self): + """Test converting lead with existing contact and organization""" + # Create existing contact + existing_contact = frappe.get_doc( + { + "doctype": "Contact", + "first_name": "Existing", + "last_name": "Contact", + "email_ids": [{"email_id": "existing@contact.com", "is_primary": 1}], + } + ).insert() + + # Create existing organization + existing_org = frappe.get_doc( + { + "doctype": "CRM Organization", + "organization_name": "Existing Org Inc", + "annual_revenue": 2000000, + } + ).insert() + + # Create lead + lead = create_lead( + first_name="Existing", + last_name="Contact", + email="existing@contact.com", + organization="Existing Org Inc", + ) + + # Convert lead using existing contact and org + deal_name = lead.convert_to_deal() + + # Verify deal was created with existing records + deal = frappe.get_doc("CRM Deal", deal_name) + self.assertTrue(deal.name) + + # Verify existing contact is linked + self.assertTrue(len(deal.contacts) > 0) + self.assertEqual(deal.contacts[0].contact, existing_contact.name) + + # Verify existing organization is linked + self.assertEqual(deal.organization, existing_org.name) + + def test_convert_to_deal_api(self): + """Test convert_to_deal API function""" + lead = create_lead( + first_name="API", + last_name="Test", + email="apitest@example.com", + mobile_no="+5555555555", + organization="API Test Corp", + annual_revenue=300000, + ) + + # Convert lead to deal using API + deal_name = convert_to_deal(lead=lead.name) + self.assertTrue(deal_name) + + # Verify lead is marked as converted + lead.reload() + self.assertEqual(lead.converted, 1) + + # Verify deal was created + deal = frappe.get_doc("CRM Deal", deal_name) + self.assertEqual(deal.first_name, "API") + self.assertEqual(deal.last_name, "Test") + self.assertEqual(deal.lead, lead.name) + self.assertTrue(deal.organization) + + # Verify contact was created + self.assertTrue(len(deal.contacts) > 0) + contact_name = deal.contacts[0].contact + contact = frappe.get_doc("Contact", contact_name) + self.assertEqual(contact.first_name, "API") + self.assertEqual(contact.email_id, "apitest@example.com") + + # Verify organization was created + org = frappe.get_doc("CRM Organization", deal.organization) + self.assertEqual(org.organization_name, "API Test Corp") + self.assertEqual(org.annual_revenue, 300000) + + def test_cannot_convert_lost_lead_to_deal(self): + """Lost leads cannot be converted to deals.""" + if not frappe.db.exists("CRM Lost Reason", "Not interested"): + frappe.get_doc({"doctype": "CRM Lost Reason", "lost_reason": "Not interested"}).insert() + + for status in ("Junk", "Unqualified"): + with self.subTest(status=status): + lead = create_lead(first_name=status, status=status, lost_reason="Not interested") + with self.assertRaisesRegex( + frappe.ValidationError, f"Cannot convert a lead with status {status}" + ): + convert_to_deal(lead=lead.name) + lead.reload() + self.assertFalse(lead.converted) + + def test_convert_to_deal_api_with_existing_records(self): + """Test convert_to_deal API with existing contact and organization parameters""" + # Create existing contact + existing_contact = frappe.get_doc( + { + "doctype": "Contact", + "first_name": "API", + "last_name": "Contact", + "email_ids": [{"email_id": "apicontact@example.com", "is_primary": 1}], + } + ).insert() + + # Create existing organization + existing_org = frappe.get_doc( + { + "doctype": "CRM Organization", + "organization_name": "API Org Ltd", + "annual_revenue": 1500000, + } + ).insert() + + # Create lead + lead = create_lead( + first_name="API", + last_name="Lead", + email="apilead@example.com", + organization="Should Be Replaced", + ) + + # Convert lead using API with existing records + deal_name = convert_to_deal( + lead=lead.name, + existing_contact=existing_contact.name, + existing_organization=existing_org.name, + ) + + # Verify deal was created with existing records + deal = frappe.get_doc("CRM Deal", deal_name) + self.assertTrue(deal.name) + + # Verify existing contact is linked + self.assertTrue(len(deal.contacts) > 0) + self.assertEqual(deal.contacts[0].contact, existing_contact.name) + + # Verify existing organization is linked + self.assertEqual(deal.organization, existing_org.name) + + def test_lead_fields_copied_to_deal(self): + """Test that relevant lead fields are copied to deal during conversion""" + lead = create_lead( + first_name="Copy", + last_name="Test", + email="copytest@example.com", + mobile_no="+9999999999", + organization="Copy Test Inc", + website="https://copytest.com", + annual_revenue=750000, + job_title="CEO", + ) + + deal_name = lead.convert_to_deal() + deal = frappe.get_doc("CRM Deal", deal_name) + + # Verify fields are copied + self.assertEqual(deal.first_name, "Copy") + self.assertEqual(deal.last_name, "Test") + self.assertEqual(deal.website, "https://copytest.com") + self.assertEqual(deal.annual_revenue, 750000) + self.assertEqual(deal.job_title, "CEO") + + def test_no_of_employees_propagated_to_organization_on_conversion(self): + """Test that no_of_employees on lead is copied to the organization created on conversion""" + lead = create_lead( + first_name="Employees", + last_name="Test", + email="employeestest@example.com", + organization="Employees Test Inc", + no_of_employees="201-500", + ) + + deal_name = lead.convert_to_deal() + deal = frappe.get_doc("CRM Deal", deal_name) + + org = frappe.get_doc("CRM Organization", deal.organization) + self.assertEqual(org.no_of_employees, "201-500") + + def test_custom_fields_copied_to_deal_by_label(self): + """Custom Lead fields map to matching custom Deal fields.""" + create_lead_deal_custom_fields() + lead = create_lead( + first_name="Custom", + organization="Custom Field Inc", + custom_lead_conversion_region="North", + ) + + deal_name = lead.convert_to_deal() + deal = frappe.get_doc("CRM Deal", deal_name) + + self.assertEqual(deal.custom_deal_conversion_region, "North") + + def test_assignees_transferred_on_conversion(self): + """Test that additional assignees are transferred from lead to deal on conversion""" + lead = create_lead( + first_name="Transfer", + lead_owner="Administrator", + ) + + lead.assign_agent("crm.user1@example.com") + + lead_assignees = lead.get_assigned_users() + + self.assertIn("Administrator", lead_assignees) + self.assertIn("crm.user1@example.com", lead_assignees) + + deal_name = lead.convert_to_deal() + deal = frappe.get_doc("CRM Deal", deal_name) + + deal_assignees = deal.get_assigned_users() + self.assertIn("Administrator", deal_assignees) + self.assertIn("crm.user1@example.com", deal_assignees) + + def test_owner_cleared_on_unassign(self): + """Unassigning the current owner clears lead_owner""" + lead = create_lead(first_name="Owner", lead_owner="crm.user1@example.com") + self.assertEqual(lead.lead_owner, "crm.user1@example.com") + + assign_remove("CRM Lead", lead.name, "crm.user1@example.com") + + self.assertIsNone(frappe.db.get_value("CRM Lead", lead.name, "lead_owner")) + + def test_assignment_overrides_owner(self): + """A new assignment takes ownership even when an owner already exists (newest owns).""" + lead = create_lead(first_name="Override", lead_owner="crm.user1@example.com") + assign_add({"assign_to": ["crm.user2@example.com"], "doctype": "CRM Lead", "name": lead.name}) + self.assertEqual(frappe.db.get_value("CRM Lead", lead.name, "lead_owner"), "crm.user2@example.com") + + def test_negative_currency_fields_rejected(self): + """Test that Currency fields reject negative values""" + for fieldname in ("annual_revenue", "total", "net_total"): + with self.subTest(fieldname=fieldname), self.assertRaises(frappe.NonNegativeError): + create_lead( + first_name="Negative", + email=f"negative.{fieldname}@example.com", + **{fieldname: -100}, + ) + + +def create_lead(**kwargs): + """Helper function to create a CRM Lead for testing""" + data = {"doctype": "CRM Lead"} + data.update(kwargs) + return frappe.get_doc(data).insert() + + +def create_lead_deal_custom_fields(): + create_custom_fields( + { + "CRM Lead": [conversion_region_field("custom_lead_conversion_region")], + "CRM Deal": [conversion_region_field("custom_deal_conversion_region")], + }, + ignore_validate=True, + ) + frappe.clear_cache(doctype="CRM Lead") + frappe.clear_cache(doctype="CRM Deal") + + +def conversion_region_field(fieldname): + return { + "fieldname": fieldname, + "fieldtype": "Data", + "insert_after": "source", + "label": "Conversion Region", + } diff --git a/crm/fcrm/doctype/crm_lead/test_records.json b/crm/fcrm/doctype/crm_lead/test_records.json new file mode 100644 index 0000000..7748c2e --- /dev/null +++ b/crm/fcrm/doctype/crm_lead/test_records.json @@ -0,0 +1,408 @@ +[ + { + "doctype": "CRM Lead", + "first_name": "John", + "last_name": "Smith", + "email": "john.smith@example.com", + "mobile_no": "+1-555-0101", + "organization": "Tech Innovations Inc", + "job_title": "CTO", + "source": "Website", + "status": "New Lead", + "lead_owner": "Administrator" + }, + { + "doctype": "CRM Lead", + "first_name": "Jane", + "last_name": "Doe", + "email": "jane.doe@example.com", + "mobile_no": "+1-555-0102", + "organization": "Global Solutions Ltd", + "job_title": "VP Sales", + "source": "Referral", + "status": "New Lead", + "lead_owner": "Administrator" + }, + { + "doctype": "CRM Lead", + "first_name": "Robert", + "last_name": "Johnson", + "email": "robert.johnson@example.com", + "mobile_no": "+1-555-0103", + "organization": "Digital Marketing Co", + "job_title": "Marketing Director", + "source": "Cold Call", + "status": "New Lead", + "lead_owner": "crm.user1@example.com" + }, + { + "doctype": "CRM Lead", + "first_name": "Emily", + "last_name": "Williams", + "email": "emily.williams@example.com", + "mobile_no": "+1-555-0104", + "organization": "Enterprise Systems", + "job_title": "IT Manager", + "source": "Email Campaign", + "status": "New Lead", + "lead_owner": "Administrator" + }, + { + "doctype": "CRM Lead", + "first_name": "Michael", + "last_name": "Brown", + "email": "michael.brown@example.com", + "mobile_no": "+1-555-0105", + "organization": "Cloud Services Inc", + "job_title": "CEO", + "source": "Social Media", + "status": "New Lead", + "lead_owner": "crm.user1@example.com" + }, + { + "doctype": "CRM Lead", + "first_name": "Sarah", + "last_name": "Davis", + "email": "sarah.davis@example.com", + "mobile_no": "+1-555-0106", + "organization": "Data Analytics Corp", + "job_title": "Data Scientist", + "source": "Website", + "status": "New Lead", + "lead_owner": "Administrator" + }, + { + "doctype": "CRM Lead", + "first_name": "David", + "last_name": "Miller", + "email": "david.miller@example.com", + "mobile_no": "+1-555-0107", + "organization": "Startup Ventures", + "job_title": "Founder", + "source": "Referral", + "status": "New Lead", + "lead_owner": "crm.user1@example.com" + }, + { + "doctype": "CRM Lead", + "first_name": "Lisa", + "last_name": "Wilson", + "email": "lisa.wilson@example.com", + "mobile_no": "+1-555-0108", + "organization": "Consulting Group", + "job_title": "Senior Consultant", + "source": "Cold Call", + "status": "New Lead", + "lead_owner": "Administrator" + }, + { + "doctype": "CRM Lead", + "first_name": "James", + "last_name": "Moore", + "email": "james.moore@example.com", + "mobile_no": "+1-555-0109", + "organization": "Innovation Labs", + "job_title": "Research Lead", + "source": "Email Campaign", + "status": "New Lead" + }, + { + "doctype": "CRM Lead", + "first_name": "Jennifer", + "last_name": "Taylor", + "email": "jennifer.taylor@example.com", + "mobile_no": "+1-555-0110", + "organization": "Business Solutions", + "job_title": "Operations Manager", + "source": "Social Media", + "status": "New Lead", + "lead_owner": "Administrator" + }, + { + "doctype": "CRM Lead", + "first_name": "William", + "last_name": "Anderson", + "email": "william.anderson@example.com", + "mobile_no": "+1-555-0111", + "organization": "Tech Corp", + "job_title": "Product Manager", + "source": "Website", + "status": "New Lead" + }, + { + "doctype": "CRM Lead", + "first_name": "Mary", + "last_name": "Thomas", + "email": "mary.thomas@example.com", + "mobile_no": "+1-555-0112", + "organization": "Software Systems", + "job_title": "Engineering Lead", + "source": "Referral", + "status": "New Lead", + "lead_owner": "Administrator" + }, + { + "doctype": "CRM Lead", + "first_name": "Richard", + "last_name": "Jackson", + "email": "richard.jackson@example.com", + "mobile_no": "+1-555-0113", + "organization": "Digital Solutions", + "job_title": "Business Analyst", + "source": "Cold Call", + "status": "New Lead" + }, + { + "doctype": "CRM Lead", + "first_name": "Patricia", + "last_name": "White", + "email": "patricia.white@example.com", + "mobile_no": "+1-555-0114", + "organization": "Enterprise Tech", + "job_title": "Project Manager", + "source": "Email Campaign", + "status": "New Lead", + "lead_owner": "Administrator" + }, + { + "doctype": "CRM Lead", + "first_name": "Charles", + "last_name": "Harris", + "email": "charles.harris@example.com", + "mobile_no": "+1-555-0115", + "organization": "Cloud Innovations", + "job_title": "Solutions Architect", + "source": "Social Media", + "status": "New Lead" + }, + { + "doctype": "CRM Lead", + "first_name": "Linda", + "last_name": "Martin", + "email": "linda.martin@example.com", + "mobile_no": "+1-555-0116", + "organization": "AI Systems", + "job_title": "AI Engineer", + "source": "Website", + "status": "New Lead", + "lead_owner": "Administrator" + }, + { + "doctype": "CRM Lead", + "first_name": "Thomas", + "last_name": "Thompson", + "email": "thomas.thompson@example.com", + "mobile_no": "+1-555-0117", + "organization": "Mobile Apps Inc", + "job_title": "Mobile Lead", + "source": "Referral", + "status": "New Lead" + }, + { + "doctype": "CRM Lead", + "first_name": "Barbara", + "last_name": "Garcia", + "email": "barbara.garcia@example.com", + "mobile_no": "+1-555-0118", + "organization": "Security Solutions", + "job_title": "Security Officer", + "source": "Cold Call", + "status": "New Lead", + "lead_owner": "Administrator" + }, + { + "doctype": "CRM Lead", + "first_name": "Christopher", + "last_name": "Martinez", + "email": "christopher.martinez@example.com", + "mobile_no": "+1-555-0119", + "organization": "Data Services", + "job_title": "Data Engineer", + "source": "Email Campaign", + "status": "New Lead" + }, + { + "doctype": "CRM Lead", + "first_name": "Susan", + "last_name": "Robinson", + "email": "susan.robinson@example.com", + "mobile_no": "+1-555-0120", + "organization": "Analytics Corp", + "job_title": "Analytics Manager", + "source": "Social Media", + "status": "New Lead", + "lead_owner": "Administrator" + }, + { + "doctype": "CRM Lead", + "first_name": "Daniel", + "last_name": "Clark", + "email": "daniel.clark@example.com", + "mobile_no": "+1-555-0121", + "organization": "Platform Tech", + "job_title": "Platform Engineer", + "source": "Website", + "status": "New Lead" + }, + { + "doctype": "CRM Lead", + "first_name": "Karen", + "last_name": "Rodriguez", + "email": "karen.rodriguez@example.com", + "mobile_no": "+1-555-0122", + "organization": "IoT Solutions", + "job_title": "IoT Specialist", + "source": "Referral", + "status": "New Lead", + "lead_owner": "Administrator" + }, + { + "doctype": "CRM Lead", + "first_name": "Matthew", + "last_name": "Lewis", + "email": "matthew.lewis@example.com", + "mobile_no": "+1-555-0123", + "organization": "Blockchain Corp", + "job_title": "Blockchain Developer", + "source": "Cold Call", + "status": "New Lead" + }, + { + "doctype": "CRM Lead", + "first_name": "Nancy", + "last_name": "Lee", + "email": "nancy.lee@example.com", + "mobile_no": "+1-555-0124", + "organization": "DevOps Services", + "job_title": "DevOps Lead", + "source": "Email Campaign", + "status": "New Lead", + "lead_owner": "Administrator" + }, + { + "doctype": "CRM Lead", + "first_name": "Paul", + "last_name": "Walker", + "email": "paul.walker@example.com", + "mobile_no": "+1-555-0125", + "organization": "Network Systems", + "job_title": "Network Admin", + "source": "Social Media", + "status": "New Lead" + }, + { + "doctype": "CRM Lead", + "first_name": "Betty", + "last_name": "Hall", + "email": "betty.hall@example.com", + "mobile_no": "+1-555-0126", + "organization": "Database Solutions", + "job_title": "DBA", + "source": "Website", + "status": "New Lead", + "lead_owner": "Administrator" + }, + { + "doctype": "CRM Lead", + "first_name": "Mark", + "last_name": "Allen", + "email": "mark.allen@example.com", + "mobile_no": "+1-555-0127", + "organization": "Web Services", + "job_title": "Web Developer", + "source": "Referral", + "status": "New Lead" + }, + { + "doctype": "CRM Lead", + "first_name": "Helen", + "last_name": "Young", + "email": "helen.young@example.com", + "mobile_no": "+1-555-0128", + "organization": "API Systems", + "job_title": "API Designer", + "source": "Cold Call", + "status": "New Lead", + "lead_owner": "Administrator" + }, + { + "doctype": "CRM Lead", + "first_name": "Donald", + "last_name": "Hernandez", + "email": "donald.hernandez@example.com", + "mobile_no": "+1-555-0129", + "organization": "Testing Labs", + "job_title": "QA Manager", + "source": "Email Campaign", + "status": "New Lead" + }, + { + "doctype": "CRM Lead", + "first_name": "Sandra", + "last_name": "King", + "email": "sandra.king@example.com", + "mobile_no": "+1-555-0130", + "organization": "UX Design Co", + "job_title": "UX Designer", + "source": "Social Media", + "status": "New Lead", + "lead_owner": "Administrator" + }, + { + "doctype": "CRM Lead", + "first_name": "George", + "last_name": "Wright", + "email": "george.wright@example.com", + "mobile_no": "+1-555-0131", + "organization": "Frontend Systems", + "job_title": "Frontend Lead", + "source": "Website", + "status": "New Lead" + }, + { + "doctype": "CRM Lead", + "first_name": "Ashley", + "last_name": "Lopez", + "email": "ashley.lopez@example.com", + "mobile_no": "+1-555-0132", + "organization": "Backend Solutions", + "job_title": "Backend Engineer", + "source": "Referral", + "status": "New Lead", + "lead_owner": "Administrator" + }, + { + "doctype": "CRM Lead", + "first_name": "Kevin", + "last_name": "Hill", + "email": "kevin.hill@example.com", + "mobile_no": "+1-555-0133", + "organization": "Full Stack Corp", + "job_title": "Full Stack Developer", + "source": "Cold Call", + "status": "New Lead" + }, + { + "doctype": "CRM Lead", + "first_name": "Donna", + "last_name": "Scott", + "email": "donna.scott@example.com", + "mobile_no": "+1-555-0134", + "organization": "Architecture Firm", + "job_title": "Enterprise Architect", + "source": "Email Campaign", + "status": "New Lead", + "lead_owner": "Administrator" + }, + { + "doctype": "CRM Lead", + "first_name": "Steven", + "last_name": "Green", + "email": "steven.green@example.com", + "mobile_no": "+1-555-0135", + "organization": "Microservices Inc", + "job_title": "Microservices Lead", + "source": "Social Media", + "status": "New Lead" + } +] diff --git a/crm/fcrm/doctype/crm_lead_source/__init__.py b/crm/fcrm/doctype/crm_lead_source/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_lead_source/crm_lead_source.js b/crm/fcrm/doctype/crm_lead_source/crm_lead_source.js new file mode 100644 index 0000000..2330b78 --- /dev/null +++ b/crm/fcrm/doctype/crm_lead_source/crm_lead_source.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Lead Source", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_lead_source/crm_lead_source.json b/crm/fcrm/doctype/crm_lead_source/crm_lead_source.json new file mode 100644 index 0000000..f2b34e0 --- /dev/null +++ b/crm/fcrm/doctype/crm_lead_source/crm_lead_source.json @@ -0,0 +1,80 @@ +{ + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "autoname": "field:source_name", + "creation": "2023-07-24 19:47:01.063203", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "source_name", + "details" + ], + "fields": [ + { + "fieldname": "source_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Source Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "details", + "fieldtype": "Text Editor", + "label": "Details" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-03-05 13:05:26.007219", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Lead Source", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1 + } + ], + "quick_entry": 1, + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "translated_doctype": 1 +} diff --git a/crm/fcrm/doctype/crm_lead_source/crm_lead_source.py b/crm/fcrm/doctype/crm_lead_source/crm_lead_source.py new file mode 100644 index 0000000..c56c6b7 --- /dev/null +++ b/crm/fcrm/doctype/crm_lead_source/crm_lead_source.py @@ -0,0 +1,21 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMLeadSource(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + details: DF.TextEditor | None + source_name: DF.Data + # end: auto-generated types + + pass diff --git a/crm/fcrm/doctype/crm_lead_source/test_crm_lead_source.py b/crm/fcrm/doctype/crm_lead_source/test_crm_lead_source.py new file mode 100644 index 0000000..31d3791 --- /dev/null +++ b/crm/fcrm/doctype/crm_lead_source/test_crm_lead_source.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import UnitTestCase + + +class TestCRMLeadSource(UnitTestCase): + pass diff --git a/crm/fcrm/doctype/crm_lead_source/test_records.json b/crm/fcrm/doctype/crm_lead_source/test_records.json new file mode 100644 index 0000000..a31f69f --- /dev/null +++ b/crm/fcrm/doctype/crm_lead_source/test_records.json @@ -0,0 +1,22 @@ +[ + { + "doctype": "CRM Lead Source", + "source_name": "Website" + }, + { + "doctype": "CRM Lead Source", + "source_name": "Referral" + }, + { + "doctype": "CRM Lead Source", + "source_name": "Cold Call" + }, + { + "doctype": "CRM Lead Source", + "source_name": "Email Campaign" + }, + { + "doctype": "CRM Lead Source", + "source_name": "Social Media" + } +] diff --git a/crm/fcrm/doctype/crm_lead_status/__init__.py b/crm/fcrm/doctype/crm_lead_status/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_lead_status/crm_lead_status.js b/crm/fcrm/doctype/crm_lead_status/crm_lead_status.js new file mode 100644 index 0000000..24b6318 --- /dev/null +++ b/crm/fcrm/doctype/crm_lead_status/crm_lead_status.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Lead Status", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_lead_status/crm_lead_status.json b/crm/fcrm/doctype/crm_lead_status/crm_lead_status.json new file mode 100644 index 0000000..d71b3ae --- /dev/null +++ b/crm/fcrm/doctype/crm_lead_status/crm_lead_status.json @@ -0,0 +1,103 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:lead_status", + "creation": "2023-11-29 11:09:53.678414", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "lead_status", + "type", + "column_break_qwtp", + "color", + "position" + ], + "fields": [ + { + "default": "gray", + "fieldname": "color", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Color", + "options": "black\ngray\nblue\ngreen\nred\npink\norange\namber\nyellow\ncyan\nteal\nviolet\npurple" + }, + { + "fieldname": "lead_status", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Status", + "reqd": 1, + "unique": 1 + }, + { + "default": "1", + "fieldname": "position", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Position" + }, + { + "default": "Open", + "fieldname": "type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Type", + "options": "Open\nOngoing\nOn Hold\nWon\nLost" + }, + { + "fieldname": "column_break_qwtp", + "fieldtype": "Column Break" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-03-05 12:42:24.932212", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Lead Status", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "translated_doctype": 1 +} diff --git a/crm/fcrm/doctype/crm_lead_status/crm_lead_status.py b/crm/fcrm/doctype/crm_lead_status/crm_lead_status.py new file mode 100644 index 0000000..bb37e69 --- /dev/null +++ b/crm/fcrm/doctype/crm_lead_status/crm_lead_status.py @@ -0,0 +1,37 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMLeadStatus(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + color: DF.Literal[ + "black", + "gray", + "blue", + "green", + "red", + "pink", + "orange", + "amber", + "yellow", + "cyan", + "teal", + "violet", + "purple", + ] + lead_status: DF.Data + position: DF.Int + type: DF.Literal["Open", "Ongoing", "On Hold", "Won", "Lost"] + # end: auto-generated types + + pass diff --git a/crm/fcrm/doctype/crm_lead_status/test_crm_lead_status.py b/crm/fcrm/doctype/crm_lead_status/test_crm_lead_status.py new file mode 100644 index 0000000..d542056 --- /dev/null +++ b/crm/fcrm/doctype/crm_lead_status/test_crm_lead_status.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import UnitTestCase + + +class TestCRMLeadStatus(UnitTestCase): + pass diff --git a/crm/fcrm/doctype/crm_lead_status/test_records.json b/crm/fcrm/doctype/crm_lead_status/test_records.json new file mode 100644 index 0000000..b1e8b6a --- /dev/null +++ b/crm/fcrm/doctype/crm_lead_status/test_records.json @@ -0,0 +1,6 @@ +[ + { + "doctype": "CRM Lead Status", + "lead_status": "New Lead" + } +] diff --git a/crm/fcrm/doctype/crm_lost_reason/__init__.py b/crm/fcrm/doctype/crm_lost_reason/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.js b/crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.js new file mode 100644 index 0000000..effd824 --- /dev/null +++ b/crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Lost Reason", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.json b/crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.json new file mode 100644 index 0000000..153250d --- /dev/null +++ b/crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.json @@ -0,0 +1,80 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:lost_reason", + "creation": "2025-06-30 16:51:31.082360", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "lost_reason", + "description" + ], + "fields": [ + { + "fieldname": "lost_reason", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Lost Reason", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Description" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-03-05 13:04:31.907824", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Lost Reason", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "translated_doctype": 1 +} diff --git a/crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.py b/crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.py new file mode 100644 index 0000000..d22e393 --- /dev/null +++ b/crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.py @@ -0,0 +1,21 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMLostReason(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + description: DF.TextEditor | None + lost_reason: DF.Data + # end: auto-generated types + + pass diff --git a/crm/fcrm/doctype/crm_lost_reason/test_crm_lost_reason.py b/crm/fcrm/doctype/crm_lost_reason/test_crm_lost_reason.py new file mode 100644 index 0000000..4ee736c --- /dev/null +++ b/crm/fcrm/doctype/crm_lost_reason/test_crm_lost_reason.py @@ -0,0 +1,29 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class UnitTestCRMLostReason(UnitTestCase): + """ + Unit tests for CRMLostReason. + Use this class for testing individual functions and methods. + """ + + pass + + +class IntegrationTestCRMLostReason(IntegrationTestCase): + """ + Integration tests for CRMLostReason. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/crm/fcrm/doctype/crm_lost_reason/test_records.json b/crm/fcrm/doctype/crm_lost_reason/test_records.json new file mode 100644 index 0000000..e00bea7 --- /dev/null +++ b/crm/fcrm/doctype/crm_lost_reason/test_records.json @@ -0,0 +1,22 @@ +[ + { + "doctype": "CRM Lost Reason", + "lost_reason": "Price too high" + }, + { + "doctype": "CRM Lost Reason", + "lost_reason": "Competition" + }, + { + "doctype": "CRM Lost Reason", + "lost_reason": "Budget constraints" + }, + { + "doctype": "CRM Lost Reason", + "lost_reason": "No response" + }, + { + "doctype": "CRM Lost Reason", + "lost_reason": "Not interested" + } +] diff --git a/crm/fcrm/doctype/crm_notification/__init__.py b/crm/fcrm/doctype/crm_notification/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_notification/crm_notification.js b/crm/fcrm/doctype/crm_notification/crm_notification.js new file mode 100644 index 0000000..5df4556 --- /dev/null +++ b/crm/fcrm/doctype/crm_notification/crm_notification.js @@ -0,0 +1,8 @@ +// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Notification", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_notification/crm_notification.json b/crm/fcrm/doctype/crm_notification/crm_notification.json new file mode 100644 index 0000000..453f4ce --- /dev/null +++ b/crm/fcrm/doctype/crm_notification/crm_notification.json @@ -0,0 +1,166 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-01-29 19:31:13.613929", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "notification_text", + "section_break_hace", + "from_user", + "type", + "column_break_dduu", + "to_user", + "read", + "section_break_pbvx", + "reference_doctype", + "reference_name", + "column_break_eant", + "notification_type_doctype", + "notification_type_doc", + "comment", + "section_break_vpwa", + "message" + ], + "fields": [ + { + "fieldname": "from_user", + "fieldtype": "Link", + "label": "From User", + "options": "User" + }, + { + "fieldname": "type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Type", + "options": "Mention\nTask\nAssignment\nWhatsApp", + "reqd": 1 + }, + { + "fieldname": "column_break_dduu", + "fieldtype": "Column Break" + }, + { + "fieldname": "to_user", + "fieldtype": "Link", + "in_list_view": 1, + "label": "To User", + "options": "User", + "reqd": 1 + }, + { + "fieldname": "comment", + "fieldtype": "Link", + "hidden": 1, + "label": "Comment", + "options": "Comment" + }, + { + "default": "0", + "fieldname": "read", + "fieldtype": "Check", + "label": "Read" + }, + { + "fieldname": "section_break_vpwa", + "fieldtype": "Section Break" + }, + { + "fieldname": "message", + "fieldtype": "HTML Editor", + "in_list_view": 1, + "label": "Message" + }, + { + "fieldname": "reference_name", + "fieldtype": "Dynamic Link", + "label": "Reference Doc", + "options": "reference_doctype" + }, + { + "fieldname": "reference_doctype", + "fieldtype": "Link", + "label": "Reference Doctype", + "options": "DocType" + }, + { + "fieldname": "section_break_pbvx", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_eant", + "fieldtype": "Column Break" + }, + { + "fieldname": "notification_type_doctype", + "fieldtype": "Link", + "label": "Notification Type Doctype", + "options": "DocType" + }, + { + "fieldname": "notification_type_doc", + "fieldtype": "Dynamic Link", + "label": "Notification Type Doc", + "options": "notification_type_doctype" + }, + { + "fieldname": "notification_text", + "fieldtype": "Text", + "label": "Notification Text" + }, + { + "fieldname": "section_break_hace", + "fieldtype": "Section Break" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-02-23 12:13:22.477210", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Notification", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_notification/crm_notification.py b/crm/fcrm/doctype/crm_notification/crm_notification.py new file mode 100644 index 0000000..db7e1c4 --- /dev/null +++ b/crm/fcrm/doctype/crm_notification/crm_notification.py @@ -0,0 +1,84 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + + +class CRMNotification(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + comment: DF.Link | None + from_user: DF.Link | None + message: DF.HTMLEditor | None + notification_text: DF.Text | None + notification_type_doc: DF.DynamicLink | None + notification_type_doctype: DF.Link | None + read: DF.Check + reference_doctype: DF.Link | None + reference_name: DF.DynamicLink | None + to_user: DF.Link + type: DF.Literal["Mention", "Task", "Assignment", "WhatsApp"] + # end: auto-generated types + + def on_update(self): + if self.to_user: + frappe.publish_realtime("crm_notification", user=self.to_user) + + +def get_permission_query_conditions(user=None): + if not user: + user = frappe.session.user + + if user == "Administrator" or "System Manager" in frappe.get_roles(user): + return "" + + return f"`tabCRM Notification`.`to_user` = {frappe.db.escape(user)}" + + +def has_permission(doc, ptype, user): + if not user: + user = frappe.session.user + + if user == "Administrator" or "System Manager" in frappe.get_roles(user): + return True + + if ptype == "create": + return False + + if not doc.to_user: + return True + + return doc.to_user == user + + +def notify_user(notification): + """ + Notify the assigned user + """ + notification = frappe._dict(notification) + if notification.owner == notification.assigned_to: + return + + values = frappe._dict( + doctype="CRM Notification", + from_user=notification.owner, + to_user=notification.assigned_to, + type=notification.notification_type, + message=notification.message, + notification_text=notification.notification_text, + notification_type_doctype=notification.reference_doctype, + notification_type_doc=notification.reference_docname, + reference_doctype=notification.redirect_to_doctype, + reference_name=notification.redirect_to_docname, + ) + + if frappe.db.exists("CRM Notification", values): + return + frappe.get_doc(values).insert(ignore_permissions=True) diff --git a/crm/fcrm/doctype/crm_notification/test_crm_notification.py b/crm/fcrm/doctype/crm_notification/test_crm_notification.py new file mode 100644 index 0000000..e7d80fd --- /dev/null +++ b/crm/fcrm/doctype/crm_notification/test_crm_notification.py @@ -0,0 +1,43 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase + +from crm.fcrm.doctype.crm_notification.crm_notification import has_permission + +SYSTEM_MANAGER = "crm.admin@example.com" # System Manager from crm/tests/test_records.json +USER1 = "crm.user1@example.com" +USER2 = "crm.user2@example.com" + + +class TestCRMNotificationPermission(FrappeTestCase): + def tearDown(self): + frappe.db.rollback() + + def test_system_manager_can_create(self): + self.assertTrue(has_permission(make_notification(USER1), "create", SYSTEM_MANAGER)) + + def test_administrator_can_create(self): + self.assertTrue(has_permission(make_notification(USER1), "create", "Administrator")) + + def test_regular_user_cannot_create(self): + self.assertFalse(has_permission(make_notification(USER1), "create", USER1)) + + def test_regular_user_cannot_create_notification_without_to_user(self): + self.assertFalse(has_permission(make_notification(None), "create", USER1)) + + def test_recipient_can_read_own_notification(self): + self.assertTrue(has_permission(make_notification(USER1), "read", USER1)) + + def test_user_cannot_read_others_notification(self): + self.assertFalse(has_permission(make_notification(USER2), "read", USER1)) + + def test_notification_without_to_user_is_readable(self): + self.assertTrue(has_permission(make_notification(None), "read", USER1)) + + +def make_notification(to_user): + doc = frappe.get_doc({"doctype": "CRM Notification", "to_user": to_user, "type": "Mention"}) + doc.flags.ignore_mandatory = True + return doc diff --git a/crm/fcrm/doctype/crm_organization/__init__.py b/crm/fcrm/doctype/crm_organization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_organization/crm_organization.js b/crm/fcrm/doctype/crm_organization/crm_organization.js new file mode 100644 index 0000000..763952d --- /dev/null +++ b/crm/fcrm/doctype/crm_organization/crm_organization.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Organization", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_organization/crm_organization.json b/crm/fcrm/doctype/crm_organization/crm_organization.json new file mode 100644 index 0000000..965dca2 --- /dev/null +++ b/crm/fcrm/doctype/crm_organization/crm_organization.json @@ -0,0 +1,174 @@ +{ + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "autoname": "field:organization_name", + "creation": "2023-11-03 16:23:59.341751", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "organization_name", + "no_of_employees", + "currency", + "exchange_rate", + "annual_revenue", + "organization_logo", + "column_break_pnpp", + "website", + "company_description", + "linkedin", + "twitter", + "facebook", + "territory", + "industry", + "address" + ], + "fields": [ + { + "fieldname": "organization_name", + "fieldtype": "Data", + "in_standard_filter": 1, + "label": "Organization Name", + "unique": 1 + }, + { + "fieldname": "website", + "fieldtype": "Data", + "label": "Website" + }, + { + "fieldname": "organization_logo", + "fieldtype": "Attach Image", + "label": "Organization Logo" + }, + { + "fieldname": "no_of_employees", + "fieldtype": "Select", + "in_standard_filter": 1, + "label": "No. of Employees", + "options": "1-10\n11-50\n51-200\n201-500\n501-1000\n1000+" + }, + { + "fieldname": "column_break_pnpp", + "fieldtype": "Column Break" + }, + { + "fieldname": "annual_revenue", + "fieldtype": "Currency", + "label": "Annual Revenue", + "non_negative": 1, + "options": "currency" + }, + { + "fieldname": "industry", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Industry", + "options": "CRM Industry" + }, + { + "fieldname": "territory", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Territory", + "options": "CRM Territory" + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency" + }, + { + "fieldname": "address", + "fieldtype": "Link", + "label": "Address", + "options": "Address" + }, + { + "description": "The rate used to convert the organization\u2019s currency to your crm's base currency (set in CRM Settings). It is set once when the currency is first added and doesn't change automatically.", + "fieldname": "exchange_rate", + "fieldtype": "Float", + "label": "Exchange Rate" + }, + { + "fieldname": "company_description", + "fieldtype": "Small Text", + "label": "Company Description", + "no_copy": 1, + "description": "Company description discovered by Domain Enrichment." + }, + { + "depends_on": "eval:doc.linkedin", + "fieldname": "linkedin", + "fieldtype": "Data", + "label": "LinkedIn", + "no_copy": 1 + }, + { + "depends_on": "eval:doc.twitter", + "fieldname": "twitter", + "fieldtype": "Data", + "label": "X (Twitter)", + "no_copy": 1 + }, + { + "depends_on": "eval:doc.facebook", + "fieldname": "facebook", + "fieldtype": "Data", + "label": "Facebook", + "no_copy": 1 + } + ], + "image_field": "organization_logo", + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-03-26 12:51:34.002222", + "modified_by": "shariq@frappe.io", + "module": "FCRM", + "name": "CRM Organization", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_organization/crm_organization.py b/crm/fcrm/doctype/crm_organization/crm_organization.py new file mode 100644 index 0000000..3647261 --- /dev/null +++ b/crm/fcrm/doctype/crm_organization/crm_organization.py @@ -0,0 +1,94 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + +from crm.api.exchange_rate import get_exchange_rate + + +class CRMOrganization(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + address: DF.Link | None + annual_revenue: DF.Currency + currency: DF.Link | None + exchange_rate: DF.Float + industry: DF.Link | None + no_of_employees: DF.Literal["1-10", "11-50", "51-200", "201-500", "501-1000", "1000+"] + organization_logo: DF.AttachImage | None + organization_name: DF.Data | None + territory: DF.Link | None + website: DF.Data | None + # end: auto-generated types + + def validate(self): + self.update_exchange_rate() + + def after_insert(self): + # Auto-enrich a new Organization from its website (best-effort, background job). + from crm.domain_enrichment.tasks import auto_enrich_on_create + + auto_enrich_on_create(self) + + def update_exchange_rate(self): + if self.has_value_changed("currency") or not self.exchange_rate: + system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD" + exchange_rate = 1 + if self.currency and self.currency != system_currency: + exchange_rate = get_exchange_rate(self.currency, system_currency) + + self.db_set("exchange_rate", exchange_rate) + + @staticmethod + def default_list_data(): + columns = [ + { + "label": "Organization", + "type": "Data", + "key": "organization_name", + "width": "16rem", + }, + { + "label": "Website", + "type": "Data", + "key": "website", + "width": "14rem", + }, + { + "label": "Industry", + "type": "Link", + "key": "industry", + "options": "CRM Industry", + "width": "14rem", + }, + { + "label": "Annual Revenue", + "type": "Currency", + "key": "annual_revenue", + "width": "14rem", + }, + { + "label": "Last Modified", + "type": "Datetime", + "key": "modified", + "width": "8rem", + }, + ] + rows = [ + "name", + "organization_name", + "organization_logo", + "website", + "industry", + "currency", + "annual_revenue", + "modified", + ] + return {"columns": columns, "rows": rows} diff --git a/crm/fcrm/doctype/crm_organization/test_crm_organization.py b/crm/fcrm/doctype/crm_organization/test_crm_organization.py new file mode 100644 index 0000000..64eb7ba --- /dev/null +++ b/crm/fcrm/doctype/crm_organization/test_crm_organization.py @@ -0,0 +1,21 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.tests import IntegrationTestCase + + +class TestCRMOrganization(IntegrationTestCase): + def tearDown(self) -> None: + frappe.db.rollback() + + def test_negative_annual_revenue_rejected(self): + """Test that annual_revenue rejects negative values""" + with self.assertRaises(frappe.NonNegativeError): + frappe.get_doc( + { + "doctype": "CRM Organization", + "organization_name": "Negative Revenue Org", + "annual_revenue": -100, + } + ).insert() diff --git a/crm/fcrm/doctype/crm_organization/test_records.json b/crm/fcrm/doctype/crm_organization/test_records.json new file mode 100644 index 0000000..5c435bb --- /dev/null +++ b/crm/fcrm/doctype/crm_organization/test_records.json @@ -0,0 +1,37 @@ +[ + { "doctype": "CRM Organization", "organization_name": "Acme Corp" }, + { "doctype": "CRM Organization", "organization_name": "Tech Solutions" }, + { + "doctype": "CRM Organization", + "organization_name": "Global Enterprises" + }, + { "doctype": "CRM Organization", "organization_name": "StartUp Inc" }, + { "doctype": "CRM Organization", "organization_name": "Innovation Labs" }, + { "doctype": "CRM Organization", "organization_name": "Digital Corp" }, + { "doctype": "CRM Organization", "organization_name": "Cloud Systems" }, + { "doctype": "CRM Organization", "organization_name": "Data Inc" }, + { "doctype": "CRM Organization", "organization_name": "Smart Solutions" }, + { "doctype": "CRM Organization", "organization_name": "Future Tech" }, + { "doctype": "CRM Organization", "organization_name": "Alpha Industries" }, + { "doctype": "CRM Organization", "organization_name": "Beta Corp" }, + { "doctype": "CRM Organization", "organization_name": "Gamma Systems" }, + { "doctype": "CRM Organization", "organization_name": "Delta Solutions" }, + { "doctype": "CRM Organization", "organization_name": "Epsilon Tech" }, + { "doctype": "CRM Organization", "organization_name": "Zeta Corp" }, + { "doctype": "CRM Organization", "organization_name": "Eta Systems" }, + { "doctype": "CRM Organization", "organization_name": "Theta Inc" }, + { "doctype": "CRM Organization", "organization_name": "Iota Labs" }, + { "doctype": "CRM Organization", "organization_name": "Kappa Tech" }, + { "doctype": "CRM Organization", "organization_name": "Lambda Corp" }, + { "doctype": "CRM Organization", "organization_name": "Mu Systems" }, + { "doctype": "CRM Organization", "organization_name": "Nu Solutions" }, + { "doctype": "CRM Organization", "organization_name": "Xi Tech" }, + { "doctype": "CRM Organization", "organization_name": "Omicron Inc" }, + { "doctype": "CRM Organization", "organization_name": "Pi Labs" }, + { "doctype": "CRM Organization", "organization_name": "Rho Corp" }, + { "doctype": "CRM Organization", "organization_name": "Sigma Systems" }, + { "doctype": "CRM Organization", "organization_name": "Tau Solutions" }, + { "doctype": "CRM Organization", "organization_name": "Upsilon Tech" }, + { "doctype": "CRM Organization", "organization_name": "Phi Corp" }, + { "doctype": "CRM Organization", "organization_name": "Chi Systems" } +] diff --git a/crm/fcrm/doctype/crm_product/__init__.py b/crm/fcrm/doctype/crm_product/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_product/crm_product.js b/crm/fcrm/doctype/crm_product/crm_product.js new file mode 100644 index 0000000..fdb98ee --- /dev/null +++ b/crm/fcrm/doctype/crm_product/crm_product.js @@ -0,0 +1,22 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("CRM Product", { + onload: function (frm) { + if (!frm.is_new()) return; + frappe.db + .get_single_value("ERPNext CRM Settings", "enabled") + .then((enabled) => { + if (!enabled) return; + frappe.show_alert({ + message: __("Create products as Items in ERPNext"), + indicator: "blue", + }); + frappe.set_route("Form", "Item", "new"); + }); + }, + product_code: function (frm) { + if (!frm.doc.product_name) + frm.set_value("product_name", frm.doc.product_code); + }, +}); diff --git a/crm/fcrm/doctype/crm_product/crm_product.json b/crm/fcrm/doctype/crm_product/crm_product.json new file mode 100644 index 0000000..a7518f3 --- /dev/null +++ b/crm/fcrm/doctype/crm_product/crm_product.json @@ -0,0 +1,130 @@ +{ + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "autoname": "field:product_code", + "creation": "2025-04-28 11:45:09.309636", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "naming_series", + "product_code", + "product_name", + "column_break_bpdj", + "disabled", + "standard_rate", + "image", + "section_break_rtwm", + "description" + ], + "fields": [ + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Naming Series", + "options": "CRM-PROD-.YYYY.-" + }, + { + "fieldname": "product_code", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Product Code", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "product_name", + "fieldtype": "Data", + "label": "Product Name" + }, + { + "fieldname": "column_break_bpdj", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "disabled", + "fieldtype": "Check", + "label": "Disabled" + }, + { + "fieldname": "image", + "fieldtype": "Attach Image", + "label": "Image" + }, + { + "fieldname": "section_break_rtwm", + "fieldtype": "Section Break" + }, + { + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Description" + }, + { + "fieldname": "standard_rate", + "fieldtype": "Currency", + "label": "Standard Selling Rate" + } + ], + "grid_page_length": 50, + "image_field": "image", + "index_web_pages_for_search": 1, + "links": [], + "make_attachments_public": 1, + "modified": "2026-03-05 13:04:42.045313", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Product", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "row_format": "Dynamic", + "search_fields": "product_name,description", + "show_name_in_global_search": 1, + "show_preview_popup": 1, + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "title_field": "product_name", + "track_changes": 1, + "translated_doctype": 1 +} diff --git a/crm/fcrm/doctype/crm_product/crm_product.py b/crm/fcrm/doctype/crm_product/crm_product.py new file mode 100644 index 0000000..eaf4075 --- /dev/null +++ b/crm/fcrm/doctype/crm_product/crm_product.py @@ -0,0 +1,100 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + +from crm.fcrm.doctype.crm_product.sync_utils import payload_differs +from crm.integrations.erpnext.utils import ( + cascade_rename, + in_cascade, + should_push_to_erpnext, + validate_rename_conflict, +) + +CATALOGUE_FIELDS = ("standard_rate", "image", "disabled", "description") + + +class CRMProduct(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + description: DF.TextEditor | None + disabled: DF.Check + image: DF.AttachImage | None + naming_series: DF.Literal["CRM-PROD-.YYYY.-"] + product_code: DF.Data + product_name: DF.Data | None + standard_rate: DF.Currency + # end: auto-generated types + + def before_insert(self): + if self.flags.get("ignore_erpnext_sync"): + return + if should_push_to_erpnext(): + frappe.throw( + _( + "ERPNext integration is active. Create an Item in ERPNext and it will appear in CRM Product automatically." + ), + title=_("Use ERPNext to Create Products"), + ) + + def validate(self): + self.set_product_name() + + def set_product_name(self): + self.product_name = (self.product_name or self.product_code or "").strip() + + def on_update(self): + if self.flags.get("ignore_erpnext_sync"): + return + if not self.get("erpnext_item_code"): + return + if not should_push_to_erpnext(): + return + _push_to_item(self) + + def before_rename(self, olddn, newdn, merge=False): + validate_rename_conflict("CRM Product", olddn, newdn, merge) + + def after_rename(self, olddn, newdn, merge=False): + cascade_rename("CRM Product", olddn, newdn, merge) + + def on_trash(self): + if in_cascade(): + return + if not self.get("erpnext_item_code"): + return + if not should_push_to_erpnext(): + return + if frappe.db.sql( + "SELECT 1 FROM `tabQuotation Item` WHERE item_code=%s LIMIT 1", + self.erpnext_item_code, + ): + frappe.throw( + _( + "Cannot delete: linked ERPNext Item {0} is referenced by a Quotation. " + "Remove the reference or delete the Item in ERPNext first." + ).format(self.erpnext_item_code) + ) + + +def _push_to_item(doc): + item_code = doc.erpnext_item_code + if not frappe.db.exists("Item", item_code): + frappe.db.set_value("CRM Product", doc.name, "erpnext_item_code", None) + return + data = {"item_name": doc.product_name, **{f: doc.get(f) for f in CATALOGUE_FIELDS}} + current = frappe.db.get_value("Item", item_code, list(data.keys()), as_dict=True) or {} + if not payload_differs(data, current): + return + item = frappe.get_doc("Item", item_code) + item.update(data) + item.flags.ignore_crm_sync = True + item.save() diff --git a/crm/fcrm/doctype/crm_product/crm_product_list.js b/crm/fcrm/doctype/crm_product/crm_product_list.js new file mode 100644 index 0000000..acc7708 --- /dev/null +++ b/crm/fcrm/doctype/crm_product/crm_product_list.js @@ -0,0 +1,19 @@ +frappe.listview_settings["CRM Product"] = { + onload(listview) { + listview.page.set_primary_action(__("Add CRM Product"), async () => { + const enabled = await frappe.db.get_single_value( + "ERPNext CRM Settings", + "enabled" + ); + if (enabled) { + frappe.show_alert({ + message: __("Create products as Items in ERPNext"), + indicator: "blue", + }); + frappe.set_route("Form", "Item", "new"); + } else { + frappe.new_doc("CRM Product"); + } + }); + }, +}; diff --git a/crm/fcrm/doctype/crm_product/reconcile_job.py b/crm/fcrm/doctype/crm_product/reconcile_job.py new file mode 100644 index 0000000..b60af64 --- /dev/null +++ b/crm/fcrm/doctype/crm_product/reconcile_job.py @@ -0,0 +1,194 @@ +import frappe +from frappe import _ + +from crm.fcrm.doctype.crm_product.reconciliation import ( + CATALOGUE_FIELDS, + classify_pair, + detect_orphan, +) + + +def enqueue_reconciliation(): + frappe.enqueue( + "crm.fcrm.doctype.crm_product.reconcile_job.run_reconciliation", + queue="long", + timeout=1200, + job_id="crm_product_item_reconciliation", + deduplicate=True, + ) + + +def run_reconciliation() -> dict: + settings = frappe.get_single("ERPNext CRM Settings") + skip_reason = _get_skip_reason(settings) + if skip_reason: + return {"skipped": skip_reason} + + summary = _initial_summary() + items = _get_items_by_code() + products = _get_products_by_code() + _sync_items_to_products(items, products, summary) + + issues = _sync_products_to_items(products, set(items.keys()), summary, settings) + _record_issues(settings, issues) + _sync_framework_mirrors() + frappe.publish_realtime("crm_product_sync_complete", user=frappe.session.user) + frappe.log_error(message=frappe.as_json(summary), title="CRM Product Reconciliation") + return summary + + +def _initial_summary(): + return { + "already_linked": 0, + "linked_by_exact_code": 0, + "created_in_crm": 0, + "created_in_erpnext": 0, + "unlinked_orphans": 0, + } + + +def _sync_items_to_products(items, products, summary): + for code, item in items.items(): + _sync_item_to_product(item, products.get(code), summary) + + +def _sync_products_to_items(products, existing_item_codes, summary, settings): + issues = [] + for code, product in products.items(): + if code in existing_item_codes: + continue + if _unlink_orphan(product, existing_item_codes, issues): + summary["unlinked_orphans"] += 1 + continue + if product.get("erpnext_item_code") or not settings.sync_products: + continue + doc = frappe.get_doc("CRM Product", product["name"]) + _create_item_from_product(doc) + summary["created_in_erpnext"] += 1 + return issues + + +def _unlink_orphan(product, existing_item_codes, issues): + if not detect_orphan(product, existing_item_codes): + return False + frappe.db.set_value("CRM Product", product["name"], "erpnext_item_code", None) + issues.append( + { + "product": product["name"], + "kind": "unlinked_orphan", + "detail": _("Linked Item {0} no longer exists").format(product["erpnext_item_code"]), + } + ) + return True + + +def _get_skip_reason(settings): + if not frappe.db.exists("DocType", "Item"): + return "erpnext_not_installed" + if not settings.enabled: + return "integration_disabled" + if settings.is_erpnext_in_different_site: + return "cross_site_not_supported" + + +def _get_items_by_code(): + return { + i["item_code"]: i + for i in frappe.db.get_all( + "Item", + fields=[ + "item_code", + "item_name", + "standard_rate", + "image", + "disabled", + "description", + "crm_product_code", + ], + ) + } + + +def _get_products_by_code(): + return { + p["product_code"]: p + for p in frappe.db.get_all( + "CRM Product", + fields=[ + "name", + "product_code", + "product_name", + "standard_rate", + "image", + "disabled", + "description", + "erpnext_item_code", + ], + ) + } + + +def _sync_item_to_product(item, product, summary): + if not product: + _create_crm_product_from_item(item) + summary["created_in_crm"] += 1 + return + + action = classify_pair(item, product) + if action.rule == "already_linked": + summary["already_linked"] += 1 + else: + summary["linked_by_exact_code"] += 1 + if action.crm_updates: + frappe.db.set_value("CRM Product", product["name"], action.crm_updates) + if action.item_updates: + frappe.db.set_value("Item", item["item_code"], action.item_updates) + + +def _sync_framework_mirrors(): + from crm.integrations.erpnext.doc_share import sync_shared_docs + from crm.integrations.erpnext.user_permission import sync_user_permissions + + sync_user_permissions() + sync_shared_docs() + + +def _create_crm_product_from_item(item): + product = frappe.new_doc("CRM Product") + product.product_code = item["item_code"] + product.erpnext_item_code = item["item_code"] + product.product_name = item.get("item_name") + for f in CATALOGUE_FIELDS: + product.set(f, item.get(f)) + product.flags.ignore_erpnext_sync = True + product.insert(ignore_permissions=True) + frappe.db.set_value("Item", item["item_code"], "crm_product_code", product.name) + + +def _create_item_from_product(product_doc): + item = frappe.get_doc( + { + "doctype": "Item", + "item_code": product_doc.product_code, + "item_name": product_doc.product_name or product_doc.product_code, + "standard_rate": product_doc.standard_rate, + "image": product_doc.image, + "disabled": product_doc.disabled, + "description": product_doc.description, + "crm_product_code": product_doc.name, + "item_group": frappe.db.get_single_value("Stock Settings", "item_group") or "All Item Groups", + "stock_uom": frappe.db.get_single_value("Stock Settings", "stock_uom") or "Nos", + "is_stock_item": 0, + } + ) + item.flags.ignore_crm_sync = True + item.insert(ignore_permissions=True) + frappe.db.set_value("CRM Product", product_doc.name, "erpnext_item_code", item.name) + + +def _record_issues(settings, issues): + if not issues: + return + for issue in issues: + settings.append("sync_issues", issue) + settings.save(ignore_permissions=True) diff --git a/crm/fcrm/doctype/crm_product/reconciliation.py b/crm/fcrm/doctype/crm_product/reconciliation.py new file mode 100644 index 0000000..99d3015 --- /dev/null +++ b/crm/fcrm/doctype/crm_product/reconciliation.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass, field +from typing import Literal + +CATALOGUE_FIELDS = ("standard_rate", "image", "disabled", "description") +Rule = Literal["already_linked", "exact_code", "no_match"] + + +@dataclass +class PairAction: + rule: Rule + crm_updates: dict = field(default_factory=dict) + item_updates: dict = field(default_factory=dict) + + +def classify_pair(item: dict, product: dict) -> PairAction: + if item.get("crm_product_code") == product.get("name") and product.get("erpnext_item_code") == item.get( + "item_code" + ): + return PairAction(rule="already_linked") + + if item.get("item_code") == product.get("product_code"): + updates = {"erpnext_item_code": item["item_code"]} + for f in CATALOGUE_FIELDS: + updates[f] = item.get(f) + if not product.get("product_name") and item.get("item_name"): + updates["product_name"] = item["item_name"] + return PairAction( + rule="exact_code", + crm_updates=updates, + item_updates={"crm_product_code": product["name"]}, + ) + + return PairAction(rule="no_match") + + +def detect_orphan(product: dict, existing_item_codes: set[str]) -> bool: + linked = product.get("erpnext_item_code") + return bool(linked) and linked not in existing_item_codes diff --git a/crm/fcrm/doctype/crm_product/sync_utils.py b/crm/fcrm/doctype/crm_product/sync_utils.py new file mode 100644 index 0000000..6148d35 --- /dev/null +++ b/crm/fcrm/doctype/crm_product/sync_utils.py @@ -0,0 +1,6 @@ +def payload_differs(payload: dict, target: dict) -> bool: + """True if any field in payload has a meaningfully different value in target.""" + for key, new_value in payload.items(): + if (new_value or None) != (target.get(key) or None): + return True + return False diff --git a/crm/fcrm/doctype/crm_product/test_product_item_sync.py b/crm/fcrm/doctype/crm_product/test_product_item_sync.py new file mode 100644 index 0000000..a2e812c --- /dev/null +++ b/crm/fcrm/doctype/crm_product/test_product_item_sync.py @@ -0,0 +1,429 @@ +import frappe +from frappe.tests.utils import FrappeTestCase + +ITEM_GROUP = "All Item Groups" + + +def enable_product_sync(): + """CI installs ERPNext but leaves the integration off; enable it and create the link fields. + + Mirrors what configuring the integration does, so the sync hooks and the + ``erpnext_item_code`` / ``crm_product_code`` custom fields are in place. + """ + settings = frappe.get_single("ERPNext CRM Settings") + settings.enabled = 1 + settings.is_erpnext_in_different_site = 0 + settings.sync_products = 1 + settings.save(ignore_permissions=True) + + from crm.patches.v1_0.create_custom_fields_for_product_item_sync import execute + + execute() + + +def set_bidirectional_product_sync(value): + frappe.db.set_single_value("ERPNext CRM Settings", "sync_products", value) + frappe.clear_document_cache("ERPNext CRM Settings", "ERPNext CRM Settings") + + +class TestReverseLinkField(FrappeTestCase): + def test_item_has_crm_product_code_field(self): + if not frappe.db.exists("DocType", "Item"): + self.skipTest("ERPNext not installed") + enable_product_sync() + self.assertTrue(frappe.db.has_column("Item", "crm_product_code")) + + +class TestEchoLoopBreaker(FrappeTestCase): + def test_payload_identical_to_target_is_noop(self): + from crm.fcrm.doctype.crm_product.sync_utils import payload_differs + + self.assertFalse( + payload_differs( + {"item_name": "Widget", "standard_rate": 10, "disabled": 0}, + {"item_name": "Widget", "standard_rate": 10, "disabled": 0}, + ) + ) + + def test_payload_differs_when_any_field_changed(self): + from crm.fcrm.doctype.crm_product.sync_utils import payload_differs + + self.assertTrue( + payload_differs( + {"item_name": "Widget", "standard_rate": 12}, + {"item_name": "Widget", "standard_rate": 10}, + ) + ) + + def test_none_and_empty_string_treated_as_equal(self): + from crm.fcrm.doctype.crm_product.sync_utils import payload_differs + + self.assertFalse(payload_differs({"description": ""}, {"description": None})) + + +class TestReconciliationRules(FrappeTestCase): + def test_already_linked_pair_is_skipped(self): + from crm.fcrm.doctype.crm_product.reconciliation import classify_pair + + item = {"item_code": "A", "crm_product_code": "A"} + product = {"name": "A", "product_code": "A", "erpnext_item_code": "A"} + self.assertEqual(classify_pair(item, product).rule, "already_linked") + + def test_exact_code_match_resolves_erpnext_wins_for_catalogue(self): + from crm.fcrm.doctype.crm_product.reconciliation import classify_pair + + item = {"item_code": "B", "item_name": "From ERP", "standard_rate": 100} + product = { + "name": "B", + "product_code": "B", + "product_name": "From CRM", + "standard_rate": 80, + } + result = classify_pair(item, product) + self.assertEqual(result.rule, "exact_code") + self.assertEqual(result.crm_updates["standard_rate"], 100) + self.assertNotIn("product_name", result.crm_updates) + + def test_crm_name_filled_in_when_empty(self): + from crm.fcrm.doctype.crm_product.reconciliation import classify_pair + + item = {"item_code": "C", "item_name": "Filled"} + product = {"name": "C", "product_code": "C", "product_name": None} + result = classify_pair(item, product) + self.assertEqual(result.crm_updates["product_name"], "Filled") + + def test_orphan_detection(self): + from crm.fcrm.doctype.crm_product.reconciliation import detect_orphan + + self.assertTrue(detect_orphan({"erpnext_item_code": "GONE"}, {"OTHER"})) + self.assertFalse(detect_orphan({"erpnext_item_code": "HERE"}, {"HERE"})) + self.assertFalse(detect_orphan({"erpnext_item_code": None}, set())) + + +class TestItemHooks(FrappeTestCase): + def setUp(self): + if not frappe.db.exists("DocType", "Item"): + self.skipTest("ERPNext not installed") + enable_product_sync() + frappe.db.delete("CRM Product", {"product_code": ["like", "HOOK-%"]}) + frappe.db.delete("Item", {"item_code": ["like", "HOOK-%"]}) + + def test_item_insert_creates_linked_crm_product(self): + frappe.get_doc( + { + "doctype": "Item", + "item_code": "HOOK-1", + "item_name": "From ERP", + "item_group": ITEM_GROUP, + "standard_rate": 30, + } + ).insert() + self.assertTrue(frappe.db.exists("CRM Product", {"erpnext_item_code": "HOOK-1"})) + self.assertEqual(frappe.db.get_value("Item", "HOOK-1", "crm_product_code"), "HOOK-1") + + def test_item_insert_syncs_to_crm_when_bidirectional_sync_is_off(self): + set_bidirectional_product_sync(0) + frappe.get_doc( + {"doctype": "Item", "item_code": "HOOK-PULL", "item_name": "Pull", "item_group": ITEM_GROUP} + ).insert() + self.assertTrue(frappe.db.exists("CRM Product", {"erpnext_item_code": "HOOK-PULL"})) + + def test_item_rename_updates_crm_product_link(self): + frappe.get_doc( + {"doctype": "Item", "item_code": "HOOK-2", "item_name": "X", "item_group": ITEM_GROUP} + ).insert() + frappe.rename_doc("Item", "HOOK-2", "HOOK-2-NEW") + self.assertEqual( + frappe.db.get_value("CRM Product", {"erpnext_item_code": "HOOK-2-NEW"}, "erpnext_item_code"), + "HOOK-2-NEW", + ) + + def test_item_delete_removes_linked_crm_product(self): + frappe.get_doc( + {"doctype": "Item", "item_code": "HOOK-3", "item_name": "Z", "item_group": ITEM_GROUP} + ).insert() + self.assertTrue(frappe.db.exists("CRM Product", "HOOK-3")) + # ERPNext is master: deleting the Item cascades to its linked CRM Product. + frappe.delete_doc("Item", "HOOK-3") + self.assertFalse(frappe.db.exists("CRM Product", "HOOK-3")) + + +class TestReconcileJob(FrappeTestCase): + def setUp(self): + if not frappe.db.exists("DocType", "Item"): + self.skipTest("ERPNext not installed") + enable_product_sync() + frappe.db.delete("CRM Product", {"product_code": ["like", "JOB-%"]}) + frappe.db.delete("Item", {"item_code": ["like", "JOB-%"]}) + settings = frappe.get_single("ERPNext CRM Settings") + settings.sync_issues = [] + settings.save(ignore_permissions=True) + + def test_unlinked_duplicates_get_linked(self): + from crm.fcrm.doctype.crm_product.reconcile_job import run_reconciliation + + # Create an unlinked Item/Product pair (bypass the auto-sync hooks) so + # reconciliation has duplicates to link. + item = frappe.get_doc( + { + "doctype": "Item", + "item_code": "JOB-A", + "item_name": "Widget", + "item_group": ITEM_GROUP, + "standard_rate": 50, + } + ) + item.flags.ignore_crm_sync = True + item.insert(ignore_permissions=True) + product = frappe.get_doc( + {"doctype": "CRM Product", "product_code": "JOB-A", "product_name": "Widget"} + ) + product.flags.ignore_erpnext_sync = True + product.insert(ignore_permissions=True) + + summary = run_reconciliation() + self.assertEqual(summary["linked_by_exact_code"], 1) + self.assertEqual(frappe.db.get_value("CRM Product", "JOB-A", "erpnext_item_code"), "JOB-A") + self.assertEqual(frappe.db.get_value("Item", "JOB-A", "crm_product_code"), "JOB-A") + self.assertEqual(frappe.db.get_value("CRM Product", "JOB-A", "standard_rate"), 50) + + def test_orphan_link_is_unlinked_and_recorded(self): + from crm.fcrm.doctype.crm_product.reconcile_job import run_reconciliation + + product = frappe.get_doc( + { + "doctype": "CRM Product", + "product_code": "JOB-ORPHAN", + "product_name": "Orphan", + "erpnext_item_code": "DELETED-ITEM", + } + ) + product.flags.ignore_erpnext_sync = True + product.insert(ignore_permissions=True) + run_reconciliation() + self.assertIsNone(frappe.db.get_value("CRM Product", "JOB-ORPHAN", "erpnext_item_code")) + issues = frappe.get_all( + "CRM Product Sync Issue", + filters={"parent": "ERPNext CRM Settings", "product": "JOB-ORPHAN"}, + fields=["kind"], + ) + self.assertEqual(len(issues), 1) + self.assertEqual(issues[0]["kind"], "unlinked_orphan") + + def test_skipped_when_cross_site(self): + from crm.fcrm.doctype.crm_product.reconcile_job import run_reconciliation + + # Set the flag directly: saving the settings would trigger remote-site + # custom-field creation, which is unrelated to what we're asserting here. + self._set_cross_site(1) + try: + self.assertEqual(run_reconciliation(), {"skipped": "cross_site_not_supported"}) + finally: + self._set_cross_site(0) + + @staticmethod + def _set_cross_site(value): + frappe.db.set_single_value("ERPNext CRM Settings", "is_erpnext_in_different_site", value) + frappe.clear_document_cache("ERPNext CRM Settings", "ERPNext CRM Settings") + + +class TestCRMToERPNextCreate(FrappeTestCase): + def setUp(self): + if not frappe.db.exists("DocType", "Item"): + self.skipTest("ERPNext not installed") + enable_product_sync() + frappe.db.delete("CRM Product", {"product_code": ["like", "PUSH-%"]}) + frappe.db.delete("Item", {"item_code": ["like", "PUSH-%"]}) + + def test_direct_crm_product_creation_is_blocked(self): + # ERPNext is the master: products must originate from an Item, not be created directly. + with self.assertRaises(frappe.ValidationError): + frappe.get_doc( + {"doctype": "CRM Product", "product_code": "PUSH-1", "product_name": "Pushed"} + ).insert() + + def test_reconciliation_creates_item_for_unlinked_product(self): + from crm.fcrm.doctype.crm_product.reconcile_job import run_reconciliation + + product = frappe.get_doc( + {"doctype": "CRM Product", "product_code": "PUSH-2", "product_name": "X", "standard_rate": 25} + ) + product.flags.ignore_erpnext_sync = True + product.insert(ignore_permissions=True) + + run_reconciliation() + self.assertTrue(frappe.db.exists("Item", "PUSH-2")) + self.assertEqual(frappe.db.get_value("Item", "PUSH-2", "standard_rate"), 25) + self.assertEqual(frappe.db.get_value("Item", "PUSH-2", "crm_product_code"), "PUSH-2") + self.assertEqual(frappe.db.get_value("CRM Product", "PUSH-2", "erpnext_item_code"), "PUSH-2") + + def test_reconciliation_does_not_create_item_when_bidirectional_sync_is_off(self): + from crm.fcrm.doctype.crm_product.reconcile_job import run_reconciliation + + set_bidirectional_product_sync(0) + frappe.get_doc({"doctype": "CRM Product", "product_code": "PUSH-OFF", "product_name": "Off"}).insert() + + run_reconciliation() + self.assertFalse(frappe.db.exists("Item", "PUSH-OFF")) + + def test_update_to_linked_product_pushes_to_item(self): + from crm.fcrm.doctype.crm_product.reconcile_job import run_reconciliation + + product = frappe.get_doc( + {"doctype": "CRM Product", "product_code": "PUSH-3", "product_name": "Y", "standard_rate": 10} + ) + product.flags.ignore_erpnext_sync = True + product.insert(ignore_permissions=True) + run_reconciliation() # creates and links Item PUSH-3 + + p = frappe.get_doc("CRM Product", "PUSH-3") + p.standard_rate = 99 + p.save() + self.assertEqual(frappe.db.get_value("Item", "PUSH-3", "standard_rate"), 99) + + +class TestUserPermissionMirror(FrappeTestCase): + def setUp(self): + if not frappe.db.exists("DocType", "Item"): + self.skipTest("ERPNext not installed") + enable_product_sync() + frappe.db.delete("User Permission", {"for_value": ["like", "PERM-%"]}) + frappe.db.delete("CRM Product", {"product_code": ["like", "PERM-%"]}) + frappe.db.delete("Item", {"item_code": ["like", "PERM-%"]}) + frappe.get_doc( + {"doctype": "Item", "item_code": "PERM-1", "item_name": "P", "item_group": ITEM_GROUP} + ).insert(ignore_permissions=True) + + def test_user_permission_on_item_mirrors_to_crm_product(self): + frappe.get_doc( + { + "doctype": "User Permission", + "user": "Administrator", + "allow": "Item", + "for_value": "PERM-1", + "apply_to_all_doctypes": 1, + } + ).insert(ignore_permissions=True) + self.assertTrue( + frappe.db.exists( + "User Permission", + {"user": "Administrator", "allow": "CRM Product", "for_value": "PERM-1"}, + ) + ) + + def test_user_permission_on_crm_product_mirrors_to_item(self): + frappe.get_doc( + { + "doctype": "User Permission", + "user": "Administrator", + "allow": "CRM Product", + "for_value": "PERM-1", + "apply_to_all_doctypes": 1, + } + ).insert(ignore_permissions=True) + self.assertTrue( + frappe.db.exists( + "User Permission", + {"user": "Administrator", "allow": "Item", "for_value": "PERM-1"}, + ) + ) + + def test_delete_user_permission_deletes_mirror(self): + p = frappe.get_doc( + { + "doctype": "User Permission", + "user": "Administrator", + "allow": "Item", + "for_value": "PERM-1", + "apply_to_all_doctypes": 1, + } + ).insert(ignore_permissions=True) + p.delete(ignore_permissions=True) + self.assertFalse( + frappe.db.exists( + "User Permission", + {"user": "Administrator", "allow": "CRM Product", "for_value": "PERM-1"}, + ) + ) + + +class TestDocShareMirror(FrappeTestCase): + def setUp(self): + if not frappe.db.exists("DocType", "Item"): + self.skipTest("ERPNext not installed") + enable_product_sync() + frappe.db.delete("DocShare", {"share_name": ["like", "SHARE-%"]}) + frappe.db.delete("CRM Product", {"product_code": ["like", "SHARE-%"]}) + frappe.db.delete("Item", {"item_code": ["like", "SHARE-%"]}) + frappe.get_doc( + {"doctype": "Item", "item_code": "SHARE-1", "item_name": "S", "item_group": ITEM_GROUP} + ).insert(ignore_permissions=True) + + def test_share_on_item_mirrors_to_crm_product(self): + frappe.get_doc( + { + "doctype": "DocShare", + "user": "Administrator", + "share_doctype": "Item", + "share_name": "SHARE-1", + "read": 1, + } + ).insert(ignore_permissions=True) + self.assertTrue( + frappe.db.exists( + "DocShare", + {"user": "Administrator", "share_doctype": "CRM Product", "share_name": "SHARE-1"}, + ) + ) + + def test_delete_share_deletes_mirror(self): + s = frappe.get_doc( + { + "doctype": "DocShare", + "user": "Administrator", + "share_doctype": "Item", + "share_name": "SHARE-1", + "read": 1, + } + ).insert(ignore_permissions=True) + s.delete(ignore_permissions=True) + self.assertFalse( + frappe.db.exists( + "DocShare", + {"user": "Administrator", "share_doctype": "CRM Product", "share_name": "SHARE-1"}, + ) + ) + + +class TestDeleteGuard(FrappeTestCase): + def test_cannot_delete_crm_product_with_referenced_item(self): + if not frappe.db.exists("DocType", "Quotation"): + self.skipTest("ERPNext not installed") + if not frappe.db.exists("Customer", "_Test Customer"): + self.skipTest("ERPNext transactional test data not set up") + company = frappe.defaults.get_global_default("company") or frappe.db.get_value("Company", {}, "name") + if not company: + self.skipTest("No ERPNext company available") + enable_product_sync() + frappe.db.delete("CRM Product", {"product_code": "DEL-1"}) + frappe.db.delete("Item", {"item_code": "DEL-1"}) + # Create via the Item so the linked CRM Product is generated by the integration. + frappe.get_doc( + {"doctype": "Item", "item_code": "DEL-1", "item_name": "X", "item_group": ITEM_GROUP} + ).insert(ignore_permissions=True) + # Set currency explicitly: a fresh ERPNext test site has no Price List to derive it from. + quotation = frappe.get_doc( + { + "doctype": "Quotation", + "quotation_to": "Customer", + "party_name": "_Test Customer", + "company": company, + "currency": frappe.db.get_value("Company", company, "default_currency"), + "conversion_rate": 1, + "items": [{"item_code": "DEL-1", "qty": 1, "rate": 10}], + } + ) + quotation.flags.ignore_price_list = True + quotation.insert(ignore_permissions=True) + with self.assertRaises(frappe.ValidationError): + frappe.delete_doc("CRM Product", "DEL-1") diff --git a/crm/fcrm/doctype/crm_product_sync_issue/__init__.py b/crm/fcrm/doctype/crm_product_sync_issue/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_product_sync_issue/crm_product_sync_issue.js b/crm/fcrm/doctype/crm_product_sync_issue/crm_product_sync_issue.js new file mode 100644 index 0000000..5cbdd17 --- /dev/null +++ b/crm/fcrm/doctype/crm_product_sync_issue/crm_product_sync_issue.js @@ -0,0 +1,8 @@ +// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Lost Reason", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_product_sync_issue/crm_product_sync_issue.json b/crm/fcrm/doctype/crm_product_sync_issue/crm_product_sync_issue.json new file mode 100644 index 0000000..e2be38f --- /dev/null +++ b/crm/fcrm/doctype/crm_product_sync_issue/crm_product_sync_issue.json @@ -0,0 +1,66 @@ +{ + "actions": [], + "autoname": "autoincrement", + "creation": "2026-05-24 12:18:55.660189", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "product", + "kind", + "detail", + "detected_on", + "dismissed" + ], + "fields": [ + { + "fieldname": "product", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Product", + "options": "CRM Product", + "read_only": 1 + }, + { + "fieldname": "kind", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Kind", + "options": "unlinked_orphan", + "read_only": 1 + }, + { + "fieldname": "detail", + "fieldtype": "Small Text", + "label": "Detail", + "read_only": 1 + }, + { + "default": "now", + "fieldname": "detected_on", + "fieldtype": "Datetime", + "label": "Detected On", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "dismissed", + "fieldtype": "Check", + "label": "Dismissed" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-05-27 09:22:48.995255", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Product Sync Issue", + "naming_rule": "Autoincrement", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_product_sync_issue/crm_product_sync_issue.py b/crm/fcrm/doctype/crm_product_sync_issue/crm_product_sync_issue.py new file mode 100644 index 0000000..5fc9197 --- /dev/null +++ b/crm/fcrm/doctype/crm_product_sync_issue/crm_product_sync_issue.py @@ -0,0 +1,27 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class CRMProductSyncIssue(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + detail: DF.SmallText | None + detected_on: DF.Datetime | None + dismissed: DF.Check + kind: DF.Literal["unlinked_orphan"] + name: DF.Int | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + product: DF.Link | None + # end: auto-generated types + + pass diff --git a/crm/fcrm/doctype/crm_product_sync_issue/test_crm_product_sync_issue.py b/crm/fcrm/doctype/crm_product_sync_issue/test_crm_product_sync_issue.py new file mode 100644 index 0000000..a6790dd --- /dev/null +++ b/crm/fcrm/doctype/crm_product_sync_issue/test_crm_product_sync_issue.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import UnitTestCase + + +class TestCRMProductSyncIssue(UnitTestCase): + pass diff --git a/crm/fcrm/doctype/crm_products/__init__.py b/crm/fcrm/doctype/crm_products/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_products/crm_products.json b/crm/fcrm/doctype/crm_products/crm_products.json new file mode 100644 index 0000000..21143f1 --- /dev/null +++ b/crm/fcrm/doctype/crm_products/crm_products.json @@ -0,0 +1,136 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-04-28 12:50:49.812915", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "product_code", + "column_break_gvbc", + "product_name", + "section_break_fnvf", + "qty", + "column_break_ajac", + "rate", + "section_break_olqb", + "discount_percentage", + "column_break_uvra", + "discount_amount", + "section_break_cnpb", + "column_break_pozr", + "amount", + "column_break_ejqw", + "net_amount" + ], + "fields": [ + { + "fieldname": "column_break_gvbc", + "fieldtype": "Column Break" + }, + { + "fieldname": "product_name", + "fieldtype": "Data", + "label": "Product Name", + "reqd": 1 + }, + { + "fieldname": "section_break_fnvf", + "fieldtype": "Section Break" + }, + { + "fieldname": "section_break_olqb", + "fieldtype": "Section Break" + }, + { + "bold": 1, + "fieldname": "discount_percentage", + "fieldtype": "Percent", + "label": "Discount %" + }, + { + "fieldname": "discount_amount", + "fieldtype": "Currency", + "label": "Discount Amount", + "options": "currency", + "read_only": 1 + }, + { + "fieldname": "section_break_cnpb", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_pozr", + "fieldtype": "Column Break" + }, + { + "bold": 1, + "fieldname": "rate", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Rate", + "options": "currency", + "reqd": 1 + }, + { + "bold": 1, + "fieldname": "amount", + "fieldtype": "Currency", + "label": "Amount", + "options": "currency", + "read_only": 1 + }, + { + "bold": 1, + "depends_on": "discount_percentage", + "description": "Amount after discount", + "fieldname": "net_amount", + "fieldtype": "Currency", + "label": "Net Amount", + "options": "currency", + "read_only": 1 + }, + { + "bold": 1, + "columns": 5, + "fieldname": "product_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Product", + "options": "CRM Product" + }, + { + "bold": 1, + "default": "1", + "fieldname": "qty", + "fieldtype": "Float", + "label": "Quantity" + }, + { + "fieldname": "column_break_ajac", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_uvra", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_ejqw", + "fieldtype": "Column Break" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-07-29 21:30:34.082020", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Products", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_products/crm_products.py b/crm/fcrm/doctype/crm_products/crm_products.py new file mode 100644 index 0000000..fd3415e --- /dev/null +++ b/crm/fcrm/doctype/crm_products/crm_products.py @@ -0,0 +1,193 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + + +class CRMProducts(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + amount: DF.Currency + discount_amount: DF.Currency + discount_percentage: DF.Percent + net_amount: DF.Currency + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + product_code: DF.Link | None + product_name: DF.Data + qty: DF.Float + rate: DF.Currency + # end: auto-generated types + + pass + + +@frappe.whitelist() +def get_product_rate_details(product_code: str, deal: str | None = None) -> dict: + product = ( + frappe.db.get_value("CRM Product", product_code, ["product_name", "standard_rate"], as_dict=True) + or {} + ) + rate = _contextual_rate(product_code, deal) or product.get("standard_rate") + return {"product_name": product.get("product_name"), "rate": rate} + + +def _contextual_rate(product_code: str, deal: str | None): + """Rate from the linked ERPNext Item, priced in the deal's context.""" + if not frappe.get_meta("CRM Product").has_field("erpnext_item_code"): + return None + item_code = frappe.db.get_value("CRM Product", product_code, "erpnext_item_code") + if not item_code: + return None + return get_deal_product_rate(item_code, deal) + + +def _resolve_price_list(customer: str | None): + """Customer's default price list, else the default selling price list.""" + if customer and frappe.db.exists("Customer", customer): + from erpnext.accounts.party import get_default_price_list + + if pl := get_default_price_list(frappe.get_cached_doc("Customer", customer)): + return pl + return frappe.db.get_single_value("Selling Settings", "selling_price_list") + + +def get_deal_product_rate(item_code: str, deal: str | None = None): + """Resolve a deal line rate the way ERPNext prices a quotation line.""" + if not frappe.db.exists("DocType", "Item Price"): + return None + customer = frappe.db.get_value("CRM Deal", deal, "erpnext_customer") if deal else None + price_list = _resolve_price_list(customer) + if not price_list: + return None + from erpnext.stock.get_item_details import get_item_price + + pctx = frappe._dict( + { + "price_list": price_list, + "customer": customer, + "uom": frappe.db.get_value("Item", item_code, "stock_uom"), + "transaction_date": frappe.utils.nowdate(), + } + ) + return _price_list_rate(get_item_price(pctx, item_code)) + + +def _price_list_rate(rows): + if not rows: + return None + row = rows[0] + return row.price_list_rate if isinstance(row, dict) else row[1] + + +def create_product_details_script(doctype): + name = "Product Details Script for " + doctype + script = get_product_details_script(doctype) + # Standard script is app-owned: keep it in sync with code on every migrate. + if frappe.db.exists("CRM Form Script", name): + if frappe.db.get_value("CRM Form Script", name, "script") != script: + frappe.db.set_value("CRM Form Script", name, "script", script) + return + frappe.get_doc( + { + "doctype": "CRM Form Script", + "name": name, + "dt": doctype, + "view": "Form", + "script": script, + "enabled": 1, + "is_standard": 1, + } + ).insert() + + +def get_product_details_script(doctype): + doctype_class = "class " + doctype.replace(" ", "") + + return ( + doctype_class + + " {" + + """ + update_total() { + let total = 0 + let total_qty = 0 + let net_total = 0 + let discount_applied = false + + this.doc.products.forEach((d) => { + total += d.amount + net_total += d.net_amount + if (d.discount_percentage > 0) { + discount_applied = true + } + }) + + this.doc.total = total + this.doc.net_total = net_total || total + + if (!net_total && discount_applied) { + this.doc.net_total = net_total + } + } +} + +class CRMProducts { + products_add() { + let row = this.doc.getRow('products') + row.trigger('qty') + this.doc.trigger('update_total') + } + + products_remove() { + this.doc.trigger('update_total') + } + + async product_code(idx) { + let row = this.doc.getRow('products', idx) + let productCode = row.product_code + + let a = await call("crm.fcrm.doctype.crm_products.crm_products.get_product_rate_details", { + product_code: productCode, + deal: this.doc.name, + }) + if (!a || row.product_code !== productCode) return + + row.product_name = a.product_name + row.rate = a.rate ?? 0 + row.trigger("rate") + } + + qty(idx) { + let row = this.doc.getRow('products', idx) + row.amount = row.qty * row.rate + row.trigger('discount_percentage', idx) + } + + rate() { + let row = this.doc.getRow('products') + row.amount = row.qty * row.rate + row.trigger('discount_percentage') + } + + discount_percentage(idx) { + let row = this.doc.getRow('products', idx) + if (!row.discount_percentage) { + row.net_amount = row.amount + row.discount_amount = 0 + } + if (row.discount_percentage && row.amount) { + row.discount_amount = (row.discount_percentage / 100) * row.amount + row.net_amount = row.amount - row.discount_amount + } + this.doc.trigger('update_total') + } +}""" + ) diff --git a/crm/fcrm/doctype/crm_products/test_crm_products.py b/crm/fcrm/doctype/crm_products/test_crm_products.py new file mode 100644 index 0000000..4d0d08f --- /dev/null +++ b/crm/fcrm/doctype/crm_products/test_crm_products.py @@ -0,0 +1,134 @@ +import unittest +from importlib.util import find_spec + +import frappe +from frappe.tests.utils import FrappeTestCase + +# These exercise ERPNext's pricing API; skip when erpnext isn't importable. +ERPNEXT_INSTALLED = find_spec("erpnext") is not None + + +@unittest.skipUnless(ERPNEXT_INSTALLED, "erpnext not installed") +class TestGetDealProductRate(FrappeTestCase): + def _fake_get_item_price(self, captured, result): + from erpnext.stock import get_item_details + + def fake(pctx, item_code, **kwargs): + captured.update(pctx=pctx, item_code=item_code) + return result + + orig = get_item_details.get_item_price + get_item_details.get_item_price = fake + self.addCleanup(setattr, get_item_details, "get_item_price", orig) + + def _fake_stock_uom(self, uom): + orig = frappe.db.get_value + + def fake(doctype, filters=None, fieldname="name", *a, **k): + if doctype == "Item" and fieldname == "stock_uom": + return uom + return orig(doctype, filters, fieldname, *a, **k) + + frappe.db.get_value = fake + self.addCleanup(setattr, frappe.db, "get_value", orig) + + def _fake_price_list(self, value): + from crm.fcrm.doctype.crm_products import crm_products + + orig = crm_products._resolve_price_list + crm_products._resolve_price_list = lambda customer: value + self.addCleanup(setattr, crm_products, "_resolve_price_list", orig) + + def test_passes_price_list_uom_and_date_context(self): + from crm.fcrm.doctype.crm_products import crm_products + + captured = {} + self._fake_get_item_price(captured, [frappe._dict(price_list_rate=1021, uom="Nos")]) + self._fake_stock_uom("Nos") + self._fake_price_list("_Test Price List 2") + + rate = crm_products.get_deal_product_rate("ITM") + + self.assertEqual(rate, 1021) + self.assertEqual(captured["item_code"], "ITM") + self.assertIsInstance(captured["pctx"], frappe._dict) + self.assertEqual(captured["pctx"]["price_list"], "_Test Price List 2") + self.assertEqual(captured["pctx"]["uom"], "Nos") + self.assertIn("transaction_date", captured["pctx"]) + + def test_reads_v15_tuple_result(self): + from crm.fcrm.doctype.crm_products import crm_products + + self._fake_get_item_price({}, [("IP-1", 1021, "Nos")]) + self._fake_stock_uom("Nos") + self._fake_price_list("_Test Price List 2") + + self.assertEqual(crm_products.get_deal_product_rate("ITM"), 1021) + + def test_returns_none_when_no_matching_price(self): + from crm.fcrm.doctype.crm_products import crm_products + + self._fake_get_item_price({}, []) + self._fake_stock_uom("Nos") + self._fake_price_list("Standard Selling") + + self.assertIsNone(crm_products.get_deal_product_rate("ITM")) + + def test_returns_none_without_price_list(self): + from crm.fcrm.doctype.crm_products import crm_products + + self._fake_price_list(None) + self.assertIsNone(crm_products.get_deal_product_rate("ITM")) + + +@unittest.skipUnless(ERPNEXT_INSTALLED, "erpnext not installed") +class TestGetProductRateDetails(FrappeTestCase): + def _fake_contextual_rate(self, value): + from crm.fcrm.doctype.crm_products import crm_products + + orig = crm_products._contextual_rate + crm_products._contextual_rate = lambda product_code, deal: value + self.addCleanup(setattr, crm_products, "_contextual_rate", orig) + + def _fake_product(self, product_name, standard_rate): + orig = frappe.db.get_value + + def fake(doctype, filters=None, fieldname="name", *a, **k): + if doctype == "CRM Product": + return frappe._dict(product_name=product_name, standard_rate=standard_rate) + return orig(doctype, filters, fieldname, *a, **k) + + frappe.db.get_value = fake + self.addCleanup(setattr, frappe.db, "get_value", orig) + + def test_prefers_contextual_rate(self): + from crm.fcrm.doctype.crm_products import crm_products + + self._fake_product("Widget", 90) + self._fake_contextual_rate(1021) + + out = crm_products.get_product_rate_details("CRM-1001", deal="D-1") + + self.assertEqual(out["rate"], 1021) + self.assertEqual(out["product_name"], "Widget") + + def test_falls_back_to_standard_rate(self): + from crm.fcrm.doctype.crm_products import crm_products + + self._fake_product("Widget", 90) + self._fake_contextual_rate(None) + + out = crm_products.get_product_rate_details("CRM-1001") + + self.assertEqual(out["rate"], 90) + + +class TestProductDetailsScript(unittest.TestCase): + def test_product_change_replaces_existing_rate(self): + from crm.fcrm.doctype.crm_products.crm_products import get_product_details_script + + script = get_product_details_script("CRM Deal") + + self.assertIn("row.rate = a.rate ?? 0", script) + self.assertNotIn("!row.rate", script) + self.assertIn("row.product_code !== productCode", script) diff --git a/crm/fcrm/doctype/crm_rolling_response_time/__init__.py b/crm/fcrm/doctype/crm_rolling_response_time/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_rolling_response_time/crm_rolling_response_time.json b/crm/fcrm/doctype/crm_rolling_response_time/crm_rolling_response_time.json new file mode 100644 index 0000000..1867977 --- /dev/null +++ b/crm/fcrm/doctype/crm_rolling_response_time/crm_rolling_response_time.json @@ -0,0 +1,52 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-11-10 19:57:22.985858", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "response_time", + "responded_on", + "status" + ], + "fields": [ + { + "fieldname": "response_time", + "fieldtype": "Duration", + "in_list_view": 1, + "label": "Response Time", + "read_only": 1 + }, + { + "fieldname": "responded_on", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "Responded On", + "read_only": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Fulfilled\nFailed", + "read_only": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-02-23 12:10:20.656031", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Rolling Response Time", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_rolling_response_time/crm_rolling_response_time.py b/crm/fcrm/doctype/crm_rolling_response_time/crm_rolling_response_time.py new file mode 100644 index 0000000..0aa8382 --- /dev/null +++ b/crm/fcrm/doctype/crm_rolling_response_time/crm_rolling_response_time.py @@ -0,0 +1,25 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMRollingResponseTime(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + responded_on: DF.Datetime | None + response_time: DF.Duration | None + status: DF.Literal["Fulfilled", "Failed"] + # end: auto-generated types + + pass diff --git a/crm/fcrm/doctype/crm_sales_hierarchy/__init__.py b/crm/fcrm/doctype/crm_sales_hierarchy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_sales_hierarchy/crm_sales_hierarchy.js b/crm/fcrm/doctype/crm_sales_hierarchy/crm_sales_hierarchy.js new file mode 100644 index 0000000..cfeb0bd --- /dev/null +++ b/crm/fcrm/doctype/crm_sales_hierarchy/crm_sales_hierarchy.js @@ -0,0 +1,8 @@ +// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Sales Hierarchy", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_sales_hierarchy/crm_sales_hierarchy.json b/crm/fcrm/doctype/crm_sales_hierarchy/crm_sales_hierarchy.json new file mode 100644 index 0000000..0021758 --- /dev/null +++ b/crm/fcrm/doctype/crm_sales_hierarchy/crm_sales_hierarchy.json @@ -0,0 +1,131 @@ +{ + "actions": [], + "allow_import": 1, + "autoname": "hash", + "creation": "2026-04-19 00:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "full_name", + "reports_to", + "is_group", + "column_break_1", + "user", + "section_break_1", + "old_parent", + "column_break_2", + "lft", + "rgt" + ], + "fields": [ + { + "fetch_from": "user.full_name", + "fieldname": "full_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Full Name", + "read_only": 1 + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "label": "User", + "options": "User", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "section_break_1", + "fieldtype": "Section Break" + }, + { + "fieldname": "old_parent", + "fieldtype": "Link", + "hidden": 1, + "label": "Old Parent", + "options": "CRM Sales Hierarchy" + }, + { + "fieldname": "reports_to", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Reports To", + "options": "CRM Sales Hierarchy" + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "fieldname": "lft", + "fieldtype": "Int", + "hidden": 1, + "label": "Left", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "rgt", + "fieldtype": "Int", + "hidden": 1, + "label": "Right", + "no_copy": 1, + "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_group", + "fieldtype": "Check", + "label": "Is Group" + } + ], + "index_web_pages_for_search": 1, + "is_tree": 1, + "links": [], + "modified": "2026-04-21 10:35:39.170967", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Sales Hierarchy", + "naming_rule": "Random", + "nsm_parent_field": "reports_to", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1 + }, + { + "read": 1, + "role": "Sales User" + } + ], + "row_format": "Dynamic", + "show_title_field_in_link": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "full_name", + "translated_doctype": 1 +} diff --git a/crm/fcrm/doctype/crm_sales_hierarchy/crm_sales_hierarchy.py b/crm/fcrm/doctype/crm_sales_hierarchy/crm_sales_hierarchy.py new file mode 100644 index 0000000..e74313c --- /dev/null +++ b/crm/fcrm/doctype/crm_sales_hierarchy/crm_sales_hierarchy.py @@ -0,0 +1,56 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.utils.nestedset import NestedSet, update_nsm + + +class CRMSalesHierarchy(NestedSet): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + enabled: DF.Check + full_name: DF.Data | None + is_group: DF.Check + lft: DF.Int + old_parent: DF.Link | None + reports_to: DF.Link | None + rgt: DF.Int + user: DF.Link | None + # end: auto-generated types + + nsm_parent_field = "reports_to" + + def on_update(self): + update_nsm(self) + frappe.cache.delete_value("crm_sales_hierarchy_subtree") + + def validate(self): + if self.user: + # Ensure the same user is not mapped to two different nodes + existing = frappe.db.get_value( + "CRM Sales Hierarchy", + {"user": self.user, "name": ["!=", self.name]}, + "name", + ) + if existing: + frappe.throw( + frappe._("User {0} is already mapped to hierarchy node {1}.").format(self.user, existing) + ) + + # A node with reports_to becomes a child so its parent must be a group + if self.reports_to and not frappe.db.get_value("CRM Sales Hierarchy", self.reports_to, "is_group"): + frappe.db.set_value("CRM Sales Hierarchy", self.reports_to, "is_group", 1) + + def on_trash(self): + frappe.cache.delete_value("crm_sales_hierarchy_subtree") + + +def on_doctype_update(): + frappe.db.add_index("CRM Sales Hierarchy", ["lft", "rgt"]) + frappe.db.add_index("CRM Sales Hierarchy", ["user"]) diff --git a/crm/fcrm/doctype/crm_sales_hierarchy/crm_sales_hierarchy_tree.js b/crm/fcrm/doctype/crm_sales_hierarchy/crm_sales_hierarchy_tree.js new file mode 100644 index 0000000..72a1913 --- /dev/null +++ b/crm/fcrm/doctype/crm_sales_hierarchy/crm_sales_hierarchy_tree.js @@ -0,0 +1,6 @@ +// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.treeview_settings["CRM Sales Hierarchy"] = { + show_expand_all: true, +}; diff --git a/crm/fcrm/doctype/crm_sales_hierarchy/test_crm_sales_hierarchy.py b/crm/fcrm/doctype/crm_sales_hierarchy/test_crm_sales_hierarchy.py new file mode 100644 index 0000000..da13cd7 --- /dev/null +++ b/crm/fcrm/doctype/crm_sales_hierarchy/test_crm_sales_hierarchy.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.tests import IntegrationTestCase + + +class TestCRMSalesHierarchy(IntegrationTestCase): + pass diff --git a/crm/fcrm/doctype/crm_service_day/__init__.py b/crm/fcrm/doctype/crm_service_day/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_service_day/crm_service_day.json b/crm/fcrm/doctype/crm_service_day/crm_service_day.json new file mode 100644 index 0000000..2161680 --- /dev/null +++ b/crm/fcrm/doctype/crm_service_day/crm_service_day.json @@ -0,0 +1,60 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-12-04 16:07:20.400084", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "workday", + "section_break_uegc", + "start_time", + "column_break_maie", + "end_time" + ], + "fields": [ + { + "fieldname": "workday", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Workday", + "options": "Monday\nTuesday\nWednesday\nThursday\nFriday\nSaturday\nSunday", + "reqd": 1 + }, + { + "fieldname": "section_break_uegc", + "fieldtype": "Section Break" + }, + { + "fieldname": "start_time", + "fieldtype": "Time", + "in_list_view": 1, + "label": "Start Time", + "reqd": 1 + }, + { + "fieldname": "column_break_maie", + "fieldtype": "Column Break" + }, + { + "fieldname": "end_time", + "fieldtype": "Time", + "in_list_view": 1, + "label": "End Time", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-02-23 12:14:06.741495", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Service Day", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_service_day/crm_service_day.py b/crm/fcrm/doctype/crm_service_day/crm_service_day.py new file mode 100644 index 0000000..3dd1cb5 --- /dev/null +++ b/crm/fcrm/doctype/crm_service_day/crm_service_day.py @@ -0,0 +1,25 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMServiceDay(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + end_time: DF.Time + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + start_time: DF.Time + workday: DF.Literal["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + # end: auto-generated types + + pass diff --git a/crm/fcrm/doctype/crm_service_level_agreement/__init__.py b/crm/fcrm/doctype/crm_service_level_agreement/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.js b/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.js new file mode 100644 index 0000000..1b8e478 --- /dev/null +++ b/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.js @@ -0,0 +1,33 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("CRM Service Level Agreement", { + refresh(frm) { + if (frm.doc.rolling_responses) { + frm.fields_dict.priorities.grid.update_docfield_property( + "first_response_time", + "label", + "Rolling Response Time", + ); + } else { + frm.fields_dict.priorities.grid.update_docfield_property( + "first_response_time", + "label", + "First Response Time", + ); + } + }, + validate(frm) { + let default_priority_count = 0; + frm.doc.priorities.forEach(function (row) { + if (row.default_priority) { + default_priority_count++; + } + }); + if (default_priority_count > 1) { + frappe.throw( + __("There can only be one default priority in Priorities table"), + ); + } + }, +}); diff --git a/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.json b/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.json new file mode 100644 index 0000000..a982731 --- /dev/null +++ b/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.json @@ -0,0 +1,173 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:sla_name", + "creation": "2023-12-04 13:07:18.426211", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "apply_on", + "sla_name", + "column_break_uxua", + "enabled", + "default", + "rolling_responses", + "section_break_nevd", + "start_date", + "end_date", + "column_break_pzjg", + "condition", + "condition_json", + "section_break_ufaf", + "priorities", + "section_break_rmgo", + "holiday_list", + "working_hours" + ], + "fields": [ + { + "fieldname": "sla_name", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "SLA Name", + "reqd": 1, + "unique": 1 + }, + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "default": "0", + "fieldname": "default", + "fieldtype": "Check", + "label": "Default" + }, + { + "fieldname": "column_break_uxua", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval: !doc.condition_json", + "description": "Simple Python Expression, Example: doc.status == 'Open' and doc.lead_source == 'Ads'", + "fieldname": "condition", + "fieldtype": "Code", + "label": "Condition", + "max_height": "5rem", + "options": "Python" + }, + { + "fieldname": "apply_on", + "fieldtype": "Link", + "label": "Apply On", + "link_filters": "[[{\"fieldname\":\"apply_on\",\"field_option\":\"DocType\"},\"name\",\"in\",[\"CRM Lead\",\"CRM Deal\"]]]", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "section_break_ufaf", + "fieldtype": "Section Break", + "label": "Response & Follow Up" + }, + { + "fieldname": "priorities", + "fieldtype": "Table", + "label": "Priorities", + "options": "CRM Service Level Priority", + "reqd": 1 + }, + { + "fieldname": "section_break_rmgo", + "fieldtype": "Section Break", + "label": "Working Hours" + }, + { + "fieldname": "working_hours", + "fieldtype": "Table", + "label": "Working Hours", + "options": "CRM Service Day", + "reqd": 1 + }, + { + "fieldname": "section_break_nevd", + "fieldtype": "Section Break", + "label": "Validity" + }, + { + "fieldname": "start_date", + "fieldtype": "Date", + "label": "Start Date" + }, + { + "fieldname": "column_break_pzjg", + "fieldtype": "Column Break" + }, + { + "fieldname": "end_date", + "fieldtype": "Date", + "label": "End Date" + }, + { + "fieldname": "holiday_list", + "fieldtype": "Link", + "label": "Holiday List", + "options": "CRM Holiday List" + }, + { + "default": "0", + "description": "Restart the SLA each time the customer replies (status changes to Open) and fulfill it when marked as Replied", + "fieldname": "rolling_responses", + "fieldtype": "Check", + "label": "Rolling Responses" + }, + { + "depends_on": "eval: doc.condition_json", + "description": "Auto-generated from the portal view \u2014 do not edit directly unless you know what you're doing! ", + "fieldname": "condition_json", + "fieldtype": "Code", + "label": "Condition", + "max_height": "5rem" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-02-23 12:41:50.104511", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Service Level Agreement", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.py b/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.py new file mode 100644 index 0000000..be3e54f --- /dev/null +++ b/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.py @@ -0,0 +1,359 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from datetime import timedelta + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import ( + add_to_date, + get_datetime, + get_weekdays, + getdate, + now_datetime, + time_diff_in_seconds, +) + +from crm.fcrm.doctype.crm_service_level_agreement.utils import get_context + + +class CRMServiceLevelAgreement(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + from crm.fcrm.doctype.crm_service_day.crm_service_day import CRMServiceDay + from crm.fcrm.doctype.crm_service_level_priority.crm_service_level_priority import ( + CRMServiceLevelPriority, + ) + + apply_on: DF.Link + condition: DF.Code | None + condition_json: DF.Code | None + default: DF.Check + enabled: DF.Check + end_date: DF.Date | None + holiday_list: DF.Link | None + priorities: DF.Table[CRMServiceLevelPriority] + rolling_responses: DF.Check + sla_name: DF.Data + start_date: DF.Date | None + working_hours: DF.Table[CRMServiceDay] + # end: auto-generated types + + def validate(self): + self.validate_default() + self.validate_condition() + + def validate_default(self): + if self.default: + other_slas = frappe.get_all( + "CRM Service Level Agreement", + filters={"apply_on": self.apply_on, "default": True, "name": ["!=", self.name]}, + fields=["name"], + ) + if other_slas: + frappe.throw( + _("Default Service Level Agreement already exists for {0}").format(self.apply_on) + ) + + def validate_condition(self): + if not self.condition: + return + try: + temp_doc = frappe.new_doc(self.apply_on) + frappe.safe_eval(self.condition, None, get_context(temp_doc)) + except Exception as e: + frappe.throw(_("The Condition '{0}' is invalid: {1}").format(self.condition, str(e))) + + def apply(self, doc: Document): + self.handle_creation(doc) + self.handle_communication_status(doc) + self.handle_targets(doc) + self.handle_sla_status(doc) + self.handle_rolling_sla_status(doc) + + def handle_creation(self, doc: Document): + doc.sla_creation = doc.sla_creation or now_datetime() + + def handle_communication_status(self, doc: Document): + if doc.is_new() or not doc.has_value_changed("communication_status"): + return + self.set_first_responded_on(doc) + self.set_first_response_time(doc) + if self.rolling_responses: + self.set_rolling_responses(doc) + + def set_first_responded_on(self, doc: Document): + if doc.communication_status != self.get_default_priority(): + doc.first_responded_on = doc.first_responded_on or now_datetime() + doc.last_responded_on = doc.last_responded_on or doc.first_responded_on + + def set_first_response_time(self, doc: Document): + start_at = doc.sla_creation + end_at = doc.first_responded_on + if not start_at or not end_at: + return + if doc.first_response_time is None: + doc.first_response_time = self.calc_elapsed_time(start_at, end_at) + + if doc.last_response_time is None and doc.first_response_time is not None: + doc.last_response_time = doc.first_response_time + + def set_rolling_responses(self, doc: Document): + if doc.last_response_time is None or not doc.last_responded_on: + return + if len(doc.rolling_responses) == 0: + doc.append( + "rolling_responses", + { + "response_time": doc.last_response_time, + "responded_on": doc.last_responded_on, + "status": "Failed" if self.is_first_response_failed(doc) else "Fulfilled", + }, + ) + elif doc.communication_status != self.get_default_priority(): + current_time = now_datetime() + doc.last_response_time = self.calc_elapsed_time(doc.last_responded_on, current_time) + doc.last_responded_on = current_time + is_failed = self.is_rolling_response_failed(doc) + doc.append( + "rolling_responses", + { + "response_time": doc.last_response_time, + "responded_on": doc.last_responded_on, + "status": "Failed" if is_failed else "Fulfilled", + }, + ) + + def handle_targets(self, doc: Document): + self.set_response_by(doc) + if self.rolling_responses and len(doc.rolling_responses) > 0: + self.set_rolling_response_by(doc) + + def set_response_by(self, doc: Document): + start_time = doc.sla_creation + communication_status = doc.communication_status + + priorities = self.get_priorities() + priority = priorities.get(communication_status) + if not priority or doc.response_by: + return + + first_response_time = priority.get("first_response_time", 0) + end_time = self.calc_time(start_time, first_response_time) + if end_time: + doc.response_by = end_time + + def _update_rolling_response_by(self, doc: Document): + default_priority = self.get_default_priority() + if doc.communication_status != default_priority: + return + + priorities = self.get_priorities() + priority = priorities.get(default_priority) + if not priority: + return + + rolling_response_time = priority.get("first_response_time", 0) + end_time = self.calc_time(now_datetime(), rolling_response_time) + if end_time: + doc.response_by = end_time + + def set_rolling_response_by(self, doc: Document): + if not doc.response_by or not doc.last_responded_on: + return + self._update_rolling_response_by(doc) + + def handle_sla_status(self, doc: Document): + is_failed = self.is_first_response_failed(doc) + options = { + "Fulfilled": True, + "First Response Due": not doc.first_responded_on, + "Failed": is_failed, + } + for status in options: + if options[status]: + doc.sla_status = status + + def is_first_response_failed(self, doc: Document): + if not doc.first_responded_on: + return get_datetime(doc.response_by) < now_datetime() + return get_datetime(doc.response_by) < get_datetime(doc.first_responded_on) + + def handle_rolling_sla_status(self, doc: Document): + if not self.rolling_responses or len(doc.rolling_responses) == 0: + return + + is_failed = self.is_rolling_response_failed(doc) + options = { + "Fulfilled": True, + "Rolling Response Due": doc.communication_status == self.get_default_priority(), + "Failed": is_failed, + } + for status in options: + if options[status]: + doc.sla_status = status + + def is_rolling_response_failed(self, doc: Document): + if not doc.response_by: + return False + # When waiting for agent's response (default priority), check if deadline has passed + if doc.communication_status == self.get_default_priority(): + return get_datetime(doc.response_by) < now_datetime() + # When agent has responded, check if they responded before the deadline + if not doc.last_responded_on: + return get_datetime(doc.response_by) < now_datetime() + return get_datetime(doc.response_by) < get_datetime(doc.last_responded_on) + + def _time_to_seconds(self, time_obj): + """ + Convert time or timedelta object to seconds + """ + if isinstance(time_obj, timedelta): + return time_obj.total_seconds() + else: + # datetime.time object + return time_obj.hour * 3600 + time_obj.minute * 60 + time_obj.second + + def calc_time( + self, + start_at: str, + duration_seconds: int, + ): + res = get_datetime(start_at) + time_needed = duration_seconds + holidays = self.get_holidays() + weekdays = get_weekdays() + workdays = self.get_workdays() + while time_needed: + today = res + today_day = getdate(today) + today_weekday = weekdays[today.weekday()] + is_workday = today_weekday in workdays + is_holiday = today_day in holidays + if is_holiday or not is_workday: + res = add_to_date(res, days=1, as_datetime=True) + continue + today_workday = workdays[today_weekday] + now_in_seconds = time_diff_in_seconds(today, today_day) + + # Convert time objects to seconds + start_time_seconds = self._time_to_seconds(today_workday.start_time) + end_time_seconds = self._time_to_seconds(today_workday.end_time) + + start_time = max(start_time_seconds, now_in_seconds) + till_start_time = max(start_time - now_in_seconds, 0) + end_time = max(end_time_seconds, now_in_seconds) + time_left = max(end_time - start_time, 0) + if not time_left: + res = getdate(add_to_date(res, days=1, as_datetime=True)) + continue + time_taken = min(time_needed, time_left) + time_needed -= time_taken + time_required = till_start_time + time_taken + res = add_to_date(res, seconds=time_required, as_datetime=True) + return res + + def calc_elapsed_time(self, start_time, end_time) -> float: + """ + Get took from start to end, excluding non-working hours + + :param start_at: Date at which calculation starts + :param end_at: Date at which calculation ends + :return: Number of seconds + """ + start_time = get_datetime(start_time) + end_time = get_datetime(end_time) + holiday_list = self.get_holidays() + working_day_list = self.get_working_days() + working_hours = self.get_working_hours() + + total_seconds = 0 + current_time = start_time + + while current_time < end_time: + in_holiday_list = current_time.date() in holiday_list + not_in_working_day_list = get_weekdays()[current_time.weekday()] not in working_day_list + if ( + in_holiday_list + or not_in_working_day_list + or not self.is_working_time(current_time, working_hours) + ): + current_time += timedelta(seconds=1) + continue + total_seconds += 1 + current_time += timedelta(seconds=1) + + return total_seconds + + def get_priorities(self): + """ + Return priorities related info as a dict. With `priority` as key + """ + res = {} + for row in self.priorities: + res[row.priority] = row + return res + + def get_default_priority(self): + """ + Return default priority + """ + for row in self.priorities: + if row.default_priority: + return row.priority + + return self.priorities[0].priority + + def get_workdays(self) -> dict[str, dict]: + """ + Return workdays related info as a dict. With `workday` as key + """ + res = {} + for row in self.working_hours: + res[row.workday] = row + return res + + def get_working_days(self) -> dict[str, dict]: + workdays = [] + for row in self.working_hours: + workdays.append(row.workday) + return workdays + + def get_working_hours(self) -> dict[str, dict]: + res = {} + for row in self.working_hours: + res[row.workday] = (row.start_time, row.end_time) + return res + + def is_working_time(self, date_time, working_hours): + day_of_week = get_weekdays()[date_time.weekday()] + start_time, end_time = working_hours.get(day_of_week, (0, 0)) + + if start_time == 0 and end_time == 0: + return False + + date_time_seconds = timedelta( + hours=date_time.hour, minutes=date_time.minute, seconds=date_time.second + ).total_seconds() + + # Convert time objects to seconds + start_time_seconds = self._time_to_seconds(start_time) + end_time_seconds = self._time_to_seconds(end_time) + + return start_time_seconds <= date_time_seconds < end_time_seconds + + def get_holidays(self): + res = [] + if not self.holiday_list: + return res + holiday_list = frappe.get_doc("CRM Holiday List", self.holiday_list) + for row in holiday_list.holidays: + res.append(row.date) + return res diff --git a/crm/fcrm/doctype/crm_service_level_agreement/test_crm_service_level_agreement.py b/crm/fcrm/doctype/crm_service_level_agreement/test_crm_service_level_agreement.py new file mode 100644 index 0000000..d29684c --- /dev/null +++ b/crm/fcrm/doctype/crm_service_level_agreement/test_crm_service_level_agreement.py @@ -0,0 +1,990 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +from datetime import datetime, time + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_to_date, get_datetime, now_datetime + + +class TestCRMServiceLevelAgreement(IntegrationTestCase): + def tearDown(self): + frappe.db.rollback() + + def test_sla_creation(self): + """Test creating a basic SLA""" + sla = create_test_sla(sla_name="Test SLA", apply_on="CRM Lead") + + self.assertEqual(sla.sla_name, "Test SLA") + self.assertEqual(sla.apply_on, "CRM Lead") + self.assertTrue(sla.enabled) + + def test_validate_default_sla(self): + """Test that only one default SLA can exist per doctype""" + # Create first default SLA + create_test_sla(sla_name="Default SLA 1", apply_on="CRM Lead", default=True) + + # Try to create another default SLA for same doctype + sla2 = frappe.get_doc( + { + "doctype": "CRM Service Level Agreement", + "sla_name": "Default SLA 2", + "apply_on": "CRM Lead", + "default": True, + "enabled": True, + } + ) + + with self.assertRaises(frappe.ValidationError): + sla2.insert() + + def test_validate_condition_valid(self): + """Test that valid condition passes validation""" + sla = create_test_sla( + sla_name="Conditional SLA", + apply_on="CRM Lead", + condition="doc.lead_owner == 'Administrator'", + ) + + # Should not raise error + self.assertTrue(sla.name) + + def test_validate_condition_invalid(self): + """Test that invalid condition fails validation""" + sla = frappe.get_doc( + { + "doctype": "CRM Service Level Agreement", + "sla_name": "Invalid Condition SLA", + "apply_on": "CRM Lead", + "enabled": True, + "condition": "invalid python code here!!!", + } + ) + + with self.assertRaises(frappe.ValidationError): + sla.insert() + + def test_get_priorities(self): + """Test get_priorities returns dict with priority as key""" + sla = create_test_sla_with_priorities() + + priorities = sla.get_priorities() + + self.assertIsInstance(priorities, dict) + self.assertIn("High", priorities) + self.assertIn("Medium", priorities) + self.assertEqual(priorities["High"].first_response_time, 3600) + self.assertEqual(priorities["Medium"].first_response_time, 7200) + + def test_get_default_priority(self): + """Test get_default_priority returns the default priority""" + sla = create_test_sla_with_priorities() + + default_priority = sla.get_default_priority() + + self.assertEqual(default_priority, "Medium") + + def test_get_workdays(self): + """Test get_workdays returns dict with workday as key""" + sla = create_test_sla_with_working_hours() + + workdays = sla.get_workdays() + + self.assertIsInstance(workdays, dict) + self.assertIn("Monday", workdays) + self.assertIn("Tuesday", workdays) + self.assertEqual(workdays["Monday"].start_time, time(9, 0)) + self.assertEqual(workdays["Monday"].end_time, time(17, 0)) + + def test_get_working_days(self): + """Test get_working_days returns list of working days""" + sla = create_test_sla_with_working_hours() + + working_days = sla.get_working_days() + + self.assertIsInstance(working_days, list) + self.assertIn("Monday", working_days) + self.assertIn("Tuesday", working_days) + self.assertIn("Wednesday", working_days) + + def test_get_working_hours(self): + """Test get_working_hours returns dict with time tuples""" + sla = create_test_sla_with_working_hours() + + working_hours = sla.get_working_hours() + + self.assertIsInstance(working_hours, dict) + self.assertIn("Monday", working_hours) + start_time, end_time = working_hours["Monday"] + self.assertEqual(start_time, time(9, 0)) + self.assertEqual(end_time, time(17, 0)) + + def test_is_working_time(self): + """Test is_working_time checks if datetime is within working hours""" + sla = create_test_sla_with_working_hours() + working_hours = sla.get_working_hours() + + # Monday 10 AM - should be working time + monday_10am = datetime(2024, 1, 1, 10, 0, 0) # Monday + self.assertTrue(sla.is_working_time(monday_10am, working_hours)) + + # Monday 8 AM - before working hours + monday_8am = datetime(2024, 1, 1, 8, 0, 0) + self.assertFalse(sla.is_working_time(monday_8am, working_hours)) + + # Monday 6 PM - after working hours + monday_6pm = datetime(2024, 1, 1, 18, 0, 0) + self.assertFalse(sla.is_working_time(monday_6pm, working_hours)) + + def test_get_holidays(self): + """Test get_holidays returns list of holiday dates""" + # Create holiday list + holiday_list = create_test_holiday_list() + + sla = create_test_sla_with_working_hours(holiday_list=holiday_list.name) + + holidays = sla.get_holidays() + + self.assertIsInstance(holidays, list) + self.assertTrue(len(holidays) > 0) + + def test_handle_creation(self): + """Test handle_creation sets sla_creation timestamp""" + sla = create_test_sla_with_priorities() + + # Create a CRM Lead document + doc = frappe.get_doc( + {"doctype": "CRM Lead", "first_name": "Test Handle Creation", "sla_creation": None} + ) + + before_time = now_datetime() + sla.handle_creation(doc) + after_time = now_datetime() + + # Verify timestamp is set and within reasonable time window + self.assertIsNotNone(doc.sla_creation) + self.assertGreaterEqual(doc.sla_creation, before_time) + self.assertLessEqual(doc.sla_creation, after_time) + + def test_handle_creation_existing_timestamp(self): + """Test handle_creation doesn't override existing timestamp""" + sla = create_test_sla_with_priorities() + + existing_time = get_datetime("2024-01-01 10:00:00") + doc = frappe.get_doc( + {"doctype": "CRM Lead", "first_name": "Test Existing Timestamp", "sla_creation": existing_time} + ) + + sla.handle_creation(doc) + + self.assertEqual(doc.sla_creation, existing_time) + + def test_set_first_responded_on(self): + """Test set_first_responded_on sets only first response timestamp""" + sla = create_test_sla_with_priorities() + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test First Response", + "communication_status": "High", + "first_responded_on": None, + "last_responded_on": None, + } + ) + + sla.set_first_responded_on(doc) + + self.assertIsNotNone(doc.first_responded_on) + self.assertIsNotNone(doc.last_responded_on) + + def test_set_first_responded_on_default_priority(self): + """Test set_first_responded_on doesn't set for default priority""" + sla = create_test_sla_with_priorities() + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Default Priority", + "communication_status": "Medium", # Default priority + "first_responded_on": None, + "last_responded_on": None, + } + ) + + sla.set_first_responded_on(doc) + + self.assertIsNone(doc.first_responded_on) + + def test_set_first_response_time_zero_duration_preserved(self): + """Test that first_response_time of 0 is preserved and not recalculated. + + The fix distinguishes None (unset) from 0 (instant response). Without the + `is None` guard a falsy check would treat 0 as unset and overwrite it with + the recalculated elapsed time. + """ + sla = create_test_sla_with_priorities() + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Zero Duration Preserved", + "sla_creation": get_datetime("2024-01-01 10:00:00"), + "first_responded_on": get_datetime("2024-01-01 10:00:00"), # same instant → 0s + "first_response_time": 0, # already recorded as 0 + "last_response_time": None, + } + ) + + sla.set_first_response_time(doc) + + # 0 must not be overwritten; `is None` guard prevents recalculation + self.assertEqual(doc.first_response_time, 0) + + def test_set_first_response_time_zero_propagates_to_last(self): + """Test that last_response_time is set to 0 when first_response_time is 0. + + When first_response_time is 0, last_response_time (if None) should be + propagated as 0, not skipped because of a falsy check. + """ + sla = create_test_sla_with_priorities() + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Zero Propagates To Last", + "sla_creation": get_datetime("2024-01-01 10:00:00"), + "first_responded_on": get_datetime("2024-01-01 10:00:00"), + "first_response_time": 0, + "last_response_time": None, + } + ) + + sla.set_first_response_time(doc) + + # last_response_time should be set to 0, propagated from first_response_time + self.assertEqual(doc.last_response_time, 0) + + def test_set_first_response_time_last_response_zero_not_overwritten(self): + """Test that an existing last_response_time of 0 is never overwritten. + + Once last_response_time is recorded as 0 it must stay 0. A falsy guard + would incorrectly treat 0 as absent and overwrite it. + """ + sla = create_test_sla_with_priorities() + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Last Response Zero Preserved", + "sla_creation": get_datetime("2024-01-01 10:00:00"), + "first_responded_on": get_datetime("2024-01-01 10:00:00"), + "first_response_time": 0, + "last_response_time": 0, # already recorded + } + ) + + sla.set_first_response_time(doc) + + self.assertEqual(doc.last_response_time, 0) + + def test_set_rolling_responses_zero_last_response_time_creates_entry(self): + """Test set_rolling_responses still creates an entry when last_response_time is 0. + + A falsy guard (`if not doc.last_response_time`) would bail out on 0 and + silently skip creating the rolling-response entry. The `is None` guard + correctly treats 0 as a valid (instant) response. + """ + sla = create_test_sla_with_priorities(rolling_responses=True) + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Zero Rolling Response", + } + ) + + doc.rolling_responses = [] + doc.last_response_time = 0 # zero, not None + doc.last_responded_on = get_datetime("2024-01-01 10:00:00") + doc.response_by = add_to_date(get_datetime("2024-01-01 10:00:00"), hours=1) + doc.first_responded_on = get_datetime("2024-01-01 10:00:00") + + sla.set_rolling_responses(doc) + + self.assertEqual(len(doc.rolling_responses), 1) + self.assertEqual(doc.rolling_responses[0].response_time, 0) + # The response was before the deadline → Fulfilled + self.assertEqual(doc.rolling_responses[0].status, "Fulfilled") + + def test_set_response_by(self): + """Test set_response_by calculates response deadline""" + sla = create_test_sla_with_priorities() + + start_time = get_datetime("2024-01-01 10:00:00") # Monday 10 AM + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Response By", + "sla_creation": start_time, + "communication_status": "High", + "response_by": None, + } + ) + + sla.set_response_by(doc) + + # High priority has 3600 seconds (1 hour) response time + # Response by should be 1 hour after creation (11:00 AM) + self.assertIsNotNone(doc.response_by) + expected_response = get_datetime("2024-01-01 11:00:00") + self.assertEqual(doc.response_by, expected_response) + + def test_set_response_by_existing(self): + """Test set_response_by doesn't override existing response_by""" + sla = create_test_sla_with_priorities() + + existing_response = get_datetime("2024-01-01 15:00:00") + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Response By Existing", + "sla_creation": get_datetime("2024-01-01 10:00:00"), + "communication_status": "High", + "response_by": existing_response, + } + ) + + sla.set_response_by(doc) + + self.assertEqual(doc.response_by, existing_response) + + def test_is_first_response_failed_before_deadline(self): + """Test is_first_response_failed when response is before deadline""" + sla = create_test_sla_with_priorities() + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Response Before Deadline", + "response_by": add_to_date(now_datetime(), hours=1), + "first_responded_on": now_datetime(), + } + ) + + self.assertFalse(sla.is_first_response_failed(doc)) + + def test_is_first_response_failed_after_deadline(self): + """Test is_first_response_failed when response is after deadline""" + sla = create_test_sla_with_priorities() + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Response After Deadline", + "response_by": add_to_date(now_datetime(), hours=-1), + "first_responded_on": now_datetime(), + } + ) + + self.assertTrue(sla.is_first_response_failed(doc)) + + def test_is_first_response_failed_no_response(self): + """Test is_first_response_failed when no response yet but deadline passed""" + sla = create_test_sla_with_priorities() + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test No Response", + "response_by": add_to_date(now_datetime(), hours=-1), + "first_responded_on": None, + } + ) + + self.assertTrue(sla.is_first_response_failed(doc)) + + def test_handle_sla_status_fulfilled(self): + """Test handle_sla_status sets status to Fulfilled""" + sla = create_test_sla_with_priorities() + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test SLA Fulfilled", + "response_by": add_to_date(now_datetime(), hours=1), + "first_responded_on": now_datetime(), + "sla_status": None, + } + ) + + sla.handle_sla_status(doc) + + self.assertEqual(doc.sla_status, "Fulfilled") + + def test_handle_sla_status_first_response_due(self): + """Test handle_sla_status sets status to First Response Due""" + sla = create_test_sla_with_priorities() + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test First Response Due", + "response_by": add_to_date(now_datetime(), hours=1), + "first_responded_on": None, + "sla_status": None, + } + ) + + sla.handle_sla_status(doc) + + self.assertEqual(doc.sla_status, "First Response Due") + + def test_handle_sla_status_failed(self): + """Test handle_sla_status sets status to Failed""" + sla = create_test_sla_with_priorities() + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test SLA Failed", + "response_by": add_to_date(now_datetime(), hours=-1), + "first_responded_on": now_datetime(), + "sla_status": None, + } + ) + + sla.handle_sla_status(doc) + + self.assertEqual(doc.sla_status, "Failed") + + def test_calc_time_basic(self): + """Test calc_time calculates end time correctly""" + sla = create_test_sla_with_working_hours() + + start_time = get_datetime("2024-01-01 10:00:00") # Monday 10 AM + duration_seconds = 3600 # 1 hour + + end_time = sla.calc_time(start_time, duration_seconds) + + # Should be 11 AM on same day + self.assertEqual(end_time.hour, 11) + self.assertEqual(end_time.day, 1) + + def test_calc_time_crosses_day(self): + """Test calc_time when duration crosses to next day""" + sla = create_test_sla_with_working_hours() + + start_time = get_datetime("2024-01-01 16:00:00") # Monday 4 PM + duration_seconds = 7200 # 2 hours (work ends at 5 PM) + + end_time = sla.calc_time(start_time, duration_seconds) + + # Should be Tuesday morning + self.assertEqual(end_time.day, 2) + + def test_calc_elapsed_time_same_day(self): + """Test calc_elapsed_time within same working day""" + sla = create_test_sla_with_working_hours() + + start_time = get_datetime("2024-01-01 10:00:00") # Monday 10 AM + end_time = get_datetime("2024-01-01 12:00:00") # Monday 12 PM + + elapsed = sla.calc_elapsed_time(start_time, end_time) + + # Should be 2 hours = 7200 seconds + self.assertEqual(elapsed, 7200) + + def test_set_rolling_responses_first_entry(self): + """Test set_rolling_responses creates first entry""" + sla = create_test_sla_with_priorities(rolling_responses=True) + + # Create a proper CRM Lead document (don't insert to avoid triggering SLA application) + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Rolling Response Test", + "email": "rolling@example.com", + } + ) + + # Set required fields for rolling responses + doc.rolling_responses = [] + doc.last_response_time = 100 + doc.last_responded_on = now_datetime() + doc.response_by = add_to_date(now_datetime(), hours=1) + doc.first_responded_on = now_datetime() + + sla.set_rolling_responses(doc) + + self.assertEqual(len(doc.rolling_responses), 1) + self.assertEqual(doc.rolling_responses[0].response_time, 100) + # Response before deadline (response_by is 1 hour in future) should be Fulfilled + self.assertEqual(doc.rolling_responses[0].status, "Fulfilled") + + def test_handle_rolling_sla_status(self): + """Test handle_rolling_sla_status sets rolling SLA status""" + sla = create_test_sla_with_priorities(rolling_responses=True) + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Rolling SLA", + "response_by": add_to_date(now_datetime(), hours=1), + "last_responded_on": now_datetime(), + "communication_status": "High", + "sla_status": None, + } + ) + doc.append("rolling_responses", {"status": "Fulfilled"}) + + sla.handle_rolling_sla_status(doc) + + self.assertIn(doc.sla_status, ["Fulfilled", "Rolling Response Due", "Failed"]) + + def test_set_rolling_responses_subsequent_entry(self): + """Test set_rolling_responses creates subsequent entries with consistent timestamps""" + sla = create_test_sla_with_priorities(rolling_responses=True) + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Rolling Response Subsequent", + "email": "rolling_sub@example.com", + } + ) + + # Set up as if first response already happened + doc.rolling_responses = [] + first_response_time = now_datetime() + doc.last_response_time = 100 + doc.last_responded_on = add_to_date(first_response_time, hours=-1) + doc.response_by = add_to_date(first_response_time, hours=1) + doc.first_responded_on = add_to_date(first_response_time, hours=-1) + doc.communication_status = "High" # Non-default = agent responding + + # Add first entry + doc.append( + "rolling_responses", + {"status": "Fulfilled", "response_time": 100, "responded_on": doc.last_responded_on}, + ) + + sla.set_rolling_responses(doc) + + self.assertEqual(len(doc.rolling_responses), 2) + # responded_on and last_responded_on should be exactly the same (no race condition) + self.assertEqual(doc.rolling_responses[1].responded_on, doc.last_responded_on) + + def test_calc_elapsed_time_excludes_holidays(self): + """Test calc_elapsed_time excludes holidays from elapsed time""" + holiday_list = create_test_holiday_list() + sla = create_test_sla_with_working_hours(holiday_list=holiday_list.name) + + # Start on Friday Jan 12, end on Tuesday Jan 16 (Jan 15 is a holiday) + start_time = get_datetime("2024-01-12 10:00:00") # Friday 10 AM + end_time = get_datetime("2024-01-16 12:00:00") # Tuesday 12 PM + + elapsed = sla.calc_elapsed_time(start_time, end_time) + + # Friday: 10 AM - 5 PM = 7 hours = 25200s + # Sat/Sun: not working days + # Monday Jan 15: holiday — should be excluded + # Tuesday: 9 AM - 12 PM = 3 hours = 10800s + # Total = 25200 + 10800 = 36000s + self.assertEqual(elapsed, 36000) + + def test_handle_rolling_sla_status_failed_takes_priority(self): + """Test that Failed status takes priority over Fulfilled in rolling SLA""" + sla = create_test_sla_with_priorities(rolling_responses=True) + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Rolling Failed Priority", + "response_by": add_to_date(now_datetime(), hours=-1), # Deadline already passed + "last_responded_on": now_datetime(), # Responded after deadline + "communication_status": "High", # Non-default priority + "sla_status": None, + } + ) + doc.append("rolling_responses", {"status": "Fulfilled"}) + + sla.handle_rolling_sla_status(doc) + + # Even though communication_status != default, Failed should take priority + self.assertEqual(doc.sla_status, "Failed") + + def test_handle_rolling_sla_status_fulfilled(self): + """Test that Fulfilled is set when response is before deadline""" + sla = create_test_sla_with_priorities(rolling_responses=True) + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Rolling Fulfilled", + "response_by": add_to_date(now_datetime(), hours=1), # Deadline in future + "last_responded_on": now_datetime(), + "communication_status": "High", # Non-default + "sla_status": None, + } + ) + doc.append("rolling_responses", {"status": "Fulfilled"}) + + sla.handle_rolling_sla_status(doc) + + self.assertEqual(doc.sla_status, "Fulfilled") + + def test_handle_rolling_sla_status_rolling_response_due(self): + """Test that Rolling Response Due is set when status is default priority""" + sla = create_test_sla_with_priorities(rolling_responses=True) + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Rolling Due", + "response_by": add_to_date(now_datetime(), hours=1), # Not expired + "last_responded_on": now_datetime(), + "communication_status": "Medium", # Default priority + "sla_status": None, + } + ) + doc.append("rolling_responses", {"status": "Fulfilled"}) + + sla.handle_rolling_sla_status(doc) + + self.assertEqual(doc.sla_status, "Rolling Response Due") + + def test_set_rolling_responses_does_not_update_response_by(self): + """Test that set_rolling_responses does NOT update response_by when agent replies. + response_by should only be updated when a customer message arrives (via handle_targets).""" + sla = create_test_sla_with_priorities(rolling_responses=True) + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Response By No Update", + } + ) + + doc.rolling_responses = [] + doc.last_response_time = 100 + doc.last_responded_on = add_to_date(now_datetime(), hours=-1) + doc.response_by = add_to_date(now_datetime(), hours=1) # Deadline in the future + doc.first_responded_on = add_to_date(now_datetime(), hours=-1) + doc.communication_status = "High" # Non-default = agent responding + + # Add first entry + doc.append( + "rolling_responses", + {"status": "Fulfilled", "response_time": 100, "responded_on": doc.last_responded_on}, + ) + + old_response_by = doc.response_by + sla.set_rolling_responses(doc) + + # response_by should NOT have been changed by set_rolling_responses + self.assertEqual(doc.response_by, old_response_by) + + def test_rolling_response_correct_with_customer_reply_gap(self): + """Test the exact scenario from the bug report: + Agent responds on time, customer replies days later, agent responds again. + The second response should be evaluated against a deadline set from when + the customer message arrived, NOT from the agent's first response.""" + sla = create_test_sla_with_priorities(rolling_responses=True) + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Customer Reply Gap", + } + ) + + # Simulate: agent responded 1 hour ago, response_by set to future (via handle_targets) + doc.rolling_responses = [] + doc.last_response_time = 100 + doc.last_responded_on = add_to_date(now_datetime(), hours=-1) + doc.first_responded_on = doc.last_responded_on + doc.response_by = add_to_date(now_datetime(), hours=1) # Valid future deadline + doc.communication_status = "High" # Non-default = agent responding + + # First rolling response entry (fulfilled) + doc.append( + "rolling_responses", + {"status": "Fulfilled", "response_time": 100, "responded_on": doc.last_responded_on}, + ) + + # Agent replies again — response_by is in the future, so this should be Fulfilled + sla.set_rolling_responses(doc) + + self.assertEqual(len(doc.rolling_responses), 2) + self.assertEqual(doc.rolling_responses[1].status, "Fulfilled") + + def test_set_rolling_responses_stale_deadline_marks_failed(self): + """Test that when response_by is a stale past deadline (from a previous cycle), + the agent's response is correctly marked as Failed.""" + sla = create_test_sla_with_priorities(rolling_responses=True) + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Stale Deadline Failed", + } + ) + + doc.rolling_responses = [] + doc.last_response_time = 100 + doc.last_responded_on = add_to_date(now_datetime(), hours=-1) + doc.first_responded_on = doc.last_responded_on + # Stale response_by from a previous cycle (already expired) + doc.response_by = add_to_date(now_datetime(), hours=-2) + doc.communication_status = "High" # Non-default = agent responding + + # First entry from earlier cycle + doc.append( + "rolling_responses", + {"status": "Fulfilled", "response_time": 100, "responded_on": doc.last_responded_on}, + ) + + sla.set_rolling_responses(doc) + + self.assertEqual(len(doc.rolling_responses), 2) + # Agent replied AFTER the stale deadline -> should be Failed + self.assertEqual(doc.rolling_responses[1].status, "Failed") + + def test_is_rolling_response_failed_waiting_for_agent(self): + """Test is_rolling_response_failed when waiting for agent response (default priority). + Should check response_by against now_datetime(), not last_responded_on.""" + sla = create_test_sla_with_priorities(rolling_responses=True) + + # Deadline has passed but agent hasn't responded yet + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Waiting For Agent", + "response_by": add_to_date(now_datetime(), hours=-1), # Expired + "last_responded_on": add_to_date(now_datetime(), hours=-3), # Agent's previous reply + "communication_status": "Medium", # Default priority = waiting for agent + } + ) + + self.assertTrue(sla.is_rolling_response_failed(doc)) + + def test_is_rolling_response_failed_waiting_for_agent_not_expired(self): + """Test is_rolling_response_failed when waiting for agent and deadline hasn't passed.""" + sla = create_test_sla_with_priorities(rolling_responses=True) + + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Waiting Not Expired", + "response_by": add_to_date(now_datetime(), hours=1), # Not expired + "last_responded_on": add_to_date(now_datetime(), hours=-3), + "communication_status": "Medium", # Default priority + } + ) + + self.assertFalse(sla.is_rolling_response_failed(doc)) + + def test_apply_method_full_workflow(self): + """Test apply method executes full SLA workflow""" + sla = create_test_sla_with_priorities() + + # Create a CRM Lead document + doc = frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": "Test Apply Full Workflow", + "communication_status": "High", + "sla_creation": None, + "first_responded_on": None, + "last_responded_on": None, + "response_by": None, + "sla_status": None, + } + ) + + # Mock has_value_changed to return False (new document) + doc.has_value_changed = lambda x: False + doc.is_new = lambda: True + + sla.apply(doc) + + # Verify SLA was applied + self.assertIsNotNone(doc.sla_creation) + self.assertIsNotNone(doc.response_by) + self.assertIsNotNone(doc.sla_status) + + +def create_test_sla(**kwargs): + """Helper function to create a CRM Service Level Agreement for testing""" + data = { + "doctype": "CRM Service Level Agreement", + "sla_name": "Test SLA", + "apply_on": "CRM Lead", + "enabled": True, + "default": False, + } + + data.update(kwargs) + + sla = frappe.get_doc(data) + + # Add minimum required priorities if not provided + if not sla.get("priorities"): + # Ensure we have a communication status to reference + if not frappe.db.exists("CRM Communication Status", "Medium"): + frappe.get_doc( + { + "doctype": "CRM Communication Status", + "status": "Medium", + } + ).insert(ignore_if_duplicate=True) + + sla.append( + "priorities", + { + "priority": "Medium", + "default_priority": True, + "first_response_time": 3600, + }, + ) + + # Add minimum required working hours if not provided + if not sla.get("working_hours"): + sla.append( + "working_hours", + { + "workday": "Monday", + "start_time": time(9, 0), + "end_time": time(17, 0), + }, + ) + + sla.insert() + return sla + + +def create_test_sla_with_priorities(**kwargs): + """Helper to create SLA with priority levels""" + rolling_responses = kwargs.pop("rolling_responses", False) + + # Ensure communication statuses exist + for priority_name in ["High", "Medium", "Low"]: + if not frappe.db.exists("CRM Communication Status", priority_name): + frappe.get_doc( + { + "doctype": "CRM Communication Status", + "status": priority_name, + } + ).insert(ignore_if_duplicate=True) + + sla = frappe.get_doc( + { + "doctype": "CRM Service Level Agreement", + "sla_name": kwargs.get("sla_name", "SLA with Priorities"), + "apply_on": kwargs.get("apply_on", "CRM Lead"), + "enabled": True, + "default": kwargs.get("default", False), + "rolling_responses": rolling_responses, + } + ) + + # Add priorities + sla.append( + "priorities", + { + "priority": "High", + "default_priority": False, + "first_response_time": 3600, # 1 hour + }, + ) + sla.append( + "priorities", + { + "priority": "Medium", + "default_priority": True, + "first_response_time": 7200, # 2 hours + }, + ) + + # Add working hours if specified + if kwargs.get("add_working_hours", True): + for day in ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]: + sla.append( + "working_hours", + { + "workday": day, + "start_time": time(9, 0), + "end_time": time(17, 0), + }, + ) + + sla.insert() + return sla + + +def create_test_sla_with_working_hours(**kwargs): + """Helper to create SLA with working hours""" + # Ensure communication status exists + if not frappe.db.exists("CRM Communication Status", "Medium"): + frappe.get_doc( + { + "doctype": "CRM Communication Status", + "status": "Medium", + } + ).insert(ignore_if_duplicate=True) + + sla = frappe.get_doc( + { + "doctype": "CRM Service Level Agreement", + "sla_name": kwargs.get("sla_name", "SLA with Working Hours"), + "apply_on": kwargs.get("apply_on", "CRM Lead"), + "enabled": True, + "holiday_list": kwargs.get("holiday_list"), + } + ) + + # Add working hours (Monday to Friday, 9 AM to 5 PM) + for day in ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]: + sla.append( + "working_hours", + { + "workday": day, + "start_time": time(9, 0), + "end_time": time(17, 0), + }, + ) + + # Add a default priority + sla.append( + "priorities", + { + "priority": "Medium", + "default_priority": True, + "first_response_time": 3600, + }, + ) + + sla.insert() + return sla + + +def create_test_holiday_list(): + """Helper to create a holiday list for testing""" + from datetime import date + + holiday_list = frappe.get_doc( + { + "doctype": "CRM Holiday List", + "holiday_list_name": "Test Holidays", + "from_date": date(2024, 1, 1), + "to_date": date(2024, 12, 31), + } + ) + + # Add a sample holiday + holiday_list.append("holidays", {"date": date(2024, 1, 15), "description": "Test Holiday"}) + + holiday_list.insert() + return holiday_list diff --git a/crm/fcrm/doctype/crm_service_level_agreement/utils.py b/crm/fcrm/doctype/crm_service_level_agreement/utils.py new file mode 100644 index 0000000..1dacab0 --- /dev/null +++ b/crm/fcrm/doctype/crm_service_level_agreement/utils.py @@ -0,0 +1,61 @@ +import frappe +from frappe.model.document import Document +from frappe.query_builder import JoinType +from frappe.utils import now_datetime +from frappe.utils.safe_exec import get_safe_globals +from pypika import Criterion + + +def get_sla(doc: Document) -> Document: + """ + Get Service Level Agreement for `doc` + + :param doc: Lead/Deal to use + :return: Applicable SLA + """ + SLA = frappe.qb.DocType("CRM Service Level Agreement") + Priority = frappe.qb.DocType("CRM Service Level Priority") + now = now_datetime() + priority = doc.communication_status + q = ( + frappe.qb.from_(SLA) + .select(SLA.name, SLA.condition) + .where(SLA.apply_on == doc.doctype) + .where(SLA.enabled) + .where(Criterion.any([SLA.start_date.isnull(), SLA.start_date <= now])) + .where(Criterion.any([SLA.end_date.isnull(), SLA.end_date >= now])) + ) + if priority: + q = ( + q.join(Priority, JoinType.inner) + .on(Priority.parent == SLA.name) + .where(Priority.priority == priority) + ) + sla_list = q.run(as_dict=True) + res = None + + # move default SLA to the end if present without mutating during iteration + default_index = next((i for i, sla in enumerate(sla_list) if sla.get("default")), None) + if default_index is not None: + sla_list.append(sla_list.pop(default_index)) + + for sla in sla_list: + cond = sla.get("condition") + if not cond or frappe.safe_eval(cond, None, get_context(doc)): + res = sla + break + return res + + +def get_context(d: Document) -> dict: + """ + Get safe context for `safe_eval` + + :param doc: `Document` to add in context + :return: Context with `doc` and safe variables + """ + utils = get_safe_globals().get("frappe").get("utils") + return { + "doc": d.as_dict(), + "frappe": frappe._dict(utils=utils), + } diff --git a/crm/fcrm/doctype/crm_service_level_priority/__init__.py b/crm/fcrm/doctype/crm_service_level_priority/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.js b/crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.js new file mode 100644 index 0000000..ef80823 --- /dev/null +++ b/crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Service Level Priority", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.json b/crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.json new file mode 100644 index 0000000..dc58f32 --- /dev/null +++ b/crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.json @@ -0,0 +1,65 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-12-04 13:18:58.028384", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "default_priority", + "column_break_grod", + "priority", + "section_break_anyl", + "first_response_time", + "column_break_bwgs" + ], + "fields": [ + { + "default": "0", + "fieldname": "default_priority", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Default Priority" + }, + { + "fieldname": "priority", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Priority", + "options": "CRM Communication Status", + "reqd": 1 + }, + { + "fieldname": "first_response_time", + "fieldtype": "Duration", + "in_list_view": 1, + "label": "First Response TIme", + "reqd": 1 + }, + { + "fieldname": "column_break_grod", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_anyl", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_bwgs", + "fieldtype": "Column Break" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-02-23 12:17:26.631462", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Service Level Priority", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.py b/crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.py new file mode 100644 index 0000000..cba9250 --- /dev/null +++ b/crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.py @@ -0,0 +1,25 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMServiceLevelPriority(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + default_priority: DF.Check + first_response_time: DF.Duration + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + priority: DF.Link + # end: auto-generated types + + pass diff --git a/crm/fcrm/doctype/crm_service_level_priority/test_crm_service_level_priority.py b/crm/fcrm/doctype/crm_service_level_priority/test_crm_service_level_priority.py new file mode 100644 index 0000000..89f9c4e --- /dev/null +++ b/crm/fcrm/doctype/crm_service_level_priority/test_crm_service_level_priority.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import UnitTestCase + + +class TestCRMServiceLevelPriority(UnitTestCase): + pass diff --git a/crm/fcrm/doctype/crm_status_change_log/__init__.py b/crm/fcrm/doctype/crm_status_change_log/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_status_change_log/crm_status_change_log.json b/crm/fcrm/doctype/crm_status_change_log/crm_status_change_log.json new file mode 100644 index 0000000..b3d5810 --- /dev/null +++ b/crm/fcrm/doctype/crm_status_change_log/crm_status_change_log.json @@ -0,0 +1,93 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-01-06 13:15:31.392201", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "from", + "to", + "from_date", + "to_date", + "column_break_mwmz", + "duration", + "last_status_change_log", + "from_type", + "to_type", + "log_owner" + ], + "fields": [ + { + "fieldname": "from", + "fieldtype": "Data", + "in_list_view": 1, + "label": "From" + }, + { + "fieldname": "from_date", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "From Date" + }, + { + "fieldname": "duration", + "fieldtype": "Duration", + "in_list_view": 1, + "label": "Duration" + }, + { + "fieldname": "column_break_mwmz", + "fieldtype": "Column Break" + }, + { + "fieldname": "to", + "fieldtype": "Data", + "in_list_view": 1, + "label": "To" + }, + { + "fieldname": "to_date", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "To Date" + }, + { + "fieldname": "last_status_change_log", + "fieldtype": "Link", + "label": "Last Status Change Log", + "options": "CRM Status Change Log" + }, + { + "fieldname": "log_owner", + "fieldtype": "Link", + "label": "Owner", + "options": "User" + }, + { + "fieldname": "from_type", + "fieldtype": "Data", + "in_list_view": 1, + "label": "From Type" + }, + { + "fieldname": "to_type", + "fieldtype": "Data", + "in_list_view": 1, + "label": "To Type" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-02-23 12:15:29.602937", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Status Change Log", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_status_change_log/crm_status_change_log.py b/crm/fcrm/doctype/crm_status_change_log/crm_status_change_log.py new file mode 100644 index 0000000..8855241 --- /dev/null +++ b/crm/fcrm/doctype/crm_status_change_log/crm_status_change_log.py @@ -0,0 +1,85 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from datetime import datetime + +import frappe +from frappe.model.document import Document +from frappe.utils import add_to_date, get_datetime + + +class CRMStatusChangeLog(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + duration: DF.Duration | None + from_date: DF.Datetime | None + from_type: DF.Data | None + last_status_change_log: DF.Link | None + log_owner: DF.Link | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + to: DF.Data | None + to_date: DF.Datetime | None + to_type: DF.Data | None + # end: auto-generated types + + pass + + +def get_duration(from_date, to_date): + if not isinstance(from_date, datetime): + from_date = get_datetime(from_date) + if not isinstance(to_date, datetime): + to_date = get_datetime(to_date) + duration = to_date - from_date + return duration.total_seconds() + + +def add_status_change_log(doc): + to_status_type = frappe.db.get_value("CRM Deal Status", doc.status, "type") if doc.status else None + + if not doc.is_new(): + previous_status = doc.get_doc_before_save().status if doc.get_doc_before_save() else None + previous_status_type = ( + frappe.db.get_value("CRM Deal Status", previous_status, "type") if previous_status else None + ) + if not doc.status_change_log and previous_status: + now_minus_one_minute = add_to_date(datetime.now(), minutes=-1) + doc.append( + "status_change_log", + { + "from": previous_status, + "from_type": previous_status_type or "", + "to": "", + "to_type": "", + "from_date": now_minus_one_minute, + "to_date": "", + "log_owner": frappe.session.user, + }, + ) + last_status_change = doc.status_change_log[-1] + last_status_change.to = doc.status + last_status_change.to_type = to_status_type or "" + last_status_change.to_date = datetime.now() + last_status_change.log_owner = frappe.session.user + last_status_change.duration = get_duration(last_status_change.from_date, last_status_change.to_date) + + doc.append( + "status_change_log", + { + "from": doc.status, + "from_type": to_status_type or "", + "to": "", + "to_type": "", + "from_date": datetime.now(), + "to_date": "", + "log_owner": frappe.session.user, + }, + ) diff --git a/crm/fcrm/doctype/crm_task/__init__.py b/crm/fcrm/doctype/crm_task/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_task/crm_task.js b/crm/fcrm/doctype/crm_task/crm_task.js new file mode 100644 index 0000000..8357f6e --- /dev/null +++ b/crm/fcrm/doctype/crm_task/crm_task.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Task", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_task/crm_task.json b/crm/fcrm/doctype/crm_task/crm_task.json new file mode 100644 index 0000000..a56b2a0 --- /dev/null +++ b/crm/fcrm/doctype/crm_task/crm_task.json @@ -0,0 +1,141 @@ +{ + "actions": [], + "allow_import": 1, + "autoname": "autoincrement", + "creation": "2023-09-28 15:04:28.084159", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "title", + "priority", + "start_date", + "reference_doctype", + "reference_docname", + "column_break_cqua", + "assigned_to", + "status", + "due_date", + "section_break_bzhd", + "description" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Title", + "reqd": 1 + }, + { + "fieldname": "priority", + "fieldtype": "Select", + "in_standard_filter": 1, + "label": "Priority", + "options": "Low\nMedium\nHigh" + }, + { + "fieldname": "start_date", + "fieldtype": "Date", + "label": "Start Date" + }, + { + "fieldname": "column_break_cqua", + "fieldtype": "Column Break" + }, + { + "fieldname": "assigned_to", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Assigned To", + "options": "User" + }, + { + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "Backlog\nTodo\nIn Progress\nDone\nCanceled" + }, + { + "fieldname": "due_date", + "fieldtype": "Datetime", + "in_standard_filter": 1, + "label": "Due Date" + }, + { + "fieldname": "section_break_bzhd", + "fieldtype": "Section Break" + }, + { + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Description" + }, + { + "fieldname": "reference_doctype", + "fieldtype": "Link", + "label": "Reference Document Type", + "options": "DocType" + }, + { + "fieldname": "reference_docname", + "fieldtype": "Dynamic Link", + "label": "Reference Doc", + "options": "reference_doctype" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-03-26 12:55:16.304522", + "modified_by": "shariq@frappe.io", + "module": "FCRM", + "name": "CRM Task", + "naming_rule": "Autoincrement", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_task/crm_task.py b/crm/fcrm/doctype/crm_task/crm_task.py new file mode 100644 index 0000000..3e0e30e --- /dev/null +++ b/crm/fcrm/doctype/crm_task/crm_task.py @@ -0,0 +1,117 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from frappe.desk.form.assign_to import add as assign +from frappe.desk.form.assign_to import remove as unassign +from frappe.model.document import Document + + +class CRMTask(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + assigned_to: DF.Link | None + description: DF.TextEditor | None + due_date: DF.Datetime | None + name: DF.Int | None + priority: DF.Literal["Low", "Medium", "High"] + reference_docname: DF.DynamicLink | None + reference_doctype: DF.Link | None + start_date: DF.Date | None + status: DF.Literal["Backlog", "Todo", "In Progress", "Done", "Canceled"] + title: DF.Data + # end: auto-generated types + + def after_insert(self): + self.assign_to() + + def validate(self): + if self.is_new() or not self.assigned_to: + return + + if self.get_doc_before_save().assigned_to != self.assigned_to: + self.unassign_from_previous_user(self.get_doc_before_save().assigned_to) + self.assign_to() + + def unassign_from_previous_user(self, user: str | None): + if user: + unassign(self.doctype, self.name, user) + + def assign_to(self): + if self.assigned_to: + assign( + { + "assign_to": [self.assigned_to], + "doctype": self.doctype, + "name": self.name, + "description": self.title or self.description, + } + ) + + @staticmethod + def default_list_data(): + columns = [ + { + "label": "Title", + "type": "Data", + "key": "title", + "width": "16rem", + }, + { + "label": "Status", + "type": "Select", + "key": "status", + "width": "8rem", + }, + { + "label": "Priority", + "type": "Select", + "key": "priority", + "width": "8rem", + }, + { + "label": "Due Date", + "type": "Date", + "key": "due_date", + "width": "8rem", + }, + { + "label": "Assigned To", + "type": "Link", + "key": "assigned_to", + "width": "10rem", + }, + { + "label": "Last Modified", + "type": "Datetime", + "key": "modified", + "width": "8rem", + }, + ] + + rows = [ + "name", + "title", + "description", + "assigned_to", + "due_date", + "status", + "priority", + "reference_doctype", + "reference_docname", + "modified", + ] + return {"columns": columns, "rows": rows} + + @staticmethod + def default_kanban_settings(): + return { + "column_field": "status", + "title_field": "title", + "kanban_fields": '["description", "priority", "creation"]', + } diff --git a/crm/fcrm/doctype/crm_task/test_crm_task.py b/crm/fcrm/doctype/crm_task/test_crm_task.py new file mode 100644 index 0000000..3feb629 --- /dev/null +++ b/crm/fcrm/doctype/crm_task/test_crm_task.py @@ -0,0 +1,228 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.tests import IntegrationTestCase + + +class TestCRMTask(IntegrationTestCase): + def tearDown(self) -> None: + frappe.db.rollback() + + def test_task_creation(self): + """Test creating a basic task""" + task = create_test_task( + title="Test Task", + description="Test task description", + status="Todo", + priority="Medium", + ) + + self.assertTrue(task.name) + self.assertEqual(task.title, "Test Task") + self.assertEqual(task.status, "Todo") + self.assertEqual(task.priority, "Medium") + + def test_task_assignment_on_creation(self): + """Test that task is assigned to user on creation""" + task = create_test_task( + title="Assigned Task", + assigned_to="Administrator", + ) + + # Verify task was assigned to exactly one user + assignees = task.get_assigned_users() + self.assertEqual(assignees, {"Administrator"}) + + def test_update_assigned_user(self): + """Test updating assigned user unassigns previous and assigns new user""" + # Create test user if not exists + if not frappe.db.exists("User", "test@example.com"): + frappe.get_doc( + { + "doctype": "User", + "email": "test@example.com", + "first_name": "Test", + } + ).insert() + + # Create task with initial assignment + task = create_test_task( + title="Reassign Task", + assigned_to="Administrator", + ) + + # Verify initial assignment + assignees = task.get_assigned_users() + self.assertIn("Administrator", assignees) + + # Get fresh copy of the document to avoid timestamp mismatch + task = frappe.get_doc("CRM Task", task.name) + + # Change assigned user + task.assigned_to = "test@example.com" + task.save() + + # Verify new assignment + task.reload() + self.assertEqual(task.assigned_to, "test@example.com") + assignees_after = task.get_assigned_users() + self.assertIn("test@example.com", assignees_after) + self.assertNotIn("Administrator", assignees_after) + + def test_task_with_reference_doctype(self): + """Test creating task with reference to another document""" + # Create a deal for reference + org = frappe.get_doc( + { + "doctype": "CRM Organization", + "organization_name": "Task Reference Org", + } + ).insert() + + deal = frappe.get_doc( + { + "doctype": "CRM Deal", + "organization": org.name, + } + ).insert() + + # Create task with reference + task = create_test_task( + title="Deal Task", + reference_doctype="CRM Deal", + reference_docname=deal.name, + ) + + self.assertEqual(task.reference_doctype, "CRM Deal") + self.assertEqual(task.reference_docname, deal.name) + + def test_task_due_date(self): + """Test task with due date""" + task = create_test_task( + title="Due Date Task", + due_date="2026-12-31 23:59:59", + start_date="2026-01-01", + ) + + self.assertTrue(task.due_date) + self.assertTrue(task.start_date) + + def test_task_priority_ordering(self): + """Test that tasks can be ordered by priority for proper display""" + # Create tasks with different priorities + low_task = create_test_task( + title="Low Priority Task", + priority="Low", + status="Todo", + ) + medium_task = create_test_task( + title="Medium Priority Task", + priority="Medium", + status="Todo", + ) + high_task = create_test_task( + title="High Priority Task", + priority="High", + status="Todo", + ) + + # Verify priorities are set + self.assertEqual(low_task.priority, "Low") + self.assertEqual(medium_task.priority, "Medium") + self.assertEqual(high_task.priority, "High") + + # Test priority-based filtering + high_priority_tasks = frappe.get_all( + "CRM Task", + filters={"priority": "High", "status": "Todo"}, + fields=["name", "priority"], + ) + + # Verify high priority task is in filtered results + high_task_names = [t.name for t in high_priority_tasks] + self.assertIn(high_task.name, high_task_names) + self.assertNotIn(low_task.name, high_task_names) + + def test_task_status_workflow_and_filtering(self): + """Test task status transitions and filtering by status""" + statuses = ["Backlog", "Todo", "In Progress", "Done", "Canceled"] + + task = create_test_task(title="Status Workflow Task", status="Backlog") + initial_name = task.name + + # Test status transitions up to Done + for status in statuses[1:4]: # Backlog -> Todo -> In Progress -> Done + task.status = status + task.save() + task.reload() + self.assertEqual(task.status, status) + + # Test filtering by completed status (task should be Done now) + done_tasks = frappe.get_all("CRM Task", filters={"status": "Done"}, fields=["name"]) + done_task_names = [str(t.name) for t in done_tasks] + self.assertIn(str(initial_name), done_task_names) + + # Test filtering by active statuses (excluding Done and Canceled) + task2 = create_test_task(title="Active Task", status="In Progress") + active_tasks = frappe.get_all( + "CRM Task", + filters={"status": ["in", ["Backlog", "Todo", "In Progress"]]}, + fields=["name"], + ) + active_task_names = [str(t.name) for t in active_tasks] + self.assertIn(str(task2.name), active_task_names) + self.assertNotIn(str(initial_name), active_task_names) # task is Done, not active + + # Test Canceled status separately + task3 = create_test_task(title="Canceled Task", status="Canceled") + canceled_tasks = frappe.get_all("CRM Task", filters={"status": "Canceled"}, fields=["name"]) + canceled_task_names = [str(t.name) for t in canceled_tasks] + self.assertIn(str(task3.name), canceled_task_names) + + def test_task_without_assigned_user(self): + """Test creating task without assigned user""" + task = create_test_task(title="Unassigned Task") + + self.assertFalse(task.assigned_to) + assignees = task.get_assigned_users() + self.assertEqual(len(assignees), 0) + + def test_task_description(self): + """Test task with rich text description""" + description = "

This is a rich text description

" + task = create_test_task( + title="Description Task", + description=description, + ) + + self.assertEqual(task.description, description) + + def test_reassign_to_same_user(self): + """Test that reassigning to same user doesn't create duplicate assignments""" + task = create_test_task( + title="Same User Task", + assigned_to="Administrator", + ) + + initial_assignees = task.get_assigned_users() + initial_count = len(initial_assignees) + + # Get fresh copy of the document to avoid timestamp mismatch + task = frappe.get_doc("CRM Task", task.name) + + # Save again without changing assigned_to + task.save() + + # Verify no duplicate assignments + task.reload() + assignees_after = task.get_assigned_users() + self.assertEqual(len(assignees_after), initial_count) + self.assertIn("Administrator", assignees_after) + + +def create_test_task(**kwargs): + """Helper function to create a CRM Task for testing""" + data = {"doctype": "CRM Task"} + data.update(kwargs) + return frappe.get_doc(data).insert() diff --git a/crm/fcrm/doctype/crm_telephony_agent/__init__.py b/crm/fcrm/doctype/crm_telephony_agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.js b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.js new file mode 100644 index 0000000..b1bea17 --- /dev/null +++ b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Telephony Agent", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.json b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.json new file mode 100644 index 0000000..903f50d --- /dev/null +++ b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.json @@ -0,0 +1,149 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:user", + "creation": "2025-01-11 16:12:46.602782", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "user", + "user_name", + "column_break_hdec", + "mobile_no", + "default_medium", + "section_break_ozjn", + "twilio_number", + "call_receiving_device", + "column_break_aydj", + "exotel_number", + "section_break_phlq", + "phone_nos" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "User", + "options": "User", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "column_break_hdec", + "fieldtype": "Column Break" + }, + { + "fieldname": "mobile_no", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Mobile No.", + "options": "Phone", + "read_only": 1 + }, + { + "fetch_from": "user.full_name", + "fieldname": "user_name", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "User Name" + }, + { + "fieldname": "exotel_number", + "fieldtype": "Data", + "label": "Exotel Number", + "options": "Phone" + }, + { + "fieldname": "section_break_phlq", + "fieldtype": "Section Break" + }, + { + "fieldname": "section_break_ozjn", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_aydj", + "fieldtype": "Column Break" + }, + { + "fieldname": "twilio_number", + "fieldtype": "Data", + "label": "Twilio Number", + "options": "Phone" + }, + { + "fieldname": "phone_nos", + "fieldtype": "Table", + "label": "Phone Numbers", + "options": "CRM Telephony Phone" + }, + { + "fieldname": "default_medium", + "fieldtype": "Select", + "label": "Default Medium", + "options": "\nTwilio\nExotel" + }, + { + "default": "Computer", + "fieldname": "call_receiving_device", + "fieldtype": "Select", + "label": "Device", + "options": "Computer\nPhone" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-08-04 19:22:17.000846", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Telephony Agent", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "title_field": "user_name" +} diff --git a/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.py b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.py new file mode 100644 index 0000000..96acec4 --- /dev/null +++ b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.py @@ -0,0 +1,72 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + + +class CRMTelephonyAgent(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + from crm.fcrm.doctype.crm_telephony_phone.crm_telephony_phone import CRMTelephonyPhone + + call_receiving_device: DF.Literal["Computer", "Phone"] + default_medium: DF.Literal["", "Twilio", "Exotel"] + exotel_number: DF.Data | None + mobile_no: DF.Data | None + phone_nos: DF.Table[CRMTelephonyPhone] + twilio_number: DF.Data | None + user: DF.Link + user_name: DF.Data | None + # end: auto-generated types + + def validate(self): + self.update_phone_nos_based_on_mobile_no() + self.set_primary() + + def update_phone_nos_based_on_mobile_no(self): + doc_before_save = self.get_doc_before_save() + if not doc_before_save: + return + + old_mobile_no = doc_before_save.mobile_no + new_mobile_no = self.mobile_no + + if old_mobile_no == new_mobile_no: + return + + # if new mobile no. is not in phone_nos table, then add it + if new_mobile_no and not any(phone.get("number") == new_mobile_no for phone in self.phone_nos): + self.append("phone_nos", {"number": new_mobile_no, "is_primary": 1}) + + # if old mobile no. is in phone_nos table, then remove it + phone_to_remove = next( + (phone for phone in self.phone_nos if phone.get("number") == old_mobile_no), None + ) + if phone_to_remove: + self.phone_nos.remove(phone_to_remove) + + def set_primary(self): + # Used to set primary mobile no. + if not self.phone_nos and not self.mobile_no: + return + + if not self.phone_nos: + self.append("phone_nos", {"number": self.mobile_no, "is_primary": 1}) + + primary_numbers = [phone.get("number") for phone in self.phone_nos if phone.get("is_primary")] + + if len(primary_numbers) > 1: + frappe.throw( + _("Only one {0} can be set as primary.").format(frappe.bold(frappe.unscrub("mobile_no"))) + ) + + primary = next((d for d in self.phone_nos if d.get("is_primary") == 1), None) + self.mobile_no = primary.get("number") if primary else "" diff --git a/crm/fcrm/doctype/crm_telephony_agent/test_crm_telephony_agent.py b/crm/fcrm/doctype/crm_telephony_agent/test_crm_telephony_agent.py new file mode 100644 index 0000000..63e0f2f --- /dev/null +++ b/crm/fcrm/doctype/crm_telephony_agent/test_crm_telephony_agent.py @@ -0,0 +1,29 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class UnitTestCRMTelephonyAgent(UnitTestCase): + """ + Unit tests for CRMTelephonyAgent. + Use this class for testing individual functions and methods. + """ + + pass + + +class IntegrationTestCRMTelephonyAgent(IntegrationTestCase): + """ + Integration tests for CRMTelephonyAgent. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/crm/fcrm/doctype/crm_telephony_phone/__init__.py b/crm/fcrm/doctype/crm_telephony_phone/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.json b/crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.json new file mode 100644 index 0000000..97ef52a --- /dev/null +++ b/crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.json @@ -0,0 +1,41 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-01-19 13:57:01.702519", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "number", + "is_primary" + ], + "fields": [ + { + "fieldname": "number", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Number", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "is_primary", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Is Primary" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-02-23 12:22:18.367755", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Telephony Phone", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.py b/crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.py new file mode 100644 index 0000000..3c9bc43 --- /dev/null +++ b/crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.py @@ -0,0 +1,24 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMTelephonyPhone(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + is_primary: DF.Check + number: DF.Data + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + # end: auto-generated types + + pass diff --git a/crm/fcrm/doctype/crm_territory/__init__.py b/crm/fcrm/doctype/crm_territory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_territory/crm_territory.js b/crm/fcrm/doctype/crm_territory/crm_territory.js new file mode 100644 index 0000000..568e9c4 --- /dev/null +++ b/crm/fcrm/doctype/crm_territory/crm_territory.js @@ -0,0 +1,8 @@ +// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Territory", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_territory/crm_territory.json b/crm/fcrm/doctype/crm_territory/crm_territory.json new file mode 100644 index 0000000..d80ed72 --- /dev/null +++ b/crm/fcrm/doctype/crm_territory/crm_territory.json @@ -0,0 +1,137 @@ +{ + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "autoname": "field:territory_name", + "creation": "2024-01-04 18:52:58.872535", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "territory_name", + "column_break_mckp", + "territory_manager", + "section_break_qhaf", + "old_parent", + "parent_crm_territory", + "column_break_pypy", + "lft", + "rgt", + "is_group" + ], + "fields": [ + { + "fieldname": "territory_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Territory Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "column_break_pypy", + "fieldtype": "Column Break" + }, + { + "fieldname": "territory_manager", + "fieldtype": "Link", + "label": "Territory Manager", + "options": "User" + }, + { + "fieldname": "lft", + "fieldtype": "Int", + "hidden": 1, + "label": "Left", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "rgt", + "fieldtype": "Int", + "hidden": 1, + "label": "Right", + "no_copy": 1, + "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_group", + "fieldtype": "Check", + "label": "Is Group" + }, + { + "fieldname": "old_parent", + "fieldtype": "Link", + "label": "Old Parent", + "options": "CRM Territory" + }, + { + "fieldname": "parent_crm_territory", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Parent CRM Territory", + "options": "CRM Territory" + }, + { + "fieldname": "column_break_mckp", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_qhaf", + "fieldtype": "Section Break" + } + ], + "index_web_pages_for_search": 1, + "is_tree": 1, + "links": [], + "modified": "2026-03-05 12:44:13.642717", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Territory", + "naming_rule": "By fieldname", + "nsm_parent_field": "parent_crm_territory", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "translated_doctype": 1 +} diff --git a/crm/fcrm/doctype/crm_territory/crm_territory.py b/crm/fcrm/doctype/crm_territory/crm_territory.py new file mode 100644 index 0000000..322ec85 --- /dev/null +++ b/crm/fcrm/doctype/crm_territory/crm_territory.py @@ -0,0 +1,26 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMTerritory(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + is_group: DF.Check + lft: DF.Int + old_parent: DF.Link | None + parent_crm_territory: DF.Link | None + rgt: DF.Int + territory_manager: DF.Link | None + territory_name: DF.Data + # end: auto-generated types + + pass diff --git a/crm/fcrm/doctype/crm_territory/crm_territory_tree.js b/crm/fcrm/doctype/crm_territory/crm_territory_tree.js new file mode 100644 index 0000000..dd363d0 --- /dev/null +++ b/crm/fcrm/doctype/crm_territory/crm_territory_tree.js @@ -0,0 +1,5 @@ +// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.treeview_settings["CRM Territory"] = { +// }; diff --git a/crm/fcrm/doctype/crm_territory/test_crm_territory.py b/crm/fcrm/doctype/crm_territory/test_crm_territory.py new file mode 100644 index 0000000..c1315d9 --- /dev/null +++ b/crm/fcrm/doctype/crm_territory/test_crm_territory.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import UnitTestCase + + +class TestCRMTerritory(UnitTestCase): + pass diff --git a/crm/fcrm/doctype/crm_twilio_settings/__init__.py b/crm/fcrm/doctype/crm_twilio_settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.js b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.js new file mode 100644 index 0000000..1bfa5e4 --- /dev/null +++ b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Twilio Settings", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.json b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.json new file mode 100644 index 0000000..431867b --- /dev/null +++ b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.json @@ -0,0 +1,177 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-08-17 18:38:02.655918", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "section_break_ssqj", + "enabled", + "column_break_avmt", + "record_calls", + "section_break_eklq", + "account_sid", + "column_break_yqvr", + "auth_token", + "section_break_malx", + "api_key", + "twiml_sid", + "column_break_idds", + "api_secret", + "app_name", + "section_break_cghd", + "twilio_apps" + ], + "fields": [ + { + "depends_on": "enabled", + "fieldname": "account_sid", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Account SID", + "mandatory_depends_on": "eval: doc.enabled" + }, + { + "depends_on": "enabled", + "fieldname": "api_key", + "fieldtype": "Data", + "label": "API Key", + "permlevel": 1, + "read_only": 1 + }, + { + "depends_on": "enabled", + "fieldname": "api_secret", + "fieldtype": "Password", + "label": "API Secret", + "permlevel": 1, + "read_only": 1 + }, + { + "fieldname": "column_break_idds", + "fieldtype": "Column Break" + }, + { + "depends_on": "enabled", + "fieldname": "auth_token", + "fieldtype": "Password", + "in_list_view": 1, + "label": "Auth Token", + "mandatory_depends_on": "eval: doc.enabled" + }, + { + "depends_on": "enabled", + "fieldname": "twiml_sid", + "fieldtype": "Data", + "label": "TwiML SID", + "permlevel": 1 + }, + { + "fieldname": "section_break_ssqj", + "fieldtype": "Section Break" + }, + { + "default": "0", + "depends_on": "enabled", + "fieldname": "record_calls", + "fieldtype": "Check", + "label": "Record Calls" + }, + { + "fieldname": "column_break_avmt", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_malx", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "fieldname": "section_break_eklq", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "fieldname": "column_break_yqvr", + "fieldtype": "Column Break" + }, + { + "fieldname": "app_name", + "fieldtype": "Data", + "label": "App Name" + }, + { + "fieldname": "section_break_cghd", + "fieldtype": "Section Break" + }, + { + "fieldname": "twilio_apps", + "fieldtype": "Data", + "hidden": 1, + "label": "Twilio Apps" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2026-03-06 15:56:05.387337", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Twilio Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "delete": 1, + "email": 1, + "permlevel": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "delete": 1, + "email": 1, + "permlevel": 1, + "print": 1, + "read": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.py b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.py new file mode 100644 index 0000000..16cfd61 --- /dev/null +++ b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.py @@ -0,0 +1,131 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from twilio.rest import Client + + +class CRMTwilioSettings(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + account_sid: DF.Data | None + api_key: DF.Data | None + api_secret: DF.Password | None + app_name: DF.Data | None + auth_token: DF.Password | None + enabled: DF.Check + record_calls: DF.Check + twilio_apps: DF.Data | None + twiml_sid: DF.Data | None + # end: auto-generated types + + friendly_resource_name = "Frappe CRM" # System creates TwiML app & API keys with this name. + + def validate(self): + old_account_sid = frappe.db.get_single_value("CRM Twilio Settings", "account_sid") + if self.account_sid != old_account_sid: + self.new_sid = True + else: + self.new_sid = False + self.validate_twilio_account() + + def on_update(self): + # Single doctype records are created in DB at time of installation and those field values are set as null. + # This condition make sure that we handle null. + if not self.account_sid: + return + + twilio = Client(self.account_sid, self.get_password("auth_token")) + self.set_api_credentials(twilio) + self.set_application_credentials(twilio, self.app_name) + self.fetch_applications() + + def validate_twilio_account(self): + try: + twilio = Client(self.account_sid, self.get_password("auth_token")) + twilio.api.accounts(self.account_sid).fetch() + return twilio + except Exception: + frappe.throw(_("Invalid Account SID or Auth Token.")) + + def set_api_credentials(self, twilio): + """Generate Twilio API credentials if not exist and update them.""" + if self.api_key and self.api_secret and not self.new_sid: + return + new_key = self.create_api_key(twilio) + self.api_key = new_key.sid + self.api_secret = new_key.secret + frappe.db.set_single_value( + "CRM Twilio Settings", + {"api_key": self.api_key, "api_secret": self.api_secret}, + ) + + def set_application_credentials(self, twilio, app_name): + """Generate TwiML app credentials if not exist and update them.""" + credentials = self.get_application(twilio, app_name) or self.create_application(twilio) + self.twiml_sid = credentials.sid + self.app_name = credentials.friendly_name + frappe.db.set_single_value( + "CRM Twilio Settings", {"twiml_sid": self.twiml_sid, "app_name": self.app_name} + ) + + def create_api_key(self, twilio): + """Create API keys in twilio account.""" + try: + return twilio.new_keys.create(friendly_name=self.friendly_resource_name) + except Exception: + frappe.log_error(title=_("Twilio API credential creation error.")) + frappe.throw(_("Twilio API credential creation error.")) + + def get_twilio_voice_url(self): + url_path = "/api/method/crm.integrations.twilio.api.voice" + return get_public_url(url_path) + + def get_application(self, twilio, friendly_name=None): + """Get TwiML App from twilio account if exists.""" + friendly_name = friendly_name or self.friendly_resource_name + applications = twilio.applications.list(friendly_name) + default_application = twilio.applications.list(self.friendly_resource_name) + + if applications: + return applications[0] + if default_application: + return default_application[0] + return None + + def create_application(self, twilio, friendly_name=None): + """Create TwilML App in twilio account.""" + friendly_name = friendly_name or self.friendly_resource_name + application = twilio.applications.create( + voice_method="POST", voice_url=self.get_twilio_voice_url(), friendly_name=friendly_name + ) + return application + + @frappe.whitelist() + def fetch_applications(self): + twilio = self.validate_twilio_account() + + if not twilio: + frappe.throw(_("Unable to connect to Twilio account to fetch applications.")) + return + + applications = [app.friendly_name for app in twilio.applications.list()] + frappe.db.set_single_value( + "CRM Twilio Settings", + "twilio_apps", + ",".join(applications), + ) + + +def get_public_url(path: str | None = None): + from frappe.utils import get_url + + return get_url().split(":8", 1)[0] + path diff --git a/crm/fcrm/doctype/crm_twilio_settings/test_crm_twilio_settings.py b/crm/fcrm/doctype/crm_twilio_settings/test_crm_twilio_settings.py new file mode 100644 index 0000000..531076a --- /dev/null +++ b/crm/fcrm/doctype/crm_twilio_settings/test_crm_twilio_settings.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import UnitTestCase + + +class TestCRMTwilioSettings(UnitTestCase): + pass diff --git a/crm/fcrm/doctype/crm_view_settings/__init__.py b/crm/fcrm/doctype/crm_view_settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/crm_view_settings/crm_view_settings.js b/crm/fcrm/doctype/crm_view_settings/crm_view_settings.js new file mode 100644 index 0000000..df838f2 --- /dev/null +++ b/crm/fcrm/doctype/crm_view_settings/crm_view_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM View Settings", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_view_settings/crm_view_settings.json b/crm/fcrm/doctype/crm_view_settings/crm_view_settings.json new file mode 100644 index 0000000..3143ad5 --- /dev/null +++ b/crm/fcrm/doctype/crm_view_settings/crm_view_settings.json @@ -0,0 +1,243 @@ +{ + "actions": [], + "autoname": "autoincrement", + "creation": "2023-11-27 16:29:10.993403", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "label", + "icon", + "user", + "is_standard", + "is_default", + "column_break_zacm", + "type", + "dt", + "route_name", + "pinned", + "public", + "filters_tab", + "filters", + "order_by_tab", + "order_by", + "list_tab", + "list_section", + "load_default_columns", + "columns", + "rows", + "group_by_tab", + "group_by_field", + "kanban_tab", + "kanban_section", + "column_field", + "title_field", + "kanban_columns", + "kanban_fields" + ], + "fields": [ + { + "fieldname": "columns", + "fieldtype": "Code", + "label": "Columns" + }, + { + "fieldname": "user", + "fieldtype": "Link", + "label": "User", + "options": "User" + }, + { + "fieldname": "rows", + "fieldtype": "Code", + "label": "Rows" + }, + { + "fieldname": "filters", + "fieldtype": "Code", + "label": "Filters" + }, + { + "fieldname": "filters_tab", + "fieldtype": "Tab Break", + "label": "Filters" + }, + { + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Label" + }, + { + "fieldname": "column_break_zacm", + "fieldtype": "Column Break" + }, + { + "fieldname": "dt", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "DocType", + "options": "DocType" + }, + { + "fieldname": "order_by_tab", + "fieldtype": "Tab Break", + "label": "Order By" + }, + { + "fieldname": "order_by", + "fieldtype": "Code", + "label": "Order By" + }, + { + "default": "0", + "fieldname": "pinned", + "fieldtype": "Check", + "label": "Pinned" + }, + { + "fieldname": "route_name", + "fieldtype": "Data", + "label": "Route Name" + }, + { + "default": "0", + "fieldname": "load_default_columns", + "fieldtype": "Check", + "label": "Load Default Columns" + }, + { + "default": "0", + "fieldname": "public", + "fieldtype": "Check", + "label": "Public" + }, + { + "fieldname": "icon", + "fieldtype": "Data", + "label": "Icon" + }, + { + "default": "list", + "fieldname": "type", + "fieldtype": "Select", + "label": "Type", + "options": "list\ngroup_by\nkanban" + }, + { + "fieldname": "group_by_tab", + "fieldtype": "Tab Break", + "label": "Group By" + }, + { + "fieldname": "group_by_field", + "fieldtype": "Data", + "label": "Group By Field" + }, + { + "fieldname": "list_section", + "fieldtype": "Section Break" + }, + { + "fieldname": "kanban_section", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_field", + "fieldtype": "Data", + "label": "Column Field" + }, + { + "fieldname": "list_tab", + "fieldtype": "Tab Break", + "label": "List" + }, + { + "fieldname": "kanban_tab", + "fieldtype": "Tab Break", + "label": "Kanban" + }, + { + "fieldname": "kanban_columns", + "fieldtype": "Code", + "label": "Kanban Columns" + }, + { + "fieldname": "kanban_fields", + "fieldtype": "Code", + "label": "Kanban Fields" + }, + { + "fieldname": "title_field", + "fieldtype": "Data", + "label": "Title Field" + }, + { + "default": "0", + "fieldname": "is_standard", + "fieldtype": "Check", + "label": "Is Standard" + }, + { + "default": "0", + "fieldname": "is_default", + "fieldtype": "Check", + "label": "Is Default" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-02-23 12:29:29.007129", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM View Settings", + "naming_rule": "Autoincrement", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "read_only": 1, + "row_format": "Dynamic", + "show_title_field_in_link": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "label", + "track_changes": 1 +} diff --git a/crm/fcrm/doctype/crm_view_settings/crm_view_settings.py b/crm/fcrm/doctype/crm_view_settings/crm_view_settings.py new file mode 100644 index 0000000..721a153 --- /dev/null +++ b/crm/fcrm/doctype/crm_view_settings/crm_view_settings.py @@ -0,0 +1,329 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt +import json + +import frappe +from frappe import _ +from frappe.model.document import Document, get_controller +from frappe.utils import parse_json + + +class CRMViewSettings(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + column_field: DF.Data | None + columns: DF.Code | None + dt: DF.Link | None + filters: DF.Code | None + group_by_field: DF.Data | None + icon: DF.Data | None + is_default: DF.Check + is_standard: DF.Check + kanban_columns: DF.Code | None + kanban_fields: DF.Code | None + label: DF.Data | None + load_default_columns: DF.Check + name: DF.Int | None + order_by: DF.Code | None + pinned: DF.Check + public: DF.Check + route_name: DF.Data | None + rows: DF.Code | None + title_field: DF.Data | None + type: DF.Literal["list", "group_by", "kanban"] + user: DF.Link | None + # end: auto-generated types + + pass + + +@frappe.whitelist() +def create(view: dict): + view = frappe._dict(view) + + view.filters = parse_json(view.filters) or {} + view.columns = parse_json(view.columns or "[]") + view.rows = parse_json(view.rows or "[]") + view.kanban_columns = parse_json(view.kanban_columns or "[]") + view.kanban_fields = parse_json(view.kanban_fields or "[]") + + default_rows = sync_default_rows(view.doctype) + view.rows = view.rows + default_rows if default_rows else view.rows + view.rows = remove_duplicates(view.rows) + + if not view.kanban_columns and view.type == "kanban": + view.kanban_columns = sync_default_columns(view) + elif not view.columns: + view.columns = sync_default_columns(view) + + doc = frappe.new_doc("CRM View Settings") + doc.name = view.label + doc.label = view.label + doc.type = view.type or "list" + doc.icon = view.icon + doc.dt = view.doctype + doc.user = frappe.session.user + doc.route_name = view.route_name or get_route_name(view.doctype) + doc.load_default_columns = view.load_default_columns or False + doc.filters = json.dumps(view.filters) + doc.order_by = view.order_by + doc.group_by_field = view.group_by_field + doc.column_field = view.column_field + doc.title_field = view.title_field + doc.kanban_columns = json.dumps(view.kanban_columns) + doc.kanban_fields = json.dumps(view.kanban_fields) + doc.columns = json.dumps(view.columns) + doc.rows = json.dumps(view.rows) + doc.insert() + return doc + + +@frappe.whitelist() +def update(view: dict): + view = frappe._dict(view) + + filters = parse_json(view.filters or {}) + columns = parse_json(view.columns or []) + rows = parse_json(view.rows or []) + kanban_columns = parse_json(view.kanban_columns or []) + kanban_fields = parse_json(view.kanban_fields or []) + + default_rows = sync_default_rows(view.doctype) + rows = rows + default_rows if default_rows else rows + rows = remove_duplicates(rows) + + doc = frappe.get_doc("CRM View Settings", view.name) + check_permission(doc) + + doc.label = view.label + doc.type = view.type or "list" + doc.icon = view.icon + doc.route_name = view.route_name or get_route_name(view.doctype) + doc.load_default_columns = view.load_default_columns or False + doc.filters = json.dumps(filters) + doc.order_by = view.order_by + doc.group_by_field = view.group_by_field + doc.column_field = view.column_field + doc.title_field = view.title_field + doc.kanban_columns = json.dumps(kanban_columns) + doc.kanban_fields = json.dumps(kanban_fields) + doc.columns = json.dumps(columns) + doc.rows = json.dumps(rows) + doc.save() + return doc + + +@frappe.whitelist() +def delete(name: str | int): + if frappe.db.exists("CRM View Settings", name): + doc = frappe.get_doc("CRM View Settings", name) + check_permission(doc) + frappe.delete_doc("CRM View Settings", name) + + +@frappe.whitelist() +def public(name: str | int, value: bool | int): + if frappe.session.user != "Administrator" and "Sales Manager" not in frappe.get_roles(): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + doc = frappe.get_doc("CRM View Settings", name) + if doc.pinned: + doc.pinned = False + doc.public = bool(value) + doc.user = "" if value else frappe.session.user + doc.save() + + +@frappe.whitelist() +def pin(name: str | int, value: bool | int): + doc = frappe.get_doc("CRM View Settings", name) + check_permission(doc) + doc.pinned = bool(value) + doc.save() + + +def check_permission(doc): + """Administrator and System Manager can edit any view. + If view is public, Sales Manager can edit. + If view is private, only the view owner can edit.""" + if frappe.session.user == "Administrator" or "System Manager" in frappe.get_roles(): + pass + elif doc.public and "Sales Manager" in frappe.get_roles(): + pass + elif doc.user and doc.user == frappe.session.user: + pass + else: + frappe.throw(_("Not permitted"), frappe.PermissionError) + + +def remove_duplicates(l): + return list(dict.fromkeys(l)) + + +def sync_default_rows(doctype, type="list"): + list = get_controller(doctype) + rows = [] + + if hasattr(list, "default_list_data"): + rows = list.default_list_data().get("rows") + + return rows + + +def sync_default_columns(view): + doctype = view.dt or view.doctype + list = get_controller(doctype) + columns = [] + + if view.type == "kanban" and view.column_field: + field_meta = frappe.get_meta(doctype).get_field(view.column_field) + if field_meta.fieldtype == "Link": + columns = frappe.get_all( + field_meta.options, + fields=["name"], + order_by="modified asc", + ) + elif field_meta.fieldtype == "Select": + columns = [{"name": option} for option in field_meta.options.split("\n")] + elif hasattr(list, "default_list_data"): + columns = list.default_list_data().get("columns") + + return columns + + +@frappe.whitelist() +def set_as_default(name: str | int | None = None, type: str | None = None, doctype: str | None = None): + if name: + frappe.db.set_value("CRM View Settings", name, "is_default", 1) + doctype = doctype or frappe.db.get_value("CRM View Settings", name, "dt") + else: + doc = create_or_update_standard_view({"type": type, "doctype": doctype, "is_default": 1}) + name = doc.name + + # remove default from other views of same user and same doctype + frappe.db.set_value( + "CRM View Settings", + {"name": ("!=", name), "user": frappe.session.user, "is_default": 1, "dt": doctype}, + "is_default", + 0, + ) + + +@frappe.whitelist() +def create_or_update_standard_view(view: dict): + view = frappe._dict(view) + + filters = parse_json(view.filters) or {} + columns = parse_json(view.columns or "[]") + rows = parse_json(view.rows or "[]") + kanban_columns = parse_json(view.kanban_columns or "[]") + kanban_fields = parse_json(view.kanban_fields or "[]") + view.column_field = view.column_field or "status" + + default_rows = sync_default_rows(view.doctype, view.type) + rows = rows + default_rows if default_rows else rows + rows = remove_duplicates(rows) + + if not kanban_columns and view.type == "kanban": + kanban_columns = sync_default_columns(view) + elif not columns: + columns = sync_default_columns(view) + + doc = frappe.db.exists( + "CRM View Settings", + {"dt": view.doctype, "type": view.type or "list", "is_standard": True, "user": frappe.session.user}, + ) + if doc: + doc = frappe.get_doc("CRM View Settings", doc) + doc.label = view.label + doc.type = view.type or "list" + doc.route_name = view.route_name or get_route_name(view.doctype) + doc.load_default_columns = view.load_default_columns or False + doc.filters = json.dumps(filters) + doc.order_by = view.order_by or "modified desc" + doc.group_by_field = view.group_by_field or "owner" + doc.column_field = view.column_field + doc.title_field = view.title_field + doc.kanban_columns = json.dumps(kanban_columns) + doc.kanban_fields = json.dumps(kanban_fields) + doc.columns = json.dumps(columns) + doc.rows = json.dumps(rows) + doc.is_default = view.is_default or False + doc.save() + else: + doc = frappe.new_doc("CRM View Settings") + + label = "List" + if view.type == "group_by": + label = "Group By" + elif view.type == "kanban": + label = "Kanban" + + doc.name = view.label or label + doc.label = view.label or label + doc.type = view.type or "list" + doc.dt = view.doctype + doc.user = frappe.session.user + doc.route_name = view.route_name or get_route_name(view.doctype) + doc.load_default_columns = view.load_default_columns or False + doc.filters = json.dumps(filters) + doc.order_by = view.order_by or "modified desc" + doc.group_by_field = view.group_by_field or "owner" + doc.column_field = view.column_field + doc.title_field = view.title_field + doc.kanban_columns = json.dumps(kanban_columns) + doc.kanban_fields = json.dumps(kanban_fields) + doc.columns = json.dumps(columns) + doc.rows = json.dumps(rows) + doc.is_standard = True + doc.is_default = view.is_default or False + doc.insert() + + return doc + + +@frappe.whitelist() +def fetch_and_update_kanban_columns(name: str | int): + doc = frappe.get_doc("CRM View Settings", name) + if doc.type != "kanban": + return + + new_columns = sync_default_columns(doc) + existing_columns = parse_json(doc.kanban_columns or "[]") + existing_column_names = [column.get("name") for column in existing_columns] + for column in new_columns: + if column.get("name") not in existing_column_names: + existing_columns.append({"name": column.get("name"), "delete": True}) + + doc.kanban_columns = json.dumps(existing_columns) + doc.save(ignore_permissions=True) + return doc.kanban_columns + + +def clear_old_versions(days=14): + from frappe.utils import add_days, now_datetime + + frappe.db.delete( + "Version", + { + "ref_doctype": "CRM View Settings", + "creation": ("<", add_days(now_datetime(), -days)), + }, + ) + + +def get_route_name(doctype): + # Example: "CRM Lead" -> "Leads" + if doctype.startswith("CRM "): + doctype = doctype[4:] + + if doctype[-1] != "s": + doctype += "s" + + return doctype diff --git a/crm/fcrm/doctype/crm_view_settings/test_crm_view_settings.py b/crm/fcrm/doctype/crm_view_settings/test_crm_view_settings.py new file mode 100644 index 0000000..f8d461e --- /dev/null +++ b/crm/fcrm/doctype/crm_view_settings/test_crm_view_settings.py @@ -0,0 +1,46 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.tests import IntegrationTestCase + +from crm.fcrm.doctype.crm_view_settings.crm_view_settings import set_as_default + + +class TestCRMViewSettings(IntegrationTestCase): + def setUp(self): + frappe.set_user("Administrator") + frappe.db.delete("CRM View Settings", {"user": "Administrator"}) + + def tearDown(self): + frappe.db.rollback() + + def make_view(self, label, doctype, is_default=0): + return frappe.get_doc( + { + "doctype": "CRM View Settings", + "name": label, + "label": label, + "dt": doctype, + "type": "list", + "user": "Administrator", + "is_default": is_default, + } + ).insert() + + def test_name_only_call_clears_previous_default_of_same_doctype(self): + old = self.make_view("Old Leads", "CRM Lead", is_default=1) + new = self.make_view("New Leads", "CRM Lead") + + set_as_default(name=new.name) + + self.assertEqual(frappe.db.get_value("CRM View Settings", new.name, "is_default"), 1) + self.assertEqual(frappe.db.get_value("CRM View Settings", old.name, "is_default"), 0) + + def test_name_only_call_keeps_default_of_other_doctype(self): + deals = self.make_view("My Deals", "CRM Deal", is_default=1) + leads = self.make_view("My Leads", "CRM Lead") + + set_as_default(name=leads.name) + + self.assertEqual(frappe.db.get_value("CRM View Settings", deals.name, "is_default"), 1) diff --git a/crm/fcrm/doctype/erpnext_crm_settings/__init__.py b/crm/fcrm/doctype/erpnext_crm_settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/erpnext_crm_settings/erpnext_crm_settings.js b/crm/fcrm/doctype/erpnext_crm_settings/erpnext_crm_settings.js new file mode 100644 index 0000000..e60d63e --- /dev/null +++ b/crm/fcrm/doctype/erpnext_crm_settings/erpnext_crm_settings.js @@ -0,0 +1,20 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("ERPNext CRM Settings", { + refresh(frm) { + if (!frm.doc.enabled) return; + frm.add_custom_button(__("Reset ERPNext Form Script"), () => { + frappe.confirm( + __( + "Are you sure you want to reset 'Create Quotation from CRM Deal' Form Script?", + ), + () => frm.trigger("reset_erpnext_form_script"), + ); + }); + }, + async reset_erpnext_form_script(frm) { + let script = await frm.call("reset_erpnext_form_script"); + script.message && frappe.msgprint(__("Form Script updated successfully")); + }, +}); diff --git a/crm/fcrm/doctype/erpnext_crm_settings/erpnext_crm_settings.json b/crm/fcrm/doctype/erpnext_crm_settings/erpnext_crm_settings.json new file mode 100644 index 0000000..c5047b0 --- /dev/null +++ b/crm/fcrm/doctype/erpnext_crm_settings/erpnext_crm_settings.json @@ -0,0 +1,159 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-07-02 15:23:17.022214", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "enabled", + "sync_issues", + "erpnext_company", + "column_break_vfru", + "is_erpnext_in_different_site", + "sync_products", + "erpnext_site_url", + "section_break_oubd", + "api_key", + "column_break_fllx", + "api_secret", + "section_break_jnbn", + "create_customer_on_status_change", + "column_break_kbhw", + "deal_status" + ], + "fields": [ + { + "depends_on": "eval:doc.enabled && doc.is_erpnext_in_different_site", + "fieldname": "api_key", + "fieldtype": "Data", + "label": "API Key", + "mandatory_depends_on": "is_erpnext_in_different_site" + }, + { + "depends_on": "eval:doc.enabled && doc.is_erpnext_in_different_site", + "fieldname": "api_secret", + "fieldtype": "Password", + "label": "API Secret", + "mandatory_depends_on": "is_erpnext_in_different_site" + }, + { + "depends_on": "enabled", + "fieldname": "section_break_oubd", + "fieldtype": "Section Break", + "label": "ERPNext Site API's" + }, + { + "fieldname": "column_break_fllx", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.enabled && doc.is_erpnext_in_different_site", + "fieldname": "erpnext_site_url", + "fieldtype": "Data", + "label": "ERPNext Site URL", + "mandatory_depends_on": "is_erpnext_in_different_site" + }, + { + "depends_on": "enabled", + "fieldname": "erpnext_company", + "fieldtype": "Data", + "label": "Company in ERPNext site", + "mandatory_depends_on": "enabled" + }, + { + "fieldname": "column_break_vfru", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "fieldname": "sync_issues", + "fieldtype": "Table", + "label": "Product Sync Issues", + "options": "CRM Product Sync Issue", + "read_only": 1 + }, + { + "default": "0", + "depends_on": "enabled", + "fieldname": "is_erpnext_in_different_site", + "fieldtype": "Check", + "label": "Is ERPNext installed on a different site?" + }, + { + "default": "1", + "depends_on": "eval:doc.enabled && !doc.is_erpnext_in_different_site", + "fieldname": "sync_products", + "fieldtype": "Check", + "label": "Bidirectional Product Sync" + }, + { + "fieldname": "section_break_jnbn", + "fieldtype": "Section Break" + }, + { + "default": "0", + "depends_on": "enabled", + "fieldname": "create_customer_on_status_change", + "fieldtype": "Check", + "label": "Create customer on status change" + }, + { + "fieldname": "column_break_kbhw", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.enabled && doc.create_customer_on_status_change", + "fieldname": "deal_status", + "fieldtype": "Link", + "label": "Deal Status", + "mandatory_depends_on": "create_customer_on_status_change", + "options": "CRM Deal Status" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2026-07-13 12:49:50.042124", + "modified_by": "Administrator", + "module": "FCRM", + "name": "ERPNext CRM Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "print": 1, + "read": 1, + "role": "Sales User", + "share": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/erpnext_crm_settings/erpnext_crm_settings.py b/crm/fcrm/doctype/erpnext_crm_settings/erpnext_crm_settings.py new file mode 100644 index 0000000..e837a05 --- /dev/null +++ b/crm/fcrm/doctype/erpnext_crm_settings/erpnext_crm_settings.py @@ -0,0 +1,690 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see license.txt + +import json + +import frappe +from frappe import _ +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields as _create_custom_fields +from frappe.custom.doctype.property_setter.property_setter import make_property_setter +from frappe.frappeclient import FrappeClient +from frappe.model.document import Document +from frappe.utils import get_url_to_form, get_url_to_list + + +def _is_erpnext_installed(): + return "erpnext" in frappe.get_installed_apps() + + +def _log_and_throw(message: str, title: str | None = None): + frappe.log_error(frappe.get_traceback(), title or message) + frappe.throw(_(message)) + + +def _get_enabled_settings(): + settings = frappe.get_single("ERPNext CRM Settings") + if not settings.enabled: + frappe.throw(_("ERPNext is not integrated with the CRM")) + return settings + + +class ERPNextCRMSettings(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + from crm.fcrm.doctype.crm_product_sync_issue.crm_product_sync_issue import CRMProductSyncIssue + + api_key: DF.Data | None + api_secret: DF.Password | None + create_customer_on_status_change: DF.Check + deal_status: DF.Link | None + enabled: DF.Check + erpnext_company: DF.Data | None + erpnext_site_url: DF.Data | None + is_erpnext_in_different_site: DF.Check + sync_issues: DF.Table[CRMProductSyncIssue] + sync_products: DF.Check + # end: auto-generated types + + def validate(self): + old = self.get_doc_before_save() + was_active = bool(old and old.enabled and not old.is_erpnext_in_different_site) + if self.enabled: + self.validate_if_erpnext_installed() + self.add_quotation_to_option() + self.create_custom_fields() + self.create_crm_form_script() + self.grant_item_access_to_sales_roles() + if not was_active and not self.is_erpnext_in_different_site: + from crm.fcrm.doctype.crm_product.reconcile_job import enqueue_reconciliation + + enqueue_reconciliation() + + def validate_if_erpnext_installed(self): + if not self.is_erpnext_in_different_site: + if not _is_erpnext_installed(): + frappe.throw(_("ERPNext is not installed in the current site")) + + def add_quotation_to_option(self): + if not self.is_erpnext_in_different_site: + if not frappe.db.exists("Property Setter", {"name": "Quotation-quotation_to-link_filters"}): + make_property_setter( + doctype="Quotation", + fieldname="quotation_to", + property="link_filters", + value='[["DocType","name","in", ["Customer", "Lead", "Prospect", "CRM Deal"]]]', + property_type="JSON", + validate_fields_for_doctype=False, + ) + + def create_custom_fields(self): + if not self.is_erpnext_in_different_site: + if not _is_erpnext_installed(): + frappe.throw(_("ERPNext is not installed in the current site")) + else: + self.create_custom_fields_in_remote_site() + + self.create_custom_fields_in_frappe_crm() + + def create_custom_fields_in_frappe_crm(self): + custom_fields = { + "CRM Deal": [ + { + "fieldname": "erpnext_customer", + "fieldtype": "Data", + "label": "Customer in ERPNext", + "insert_after": "lead_name", + } + ], + "CRM Product": [ + { + "fieldname": "erpnext_item_code", + "fieldtype": "Data", + "label": "Item Code in ERPNext", + "read_only": 1, + "insert_after": "product_code", + } + ], + } + if frappe.db.exists("DocType", "Item"): + custom_fields["Item"] = [ + { + "fieldname": "crm_product_code", + "fieldtype": "Data", + "label": "CRM Product", + "read_only": 1, + "no_copy": 1, + "insert_after": "item_code", + } + ] + if frappe.db.exists("DocType", "Quotation"): + custom_fields["Quotation"] = [ + { + "fieldname": "crm_deal", + "fieldtype": "Data", + "label": "Frappe CRM Deal", + "read_only": 1, + "insert_after": "party_name", + } + ] + if frappe.db.exists("DocType", "Customer"): + custom_fields["Customer"] = [ + { + "fieldname": "crm_deal", + "fieldtype": "Data", + "label": "Frappe CRM Deal", + "read_only": 1, + "insert_after": "prospect_name", + } + ] + _create_custom_fields(custom_fields, ignore_validate=True) + + def create_custom_fields_in_remote_site(self): + client = get_erpnext_site_client(self) + try: + client.post_api("erpnext.crm.frappe_crm_api.create_custom_fields_for_frappe_crm") + except Exception: + frappe.log_error( + frappe.get_traceback(), + f"Could not create custom fields on remote ERPNext site: {self.erpnext_site_url}", + ) + frappe.msgprint( + _( + "Could not create the Frappe CRM custom fields on {0} automatically. " + "If it is running the latest ERPNext, enable Frappe CRM Data Synchronization " + "in its CRM Settings, Otherwise check the Error Log." + ).format(self.erpnext_site_url), + title=_("ERPNext custom fields not created"), + indicator="orange", + ) + + def grant_item_access_to_sales_roles(self): + if self.is_erpnext_in_different_site: + return + if not frappe.db.exists("DocType", "Item"): + return + from frappe.permissions import add_permission, update_permission_property + + for role in ("Sales User", "Sales Manager"): + if frappe.db.exists("Custom DocPerm", {"parent": "Item", "role": role, "permlevel": 0}): + continue + add_permission("Item", role, 0, "write") + for prop in ("create", "delete", "share", "print", "report", "export"): + update_permission_property("Item", role, 0, prop, 1) + + def create_crm_form_script(self): + if not frappe.db.exists("CRM Form Script", "Create Quotation from CRM Deal"): + script = get_crm_form_script() + frappe.get_doc( + { + "doctype": "CRM Form Script", + "name": "Create Quotation from CRM Deal", + "dt": "CRM Deal", + "view": "Form", + "script": script, + "enabled": 1, + "is_standard": 1, + } + ).insert() + + @frappe.whitelist() + def reset_erpnext_form_script(self): + try: + if frappe.db.exists("CRM Form Script", "Create Quotation from CRM Deal"): + script = get_crm_form_script() + frappe.db.set_value("CRM Form Script", "Create Quotation from CRM Deal", "script", script) + return True + return False + except Exception: + frappe.log_error(frappe.get_traceback(), "Error while resetting form script") + return False + + @frappe.whitelist() + def get_external_companies(self): + if not self.erpnext_site_url or not self.api_key or not self.api_secret: + return [] + client = get_erpnext_site_client(self) + return client.get_list("Company", fields=["company_name"]) + + @frappe.whitelist() + def is_erpnext_installed(self): + return _is_erpnext_installed() + + @frappe.whitelist() + def run_product_sync(self): + if not self.enabled or self.is_erpnext_in_different_site: + frappe.throw(_("ERPNext integration must be enabled on the same site")) + from crm.fcrm.doctype.crm_product.reconcile_job import enqueue_reconciliation + + enqueue_reconciliation() + return True + + +@frappe.whitelist() +def get_open_sync_issues(): + if not frappe.has_permission("CRM Product", "read"): + return [] + settings = frappe.get_single("ERPNext CRM Settings") + return [ + { + "name": issue.name, + "product": issue.product, + "kind": issue.kind, + "detail": issue.detail, + "detected_on": issue.detected_on, + } + for issue in settings.sync_issues + if not issue.dismissed + ] + + +@frappe.whitelist() +def dismiss_sync_issue(issue_name: str): + settings = frappe.get_single("ERPNext CRM Settings") + for issue in settings.sync_issues: + if issue.name == issue_name: + issue.dismissed = 1 + settings.save() + return True + return False + + +@frappe.whitelist() +def get_product_sync_status(): + if not frappe.has_permission("CRM Product", "read"): + return {} + return { + "products": _get_products(), + "items": _get_synced_items(), + "failed": get_open_sync_issues(), + "unsynced": _get_unsynced_counts(), + } + + +def _get_unsynced_counts(): + settings = frappe.get_single("ERPNext CRM Settings") + items = _count_linked("Item", "crm_product_code", is_set=False) if _can_read_items() else 0 + return { + "items": items, + "products": frappe.db.count("CRM Product", filters={"erpnext_item_code": ["is", "not set"]}), + "sync_products": bool(settings.sync_products), + } + + +def _get_products(): + rows = frappe.get_all( + "CRM Product", + fields=["name", "product_code", "product_name", "erpnext_item_code"], + limit=20, + order_by="modified desc", + ) + _set_product_item_groups(rows) + return { + "count": frappe.db.count("CRM Product"), + "rows": rows, + } + + +def _get_synced_items(): + if not _can_read_items(): + return {"count": 0, "rows": []} + return { + "count": _count_linked("Item", "crm_product_code"), + "rows": frappe.get_all( + "Item", + fields=["name", "item_code", "item_name", "item_group", "crm_product_code"], + filters={"crm_product_code": ["is", "set"]}, + limit=20, + order_by="modified desc", + ), + } + + +def _set_product_item_groups(rows): + item_codes = [row.erpnext_item_code for row in rows if row.erpnext_item_code] + if not item_codes or not _can_read_items(): + return + item_groups = frappe.get_all( + "Item", + fields=["name", "item_group"], + filters={"name": ["in", item_codes]}, + ) + item_groups = {row.name: row.item_group for row in item_groups} + for row in rows: + row["item_group"] = item_groups.get(row.erpnext_item_code) + + +def _count_linked(doctype: str, fieldname: str, is_set: bool = True): + if not frappe.db.has_column(doctype, fieldname): + return 0 + operator = "set" if is_set else "not set" + return frappe.db.count(doctype, filters={fieldname: ["is", operator]}) + + +def _can_read_items(): + return frappe.db.exists("DocType", "Item") and frappe.has_permission("Item", "read") + + +def get_erpnext_site_client(erpnext_crm_settings): + site_url = erpnext_crm_settings.erpnext_site_url + api_key = erpnext_crm_settings.api_key + api_secret = erpnext_crm_settings.get_password("api_secret", raise_exception=False) + + return FrappeClient(site_url, api_key=api_key, api_secret=api_secret) + + +def get_local_customer(crm_deal: str): + customer = frappe.db.exists("Customer", {"crm_deal": crm_deal}) + if not customer: + customer = frappe.db.get_value("CRM Deal", crm_deal, "erpnext_customer") + return customer + + +@frappe.whitelist() +def get_customer_link(crm_deal: str): + erpnext_crm_settings = _get_enabled_settings() + + if not erpnext_crm_settings.is_erpnext_in_different_site: + customer = get_local_customer(crm_deal) + return get_url_to_form("Customer", customer) if customer else "" + + client = get_erpnext_site_client(erpnext_crm_settings) + try: + customer = client.get_list("Customer", filters={"crm_deal": crm_deal}) + customer = customer[0].get("name") if customer else None + + if not customer: + customer = frappe.db.get_value("CRM Deal", crm_deal, "erpnext_customer") + + if customer: + return f"{erpnext_crm_settings.erpnext_site_url}/app/customer/{customer}" + return "" + except Exception: + _log_and_throw( + "Error while fetching customer in ERPNext, check error log for more details", + f"Error while fetching customer in remote site: {erpnext_crm_settings.erpnext_site_url}", + ) + + +@frappe.whitelist() +def get_quotation_url(crm_deal: str, organization: str | None = None): + erpnext_crm_settings = _get_enabled_settings() + + contact = get_primary_contact(crm_deal) + address = get_organization_address(organization) + address_name = address.get("name") if address else None + + if not erpnext_crm_settings.is_erpnext_in_different_site: + customer = get_local_customer(crm_deal) + base_url = f"{get_url_to_list('Quotation')}/new" + params = { + "quotation_to": "Customer" if customer else "CRM Deal", + "crm_deal": crm_deal, + "party_name": customer or crm_deal, + "company": erpnext_crm_settings.erpnext_company, + "contact_person": contact, + "customer_address": address_name, + } + else: + site_url = erpnext_crm_settings.get("erpnext_site_url") + base_url = f"{site_url}/app/quotation/new" + prospect = create_prospect_in_remote_site(crm_deal, erpnext_crm_settings) + params = { + "quotation_to": "Prospect", + "crm_deal": crm_deal, + "party_name": prospect, + "company": erpnext_crm_settings.erpnext_company, + "contact_person": contact, + "customer_address": address_name, + } + + # Filter out None values and build query string + query_string = "&".join(f"{key}={value}" for key, value in params.items() if value is not None) + + return f"{base_url}?{query_string}" + + +def create_prospect_in_remote_site(crm_deal, erpnext_crm_settings): + try: + client = get_erpnext_site_client(erpnext_crm_settings) + doc = frappe.get_cached_doc("CRM Deal", crm_deal) + contacts = get_contacts(doc) + address = get_organization_address(doc.organization) or None + + if address and not isinstance(address, dict): + address = address.as_dict() + + return client.post_api( + "erpnext.crm.frappe_crm_api.create_prospect_against_crm_deal", + { + "organization": doc.organization, + "lead_name": doc.lead_name, + "no_of_employees": doc.no_of_employees, + "deal_owner": doc.deal_owner, + "crm_deal": doc.name, + "territory": doc.territory, + "industry": doc.industry, + "website": doc.website, + "annual_revenue": doc.annual_revenue, + "contacts": json.dumps(contacts) if contacts else None, + "erpnext_company": erpnext_crm_settings.erpnext_company, + "address": json.dumps(address) if address else None, + }, + ) + except Exception: + _log_and_throw( + "Error while creating prospect in ERPNext, check error log for more details", + f"Error while creating prospect in remote site: {erpnext_crm_settings.erpnext_site_url}", + ) + + +@frappe.whitelist() +def prefill_quotation_items(crm_deal: str): + if not frappe.db.exists("CRM Deal", crm_deal): + return [] + deal = frappe.get_doc("CRM Deal", crm_deal) + items = [] + for row in deal.products: + item_code = frappe.db.get_value("CRM Product", row.product_code, "erpnext_item_code") + if not item_code: + continue + items.append( + { + "item_code": item_code, + "qty": row.qty or 1, + "price_list_rate": row.rate or 0, + "discount_percentage": row.discount_percentage or 0, + } + ) + return items + + +def get_primary_contact(crm_deal): + doc = frappe.get_cached_doc("CRM Deal", crm_deal) + for c in doc.contacts: + if c.is_primary: + return c.contact + return None + + +def get_contacts(doc): + return [ + { + "contact": c.contact, + "full_name": c.full_name, + "email": c.email, + "mobile_no": c.mobile_no, + "gender": c.gender, + "is_primary": c.is_primary, + } + for c in doc.contacts + ] + + +def get_organization_address(organization: str | None = None): + if not organization: + return None + address = frappe.db.get_value("CRM Organization", organization, "address") + address = frappe.get_cached_doc("Address", address) if address else None + if not address: + return None + return { + "name": address.name, + "address_title": address.address_title, + "address_type": address.address_type, + "address_line1": address.address_line1, + "address_line2": address.address_line2, + "city": address.city, + "county": address.county, + "state": address.state, + "country": address.country, + "pincode": address.pincode, + } + + +def create_customer_in_erpnext(doc, method): + erpnext_crm_settings = frappe.get_single("ERPNext CRM Settings") + if ( + not erpnext_crm_settings.enabled + or not erpnext_crm_settings.create_customer_on_status_change + or doc.status != erpnext_crm_settings.deal_status + ): + return + + create_customer_from_deal(doc, erpnext_crm_settings) + + +def create_customer_on_sales_order(doc, method): + if doc.customer: + return + + crm_deal = get_deal_from_sales_order(doc) + customer = check_customer_for_deal(crm_deal) if crm_deal else None + if customer: + doc.customer = customer + + +def check_customer_for_deal(crm_deal: str): + """Return the ERPNext Customer for the deal and create it if it doesn't exist""" + erpnext_crm_settings = frappe.get_single("ERPNext CRM Settings") + if not erpnext_crm_settings.enabled or erpnext_crm_settings.is_erpnext_in_different_site: + return None + if not crm_deal or not frappe.db.exists("CRM Deal", crm_deal): + return None + + customer = get_local_customer(crm_deal) + if not customer: + customer = create_customer_from_deal( + frappe.get_cached_doc("CRM Deal", crm_deal), erpnext_crm_settings + ) + return customer + + +@frappe.whitelist() +def check_customer_for_quotation(quotation: str): + """Create/fetch the Customer for the CRM Deal behind a quotation. Called when a + Sales Order form is opened from a CRM Deal quotation that has no customer yet. + """ + crm_deal = frappe.db.get_value("Quotation", quotation, "crm_deal") + if not crm_deal: + return None + return check_customer_for_deal(crm_deal) + + +def get_deal_from_sales_order(doc): + for item in doc.items: + quotation = item.get("prevdoc_docname") + if quotation: + crm_deal = frappe.db.get_value("Quotation", quotation, "crm_deal") + if crm_deal: + return crm_deal + return None + + +def create_customer_from_deal(doc, erpnext_crm_settings): + contacts = get_contacts(doc) + address = get_organization_address(doc.organization) + + if doc.organization: + customer_title = doc.organization + customer_type = "Company" + else: + primary_contact = next((c for c in contacts if c.get("is_primary")), None) + customer_title = (primary_contact or {}).get("full_name") or doc.lead_name + if not customer_title: + frappe.throw(_("Organization or a primary Contact is required to create a customer")) + customer_type = "Individual" + + customer_data = { + "customer_name": customer_title, + "customer_type": customer_type, + "territory": doc.territory, + "default_currency": doc.currency, + "industry": doc.industry, + "website": doc.website, + "crm_deal": doc.name, + "contacts": json.dumps(contacts), + "address": json.dumps(address) if address else None, + } + + try: + if not erpnext_crm_settings.is_erpnext_in_different_site: + try: + from erpnext.crm.frappe_crm_api import create_customer + except ImportError: + frappe.throw(_("ERPNext is not installed in the current site")) + + if doc.territory and not frappe.db.exists("Territory", doc.territory): + customer_data["territory"] = "" + + if doc.industry and not frappe.db.exists("Industry Type", doc.industry): + customer_data["industry"] = "" + + customer_name = create_customer(customer_data) + else: + client = get_erpnext_site_client(erpnext_crm_settings) + + if doc.territory and not client.get_list("Territory", filters={"name": doc.territory}): + customer_data["territory"] = "" + + if doc.industry and not client.get_list("Industry Type", filters={"name": doc.industry}): + customer_data["industry"] = "" + + customer_name = client.post_api("erpnext.crm.frappe_crm_api.create_customer", customer_data) + + if not customer_name: + _log_and_throw( + "Error while creating customer in ERPNext, check error log for more details", + f"Error while creating customer in ERPNext for CRM Deal: {doc.name}", + ) + except (frappe.ValidationError, frappe.PermissionError): + raise + except Exception: + _log_and_throw("Error while creating customer in ERPNext, check error log for more details") + + if customer_name: + frappe.db.set_value("CRM Deal", doc.name, "erpnext_customer", customer_name) + frappe.publish_realtime("crm_customer_created") + + return customer_name + + +@frappe.whitelist() +def get_crm_form_script(): + return """class CRMDeal { + onLoad() { + if (this.doc.__newDocument) return + call( + "frappe.client.get_single_value", + { + doctype: "ERPNext CRM Settings", + field: "enabled" + } + ).then((enabled) => { + if (enabled) this.doc.trigger('setActions') + }) + } + setActions() { + // Add Create Quotation Button + this.actions.push({ + label: __("Create Quotation"), + onClick: () => { + call( + "crm.fcrm.doctype.erpnext_crm_settings.erpnext_crm_settings.get_quotation_url", + { + crm_deal: this.doc.name, + organization: this.doc.organization + } + ).then((quotation_url) => { + if (quotation_url) { + window.open(quotation_url, '_blank'); + } else { + toast.error("Error while creating quotation in ERPNext"); + } + }).catch((e) => { + toast.error(e.messages[0] || "Error while creating quotation in ERPNext. Check error log in ERPNext for more details"); + }); + } + }) + + // Add View Customer Button + call("crm.fcrm.doctype.erpnext_crm_settings.erpnext_crm_settings.get_customer_link", { + crm_deal: this.doc.name + }).then((customer_url) => { + if (customer_url) { + this.actions.push({ + label: __("View Customer"), + onClick: () => window.open(customer_url, '_blank') + }); + } + }).catch((e) => { + toast.error(e.messages[0] || "Error while fetching customer link from ERPNext. Check error log in ERPNext for more details"); + }); + } +} +""" diff --git a/crm/fcrm/doctype/erpnext_crm_settings/test_erpnext_crm_settings.py b/crm/fcrm/doctype/erpnext_crm_settings/test_erpnext_crm_settings.py new file mode 100644 index 0000000..937b9ef --- /dev/null +++ b/crm/fcrm/doctype/erpnext_crm_settings/test_erpnext_crm_settings.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import UnitTestCase + + +class TestERPNextCRMSettings(UnitTestCase): + pass diff --git a/crm/fcrm/doctype/fcrm_note/__init__.py b/crm/fcrm/doctype/fcrm_note/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/fcrm_note/fcrm_note.js b/crm/fcrm/doctype/fcrm_note/fcrm_note.js new file mode 100644 index 0000000..843e8f7 --- /dev/null +++ b/crm/fcrm/doctype/fcrm_note/fcrm_note.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("FCRM Note", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/fcrm_note/fcrm_note.json b/crm/fcrm/doctype/fcrm_note/fcrm_note.json new file mode 100644 index 0000000..64e7189 --- /dev/null +++ b/crm/fcrm/doctype/fcrm_note/fcrm_note.json @@ -0,0 +1,102 @@ +{ + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "creation": "2023-08-26 12:04:00.759437", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "title", + "content", + "reference_doctype", + "reference_docname" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Title", + "reqd": 1 + }, + { + "fieldname": "content", + "fieldtype": "Text Editor", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Content" + }, + { + "default": "CRM Lead", + "fieldname": "reference_doctype", + "fieldtype": "Link", + "label": "Reference Document Type", + "options": "DocType" + }, + { + "fieldname": "reference_docname", + "fieldtype": "Dynamic Link", + "label": "Reference Doc", + "options": "reference_doctype" + } + ], + "index_web_pages_for_search": 1, + "links": [ + { + "link_doctype": "CRM Call Log", + "link_fieldname": "note" + } + ], + "modified": "2026-02-23 12:22:56.394635", + "modified_by": "Administrator", + "module": "FCRM", + "name": "FCRM Note", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "title", + "track_changes": 1 +} diff --git a/crm/fcrm/doctype/fcrm_note/fcrm_note.py b/crm/fcrm/doctype/fcrm_note/fcrm_note.py new file mode 100644 index 0000000..ff88bbd --- /dev/null +++ b/crm/fcrm/doctype/fcrm_note/fcrm_note.py @@ -0,0 +1,34 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class FCRMNote(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + content: DF.TextEditor | None + reference_docname: DF.DynamicLink | None + reference_doctype: DF.Link | None + title: DF.Data + # end: auto-generated types + + @staticmethod + def default_list_data(): + rows = [ + "name", + "title", + "content", + "reference_doctype", + "reference_docname", + "owner", + "modified", + ] + return {"columns": [], "rows": rows} diff --git a/crm/fcrm/doctype/fcrm_note/test_fcrm_note.py b/crm/fcrm/doctype/fcrm_note/test_fcrm_note.py new file mode 100644 index 0000000..6a98a6f --- /dev/null +++ b/crm/fcrm/doctype/fcrm_note/test_fcrm_note.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import UnitTestCase + + +class TestFCRMNote(UnitTestCase): + pass diff --git a/crm/fcrm/doctype/fcrm_settings/__init__.py b/crm/fcrm/doctype/fcrm_settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/fcrm/doctype/fcrm_settings/fcrm_settings.js b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.js new file mode 100644 index 0000000..d604355 --- /dev/null +++ b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.js @@ -0,0 +1,72 @@ +// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("FCRM Settings", { + refresh(frm) { + if ( + (frappe.user.has_role("Sales Manager") || + frappe.user.has_role("System Manager")) && + frappe.boot.user.defaults.crm_demo_data_created !== "1" + ) { + frm.set_df_property("restore_demo_data", "hidden", false); + } else { + frm.set_df_property("restore_demo_data", "hidden", true); + } + }, + restore_defaults: function (frm) { + let message = __( + "This will restore (if not exist) all the default statuses, custom fields and layouts. Delete & Restore will delete default layouts and then restore them." + ); + let d = new frappe.ui.Dialog({ + title: __("Restore Defaults"), + primary_action_label: __("Restore"), + primary_action: () => { + frm.call("restore_defaults", { force: false }, () => { + frappe.show_alert({ + message: __( + "Default statuses, custom fields and layouts restored successfully." + ), + indicator: "green" + }); + }); + d.hide(); + }, + secondary_action_label: __("Delete & Restore"), + secondary_action: () => { + frm.call("restore_defaults", { force: true }, () => { + frappe.show_alert({ + message: __( + "Default statuses, custom fields and layouts restored successfully." + ), + indicator: "green" + }); + }); + d.hide(); + } + }); + d.show(); + d.set_message(message); + }, + + restore_demo_data: function (frm) { + let d = new frappe.ui.Dialog({ + title: __("Restore Demo Data"), + primary_action_label: __("Restore"), + primary_action: () => { + frm.call("restore_demo_data", { force: false }, () => { + frappe.show_alert({ + message: __("Demo data restored successfully."), + indicator: "green" + }); + }); + d.hide(); + } + }); + d.show(); + d.set_message( + __( + "This will restore the demo data. Are you sure you want to continue?" + ) + ); + } +}); diff --git a/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json new file mode 100644 index 0000000..6940181 --- /dev/null +++ b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json @@ -0,0 +1,265 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-09-29 13:48:02.715924", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "defaults_tab", + "restore_defaults", + "restore_demo_data", + "enable_forecasting", + "enable_sales_hierarchy", + "auto_update_expected_deal_value", + "update_timestamp_on_new_communication", + "auto_mark_replied_on_response", + "auto_reopen_on_new_communication", + "activity_timeline_section", + "crm_timeline_timestamp_format", + "crm_timeline_sort_order", + "currency_tab", + "currency", + "exchange_rate_provider_section", + "service_provider", + "column_break_vqck", + "access_key", + "branding_tab", + "brand_name", + "brand_logo", + "favicon", + "calendar_tab", + "default_reminder_section", + "default_calendar_view", + "event_notifications", + "all_day_event_notifications", + "dropdown_items_tab", + "dropdown_items", + "persona_captured" + ], + "fields": [ + { + "fieldname": "restore_defaults", + "fieldtype": "Button", + "label": "Restore Defaults" + }, + { + "fieldname": "dropdown_items", + "fieldtype": "Table", + "options": "CRM Dropdown Item" + }, + { + "fieldname": "defaults_tab", + "fieldtype": "Tab Break", + "label": "Settings" + }, + { + "fieldname": "branding_tab", + "fieldtype": "Tab Break", + "label": "Branding" + }, + { + "description": "An image with 1:1 & 2:1 ratio is preferred", + "fieldname": "brand_logo", + "fieldtype": "Attach", + "label": "Logo" + }, + { + "fieldname": "dropdown_items_tab", + "fieldtype": "Tab Break", + "label": "Dropdown items" + }, + { + "fieldname": "brand_name", + "fieldtype": "Data", + "label": "Name" + }, + { + "description": "An icon file with .ico extension. Should be 16 x 16 px. Generated using a favicon generator. [favicon-generator.org]", + "fieldname": "favicon", + "fieldtype": "Attach", + "label": "Favicon" + }, + { + "default": "0", + "description": "It will make deal's \"Expected Closure Date\" & \"Expected Deal Value\" mandatory to get accurate forecasting insights", + "fieldname": "enable_forecasting", + "fieldtype": "Check", + "label": "Enable Forecasting" + }, + { + "default": "0", + "description": "Restrict Lead and Deal visibility based on the Sales Hierarchy tree. Managers can see all records owned by or assigned to their reports.", + "fieldname": "enable_sales_hierarchy", + "fieldtype": "Check", + "label": "Enable Sales Hierarchy Permissions" + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Currency", + "options": "Currency" + }, + { + "fieldname": "currency_tab", + "fieldtype": "Tab Break", + "label": "Currency" + }, + { + "fieldname": "exchange_rate_provider_section", + "fieldtype": "Section Break", + "label": "Exchange Rate Provider" + }, + { + "default": "frankfurter.app", + "fieldname": "service_provider", + "fieldtype": "Select", + "label": "Service Provider", + "options": "frankfurter.app\nfawazahmed-exchange-api\nexchangerate.host\nexchangerate-api" + }, + { + "depends_on": "eval:doc.service_provider == 'exchangerate.host';", + "fieldname": "access_key", + "fieldtype": "Data", + "label": "Access Key", + "mandatory_depends_on": "eval:doc.service_provider == 'exchangerate.host';" + }, + { + "fieldname": "column_break_vqck", + "fieldtype": "Column Break" + }, + { + "default": "1", + "description": "Automatically update \"Expected Deal Value\" based on the total value of associated products in a deal", + "fieldname": "auto_update_expected_deal_value", + "fieldtype": "Check", + "label": "Auto update Expected Deal Value" + }, + { + "fieldname": "calendar_tab", + "fieldtype": "Tab Break", + "label": "Calendar" + }, + { + "description": "Time before the event when a reminder notification will be sent by default. This will be applied to all events unless a custom notification is set for a specific event.", + "fieldname": "default_reminder_section", + "fieldtype": "Section Break", + "label": "Default Event Notifications" + }, + { + "fieldname": "event_notifications", + "fieldtype": "Table", + "label": "Event Notifications", + "options": "Event Notifications" + }, + { + "fieldname": "all_day_event_notifications", + "fieldtype": "Table", + "label": "All Day Event Notifications", + "options": "Event Notifications" + }, + { + "default": "Weekly", + "fieldname": "default_calendar_view", + "fieldtype": "Select", + "label": "Default Calendar View", + "options": "Daily\nWeekly\nMonthly" + }, + { + "default": "1", + "description": "Update the modified timestamp on new email communication & comment for leads & deals", + "fieldname": "update_timestamp_on_new_communication", + "fieldtype": "Check", + "label": "Update timestamp on new communication" + }, + { + "default": "0", + "description": "Automatically sets Communication Status to \u201cReplied\u201d for the lead or deal when a response is received. Applies only when SLA is enabled", + "fieldname": "auto_mark_replied_on_response", + "fieldtype": "Check", + "label": "Mark lead/deal as replied on response" + }, + { + "default": "0", + "description": "Automatically sets Communication Status to \u201cOpen\u201d for the lead or deal when a new communication is created. Applies only when SLA is enabled", + "fieldname": "auto_reopen_on_new_communication", + "fieldtype": "Check", + "label": "Reopen lead/deal on new communication" + }, + { + "fieldname": "restore_demo_data", + "fieldtype": "Button", + "label": "Restore Demo Data" + }, + { + "fieldname": "activity_timeline_section", + "fieldtype": "Section Break", + "label": "Activity Timeline" + }, + { + "default": "Relative", + "description": "How timestamps are shown in the activity timeline for all users", + "fieldname": "crm_timeline_timestamp_format", + "fieldtype": "Select", + "label": "Timeline Timestamp Format", + "options": "Relative\nExact" + }, + { + "default": "Oldest First", + "description": "Order of activities, emails, comments and calls in the timeline for all users", + "fieldname": "crm_timeline_sort_order", + "fieldtype": "Select", + "label": "Timeline Sort Order", + "options": "Oldest First\nNewest First" + }, + { + "default": "0", + "description": "Set once the first admin completes the onboarding persona questionnaire", + "fieldname": "persona_captured", + "fieldtype": "Check", + "hidden": 1, + "label": "Persona Captured" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2026-03-27 18:02:14.802197", + "modified_by": "Administrator", + "module": "FCRM", + "name": "FCRM Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "print": 1, + "read": 1, + "role": "Sales User", + "share": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/fcrm/doctype/fcrm_settings/fcrm_settings.py b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.py new file mode 100644 index 0000000..0c8bb0e --- /dev/null +++ b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.py @@ -0,0 +1,247 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import json + +import frappe +from frappe import _ +from frappe.custom.doctype.property_setter.property_setter import delete_property_setter, make_property_setter +from frappe.model.document import Document + +from crm.demo.api import create_demo_data +from crm.install import after_install + + +class FCRMSettings(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.desk.doctype.event_notifications.event_notifications import EventNotifications + from frappe.types import DF + + from crm.fcrm.doctype.crm_dropdown_item.crm_dropdown_item import CRMDropdownItem + + access_key: DF.Data | None + all_day_event_notifications: DF.Table[EventNotifications] + auto_mark_replied_on_response: DF.Check + auto_reopen_on_new_communication: DF.Check + auto_update_expected_deal_value: DF.Check + brand_logo: DF.Attach | None + brand_name: DF.Data | None + currency: DF.Link | None + default_calendar_view: DF.Literal["Daily", "Weekly", "Monthly"] + dropdown_items: DF.Table[CRMDropdownItem] + enable_forecasting: DF.Check + event_notifications: DF.Table[EventNotifications] + favicon: DF.Attach | None + service_provider: DF.Literal[ + "frankfurter.app", "fawazahmed-exchange-api", "exchangerate.host", "exchangerate-api" + ] + update_timestamp_on_new_communication: DF.Check + # end: auto-generated types + + @frappe.whitelist() + def restore_defaults(self, force: bool = False): + after_install(force) + + @frappe.whitelist() + def restore_demo_data(self): + create_demo_data() + + def validate(self): + self.do_not_allow_to_delete_if_standard() + self.setup_forecasting() + self.make_currency_read_only() + + def do_not_allow_to_delete_if_standard(self): + if not self.has_value_changed("dropdown_items"): + return + old_items = self.get_doc_before_save().get("dropdown_items") + standard_new_items = [d.name1 for d in self.dropdown_items if d.is_standard] + standard_old_items = [d.name1 for d in old_items if d.is_standard] + deleted_standard_items = set(standard_old_items) - set(standard_new_items) + if deleted_standard_items: + standard_dropdown_items = get_standard_dropdown_items() + if not deleted_standard_items.intersection(standard_dropdown_items): + return + frappe.throw(_("Cannot delete standard items {0}").format(", ".join(deleted_standard_items))) + + def setup_forecasting(self): + if self.has_value_changed("enable_forecasting"): + if not self.enable_forecasting: + self.remove_forecasting_section("Side Panel") + self.remove_forecasting_section("Quick Entry") + delete_property_setter( + "CRM Deal", + "reqd", + "expected_closure_date", + ) + delete_property_setter( + "CRM Deal", + "reqd", + "expected_deal_value", + ) + else: + self.add_forecasting_section("Side Panel") + self.add_forecasting_section("Quick Entry") + make_property_setter( + "CRM Deal", + "expected_closure_date", + "reqd", + 1 if self.enable_forecasting else 0, + "Check", + ) + make_property_setter( + "CRM Deal", + "expected_deal_value", + "reqd", + 1 if self.enable_forecasting else 0, + "Check", + ) + + def make_currency_read_only(self): + if self.currency and self.has_value_changed("currency"): + make_property_setter( + "FCRM Settings", + "currency", + "read_only", + 1, + "Check", + ) + + def add_forecasting_section(self, layout_type): + layout_name = f"CRM Deal-{layout_type}" + if not frappe.db.exists("CRM Fields Layout", layout_name): + return + doc = frappe.get_doc("CRM Fields Layout", layout_name) + layout = json.loads(doc.layout) if doc.layout else [] + # layout is either a plain list of sections or a list of tabs with sections + has_tabs = any("sections" in item for item in layout) + sections = layout[-1].setdefault("sections", []) if has_tabs else layout + all_sections = ( + [section for tab in layout for section in tab.get("sections", [])] if has_tabs else layout + ) + if any(section.get("name") == "forecasted_sales_section" for section in all_sections): + return + # FIX: fields inside columns are dicts; extract "fieldname" string for set + # comparison instead of using dicts as set elements (unhashable on Python 3.14) + existing_fields = { + (field.get("fieldname") or field.get("name")) if isinstance(field, dict) else field + for section in all_sections + for column in section.get("columns") or [] + for field in column.get("fields") or [] + } + fields = [ + field + for field in ("expected_deal_value", "expected_closure_date", "probability") + if field not in existing_fields + ] + if not fields: + return + + if layout_type == "Side Panel": + new_section = { + "name": "forecasted_sales_section", + "label": "Forecasted Sales", + "opened": True, + "columns": [{"name": "forecasted_sales_column", "fields": fields}], + } + # Insert after contacts_section if it's the first section, else insert at the beginning + if sections and sections[0].get("name") == "contacts_section": + sections.insert(1, new_section) + else: + sections.insert(0, new_section) + else: + # one column per field so they render in a single row + new_section = { + "name": "forecasted_sales_section", + "columns": [{"name": field + "_column", "fields": [field]} for field in fields], + } + sections.append(new_section) + + doc.layout = json.dumps(layout) + doc.save(ignore_permissions=True) + + def remove_forecasting_section(self, layout_type): + layout_name = f"CRM Deal-{layout_type}" + if not frappe.db.exists("CRM Fields Layout", layout_name): + return + doc = frappe.get_doc("CRM Fields Layout", layout_name) + layout = json.loads(doc.layout) if doc.layout else [] + has_tabs = any("sections" in item for item in layout) + if has_tabs: + for tab in layout: + tab["sections"] = [ + section + for section in tab.get("sections", []) + if section.get("name") != "forecasted_sales_section" + ] + else: + layout = [section for section in layout if section.get("name") != "forecasted_sales_section"] + doc.layout = json.dumps(layout) + doc.save(ignore_permissions=True) + + +def get_standard_dropdown_items(): + return [item.get("name1") for item in frappe.get_hooks("standard_dropdown_items")] + + +def after_migrate(): + sync_table("dropdown_items", "standard_dropdown_items") + + +def sync_table(key, hook): + crm_settings = FCRMSettings("FCRM Settings") + existing_items = {d.name1: d for d in crm_settings.get(key)} + new_standard_items = {} + + # add new items + count = 0 # maintain count because list may come from separate apps + for item in frappe.get_hooks(hook): + if item.get("name1") not in existing_items: + crm_settings.append(key, item, count) + new_standard_items[item.get("name1")] = True + count += 1 + + # remove unused items + items = crm_settings.get(key) + items = [item for item in items if not (item.is_standard and (item.name1 not in new_standard_items))] + crm_settings.set(key, items) + + crm_settings.save() + + +def create_forecasting_script(): + if not frappe.db.exists("CRM Form Script", "Forecasting Script"): + script = get_forecasting_script() + frappe.get_doc( + { + "doctype": "CRM Form Script", + "name": "Forecasting Script", + "dt": "CRM Deal", + "view": "Form", + "script": script, + "enabled": 1, + "is_standard": 1, + } + ).insert() + + +def get_forecasting_script(): + return """class CRMDeal { + async status() { + await this.doc.trigger('updateProbability') + } + async updateProbability() { + let status = await call("frappe.client.get_value", { + doctype: "CRM Deal Status", + fieldname: "probability", + filters: { name: this.doc.status }, + }) + + this.doc.probability = status.probability + } +}""" diff --git a/crm/fcrm/doctype/fcrm_settings/test_fcrm_settings.py b/crm/fcrm/doctype/fcrm_settings/test_fcrm_settings.py new file mode 100644 index 0000000..97832a0 --- /dev/null +++ b/crm/fcrm/doctype/fcrm_settings/test_fcrm_settings.py @@ -0,0 +1,109 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import json + +import frappe +from frappe.tests import IntegrationTestCase + +FORECASTING_FIELDS = ["expected_closure_date", "probability", "expected_deal_value"] + + +class TestFCRMSettings(IntegrationTestCase): + def tearDown(self) -> None: + frappe.db.rollback() + + def get_quick_entry_sections(self): + layout = json.loads(frappe.db.get_value("CRM Fields Layout", "CRM Deal-Quick Entry", "layout")) + if any("sections" in item for item in layout): + return [section for tab in layout for section in tab.get("sections", [])] + return layout + + def get_quick_entry_fields(self): + return [ + field + for section in self.get_quick_entry_sections() + for column in section.get("columns") or [] + for field in column.get("fields") or [] + ] + + def set_forecasting(self, value): + settings = frappe.get_single("FCRM Settings") + settings.enable_forecasting = value + settings.save() + + def test_forecasting_adds_section_to_quick_entry(self): + """Enabling forecasting should append forecasting fields to the deal quick entry layout""" + self.set_forecasting(0) + self.assertFalse(set(FORECASTING_FIELDS) & set(self.get_quick_entry_fields())) + + self.set_forecasting(1) + sections = self.get_quick_entry_sections() + self.assertTrue(any(section.get("name") == "forecasted_sales_section" for section in sections)) + fields = self.get_quick_entry_fields() + for field in FORECASTING_FIELDS: + self.assertIn(field, fields) + + def test_disabling_forecasting_removes_section_from_quick_entry(self): + self.set_forecasting(1) + self.set_forecasting(0) + sections = self.get_quick_entry_sections() + self.assertFalse(any(section.get("name") == "forecasted_sales_section" for section in sections)) + + def test_forecasting_skips_fields_already_in_quick_entry(self): + """Fields already placed in the layout by a manager should not be duplicated""" + self.set_forecasting(0) + + doc = frappe.get_doc("CRM Fields Layout", "CRM Deal-Quick Entry") + layout = json.loads(doc.layout) + custom_section = { + "name": "custom_section", + "columns": [{"name": "custom_column", "fields": ["expected_deal_value"]}], + } + if any("sections" in item for item in layout): + layout[-1]["sections"].append(custom_section) + else: + layout.append(custom_section) + doc.layout = json.dumps(layout) + doc.save(ignore_permissions=True) + + self.set_forecasting(1) + + fields = self.get_quick_entry_fields() + self.assertEqual(fields.count("expected_deal_value"), 1) + self.assertIn("expected_closure_date", fields) + self.assertIn("probability", fields) + + def test_forecasting_adds_section_to_sidepanel(self): + """Side panel should get a labeled Forecasted Sales section after the contacts section""" + self.set_forecasting(0) + self.set_forecasting(1) + + sections = json.loads(frappe.db.get_value("CRM Fields Layout", "CRM Deal-Side Panel", "layout")) + section = next((s for s in sections if s.get("name") == "forecasted_sales_section"), None) + self.assertIsNotNone(section) + self.assertEqual(section.get("label"), "Forecasted Sales") + if sections[0].get("name") == "contacts_section": + self.assertEqual(sections[1].get("name"), "forecasted_sales_section") + else: + self.assertEqual(sections[0].get("name"), "forecasted_sales_section") + + self.set_forecasting(0) + sections = json.loads(frappe.db.get_value("CRM Fields Layout", "CRM Deal-Side Panel", "layout")) + self.assertFalse(any(s.get("name") == "forecasted_sales_section" for s in sections)) + + def test_forecasting_adds_section_to_quick_entry_with_tabs_layout(self): + """Layouts saved from the layout editor are wrapped in tabs and should also work""" + self.set_forecasting(0) + + doc = frappe.get_doc("CRM Fields Layout", "CRM Deal-Quick Entry") + layout = json.loads(doc.layout) + if not any("sections" in item for item in layout): + doc.layout = json.dumps([{"name": "first_tab", "sections": layout}]) + doc.save(ignore_permissions=True) + + self.set_forecasting(1) + + fields = self.get_quick_entry_fields() + for field in FORECASTING_FIELDS: + self.assertIn(field, fields) diff --git a/crm/fcrm/doctype/utils.py b/crm/fcrm/doctype/utils.py new file mode 100644 index 0000000..bf822ab --- /dev/null +++ b/crm/fcrm/doctype/utils.py @@ -0,0 +1,45 @@ +import json + +import frappe + + +def add_or_remove_lost_reason_section_in_sidepanel(doc): + doctype = doc.doctype + if doctype not in ("CRM Deal", "CRM Lead"): + return + + status_doctype = "CRM Deal Status" if doctype == "CRM Deal" else "CRM Lead Status" + + status = None + if getattr(doc, "status", None): + status = frappe.db.get_value(status_doctype, doc.status, "type") + is_lost = status and status == "Lost" + + layout_doc = frappe.get_doc("CRM Fields Layout", f"{doctype}-Side Panel") + sections = json.loads(layout_doc.layout) + + lost_reason_section = { + "name": "lost_reason_section", + "label": "Lost Reason", + "opened": True, + "columns": [ + { + "name": "lost_reason_column", + "fields": ["lost_reason", "lost_notes"], + } + ], + } + + section_exists = any(section.get("name") == "lost_reason_section" for section in sections) + + if is_lost and not section_exists: + if sections and sections[0].get("name") == "contacts_section": + sections = [*sections[:1], lost_reason_section, *sections[1:]] + else: + sections = [lost_reason_section, *sections] + layout_doc.layout = json.dumps(sections) + layout_doc.save(ignore_permissions=True) + elif not is_lost and section_exists: + sections = [section for section in sections if section.get("name") != "lost_reason_section"] + layout_doc.layout = json.dumps(sections) + layout_doc.save(ignore_permissions=True) diff --git a/crm/fcrm/workspace/frappe_crm/frappe_crm.json b/crm/fcrm/workspace/frappe_crm/frappe_crm.json new file mode 100644 index 0000000..543e49a --- /dev/null +++ b/crm/fcrm/workspace/frappe_crm/frappe_crm.json @@ -0,0 +1,133 @@ +{ + "charts": [], + "content": "[{\"id\":\"1nr6UkvDiL\",\"type\":\"header\",\"data\":{\"text\":\"PORTAL\",\"col\":12}},{\"id\":\"1hyi8SysUY\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"CRM Portal Page\",\"col\":3}},{\"id\":\"ktENiGaqXQ\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"VgeWLYOuAS\",\"type\":\"paragraph\",\"data\":{\"text\":\"SHORTCUTS\",\"col\":12}},{\"id\":\"A66FpG-K3T\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Leads\",\"col\":3}},{\"id\":\"n9b6N5ebOj\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Deals\",\"col\":3}},{\"id\":\"sGHTXrludH\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Organizations\",\"col\":3}},{\"id\":\"uXZNCdqxy0\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Contacts\",\"col\":3}},{\"id\":\"v1kkMwlntf\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"SLA\",\"col\":3}},{\"id\":\"WRzt4SMh_b\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Assignment Rule\",\"col\":3}},{\"id\":\"TZ7cULX3Tk\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"zpySv0nGVQ\",\"type\":\"paragraph\",\"data\":{\"text\":\"META\",\"col\":12}},{\"id\":\"fa-uKzobpp\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Lead Statuses\",\"col\":3}},{\"id\":\"hxoZghUHP2\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Deal Statuses\",\"col\":3}},{\"id\":\"HbgghUpc8N\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Lead Sources\",\"col\":3}},{\"id\":\"8cPs7Fohb4\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Industries\",\"col\":3}},{\"id\":\"arT4xZ9HWR\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Territories\",\"col\":3}},{\"id\":\"ApHOcISpiJ\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Communication Statuses\",\"col\":3}}]", + "creation": "2023-11-27 13:55:17.090361", + "custom_blocks": [], + "docstatus": 0, + "doctype": "Workspace", + "for_user": "", + "hide_custom": 0, + "icon": "filter", + "idx": 0, + "indicator_color": "", + "is_hidden": 0, + "label": "Frappe CRM", + "links": [], + "modified": "2024-01-04 19:08:27.799960", + "modified_by": "Administrator", + "module": "FCRM", + "name": "Frappe CRM", + "number_cards": [], + "owner": "shariq@frappe.io", + "parent_page": "", + "public": 1, + "quick_lists": [], + "roles": [], + "sequence_id": 1.0, + "shortcuts": [ + { + "color": "Grey", + "doc_view": "List", + "label": "Communication Statuses", + "link_to": "CRM Communication Status", + "stats_filter": "[]", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "List", + "label": "Leads", + "link_to": "CRM Lead", + "stats_filter": "[[\"CRM Lead\",\"converted\",\"=\",0,false]]", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "List", + "label": "CRM Portal Page", + "type": "URL", + "url": "/crm" + }, + { + "color": "Grey", + "doc_view": "List", + "label": "SLA", + "link_to": "CRM Service Level Agreement", + "stats_filter": "[]", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "List", + "label": "Lead Statuses", + "link_to": "CRM Lead Status", + "stats_filter": "[]", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "Tree", + "label": "Territories", + "link_to": "CRM Territory", + "stats_filter": "[]", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "List", + "label": "Assignment Rule", + "link_to": "Assignment Rule", + "stats_filter": "[]", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "List", + "label": "Deals", + "link_to": "CRM Deal", + "stats_filter": "[]", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "List", + "label": "Deal Statuses", + "link_to": "CRM Deal Status", + "stats_filter": "[]", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "List", + "label": "Lead Sources", + "link_to": "CRM Lead Source", + "stats_filter": "[]", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "List", + "label": "Organizations", + "link_to": "CRM Organization", + "stats_filter": "[]", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "List", + "label": "Contacts", + "link_to": "Contact", + "stats_filter": "[]", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "List", + "label": "Industries", + "link_to": "CRM Industry", + "stats_filter": "[]", + "type": "DocType" + } + ], + "title": "Frappe CRM" +} \ No newline at end of file diff --git a/crm/hooks.py b/crm/hooks.py new file mode 100644 index 0000000..ac3a8f5 --- /dev/null +++ b/crm/hooks.py @@ -0,0 +1,367 @@ +app_name = "crm" +app_title = "Frappe CRM" +app_publisher = "Frappe Technologies Pvt. Ltd." +app_description = "Kick-ass Open Source CRM" +app_email = "shariq@frappe.io" +app_license = "AGPLv3" +app_icon_url = "/assets/crm/images/logo.svg" +app_icon_title = "CRM" +app_icon_route = "/crm" + +# Apps +# ------------------ + +# required_apps = [] +add_to_apps_screen = [ + { + "name": "crm", + "logo": "/assets/crm/images/logo.svg", + "title": "CRM", + "route": "/crm", + "has_permission": "crm.api.check_app_permission", + } +] + +get_site_info = "crm.activation.get_site_info" + +export_python_type_annotations = True +require_type_annotated_api_methods = True + +# Includes in +# ------------------ + +# include js, css files in header of desk.html +# app_include_css = "/assets/crm/css/crm.css" +# app_include_js = "/assets/crm/js/crm.js" + +# include js, css files in header of web template +# web_include_css = "/assets/crm/css/crm.css" +# web_include_js = "/assets/crm/js/crm.js" + +# include custom scss in every website theme (without file extension ".scss") +# website_theme_scss = "crm/public/scss/website" + +# include js, css files in header of web form +# webform_include_js = {"doctype": "public/js/doctype.js"} +# webform_include_css = {"doctype": "public/css/doctype.css"} + +# include js in page +# page_js = {"page" : "public/js/file.js"} + +# include js in doctype views +doctype_js = { + "Quotation": "public/js/erpnext_quotation_prefill.js", + "Sales Order": "public/js/erpnext_sales_order_customer.js", + "CRM Lead": "public/js/domain_enrichment.js", + "CRM Organization": "public/js/domain_enrichment.js", + "CRM Deal": "public/js/domain_enrichment.js", +} +# doctype_list_js = {"doctype" : "public/js/doctype_list.js"} +# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} +# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} + +# Home Pages +# ---------- + +# application home page (will override Website Settings) +# home_page = "login" + +# website user home page (by Role) +# role_home_page = { +# "Role": "home_page" +# } + +website_route_rules = [ + {"from_route": "/crm/", "to_route": "crm"}, + {"from_route": "/crm-form/", "to_route": "crm_form"}, +] + +# Generators +# ---------- + +# automatically create page for each record of this doctype +# website_generators = ["Web Page"] + +# Jinja +# ---------- + +# add methods and filters to jinja environment +# jinja = { +# "methods": "crm.utils.jinja_methods", +# "filters": "crm.utils.jinja_filters" +# } + +# Setup wizard +# setup_wizard_requires = "assets/crm/js/setup_wizard.js" +# setup_wizard_stages = "crm.setup.setup_wizard.setup_wizard.get_setup_stages" +setup_wizard_complete = "crm.demo.api.create_demo_data" +# setup_wizard_test = "crm.setup.setup_wizard.test_setup_wizard.run_setup_wizard_test" + +# Installation +# ------------ + +before_install = "crm.install.before_install" +after_install = "crm.install.after_install" + +# Uninstallation +# ------------ + +before_uninstall = "crm.uninstall.before_uninstall" +# after_uninstall = "crm.uninstall.after_uninstall" + +# Integration Setup +# ------------------ +# To set up dependencies/integrations with other apps +# Name of the app being installed is passed as an argument + +# before_app_install = "crm.utils.before_app_install" +# after_app_install = "crm.utils.after_app_install" + +# Integration Cleanup +# ------------------- +# To clean up dependencies/integrations with other apps +# Name of the app being uninstalled is passed as an argument + +# before_app_uninstall = "crm.utils.before_app_uninstall" +# after_app_uninstall = "crm.utils.after_app_uninstall" + +# Desk Notifications +# ------------------ +# See frappe.core.notifications.get_notification_config + +# notification_config = "crm.notifications.get_notification_config" + +# Permissions +# ----------- +# Permissions evaluated in scripted ways + +permission_query_conditions = { + "CRM Lead": "crm.permissions.org_hierarchy.get_lead_permission_query_conditions", + "CRM Deal": "crm.permissions.org_hierarchy.get_deal_permission_query_conditions", + "CRM Notification": "crm.fcrm.doctype.crm_notification.crm_notification.get_permission_query_conditions", +} + +has_permission = { + "CRM Lead": "crm.permissions.org_hierarchy.has_lead_permission", + "CRM Deal": "crm.permissions.org_hierarchy.has_deal_permission", + "CRM Notification": "crm.fcrm.doctype.crm_notification.crm_notification.has_permission", +} + +# DocType Class +# --------------- +# Override standard doctype classes + +override_doctype_class = { + "Contact": "crm.overrides.contact.CustomContact", + "Email Template": "crm.overrides.email_template.CustomEmailTemplate", +} + +# Document Events +# --------------- +# Hook on document methods and events + +doc_events = { + "Contact": { + "validate": ["crm.api.contact.validate"], + }, + "Notification Log": { + "before_insert": ["crm.extends.notification_log.before_insert"], + }, + "ToDo": { + "validate": ["crm.api.todo.validate"], + "after_insert": ["crm.api.todo.after_insert"], + "on_update": ["crm.api.todo.on_update"], + }, + "Communication": { + "after_insert": ["crm.utils.on_communication_insert"], + "on_update": ["crm.utils.on_communication_update"], + }, + "Comment": { + "after_insert": ["crm.utils.on_comment_insert"], + "on_update": ["crm.api.comment.on_update"], + }, + "WhatsApp Message": { + "validate": ["crm.api.whatsapp.validate"], + "on_update": ["crm.api.whatsapp.on_update"], + }, + "CRM Deal": { + "on_update": [ + "crm.fcrm.doctype.erpnext_crm_settings.erpnext_crm_settings.create_customer_in_erpnext" + ], + }, + "Sales Order": { + "before_validate": [ + "crm.fcrm.doctype.erpnext_crm_settings.erpnext_crm_settings.create_customer_on_sales_order" + ], + }, + "Item": { + "after_insert": ["crm.integrations.erpnext.item.after_insert"], + "on_update": ["crm.integrations.erpnext.item.on_update"], + "before_rename": ["crm.integrations.erpnext.item.before_rename"], + "after_rename": ["crm.integrations.erpnext.item.after_rename"], + "on_trash": ["crm.integrations.erpnext.item.on_trash"], + }, + "User Permission": { + "before_validate": ["crm.integrations.erpnext.user_permission.before_validate"], + "after_insert": ["crm.integrations.erpnext.user_permission.after_insert"], + "on_update": ["crm.integrations.erpnext.user_permission.on_update"], + "on_trash": ["crm.integrations.erpnext.user_permission.on_trash"], + }, + "DocShare": { + "before_validate": ["crm.integrations.erpnext.doc_share.before_validate"], + "after_insert": ["crm.integrations.erpnext.doc_share.after_insert"], + "on_update": ["crm.integrations.erpnext.doc_share.on_update"], + "on_trash": ["crm.integrations.erpnext.doc_share.on_trash"], + }, + "User": { + "before_validate": ["crm.api.live_demo.validate_user"], + "validate_reset_password": ["crm.api.live_demo.validate_reset_password"], + }, +} + +# Scheduled Tasks +# --------------- + +scheduler_events = { + "all": ["crm.api.event.trigger_offset_event_notifications"], + "hourly": ["crm.api.event.trigger_hourly_event_notifications"], + "daily": [ + "crm.api.event.trigger_daily_event_notifications", + "crm.fcrm.doctype.crm_invitation.crm_invitation.expire_invitations", + "crm.fcrm.doctype.crm_view_settings.crm_view_settings.clear_old_versions", + "crm.telemetry.capture_feature_state", + ], + "weekly": ["crm.api.event.trigger_weekly_event_notifications"], + "daily_long": ["crm.lead_syncing.background_sync.sync_leads_from_sources_daily"], + "hourly_long": ["crm.lead_syncing.background_sync.sync_leads_from_sources_hourly"], + "monthly_long": ["crm.lead_syncing.background_sync.sync_leads_from_sources_monthly"], + "cron": { + "*/5 * * * *": ["crm.lead_syncing.background_sync.sync_leads_from_sources_5_minutes"], + "*/10 * * * *": ["crm.lead_syncing.background_sync.sync_leads_from_sources_10_minutes"], + "*/15 * * * *": ["crm.lead_syncing.background_sync.sync_leads_from_sources_15_minutes"], + }, +} + +# Testing +# ------- + +before_tests = "crm.tests.before_tests" + +# Overriding Methods +# ------------------------------ +# +# override_whitelisted_methods = { +# "frappe.desk.doctype.event.event.get_events": "crm.event.get_events" +# } +# +# each overriding function accepts a `data` argument; +# generated from the base implementation of the doctype dashboard, +# along with any modifications made in other Frappe apps +# override_doctype_dashboards = { +# "Task": "crm.task.get_dashboard_data" +# } + +# exempt linked doctypes from being automatically cancelled +# +# auto_cancel_exempted_doctypes = ["Auto Repeat"] + +# Ignore links to specified DocTypes when deleting documents +# ----------------------------------------------------------- + +ignore_links_on_delete = ["Failed Lead Sync Log"] + +# Request Events +# ---------------- +# before_request = ["crm.utils.before_request"] +# after_request = ["crm.utils.after_request"] + +# Job Events +# ---------- +# before_job = ["crm.utils.before_job"] +# after_job = ["crm.utils.after_job"] + +# User Data Protection +# -------------------- + +# user_data_fields = [ +# { +# "doctype": "{doctype_1}", +# "filter_by": "{filter_by}", +# "redact_fields": ["{field_1}", "{field_2}"], +# "partial": 1, +# }, +# { +# "doctype": "{doctype_2}", +# "filter_by": "{filter_by}", +# "partial": 1, +# }, +# { +# "doctype": "{doctype_3}", +# "strict": False, +# }, +# { +# "doctype": "{doctype_4}" +# } +# ] + +# Authentication and authorization +# -------------------------------- + +# auth_hooks = [ +# "crm.auth.validate" +# ] + +after_migrate = [ + "crm.fcrm.doctype.fcrm_settings.fcrm_settings.after_migrate", + "crm.api.whatsapp.add_roles", + "crm.domain_enrichment.install.seed_default_rules_and_mappings", + "crm.install.add_default_scripts", + "crm.install.add_web_form_custom_fields", +] + +standard_dropdown_items = [ + { + "name1": "app_selector", + "label": "Apps", + "type": "Route", + "route": "#", + "is_standard": 1, + }, + { + "name1": "settings", + "label": "Settings", + "type": "Route", + "icon": "settings", + "route": "#", + "is_standard": 1, + }, + { + "name1": "login_to_fc", + "label": "Login to Frappe Cloud", + "type": "Route", + "route": "#", + "is_standard": 1, + }, + { + "name1": "about", + "label": "About", + "type": "Route", + "icon": "info", + "route": "#", + "is_standard": 1, + }, + { + "name1": "separator", + "label": "", + "type": "Separator", + "is_standard": 1, + }, + { + "name1": "logout", + "label": "Log out", + "type": "Route", + "icon": "log-out", + "route": "#", + "is_standard": 1, + }, +] diff --git a/crm/install.py b/crm/install.py new file mode 100644 index 0000000..29b664e --- /dev/null +++ b/crm/install.py @@ -0,0 +1,614 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt +import json + +import click +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + +from crm.domain_enrichment.install import seed_default_rules_and_mappings +from crm.fcrm.doctype.crm_dashboard.crm_dashboard import create_default_manager_dashboard +from crm.fcrm.doctype.crm_products.crm_products import create_product_details_script + + +def before_install(): + pass + + +def after_install(force=False): + add_default_lead_statuses() + add_default_deal_statuses() + add_default_communication_statuses() + add_default_fields_layout(force) + add_property_setter() + add_email_template_custom_fields() + add_email_account_custom_field() + add_web_form_custom_fields() + add_default_industries() + add_default_lead_sources() + add_default_lost_reasons() + add_default_quick_filters() + add_standard_dropdown_items() + add_default_scripts() + create_default_manager_dashboard(force) + create_assignment_rule_custom_fields() + add_assignment_rule_property_setters() + seed_default_rules_and_mappings() + frappe.db.commit() + + +def add_default_lead_statuses(): + statuses = { + "New": { + "color": "gray", + "type": "Open", + "position": 1, + }, + "Contacted": { + "color": "orange", + "type": "Ongoing", + "position": 2, + }, + "Nurture": { + "color": "blue", + "type": "Ongoing", + "position": 3, + }, + "Qualified": { + "color": "green", + "type": "Won", + "position": 4, + }, + "Converted": { + "color": "teal", + "type": "Won", + "position": 5, + }, + "Unqualified": { + "color": "red", + "type": "Lost", + "position": 6, + }, + "Junk": { + "color": "purple", + "type": "Lost", + "position": 7, + }, + } + + for status in statuses: + if frappe.db.exists("CRM Lead Status", status): + continue + + doc = frappe.new_doc("CRM Lead Status") + doc.lead_status = status + doc.color = statuses[status]["color"] + doc.type = statuses[status]["type"] + doc.position = statuses[status]["position"] + doc.insert() + + +def add_default_deal_statuses(): + statuses = { + "Qualification": { + "color": "gray", + "type": "Open", + "probability": 10, + "position": 1, + }, + "Demo/Making": { + "color": "orange", + "type": "Ongoing", + "probability": 25, + "position": 2, + }, + "Proposal/Quotation": { + "color": "blue", + "type": "Ongoing", + "probability": 50, + "position": 3, + }, + "Negotiation": { + "color": "yellow", + "type": "Ongoing", + "probability": 70, + "position": 4, + }, + "Ready to Close": { + "color": "purple", + "type": "Ongoing", + "probability": 90, + "position": 5, + }, + "Won": { + "color": "green", + "type": "Won", + "probability": 100, + "position": 6, + }, + "Lost": { + "color": "red", + "type": "Lost", + "probability": 0, + "position": 7, + }, + } + + for status in statuses: + if frappe.db.exists("CRM Deal Status", status): + continue + + doc = frappe.new_doc("CRM Deal Status") + doc.deal_status = status + doc.color = statuses[status]["color"] + doc.type = statuses[status]["type"] + doc.probability = statuses[status]["probability"] + doc.position = statuses[status]["position"] + doc.insert() + + +def add_default_communication_statuses(): + statuses = ["Open", "Replied"] + + for status in statuses: + if frappe.db.exists("CRM Communication Status", status): + continue + + doc = frappe.new_doc("CRM Communication Status") + doc.status = status + doc.insert() + + +def add_default_fields_layout(force=False): + quick_entry_layouts = { + "CRM Lead-Quick Entry": { + "doctype": "CRM Lead", + "layout": '[{"name": "person_section", "columns": [{"name": "column_5jrk", "fields": ["salutation", "email"]}, {"name": "column_5CPV", "fields": ["first_name", "mobile_no"]}, {"name": "column_gXOy", "fields": ["last_name", "gender"]}]}, {"name": "organization_section", "columns": [{"name": "column_GHfX", "fields": ["organization", "territory"]}, {"name": "column_hXjS", "fields": ["website", "annual_revenue", "company_description"]}, {"name": "column_RDNA", "fields": ["no_of_employees", "industry", "linkedin", "twitter", "facebook"]}]}, {"name": "lead_section", "columns": [{"name": "column_EO1H", "fields": ["status"]}, {"name": "column_RWBe", "fields": ["lead_owner"]}]}]', + }, + "CRM Deal-Quick Entry": { + "doctype": "CRM Deal", + "layout": '[{"name": "organization_section", "hidden": true, "editable": false, "columns": [{"name": "column_GpMP", "fields": ["organization"]}, {"name": "column_FPTn", "fields": []}]}, {"name": "organization_details_section", "editable": false, "columns": [{"name": "column_S3tQ", "fields": ["organization_name", "territory"]}, {"name": "column_KqV1", "fields": ["website", "annual_revenue", "company_description"]}, {"name": "column_1r67", "fields": ["no_of_employees", "industry", "linkedin", "twitter", "facebook"]}]}, {"name": "contact_section", "hidden": true, "editable": false, "columns": [{"name": "column_CeXr", "fields": ["contact"]}, {"name": "column_yHbk", "fields": []}]}, {"name": "contact_details_section", "editable": false, "columns": [{"name": "column_ZTWr", "fields": ["salutation", "email"]}, {"name": "column_tabr", "fields": ["first_name", "mobile_no"]}, {"name": "column_Qjdx", "fields": ["last_name", "gender"]}]}, {"name": "deal_section", "columns": [{"name": "column_mdps", "fields": ["status"]}, {"name": "column_H40H", "fields": ["deal_owner"]}]}]', + }, + "Contact-Quick Entry": { + "doctype": "Contact", + "layout": '[{"name": "salutation_section", "columns": [{"name": "column_eXks", "fields": ["salutation"]}]}, {"name": "full_name_section", "hideBorder": true, "columns": [{"name": "column_cSxf", "fields": ["first_name"]}, {"name": "column_yBc7", "fields": ["last_name"]}]}, {"name": "email_section", "hideBorder": true, "columns": [{"name": "column_tH3L", "fields": ["email_id"]}]}, {"name": "mobile_gender_section", "hideBorder": true, "columns": [{"name": "column_lrfI", "fields": ["mobile_no"]}, {"name": "column_Tx3n", "fields": ["gender"]}]}, {"name": "organization_section", "hideBorder": true, "columns": [{"name": "column_S0J8", "fields": ["company_name"]}]}, {"name": "designation_section", "hideBorder": true, "columns": [{"name": "column_bsO8", "fields": ["designation"]}]}, {"name": "address_section", "hideBorder": true, "columns": [{"name": "column_W3VY", "fields": ["address"]}]}]', + }, + "CRM Organization-Quick Entry": { + "doctype": "CRM Organization", + "layout": '[{"name": "organization_section", "columns": [{"name": "column_zOuv", "fields": ["organization_name"]}]}, {"name": "website_revenue_section", "hideBorder": true, "columns": [{"name": "column_I5Dy", "fields": ["website", "company_description"]}, {"name": "column_Rgss", "fields": ["annual_revenue"]}]}, {"name": "territory_section", "hideBorder": true, "columns": [{"name": "column_w6ap", "fields": ["territory"]}]}, {"name": "employee_industry_section", "hideBorder": true, "columns": [{"name": "column_u5tZ", "fields": ["no_of_employees", "linkedin", "twitter"]}, {"name": "column_FFrT", "fields": ["industry", "facebook"]}]}, {"name": "address_section", "hideBorder": true, "columns": [{"name": "column_O2dk", "fields": ["address"]}]}]', + }, + "Address-Quick Entry": { + "doctype": "Address", + "layout": '[{"name": "details_section", "columns": [{"name": "column_uSSG", "fields": ["address_title", "address_type", "address_line1", "address_line2"]}]}, {"name": "location_section", "hideBorder": true, "columns": [{"name": "column_TCoZ", "fields": ["country", "city"]}, {"name": "column_PqrK", "fields": ["state", "pincode"]}]}]', + }, + "CRM Call Log-Quick Entry": { + "doctype": "CRM Call Log", + "layout": '[{"name":"details_section","columns":[{"name":"column_uMSG","fields":["type","from","duration"]},{"name":"column_wiZT","fields":["to","status","caller","receiver"]}]}]', + }, + "FCRM Note-Quick Entry": { + "doctype": "FCRM Note", + "layout": '[{"name":"details_section","columns":[{"name":"column_o2s9","fields":["title", "content"]}]}]', + }, + "CRM Task-Quick Entry": { + "doctype": "CRM Task", + "layout": '[{"name":"first_tab","sections":[{"name":"details_section","columns":[{"name":"column_X9sG","fields":["title","description"]}]},{"name":"assignment_section","columns":[{"name":"column_9XjK","fields":["priority","due_date"]},{"name":"column_7s8n","fields":["assigned_to","status"]}],"hideBorder":true}]}]', + }, + } + + sidebar_fields_layouts = { + "CRM Lead-Side Panel": { + "doctype": "CRM Lead", + "layout": '[{"label": "Details", "name": "details_section", "opened": true, "columns": [{"name": "column_kl92", "fields": ["organization", "company_description", "website", "territory", "industry", "no_of_employees", "job_title", "source", "lead_owner", "linkedin", "twitter", "facebook"]}]}, {"label": "Person", "name": "person_section", "opened": true, "columns": [{"name": "column_XmW2", "fields": ["salutation", "first_name", "last_name", "email", "mobile_no"]}]}]', + }, + "CRM Deal-Side Panel": { + "doctype": "CRM Deal", + "layout": '[{"label": "Contacts", "name": "contacts_section", "opened": true, "editable": false, "contacts": []}, {"label": "Organization Details", "name": "organization_section", "opened": true, "columns": [{"name": "column_na2Q", "fields": ["organization", "company_description", "industry", "no_of_employees", "website", "territory", "annual_revenue", "closed_date", "probability", "next_step", "deal_owner", "linkedin", "twitter", "facebook"]}]}]', + }, + "Contact-Side Panel": { + "doctype": "Contact", + "layout": '[{"label": "Details", "name": "details_section", "opened": true, "columns": [{"name": "column_eIWl", "fields": ["salutation", "first_name", "last_name", "email_id", "mobile_no", "gender", "company_name", "designation", "address"]}]}]', + }, + "CRM Organization-Side Panel": { + "doctype": "CRM Organization", + "layout": '[{"label": "Details", "name": "details_section", "opened": true, "columns": [{"name": "column_IJOV", "fields": ["organization_name", "company_description", "website", "territory", "industry", "no_of_employees", "address", "linkedin", "twitter", "facebook"]}]}]', + }, + } + + data_fields_layouts = { + "CRM Lead-Data Fields": { + "doctype": "CRM Lead", + "layout": '[{"label": "Details", "name": "details_section", "opened": true, "columns": [{"name": "column_ZgLG", "fields": ["organization", "company_description", "industry", "no_of_employees"]}, {"name": "column_TbYq", "fields": ["website", "linkedin", "twitter", "facebook", "job_title"]}, {"name": "column_OKSX", "fields": ["territory", "source", "lead_owner"]}]}, {"label": "Person", "name": "person_section", "opened": true, "columns": [{"name": "column_6c5g", "fields": ["salutation", "email"]}, {"name": "column_1n7Q", "fields": ["first_name", "mobile_no"]}, {"name": "column_cT6C", "fields": ["last_name"]}]}]', + }, + "CRM Deal-Data Fields": { + "doctype": "CRM Deal", + "layout": '[{"name":"first_tab","sections":[{"label":"Details","name":"details_section","opened":true,"columns":[{"name":"column_z9XL","fields":["organization","company_description","industry","no_of_employees","annual_revenue"]},{"name":"column_gM4w","fields":["website","linkedin","twitter","facebook","closed_date","next_step"]},{"name":"column_gWmE","fields":["territory","probability","deal_owner"]}]},{"label":"Products","name":"section_jHhQ","opened":true,"columns":[{"name":"column_xiNF","fields":["products"]}],"editingLabel":false,"hideLabel":true},{"label":"New Section","name":"section_WNOQ","opened":true,"columns":[{"name":"column_ziBW","fields":["total"]},{"label":"","name":"column_wuwA","fields":["net_total"]}],"hideBorder":true,"hideLabel":true}]}]', + }, + } + + for layout in quick_entry_layouts: + if frappe.db.exists("CRM Fields Layout", layout): + if force: + frappe.delete_doc("CRM Fields Layout", layout) + else: + continue + + doc = frappe.new_doc("CRM Fields Layout") + doc.type = "Quick Entry" + doc.dt = quick_entry_layouts[layout]["doctype"] + doc.layout = quick_entry_layouts[layout]["layout"] + doc.insert() + + for layout in sidebar_fields_layouts: + if frappe.db.exists("CRM Fields Layout", layout): + if force: + frappe.delete_doc("CRM Fields Layout", layout) + else: + continue + + doc = frappe.new_doc("CRM Fields Layout") + doc.type = "Side Panel" + doc.dt = sidebar_fields_layouts[layout]["doctype"] + doc.layout = sidebar_fields_layouts[layout]["layout"] + doc.insert() + + for layout in data_fields_layouts: + if frappe.db.exists("CRM Fields Layout", layout): + if force: + frappe.delete_doc("CRM Fields Layout", layout) + else: + continue + + doc = frappe.new_doc("CRM Fields Layout") + doc.type = "Data Fields" + doc.dt = data_fields_layouts[layout]["doctype"] + doc.layout = data_fields_layouts[layout]["layout"] + doc.insert() + + +def add_property_setter(): + if not frappe.db.exists("Property Setter", {"name": "Contact-main-search_fields"}): + doc = frappe.new_doc("Property Setter") + doc.doctype_or_field = "DocType" + doc.doc_type = "Contact" + doc.property = "search_fields" + doc.property_type = "Data" + doc.value = "email_id" + doc.insert() + + +def add_email_template_custom_fields(): + meta = frappe.get_meta("Email Template") + + fields = [ + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled", + "insert_after": "", + }, + { + "fieldname": "reference_doctype", + "fieldtype": "Link", + "label": "Doctype", + "options": "DocType", + "insert_after": "enabled", + }, + ] + + fields = [field for field in fields if not meta.has_field(field["fieldname"])] + if not fields: + return + + click.secho("* Installing Custom Fields in Email Template") + create_custom_fields({"Email Template": fields}) + frappe.clear_cache(doctype="Email Template") + + +def add_email_account_custom_field(): + if not frappe.get_meta("Email Account").has_field("create_lead_from_incoming_email"): + click.secho("* Installing Custom Fields in Email Account") + + create_custom_fields( + { + "Email Account": [ + { + "default": "0", + "fieldname": "create_lead_from_incoming_email", + "fieldtype": "Check", + "label": "Create Lead from Incoming Emails", + "description": "Automatically create a lead when an incoming email is received from an unknown contact", + "insert_after": "create_contact", + } + ] + } + ) + + frappe.clear_cache(doctype="Email Account") + + +def add_web_form_custom_fields(): + """CRM's own fields on the native Web Form. + + - `crm_published`: separate publish flag so CRM forms are served only by the + CRM's own public page (and never rendered by the framework's web form page). + The native `published` is always left 0. + - `crm_hidden_defaults`: JSON of doctype-mandatory fields the author removed + from the visible form, with the default value to apply on submission so the + target record can still be created. + """ + meta = frappe.get_meta("Web Form") + if meta.has_field("crm_published") and meta.has_field("crm_hidden_defaults"): + return + click.secho("* Installing Custom Fields in Web Form") + create_custom_fields( + { + "Web Form": [ + { + "default": "0", + "fieldname": "crm_published", + "fieldtype": "Check", + "label": "CRM Published", + "insert_after": "published", + "hidden": 1, + }, + { + "fieldname": "crm_hidden_defaults", + "fieldtype": "Long Text", + "label": "CRM Hidden Field Defaults", + "insert_after": "crm_published", + "hidden": 1, + }, + ] + } + ) + frappe.clear_cache(doctype="Web Form") + + +def add_default_industries(): + industries = [ + "Accounting", + "Advertising", + "Aerospace", + "Agriculture", + "Airline", + "Apparel & Accessories", + "Automotive", + "Banking", + "Biotechnology", + "Broadcasting", + "Brokerage", + "Chemical", + "Computer", + "Consulting", + "Consumer Products", + "Cosmetics", + "Defense", + "Department Stores", + "Education", + "Electronics", + "Energy", + "Entertainment & Leisure, Executive Search", + "Financial Services", + "Food", + "Beverage & Tobacco", + "Grocery", + "Health Care", + "Internet Publishing", + "Investment Banking", + "Legal", + "Manufacturing", + "Motion Picture & Video", + "Music", + "Newspaper Publishers", + "Online Auctions", + "Pension Funds", + "Pharmaceuticals", + "Private Equity", + "Publishing", + "Real Estate", + "Retail & Wholesale", + "Securities & Commodity Exchanges", + "Service", + "Soap & Detergent", + "Software", + "Sports", + "Technology", + "Telecommunications", + "Television", + "Transportation", + "Venture Capital", + ] + + for industry in industries: + if frappe.db.exists("CRM Industry", industry): + continue + + doc = frappe.new_doc("CRM Industry") + doc.industry = industry + doc.insert() + + +def add_default_lead_sources(): + lead_sources = [ + "Email", + "Existing Customer", + "Reference", + "Advertisement", + "Cold Calling", + "Exhibition", + "Supplier Reference", + "Mass Mailing", + "Customer's Vendor", + "Campaign", + "Walk In", + "Facebook", + "Website", + "Web Form", + ] + + for source in lead_sources: + if frappe.db.exists("CRM Lead Source", source): + continue + + doc = frappe.new_doc("CRM Lead Source") + doc.source_name = source + doc.insert() + + +def add_default_lost_reasons(): + lost_reasons = [ + { + "reason": "Pricing", + "description": "The prospect found the pricing to be too high or not competitive.", + }, + {"reason": "Competition", "description": "The prospect chose a competitor's product or service."}, + { + "reason": "Budget Constraints", + "description": "The prospect did not have the budget to proceed with the purchase.", + }, + { + "reason": "Missing Features", + "description": "The prospect felt that the product or service was missing key features they needed.", + }, + { + "reason": "Long Sales Cycle", + "description": "The sales process took too long, leading to loss of interest.", + }, + { + "reason": "No Decision-Maker", + "description": "The prospect was not the decision-maker and could not proceed.", + }, + {"reason": "Unresponsive Prospect", "description": "The prospect did not respond to follow-ups."}, + {"reason": "Poor Fit", "description": "The prospect was not a good fit for the product or service."}, + {"reason": "Other", "description": ""}, + ] + + for reason in lost_reasons: + if frappe.db.exists("CRM Lost Reason", reason["reason"]): + continue + + doc = frappe.new_doc("CRM Lost Reason") + doc.lost_reason = reason["reason"] + doc.description = reason["description"] + doc.insert() + + +def add_default_quick_filters(): + quick_filters = { + "CRM Lead": ["lead_name", "email", "organization", "status", "source"], + "CRM Deal": ["organization", "status", "probability", "email"], + "Contact": ["status", "email_id", "phone"], + "CRM Organization": ["organization_name", "no_of_employees", "territory", "industry"], + "CRM Task": ["title", "priority", "assigned_to", "status", "due_date"], + "CRM Call Log": ["telephony_medium", "type", "status", "from", "to"], + } + + for quick_filter in quick_filters: + if frappe.db.exists("CRM Global Settings", {"dt": quick_filter}): + continue + + doc = frappe.new_doc("CRM Global Settings") + doc.dt = quick_filter + doc.json = json.dumps(quick_filters[quick_filter]) + doc.insert() + + +def add_standard_dropdown_items(): + crm_settings = frappe.get_single("FCRM Settings") + + # don't add dropdown items if they're already present + if crm_settings.dropdown_items: + return + + crm_settings.dropdown_items = [] + + for item in frappe.get_hooks("standard_dropdown_items"): + crm_settings.append("dropdown_items", item) + + crm_settings.save() + + +def add_default_scripts(): + from crm.fcrm.doctype.fcrm_settings.fcrm_settings import create_forecasting_script + + for doctype in ["CRM Lead", "CRM Deal"]: + create_product_details_script(doctype) + create_forecasting_script() + + +def add_assignment_rule_property_setters(): + """Add a property setter to the Assignment Rule DocType for assign_condition and unassign_condition.""" + + default_fields = { + "doctype": "Property Setter", + "doctype_or_field": "DocField", + "doc_type": "Assignment Rule", + "property_type": "Data", + "is_system_generated": 1, + } + + if not frappe.db.exists("Property Setter", {"name": "Assignment Rule-assign_condition-depends_on"}): + frappe.get_doc( + { + **default_fields, + "name": "Assignment Rule-assign_condition-depends_on", + "field_name": "assign_condition", + "property": "depends_on", + "value": "eval: !doc.assign_condition_json", + } + ).insert() + else: + frappe.db.set_value( + "Property Setter", + {"name": "Assignment Rule-assign_condition-depends_on"}, + "value", + "eval: !doc.assign_condition_json", + ) + if not frappe.db.exists("Property Setter", {"name": "Assignment Rule-unassign_condition-depends_on"}): + frappe.get_doc( + { + **default_fields, + "name": "Assignment Rule-unassign_condition-depends_on", + "field_name": "unassign_condition", + "property": "depends_on", + "value": "eval: !doc.unassign_condition_json", + } + ).insert() + else: + frappe.db.set_value( + "Property Setter", + {"name": "Assignment Rule-unassign_condition-depends_on"}, + "value", + "eval: !doc.unassign_condition_json", + ) + + +def create_assignment_rule_custom_fields(): + if not frappe.get_meta("Assignment Rule").has_field("assign_condition_json"): + click.secho("* Installing Custom Fields in Assignment Rule") + + create_custom_fields( + { + "Assignment Rule": [ + { + "description": "Autogenerated field by CRM App", + "fieldname": "assign_condition_json", + "fieldtype": "Code", + "label": "Assign Condition JSON", + "insert_after": "assign_condition", + "depends_on": "eval: doc.assign_condition_json", + }, + { + "description": "Autogenerated field by CRM App", + "fieldname": "unassign_condition_json", + "fieldtype": "Code", + "label": "Unassign Condition JSON", + "insert_after": "unassign_condition", + "depends_on": "eval: doc.unassign_condition_json", + }, + ], + } + ) + + frappe.clear_cache(doctype="Assignment Rule") diff --git a/crm/integrations/__init__.py b/crm/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/integrations/api.py b/crm/integrations/api.py new file mode 100644 index 0000000..5c8b19a --- /dev/null +++ b/crm/integrations/api.py @@ -0,0 +1,381 @@ +import ipaddress +import socket +from urllib.parse import urlparse, urlunparse + +import frappe +import requests +from frappe import _ +from frappe.query_builder import Order +from pypika.functions import Replace +from werkzeug.wrappers import Response + +from crm.utils import are_same_phone_number, parse_phone_number + + +def _get_recording_credentials(telephony_medium: str) -> tuple | None: + """Return (api_key, secret) for the given telephony medium, or None when the + recording needs no auth. + + A manual/unrecognized medium (a recording added by hand) is fetched as-is, and + a provider whose credentials aren't configured yet falls back to no auth rather + than raising — so the proxy attempts the fetch and lets the provider decide, + instead of 500-ing before the request is even made. + """ + if telephony_medium == "Twilio": + s = frappe.get_single("CRM Twilio Settings") + secret = s.get_password("api_secret", raise_exception=False) + return (s.api_key, secret) if s.api_key and secret else None + elif telephony_medium == "Exotel": + s = frappe.get_single("CRM Exotel Settings") + token = s.get_password("api_token", raise_exception=False) + return (s.api_key, token) if s.api_key and token else None + # manual or unrecognized medium: no provider auth to apply + return None + + +@frappe.whitelist() +def is_call_integration_enabled(): + return { + "integrations": { + "twilio": bool(frappe.db.get_single_value("CRM Twilio Settings", "enabled")), + "exotel": bool(frappe.db.get_single_value("CRM Exotel Settings", "enabled")), + }, + "default_calling_medium": get_user_default_calling_medium(), + } + + +def get_user_default_calling_medium(): + if not frappe.db.exists("CRM Telephony Agent", frappe.session.user): + return None + + default_medium = frappe.db.get_value("CRM Telephony Agent", frappe.session.user, "default_medium") + + if not default_medium: + return None + + return default_medium + + +@frappe.whitelist() +def set_default_calling_medium(medium: str): + if not frappe.db.exists("CRM Telephony Agent", frappe.session.user): + frappe.get_doc( + { + "doctype": "CRM Telephony Agent", + "user": frappe.session.user, + "default_medium": medium, + } + ).insert(ignore_permissions=True) + else: + frappe.db.set_value("CRM Telephony Agent", frappe.session.user, "default_medium", medium) + + return get_user_default_calling_medium() + + +@frappe.whitelist() +def add_note_to_call_log(call_sid: str, note: dict): + """Add/Update note to call log based on call sid.""" + if not frappe.has_permission("CRM Call Log", "write", call_sid): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + _note = None + if not note.get("name"): + _note = frappe.get_doc( + { + "doctype": "FCRM Note", + "title": note.get("title", "Call Note"), + "content": note.get("content"), + } + ).insert(ignore_permissions=True) + else: + _note = frappe.set_value("FCRM Note", note.get("name"), "content", note.get("content")) + + call_log = frappe.get_cached_doc("CRM Call Log", call_sid) + call_log.link_with_reference_doc("FCRM Note", _note.name) + call_log.save(ignore_permissions=True) + + return _note + + +@frappe.whitelist() +def add_task_to_call_log(call_sid: str, task: dict): + """Add/Update task to call log based on call sid.""" + if not frappe.has_permission("CRM Call Log", "write", call_sid): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + _task = None + if not task.get("name"): + _task = frappe.get_doc( + { + "doctype": "CRM Task", + "title": task.get("title"), + "description": task.get("description"), + "assigned_to": task.get("assigned_to"), + "due_date": task.get("due_date"), + "status": task.get("status"), + "priority": task.get("priority"), + } + ).insert(ignore_permissions=True) + else: + _task = frappe.get_doc("CRM Task", task.get("name")) + _task.update( + { + "title": task.get("title"), + "description": task.get("description"), + "assigned_to": task.get("assigned_to"), + "due_date": task.get("due_date"), + "status": task.get("status"), + "priority": task.get("priority"), + } + ) + _task.save(ignore_permissions=True) + + call_log = frappe.get_doc("CRM Call Log", call_sid) + call_log.link_with_reference_doc("CRM Task", _task.name) + call_log.save(ignore_permissions=True) + + return _task + + +@frappe.whitelist() +def get_contact_lead_or_deal_from_number(number: str): + """Get contact, lead or deal from the given number.""" + contact = get_contact_by_phone_number(number) + if contact.get("name"): + doctype = "Contact" + docname = contact.get("name") + if contact.get("lead"): + doctype = "CRM Lead" + docname = contact.get("lead") + elif contact.get("deal"): + doctype = "CRM Deal" + docname = contact.get("deal") + return docname, doctype + return None, None + + +@frappe.whitelist() +def get_contact_by_phone_number(phone_number: str): + """Get contact by phone number.""" + number = parse_phone_number(phone_number) + + if number.get("is_valid"): + return get_contact(number.get("national_number"), number.get("country")) + else: + return get_contact(phone_number, number.get("country"), exact_match=True) + + +def _resolve_validated_ip(hostname: str, port: int) -> str: + # Refuse any host that resolves to a non-public address (cloud metadata, localhost, + # private/link-local ranges) and return a single validated IP to connect to. Returning + # the exact resolved IP — rather than re-resolving at connect time — is what closes the + # DNS-rebinding TOCTOU window. + try: + addrinfos = socket.getaddrinfo(hostname, port, proto=socket.IPPROTO_TCP) + except socket.gaierror: + frappe.throw(_("Invalid recording URL"), frappe.ValidationError) + + ips = [info[4][0] for info in addrinfos] + if not ips: + frappe.throw(_("Invalid recording URL"), frappe.ValidationError) + + for ip in ips: + if not ipaddress.ip_address(ip).is_global: + frappe.throw(_("Recording URL is not allowed"), frappe.ValidationError) + + return ips[0] + + +class _PinnedIPAdapter(requests.adapters.HTTPAdapter): + # Connect to a pre-validated IP while keeping the original hostname for the Host header, + # TLS SNI and certificate verification. The socket therefore reaches the exact IP that was + # checked, so DNS can't be rebound to an internal address between check and connect. + def __init__(self, pinned_ip: str, hostname: str, **kwargs): + self._pinned_ip = pinned_ip + self._hostname = hostname + super().__init__(**kwargs) + + def send(self, request, **kwargs): + parsed = urlparse(request.url) + literal_ip = ( + f"[{self._pinned_ip}]" if ipaddress.ip_address(self._pinned_ip).version == 6 else self._pinned_ip + ) + netloc = f"{literal_ip}:{parsed.port}" if parsed.port else literal_ip + request.url = urlunparse(parsed._replace(netloc=netloc)) + host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname + request.headers["Host"] = f"{host}:{parsed.port}" if parsed.port else host + if parsed.scheme == "https": + self.poolmanager.connection_pool_kw["server_hostname"] = self._hostname + self.poolmanager.connection_pool_kw["assert_hostname"] = self._hostname + return super().send(request, **kwargs) + + +def _safe_get(url: str, auth, headers: dict): + # TODO: this SSRF-safe fetch (host validation + IP pinning) will likely need to be shared + # with domain enrichment; until that feature ships, this local helper is the interim workaround. + parsed = urlparse(url) + if parsed.scheme not in ("http", "https") or not parsed.hostname: + frappe.throw(_("Invalid recording URL"), frappe.ValidationError) + + port = parsed.port or (443 if parsed.scheme == "https" else 80) + pinned_ip = _resolve_validated_ip(parsed.hostname, port) + + session = requests.Session() + session.mount(f"{parsed.scheme}://", _PinnedIPAdapter(pinned_ip, parsed.hostname)) + resp = session.get(url, auth=auth, headers=headers, stream=True, timeout=30, allow_redirects=False) + return resp, session + + +def _fetch_recording(url: str, auth, headers: dict): + # Follow redirects manually so every hop is validated and IP-pinned: a provider URL can + # 302 to a signed CDN URL (legitimate), but without per-hop checks a redirect to an + # internal address would bypass validation. Provider credentials are dropped after the + # first hop so they aren't leaked to the redirect target. + current_url = url + current_auth = auth + for _hop in range(5): + resp, session = _safe_get(current_url, current_auth, headers) + if resp.is_redirect and resp.headers.get("Location"): + current_url = requests.compat.urljoin(current_url, resp.headers["Location"]) + current_auth = None + resp.close() + session.close() + continue + resp._pinned_session = session + return resp + + frappe.throw(_("Too many redirects while fetching recording"), frappe.ValidationError) + + +@frappe.whitelist() +def get_recording_url(call_log_name: str): + """Proxy a call recording (authenticating with the provider) so it plays in the browser. + + Forwards the browser's Range request to the provider and passes the response back with + Accept-Ranges/Content-Length set. Without range support the HTML