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/11.added.md b/changelog.d/11.added.md new file mode 100644 index 0000000..ef6fcb1 --- /dev/null +++ b/changelog.d/11.added.md @@ -0,0 +1 @@ +Add support for managing attachments diff --git a/docs/managers/account-move.md b/docs/managers/account-move.md index 3e2ac57..263a0a2 100644 --- a/docs/managers/account-move.md +++ b/docs/managers/account-move.md @@ -177,6 +177,40 @@ is_move_sent: bool Whether or not the account move (invoice) has been sent. +### `message_main_attachment_id` + +```python +message_main_attachment_id: int | None +``` + +The ID of the main [attachment](attachment.md) on the account move (invoice), +if there is one. + +*Added in version 0.2.0.* + +### `message_main_attachment_name` + +```python +message_main_attachment_name: str | None +``` + +The name of the main [attachment](attachment.md) on the account move (invoice), +if there is one. + +*Added in version 0.2.0.* + +### `message_main_attachment` + +```python +message_main_attachment: Attachment | None +``` +The main [attachment](attachment.md) on the account move (invoice), if there is one. + +This fetches the full record from Odoo once, +and caches it for subsequent accesses. + +*Added in version 0.2.0.* + ### `move_type` ```python diff --git a/docs/managers/attachment.md b/docs/managers/attachment.md new file mode 100644 index 0000000..9535255 --- /dev/null +++ b/docs/managers/attachment.md @@ -0,0 +1,527 @@ +# 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[Any] | 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` +(and in some cases, `res_field`) 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, +... ) +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[Any] | 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 = attachment.refresh() +>>> 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/custom.md b/docs/managers/custom.md index 0c5fd6a..9b040c7 100644 --- a/docs/managers/custom.md +++ b/docs/managers/custom.md @@ -932,6 +932,60 @@ class CustomRecordManager( For more information on using record managers with unique `code` fields, see [Coded Record Managers](index.md#coded-record-managers). +#### Records with Attachments + +If your record can have [attachments](attachment.md) associated with it, +you can use the `RecordWithAttachmentMixin` mixin to define the associated +fields used to reference the attachment record. + +```python +from __future__ import annotations + +from openstack_odooclient import ( + RecordBase, + RecordManagerBase, + RecordWithAttachmentMixin, +) + +class CustomRecord( + RecordBase["CustomRecordManager"], + RecordWithAttachmentMixin["CustomRecordManager"], +): + custom_field: str + """Description of the field.""" + + # Added by RecordWithAttachmentMixin: + # + # 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. + # """ + +class CustomRecordManager(RecordManagerBase[CustomRecord]): + env_name = "custom.record" + record_class = CustomRecord +``` + +For more information on using attachments, +see [Records with Attachments](index.md#records-with-attachments). + ### Creating Mixins It is possible to create your own custom mixins to incorporate into diff --git a/docs/managers/index.md b/docs/managers/index.md index 4fccb4b..2c69b25 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) @@ -1480,6 +1481,187 @@ Traceback (most recent call last): openstack_odooclient.exceptions.RecordNotFoundError: User record not found with ID: 1234 ``` +## Records with Attachments + +Some records can have [attachments](attachment.md) uploaded and associated with them. + +* [Account Moves (Invoices)](account-move.md) + +When fetching an attachment record from Odoo, the contents of the attachment +(available using the `datas` field) are not fetched by default. This is to +ensure that downloading only takes place when requested, preventing +unexpected large downloads. + +```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) +>>> attachment = odoo_client.attachments.get( +... account_move.message_main_attachment_id, +... ) +>>> attachment +Attachment(record={'id': 5678, ...}, fields=None) +>>> attachment.datas # AttributeError +``` + +Common attachment operations for binary attachments +(attachments with a `type` of `binary`) are documented below. + +In addition to regular binary attachments, Odoo +also supports URL-referenced attachments and a number +of other useful fields; for more information see the +manager page for [attachments](attachment.md). + +### Downloading + +Attachments can be downloaded by simply calling the `download` method +on an attachment object. + +The contents of the attachment will be returned as a `bytes` object. + +```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) +>>> attachment = odoo_client.attachments.get( +... account_move.message_main_attachment_id, +... ) +>>> attachment.download() +b'Hello, world!' +``` + +### Uploading + +New attachments can be uploaded and associated with a record +using the `upload` method. + +For more information, see the documentation for the +[`upload`](attachment.md#upload) method. + +```python +>>> from openstack_odooclient import Client as OdooClient +>>> odoo_client = OdooClient( +... hostname="localhost", +... port=8069, +... protocol="jsonrpc", +... database="odoodb", +... user="test-user", +... password="", +... ) +>>> contents = b"Hello, world!" +>>> account_move = odoo_client.account_moves.get(1234) +>>> odoo_client.attachments.upload( +... "example.txt", +... contents, +... record=account_move, +... ) +5678 +``` + +### Evaluating checksums + +Attachments have a `checksum` field, which is a SHA-1 hash of the attachment +stored by Odoo that can be used to efficiently verify the integrity of the +uploaded attachment without redownloading the contents. + +```python +>>> import hashlib +>>> from openstack_odooclient import Client as OdooClient +>>> odoo_client = OdooClient( +... hostname="localhost", +... port=8069, +... protocol="jsonrpc", +... database="odoodb", +... user="test-user", +... password="", +... ) +>>> contents = b"Hello, world!" +>>> account_move = odoo_client.account_moves.get(1234) +>>> attachment_id = odoo_client.attachments.upload( +... "example.txt", +... contents, +... record=account_move, +... ) +>>> attachment.checksum == hashlib.sha1(contents).hexdigest() +True +``` + +### In-place updates + +If you'd like to update an attachment in-place without creating +a new record (and then having to delete the old one), this can +be done using the `reupload` method on the attachment object. + +For more information, see the documentation for the +[`reupload`](attachment.md#reupload_1) method. + +```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) +>>> attachment = odoo_client.attachments.get( +... account_move.message_main_attachment_id, +... ) +>>> attachment.download() +b'Hello, world!' +>>> attachment.reupload(b"Goodbye, world!") +>>> attachment = attachment.refresh() +>>> attachment.download() +b'Goodbye, world!' +``` + +### Attributes and Methods + +Record types that can have attachments associated with them +have the following fields added. + +#### `message_main_attachment_id` + +```python +message_main_attachment_id: int | None +``` + +The ID of the main [attachment](attachment.md) on the record, if there is one. + +#### `message_main_attachment_name` + +```python +message_main_attachment_name: str | None +``` + +The name of the main [attachment](attachment.md) on the record, if there is one. + +#### `message_main_attachment` + +```python +message_main_attachment: Attachment | None +``` +The main [attachment](attachment.md) on the record, if there is one. + +This fetches the full record from Odoo once, +and caches it for subsequent accesses. + ## Custom Managers and Record Types The OpenStack Odoo Client library supports defining new record types and adding 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 22603bd..07ddd3d 100644 --- a/openstack_odooclient/__init__.py +++ b/openstack_odooclient/__init__.py @@ -31,6 +31,10 @@ AccountMoveLine, AccountMoveLineManager, ) +from .managers.attachment import ( + Attachment, + AttachmentManager, +) from .managers.company import Company, CompanyManager from .managers.credit import Credit, CreditManager from .managers.credit_transaction import ( @@ -76,6 +80,7 @@ from .managers.voucher_code import VoucherCode, VoucherCodeManager from .mixins.coded_record import CodedRecordManagerMixin, CodedRecordMixin from .mixins.named_record import NamedRecordManagerMixin, NamedRecordMixin +from .mixins.record_with_attachment import RecordWithAttachmentMixin __all__ = [ "RM", @@ -83,6 +88,8 @@ "AccountMoveLine", "AccountMoveLineManager", "AccountMoveManager", + "Attachment", + "AttachmentManager", "Client", "ClientBase", "ClientError", @@ -130,6 +137,7 @@ "RecordManagerProtocol", "RecordNotFoundError", "RecordProtocol", + "RecordWithAttachmentMixin", "ReferralCode", "ReferralCodeManager", "Reseller", diff --git a/openstack_odooclient/base/client.py b/openstack_odooclient/base/client.py index db5fe6c..9cdea81 100644 --- a/openstack_odooclient/base/client.py +++ b/openstack_odooclient/base/client.py @@ -19,7 +19,7 @@ import urllib.request from pathlib import Path -from typing import TYPE_CHECKING, Literal, Type, overload +from typing import TYPE_CHECKING, Any, Literal, Type, overload from odoorpc import ODOO # type: ignore[import] from packaging.version import Version @@ -167,9 +167,17 @@ def __init__( opener=opener, ) self._odoo.login(database, username, password) + self._env_manager_mapping: dict[str, RecordManagerBase[Any]] = {} + """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, + Type[RecordBase[Any]], + RecordManagerBase[Any], ] = {} """An internal mapping between record classes and their managers. diff --git a/openstack_odooclient/base/record_manager/base.py b/openstack_odooclient/base/record_manager/base.py index 1b80f0c..d420d3f 100644 --- a/openstack_odooclient/base/record_manager/base.py +++ b/openstack_odooclient/base/record_manager/base.py @@ -93,6 +93,7 @@ def __init__(self, client: ClientBase) -> None: self._client_ = client # 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( 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 28dc955..0c4dad7 100644 --- a/openstack_odooclient/managers/account_move.py +++ b/openstack_odooclient/managers/account_move.py @@ -22,6 +22,7 @@ from ..base.record.types import ModelRef from ..base.record_manager.base import RecordManagerBase from ..mixins.named_record import NamedRecordManagerMixin, NamedRecordMixin +from ..mixins.record_with_attachment import RecordWithAttachmentMixin if TYPE_CHECKING: from collections.abc import Iterable, Mapping @@ -30,6 +31,7 @@ class AccountMove( RecordBase["AccountMoveManager"], NamedRecordMixin["AccountMoveManager"], + RecordWithAttachmentMixin["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..ecde516 --- /dev/null +++ b/openstack_odooclient/managers/attachment.py @@ -0,0 +1,451 @@ +# 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.base import RecordBase +from ..base.record.types import ModelRef +from ..base.record_manager.base import 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) -> RecordManagerBase[Any] | 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. Any keyword arguments passed to this + method are passed to the attachment record as fields. + + 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[Any] | 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`` + (and in some cases, ``res_field``) 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[Any] | 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. Any keyword arguments passed to this + method are passed to the attachment record as fields. + + :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, + ) + + +def get_res_model_manager( + client: ClientBase, + res_model: str, +) -> RecordManagerBase[Any]: + """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: RecordManagerBase[Any] + """ + + return client._env_manager_mapping[res_model] + + +def upload( + *, + manager: AttachmentManager, + name: str, + data: bytes, + record: RecordBase[Any] | 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[Any] | 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. Any keyword arguments passed to this + method are passed to the attachment record as fields. + + :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/mixins/record_with_attachment.py b/openstack_odooclient/mixins/record_with_attachment.py new file mode 100644 index 0000000..79608e5 --- /dev/null +++ b/openstack_odooclient/mixins/record_with_attachment.py @@ -0,0 +1,54 @@ +# 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 + +from typing import Annotated, Generic + +from ..base.record.base import RM, RecordProtocol +from ..base.record.types import ModelRef +from ..managers.attachment import Attachment + + +class RecordWithAttachmentMixin(RecordProtocol[RM], Generic[RM]): + """A record mixin for record types with an attachment associated with it. + + Include this mixin to add the ``message_main_attachment_id``, + ``message_main_attachment_name`` and ``message_main_attachment`` fields + to your record class, which allow attachments associated with the record + to be referenced. + """ + + 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. + """