odoo-fastapi-gateway 18.1.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_fastapi_gateway-18.1.0/PKG-INFO +14 -0
- odoo_fastapi_gateway-18.1.0/README.md +145 -0
- odoo_fastapi_gateway-18.1.0/pyproject.toml +27 -0
- odoo_fastapi_gateway-18.1.0/setup.cfg +4 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_api/__init__.py +29 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_api/app.py +35 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_api/core/__init__.py +0 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_api/core/config.py +65 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_api/core/connection.py +62 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_api/dependencies/__init__.py +0 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_api/dependencies/auth.py +147 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_api/dependencies/odoo_session.py +59 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_api/exceptions.py +35 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_api/models/__init__.py +0 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_api/models/base.py +11 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_api/models/request.py +95 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_api/routers/__init__.py +7 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_api/routers/auth.py +17 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_api/routers/health.py +8 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_api/routers/models.py +103 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_fastapi_gateway.egg-info/PKG-INFO +14 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_fastapi_gateway.egg-info/SOURCES.txt +23 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_fastapi_gateway.egg-info/dependency_links.txt +1 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_fastapi_gateway.egg-info/requires.txt +10 -0
- odoo_fastapi_gateway-18.1.0/src/odoo_fastapi_gateway.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: odoo-fastapi-gateway
|
|
3
|
+
Version: 18.1.0
|
|
4
|
+
Summary: FastAPI gateway library for Odoo XML-RPC integration
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: fastapi>=0.100.0
|
|
7
|
+
Requires-Dist: uvicorn>=0.20.0
|
|
8
|
+
Requires-Dist: python-jose[cryptography]>=3.3.0
|
|
9
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
10
|
+
Requires-Dist: cryptography>=41.0.0
|
|
11
|
+
Requires-Dist: pydantic-settings>=2.0.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
14
|
+
Requires-Dist: httpx>=0.24.0; extra == "dev"
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# odoo-fastapi-gateway
|
|
2
|
+
|
|
3
|
+
<p align="center"><em>A modular FastAPI library for integrating with Odoo via XML-RPC. Install as a package, plug in pre-built routers, and start building.</em></p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.11+-blue.svg" alt="Python 3.11+"></a>
|
|
7
|
+
<a href="https://fastapi.tiangolo.com"><img src="https://img.shields.io/badge/FastAPI-0.100+-009688.svg" alt="FastAPI"></a>
|
|
8
|
+
<a href="https://www.odoo.com/documentation/18.0/developer/reference/external_api.html"><img src="https://img.shields.io/badge/Odoo-18-714B67.svg" alt="Odoo 18"></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install odoo-fastapi-gateway
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from fastapi import FastAPI
|
|
21
|
+
from odoo_api import get_routers, register_exception_handlers
|
|
22
|
+
|
|
23
|
+
app = FastAPI()
|
|
24
|
+
register_exception_handlers(app)
|
|
25
|
+
|
|
26
|
+
for router in get_routers():
|
|
27
|
+
app.include_router(router)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uvicorn app:app --reload
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
That's it — you now have login, CRUD, and execute endpoints for any Odoo model.
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
- **Plug & play routers** — Health, auth, and full CRUD included out of the box
|
|
39
|
+
- **JWT + AES-256-CBC** — Passwords encrypted in tokens, never stored in plain text
|
|
40
|
+
- **Pydantic validation** — Type-safe request/response models with limit enforcement
|
|
41
|
+
- **Async XML-RPC** — All Odoo calls run in a thread pool, non-blocking
|
|
42
|
+
- **Extensible** — Add custom routers using the same auth dependency
|
|
43
|
+
|
|
44
|
+
## API Endpoints
|
|
45
|
+
|
|
46
|
+
| Method | Path | Auth | Description |
|
|
47
|
+
|--------|------|------|-------------|
|
|
48
|
+
| `GET` | `/api/health` | — | Health check |
|
|
49
|
+
| `POST` | `/api/login` | — | Authenticate with Odoo, returns JWT |
|
|
50
|
+
| `GET` | `/api/model/{model}/{record_id}` | Bearer | Read a single record |
|
|
51
|
+
| `POST` | `/api/models/search/{model}` | Bearer | Search/list records |
|
|
52
|
+
| `POST` | `/api/models/{model}` | Bearer | Create records |
|
|
53
|
+
| `PUT` | `/api/models/{model}` | Bearer | Update records |
|
|
54
|
+
| `DELETE` | `/api/models/{model}` | Bearer | Delete records |
|
|
55
|
+
| `POST` | `/api/models/{model}/execute/{func_name}` | Bearer | Call custom Odoo method |
|
|
56
|
+
|
|
57
|
+
> **Model name convention:** Use hyphens in URLs — converted to dots automatically.
|
|
58
|
+
> `/api/models/search/sale-order` → `sale.order`
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
Create a `.env` file (or set environment variables):
|
|
63
|
+
|
|
64
|
+
```ini
|
|
65
|
+
ODOO_HOST=http://localhost:8069
|
|
66
|
+
JWT_SECRET_KEY=<your-64-char-hex-key>
|
|
67
|
+
AES_SECRET_KEY=<your-64-char-hex-key>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Generate keys: `python3 -c "import secrets; print(secrets.token_hex(32))"`
|
|
71
|
+
|
|
72
|
+
| Variable | Default | Description |
|
|
73
|
+
|----------|---------|-------------|
|
|
74
|
+
| `ODOO_HOST` | `http://odoo:8069` | Odoo server URL |
|
|
75
|
+
| `JWT_SECRET_KEY` | — *(required)* | JWT signing key (min 32 chars) |
|
|
76
|
+
| `AES_SECRET_KEY` | — *(required)* | AES encryption key (min 32 chars) |
|
|
77
|
+
| `HASH_ALGORITHM` | `HS256` | JWT hash algorithm |
|
|
78
|
+
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `None` | Token TTL (`None` = no expiry) |
|
|
79
|
+
| `LIMIT_SEARCH_RECORDS` | `50` | Max records per search |
|
|
80
|
+
| `LIMIT_CREATE_RECORDS` | `50` | Max records per create |
|
|
81
|
+
| `RATE_LIMIT` | `100/minute` | Rate limit string |
|
|
82
|
+
|
|
83
|
+
## Installation
|
|
84
|
+
|
|
85
|
+
### From PyPI
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install odoo-fastapi-gateway
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Development (editable)
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
git clone https://github.com/vdx-vn/odoo-api.git
|
|
95
|
+
cd odoo-api && pip install -e .
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Dependencies
|
|
99
|
+
|
|
100
|
+
Installed automatically:
|
|
101
|
+
|
|
102
|
+
`fastapi` · `uvicorn` · `python-jose[cryptography]` · `cryptography` · `pydantic-settings` · `python-dotenv`
|
|
103
|
+
|
|
104
|
+
Optional (not included): `slowapi` (rate limiting) · `gunicorn` (production)
|
|
105
|
+
|
|
106
|
+
## Documentation
|
|
107
|
+
|
|
108
|
+
| Topic | Link |
|
|
109
|
+
|-------|------|
|
|
110
|
+
| Architecture diagrams | [docs/architecture.md](docs/architecture.md) |
|
|
111
|
+
| Public API reference | [docs/api-reference.md](docs/api-reference.md) |
|
|
112
|
+
| Extending the library | [docs/extending.md](docs/extending.md) |
|
|
113
|
+
| Security details | [docs/security.md](docs/security.md) |
|
|
114
|
+
| Docker deployment | [docs/docker.md](docs/docker.md) |
|
|
115
|
+
| Publishing to PyPI | [docs/publishing.md](docs/publishing.md) |
|
|
116
|
+
| Request body examples | [docs/request-examples.md](docs/request-examples.md) |
|
|
117
|
+
|
|
118
|
+
## Project Structure
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
src/odoo_api/
|
|
122
|
+
├── __init__.py # Public API exports
|
|
123
|
+
├── exceptions.py # APIException, UnauthorizedException, DecryptionError
|
|
124
|
+
├── core/
|
|
125
|
+
│ ├── config.py # AppSettings (pydantic-settings) + get_settings()
|
|
126
|
+
│ └── connection.py # call_xmlrpc, execute, sanitize_model
|
|
127
|
+
├── dependencies/
|
|
128
|
+
│ ├── auth.py # JWT creation, AES encrypt/decrypt, authenticate()
|
|
129
|
+
│ └── odoo_session.py # get_logged_in_user dependency, auth models
|
|
130
|
+
├── models/
|
|
131
|
+
│ ├── base.py # APIBaseModel (Pydantic base with settings access)
|
|
132
|
+
│ └── request.py # Search, Create, Update, Delete, ExecuteFunction
|
|
133
|
+
└── routers/
|
|
134
|
+
├── __init__.py # get_routers() helper
|
|
135
|
+
├── health.py # GET /api/health
|
|
136
|
+
├── auth.py # POST /api/login
|
|
137
|
+
└── models.py # CRUD + execute endpoints
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## References
|
|
141
|
+
|
|
142
|
+
- [FastAPI](https://fastapi.tiangolo.com/)
|
|
143
|
+
- [Odoo 18 External API](https://www.odoo.com/documentation/18.0/developer/reference/external_api.html)
|
|
144
|
+
- [SlowAPI](https://github.com/laurentS/slowapi)
|
|
145
|
+
- [Gunicorn](https://docs.gunicorn.org/en/stable/settings.html)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
|
|
3
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
4
|
+
build-backend = "setuptools.build_meta"
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "odoo-fastapi-gateway"
|
|
8
|
+
version = "18.1.0"
|
|
9
|
+
description = "FastAPI gateway library for Odoo XML-RPC integration"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"fastapi>=0.100.0",
|
|
13
|
+
"uvicorn>=0.20.0",
|
|
14
|
+
"python-jose[cryptography]>=3.3.0",
|
|
15
|
+
"python-dotenv>=1.0.0",
|
|
16
|
+
"cryptography>=41.0.0",
|
|
17
|
+
"pydantic-settings>=2.0.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=7.0",
|
|
23
|
+
"httpx>=0.24.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from odoo_api.core.config import AppSettings, get_settings
|
|
2
|
+
from odoo_api.core.connection import execute, sanitize_model, call_xmlrpc
|
|
3
|
+
from odoo_api.dependencies.odoo_session import (
|
|
4
|
+
AuthenticatedUser, Token, AuthenticationBody, get_logged_in_user
|
|
5
|
+
)
|
|
6
|
+
from odoo_api.dependencies.auth import authenticate, create_access_token
|
|
7
|
+
from odoo_api.models.request import (
|
|
8
|
+
SearchDataModel, CreateDataModel, UpdateDataModel,
|
|
9
|
+
DeleteDataModel, ExecuteFunctionModel
|
|
10
|
+
)
|
|
11
|
+
from odoo_api.exceptions import (
|
|
12
|
+
APIException, UnauthorizedException, DecryptionError,
|
|
13
|
+
register_exception_handlers,
|
|
14
|
+
)
|
|
15
|
+
from odoo_api.routers import get_routers
|
|
16
|
+
from odoo_api.app import create_app
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AppSettings", "get_settings",
|
|
20
|
+
"execute", "sanitize_model", "call_xmlrpc",
|
|
21
|
+
"AuthenticatedUser", "Token", "AuthenticationBody", "get_logged_in_user",
|
|
22
|
+
"authenticate", "create_access_token",
|
|
23
|
+
"SearchDataModel", "CreateDataModel", "UpdateDataModel",
|
|
24
|
+
"DeleteDataModel", "ExecuteFunctionModel",
|
|
25
|
+
"APIException", "UnauthorizedException", "DecryptionError",
|
|
26
|
+
"register_exception_handlers",
|
|
27
|
+
"get_routers",
|
|
28
|
+
"create_app",
|
|
29
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
|
|
5
|
+
from odoo_api.routers import get_routers
|
|
6
|
+
from odoo_api.exceptions import register_exception_handlers
|
|
7
|
+
|
|
8
|
+
OPENAPI_TAGS = [
|
|
9
|
+
{"name": "Health", "description": "Server health check"},
|
|
10
|
+
{"name": "Authentication", "description": "Login and token management"},
|
|
11
|
+
{"name": "Models", "description": "CRUD operations and function execution on Odoo models"},
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_app(
|
|
16
|
+
title: str = "Odoo FastAPI Gateway",
|
|
17
|
+
description: str = "REST API gateway for Odoo ERP via XML-RPC.",
|
|
18
|
+
version: str = "0.1.0",
|
|
19
|
+
**kwargs,
|
|
20
|
+
) -> FastAPI:
|
|
21
|
+
"""Create a FastAPI app pre-configured with Odoo gateway routers, exception handlers, and OpenAPI docs."""
|
|
22
|
+
app = FastAPI(
|
|
23
|
+
title=title,
|
|
24
|
+
description=description,
|
|
25
|
+
version=version,
|
|
26
|
+
openapi_tags=OPENAPI_TAGS,
|
|
27
|
+
**kwargs,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
register_exception_handlers(app)
|
|
31
|
+
|
|
32
|
+
for router in get_routers():
|
|
33
|
+
app.include_router(router)
|
|
34
|
+
|
|
35
|
+
return app
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import field_validator
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AppSettings(BaseSettings):
|
|
12
|
+
odoo_host: str = 'http://odoo:8069'
|
|
13
|
+
jwt_secret_key: str
|
|
14
|
+
aes_secret_key: str
|
|
15
|
+
hash_algorithm: str = 'HS256'
|
|
16
|
+
rate_limit: str = '100/minute'
|
|
17
|
+
access_token_expire_minutes: Optional[int] = None
|
|
18
|
+
limit_search_records: Optional[int] = 50
|
|
19
|
+
limit_create_records: Optional[int] = 50
|
|
20
|
+
|
|
21
|
+
model_config = SettingsConfigDict(
|
|
22
|
+
extra='ignore',
|
|
23
|
+
env_file=('.env',),
|
|
24
|
+
env_file_encoding='utf-8',
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@field_validator('jwt_secret_key', 'aes_secret_key')
|
|
28
|
+
@classmethod
|
|
29
|
+
def validate_secret_keys(cls, v: str, info) -> str:
|
|
30
|
+
if len(v) < 32:
|
|
31
|
+
raise ValueError(f'{info.field_name} must be at least 32 characters')
|
|
32
|
+
return v
|
|
33
|
+
|
|
34
|
+
@field_validator('rate_limit')
|
|
35
|
+
@classmethod
|
|
36
|
+
def validate_rate_limit(cls, v: str) -> str:
|
|
37
|
+
if not re.match(r'^\d+/(second|minute|hour|day)$', v):
|
|
38
|
+
raise ValueError('rate_limit must match format like "100/minute"')
|
|
39
|
+
return v
|
|
40
|
+
|
|
41
|
+
@field_validator('access_token_expire_minutes')
|
|
42
|
+
@classmethod
|
|
43
|
+
def validate_expire_minutes(cls, v: Optional[int]) -> Optional[int]:
|
|
44
|
+
if v is not None and v <= 0:
|
|
45
|
+
raise ValueError('access_token_expire_minutes must be positive')
|
|
46
|
+
return v
|
|
47
|
+
|
|
48
|
+
@field_validator('limit_search_records', 'limit_create_records')
|
|
49
|
+
@classmethod
|
|
50
|
+
def validate_limits(cls, v: Optional[int], info) -> Optional[int]:
|
|
51
|
+
if v is not None and v <= 0:
|
|
52
|
+
raise ValueError(f'{info.field_name} must be positive')
|
|
53
|
+
return v
|
|
54
|
+
|
|
55
|
+
@field_validator('odoo_host')
|
|
56
|
+
@classmethod
|
|
57
|
+
def validate_odoo_host(cls, v: str) -> str:
|
|
58
|
+
if not re.match(r'^https?://', v):
|
|
59
|
+
raise ValueError('odoo_host must be a valid HTTP(S) URL')
|
|
60
|
+
return v
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@lru_cache()
|
|
64
|
+
def get_settings():
|
|
65
|
+
return AppSettings()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
import xmlrpc.client
|
|
6
|
+
|
|
7
|
+
from odoo_api.exceptions import APIException
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def call_xmlrpc(url: str, method: str, *args):
|
|
11
|
+
proxy = xmlrpc.client.ServerProxy(url)
|
|
12
|
+
return getattr(proxy, method)(*args)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_additional_context():
|
|
16
|
+
return {
|
|
17
|
+
"active_test": False
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def sanitize_error_message(err: Exception) -> str:
|
|
22
|
+
if hasattr(err, 'faultString'):
|
|
23
|
+
error_message = err.faultString
|
|
24
|
+
else:
|
|
25
|
+
error_message = str(err)
|
|
26
|
+
messages = re.findall(r'^\S.*(?=error|exception).*', error_message, re.IGNORECASE | re.M)
|
|
27
|
+
if not messages:
|
|
28
|
+
messages = [error_message]
|
|
29
|
+
return '\n'.join(messages)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def execute(model, func_name, args, kwargs, additional_context=False):
|
|
33
|
+
try:
|
|
34
|
+
uid = kwargs.pop('uid', '')
|
|
35
|
+
password = kwargs.pop('password', '')
|
|
36
|
+
host = kwargs.pop('host', '')
|
|
37
|
+
db = kwargs.pop('db', '')
|
|
38
|
+
url = '{}/xmlrpc/2/object'.format(host)
|
|
39
|
+
# add context for customized behavior
|
|
40
|
+
context = kwargs.pop('context', {})
|
|
41
|
+
if additional_context:
|
|
42
|
+
merged = get_additional_context()
|
|
43
|
+
merged.update(context)
|
|
44
|
+
context = merged
|
|
45
|
+
kwargs.update(dict(context=context))
|
|
46
|
+
loop = asyncio.get_running_loop()
|
|
47
|
+
data = await loop.run_in_executor(
|
|
48
|
+
None, lambda: call_xmlrpc(url, 'execute_kw', db, uid, password, model, func_name, args, kwargs)
|
|
49
|
+
)
|
|
50
|
+
return data
|
|
51
|
+
except Exception as e:
|
|
52
|
+
error_message = sanitize_error_message(e)
|
|
53
|
+
raise APIException(message=error_message)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def sanitize_model(model):
|
|
57
|
+
"""Use hyphen character to make RESTful API endpoint"""
|
|
58
|
+
model = model.replace('-', '.')
|
|
59
|
+
if not re.match(r'^[a-z][a-z0-9_.]+$', model):
|
|
60
|
+
raise APIException(message="Invalid model name: %s" % model)
|
|
61
|
+
return model
|
|
62
|
+
|
|
File without changes
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import base64
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
|
|
11
|
+
from cryptography.hazmat.backends import default_backend
|
|
12
|
+
from cryptography.hazmat.primitives import hashes
|
|
13
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
14
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
15
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
16
|
+
from jose import jwt
|
|
17
|
+
|
|
18
|
+
from odoo_api.core.connection import call_xmlrpc, sanitize_error_message
|
|
19
|
+
from odoo_api.core.config import get_settings
|
|
20
|
+
from odoo_api.exceptions import UnauthorizedException, DecryptionError
|
|
21
|
+
|
|
22
|
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_access_token(user_data):
|
|
26
|
+
settings = get_settings()
|
|
27
|
+
expire_minutes = settings.access_token_expire_minutes
|
|
28
|
+
user_password = user_data.get('password')
|
|
29
|
+
encrypted_user_password = encrypt(user_password, settings.aes_secret_key)
|
|
30
|
+
user_data.update({
|
|
31
|
+
"password": encrypted_user_password
|
|
32
|
+
})
|
|
33
|
+
if expire_minutes:
|
|
34
|
+
expire_datetime = datetime.now(UTC) + timedelta(minutes=expire_minutes)
|
|
35
|
+
user_data.update({
|
|
36
|
+
"exp": expire_datetime
|
|
37
|
+
})
|
|
38
|
+
return jwt.encode(user_data, settings.jwt_secret_key, algorithm=settings.hash_algorithm)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def authenticate(db, username, password):
|
|
42
|
+
"""Authenticate Odoo user with provided API key, using Odoo external API
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
db (str): Odoo database name
|
|
46
|
+
username (str): Odoo username
|
|
47
|
+
password (str): Odoo user API key (or user password)
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
str: Access token
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
host = get_settings().odoo_host
|
|
54
|
+
common_url = re.sub(r'(?<!:)/+', '/', f"{host}/xmlrpc/2/common")
|
|
55
|
+
loop = asyncio.get_running_loop()
|
|
56
|
+
uid = await loop.run_in_executor(
|
|
57
|
+
None, lambda: call_xmlrpc(common_url, 'authenticate', db, username, password, {})
|
|
58
|
+
)
|
|
59
|
+
if uid:
|
|
60
|
+
user_data = {
|
|
61
|
+
"host": host,
|
|
62
|
+
"db": db,
|
|
63
|
+
"uid": uid,
|
|
64
|
+
"password": password,
|
|
65
|
+
}
|
|
66
|
+
token = create_access_token(user_data)
|
|
67
|
+
return token
|
|
68
|
+
except Exception as e:
|
|
69
|
+
error_message = sanitize_error_message(e)
|
|
70
|
+
raise UnauthorizedException(message="Could not validate credentials: %s" % error_message)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ========== Encryption / Decryption ===============
|
|
74
|
+
|
|
75
|
+
@lru_cache(maxsize=256)
|
|
76
|
+
def generate_key(aes_secret_key: str, salt: bytes) -> bytes:
|
|
77
|
+
"""Derives a 32-byte key from an aes_secret_key and salt."""
|
|
78
|
+
kdf = PBKDF2HMAC(
|
|
79
|
+
algorithm=hashes.SHA256(),
|
|
80
|
+
length=32,
|
|
81
|
+
salt=salt,
|
|
82
|
+
iterations=100000,
|
|
83
|
+
backend=default_backend()
|
|
84
|
+
)
|
|
85
|
+
return kdf.derive(aes_secret_key.encode())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def encrypt(text: str, aes_secret_key: str) -> str:
|
|
89
|
+
"""
|
|
90
|
+
Encrypts a text string using AES-256.
|
|
91
|
+
Works with text of any length, including very short strings.
|
|
92
|
+
"""
|
|
93
|
+
# Even empty strings can be encrypted
|
|
94
|
+
text = text or ""
|
|
95
|
+
salt = os.urandom(16) # Generate a random salt
|
|
96
|
+
key = generate_key(aes_secret_key, salt)
|
|
97
|
+
iv = os.urandom(16) # Generate a random IV
|
|
98
|
+
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
|
|
99
|
+
encryptor = cipher.encryptor()
|
|
100
|
+
|
|
101
|
+
# Pad text to be a multiple of 16 bytes (AES block size) using PKCS#7
|
|
102
|
+
# For a very short text, this ensures it's at least 16 bytes after padding
|
|
103
|
+
padding_length = 16 - (len(text) % 16)
|
|
104
|
+
padded_text = text + chr(padding_length) * padding_length
|
|
105
|
+
|
|
106
|
+
ciphertext = encryptor.update(padded_text.encode()) + encryptor.finalize()
|
|
107
|
+
|
|
108
|
+
# For very short inputs, the final structure is still:
|
|
109
|
+
# 16 bytes (salt) + 16 bytes (IV) + at least 16 bytes (padded ciphertext)
|
|
110
|
+
return base64.b64encode(salt + iv + ciphertext).decode()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def decrypt(encrypted_text: str, aes_secret_key: str) -> str:
|
|
114
|
+
"""Decrypts a previously encrypted text string using AES-256."""
|
|
115
|
+
try:
|
|
116
|
+
# Decode the base64 string to raw bytes
|
|
117
|
+
encrypted_data = base64.b64decode(encrypted_text)
|
|
118
|
+
|
|
119
|
+
# Ensure we have enough data (at minimum: salt + iv + 16 bytes of content)
|
|
120
|
+
if len(encrypted_data) < 48:
|
|
121
|
+
raise DecryptionError("Encrypted data is too short")
|
|
122
|
+
|
|
123
|
+
# Extract components
|
|
124
|
+
salt, iv, ciphertext = encrypted_data[:16], encrypted_data[16:32], encrypted_data[32:]
|
|
125
|
+
key = generate_key(aes_secret_key, salt)
|
|
126
|
+
|
|
127
|
+
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
|
|
128
|
+
decryptor = cipher.decryptor()
|
|
129
|
+
decrypted_padded_text = decryptor.update(ciphertext) + decryptor.finalize()
|
|
130
|
+
|
|
131
|
+
# Validate and remove PKCS#7 padding
|
|
132
|
+
padding_length = decrypted_padded_text[-1]
|
|
133
|
+
|
|
134
|
+
# Check if padding_length is valid
|
|
135
|
+
if padding_length > 16 or padding_length < 1:
|
|
136
|
+
raise DecryptionError("Invalid padding")
|
|
137
|
+
|
|
138
|
+
# Verify the padding is consistent (all padding bytes should be equal to padding_length)
|
|
139
|
+
padding = decrypted_padded_text[-padding_length:]
|
|
140
|
+
if not all(p == padding_length for p in padding):
|
|
141
|
+
raise DecryptionError("Padding validation failed")
|
|
142
|
+
|
|
143
|
+
# Remove padding to get the original text (could be empty string)
|
|
144
|
+
return decrypted_padded_text[:-padding_length].decode('utf-8')
|
|
145
|
+
|
|
146
|
+
except Exception as e:
|
|
147
|
+
raise DecryptionError(f"Decryption failed: {str(e)}")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from operator import itemgetter
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends
|
|
6
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
7
|
+
from jose import jwt
|
|
8
|
+
from jose.exceptions import ExpiredSignatureError
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from odoo_api.core.connection import sanitize_error_message
|
|
12
|
+
from odoo_api.dependencies.auth import decrypt
|
|
13
|
+
from odoo_api.core.config import get_settings
|
|
14
|
+
from odoo_api.exceptions import UnauthorizedException, DecryptionError
|
|
15
|
+
|
|
16
|
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/login")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_logged_in_user(token: str = Depends(oauth2_scheme)):
|
|
20
|
+
try:
|
|
21
|
+
settings = get_settings()
|
|
22
|
+
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.hash_algorithm])
|
|
23
|
+
resp_db, resp_uid, resp_password = itemgetter("db", "uid", "password")(payload)
|
|
24
|
+
decrypted_password = decrypt(resp_password, settings.aes_secret_key)
|
|
25
|
+
return AuthenticatedUser(**{
|
|
26
|
+
"uid": resp_uid,
|
|
27
|
+
"password": decrypted_password,
|
|
28
|
+
"host": settings.odoo_host,
|
|
29
|
+
"db": resp_db
|
|
30
|
+
})
|
|
31
|
+
except ExpiredSignatureError:
|
|
32
|
+
raise UnauthorizedException(message="Access token has expired.")
|
|
33
|
+
except DecryptionError:
|
|
34
|
+
raise UnauthorizedException(message="Could not validate credentials")
|
|
35
|
+
except Exception as e:
|
|
36
|
+
error_message = sanitize_error_message(e)
|
|
37
|
+
raise UnauthorizedException(message="Could not validate credentials: %s" % error_message)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Token(BaseModel):
|
|
41
|
+
access_token: str = Field(..., description="JWT access token")
|
|
42
|
+
token_type: str = Field(..., description="Token type (bearer)")
|
|
43
|
+
|
|
44
|
+
model_config = {"json_schema_extra": {"examples": [{"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "bearer"}]}}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AuthenticationBody(BaseModel):
|
|
48
|
+
db: str = Field(..., description="Odoo database name")
|
|
49
|
+
username: str = Field(..., description="Odoo username")
|
|
50
|
+
password: str = Field(..., description="Odoo password")
|
|
51
|
+
|
|
52
|
+
model_config = {"json_schema_extra": {"examples": [{"db": "mydb", "username": "admin", "password": "admin"}]}}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AuthenticatedUser(BaseModel):
|
|
56
|
+
uid: int
|
|
57
|
+
password: str
|
|
58
|
+
host: str
|
|
59
|
+
db: str
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI, status
|
|
4
|
+
from fastapi.responses import JSONResponse
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class APIException(Exception):
|
|
8
|
+
def __init__(self, message: str):
|
|
9
|
+
self.message = message
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UnauthorizedException(Exception):
|
|
13
|
+
def __init__(self, message: str):
|
|
14
|
+
self.message = message
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DecryptionError(Exception):
|
|
18
|
+
def __init__(self, message: str):
|
|
19
|
+
self.message = message
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register_exception_handlers(app: FastAPI):
|
|
23
|
+
@app.exception_handler(APIException)
|
|
24
|
+
async def handle_api_error(request, exc):
|
|
25
|
+
return JSONResponse(
|
|
26
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
27
|
+
content={"message": exc.message, "data": None, "code": 0}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
@app.exception_handler(UnauthorizedException)
|
|
31
|
+
async def handle_auth_error(request, exc):
|
|
32
|
+
return JSONResponse(
|
|
33
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
34
|
+
content={"message": exc.message, "data": None, "code": 0}
|
|
35
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from typing import List, Union, Dict, Optional, Any
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, field_validator
|
|
6
|
+
|
|
7
|
+
from odoo_api.models.base import APIBaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SearchDataModel(APIBaseModel):
|
|
11
|
+
model_config = {"json_schema_extra": {"examples": [{"domain": [["name", "ilike", "test"]], "fields": ["id", "name", "email"], "offset": 0, "limit": 10, "order": "id desc", "context": {}}]}}
|
|
12
|
+
|
|
13
|
+
domain: List[Union[list, str]] = Field(default_factory=list, description="Odoo domain filter, e.g. [['name', 'ilike', 'test']]")
|
|
14
|
+
fields: List[str] = Field(default_factory=lambda: ['id'], description="List of field names to return")
|
|
15
|
+
offset: int = Field(default=0, description="Number of records to skip")
|
|
16
|
+
limit: int = Field(default=50, description="Maximum number of records to return")
|
|
17
|
+
order: str = Field(default='id', description="Sort order, e.g. 'id desc'")
|
|
18
|
+
context: Dict[str, Any] = Field(default_factory=dict, description="Optional Odoo context")
|
|
19
|
+
|
|
20
|
+
@field_validator('limit')
|
|
21
|
+
@classmethod
|
|
22
|
+
def check_limit(cls, v):
|
|
23
|
+
limit_search_records = cls.settings().limit_search_records
|
|
24
|
+
if v > limit_search_records:
|
|
25
|
+
raise ValueError("The maximum number of records given per request is %s" % limit_search_records)
|
|
26
|
+
return v
|
|
27
|
+
|
|
28
|
+
@field_validator('fields')
|
|
29
|
+
@classmethod
|
|
30
|
+
def check_fields(cls, v):
|
|
31
|
+
return v if v else ['id']
|
|
32
|
+
|
|
33
|
+
@field_validator('domain')
|
|
34
|
+
@classmethod
|
|
35
|
+
def validate_domain(cls, v):
|
|
36
|
+
for item in v:
|
|
37
|
+
if isinstance(item, str):
|
|
38
|
+
if item not in {'&', '|', '!'}:
|
|
39
|
+
raise ValueError(f"Domain string contains invalid characters: {item}")
|
|
40
|
+
else:
|
|
41
|
+
if len(item) != 3:
|
|
42
|
+
raise ValueError(f"Domain item must be a list of 3 elements: {item}")
|
|
43
|
+
return v
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CreateDataModel(APIBaseModel):
|
|
47
|
+
model_config = {"json_schema_extra": {"examples": [{"values": [{"name": "New Partner", "email": "partner@example.com"}], "context": {}}]}}
|
|
48
|
+
|
|
49
|
+
values: List[dict] = Field(..., description="List of record dicts to create")
|
|
50
|
+
context: Dict[str, Any] = Field(default_factory=dict, description="Optional Odoo context")
|
|
51
|
+
|
|
52
|
+
@field_validator('values')
|
|
53
|
+
@classmethod
|
|
54
|
+
def check_values(cls, v):
|
|
55
|
+
limit_create_records = cls.settings().limit_create_records
|
|
56
|
+
if len(v) > limit_create_records:
|
|
57
|
+
raise ValueError("The maximum number of records that can be created is %r" % limit_create_records)
|
|
58
|
+
return v
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class UpdateDataModel(APIBaseModel):
|
|
62
|
+
model_config = {"json_schema_extra": {"examples": [{"ids": [1, 2], "values": {"name": "Updated Name"}, "context": {}}]}}
|
|
63
|
+
|
|
64
|
+
ids: List[int] = Field(..., min_length=1, description="List of record IDs to update")
|
|
65
|
+
values: Dict[str, Any] = Field(..., description="Field values to write")
|
|
66
|
+
context: Dict[str, Any] = Field(default_factory=dict, description="Optional Odoo context")
|
|
67
|
+
|
|
68
|
+
@field_validator('ids')
|
|
69
|
+
@classmethod
|
|
70
|
+
def check_ids(cls, v):
|
|
71
|
+
if any(i <= 0 for i in v):
|
|
72
|
+
raise ValueError("All IDs must be positive integers")
|
|
73
|
+
return v
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DeleteDataModel(APIBaseModel):
|
|
77
|
+
model_config = {"json_schema_extra": {"examples": [{"ids": [1, 2], "context": {}}]}}
|
|
78
|
+
|
|
79
|
+
ids: List[int] = Field(..., min_length=1, description="List of record IDs to delete")
|
|
80
|
+
context: Dict[str, Any] = Field(default_factory=dict, description="Optional Odoo context")
|
|
81
|
+
|
|
82
|
+
@field_validator('ids')
|
|
83
|
+
@classmethod
|
|
84
|
+
def check_ids(cls, v):
|
|
85
|
+
if any(i <= 0 for i in v):
|
|
86
|
+
raise ValueError("All IDs must be positive integers")
|
|
87
|
+
return v
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ExecuteFunctionModel(APIBaseModel):
|
|
91
|
+
model_config = {"json_schema_extra": {"examples": [{"args": [[1, 2]], "kwargs": {"force": True}, "context": {}}]}}
|
|
92
|
+
|
|
93
|
+
args: List[Any] = Field(default_factory=list, description="Positional arguments for the function")
|
|
94
|
+
kwargs: Dict[str, Any] = Field(default_factory=dict, description="Keyword arguments for the function")
|
|
95
|
+
context: Dict[str, Any] = Field(default_factory=dict, description="Optional Odoo context")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
|
|
5
|
+
from odoo_api.dependencies.auth import authenticate
|
|
6
|
+
from odoo_api.dependencies.odoo_session import Token, AuthenticationBody
|
|
7
|
+
from odoo_api.exceptions import UnauthorizedException
|
|
8
|
+
|
|
9
|
+
router = APIRouter(tags=["Authentication"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.post('/api/login', response_model=Token, summary="Login", description="Authenticate with Odoo credentials and receive a JWT access token.")
|
|
13
|
+
async def login(info: AuthenticationBody):
|
|
14
|
+
token = await authenticate(info.db, info.username, info.password)
|
|
15
|
+
if not token:
|
|
16
|
+
raise UnauthorizedException(message="Incorrect login information")
|
|
17
|
+
return {"access_token": token, "token_type": "bearer"}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, Query
|
|
4
|
+
|
|
5
|
+
from odoo_api.core.connection import execute, sanitize_model
|
|
6
|
+
from odoo_api.dependencies.odoo_session import AuthenticatedUser, get_logged_in_user
|
|
7
|
+
from odoo_api.models.request import (
|
|
8
|
+
SearchDataModel, CreateDataModel, UpdateDataModel,
|
|
9
|
+
DeleteDataModel, ExecuteFunctionModel,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
router = APIRouter(tags=["Models"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.get('/api/model/{model}/{record_id}', summary="Read record", description="Read a single record by ID from an Odoo model.")
|
|
16
|
+
async def retrieve(model: str,
|
|
17
|
+
record_id: int,
|
|
18
|
+
fields: str = Query(default='id'),
|
|
19
|
+
user: AuthenticatedUser = Depends(get_logged_in_user)):
|
|
20
|
+
model = sanitize_model(model)
|
|
21
|
+
kwargs = dict(uid=user.uid, password=user.password, host=user.host, db=user.db, fields=fields.split(','))
|
|
22
|
+
args = [[record_id]]
|
|
23
|
+
res = await execute(model, "read", args, kwargs, additional_context=True)
|
|
24
|
+
return {
|
|
25
|
+
"data": res
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.post('/api/models/search/{model}', summary="Search records", description="Search and list records from an Odoo model with filtering, pagination, and field selection.")
|
|
30
|
+
async def search(model: str,
|
|
31
|
+
request_data: SearchDataModel = None,
|
|
32
|
+
user: AuthenticatedUser = Depends(get_logged_in_user)):
|
|
33
|
+
model = sanitize_model(model)
|
|
34
|
+
args = []
|
|
35
|
+
if not request_data:
|
|
36
|
+
request_data = SearchDataModel()
|
|
37
|
+
kwargs = dict(uid=user.uid, password=user.password, host=user.host, db=user.db, context=request_data.context)
|
|
38
|
+
|
|
39
|
+
param_keys = ['domain', 'fields', 'offset', 'limit', 'order']
|
|
40
|
+
domain, fields, offset, limit, order = map(lambda k: getattr(request_data, k, None), param_keys)
|
|
41
|
+
kwargs.update(dict(domain=domain, fields=fields, offset=offset, limit=limit, order=order))
|
|
42
|
+
res = await execute(model, 'search_read', args, kwargs, additional_context=True)
|
|
43
|
+
return {
|
|
44
|
+
"data": res
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.post('/api/models/{model}', summary="Create records", description="Create one or more records in an Odoo model.")
|
|
49
|
+
async def create(request_data: CreateDataModel,
|
|
50
|
+
model: str,
|
|
51
|
+
user: AuthenticatedUser = Depends(get_logged_in_user)):
|
|
52
|
+
model = sanitize_model(model)
|
|
53
|
+
values = request_data.values
|
|
54
|
+
args = [values]
|
|
55
|
+
kwargs = dict(uid=user.uid, password=user.password, host=user.host, db=user.db, context=request_data.context)
|
|
56
|
+
res = await execute(model, 'create', args, kwargs)
|
|
57
|
+
return {
|
|
58
|
+
"data": res
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@router.put('/api/models/{model}', summary="Update records", description="Update one or more records in an Odoo model by IDs.")
|
|
63
|
+
async def update(request_data: UpdateDataModel,
|
|
64
|
+
model: str,
|
|
65
|
+
user: AuthenticatedUser = Depends(get_logged_in_user)):
|
|
66
|
+
model = sanitize_model(model)
|
|
67
|
+
ids = request_data.ids
|
|
68
|
+
values = request_data.values
|
|
69
|
+
args = [ids, values]
|
|
70
|
+
kwargs = dict(uid=user.uid, password=user.password, host=user.host, db=user.db, context=request_data.context)
|
|
71
|
+
res = await execute(model, 'write', args, kwargs)
|
|
72
|
+
return {
|
|
73
|
+
"data": res
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.delete('/api/models/{model}', summary="Delete records", description="Delete one or more records from an Odoo model by IDs.")
|
|
78
|
+
async def delete(request_data: DeleteDataModel,
|
|
79
|
+
model: str,
|
|
80
|
+
user: AuthenticatedUser = Depends(get_logged_in_user)):
|
|
81
|
+
model = sanitize_model(model)
|
|
82
|
+
ids = request_data.ids
|
|
83
|
+
args = [ids]
|
|
84
|
+
kwargs = dict(uid=user.uid, password=user.password, host=user.host, db=user.db, context=request_data.context)
|
|
85
|
+
res = await execute(model, 'unlink', args, kwargs)
|
|
86
|
+
return {
|
|
87
|
+
"data": res
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@router.post('/api/models/{model}/execute/{func_name}', summary="Execute function", description="Execute a custom function on an Odoo model.")
|
|
92
|
+
async def execute_func(request_data: ExecuteFunctionModel,
|
|
93
|
+
model: str,
|
|
94
|
+
func_name: str,
|
|
95
|
+
user: AuthenticatedUser = Depends(get_logged_in_user)):
|
|
96
|
+
model = sanitize_model(model)
|
|
97
|
+
args = request_data.args
|
|
98
|
+
kwargs = request_data.kwargs
|
|
99
|
+
kwargs.update(dict(uid=user.uid, password=user.password, host=user.host, db=user.db, context=request_data.context))
|
|
100
|
+
res = await execute(model, func_name, args, kwargs)
|
|
101
|
+
return {
|
|
102
|
+
"data": res
|
|
103
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: odoo-fastapi-gateway
|
|
3
|
+
Version: 18.1.0
|
|
4
|
+
Summary: FastAPI gateway library for Odoo XML-RPC integration
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: fastapi>=0.100.0
|
|
7
|
+
Requires-Dist: uvicorn>=0.20.0
|
|
8
|
+
Requires-Dist: python-jose[cryptography]>=3.3.0
|
|
9
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
10
|
+
Requires-Dist: cryptography>=41.0.0
|
|
11
|
+
Requires-Dist: pydantic-settings>=2.0.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
14
|
+
Requires-Dist: httpx>=0.24.0; extra == "dev"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/odoo_api/__init__.py
|
|
4
|
+
src/odoo_api/app.py
|
|
5
|
+
src/odoo_api/exceptions.py
|
|
6
|
+
src/odoo_api/core/__init__.py
|
|
7
|
+
src/odoo_api/core/config.py
|
|
8
|
+
src/odoo_api/core/connection.py
|
|
9
|
+
src/odoo_api/dependencies/__init__.py
|
|
10
|
+
src/odoo_api/dependencies/auth.py
|
|
11
|
+
src/odoo_api/dependencies/odoo_session.py
|
|
12
|
+
src/odoo_api/models/__init__.py
|
|
13
|
+
src/odoo_api/models/base.py
|
|
14
|
+
src/odoo_api/models/request.py
|
|
15
|
+
src/odoo_api/routers/__init__.py
|
|
16
|
+
src/odoo_api/routers/auth.py
|
|
17
|
+
src/odoo_api/routers/health.py
|
|
18
|
+
src/odoo_api/routers/models.py
|
|
19
|
+
src/odoo_fastapi_gateway.egg-info/PKG-INFO
|
|
20
|
+
src/odoo_fastapi_gateway.egg-info/SOURCES.txt
|
|
21
|
+
src/odoo_fastapi_gateway.egg-info/dependency_links.txt
|
|
22
|
+
src/odoo_fastapi_gateway.egg-info/requires.txt
|
|
23
|
+
src/odoo_fastapi_gateway.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
odoo_api
|