biszx-odoo-mcp 1.2.0__py3-none-any.whl

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.

Potentially problematic release.


This version of biszx-odoo-mcp might be problematic. Click here for more details.

@@ -0,0 +1,697 @@
1
+ """
2
+ Odoo client for interacting with Odoo via JSON-RPC using OdooRPC library.
3
+
4
+ TABLE OF CONTENTS:
5
+ =================
6
+
7
+ 1. INITIALIZATION AND CONNECTION MANAGEMENT
8
+ - __init__()
9
+ - _connect()
10
+ - _ensure_connected()
11
+
12
+ 2. MODEL INTROSPECTION
13
+ - get_models()
14
+ - get_model_info()
15
+ - get_model_fields()
16
+ 3. SEARCH AND READ OPERATIONS
17
+ - search_ids()
18
+ - search_count()
19
+ - search_read()
20
+ - read_records()
21
+
22
+ 4. CRUD OPERATIONS
23
+ - create_record()
24
+ - create_records()
25
+ - write_records()
26
+ - unlink_records()
27
+ - copy_record()
28
+
29
+ 5. ACCESS CONTROL
30
+ - check_access_rights()
31
+
32
+ 6. GENERIC METHOD EXECUTION
33
+ - execute_method()
34
+ - call_method()
35
+ """
36
+
37
+ import urllib.parse
38
+ from typing import Any, Protocol, cast
39
+
40
+ import odoorpc # type: ignore
41
+ from loguru import logger
42
+ from odoorpc.error import InternalError, RPCError # type: ignore
43
+ from odoorpc.rpc.error import ConnectorError # type: ignore
44
+
45
+ from biszx_odoo_mcp.exceptions import (
46
+ AuthenticationError,
47
+ ConnectionTimeoutError,
48
+ InternalServerError,
49
+ ModelNotFoundError,
50
+ OdooRPCError,
51
+ )
52
+ from biszx_odoo_mcp.tools.config import Config
53
+
54
+
55
+ class OdooModelProtocol(Protocol):
56
+ """Protocol defining the interface for Odoo model proxy objects"""
57
+
58
+ def search(self, domain: list[Any], **kwargs: Any) -> list[int]:
59
+ """Search for record IDs"""
60
+ ...
61
+
62
+ def search_count(self, domain: list[Any]) -> int:
63
+ """Count records matching domain"""
64
+ ...
65
+
66
+ def search_read(
67
+ self, domain: list[Any], fields: list[str] | None = None, **kwargs: Any
68
+ ) -> list[dict[str, Any]]:
69
+ """Search and read records"""
70
+ ...
71
+
72
+ def browse(self, ids: list[int]) -> Any:
73
+ """Browse records by IDs"""
74
+ ...
75
+
76
+ def create(self, values: list[dict[str, Any]]) -> Any:
77
+ """Create records"""
78
+ ...
79
+
80
+ def fields_get(self) -> dict[str, Any]:
81
+ """Get field definitions"""
82
+ ...
83
+
84
+ def check_access_rights(self, operation: str, raise_exception: bool = True) -> bool:
85
+ """Check access rights for the given operation"""
86
+ ...
87
+
88
+ def read_group(self, domain: list[Any], **kwargs: Any) -> list[dict[str, Any]]:
89
+ """Group records and perform aggregations"""
90
+ ...
91
+
92
+
93
+ class OdooClient:
94
+ """
95
+ Client for interacting with Odoo via JSON-RPC
96
+ """
97
+
98
+ # ============================================================================
99
+ # INITIALIZATION AND CONNECTION MANAGEMENT
100
+ # ============================================================================
101
+
102
+ def __init__(
103
+ self,
104
+ config: Config,
105
+ ) -> None:
106
+ """
107
+ Initialize the Odoo client with connection parameters
108
+
109
+ Args:
110
+ config: Odoo client configuration
111
+ """
112
+ self.config = config
113
+ parsed_url = urllib.parse.urlparse(self.config.url)
114
+ self.hostname = parsed_url.netloc
115
+ self.odoo: odoorpc.ODOO | None = None # Will be initialized in _connect
116
+ self.uid: int | None = None # Will be set after login
117
+ self._connect()
118
+
119
+ def _ensure_connected(self) -> Any:
120
+ """Ensure we have a valid connection"""
121
+ if self.odoo is None:
122
+ raise InternalServerError("Not connected to Odoo")
123
+ return self.odoo
124
+
125
+ def _get_model(self, model_name: str) -> OdooModelProtocol:
126
+ """Get a model proxy with proper typing"""
127
+ odoo_conn = self._ensure_connected()
128
+ return cast(OdooModelProtocol, odoo_conn.env[model_name])
129
+
130
+ def _connect(self) -> None:
131
+ """Initialize the OdooRPC connection and authenticate"""
132
+ logger.debug(f"Connecting to Odoo at: {self.config.url}")
133
+ logger.debug(f"Database: {self.config.db}, User: {self.config.username}")
134
+
135
+ try:
136
+ # Determine protocol and port based on URL scheme
137
+ is_https = self.config.url.startswith("https://")
138
+ protocol = "jsonrpc+ssl" if is_https else "jsonrpc"
139
+ port = 443 if is_https else 80
140
+
141
+ self.odoo = odoorpc.ODOO(
142
+ self.hostname,
143
+ protocol=protocol,
144
+ port=port,
145
+ timeout=self.config.timeout,
146
+ version=None,
147
+ )
148
+ self.odoo.login(self.config.db, self.config.username, self.config.password)
149
+
150
+ # Get user ID for later use
151
+ user_model = self._get_model("res.users")
152
+ user_records = user_model.search([("login", "=", self.config.username)])
153
+ self.uid = user_records[0] if user_records else None
154
+
155
+ logger.info("✅ Successfully connected to Odoo")
156
+
157
+ except (RPCError, InternalError, ConnectorError) as e:
158
+ # Log connection errors as they're important for debugging
159
+ logger.error(f"🔴 Failed to connect to Odoo: {str(e)}")
160
+
161
+ # Check specific error types and raise appropriate custom exceptions
162
+ error_msg = str(e).lower()
163
+ if isinstance(e, RPCError):
164
+ if "access" in error_msg or "denied" in error_msg:
165
+ raise AuthenticationError(
166
+ f"Authentication failed: {str(e)}",
167
+ username=self.config.username,
168
+ database=self.config.db,
169
+ ) from e
170
+ raise OdooRPCError(error=e, method="connect") from e
171
+ if isinstance(e, ConnectorError):
172
+ raise ConnectionTimeoutError(
173
+ f"Connection failed: {str(e)}", timeout=self.config.timeout
174
+ ) from e
175
+ raise InternalServerError(f"Internal error: {str(e)}") from e
176
+
177
+ # ============================================================================
178
+ # MODEL INTROSPECTION
179
+ # ============================================================================
180
+
181
+ def search_models(self, query: str) -> dict[str, Any]:
182
+ """
183
+ Search for models that match a query term
184
+
185
+ This searches through model names and display names to find models that
186
+ match the given query term.
187
+
188
+ Args:
189
+ query: Search term to find models (searches in model name and display name)
190
+
191
+ Returns:
192
+ Dictionary with search results
193
+
194
+ Examples:
195
+ >>> client = OdooClient(url, db, username, password)
196
+ >>> results = client.search_models('partner')
197
+ >>> print(results['length'])
198
+ 3
199
+ >>> print([m['model'] for m in results['models']])
200
+ ['res.partner', 'res.partner.bank', 'res.partner.category']
201
+ """
202
+ try:
203
+ IrModel = self._get_model("ir.model")
204
+ IrModel.check_access_rights("read")
205
+ domain = [
206
+ "&",
207
+ ("transient", "=", False),
208
+ "&",
209
+ "|",
210
+ ("model", "like", query),
211
+ ("name", "like", query),
212
+ "|",
213
+ "&",
214
+ ("model", "not like", "base.%"),
215
+ ("model", "not like", "ir.%"),
216
+ (
217
+ "model",
218
+ "in",
219
+ [
220
+ "ir.attachment",
221
+ "ir.model",
222
+ "ir.model.fields",
223
+ ],
224
+ ),
225
+ ]
226
+ matching_models = IrModel.search_read(domain, ["model", "name"])
227
+ return {
228
+ "query": query,
229
+ "length": len(matching_models),
230
+ "models": [
231
+ {
232
+ "model": model["model"],
233
+ "name": model["name"],
234
+ }
235
+ for model in matching_models
236
+ ],
237
+ }
238
+ except RPCError as e:
239
+ raise OdooRPCError(e, method="search_models") from e
240
+
241
+ def get_model_info(self, model_name: str) -> dict[str, Any]:
242
+ """
243
+ Get information about a specific model
244
+
245
+ Args:
246
+ model_name: Name of the model (e.g., 'res.partner')
247
+
248
+ Returns:
249
+ Dictionary with model information
250
+
251
+ Examples:
252
+ >>> client = OdooClient(url, db, username, password)
253
+ >>> info = client.get_model_info('res.partner')
254
+ >>> print(info['name'])
255
+ 'Contact'
256
+ """
257
+ try:
258
+ IrModel = self._get_model("ir.model")
259
+ IrModel.check_access_rights("read")
260
+ result = IrModel.search_read(
261
+ [("model", "=", model_name)], ["model", "name"]
262
+ )
263
+ if not result:
264
+ raise ModelNotFoundError(model_name)
265
+ return result[0]
266
+ except RPCError as e:
267
+ raise OdooRPCError(e, method="get_model_info") from e
268
+
269
+ def get_model_fields(
270
+ self, model_name: str, query: str | None = None
271
+ ) -> dict[str, Any]:
272
+ """
273
+ Get field definitions for a specific model
274
+
275
+ Args:
276
+ model_name: Name of the model (e.g., 'res.partner')
277
+
278
+ Returns:
279
+ Dictionary mapping field names to their definitions
280
+
281
+ Examples:
282
+ >>> client = OdooClient(url, db, username, password)
283
+ >>> fields = client.get_model_fields('res.partner')
284
+ >>> print(fields['name']['type'])
285
+ 'char'
286
+ """
287
+ try:
288
+ Model = self._get_model(model_name)
289
+ data: dict[str, Any] = Model.fields_get()
290
+ result: dict[str, Any] = {
291
+ "length": 0,
292
+ "fields": {},
293
+ }
294
+ if query is not None:
295
+ for field, value in data.items():
296
+ if "related" in value:
297
+ continue
298
+
299
+ if all(
300
+ {
301
+ query.lower() in field.lower()
302
+ or query.lower() in value["string"].lower(),
303
+ }
304
+ ):
305
+ result["fields"][field] = {
306
+ "name": field,
307
+ "string": value["string"],
308
+ "type": value["type"],
309
+ "required": value.get("required", False),
310
+ "readonly": value.get("readonly", False),
311
+ "searchable": value.get("searchable", False),
312
+ "relation": value.get("relation", False),
313
+ }
314
+ else:
315
+ result["fields"] = data
316
+ result["length"] = len(result["fields"])
317
+ return result
318
+ except RPCError as e:
319
+ raise OdooRPCError(e, method="get_model_fields") from e
320
+
321
+ # ============================================================================
322
+ # SEARCH AND READ OPERATIONS
323
+ # ============================================================================
324
+
325
+ def search_ids(
326
+ self,
327
+ model_name: str,
328
+ domain: list[Any],
329
+ offset: int | None = None,
330
+ limit: int | None = None,
331
+ order: str | None = None,
332
+ ) -> list[int]:
333
+ """
334
+ Search for record IDs that match a domain
335
+
336
+ Args:
337
+ model_name: Name of the model (e.g., 'res.partner')
338
+ domain: Search domain (e.g., [('is_company', '=', True)])
339
+ offset: Number of records to skip
340
+ limit: Maximum number of records to return
341
+ order: Sorting criteria (e.g., 'name ASC, id DESC')
342
+
343
+ Returns:
344
+ List of matching record IDs
345
+
346
+ Examples:
347
+ >>> client = OdooClient(url, db, username, password)
348
+ >>> ids = client.search_ids(
349
+ ... 'res.partner', [('is_company', '=', True)], limit=5
350
+ ... )
351
+ >>> print(ids)
352
+ [1, 2, 3, 4, 5]
353
+ """
354
+ try:
355
+ Model = self._get_model(model_name)
356
+
357
+ # Build search kwargs
358
+ search_kwargs: dict[str, Any] = {}
359
+ if offset is not None:
360
+ search_kwargs["offset"] = offset
361
+ if limit is not None:
362
+ search_kwargs["limit"] = limit
363
+ if order is not None:
364
+ search_kwargs["order"] = order
365
+
366
+ return Model.search(domain, **search_kwargs)
367
+ except RPCError as e:
368
+ raise OdooRPCError(e, method="search_ids") from e
369
+
370
+ def search_count(self, model_name: str, domain: list[Any]) -> int:
371
+ """
372
+ Count records that match a search domain
373
+
374
+ Args:
375
+ model_name: Name of the model (e.g., 'res.partner')
376
+ domain: Search domain (e.g., [('is_company', '=', True)])
377
+
378
+ Returns:
379
+ Integer count of matching records
380
+
381
+ Examples:
382
+ >>> client = OdooClient(url, db, username, password)
383
+ >>> count = client.search_count('res.partner', [('is_company', '=', True)])
384
+ >>> print(count)
385
+ 25
386
+ """
387
+ try:
388
+ Model = self._get_model(model_name)
389
+ return Model.search_count(domain)
390
+ except RPCError as e:
391
+ raise OdooRPCError(e, method="search_count") from e
392
+
393
+ def search_read(
394
+ self,
395
+ model_name: str,
396
+ domain: list[Any],
397
+ fields: list[str] | None = None,
398
+ offset: int | None = None,
399
+ limit: int | None = None,
400
+ order: str | None = None,
401
+ ) -> list[dict[str, Any]]:
402
+ """
403
+ Search for records and read their data in a single call
404
+
405
+ Args:
406
+ model_name: Name of the model (e.g., 'res.partner')
407
+ domain: Search domain (e.g., [('is_company', '=', True)])
408
+ fields: List of field names to return (None for all)
409
+ offset: Number of records to skip
410
+ limit: Maximum number of records to return
411
+ order: Sorting criteria (e.g., 'name ASC, id DESC')
412
+
413
+ Returns:
414
+ List of dictionaries with the matching records
415
+
416
+ Examples:
417
+ >>> client = OdooClient(url, db, username, password)
418
+ >>> records = client.search_read('res.partner', [
419
+ ('is_company', '=', True)
420
+ ], limit=5)
421
+ >>> print(len(records))
422
+ 5
423
+ """
424
+ try:
425
+ Model = self._get_model(model_name)
426
+
427
+ # Build search_read arguments
428
+ search_kwargs: dict[str, Any] = {}
429
+ if offset is not None:
430
+ search_kwargs["offset"] = offset
431
+ if fields is not None:
432
+ search_kwargs["fields"] = fields
433
+ if limit is not None:
434
+ search_kwargs["limit"] = limit
435
+ if order is not None:
436
+ search_kwargs["order"] = order
437
+
438
+ result = Model.search_read(domain, **search_kwargs)
439
+ return result
440
+ except RPCError as e:
441
+ raise OdooRPCError(e, method="search_read") from e
442
+
443
+ def read_records(
444
+ self,
445
+ model_name: str,
446
+ ids: list[int],
447
+ fields: list[str] | None = None,
448
+ ) -> list[dict[str, Any]]:
449
+ """
450
+ Read data of records by IDs
451
+
452
+ Args:
453
+ model_name: Name of the model (e.g., 'res.partner')
454
+ ids: List of record IDs to read
455
+ fields: List of field names to return (None for all)
456
+
457
+ Returns:
458
+ List of dictionaries with the requested records
459
+
460
+ Examples:
461
+ >>> client = OdooClient(url, db, username, password)
462
+ >>> records = client.read_records('res.partner', [1])
463
+ >>> print(records[0]['name'])
464
+ 'YourCompany'
465
+ """
466
+ try:
467
+ Model = self._get_model(model_name)
468
+
469
+ if fields is not None:
470
+ result = Model.browse(ids).read(fields)
471
+ else:
472
+ result = Model.browse(ids).read()
473
+
474
+ return cast(list[dict[str, Any]], result)
475
+ except RPCError as e:
476
+ raise OdooRPCError(e, method="read_records") from e
477
+
478
+ def read_group(
479
+ self,
480
+ model_name: str,
481
+ domain: list[Any],
482
+ fields: list[str],
483
+ groupby: list[str],
484
+ offset: int | None = None,
485
+ limit: int | None = None,
486
+ order: str | None = None,
487
+ lazy: bool | None = None,
488
+ ) -> list[dict[str, Any]]:
489
+ """
490
+ Group records and perform aggregations on an Odoo model
491
+
492
+ Args:
493
+ model_name: Name of the model (e.g., 'res.partner')
494
+ domain: Search domain (e.g., [('is_company', '=', True)])
495
+ fields: List of field names to include, can include aggregation functions
496
+ groupby: List of field names to group by
497
+ offset: Number of groups to skip
498
+ limit: Maximum number of groups to return
499
+ order: Sorting criteria for groups (e.g., 'field_name ASC')
500
+ lazy: Whether to use lazy loading for grouped fields
501
+
502
+ Returns:
503
+ List of dictionaries with grouped and aggregated data
504
+
505
+ Examples:
506
+ >>> client = OdooClient(url, db, username, password)
507
+ >>> groups = client.read_group(
508
+ ... 'res.partner',
509
+ ... [('is_company', '=', True)],
510
+ ... ['name', 'partner_count:count(id)'],
511
+ ... ['country_id'],
512
+ ... limit=5
513
+ ... )
514
+ >>> print(len(groups))
515
+ 5
516
+ """
517
+ try:
518
+ Model = self._get_model(model_name)
519
+
520
+ # Build read_group arguments
521
+ read_group_kwargs: dict[str, Any] = {
522
+ "fields": fields,
523
+ "groupby": groupby,
524
+ }
525
+ if offset is not None:
526
+ read_group_kwargs["offset"] = offset
527
+ if limit is not None:
528
+ read_group_kwargs["limit"] = limit
529
+ if order is not None:
530
+ read_group_kwargs["orderby"] = order
531
+ if lazy is not None:
532
+ read_group_kwargs["lazy"] = lazy
533
+
534
+ result = Model.read_group(domain, **read_group_kwargs)
535
+ return cast(list[dict[str, Any]], result)
536
+ except RPCError as e:
537
+ raise OdooRPCError(e, method="read_group") from e
538
+
539
+ # ============================================================================
540
+ # CRUD OPERATIONS
541
+ # ============================================================================
542
+
543
+ def create_records(
544
+ self, model_name: str, values_list: list[dict[str, Any]]
545
+ ) -> int | list[int]:
546
+ """
547
+ Create records in an Odoo model
548
+
549
+ Args:
550
+ model_name: Name of the model (e.g., 'res.partner')
551
+ values_list: List of dictionaries with field values for the new records
552
+
553
+ Returns:
554
+ List of created record IDs
555
+
556
+ Examples:
557
+ >>> client = OdooClient(url, db, username, password)
558
+ >>> record_ids = client.create_records('res.partner', [
559
+ {'name': 'Company 1'}, {'name': 'Company 2'}
560
+ ])
561
+ >>> print(record_ids)
562
+ [42, 43]
563
+ """
564
+ try:
565
+ Model = self._get_model(model_name)
566
+ return cast(int | list[int], Model.create(values_list))
567
+ except RPCError as e:
568
+ raise OdooRPCError(e, method="create_records") from e
569
+
570
+ def write_records(
571
+ self,
572
+ model_name: str,
573
+ record_ids: list[int],
574
+ values: dict[str, Any],
575
+ ) -> bool:
576
+ """
577
+ Update records in an Odoo model
578
+
579
+ Args:
580
+ model_name: Name of the model (e.g., 'res.partner')
581
+ record_ids: List of record IDs to update
582
+ values: Dictionary with field values to update
583
+
584
+ Returns:
585
+ Boolean indicating success
586
+
587
+ Examples:
588
+ >>> client = OdooClient(url, db, username, password)
589
+ >>> success = client.write_records(
590
+ ... 'res.partner', [1], {'name': 'Updated Name'}
591
+ ... )
592
+ >>> print(success)
593
+ True
594
+ """
595
+ try:
596
+ Model = self._get_model(model_name)
597
+ records = Model.browse(record_ids)
598
+ return cast(bool, records.write(values))
599
+ except RPCError as e:
600
+ raise OdooRPCError(e, method="write_records") from e
601
+
602
+ def unlink_records(self, model_name: str, record_ids: list[int]) -> bool:
603
+ """
604
+ Delete records from an Odoo model
605
+
606
+ Args:
607
+ model_name: Name of the model (e.g., 'res.partner')
608
+ record_ids: List of record IDs to delete
609
+
610
+ Returns:
611
+ Boolean indicating success
612
+
613
+ Examples:
614
+ >>> client = OdooClient(url, db, username, password)
615
+ >>> success = client.unlink_records('res.partner', [42])
616
+ >>> print(success)
617
+ True
618
+ """
619
+ try:
620
+ Model = self._get_model(model_name)
621
+ records = Model.browse(record_ids)
622
+ return cast(bool, records.unlink())
623
+ except RPCError as e:
624
+ raise OdooRPCError(e, method="unlink_records") from e
625
+
626
+ # ============================================================================
627
+ # GENERIC METHOD EXECUTION
628
+ # ============================================================================
629
+
630
+ def execute_method(self, model: str, method: str, *args: Any, **kwargs: Any) -> Any:
631
+ """
632
+ Execute an arbitrary method on a model
633
+
634
+ Args:
635
+ model: The model name (e.g., 'res.partner')
636
+ method: Method name to execute
637
+ *args: Positional arguments to pass to the method
638
+ **kwargs: Keyword arguments to pass to the method
639
+
640
+ Returns:
641
+ Result of the method execution
642
+ """
643
+ try:
644
+ model_proxy = self._get_model(model)
645
+ # Use getattr to dynamically call the method on the model proxy
646
+ method_func = getattr(model_proxy, method)
647
+ return method_func(*args, **kwargs)
648
+ except RPCError as e:
649
+ raise OdooRPCError(e, method=f"execute_method: {model}.{method}") from e
650
+
651
+ def call_method(
652
+ self,
653
+ model_name: str,
654
+ method_name: str,
655
+ args: list[Any],
656
+ kwargs: dict[str, Any],
657
+ ) -> Any:
658
+ """
659
+ Call a custom method on an Odoo model
660
+
661
+ Args:
662
+ model_name: Name of the model (e.g., 'res.partner')
663
+ method_name: Name of the method to call
664
+ args: Positional arguments to pass to the method
665
+ kwargs: Keyword arguments to pass to the method
666
+
667
+ Returns:
668
+ Method result
669
+
670
+ Examples:
671
+ >>> client = OdooClient(url, db, username, password)
672
+ >>> result = client.call_method('res.partner', 'name_get', [1], {})
673
+ >>> print(result)
674
+ [(1, 'YourCompany')]
675
+ """
676
+ try:
677
+ model_proxy = self._get_model(model_name)
678
+ # Use getattr to dynamically call the method on the model proxy
679
+ method_func = getattr(model_proxy, method_name)
680
+ result = method_func(*args, **kwargs)
681
+ if result is None:
682
+ raise ValueError(f"Failed to call method {method_name}")
683
+ return result
684
+ except RPCError as e:
685
+ raise OdooRPCError(
686
+ e, method=f"call_method: {model_name}.{method_name}"
687
+ ) from e
688
+
689
+
690
+ def get_odoo_client() -> OdooClient:
691
+ """
692
+ Get a configured Odoo client instance
693
+
694
+ Returns:
695
+ OdooClient: A configured Odoo client instance
696
+ """
697
+ return OdooClient(config=Config())