snapshot of https://github.com/frappe/crm.git (develop, 2026-09-18; blobs > 131072B omitted)
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
This commit is contained in:
21
.devcontainer/devcontainer.json
Normal file
21
.devcontainer/devcontainer.json
Normal file
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
46
.devcontainer/docker-compose.yml
Normal file
46
.devcontainer/docker-compose.yml
Normal file
@@ -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:
|
||||||
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -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.
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -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.
|
||||||
43
.github/actions/frontend-tests/action.yml
vendored
Normal file
43
.github/actions/frontend-tests/action.yml
vendored
Normal file
@@ -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"
|
||||||
28
.github/actions/publish-codecov/action.yml
vendored
Normal file
28
.github/actions/publish-codecov/action.yml
vendored
Normal file
@@ -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 }}
|
||||||
42
.github/actions/run-server-tests/action.yml
vendored
Normal file
42
.github/actions/run-server-tests/action.yml
vendored
Normal file
@@ -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
|
||||||
95
.github/actions/setup-server-env/action.yml
vendored
Normal file
95
.github/actions/setup-server-env/action.yml
vendored
Normal file
@@ -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
|
||||||
18
.github/actions/upload-coverage/action.yml
vendored
Normal file
18
.github/actions/upload-coverage/action.yml
vendored
Normal file
@@ -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 }}
|
||||||
63
.github/helper/install.sh
vendored
Normal file
63
.github/helper/install.sh
vendored
Normal file
@@ -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
|
||||||
16
.github/helper/site_config.json
vendored
Normal file
16
.github/helper/site_config.json
vendored
Normal file
@@ -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
|
||||||
|
}
|
||||||
59
.github/helper/update_pot_file.sh
vendored
Normal file
59
.github/helper/update_pot_file.sh
vendored
Normal file
@@ -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
|
||||||
BIN
.github/logo.png
vendored
Normal file
BIN
.github/logo.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
4
.github/logo.svg
vendored
Normal file
4
.github/logo.svg
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M214.286 0H85.7143C38.3756 0 0 38.3756 0 85.7143V214.286C0 261.624 38.3756 300 85.7143 300H214.286C261.624 300 300 261.624 300 214.286V85.7143C300 38.3756 261.624 0 214.286 0Z" fill="#EF0BF5"/>
|
||||||
|
<path d="M64.2141 90.301V111.862H214.339V140.214L160.187 193.146V208.993L139.705 208.885V193.146L85.6605 140.214H64.2141V149.269L118.259 202.202V230.23L181.634 230.769V202.202L235.786 149.269V90.301H64.2141Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 534 B |
65
.github/workflows/builds.yml
vendored
Normal file
65
.github/workflows/builds.yml
vendored
Normal file
@@ -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 }}"
|
||||||
125
.github/workflows/frontend-tests.yml
vendored
Normal file
125
.github/workflows/frontend-tests.yml
vendored
Normal file
@@ -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 }}
|
||||||
40
.github/workflows/generate-pot-file.yml
vendored
Normal file
40
.github/workflows/generate-pot-file.yml
vendored
Normal file
@@ -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 }}
|
||||||
85
.github/workflows/linters.yml
vendored
Normal file
85
.github/workflows/linters.yml
vendored
Normal file
@@ -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
|
||||||
107
.github/workflows/migration-test.yml
vendored
Normal file
107
.github/workflows/migration-test.yml
vendored
Normal file
@@ -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
|
||||||
32
.github/workflows/on_release.yml
vendored
Normal file
32
.github/workflows/on_release.yml
vendored
Normal file
@@ -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
|
||||||
40
.github/workflows/release_notes.yml
vendored
Normal file
40
.github/workflows/release_notes.yml
vendored
Normal file
@@ -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 }}
|
||||||
106
.github/workflows/server-tests.yml
vendored
Normal file
106
.github/workflows/server-tests.yml
vendored
Normal file
@@ -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 }}
|
||||||
181
.github/workflows/ui-tests.yml
vendored
Normal file
181
.github/workflows/ui-tests.yml
vendored
Normal file
@@ -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
|
||||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -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
|
||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "frappe-ui"]
|
||||||
|
path = frappe-ui
|
||||||
|
url = https://github.com/frappe/frappe-ui
|
||||||
67
.mergify.yml
Normal file
67
.mergify.yml
Normal file
@@ -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
|
||||||
70
.pre-commit-config.yaml
Normal file
70
.pre-commit-config.yaml
Normal file
@@ -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
|
||||||
21
.releaserc
Normal file
21
.releaserc
Normal file
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
661
LICENSE
Normal file
661
LICENSE
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
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.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
205
README.md
Normal file
205
README.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<div align="center" markdown="1">
|
||||||
|
|
||||||
|
<a href="https://frappe.io/products/crm">
|
||||||
|
<img src=".github/logo.svg" height="80" alt="Frappe CRM Logo">
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<h1>Frappe CRM</h1>
|
||||||
|
|
||||||
|
**Simplify Sales, Amplify Relationships**
|
||||||
|
|
||||||
|
[](https://github.com/frappe/crm/releases)
|
||||||
|
[](https://github.com/frappe/crm/actions/workflows/frontend-tests.yml)
|
||||||
|
[](https://github.com/frappe/crm/actions/workflows/migration-test.yml)
|
||||||
|
[](https://github.com/frappe/crm/actions/workflows/server-tests.yml)
|
||||||
|
<div>
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset=".github/screenshots/FrappeCRMHeroImage.png">
|
||||||
|
<img width="1402" alt="Frappe CRM Hero Image" src=".github/screenshots/FrappeCRMHeroImage.png">
|
||||||
|
</picture>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
[Live Demo](https://frappecrm-demo.frappe.cloud/#login) - [Website](https://frappe.io/crm) - [Documentation](https://docs.frappe.io/crm)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Screenshots</summary>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset=".github/screenshots/LeadList.png">
|
||||||
|
<img width="1402" alt="Lead List" src=".github/screenshots/LeadList.png">
|
||||||
|
</picture>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset=".github/screenshots/LeadPage.png">
|
||||||
|
<img width="1402" alt="Lead Page" src=".github/screenshots/LeadPage.png">
|
||||||
|
</picture>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset=".github/screenshots/EmailTemplate.png">
|
||||||
|
<img width="1402" alt="Email Template" src=".github/screenshots/EmailTemplate.png">
|
||||||
|
</picture>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset=".github/screenshots/CallUI.png">
|
||||||
|
<img width="1402" alt="Call UI" src=".github/screenshots/CallUI.png">
|
||||||
|
</picture>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset=".github/screenshots/CallLog.png">
|
||||||
|
<img width="1402" alt="Call Log" src=".github/screenshots/CallLog.png">
|
||||||
|
</picture>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
<div>
|
||||||
|
<a href="https://frappecloud.com/crm/signup" target="_blank">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://frappe.io/files/try-on-fc-white.png">
|
||||||
|
<img src="https://frappe.io/files/try-on-fc-black.png" alt="Try on Frappe Cloud" height="28" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<div align="center" style="padding-top: 0.75rem;">
|
||||||
|
<a href="https://frappe.io" target="_blank">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://frappe.io/files/Frappe-white.png">
|
||||||
|
<img src="https://frappe.io/files/Frappe-black.png" alt="Frappe Technologies" height="28"/>
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
23
codecov.yml
Normal file
23
codecov.yml
Normal file
@@ -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
|
||||||
27
commitlint.config.cjs
Normal file
27
commitlint.config.cjs
Normal file
@@ -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
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
2
crm/__init__.py
Normal file
2
crm/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
__version__ = "2.0.0-dev"
|
||||||
|
__title__ = "Frappe CRM"
|
||||||
36
crm/activation.py
Normal file
36
crm/activation.py
Normal file
@@ -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}
|
||||||
179
crm/api/__init__.py
Normal file
179
crm/api/__init__.py
Normal file
@@ -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'<br><p class="signature">{signature}</p>'
|
||||||
|
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),
|
||||||
|
}
|
||||||
536
crm/api/activities.py
Normal file
536
crm/api/activities.py
Normal file
@@ -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()
|
||||||
32
crm/api/assignment_rule.py
Normal file
32
crm/api/assignment_rule.py
Normal file
@@ -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
|
||||||
39
crm/api/auth.py
Normal file
39
crm/api/auth.py
Normal file
@@ -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"<img src='{provider.icon}' alt={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
|
||||||
128
crm/api/comment.py
Normal file
128
crm/api/comment.py
Normal file
@@ -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"""
|
||||||
|
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||||
|
<span class="font-medium text-ink-gray-9">{owner}</span>
|
||||||
|
<span>{_("mentioned you in {0}").format(doctype)}</span>
|
||||||
|
<span class="font-medium text-ink-gray-9">{name}</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
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)
|
||||||
145
crm/api/contact.py
Normal file
145
crm/api/contact.py
Normal file
@@ -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
|
||||||
1323
crm/api/dashboard.py
Normal file
1323
crm/api/dashboard.py
Normal file
File diff suppressed because it is too large
Load Diff
857
crm/api/doc.py
Normal file
857
crm/api/doc.py
Normal file
@@ -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"
|
||||||
372
crm/api/event.py
Normal file
372
crm/api/event.py
Normal file
@@ -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"""
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px;">
|
||||||
|
<h2 style="color: #333;">Event Reminder</h2>
|
||||||
|
<p>This is a reminder for your upcoming event:</p>
|
||||||
|
<div style="background-color: #f8f9fa; padding: 15px; border-left: 4px solid #007bff; margin: 20px 0;">
|
||||||
|
<h3 style="margin: 0; color: #007bff;">{notification.subject}</h3>
|
||||||
|
{f'<p style="margin: 10px 0; color: #666;">{notification.description}</p>' if notification.description else ""}
|
||||||
|
<p style="margin: 5px 0;"><strong>Start Time:</strong> {event_start.strftime("%Y-%m-%d %H:%M:%S")}</p>
|
||||||
|
<p style="margin: 5px 0;"><strong>Time Remaining:</strong> {time_remaining_text}</p>
|
||||||
|
</div>
|
||||||
|
<p style="color: #666; font-size: 12px;">This is an automated reminder from your calendar system.</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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)
|
||||||
158
crm/api/exchange_rate.py
Normal file
158
crm/api/exchange_rate.py
Normal file
@@ -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)
|
||||||
|
)
|
||||||
638
crm/api/form.py
Normal file
638
crm/api/form.py
Normal file
@@ -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/<route>; 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 /<route> — an additional,
|
||||||
|
# unbranded public URL alongside /crm-form/<route>.)
|
||||||
|
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)
|
||||||
34
crm/api/live_demo.py
Normal file
34
crm/api/live_demo.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
69
crm/api/notifications.py
Normal file
69
crm/api/notifications.py
Normal file
@@ -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
|
||||||
24
crm/api/onboarding.py
Normal file
24
crm/api/onboarding.py
Normal file
@@ -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
|
||||||
171
crm/api/session.py
Normal file
171
crm/api/session.py
Normal file
@@ -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
|
||||||
99
crm/api/settings.py
Normal file
99
crm/api/settings.py
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
144
crm/api/todo.py
Normal file
144
crm/api/todo.py
Normal file
@@ -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"""
|
||||||
|
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||||
|
<span>{ _('Your assignment on {0} {1} has been removed by {2}').format(
|
||||||
|
doctype,
|
||||||
|
f'<span class="font-medium text-ink-gray-9">{ name }</span>',
|
||||||
|
f'<span class="font-medium text-ink-gray-9">{ owner }</span>'
|
||||||
|
) }</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||||
|
<span class="font-medium text-ink-gray-9">{ owner }</span>
|
||||||
|
<span>{ _('assigned a {0} {1} to you').format(
|
||||||
|
doctype,
|
||||||
|
f'<span class="font-medium text-ink-gray-9">{ name }</span>'
|
||||||
|
) }</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if doctype == "task":
|
||||||
|
if is_cancelled:
|
||||||
|
return f"""
|
||||||
|
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||||
|
<span>{ _('Your assignment on task {0} has been removed by {1}').format(
|
||||||
|
f'<span class="font-medium text-ink-gray-9">{ reference_doc.title }</span>',
|
||||||
|
f'<span class="font-medium text-ink-gray-9">{ owner }</span>'
|
||||||
|
) }</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
return f"""
|
||||||
|
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||||
|
<span class="font-medium text-ink-gray-9">{ owner }</span>
|
||||||
|
<span>{ _('assigned a new task {0} to you').format(
|
||||||
|
f'<span class="font-medium text-ink-gray-9">{ reference_doc.title }</span>'
|
||||||
|
) }</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
238
crm/api/user.py
Normal file
238
crm/api/user.py
Normal file
@@ -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)
|
||||||
16
crm/api/views.py
Normal file
16
crm/api/views.py
Normal file
@@ -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
|
||||||
387
crm/api/whatsapp.py
Normal file
387
crm/api/whatsapp.py
Normal file
@@ -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"""
|
||||||
|
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||||
|
<span class="font-medium text-ink-gray-9">{_("You")}</span>
|
||||||
|
<span>{_("received a whatsapp message in {0}").format(doctype)}</span>
|
||||||
|
<span class="font-medium text-ink-gray-9">{safe_reference_name}</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
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)
|
||||||
0
crm/config/__init__.py
Normal file
0
crm/config/__init__.py
Normal file
8
crm/crowdin.yml
Normal file
8
crm/crowdin.yml
Normal file
@@ -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
|
||||||
0
crm/demo/__init__.py
Normal file
0
crm/demo/__init__.py
Normal file
191
crm/demo/activities.py
Normal file
191
crm/demo/activities.py
Normal file
@@ -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": (
|
||||||
|
"<p>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.</p>"
|
||||||
|
),
|
||||||
|
"days_ago": 56, # lead[0] deal created at 50 days ago — keep before that
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lead": lead_names[1],
|
||||||
|
"owner": owner_1,
|
||||||
|
"content": (
|
||||||
|
"<p>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.</p>"
|
||||||
|
),
|
||||||
|
"days_ago": 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lead": lead_names[2],
|
||||||
|
"owner": owner_2,
|
||||||
|
"content": (
|
||||||
|
"<p>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.</p>"
|
||||||
|
),
|
||||||
|
"days_ago": 11,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lead": lead_names[3],
|
||||||
|
"owner": owner_1,
|
||||||
|
"content": (
|
||||||
|
"<p>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.</p>"
|
||||||
|
),
|
||||||
|
"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": (
|
||||||
|
"<p>Hi Alice,</p>"
|
||||||
|
"<p>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.</p>"
|
||||||
|
"<p>Please let me know if you have any questions before we meet.</p>"
|
||||||
|
"<p>Best regards</p>"
|
||||||
|
),
|
||||||
|
"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": (
|
||||||
|
"<p>Hi,</p>"
|
||||||
|
"<p>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?</p>"
|
||||||
|
"<p>Best,<br>Bob Martinez<br>CEO, Globex Industries</p>"
|
||||||
|
),
|
||||||
|
"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": (
|
||||||
|
"<p>Hi David,</p>"
|
||||||
|
"<p>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.</p>"
|
||||||
|
"<p>Happy to jump on a call if you have questions.</p>"
|
||||||
|
"<p>Cheers</p>"
|
||||||
|
),
|
||||||
|
"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)
|
||||||
91
crm/demo/api.py
Normal file
91
crm/demo/api.py
Normal file
@@ -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))}
|
||||||
122
crm/demo/call_logs.py
Normal file
122
crm/demo/call_logs.py
Normal file
@@ -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)
|
||||||
348
crm/demo/deals.py
Normal file
348
crm/demo/deals.py
Normal file
@@ -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": (
|
||||||
|
"<p>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.</p>"
|
||||||
|
),
|
||||||
|
"days_ago": 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"deal": deal_names[1], # David / TechStart Inc
|
||||||
|
"owner": owner_1,
|
||||||
|
"content": (
|
||||||
|
"<p>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.</p>"
|
||||||
|
),
|
||||||
|
"days_ago": 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"deal": deal_names[2], # Henry / PivotTech Solutions
|
||||||
|
"owner": owner_2,
|
||||||
|
"content": (
|
||||||
|
"<p>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.</p>"
|
||||||
|
),
|
||||||
|
"days_ago": 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"deal": deal_names[3], # Iris / ScaleUp Labs
|
||||||
|
"owner": session_user,
|
||||||
|
"content": (
|
||||||
|
"<p>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.</p>"
|
||||||
|
),
|
||||||
|
"days_ago": 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"deal": deal_names[4], # Jack / Meridian Systems
|
||||||
|
"owner": owner_1,
|
||||||
|
"content": (
|
||||||
|
"<p>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!</p>"
|
||||||
|
),
|
||||||
|
"days_ago": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"deal": deal_names[5], # Karen / Vertex Analytics
|
||||||
|
"owner": owner_2,
|
||||||
|
"content": (
|
||||||
|
"<p>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.</p>"
|
||||||
|
),
|
||||||
|
"days_ago": 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"deal": deal_names[6], # Leo / Forge Digital
|
||||||
|
"owner": session_user,
|
||||||
|
"content": (
|
||||||
|
"<p>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.</p>"
|
||||||
|
),
|
||||||
|
"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": (
|
||||||
|
"<p>Hi Alice,</p>"
|
||||||
|
"<p>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.</p>"
|
||||||
|
"<p>Our onboarding specialist Sarah will also be available for a walkthrough "
|
||||||
|
"session this week — just let us know a convenient time.</p>"
|
||||||
|
"<p>Best regards</p>"
|
||||||
|
),
|
||||||
|
"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": (
|
||||||
|
"<p>Hi,</p>"
|
||||||
|
"<p>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.</p>"
|
||||||
|
"<p>Also, is there flexibility on the contract start date?</p>"
|
||||||
|
"<p>Thanks,<br>David Lee<br>CTO, TechStart Inc</p>"
|
||||||
|
),
|
||||||
|
"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)
|
||||||
253
crm/demo/leads.py
Normal file
253
crm/demo/leads.py
Normal file
@@ -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)
|
||||||
86
crm/demo/notes.py
Normal file
86
crm/demo/notes.py
Normal file
@@ -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": "<p>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.</p>",
|
||||||
|
"reference_doctype": "CRM Lead",
|
||||||
|
"reference_docname": lead_names[0],
|
||||||
|
"owner": session_user,
|
||||||
|
"days_ago": 56,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Pain Points Identified",
|
||||||
|
"content": "<p>Current system lacks reporting and integrations. Team size is growing fast — 40% headcount increase this year. Decision timeline is 6–8 weeks.</p>",
|
||||||
|
"reference_doctype": "CRM Lead",
|
||||||
|
"reference_docname": lead_names[0],
|
||||||
|
"owner": session_user,
|
||||||
|
"days_ago": 51,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Demo Feedback",
|
||||||
|
"content": "<p>Very positive response to the pipeline view. Asked about custom fields and API access. Needs sign-off from CFO before moving forward.</p>",
|
||||||
|
"reference_doctype": "CRM Lead",
|
||||||
|
"reference_docname": lead_names[1],
|
||||||
|
"owner": owner_1,
|
||||||
|
"days_ago": 46,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Competitor Mentioned",
|
||||||
|
"content": "<p>Currently trialing a competitor. Our differentiators: better UX, open source, and lower TCO. Send a comparison doc before next call.</p>",
|
||||||
|
"reference_doctype": "CRM Lead",
|
||||||
|
"reference_docname": lead_names[1],
|
||||||
|
"owner": owner_1,
|
||||||
|
"days_ago": 39,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Requirements Gathered",
|
||||||
|
"content": "<p>Needs CRM + email integration + team collaboration. Currently using spreadsheets. Strong motivation to move to a structured system this quarter.</p>",
|
||||||
|
"reference_doctype": "CRM Lead",
|
||||||
|
"reference_docname": lead_names[2],
|
||||||
|
"owner": owner_2,
|
||||||
|
"days_ago": 44,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Pricing Discussion",
|
||||||
|
"content": "<p>Walked through pricing tiers. They prefer an annual plan. Requested a 2-week trial extension. Champion is very engaged — internal buy-in looks strong.</p>",
|
||||||
|
"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)
|
||||||
96
crm/demo/tasks.py
Normal file
96
crm/demo/tasks.py
Normal file
@@ -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)
|
||||||
89
crm/demo/users.py
Normal file
89
crm/demo/users.py
Normal file
@@ -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()
|
||||||
117
crm/demo/utils.py
Normal file
117
crm/demo/utils.py
Normal file
@@ -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
|
||||||
316
crm/domain_enrichment/README.md
Normal file
316
crm/domain_enrichment/README.md
Normal file
@@ -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 = <your 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("<Type>")`. 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 `<head>` 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 `<link rel=icon>` — 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 <site> run-tests --app crm --module crm.domain_enrichment.tests.test_extractors
|
||||||
|
bench --site <site> run-tests --app crm --module crm.domain_enrichment.tests.test_integration
|
||||||
|
```
|
||||||
9
crm/domain_enrichment/__init__.py
Normal file
9
crm/domain_enrichment/__init__.py
Normal file
@@ -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.
|
||||||
|
"""
|
||||||
88
crm/domain_enrichment/api.py
Normal file
88
crm/domain_enrichment/api.py
Normal file
@@ -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)
|
||||||
261
crm/domain_enrichment/config.py
Normal file
261
crm/domain_enrichment/config.py
Normal file
@@ -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()
|
||||||
449
crm/domain_enrichment/crawler.py
Normal file
449
crm/domain_enrichment/crawler.py
Normal file
@@ -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 <loc> 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 "<!doctype" in xml_text.lower():
|
||||||
|
return None, []
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(xml_text)
|
||||||
|
except ET.ParseError:
|
||||||
|
return None, []
|
||||||
|
kind = _local_tag(root.tag)
|
||||||
|
if kind not in ("urlset", "sitemapindex"):
|
||||||
|
return None, []
|
||||||
|
child_tag = "sitemap" if kind == "sitemapindex" else "url"
|
||||||
|
locs = []
|
||||||
|
for el in root:
|
||||||
|
if _local_tag(el.tag) != child_tag:
|
||||||
|
continue
|
||||||
|
for child in el:
|
||||||
|
if _local_tag(child.tag) == "loc" and child.text:
|
||||||
|
locs.append(child.text.strip())
|
||||||
|
break
|
||||||
|
return kind, locs
|
||||||
|
|
||||||
|
|
||||||
|
def discover_sitemap_urls(start_url, cfg, session, robots):
|
||||||
|
"""Falls back from robots.txt ``Sitemap:`` to ``/sitemap.xml``, bounded by
|
||||||
|
``SITEMAP_MAX_SITEMAPS``/``SITEMAP_MAX_URLS``."""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(normalize_url(start_url))
|
||||||
|
if not parsed.netloc:
|
||||||
|
return []
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
if robots is not None:
|
||||||
|
try:
|
||||||
|
candidates.extend(robots.site_maps() or [])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not candidates:
|
||||||
|
candidates.append(f"{parsed.scheme}://{parsed.netloc}/sitemap.xml")
|
||||||
|
|
||||||
|
to_fetch = deque(candidates[:SITEMAP_MAX_SITEMAPS])
|
||||||
|
sitemaps_queued = len(to_fetch) # running total: seed + discovered children
|
||||||
|
fetched_sitemaps = set()
|
||||||
|
urls = []
|
||||||
|
seen_urls = set()
|
||||||
|
|
||||||
|
while to_fetch and len(fetched_sitemaps) < SITEMAP_MAX_SITEMAPS and len(urls) < SITEMAP_MAX_URLS:
|
||||||
|
sitemap_url = to_fetch.popleft()
|
||||||
|
if sitemap_url in fetched_sitemaps:
|
||||||
|
continue
|
||||||
|
fetched_sitemaps.add(sitemap_url)
|
||||||
|
if not same_site(sitemap_url, parsed.netloc):
|
||||||
|
continue
|
||||||
|
|
||||||
|
status, body, error, _final = fetch(sitemap_url, cfg, session=session, html_only=False)
|
||||||
|
if error or status != 200 or not body:
|
||||||
|
continue
|
||||||
|
|
||||||
|
kind, locs = _parse_sitemap(body)
|
||||||
|
|
||||||
|
if kind == "sitemapindex":
|
||||||
|
for loc in locs:
|
||||||
|
if sitemaps_queued >= 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
|
||||||
112
crm/domain_enrichment/cross_record.py
Normal file
112
crm/domain_enrichment/cross_record.py
Normal file
@@ -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 []
|
||||||
0
crm/domain_enrichment/doctype/__init__.py
Normal file
0
crm/domain_enrichment/doctype/__init__.py
Normal file
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
// });
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user