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..4e1d9df 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( @@ -360,7 +361,7 @@ def get( :param optional: Return ``None`` if not found, defaults to ``False`` :raises RecordNotFoundError: Record with the given ID not found :return: List of records - :rtype: Record | list[str, Any] + :rtype: Record | dict[str, Any] """ try: return self.list( @@ -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..0703c76 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.""" @@ -53,8 +59,8 @@ class Product(RecordBase["ProductManager"]): and caches it for subsequent accesses. """ - default_code: str - """The Default Code for this product. + default_code: str | Literal[False] + """The Default Code for this product, if set. In the OpenStack Integration add-on, this is used to store the rated unit for the service product. @@ -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."""