odoo-pulse 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- odoo_pulse-1.0.0/.env.example +33 -0
- odoo_pulse-1.0.0/.github/workflows/ci.yml +31 -0
- odoo_pulse-1.0.0/.github/workflows/playground.yml +21 -0
- odoo_pulse-1.0.0/.github/workflows/release.yml +53 -0
- odoo_pulse-1.0.0/.gitignore +27 -0
- odoo_pulse-1.0.0/CLAUDE.md +50 -0
- odoo_pulse-1.0.0/LICENSE +21 -0
- odoo_pulse-1.0.0/Makefile +13 -0
- odoo_pulse-1.0.0/PKG-INFO +120 -0
- odoo_pulse-1.0.0/README.md +91 -0
- odoo_pulse-1.0.0/assets/business_pulse.svg +137 -0
- odoo_pulse-1.0.0/assets/og-card.png +0 -0
- odoo_pulse-1.0.0/docker/seed/seed.py +426 -0
- odoo_pulse-1.0.0/docker-compose.playground.yml +58 -0
- odoo_pulse-1.0.0/docs/install.md +91 -0
- odoo_pulse-1.0.0/docs/playground.md +68 -0
- odoo_pulse-1.0.0/docs/tools.md +242 -0
- odoo_pulse-1.0.0/llms-install.md +65 -0
- odoo_pulse-1.0.0/odoo_pulse/__init__.py +3 -0
- odoo_pulse-1.0.0/odoo_pulse/cache.py +45 -0
- odoo_pulse-1.0.0/odoo_pulse/domain_tools.py +516 -0
- odoo_pulse-1.0.0/odoo_pulse/odoo_client.py +437 -0
- odoo_pulse-1.0.0/odoo_pulse/runtime.py +77 -0
- odoo_pulse-1.0.0/odoo_pulse/server.py +36 -0
- odoo_pulse-1.0.0/odoo_pulse/tool_groups.py +57 -0
- odoo_pulse-1.0.0/odoo_pulse/tools_engagement.py +164 -0
- odoo_pulse-1.0.0/odoo_pulse/tools_generic.py +220 -0
- odoo_pulse-1.0.0/odoo_pulse/tools_hr.py +215 -0
- odoo_pulse-1.0.0/odoo_pulse/tools_niche.py +545 -0
- odoo_pulse-1.0.0/odoo_pulse/tools_operations.py +268 -0
- odoo_pulse-1.0.0/odoo_pulse/tools_projects.py +149 -0
- odoo_pulse-1.0.0/odoo_pulse/tools_reports.py +892 -0
- odoo_pulse-1.0.0/odoo_pulse/tools_workflows.py +751 -0
- odoo_pulse-1.0.0/odoo_pulse/tools_write.py +191 -0
- odoo_pulse-1.0.0/odoo_pulse/workflow_helpers.py +108 -0
- odoo_pulse-1.0.0/pyproject.toml +58 -0
- odoo_pulse-1.0.0/requirements.txt +1 -0
- odoo_pulse-1.0.0/scripts/demo_pulse.py +99 -0
- odoo_pulse-1.0.0/scripts/make_og.py +96 -0
- odoo_pulse-1.0.0/scripts/playground_smoke.sh +54 -0
- odoo_pulse-1.0.0/scripts/smoke_live.py +219 -0
- odoo_pulse-1.0.0/server.json +55 -0
- odoo_pulse-1.0.0/tests/conftest.py +167 -0
- odoo_pulse-1.0.0/tests/test_cache.py +41 -0
- odoo_pulse-1.0.0/tests/test_client.py +296 -0
- odoo_pulse-1.0.0/tests/test_client_aggregate.py +69 -0
- odoo_pulse-1.0.0/tests/test_client_cache.py +46 -0
- odoo_pulse-1.0.0/tests/test_config.py +133 -0
- odoo_pulse-1.0.0/tests/test_domain_tools.py +127 -0
- odoo_pulse-1.0.0/tests/test_runtime.py +72 -0
- odoo_pulse-1.0.0/tests/test_tool_groups.py +40 -0
- odoo_pulse-1.0.0/tests/test_tools_aggregate.py +49 -0
- odoo_pulse-1.0.0/tests/test_tools_attachment.py +63 -0
- odoo_pulse-1.0.0/tests/test_tools_project_status_report.py +246 -0
- odoo_pulse-1.0.0/tests/test_tools_projects.py +71 -0
- odoo_pulse-1.0.0/tests/test_tools_reports_absence.py +81 -0
- odoo_pulse-1.0.0/tests/test_tools_reports_inventory.py +85 -0
- odoo_pulse-1.0.0/tests/test_tools_reports_pipeline.py +111 -0
- odoo_pulse-1.0.0/tests/test_tools_reports_pulse.py +79 -0
- odoo_pulse-1.0.0/tests/test_tools_reports_receivables.py +78 -0
- odoo_pulse-1.0.0/tests/test_tools_reports_sales.py +98 -0
- odoo_pulse-1.0.0/tests/test_tools_smoke.py +109 -0
- odoo_pulse-1.0.0/tests/test_tools_sprint_health.py +161 -0
- odoo_pulse-1.0.0/tests/test_tools_team_workload.py +155 -0
- odoo_pulse-1.0.0/tests/test_tools_write.py +172 -0
- odoo_pulse-1.0.0/tests/test_workflow_helpers.py +67 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Odoo connection (XML-RPC external API)
|
|
2
|
+
# For Odoo Online (SaaS) the URL is https://<your-db>.odoo.com
|
|
3
|
+
ODOO_URL=https://your-instance.odoo.com
|
|
4
|
+
ODOO_DB=your-database-name
|
|
5
|
+
ODOO_USERNAME=you@example.com
|
|
6
|
+
# Generate under: Settings > Users > (your user) > Account Security > New API Key
|
|
7
|
+
ODOO_API_KEY=your-api-key
|
|
8
|
+
|
|
9
|
+
# Safety controls
|
|
10
|
+
# Keep true to block create/write/unlink. Set to false to allow writes later.
|
|
11
|
+
ODOO_READ_ONLY=true
|
|
12
|
+
# Hard cap on records returned by a single search_read call.
|
|
13
|
+
ODOO_MAX_RECORDS=200
|
|
14
|
+
# Verify the server's TLS certificate. Set to false only for on-premise
|
|
15
|
+
# instances using a self-signed / private-CA certificate.
|
|
16
|
+
ODOO_VERIFY_SSL=true
|
|
17
|
+
# Socket timeout (seconds) for each XML-RPC call, so a hung Odoo can't
|
|
18
|
+
# block a tool call forever.
|
|
19
|
+
ODOO_TIMEOUT=30
|
|
20
|
+
|
|
21
|
+
# --- Write access (all default to "no writes") ---
|
|
22
|
+
# Master switch must be false before any write is possible.
|
|
23
|
+
# (ODOO_READ_ONLY above must be set to false.)
|
|
24
|
+
# Comma-separated allow-list of models that may be written. Empty = none.
|
|
25
|
+
# System models (ir.*, res.users, base*, ...) are blocked even if listed here.
|
|
26
|
+
ODOO_WRITABLE_MODELS=
|
|
27
|
+
# Allow record deletion (unlink). Stays blocked unless set to true.
|
|
28
|
+
ODOO_ALLOW_DELETE=false
|
|
29
|
+
|
|
30
|
+
# Which tool groups the server exposes (comma-separated).
|
|
31
|
+
# Default: core,reports (~20 tools). Groups: core, reports, business, hr,
|
|
32
|
+
# projects, operations, engagement, niche — or "all".
|
|
33
|
+
#ODOO_TOOL_GROUPS=core,reports
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: ["**"]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
20
|
+
uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
cache: pip
|
|
24
|
+
|
|
25
|
+
- name: Install dependencies
|
|
26
|
+
run: |
|
|
27
|
+
python -m pip install --upgrade pip
|
|
28
|
+
pip install -e ".[dev]"
|
|
29
|
+
|
|
30
|
+
- name: Run tests
|
|
31
|
+
run: pytest -q
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: playground-smoke
|
|
2
|
+
|
|
3
|
+
# Manual + nightly only — never on PRs, so normal CI stays fast.
|
|
4
|
+
on:
|
|
5
|
+
workflow_dispatch:
|
|
6
|
+
schedule:
|
|
7
|
+
- cron: "0 3 * * *" # 03:00 UTC daily
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
smoke:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
timeout-minutes: 25
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
- name: Install odoo-pulse
|
|
19
|
+
run: pip install -e .
|
|
20
|
+
- name: Run playground smoke
|
|
21
|
+
run: ./scripts/playground_smoke.sh
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
# Build and publish to PyPI when a version tag is pushed.
|
|
4
|
+
# Publishing uses PyPI Trusted Publishing (OIDC) — no API token is stored.
|
|
5
|
+
#
|
|
6
|
+
# One-time setup (owner, on PyPI):
|
|
7
|
+
# PyPI > your project > Publishing > Add a trusted publisher (GitHub Actions)
|
|
8
|
+
# Owner: minhhq-a1
|
|
9
|
+
# Repository: odoo-pulse
|
|
10
|
+
# Workflow name: release.yml
|
|
11
|
+
# Environment name: pypi
|
|
12
|
+
#
|
|
13
|
+
# Then: `git tag v1.0.0 && git push origin v1.0.0` publishes automatically.
|
|
14
|
+
|
|
15
|
+
on:
|
|
16
|
+
push:
|
|
17
|
+
tags:
|
|
18
|
+
- "v*"
|
|
19
|
+
|
|
20
|
+
permissions:
|
|
21
|
+
contents: read
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
build:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
- uses: actions/setup-python@v5
|
|
29
|
+
with:
|
|
30
|
+
python-version: "3.12"
|
|
31
|
+
- name: Build sdist and wheel
|
|
32
|
+
run: |
|
|
33
|
+
python -m pip install --upgrade build twine
|
|
34
|
+
python -m build
|
|
35
|
+
python -m twine check dist/*
|
|
36
|
+
- uses: actions/upload-artifact@v4
|
|
37
|
+
with:
|
|
38
|
+
name: dist
|
|
39
|
+
path: dist/
|
|
40
|
+
|
|
41
|
+
publish:
|
|
42
|
+
needs: build
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
environment: pypi
|
|
45
|
+
permissions:
|
|
46
|
+
id-token: write # required for OIDC trusted publishing
|
|
47
|
+
steps:
|
|
48
|
+
- uses: actions/download-artifact@v4
|
|
49
|
+
with:
|
|
50
|
+
name: dist
|
|
51
|
+
path: dist/
|
|
52
|
+
- name: Publish to PyPI
|
|
53
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# macOS
|
|
2
|
+
.DS_Store
|
|
3
|
+
|
|
4
|
+
# Secrets
|
|
5
|
+
.env
|
|
6
|
+
|
|
7
|
+
# Python
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.py[cod]
|
|
10
|
+
*.egg-info/
|
|
11
|
+
.eggs/
|
|
12
|
+
build/
|
|
13
|
+
dist/
|
|
14
|
+
.venv/
|
|
15
|
+
venv/
|
|
16
|
+
env/
|
|
17
|
+
|
|
18
|
+
# Tooling
|
|
19
|
+
.pytest_cache/
|
|
20
|
+
.mypy_cache/
|
|
21
|
+
.ruff_cache/
|
|
22
|
+
|
|
23
|
+
# Local design docs / plans (internal); shipped docs are re-included below
|
|
24
|
+
docs/*
|
|
25
|
+
!docs/playground.md
|
|
26
|
+
!docs/install.md
|
|
27
|
+
!docs/tools.md
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Install with dev dependencies
|
|
9
|
+
pip install -e ".[dev]"
|
|
10
|
+
|
|
11
|
+
# Run tests (no live Odoo needed)
|
|
12
|
+
pytest
|
|
13
|
+
|
|
14
|
+
# Run a single test file
|
|
15
|
+
pytest tests/test_client.py
|
|
16
|
+
|
|
17
|
+
# Run a single test by name
|
|
18
|
+
pytest tests/test_tools_write.py::test_create_record_preview
|
|
19
|
+
|
|
20
|
+
# Live smoke test against a real Odoo instance (reads only, never writes)
|
|
21
|
+
python scripts/smoke_live.py
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Architecture
|
|
25
|
+
|
|
26
|
+
This is an MCP server that exposes Odoo's XML-RPC external API as MCP tools. The entry point is `odoo_pulse/server.py`, which imports all tool modules as side effects — each import registers `@mcp.tool()` functions with the shared FastMCP instance.
|
|
27
|
+
|
|
28
|
+
**Module responsibilities:**
|
|
29
|
+
|
|
30
|
+
- `runtime.py` — shared singleton: holds the `mcp` (FastMCP) instance, the lazy `OdooClient` (created on first tool call), and shared helpers used by every tool: `safe()` (runs a lambda and serialises result/error to JSON), `name_domain()`, `date_domain()`, `preview()` (dry-run struct).
|
|
31
|
+
- `odoo_client.py` — thin XML-RPC wrapper. `OdooConfig.from_env()` reads all env vars. `OdooClient._check_write()` enforces the four-layer write safety guard. All XML-RPC faults become `OdooError`. `fields_get` results are cached via a process-local TTL+LRU cache (`cache.py`); `aggregate_records` dispatches between `read_group` (Odoo ≤18) and `formatted_read_group` (19+) based on `major_version()`.
|
|
32
|
+
- `tools_generic.py` — model-agnostic tools: `search_read`, `search_count`, `read_records`, `get_model_fields`, `list_models`, `odoo_version`, `aggregate_records`, `read_attachment`.
|
|
33
|
+
- `tools_write.py` — write tools (`create_record`, `update_records`, `delete_records`) plus domain-specific helpers (`create_lead`, `create_contact`, `create_task`, `confirm_sale_order`). Every tool returns a dry-run preview unless `confirm=True`.
|
|
34
|
+
- `workflow_helpers.py` — shared building blocks for composed workflow tools: `today_in_tz`, `parse_deadline`, archived-aware `resolve_user_names`, and `build_report` (the standard report envelope: `tool`, `as_of`, tool-specific keys, `summary`, `breakdown`, `highlights`, `risks`). Used by `tools_workflows.py` and `standup_digest`.
|
|
35
|
+
- `tools_workflows.py` — composed, opinionated workflow tools that answer a business question in one call (e.g. `sprint_health`, `team_workload`, `project_status_report`, `standup_digest`). Read-only; compose `search_read`/aggregates server-side and return the `build_report` envelope.
|
|
36
|
+
- `tools_reports.py` — cross-department report tools (`pipeline_review`, `sales_snapshot`, `receivables_health`, `inventory_risk`, `absence_overview`, `business_pulse`). Same envelope and composition style as `tools_workflows.py`.
|
|
37
|
+
- `tool_groups.py` — maps `ODOO_TOOL_GROUPS` (default `core,reports`) to the tool modules `server.py` imports; unknown group names fail at startup.
|
|
38
|
+
- `domain_tools.py`, `tools_hr.py`, `tools_projects.py`, `tools_operations.py`, `tools_engagement.py`, `tools_niche.py` — domain-specific read tools wrapping `search_read` with hard-coded fields and domains for common Odoo models.
|
|
39
|
+
|
|
40
|
+
**Write safety chain** (all four must pass for any write to execute):
|
|
41
|
+
1. `ODOO_READ_ONLY=false` — master switch (default: `true`, blocking all writes)
|
|
42
|
+
2. `ODOO_WRITABLE_MODELS` — comma-separated allow-list; model must be in it
|
|
43
|
+
3. `ODOO_ALLOW_DELETE=true` — required for `delete_records` (default: `false`)
|
|
44
|
+
4. `confirm=True` on the tool call — all write tools return a preview struct by default
|
|
45
|
+
|
|
46
|
+
System models (`ir.*`, `base*`, `res.users`, `res.groups`, etc.) are permanently blocked regardless of `ODOO_WRITABLE_MODELS`.
|
|
47
|
+
|
|
48
|
+
**Testing pattern:** Tests inject a `FakeClient` directly into `runtime._client` (see `conftest.py`). The fake records every call in `fake_client.calls` and returns canned data from `search_responses`/`read_responses` dicts. No real Odoo or network is needed. Tests assert on the model name and domain that a tool built, not on Odoo's actual response.
|
|
49
|
+
|
|
50
|
+
**Adding a new tool module:** Create `odoo_pulse/tools_foo.py`, import `mcp` and `get_client` from `.runtime`, decorate functions with `@mcp.tool()`, then add the module to a group in `tool_groups.GROUP_MODULES` (server.py imports modules per enabled group).
|
odoo_pulse-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Minh Hong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
COMPOSE = docker compose -f docker-compose.playground.yml
|
|
2
|
+
|
|
3
|
+
.PHONY: playground playground-reset playground-smoke
|
|
4
|
+
|
|
5
|
+
playground: ## Boot Odoo + seed the demo story
|
|
6
|
+
$(COMPOSE) up -d
|
|
7
|
+
$(COMPOSE) logs -f seed
|
|
8
|
+
|
|
9
|
+
playground-reset: ## Wipe the playground (drops the database)
|
|
10
|
+
$(COMPOSE) down -v
|
|
11
|
+
|
|
12
|
+
playground-smoke: ## End-to-end: boot, seed, assert reports, tear down
|
|
13
|
+
./scripts/playground_smoke.sh
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: odoo-pulse
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: AI business analyst for Odoo ERP — one-call reports with verdicts, over MCP
|
|
5
|
+
Project-URL: Homepage, https://github.com/minhhq-a1/odoo-pulse
|
|
6
|
+
Project-URL: Repository, https://github.com/minhhq-a1/odoo-pulse
|
|
7
|
+
Project-URL: Issues, https://github.com/minhhq-a1/odoo-pulse/issues
|
|
8
|
+
Author-email: Minh Hong <minhhq@arrowhitech.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai,analytics,business-intelligence,claude,erp,mcp,model-context-protocol,odoo,xml-rpc
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Information Technology
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Office/Business
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: mcp[cli]>=1.2.0
|
|
25
|
+
Requires-Dist: python-dotenv>=1.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# odoo-pulse
|
|
31
|
+
|
|
32
|
+
[](https://github.com/minhhq-a1/odoo-pulse/actions/workflows/ci.yml)
|
|
33
|
+
[](https://pypi.org/project/odoo-pulse/)
|
|
34
|
+
[](LICENSE)
|
|
35
|
+
|
|
36
|
+
**An AI business analyst for your [Odoo](https://www.odoo.com) ERP.** Ask one
|
|
37
|
+
question, get one answer — numbers, highlights, risks, and a verdict
|
|
38
|
+
(on-track / at-risk / off-track) — over the [Model Context
|
|
39
|
+
Protocol](https://modelcontextprotocol.io). CRUD bridges to Odoo already exist;
|
|
40
|
+
this is the analytics layer that sits on top.
|
|
41
|
+
|
|
42
|
+

|
|
43
|
+
|
|
44
|
+
## The analyst tools
|
|
45
|
+
|
|
46
|
+
Each tool answers a whole management question in a single call, returning a
|
|
47
|
+
structured report with a verdict — not a raw dump you have to interpret.
|
|
48
|
+
|
|
49
|
+
| Tool | Answers |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `business_pulse` ⭐ | The morning briefing: yesterday's sales, new leads, overdue invoices, late tasks, who's off — with a company-wide verdict |
|
|
52
|
+
| `pipeline_review` | CRM funnel by stage, stalled deals, weighted revenue, recent win rate |
|
|
53
|
+
| `sales_snapshot` | Revenue this period vs last (Δ%), top customers/products, stale quotations |
|
|
54
|
+
| `receivables_health` | AR/AP aging buckets, % overdue, top debtors |
|
|
55
|
+
| `inventory_risk` | Shortages (negative forecast) and dead stock |
|
|
56
|
+
| `absence_overview` | Who's off this week, pending approvals, thin-coverage departments |
|
|
57
|
+
| `sprint_health` · `team_workload` · `project_status_report` | Project delivery: completion, overloaded members, at-risk projects |
|
|
58
|
+
|
|
59
|
+
Under the hood it's the standard Odoo XML-RPC external API — nothing to install
|
|
60
|
+
inside Odoo, works on Odoo Online, Odoo.sh, and on-premise.
|
|
61
|
+
|
|
62
|
+
## Try it in 5 minutes
|
|
63
|
+
|
|
64
|
+
No Odoo account? Boot a demo Odoo pre-seeded with a story to tell (a stalled
|
|
65
|
+
deal, a 90-day-overdue invoice, a stock shortage, someone off today):
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
docker compose -f docker-compose.playground.yml up -d
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Then point Claude at it and ask it to **`run business_pulse`**. Full
|
|
72
|
+
walkthrough: [docs/playground.md](docs/playground.md).
|
|
73
|
+
|
|
74
|
+
## Install & connect
|
|
75
|
+
|
|
76
|
+
Add it to Claude Code (no install step — `uvx` fetches it):
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
claude mcp add odoo-pulse \
|
|
80
|
+
--env ODOO_URL=https://acme.odoo.com \
|
|
81
|
+
--env ODOO_DB=acme \
|
|
82
|
+
--env ODOO_USERNAME=you@example.com \
|
|
83
|
+
--env ODOO_API_KEY=your-api-key \
|
|
84
|
+
--env ODOO_READ_ONLY=true \
|
|
85
|
+
-- uvx odoo-pulse
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Generate the API key in Odoo under **Settings → Users → (your user) → Account
|
|
89
|
+
Security → New API Key**. Config for **Claude Desktop** and **Cursor**, plus pip
|
|
90
|
+
and Docker alternatives: [docs/install.md](docs/install.md).
|
|
91
|
+
|
|
92
|
+
## Read-only by default, safe writes when you want them
|
|
93
|
+
|
|
94
|
+
The server is read-only out of the box. Writes require four independent controls
|
|
95
|
+
to line up (`ODOO_READ_ONLY=false`, a model allow-list, a delete flag, and a
|
|
96
|
+
per-call `confirm=true` after a dry-run preview); system models are never
|
|
97
|
+
writable. Details: [docs/tools.md#write-operations](docs/tools.md#write-operations).
|
|
98
|
+
|
|
99
|
+
## More tools
|
|
100
|
+
|
|
101
|
+
Beyond the analyst reports, there are ~60 model-aware query tools spanning CRM,
|
|
102
|
+
Sales, Inventory, Accounting, HR, Project, Manufacturing, PoS, and Enterprise
|
|
103
|
+
apps — opt in via `ODOO_TOOL_GROUPS`. Full catalogue and configuration:
|
|
104
|
+
[docs/tools.md](docs/tools.md).
|
|
105
|
+
|
|
106
|
+
## Testing
|
|
107
|
+
|
|
108
|
+
The suite mocks the XML-RPC layer, so **no real Odoo or network is needed**:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pip install -e ".[dev]"
|
|
112
|
+
pytest
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
For a live check against a real Odoo (read-only), see
|
|
116
|
+
[docs/tools.md#live-smoke-test-against-a-real-odoo](docs/tools.md#live-smoke-test-against-a-real-odoo).
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# odoo-pulse
|
|
2
|
+
|
|
3
|
+
[](https://github.com/minhhq-a1/odoo-pulse/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/odoo-pulse/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
**An AI business analyst for your [Odoo](https://www.odoo.com) ERP.** Ask one
|
|
8
|
+
question, get one answer — numbers, highlights, risks, and a verdict
|
|
9
|
+
(on-track / at-risk / off-track) — over the [Model Context
|
|
10
|
+
Protocol](https://modelcontextprotocol.io). CRUD bridges to Odoo already exist;
|
|
11
|
+
this is the analytics layer that sits on top.
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
## The analyst tools
|
|
16
|
+
|
|
17
|
+
Each tool answers a whole management question in a single call, returning a
|
|
18
|
+
structured report with a verdict — not a raw dump you have to interpret.
|
|
19
|
+
|
|
20
|
+
| Tool | Answers |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `business_pulse` ⭐ | The morning briefing: yesterday's sales, new leads, overdue invoices, late tasks, who's off — with a company-wide verdict |
|
|
23
|
+
| `pipeline_review` | CRM funnel by stage, stalled deals, weighted revenue, recent win rate |
|
|
24
|
+
| `sales_snapshot` | Revenue this period vs last (Δ%), top customers/products, stale quotations |
|
|
25
|
+
| `receivables_health` | AR/AP aging buckets, % overdue, top debtors |
|
|
26
|
+
| `inventory_risk` | Shortages (negative forecast) and dead stock |
|
|
27
|
+
| `absence_overview` | Who's off this week, pending approvals, thin-coverage departments |
|
|
28
|
+
| `sprint_health` · `team_workload` · `project_status_report` | Project delivery: completion, overloaded members, at-risk projects |
|
|
29
|
+
|
|
30
|
+
Under the hood it's the standard Odoo XML-RPC external API — nothing to install
|
|
31
|
+
inside Odoo, works on Odoo Online, Odoo.sh, and on-premise.
|
|
32
|
+
|
|
33
|
+
## Try it in 5 minutes
|
|
34
|
+
|
|
35
|
+
No Odoo account? Boot a demo Odoo pre-seeded with a story to tell (a stalled
|
|
36
|
+
deal, a 90-day-overdue invoice, a stock shortage, someone off today):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
docker compose -f docker-compose.playground.yml up -d
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then point Claude at it and ask it to **`run business_pulse`**. Full
|
|
43
|
+
walkthrough: [docs/playground.md](docs/playground.md).
|
|
44
|
+
|
|
45
|
+
## Install & connect
|
|
46
|
+
|
|
47
|
+
Add it to Claude Code (no install step — `uvx` fetches it):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
claude mcp add odoo-pulse \
|
|
51
|
+
--env ODOO_URL=https://acme.odoo.com \
|
|
52
|
+
--env ODOO_DB=acme \
|
|
53
|
+
--env ODOO_USERNAME=you@example.com \
|
|
54
|
+
--env ODOO_API_KEY=your-api-key \
|
|
55
|
+
--env ODOO_READ_ONLY=true \
|
|
56
|
+
-- uvx odoo-pulse
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Generate the API key in Odoo under **Settings → Users → (your user) → Account
|
|
60
|
+
Security → New API Key**. Config for **Claude Desktop** and **Cursor**, plus pip
|
|
61
|
+
and Docker alternatives: [docs/install.md](docs/install.md).
|
|
62
|
+
|
|
63
|
+
## Read-only by default, safe writes when you want them
|
|
64
|
+
|
|
65
|
+
The server is read-only out of the box. Writes require four independent controls
|
|
66
|
+
to line up (`ODOO_READ_ONLY=false`, a model allow-list, a delete flag, and a
|
|
67
|
+
per-call `confirm=true` after a dry-run preview); system models are never
|
|
68
|
+
writable. Details: [docs/tools.md#write-operations](docs/tools.md#write-operations).
|
|
69
|
+
|
|
70
|
+
## More tools
|
|
71
|
+
|
|
72
|
+
Beyond the analyst reports, there are ~60 model-aware query tools spanning CRM,
|
|
73
|
+
Sales, Inventory, Accounting, HR, Project, Manufacturing, PoS, and Enterprise
|
|
74
|
+
apps — opt in via `ODOO_TOOL_GROUPS`. Full catalogue and configuration:
|
|
75
|
+
[docs/tools.md](docs/tools.md).
|
|
76
|
+
|
|
77
|
+
## Testing
|
|
78
|
+
|
|
79
|
+
The suite mocks the XML-RPC layer, so **no real Odoo or network is needed**:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install -e ".[dev]"
|
|
83
|
+
pytest
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
For a live check against a real Odoo (read-only), see
|
|
87
|
+
[docs/tools.md#live-smoke-test-against-a-real-odoo](docs/tools.md#live-smoke-test-against-a-real-odoo).
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
<svg class="rich-terminal" viewBox="0 0 970 489.2" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<!-- Generated with Rich https://www.textualize.io -->
|
|
3
|
+
<style>
|
|
4
|
+
|
|
5
|
+
@font-face {
|
|
6
|
+
font-family: "Fira Code";
|
|
7
|
+
src: local("FiraCode-Regular"),
|
|
8
|
+
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
|
9
|
+
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
|
10
|
+
font-style: normal;
|
|
11
|
+
font-weight: 400;
|
|
12
|
+
}
|
|
13
|
+
@font-face {
|
|
14
|
+
font-family: "Fira Code";
|
|
15
|
+
src: local("FiraCode-Bold"),
|
|
16
|
+
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
|
17
|
+
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
|
18
|
+
font-style: bold;
|
|
19
|
+
font-weight: 700;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.terminal-571639065-matrix {
|
|
23
|
+
font-family: Fira Code, monospace;
|
|
24
|
+
font-size: 20px;
|
|
25
|
+
line-height: 24.4px;
|
|
26
|
+
font-variant-east-asian: full-width;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.terminal-571639065-title {
|
|
30
|
+
font-size: 18px;
|
|
31
|
+
font-weight: bold;
|
|
32
|
+
font-family: arial;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.terminal-571639065-r1 { fill: #c5c8c6 }
|
|
36
|
+
.terminal-571639065-r2 { fill: #6b7280 }
|
|
37
|
+
.terminal-571639065-r3 { fill: #6b7280;font-weight: bold }
|
|
38
|
+
.terminal-571639065-r4 { fill: #d1d5db }
|
|
39
|
+
.terminal-571639065-r5 { fill: #8b5cf6 }
|
|
40
|
+
.terminal-571639065-r6 { fill: #8b5cf6;font-weight: bold }
|
|
41
|
+
.terminal-571639065-r7 { fill: #e5e7eb;font-weight: bold }
|
|
42
|
+
.terminal-571639065-r8 { fill: #9ca3af }
|
|
43
|
+
.terminal-571639065-r9 { fill: #22c55e }
|
|
44
|
+
.terminal-571639065-r10 { fill: #e5e7eb }
|
|
45
|
+
.terminal-571639065-r11 { fill: #f59e0b;font-weight: bold }
|
|
46
|
+
.terminal-571639065-r12 { fill: #f59e0b }
|
|
47
|
+
</style>
|
|
48
|
+
|
|
49
|
+
<defs>
|
|
50
|
+
<clipPath id="terminal-571639065-clip-terminal">
|
|
51
|
+
<rect x="0" y="0" width="950.5999999999999" height="438.2" />
|
|
52
|
+
</clipPath>
|
|
53
|
+
<clipPath id="terminal-571639065-line-0">
|
|
54
|
+
<rect x="0" y="1.5" width="951.6" height="24.65"/>
|
|
55
|
+
</clipPath>
|
|
56
|
+
<clipPath id="terminal-571639065-line-1">
|
|
57
|
+
<rect x="0" y="25.9" width="951.6" height="24.65"/>
|
|
58
|
+
</clipPath>
|
|
59
|
+
<clipPath id="terminal-571639065-line-2">
|
|
60
|
+
<rect x="0" y="50.3" width="951.6" height="24.65"/>
|
|
61
|
+
</clipPath>
|
|
62
|
+
<clipPath id="terminal-571639065-line-3">
|
|
63
|
+
<rect x="0" y="74.7" width="951.6" height="24.65"/>
|
|
64
|
+
</clipPath>
|
|
65
|
+
<clipPath id="terminal-571639065-line-4">
|
|
66
|
+
<rect x="0" y="99.1" width="951.6" height="24.65"/>
|
|
67
|
+
</clipPath>
|
|
68
|
+
<clipPath id="terminal-571639065-line-5">
|
|
69
|
+
<rect x="0" y="123.5" width="951.6" height="24.65"/>
|
|
70
|
+
</clipPath>
|
|
71
|
+
<clipPath id="terminal-571639065-line-6">
|
|
72
|
+
<rect x="0" y="147.9" width="951.6" height="24.65"/>
|
|
73
|
+
</clipPath>
|
|
74
|
+
<clipPath id="terminal-571639065-line-7">
|
|
75
|
+
<rect x="0" y="172.3" width="951.6" height="24.65"/>
|
|
76
|
+
</clipPath>
|
|
77
|
+
<clipPath id="terminal-571639065-line-8">
|
|
78
|
+
<rect x="0" y="196.7" width="951.6" height="24.65"/>
|
|
79
|
+
</clipPath>
|
|
80
|
+
<clipPath id="terminal-571639065-line-9">
|
|
81
|
+
<rect x="0" y="221.1" width="951.6" height="24.65"/>
|
|
82
|
+
</clipPath>
|
|
83
|
+
<clipPath id="terminal-571639065-line-10">
|
|
84
|
+
<rect x="0" y="245.5" width="951.6" height="24.65"/>
|
|
85
|
+
</clipPath>
|
|
86
|
+
<clipPath id="terminal-571639065-line-11">
|
|
87
|
+
<rect x="0" y="269.9" width="951.6" height="24.65"/>
|
|
88
|
+
</clipPath>
|
|
89
|
+
<clipPath id="terminal-571639065-line-12">
|
|
90
|
+
<rect x="0" y="294.3" width="951.6" height="24.65"/>
|
|
91
|
+
</clipPath>
|
|
92
|
+
<clipPath id="terminal-571639065-line-13">
|
|
93
|
+
<rect x="0" y="318.7" width="951.6" height="24.65"/>
|
|
94
|
+
</clipPath>
|
|
95
|
+
<clipPath id="terminal-571639065-line-14">
|
|
96
|
+
<rect x="0" y="343.1" width="951.6" height="24.65"/>
|
|
97
|
+
</clipPath>
|
|
98
|
+
<clipPath id="terminal-571639065-line-15">
|
|
99
|
+
<rect x="0" y="367.5" width="951.6" height="24.65"/>
|
|
100
|
+
</clipPath>
|
|
101
|
+
<clipPath id="terminal-571639065-line-16">
|
|
102
|
+
<rect x="0" y="391.9" width="951.6" height="24.65"/>
|
|
103
|
+
</clipPath>
|
|
104
|
+
</defs>
|
|
105
|
+
|
|
106
|
+
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="968" height="487.2" rx="8"/><text class="terminal-571639065-title" fill="#c5c8c6" text-anchor="middle" x="484" y="27">odoo-pulse · business_pulse</text>
|
|
107
|
+
<g transform="translate(26,22)">
|
|
108
|
+
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
|
109
|
+
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
|
110
|
+
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
|
111
|
+
</g>
|
|
112
|
+
|
|
113
|
+
<g transform="translate(9, 41)" clip-path="url(#terminal-571639065-clip-terminal)">
|
|
114
|
+
|
|
115
|
+
<g class="terminal-571639065-matrix">
|
|
116
|
+
<text class="terminal-571639065-r1" x="951.6" y="20" textLength="12.2" clip-path="url(#terminal-571639065-line-0)">
|
|
117
|
+
</text><text class="terminal-571639065-r2" x="12.2" y="44.4" textLength="24.4" clip-path="url(#terminal-571639065-line-1)">▎ </text><text class="terminal-571639065-r3" x="36.6" y="44.4" textLength="36.6" clip-path="url(#terminal-571639065-line-1)">You</text><text class="terminal-571639065-r1" x="951.6" y="44.4" textLength="12.2" clip-path="url(#terminal-571639065-line-1)">
|
|
118
|
+
</text><text class="terminal-571639065-r4" x="12.2" y="68.8" textLength="646.6" clip-path="url(#terminal-571639065-line-2)">  Run business_pulse — how's the company doing today?</text><text class="terminal-571639065-r1" x="951.6" y="68.8" textLength="12.2" clip-path="url(#terminal-571639065-line-2)">
|
|
119
|
+
</text><text class="terminal-571639065-r1" x="951.6" y="93.2" textLength="12.2" clip-path="url(#terminal-571639065-line-3)">
|
|
120
|
+
</text><text class="terminal-571639065-r5" x="12.2" y="117.6" textLength="24.4" clip-path="url(#terminal-571639065-line-4)">▎ </text><text class="terminal-571639065-r6" x="36.6" y="117.6" textLength="73.2" clip-path="url(#terminal-571639065-line-4)">Claude</text><text class="terminal-571639065-r1" x="951.6" y="117.6" textLength="12.2" clip-path="url(#terminal-571639065-line-4)">
|
|
121
|
+
</text><text class="terminal-571639065-r1" x="951.6" y="142" textLength="12.2" clip-path="url(#terminal-571639065-line-5)">
|
|
122
|
+
</text><text class="terminal-571639065-r7" x="12.2" y="166.4" textLength="219.6" clip-path="url(#terminal-571639065-line-6)">  📊 Business Pulse</text><text class="terminal-571639065-r8" x="244" y="166.4" textLength="256.2" clip-path="url(#terminal-571639065-line-6)">  ·  as of 2026-07-03</text><text class="terminal-571639065-r1" x="951.6" y="166.4" textLength="12.2" clip-path="url(#terminal-571639065-line-6)">
|
|
123
|
+
</text><text class="terminal-571639065-r1" x="951.6" y="190.8" textLength="12.2" clip-path="url(#terminal-571639065-line-7)">
|
|
124
|
+
</text><text class="terminal-571639065-r9" x="12.2" y="215.2" textLength="48.8" clip-path="url(#terminal-571639065-line-8)">  • </text><text class="terminal-571639065-r10" x="61" y="215.2" textLength="451.4" clip-path="url(#terminal-571639065-line-8)">yesterday: 1 order(s), revenue 6900.0</text><text class="terminal-571639065-r1" x="951.6" y="215.2" textLength="12.2" clip-path="url(#terminal-571639065-line-8)">
|
|
125
|
+
</text><text class="terminal-571639065-r9" x="12.2" y="239.6" textLength="48.8" clip-path="url(#terminal-571639065-line-9)">  • </text><text class="terminal-571639065-r10" x="61" y="239.6" textLength="280.6" clip-path="url(#terminal-571639065-line-9)">1 new lead(s) yesterday</text><text class="terminal-571639065-r1" x="951.6" y="239.6" textLength="12.2" clip-path="url(#terminal-571639065-line-9)">
|
|
126
|
+
</text><text class="terminal-571639065-r9" x="12.2" y="264" textLength="48.8" clip-path="url(#terminal-571639065-line-10)">  • </text><text class="terminal-571639065-r10" x="61" y="264" textLength="219.6" clip-path="url(#terminal-571639065-line-10)">2 people off today</text><text class="terminal-571639065-r1" x="951.6" y="264" textLength="12.2" clip-path="url(#terminal-571639065-line-10)">
|
|
127
|
+
</text><text class="terminal-571639065-r1" x="951.6" y="288.4" textLength="12.2" clip-path="url(#terminal-571639065-line-11)">
|
|
128
|
+
</text><text class="terminal-571639065-r11" x="12.2" y="312.8" textLength="207.4" clip-path="url(#terminal-571639065-line-12)">  Needs attention</text><text class="terminal-571639065-r1" x="951.6" y="312.8" textLength="12.2" clip-path="url(#terminal-571639065-line-12)">
|
|
129
|
+
</text><text class="terminal-571639065-r12" x="12.2" y="337.2" textLength="48.8" clip-path="url(#terminal-571639065-line-13)">  ⚠ </text><text class="terminal-571639065-r10" x="61" y="337.2" textLength="610" clip-path="url(#terminal-571639065-line-13)">2 customer invoice(s) overdue, 13225.0 outstanding</text><text class="terminal-571639065-r1" x="951.6" y="337.2" textLength="12.2" clip-path="url(#terminal-571639065-line-13)">
|
|
130
|
+
</text><text class="terminal-571639065-r12" x="12.2" y="361.6" textLength="48.8" clip-path="url(#terminal-571639065-line-14)">  ⚠ </text><text class="terminal-571639065-r10" x="61" y="361.6" textLength="280.6" clip-path="url(#terminal-571639065-line-14)">2 task(s) past deadline</text><text class="terminal-571639065-r1" x="951.6" y="361.6" textLength="12.2" clip-path="url(#terminal-571639065-line-14)">
|
|
131
|
+
</text><text class="terminal-571639065-r1" x="951.6" y="386" textLength="12.2" clip-path="url(#terminal-571639065-line-15)">
|
|
132
|
+
</text><text class="terminal-571639065-r8" x="12.2" y="410.4" textLength="134.2" clip-path="url(#terminal-571639065-line-16)">  Verdict: </text><text class="terminal-571639065-r11" x="146.4" y="410.4" textLength="134.2" clip-path="url(#terminal-571639065-line-16)">⚠ ATTENTION</text><text class="terminal-571639065-r1" x="951.6" y="410.4" textLength="12.2" clip-path="url(#terminal-571639065-line-16)">
|
|
133
|
+
</text><text class="terminal-571639065-r1" x="951.6" y="434.8" textLength="12.2" clip-path="url(#terminal-571639065-line-17)">
|
|
134
|
+
</text>
|
|
135
|
+
</g>
|
|
136
|
+
</g>
|
|
137
|
+
</svg>
|
|
Binary file
|