From 763759ff10106dcd99cc0159891c7b3dbb463fa1 Mon Sep 17 00:00:00 2001 From: Callum Dickinson Date: Thu, 16 Oct 2025 17:09:50 +1300 Subject: [PATCH 1/3] Add support for updating records This adds support for updating records in place, using new `update` methods added to the record and record manager base classes. The `update` method has the same smart evaluation of field names based on record classes that the `create` method has. This allows ID or object field names to be used for model refs, and field aliases. --- changelog.d/8.added.md | 1 + docs/managers/account-move.md | 2 +- docs/managers/index.md | 86 ++++++++++++++++++++- openstack_odooclient/base/record.py | 17 ++++ openstack_odooclient/base/record_manager.py | 21 ++++- pyproject.toml | 1 + 6 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 changelog.d/8.added.md diff --git a/changelog.d/8.added.md b/changelog.d/8.added.md new file mode 100644 index 0000000..f2e5008 --- /dev/null +++ b/changelog.d/8.added.md @@ -0,0 +1 @@ +Add support for updating records diff --git a/docs/managers/account-move.md b/docs/managers/account-move.md index 893fca5..3e2ac57 100644 --- a/docs/managers/account-move.md +++ b/docs/managers/account-move.md @@ -265,7 +265,7 @@ Values: * ``reversed`` - Reversed * ``invoicing_legacy`` - Invoicing App Legacy -### state +### `state` ```python state: Literal["draft", "posted", "cancel"] diff --git a/docs/managers/index.md b/docs/managers/index.md index 8a58686..f0fb759 100644 --- a/docs/managers/index.md +++ b/docs/managers/index.md @@ -770,14 +770,55 @@ pass the returned IDs to the [``list``](#list) method. |-------------|--------------------------------------| | `list[int]` | The IDs of the newly created records | +### `update` + +```python +def update(record: int | Record, **fields: Any) -> None +``` + +Update one or more fields on this record in place. + +```python +>>> from openstack_odooclient import Client as OdooClient +>>> odoo_client = OdooClient( +... hostname="localhost", +... port=8069, +... protocol="jsonrpc", +... database="odoodb", +... user="test-user", +... password="", +... ) +>>> odoo_client.users.get(1234) +User(record={'id': 1234, 'name': 'Old Name', ...}, fields=None) +>>> odoo_client.users.update(1234, name="New Name") +>>> odoo_client.users.get(1234) +User(record={'id': 1234, 'name': 'New Name', ...}, fields=None) +``` + +Field names are passed as keyword arguments. +This method has the same flexibility with regards to what +field names are used as when [creating records](#create); +for example, when updating a model ref, either its ID +(e.g. ``user_id``) or object (e.g. ``user``) field names +can be used. + +*Added in version 0.2.0.* + +#### Parameters + +| Name | Type | Description | Default | +|------------|----------------|-----------------------------------------|------------| +| `record` | `int | Record` | The record to update (object or ID) | (required) | +| `**fields` | `Any` | Record field values (keyword arguments) | (required) | + ### `unlink`/`delete` ```python -unlink(*records: Record | int | Iterable[Record | int]) -> None +unlink(*records: int | Record | Iterable[int | Record]) -> None ``` ```python -delete(*records: Record | int | Iterable[Record | int]) -> None +delete(*records: int | Record | Iterable[int | Record]) -> None ``` Delete one or more records from Odoo. @@ -1357,6 +1398,44 @@ User(record={'id': 1234, ...}, fields=None) |------------------|-------------------| | `dict[str, Any]` | Record dictionary | +#### `update` + +```python +def update(**fields: Any) -> None +``` + +Update one or more fields on this record in place. + +```python +>>> user +User(record={'id': 1234, 'name': 'Old Name', ...}, fields=None) +>>> user.update(name="New Name") +>>> user.refresh() +User(record={'id': 1234, 'name': 'New Name', ...}, fields=None) +``` + +Field names are passed as keyword arguments. +This method has the same flexibility with regards to what +field names are used as when [creating records](#create); +for example, when updating a model ref, either its ID +(e.g. ``user_id``) or object (e.g. ``user``) field names +can be used. + +!!! note + + This record object is not updated in place by this method. + + If you need an updated version of the record object, + use the [`refresh`](#refresh) method to fetch the latest version. + +*Added in version 0.2.0.* + +##### Parameters + +| Name | Type | Description | Default | +|------------|-------|-----------------------------------------|------------| +| `**fields` | `Any` | Record field values (keyword arguments) | (required) | + #### `refresh` ```python @@ -1395,9 +1474,10 @@ Delete this record from Odoo. ```python >>> user -User(record={'id': 1234, 'name': 'Old Name', ...}, fields=None) +User(record={'id': 1234, ...}, fields=None) >>> user.unlink() >>> user.refresh() +Traceback (most recent call last): ... openstack_odooclient.exceptions.RecordNotFoundError: User record not found with ID: 1234 ``` diff --git a/openstack_odooclient/base/record.py b/openstack_odooclient/base/record.py index e650409..b97fa79 100644 --- a/openstack_odooclient/base/record.py +++ b/openstack_odooclient/base/record.py @@ -266,6 +266,23 @@ def as_dict(self, raw: bool = False) -> dict[str, Any]: } ) + def update(self, **fields: Any) -> None: + """Update one or more fields on this record in place. + + Field names are passed as keyword arguments. + This method has the same flexibility with regards to what + field names are used as when creating records; for example, + when updating a model ref, either its ID (e.g. ``user_id``) + or object (e.g. ``user``) field names can be used. + + Note that this record object is not updated in place by + this method. If you need an updated version of the record + object, use the `refresh` method to fetch the latest version. + + *Added in version 0.2.0.* + """ + self._manager.update(self.id, **fields) + def refresh(self) -> Self: """Fetch the latest version of this record from Odoo. diff --git a/openstack_odooclient/base/record_manager.py b/openstack_odooclient/base/record_manager.py index 123ec70..b2afded 100644 --- a/openstack_odooclient/base/record_manager.py +++ b/openstack_odooclient/base/record_manager.py @@ -629,7 +629,7 @@ def _encode_filter_field(self, field: str) -> tuple[Any, str]: type_hint = self._record_type_hints[local_field] return (type_hint, remote_field) - def create(self, **fields) -> int: + def create(self, **fields: Any) -> int: """Create a new record, using the specified keyword arguments as input fields. @@ -808,6 +808,25 @@ def _encode_create_field( self._encode_value(type_hint=type_hint, value=value), ) + def update(self, record: int | Record, **fields: Any) -> None: + """Update one or more fields on the given record in place. + + Field names are passed as keyword arguments. + This method has the same flexibility with regards to what + field names are used as when creating records; for example, + when updating a model ref, either its ID (e.g. ``user_id``) + or object (e.g. ``user``) field names can be used. + + *Added in version 0.2.0.* + + :param record: The record to update (object or ID) + :type record: int | Record + """ + self._env.update( + record.id if isinstance(record, RecordBase) else record, + self._encode_create_fields(fields), + ) + def unlink( self, *records: int | Record | Iterable[int | Record], diff --git a/pyproject.toml b/pyproject.toml index 0d8a202..d8a9394 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ packages = ["openstack_odooclient"] [tool.poe.tasks] lint = "ruff check" format = "ruff format" +type-check = "mypy openstack_odooclient" [tool.ruff] fix = true From 2f9cd30e540d5fd6c094d355eb42eed55e2a2dfd Mon Sep 17 00:00:00 2001 From: Callum Dickinson Date: Wed, 15 Oct 2025 15:19:01 +1300 Subject: [PATCH 2/3] Add support for managing attachments Add a new manager for attachment records (`ir.attachment`) in Odoo. These are intended to be used for uploading/downloading attachments to/from invoices. Invoice attachments will then be attached to invoice emails sent out to customers. The contents of the attachments won't be fetched when querying them from Odoo by default; instead it is intended that the separate `download` method be used to download the attachment contents separately. An `upload` method is also available, to provide an easier to use interface for uploading attachments. --- _typos.toml | 2 + changelog.d/7.added.md | 1 + docs/managers/attachment.md | 526 ++++++++++++++++++ docs/managers/index.md | 1 + mkdocs.yml | 1 + openstack_odooclient/__init__.py | 8 + openstack_odooclient/base/client.py | 8 + openstack_odooclient/base/record.py | 5 +- openstack_odooclient/base/record_manager.py | 4 + openstack_odooclient/client.py | 4 + openstack_odooclient/managers/account_move.py | 3 +- openstack_odooclient/managers/attachment.py | 470 ++++++++++++++++ openstack_odooclient/managers/product.py | 12 + 13 files changed, 1042 insertions(+), 3 deletions(-) create mode 100644 _typos.toml create mode 100644 changelog.d/7.added.md create mode 100644 docs/managers/attachment.md create mode 100644 openstack_odooclient/managers/attachment.py diff --git a/_typos.toml b/_typos.toml new file mode 100644 index 0000000..02d3ec1 --- /dev/null +++ b/_typos.toml @@ -0,0 +1,2 @@ +[default.extend-words] +datas = "datas" diff --git a/changelog.d/7.added.md b/changelog.d/7.added.md new file mode 100644 index 0000000..ef6fcb1 --- /dev/null +++ b/changelog.d/7.added.md @@ -0,0 +1 @@ +Add support for managing attachments diff --git a/docs/managers/attachment.md b/docs/managers/attachment.md new file mode 100644 index 0000000..57f7c8b --- /dev/null +++ b/docs/managers/attachment.md @@ -0,0 +1,526 @@ +# Attachments + +*Added in version 0.2.0.* + +This page documents how to use the manager and record objects +for attachments. + +## Details + +| Name | Value | +|-----------------|-----------------| +| Odoo Modules | Base, Mail | +| Odoo Model Name | `ir.attachment` | +| Manager | `attachments` | +| Record Type | `Attachment` | + +## Manager + +The attachment manager is available as the `attachments` +attribute on the Odoo client object. + +```python +>>> from openstack_odooclient import Client as OdooClient +>>> odoo_client = OdooClient( +... hostname="localhost", +... port=8069, +... protocol="jsonrpc", +... database="odoodb", +... user="test-user", +... password="", +... ) +>>> odoo_client.attachments.get(1234) +Attachment(record={'id': 1234, ...}, fields=None) +``` + +For more information on how to use managers, refer to [Managers](index.md). + +The following manager methods are also available, in addition to the standard methods. + +### `upload` + +```python +def upload( + name: str, + data: bytes, + *, + record: RecordBase | None = None, + res_id: int | None = None, + res_model: str | None = None, + type: str = "binary", + **fields: Any, +) -> int +``` + +Upload an attachment and associate it with the given record. + +One of ``record`` or ``res_id`` must be set to specify the record +to link the attachment to. When ``res_id`` is used, ``res_model`` +must also be specified to define the model of the record. + +```python +>>> from openstack_odooclient import Client as OdooClient +>>> odoo_client = OdooClient( +... hostname="localhost", +... port=8069, +... protocol="jsonrpc", +... database="odoodb", +... user="test-user", +... password="", +... ) +>>> odoo_client.attachments.upload( +... "example.txt", +... b"Hello, world!", +... res_id=1234, +... res_model="account.move", +... res_field="message_main_attachment_id", +... ) +5678 +``` + +When ``record`` is used, this is not necessary. + +```python +>>> from openstack_odooclient import Client as OdooClient +>>> odoo_client = OdooClient( +... hostname="localhost", +... port=8069, +... protocol="jsonrpc", +... database="odoodb", +... user="test-user", +... password="", +... ) +>>> account_move = odoo_client.account_moves.get(1234) +>>> odoo_client.attachments.upload( +... "example.txt", +... b"Hello, world!", +... record=account_move, +... res_field="message_main_attachment_id", +... ) +5678 +``` + +Any keyword arguments passed to this method are passed to +the attachment record as fields. + +#### Parameters + +| Name | Type | Description | Default | +|-------------|---------------------|----------------------------------------------------------------|------------| +| `name` | `str` | The name of the attachment | (required) | +| `data` | `bytes` | The contents of the attachment | (required) | +| `record` | `RecordBase | None` | The linked record (if referencing by object) | `None` | +| `res_id` | `int | None` | The ID of the linked record (if referencing by ID) | `None` | +| `res_model` | `str | None` | The model of the linked record (if referencing by ID) | `None` | +| `**fields` | `Any` | Additional fields to set on the attachment (keyword arguments) | (none) | + +#### Returns + +| Type | Description | +|-------|------------------------------------------------| +| `int` | The record ID of the newly uploaded attachment | + +### `download` + +```python +def download( + attachment: int | Attachment, +) -> bytes +``` + +Download a given attachment, and return the contents as bytes. + +#### Parameters + +| Name | Type | Description | Default | +|--------------|--------------------|---------------------------|------------| +| `attachment` | `int | Attachment` | Attachment (ID or object) | (required) | + +#### Returns + +| Type | Description | +|---------|---------------------| +| `bytes` | Attachment contents | + +### `reupload` + +```python +def reupload( + attachment: int | Attachment, + data: bytes, + **fields: Any, +) -> None +``` + +Reupload a new version of the contents of the given attachment, +and update the attachment in place. + +```python +>>> from openstack_odooclient import Client as OdooClient +>>> odoo_client = OdooClient( +... hostname="localhost", +... port=8069, +... protocol="jsonrpc", +... database="odoodb", +... user="test-user", +... password="", +... ) +>>> odoo_client.attachments.download(1234) +b'Goodbye, world!' +>>> odoo_client.attachments.reupload( +... 1234, +... b"Hello, world!", +... ) +>>> odoo_client.attachments.download(1234) +b'Hello, world!' +``` + +Other fields can be updated at the same time by passing them +as keyword arguments. + +```python +>>> from openstack_odooclient import Client as OdooClient +>>> odoo_client = OdooClient( +... hostname="localhost", +... port=8069, +... protocol="jsonrpc", +... database="odoodb", +... user="test-user", +... password="", +... ) +>>> odoo_client.attachments.get(1234) +Attachment(record={'id': 1234, 'name': 'hello.txt', ...}, fields=None) +>>> odoo_client.attachments.download(1234) +b'Goodbye, world!' +>>> odoo_client.attachments.reupload( +... 1234, +... b"Hello, world!", +... name="example.txt", +... ) +>>> odoo_client.attachments.get(1234) +Attachment(record={'id': 1234, 'name': 'example.txt', ...}, fields=None) +>>> odoo_client.attachments.download(1234) +b'Hello, world!' +``` + +Any keyword arguments passed to this method are passed to +the attachment record as fields. + +#### Parameters + +| Name | Type | Description | Default | +|--------------|--------------------|----------------------------------------------------------------|------------| +| `attachment` | `int | Attachment` | Attachment (ID or object) | (required) | +| `data` | `bytes` | The contents of the attachment | (required) | +| `**fields` | `Any` | Additional fields to set on the attachment (keyword arguments) | (none) | + +### `register_as_main_attachment` + +```python +def register_as_main_attachment( + attachment: int | Attachment, + force: bool = True, +) -> None +``` + +Register the given attachment as the main attachment +of the record it is attached to. + +The model of the attached record must have the +``message_main_attachment_id`` field defined. + +#### Parameters + +| Name | Type | Description | Default | +|--------------|--------------------|---------------------------|------------| +| `attachment` | `int | Attachment` | Attachment (ID or object) | (required) | +| `force` | `bool` | Overwrite if already set | `True` | + +## Record + +The attachment manager returns `Attachment` record objects. + +To import the record class for type hinting purposes: + +```python +from openstack_odooclient import Attachment +``` + +The record class currently implements the following fields and methods. + +For more information on attributes and methods common to all record types, +see [Record Attributes and Methods](index.md#attributes-and-methods). + +### `checksum` + +```python +checksum: str +``` + +A SHA1 checksum of the attachment contents. + +### `company_id` + +```python +company_id: int | None +``` + +The ID for the [company](company.md) that owns this attachment, if set. + +### `company_name` + +```python +company_name: str | None +``` + +The name of the [company](company.md) that owns this attachment, if set. + +### `company` + +```python +company: Company | None +``` + +The [company](company.md) that owns this attachment, if set. + +This fetches the full record from Odoo once, +and caches it for subsequent accesses. + +### `datas` + +```python +datas: str | Literal[False] +``` + +The contents of the attachment, encoded in base64. + +Only applies when [``type``](#type) is set to ``binary``. + +**This field is not fetched by default.** To make this field available, +use the ``fields`` parameter on the [``get``](index.md#get) or +[``list``](index.md#list) methods to select the ``datas`` field. + +### `description` + +```python +description: str | Literal[False] +``` + +A description of the file, if defined. + +### `index_content` + +```python +index_content: str +``` + +The index content value computed from the attachment contents. + +**This field is not fetched by default.** To make this field available, +use the ``fields`` parameter on the [``get``](index.md#get) or +[``list``](index.md#list) methods to select the ``index_content`` field. + +### `mimetype` + +```python +mimetype: str +``` + +MIME type of the attached file. + +### `name` + +```python +name: str +``` + +The name of the attachment. + +Usually matches the filename of the attached file. + +### `public` + +```python +public: bool +``` + +Whether or not the attachment is publicly accessible. + +### `res_field` + +```python +res_field: str | Literal[False] +``` + +The name of the field used to refer to this attachment +on the linked record's model, if set. + +### `res_id` + +```python +res_id: int | Literal[False] +``` + +The ID of the record this attachment is linked to, if set. + +### `res_model` + +```python +res_model: str | Literal[False] +``` + +The name of the model of the record this attachment +is linked to, if set. + +### `res_name` + +```python +res_name: str | Literal[False] +``` + +The name of the record this attachment is linked to, if set. + +### `store_fname` + +```python +store_fname: str | Literal[False] +``` + +The stored filename for this attachment, if set. + +### `type` + +```python +type: Literal["binary", "url"] +``` + +The type of the attachment. + +When set to ``binary``, the contents of the attachment are available +using the ``datas`` field. When set to ``url``, the attachment can be +downloaded from the URL configured in the ``url`` field. + +Values: + +* ``binary`` - Stored internally as binary data +* ``url`` - Stored externally, accessible using a URL + +### `url` + +```python +url: str | Literal[False] +``` + +The URL the contents of the attachment are available from. + +Only applies when ``type`` is set to ``url``. + +### `download` + +```python +def download() -> bytes +``` + +Download this attachment, and return the contents as bytes. + +#### Returns + +| Type | Description | +|---------|---------------------| +| `bytes` | Attachment contents | + +### `reupload` + +```python +def reupload( + data: bytes, + **fields: Any, +) -> None +``` + +Reupload a new version of the contents of this attachment, +and update the attachment in place. + +```python +>>> from openstack_odooclient import Client as OdooClient +>>> odoo_client = OdooClient( +... hostname="localhost", +... port=8069, +... protocol="jsonrpc", +... database="odoodb", +... user="test-user", +... password="", +... ) +>>> attachment = odoo_client.attachments.get(1234) +>>> attachment.download() +b'Goodbye, world!' +>>> attachment.reupload(b"Hello, world!") +>>> attachment.download() +b'Hello, world!' +``` + +Other fields can be updated at the same time by passing them +as keyword arguments. + +```python +>>> from openstack_odooclient import Client as OdooClient +>>> odoo_client = OdooClient( +... hostname="localhost", +... port=8069, +... protocol="jsonrpc", +... database="odoodb", +... user="test-user", +... password="", +... ) +>>> attachment = odoo_client.attachments.get(1234) +>>> attachment +Attachment(record={'id': 1234, 'name': 'hello.txt', ...}, fields=None) +>>> attachment.download() +b'Goodbye, world!' +>>> attachment.reupload( +... b"Hello, world!", +... name="example.txt", +... ) +>>> attachment = attachment.refresh() +>>> attachment +Attachment(record={'id': 1234, 'name': 'example.txt', ...}, fields=None) +>>> attachment.download() +b'Hello, world!' +``` + +Any keyword arguments passed to this method are passed to +the attachment record as fields. + +!!! note + + This attachment object not updated in place by this method. + + If you need an updated version of the attachment object, + use the [`refresh`](index.md#refresh) method to fetch the latest version. + +#### Parameters + +| Name | Type | Description | Default | +|------------|---------|----------------------------------------------------------------|------------| +| `data` | `bytes` | The contents of the attachment | (required) | +| `**fields` | `Any` | Additional fields to set on the attachment (keyword arguments) | (none) | + +### `register_as_main_attachment` + +```python +def register_as_main_attachment( + force: bool = True, +) -> None +``` + +Register this attachment as the main attachment +of the record it is attached to. + +The model of the attached record must have the +``message_main_attachment_id`` field defined. + +#### Parameters + +| Name | Type | Description | Default | +|---------|--------|--------------------------|---------| +| `force` | `bool` | Overwrite if already set | `True` | diff --git a/docs/managers/index.md b/docs/managers/index.md index f0fb759..7b906ec 100644 --- a/docs/managers/index.md +++ b/docs/managers/index.md @@ -23,6 +23,7 @@ For example, performing a simple search query would look something like this: * [Account Moves (Invoices)](account-move.md) * [Account Move (Invoice) Lines](account-move-line.md) +* [Attachments](attachment.md) * [Companies](company.md) * [OpenStack Credits](credit.md) * [OpenStack Credit Transactions](credit-transaction.md) diff --git a/mkdocs.yml b/mkdocs.yml index f2ecb21..30fcc52 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -34,6 +34,7 @@ nav: - managers/index.md - managers/account-move.md - managers/account-move-line.md + - managers/attachment.md - managers/company.md - managers/credit.md - managers/credit-type.md diff --git a/openstack_odooclient/__init__.py b/openstack_odooclient/__init__.py index 97cd8aa..eb02493 100644 --- a/openstack_odooclient/__init__.py +++ b/openstack_odooclient/__init__.py @@ -34,6 +34,11 @@ AccountMoveLine, AccountMoveLineManager, ) +from .managers.attachment import ( + Attachment, + AttachmentManager, + AttachmentMixin, +) from .managers.company import Company, CompanyManager from .managers.credit import Credit, CreditManager from .managers.credit_transaction import ( @@ -83,6 +88,9 @@ "AccountMoveLine", "AccountMoveLineManager", "AccountMoveManager", + "Attachment", + "AttachmentManager", + "AttachmentMixin", "Client", "ClientBase", "ClientError", diff --git a/openstack_odooclient/base/client.py b/openstack_odooclient/base/client.py index 04bd4c2..dc9d314 100644 --- a/openstack_odooclient/base/client.py +++ b/openstack_odooclient/base/client.py @@ -167,6 +167,14 @@ def __init__( opener=opener, ) self._odoo.login(database, username, password) + self._env_manager_mapping: dict[str, RecordManagerBase] = {} + """An internal mapping between env (model) names and their managers. + + This is populated by the manager classes themselves when created, + and used by the ``Attachment.res_model_manager`` field. + + *Added in version 0.2.0.* + """ self._record_manager_mapping: dict[ Type[RecordBase], RecordManagerBase, diff --git a/openstack_odooclient/base/record.py b/openstack_odooclient/base/record.py index b97fa79..8be05c4 100644 --- a/openstack_odooclient/base/record.py +++ b/openstack_odooclient/base/record.py @@ -19,7 +19,7 @@ from dataclasses import dataclass from datetime import date, datetime -from types import MappingProxyType +from types import MappingProxyType, UnionType from typing import ( TYPE_CHECKING, Annotated, @@ -392,7 +392,8 @@ def _getattr_model_ref( # The following is for decoding a singular model ref value. # Check if the model ref is optional, and if it is, # return the desired value for when the value is empty. - if get_type_origin(attr_type) is Union: + attr_type_origin = get_type_origin(attr_type) + if attr_type_origin is Union or attr_type_origin is UnionType: unsupported_union = ( "Only unions of the format Optional[T], " "Union[T, type(None)] or Union[T, Literal[False]] " diff --git a/openstack_odooclient/base/record_manager.py b/openstack_odooclient/base/record_manager.py index b2afded..3088d99 100644 --- a/openstack_odooclient/base/record_manager.py +++ b/openstack_odooclient/base/record_manager.py @@ -109,6 +109,7 @@ def __init__(self, client: ClientBase) -> None: """The Odoo client object the manager uses.""" # Assign this record manager object as the manager # responsible for the configured record class in the client. + self._client._env_manager_mapping[self.env_name] = self self._client._record_manager_mapping[self.record_class] = self self._record_type_hints = MappingProxyType( get_type_hints( @@ -983,3 +984,6 @@ def _encode_value(self, type_hint: Any, value: Any) -> Any: v_type = get_type_args(type_hint)[0] return [self._encode_value(v_type, v) for v in value] return value + + +RecordManager = RecordManagerBase[RecordBase["RecordManager"]] diff --git a/openstack_odooclient/client.py b/openstack_odooclient/client.py index 00ea202..c68149e 100644 --- a/openstack_odooclient/client.py +++ b/openstack_odooclient/client.py @@ -18,6 +18,7 @@ from .base.client import ClientBase from .managers.account_move import AccountMoveManager from .managers.account_move_line import AccountMoveLineManager +from .managers.attachment import AttachmentManager from .managers.company import CompanyManager from .managers.credit import CreditManager from .managers.credit_transaction import CreditTransactionManager @@ -90,6 +91,9 @@ class Client(ClientBase): account_move_lines: AccountMoveLineManager """Account move (invoice) line manager.""" + attachments: AttachmentManager + """Attachment manager.""" + companies: CompanyManager """Company manager.""" diff --git a/openstack_odooclient/managers/account_move.py b/openstack_odooclient/managers/account_move.py index c401485..7df3d01 100644 --- a/openstack_odooclient/managers/account_move.py +++ b/openstack_odooclient/managers/account_move.py @@ -20,12 +20,13 @@ from ..base.record import ModelRef, RecordBase from ..base.record_manager_named import NamedRecordManagerBase +from .attachment import AttachmentMixin if TYPE_CHECKING: from collections.abc import Iterable, Mapping -class AccountMove(RecordBase["AccountMoveManager"]): +class AccountMove(AttachmentMixin, RecordBase["AccountMoveManager"]): amount_total: float """Total (taxed) amount charged on the account move (invoice).""" diff --git a/openstack_odooclient/managers/attachment.py b/openstack_odooclient/managers/attachment.py new file mode 100644 index 0000000..26f69b5 --- /dev/null +++ b/openstack_odooclient/managers/attachment.py @@ -0,0 +1,470 @@ +# Copyright (C) 2025 Catalyst Cloud Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import base64 + +from typing import TYPE_CHECKING, Annotated, Any, Literal + +from ..base.record import ModelRef, RecordBase +from ..base.record_manager import RecordManager, RecordManagerBase + +if TYPE_CHECKING: + from ..base.client import ClientBase + + +class Attachment(RecordBase["AttachmentManager"]): + access_token: str | Literal[False] + """An access token that can be used to + fetch the attachment, if defined. + """ + + checksum: str + """A SHA1 checksum of the attachment contents.""" + + company_id: Annotated[int | None, ModelRef("company_id", Company)] + """The ID for the company that owns this attachment, if set.""" + + company_name: Annotated[str | None, ModelRef("company_id", Company)] + """The name of the company that owns this attachment, if set.""" + + company: Annotated[Company | None, ModelRef("company_id", Company)] + """The company that owns this attachment, if set. + + This fetches the full record from Odoo once, + and caches it for subsequent accesses. + """ + + datas: str | Literal[False] + """The contents of the attachment, encoded in base64. + + Only applies when ``type`` is set to ``binary``. + + **This field is not fetched by default.** To make this field available, + use the ``fields`` parameter on the ``get`` or ``list`` methods to select + the ``datas`` field. + """ + + description: str | Literal[False] + """A description of the file, if defined.""" + + index_content: str + """The index content value computed from the attachment contents. + + **This field is not fetched by default.** To make this field available, + use the ``fields`` parameter on the ``get`` or ``list`` methods to select + the ``index_content`` field. + """ + + mimetype: str + """MIME type of the attached file.""" + + name: str + """The name of the attachment. + + Usually matches the filename of the attached file. + """ + + public: bool + """Whether or not the attachment is publicly accessible.""" + + res_field: str | Literal[False] + """The name of the field used to refer to this attachment + on the linked record's model, if set. + """ + + res_id: int | Literal[False] + """The ID of the record this attachment is linked to, if set.""" + + res_model: str | Literal[False] + """The name of the model of the record this attachment + is linked to, if set. + """ + + res_name: str | Literal[False] + """The name of the record this attachment is linked to, if set.""" + + store_fname: str | Literal[False] + """The stored filename for this attachment, if set.""" + + type: Literal["binary", "url"] + """The type of the attachment. + + When set to ``binary``, the contents of the attachment are available + using the ``datas`` field. When set to ``url``, the attachment can be + downloaded from the URL configured in the ``url`` field. + + Values: + + * ``binary`` - Stored internally as binary data + * ``url`` - Stored externally, accessible using a URL + """ + + url: str | Literal[False] + """The URL the contents of the attachment are available from. + + Only applies when ``type`` is set to ``url``. + """ + + @property + def res_model_manager(self) -> RecordManager | None: + """The manager for the model of the record + this attachment is linked to. + """ + if not self.res_model: + return None + return get_res_model_manager( + client=self._client, + res_model=self.res_model, + ) + + def download(self) -> bytes: + """Download this attachment, and return the contents as bytes. + + :return: Attachment contents + :rtype: bytes + """ + return download(manager=self._manager, attachment_id=self.id) + + def reupload(self, data: bytes, **fields: Any) -> None: + """Reupload a new version of the contents of this attachment, + and update the attachment in place. + + Other fields can be updated at the same time by passing them + as keyword arguments. + + Note that this attachment object not updated in place by + this method. If you need an updated version of the attachment + object, use the `refresh` method to fetch the latest version. + + :param data: Contents of the attachment + :type data: bytes + """ + reupload( + manager=self._manager, + attachment_id=self.id, + data=data, + **fields, + ) + + def register_as_main_attachment(self, force: bool = True) -> None: + """Register this attachment as the main attachment + of the record it is attached to. + + The model of the attached record must have the + ``message_main_attachment_id`` field defined. + + :param force: Overwrite if already set, defaults to True + :type force: bool, optional + """ + self._env.register_as_main_attachment(self.id, force=force) + + +class AttachmentManager(RecordManagerBase[Attachment]): + env_name = "ir.attachment" + record_class = Attachment + default_fields = ( + "access_token", + "checksum", + "company_id", + # datas not fetched by default + "description", + # index_content not fetched by default + "mimetype", + "name", + "public", + "res_field", + "res_id", + "res_model", + "res_name", + "store_fname", + "type", + "url", + ) + + def upload( + self, + name: str, + data: bytes, + *, + record: RecordBase | None = None, + res_id: int | None = None, + res_model: str | None = None, + **fields: Any, + ) -> int: + """Upload an attachment and associate it with the given record. + + One of ``record`` or ``res_id`` must be set to specify the record + to link the attachment to. When ``res_id`` is used, ``res_model`` + must also be specified to define the model of the record. + + When ``record`` is used, this is not necessary. + + Any keyword arguments passed to this method are passed to + the attachment record as fields. + + :param name: The name of the attachment + :type name: str + :param data: The contents of the attachment + :type data: bytes + :param record: The linked record, defaults to None + :type record: RecordBase | None, optional + :param res_id: The ID of the linked record, defaults to None + :type res_id: int | None, optional + :param res_model: The model of the linked record, defaults to None + :type res_model: str | None, optional + :return: The record ID of the newly uploaded attachment + :rtype: int + """ + return upload( + manager=self, + name=name, + data=data, + record=record, + res_id=res_id, + res_model=res_model, + **fields, + ) + + def download(self, attachment: int | Attachment) -> bytes: + """Download a given attachment, and return the contents as bytes. + + :param attachment: Attachment (ID or object) + :type attachment: int | Attachment + :return: Attachment contents + :rtype: bytes + """ + return download( + manager=self, + attachment_id=( + attachment.id + if isinstance(attachment, Attachment) + else attachment + ), + ) + + def reupload( + self, + attachment: int | Attachment, + data: bytes, + **fields: Any, + ) -> None: + """Reupload a new version of the contents of the given attachment, + and update the attachment in place. + + Other fields can be updated at the same time by passing them + as keyword arguments. + + :param data: Contents of the attachment + :type data: bytes + """ + reupload( + manager=self, + attachment_id=( + attachment.id + if isinstance(attachment, Attachment) + else attachment + ), + data=data, + **fields, + ) + + def register_as_main_attachment( + self, + attachment: int | Attachment, + force: bool = True, + ) -> None: + """Register the given attachment as the main attachment + of the record it is attached to. + + The model of the attached record must have the + ``message_main_attachment_id`` field defined. + + :param attachment: Attachment (ID or object) + :type attachment: int | Attachment + :param force: Overwrite if already set, defaults to True + :type force: bool, optional + """ + self._env.register_as_main_attachment( + ( + attachment.id + if isinstance(attachment, Attachment) + else attachment + ), + force=force, + ) + + +class AttachmentMixin: + message_main_attachment_id: Annotated[ + int | None, + ModelRef("message_main_attachment_id", Attachment), + ] + """The ID of the main attachment on the record, if there is one.""" + + message_main_attachment_name: Annotated[ + str | None, + ModelRef("message_main_attachment_name", Attachment), + ] + """The name of the main attachment on the record, if there is one.""" + + message_main_attachment: Annotated[ + Attachment | None, + ModelRef("message_main_attachment", Attachment), + ] + """The main attachment on the record, if there is one. + + This fetches the full record from Odoo once, + and caches it for subsequent accesses. + """ + + +def get_res_model_manager( + client: ClientBase, + res_model: str, +) -> RecordManager: + """Return the manager for the given model. + + :param client: Odoo client object + :type client: ClientBase + :param res_model: Model name + :type res_model: str + :return: Model manager + :rtype: RecordManager + """ + + return client._env_manager_mapping[res_model] + + +def upload( + *, + manager: AttachmentManager, + name: str, + data: bytes, + record: RecordBase | None = None, + res_id: int | None = None, + res_model: str | None = None, + **fields: Any, +) -> int: + """Upload an attachment and associate it with the given record. + + One of ``record`` or ``res_id`` must be set to specify the record + to link the attachment to. When ``res_id`` is used, ``res_model`` + must also be specified to define the model of the record. + + When ``record`` is used, this is not necessary. + + Any keyword arguments passed to this method are passed to + the attachment record as fields. + + :param manager: Attachment manager + :type manager: AttachmentManager + :param name: The name of the attachment + :type name: str + :param data: The contents of the attachment + :type data: bytes + :param record: The linked record, defaults to None + :type record: RecordBase | None, optional + :param res_id: The ID of the linked record, defaults to None + :type res_id: int | None, optional + :param res_model: The model of the linked record, defaults to None + :type res_model: str | None, optional + :return: The record ID of the newly uploaded attachment + :rtype: int + """ + + if record: + res_id = record.id + res_model = record._manager.env_name + elif not res_id: + raise ValueError( + ( + "Either record or res_id must be specified " + f"when uploading attachment: {name}" + ), + ) + + if not res_model: + raise ValueError( + ( + "res_model must be specified for a record reference using " + f"res_id {res_id} when uploading attachment: {name}" + ), + ) + + fields["type"] = "binary" + fields.pop("datas", None) + fields.pop("url", None) + + return manager.create( + name=name, + res_id=res_id, + res_model=res_model, + datas=base64.b64encode(data).decode(encoding="ascii"), + **fields, + ) + + +def download(manager: AttachmentManager, attachment_id: int) -> bytes: + """Download an attachment by ID, and return the contents as bytes. + + :param manager: Attachment manager + :type manager: AttachmentManager + :param attachment_id: ID of the attachment to download + :type attachment_id: int + :return: Attachment contents + :rtype: bytes + """ + + return base64.b64decode( + manager._env.read(attachment_id, fields=["datas"])[0]["datas"], + ) + + +def reupload( + *, + manager: AttachmentManager, + attachment_id: int, + data: bytes, + **fields: Any, +) -> None: + """Reupload a new version of the contents of the given attachment, + and update the attachment in place. + + Other fields can be updated at the same time by passing them + as keyword arguments. + + :param manager: Attachment manager + :type manager: AttachmentManager + :param attachment_id: Attachment ID + :type attachment_id: int + :param data: The contents of the attachment + :type data: bytes + """ + + fields.pop("type", None) + fields.pop("datas", None) + fields.pop("url", None) + + return manager.update( + attachment_id, + datas=base64.b64encode(data).decode(encoding="ascii"), + **fields, + ) + + +# NOTE(callumdickinson): Import here to make sure circular imports work. +from .company import Company # noqa: E402 diff --git a/openstack_odooclient/managers/product.py b/openstack_odooclient/managers/product.py index d57eaae..86c97f0 100644 --- a/openstack_odooclient/managers/product.py +++ b/openstack_odooclient/managers/product.py @@ -27,6 +27,12 @@ class Product(RecordBase["ProductManager"]): + active: bool + """Whether or not this product is active (enabled). + + *Added in version 0.2.0.* + """ + categ_id: Annotated[int, ModelRef("categ_id", ProductCategory)] """The ID for the category this product is under.""" @@ -75,6 +81,12 @@ class Product(RecordBase["ProductManager"]): name: str """The name of the product.""" + sale_ok: bool + """Whether or not this product is sellable. + + *Added in version 0.2.0.* + """ + uom_id: Annotated[int, ModelRef("uom_id", Uom)] """The ID for the Unit of Measure for this product.""" From acc497a837f0f00b5230f6f1516fac85a312f422 Mon Sep 17 00:00:00 2001 From: Callum Dickinson Date: Fri, 31 Oct 2025 17:02:05 +1300 Subject: [PATCH 3/3] Use lazy evaluation Refactor the record manager `list` and `search` methods, and other methods on record managers, so that it is possible to use lazy evaluation. The `list` and `search` methods now return `ListRecords` and `SearchRecords` objects, which do not perform any requests or processing at first. Calling the `as_ids`, `as_dicts` or `as_records` methods on these objects invokes the API request, but instead of returning lists as with the previous behaviour, generators are returned instead so the user can choose between performing per-item processing lazily on each iteration, or converting to a list to eagerly evaluate everything. This removes the `as_id` and `as_dict` optional parameters from the `list` and `search` methods, which were not possible to express correctly with generators due to bugs in how Mypy handles conditional overloads. Returning a common object that contains separate methods with different, static return types is easier to make compliant with static type checkers. --- openstack_odooclient/__init__.py | 4 +- openstack_odooclient/base/record_manager.py | 336 ++++++++---------- .../base/record_manager_with_unique_field.py | 12 +- openstack_odooclient/managers/product.py | 66 +--- .../managers/volume_discount_range.py | 2 +- 5 files changed, 171 insertions(+), 249 deletions(-) diff --git a/openstack_odooclient/__init__.py b/openstack_odooclient/__init__.py index eb02493..de8ca60 100644 --- a/openstack_odooclient/__init__.py +++ b/openstack_odooclient/__init__.py @@ -17,7 +17,7 @@ from .base.client import ClientBase from .base.record import FieldAlias, ModelRef, RecordBase -from .base.record_manager import RecordManagerBase +from .base.record_manager import ListRecords, RecordManagerBase, SearchRecords from .base.record_manager_coded import CodedRecordManagerBase from .base.record_manager_named import NamedRecordManagerBase from .base.record_manager_with_unique_field import ( @@ -112,6 +112,7 @@ "GrantManager", "GrantType", "GrantTypeManager", + "ListRecords", "ModelRef", "MultipleRecordsFoundError", "NamedRecordManagerBase", @@ -143,6 +144,7 @@ "SaleOrderLine", "SaleOrderLineManager", "SaleOrderManager", + "SearchRecords", "SupportSubscription", "SupportSubscriptionManager", "SupportSubscriptionType", diff --git a/openstack_odooclient/base/record_manager.py b/openstack_odooclient/base/record_manager.py index 3088d99..c14615b 100644 --- a/openstack_odooclient/base/record_manager.py +++ b/openstack_odooclient/base/record_manager.py @@ -47,7 +47,7 @@ from .record import FieldAlias, ModelRef, RecordBase if TYPE_CHECKING: - from collections.abc import Iterable, Mapping, Sequence + from collections.abc import Generator, Iterable, Mapping, Sequence from odoorpc import ODOO # type: ignore[import] from odoorpc.env import Environment # type: ignore[import] @@ -59,6 +59,142 @@ Record = TypeVar("Record", bound=RecordBase) +class ListRecords(Generic[Record]): + def __init__( + self, + manager: RecordManagerBase[Record], + ids: int | Iterable[int], + fields: Iterable[str] | None, + optional: bool, + ) -> None: + self._manager = manager + self._ids = (ids,) if isinstance(ids, int) else tuple(ids) + self._fields = tuple(fields) if fields else None + self._optional = optional + + @property + def ids(self) -> tuple[int, ...]: + return self._ids + + @property + def fields(self) -> tuple[str, ...] | None: + return self._fields + + @property + def optional(self) -> bool: + return self._optional + + def as_dicts(self) -> Generator[dict[str, Any], None, None]: + yield from ( + { + self._manager._get_local_field(field): value + for field, value in record.items() + } + for record in self._read() + ) + + def as_records(self) -> Generator[Record, None, None]: + yield from (self._as_record(record) for record in self._read()) + + def _read(self) -> Generator[dict[str, Any], None, None]: + if not self.ids: + yield from () + return + record_ids: set[int] = set() + for record in self._manager._env.read( + self.ids, + fields=self.fields, + ): + record_ids.add(record["id"]) + yield record + if not self.optional: + required_ids = set(self.ids) + missing_ids = required_ids - record_ids + if missing_ids: + raise RecordNotFoundError( + ( + f"{self._manager.record_class.__name__} records " + "with IDs not found: " + f"{', '.join(str(i) for i in sorted(missing_ids))}" + ), + ) + + def _as_record(self, record: dict[str, Any]) -> Record: + return self._manager.record_class( + client=self._manager._client, + record=record, + fields=self._fields, + ) + + +class SearchRecords(Generic[Record]): + def __init__( + self, + manager: RecordManagerBase[Record], + filters: Sequence[FilterCriterion] | None, + fields: Iterable[str] | None, + order: str | None, + ) -> None: + self._manager = manager + self._filters = ( + tuple(self._manager._encode_filters(filters)) + if filters + else tuple() + ) + self._fields = tuple(fields) if fields else None + self._order = order + + @property + def filters(self) -> tuple[FilterCriterion, ...]: + return self._filters + + @property + def fields(self) -> tuple[str, ...] | None: + return self._fields + + @property + def order(self) -> str | None: + return self._order + + def as_ids(self) -> Generator[int, None, None]: + yield from self._search() + + def as_dicts(self) -> Generator[dict[str, Any], None, None]: + yield from ( + { + self._manager._get_local_field(field): value + for field, value in record.items() + } + for record in self._read(self._search()) + ) + + def as_records(self) -> Generator[Record, None, None]: + yield from ( + self._as_record(record) for record in self._read(self._search()) + ) + + def _search(self) -> builtins.list[int]: + return self._manager._env.search( + self.filters, + order=self.order, + ) + + def _read(self, ids: builtins.list[int]) -> builtins.list[dict[str, Any]]: + if not ids: + return [] + return self._manager._env.read( + ids, + fields=self._fields, + ) + + def _as_record(self, record: dict[str, Any]) -> Record: + return self._manager.record_class( + client=self._manager._client, + record=record, + fields=self._fields, + ) + + class RecordManagerBase(Generic[Record]): """A generic record manager base class. @@ -164,43 +300,12 @@ def _env(self) -> Environment: """The OdooRPC environment object this record manager uses.""" return self._odoo.env[self.env_name] - @overload - def list( - self, - ids: int | Iterable[int], - *, - fields: Iterable[str] | None = ..., - as_dict: Literal[False] = ..., - optional: bool = ..., - ) -> builtins.list[Record]: ... - - @overload - def list( - self, - ids: int | Iterable[int], - *, - fields: Iterable[str] | None = ..., - as_dict: Literal[True], - optional: bool = ..., - ) -> builtins.list[dict[str, Any]]: ... - - @overload - def list( - self, - ids: int | Iterable[int], - *, - fields: Iterable[str] | None = ..., - as_dict: bool = ..., - optional: bool = ..., - ) -> builtins.list[Record] | builtins.list[dict[str, Any]]: ... - def list( self, ids: int | Iterable[int], fields: Iterable[str] | None = None, - as_dict: bool = False, optional: bool = False, - ) -> builtins.list[Record] | builtins.list[dict[str, Any]]: + ) -> ListRecords[Record]: """Get one or more specific records by ID. By default all fields available on the record model @@ -223,68 +328,18 @@ def list( :type ids: int | Iterable[int] :param fields: Fields to select, defaults to ``None`` (select all) :type fields: Iterable[str] | None, optional - :param as_dict: Return records as dictionaries, defaults to ``False`` - :type as_dict: bool, optional :param optional: Disable missing record errors, defaults to ``False`` :type optional: bool, optional :raises RecordNotFoundError: If IDs are required but some are missing - :return: List of records - :rtype: list[Record] | list[dict[str, Any]] + :return: Iterable of records + :rtype: Iterable[Record] | Iterable[dict[str, Any]] """ - if isinstance(ids, int): - _ids: int | list[int] = ids - else: - _ids = list(ids) - if not _ids: - return [] # type: ignore[return-value] - fields = fields or self.default_fields or None - _fields = ( - list( - dict.fromkeys( - (self._encode_field(f) for f in fields), - ).keys(), - ) - if fields is not None - else None - ) - records: Iterable[dict[str, Any]] = self._env.read( - _ids, - fields=_fields, + return ListRecords( + manager=self, + ids=ids, + fields=fields, + optional=optional, ) - if as_dict: - res_dicts = [ - { - self._get_local_field(field): value - for field, value in record_dict.items() - } - for record_dict in records - ] - else: - res_objs = [ - self.record_class( - client=self._client, - record=record, - fields=_fields, - ) - for record in records - ] - if not optional: - required_ids = {_ids} if isinstance(_ids, int) else set(_ids) - found_ids: set[int] = ( - set(record["id"] for record in res_dicts) - if as_dict - else set(record.id for record in res_objs) - ) - missing_ids = required_ids - found_ids - if missing_ids: - raise RecordNotFoundError( - ( - f"{self.record_class.__name__} records " - "with IDs not found: " - f"{', '.join(str(i) for i in sorted(missing_ids))}" - ), - ) - return res_dicts if as_dict else res_objs @overload def get( @@ -364,13 +419,12 @@ def get( :rtype: Record | list[str, Any] """ try: - return self.list( - id, - fields=fields, - as_dict=as_dict, - optional=True, - )[0] - except IndexError: + query = self.list(id, fields=fields, optional=True) + if as_dict: + return next(query.as_dicts()) + else: + return next(query.as_records()) + except StopIteration: if optional: return None else: @@ -381,75 +435,12 @@ def get( ), ) from None - @overload - def search( - self, - filters: Sequence[FilterCriterion] | None = ..., - fields: Iterable[str] | None = ..., - order: str | None = ..., - as_id: Literal[False] = ..., - as_dict: Literal[False] = ..., - ) -> builtins.list[Record]: ... - - @overload - def search( - self, - filters: Sequence[FilterCriterion] | None = ..., - fields: Iterable[str] | None = ..., - order: str | None = ..., - *, - as_id: Literal[True], - as_dict: Literal[False] = ..., - ) -> builtins.list[int]: ... - - @overload - def search( - self, - filters: Sequence[FilterCriterion] | None = ..., - fields: Iterable[str] | None = ..., - order: str | None = ..., - as_id: Literal[False] = ..., - *, - as_dict: Literal[True], - ) -> builtins.list[dict[str, Any]]: ... - - @overload - def search( - self, - filters: Sequence[FilterCriterion] | None = ..., - fields: Iterable[str] | None = ..., - order: str | None = ..., - *, - as_id: Literal[True], - as_dict: Literal[True], - ) -> builtins.list[int]: ... - - @overload - def search( - self, - filters: Sequence[FilterCriterion] | None = ..., - fields: Iterable[str] | None = ..., - order: str | None = ..., - as_id: bool = ..., - as_dict: bool = ..., - ) -> ( - builtins.list[Record] - | builtins.list[int] - | builtins.list[dict[str, Any]] - ): ... - def search( self, filters: Sequence[FilterCriterion] | None = None, fields: Iterable[str] | None = None, order: str | None = None, - as_id: bool = False, - as_dict: bool = False, - ) -> ( - builtins.list[Record] - | builtins.list[int] - | builtins.list[dict[str, Any]] - ): + ) -> SearchRecords[Record]: """Query the ERP for records, optionally defining filters to constrain the search and other parameters, and return the results. @@ -521,30 +512,15 @@ def search( :type fields: Iterable[str] or None, optional :param order: Order results by field name, defaults to ``None`` :type order: str or None, optional - :param as_id: Return the record IDs only, defaults to ``False`` - :type as_id: bool, optional - :param as_dict: Return records as dictionaries, defaults to ``False`` - :type as_dict: bool, optional :return: List of records :rtype: list[Record] | list[int] | list[dict[str, Any]] """ - ids: list[int] = self._env.search( - (self._encode_filters(filters) if filters else []), + return SearchRecords( + manager=self, + filters=filters, + fields=fields, order=order, ) - if as_id: - return ids - if ids: - return self.list( - ids, - fields=fields, - as_dict=as_dict, - # A race condition might occur where a record is deleted - # after finding the ID but before querying the contents of it. - # If this happens, silently drop the record ID from the result. - optional=True, - ) - return [] # type: ignore[return-value] def _encode_filters( self, diff --git a/openstack_odooclient/base/record_manager_with_unique_field.py b/openstack_odooclient/base/record_manager_with_unique_field.py index f263b3d..b77095f 100644 --- a/openstack_odooclient/base/record_manager_with_unique_field.py +++ b/openstack_odooclient/base/record_manager_with_unique_field.py @@ -227,16 +227,22 @@ def _get_by_unique_field( """ field_filter = [(field, "=", value)] try: - records = self.search( + query = self.search( filters=( list(itertools.chain(field_filter, filters)) if filters else field_filter ), fields=fields, - as_id=as_id, - as_dict=as_dict, ) + if as_id: + records: list[int | dict[str, Any] | Record] = list( + query.as_ids(), + ) + elif as_dict: + records = list(query.as_dicts()) + else: + records = list(query.as_records()) if len(records) > 1: raise MultipleRecordsFoundError( ( diff --git a/openstack_odooclient/managers/product.py b/openstack_odooclient/managers/product.py index 86c97f0..1640961 100644 --- a/openstack_odooclient/managers/product.py +++ b/openstack_odooclient/managers/product.py @@ -18,6 +18,7 @@ from typing import TYPE_CHECKING, Annotated, Any, Literal, overload from ..base.record import ModelRef, RecordBase +from ..base.record_manager import SearchRecords from ..base.record_manager_with_unique_field import ( RecordManagerWithUniqueFieldBase, ) @@ -105,69 +106,12 @@ class ProductManager(RecordManagerWithUniqueFieldBase[Product, str]): env_name = "product.product" record_class = Product - @overload - def get_sellable_company_products( - self, - company: int | Company, - *, - fields: Iterable[str] | None = ..., - order: str | None = ..., - as_id: Literal[False] = ..., - as_dict: Literal[False] = ..., - ) -> list[Product]: ... - - @overload - def get_sellable_company_products( - self, - company: int | Company, - *, - fields: Iterable[str] | None = ..., - order: str | None = ..., - as_id: Literal[True], - as_dict: Literal[False] = ..., - ) -> list[int]: ... - - @overload - def get_sellable_company_products( - self, - company: int | Company, - fields: Iterable[str] | None = ..., - order: str | None = ..., - *, - as_id: Literal[True], - as_dict: Literal[True], - ) -> list[int]: ... - - @overload - def get_sellable_company_products( - self, - company: int | Company, - *, - fields: Iterable[str] | None = ..., - order: str | None = ..., - as_id: Literal[False] = ..., - as_dict: Literal[True], - ) -> list[dict[str, Any]]: ... - - @overload - def get_sellable_company_products( - self, - company: int | Company, - *, - fields: Iterable[str] | None = ..., - order: str | None = ..., - as_id: bool = ..., - as_dict: bool = ..., - ) -> list[Product] | list[int] | list[dict[str, Any]]: ... - def get_sellable_company_products( self, company: int | Company, fields: Iterable[str] | None = None, order: str | None = None, - as_id: bool = False, - as_dict: bool = False, - ) -> list[Product] | list[int] | list[dict[str, Any]]: + ) -> SearchRecords[Product]: """Fetch a list of active and saleable products for the given company. :param company: The company to search for products (ID or object) @@ -176,10 +120,6 @@ def get_sellable_company_products( :type fields: Iterable[str] | None, optional :param order: Order results by a specific field, defaults to None :type order: str | None, optional - :param as_id: Return the record IDs only, defaults to False - :type as_id: bool, optional - :param as_dict: Return records as dictionaries, defaults to False - :type as_dict: bool, optional :return: List of products :rtype: list[Product] | list[int] | list[dict[str, Any]] """ @@ -191,8 +131,6 @@ def get_sellable_company_products( ], fields=fields, order=order, - as_id=as_id, - as_dict=as_dict, ) @overload diff --git a/openstack_odooclient/managers/volume_discount_range.py b/openstack_odooclient/managers/volume_discount_range.py index f6d68b6..e591d8a 100644 --- a/openstack_odooclient/managers/volume_discount_range.py +++ b/openstack_odooclient/managers/volume_discount_range.py @@ -108,7 +108,7 @@ def get_for_charge( """ ranges = self.search( [("customer_group", "=", customer_group or False)], - ) + ).as_records() found_ranges: list[VolumeDiscountRange] = [] for vol_range in ranges: if charge < vol_range.min: