Metadata-Version: 2.4
Name: unergy-odoo
Version: 0.7.0
Summary: Lightweight Odoo XML-RPC client with buit-in payroll novedades support
License-File: LICENSE
Requires-Python: >=3.10
Provides-Extra: django
Requires-Dist: cryptography>=39.0; extra == 'django'
Requires-Dist: django>=3.2; extra == 'django'
Description-Content-Type: text/markdown

# unergy-odoo

Lightweight Odoo XML-RPC client for Python. No Django required.

## Installation

```bash
# From PyPI
pip install unergy-odoo

# With uv
uv add unergy-odoo
```

## Configuration

Credentials are resolved from environment variables by default:

```bash
export ODOO_HOST="https://your-instance.odoo.com"
export ODOO_DB="your-database"
export ODOO_USERNAME="user@example.com"
export ODOO_PASSWORD="your-api-key"
```

### Multicompany support

Use `OdooCredentials` to bundle credentials for a specific company and pass them
to any class. The library does not care where the values come from.

```python
from unergy_odoo import Odoo, OdooCredentials

creds_a = OdooCredentials(
    host="https://company-a.odoo.com",
    db="company_a",
    username="admin@a.com",
    password="secret",
)
creds_b = OdooCredentials(
    host="https://company-b.odoo.com",
    db="company_b",
    username="admin@b.com",
    password="secret",
)

partners_a = Odoo("res.partner", credentials=creds_a)
partners_b = Odoo("res.partner", credentials=creds_b)
```

Credential resolution order for all classes:

1. `credentials` argument (`OdooCredentials` instance)
2. Individual keyword arguments (`host`, `db`, `username`, `password`)
3. Environment variables

## Usage

### `Odoo` — model client

```python
from unergy_odoo import Odoo

# Query records
payslips = Odoo("hr.payslip").filter(
    fields=["name", "state", "employee_id"],
    filter=[["state", "=", "done"]],
    limit=50,
)

# Get a single record (raises Odoo.DoesNotExist if not found)
employee = Odoo("hr.employee").get(filter=[["identification_id", "=", "1234567890"]])

# Count
total = Odoo("hr.contract").count(filter=[["state", "=", "open"]])

# Create / update / delete
record_id = Odoo("res.partner").create({"name": "Acme", "email": "acme@example.com"})
Odoo("res.partner").update([record_id], {"phone": "+57 300 000 0000"})
Odoo("res.partner").delete([record_id])
```

### `OdooExplorer` — read-only introspection

Useful for discovering models, fields, and relations without risking writes.

```python
from unergy_odoo import OdooExplorer

explorer = OdooExplorer()

# Find models by keyword
explorer.search_models("payslip")
explorer.search_models("nomina")

# Inspect fields
explorer.model_fields("hr.payslip")
explorer.model_fields("hr.payslip", field_type="selection")

# Inspect relational fields only
explorer.model_relations("hr.payslip")

# Count records (with optional domain)
explorer.count_records("hr.payslip")
explorer.count_records("hr.contract", [["state", "=", "open"]])

# Fetch sample records
explorer.sample("hr.payslip", limit=2)
explorer.sample("hr.payslip", fields=["name", "state", "employee_id"])
```

### `OdooManager` — base connection

Use directly when you need raw `execute_kw` access.

```python
from unergy_odoo import OdooManager

mgr = OdooManager()
uid = mgr.authenticate()
result = mgr._exec(mgr.db, uid, mgr.password, "hr.payslip", "search_count", [[]])
```

## Novedades (payroll attachments)

High-level dataclasses for registering `hr.salary.attachment` records in Odoo.

### `TypePayment`

Constants and helpers for payment period logic.

```python
from unergy_odoo import TypePayment
from datetime import date

# Constants
TypePayment.MONTHLY          # "monthly"
TypePayment.FIRST_HALF       # "first_half"
TypePayment.SECOND_HALF      # "second_half"
TypePayment.BOTH_FORTNIGHT   # "both_fortnight"

# Determine the payment half from a date
tp = TypePayment.determine_type(date(2026, 4, 10))   # "second_half"
tp = TypePayment.determine_type(date(2026, 4, 1))    # "first_half"

# Get the actual payment date for a period
pd = TypePayment.determine_date(date(2026, 4, 10), TypePayment.SECOND_HALF)  # date(2026, 4, 30)
pd = TypePayment.determine_date(date(2026, 4, 1),  TypePayment.FIRST_HALF)   # date(2026, 4, 15)
```

| Value | Quincena |
|---|---|
| `TypePayment.MONTHLY` | Mensual |
| `TypePayment.FIRST_HALF` | Primera quincena |
| `TypePayment.SECOND_HALF` | Segunda quincena |
| `TypePayment.BOTH_FORTNIGHT` | Ambas quincenas |

### Auto-closing previous novedades

By default, `register()` closes all active (`state='open'`) novedades for the
same employee and `deduction_type` before creating the new one, preventing
double payments.

To disable this behaviour, pass `complete_previous=False`:

```python
bono = BonoGimnasio(
    ...,
    complete_previous=False,
)
odoo_id = bono.register()
```

### Common optional fields

All novedad types inherit these optional fields from `NovedadBase`. They are only
sent to Odoo when their value is not `None`.

| Field | Type | Description |
|---|---|---|
| `total_amount` | `float \| None` | Total amount of the concept (e.g. for fixed-amount payments) |
| `remaining_amount` | `float \| None` | Remaining unpaid balance |

```python
deuda = Deuda(
    identification="1234567890",
    description="Préstamo equipo",
    monthly_amount=45_000,
    date_start=date(2026, 4, 1),
    type_payment=TypePayment.FIRST_HALF,
    total_amount=180_000,
    remaining_amount=135_000,
)
```

### Extra fields

Pass any arbitrary Odoo field via `extra_fields`. Values that are `None` are
ignored. These are merged last, so they take precedence over all other fields.

```python
bono = BonoGimnasio(
    identification="1234567890",
    description="GIMNASIO JOHN DOE 2026-04-15 #10",
    monthly_amount=80_000,
    date_start=date(2026, 4, 1),
    type_payment=TypePayment.SECOND_HALF,
    extra_fields={"x_custom_field": "value", "note": "approved by HR"},
)
```

### `BonoGimnasio`

```python
from datetime import date
from unergy_odoo import BonoGimnasio, TypePayment

date_start = date(2026, 4, 1)
bono = BonoGimnasio(
    identification="1234567890",
    description="GIMNASIO JOHN DOE 2026-04-15 #10",
    monthly_amount=80_000,
    date_start=date_start,
    type_payment=TypePayment.determine_type(date_start),
)
odoo_id = bono.register()
```

### `Viatico`

```python
from datetime import date
from unergy_odoo import Viatico, TypePayment

viatico = Viatico(
    identification="1234567890",
    description="Viático viaje Bogotá",
    monthly_amount=150_000,
    date_start=date(2026, 4, 1),
    type_payment=TypePayment.FIRST_HALF,
    attachments=["soporte.pdf"],        # str, Path, or file-like object
)
odoo_id = viatico.register()

# Fixed total amount (single payment)
viatico = Viatico(
    identification="1234567890",
    description="Viático viaje Medellín",
    monthly_amount=0,
    date_start=date(2026, 4, 1),
    type_payment=TypePayment.MONTHLY,
    total_amount=300_000,
    date_end=date(2026, 4, 30),
)
```

### `Deuda`

```python
from datetime import date
from unergy_odoo import Deuda, TypePayment

deuda = Deuda(
    identification="1234567890",
    description="Comidas 2026-04-01/2026-04-15 — D:3 A:5 C:0",
    monthly_amount=45_000,
    date_start=date(2026, 4, 1),
    type_payment=TypePayment.FIRST_HALF,
    date_end=date(2026, 4, 15),         # optional
)
odoo_id = deuda.register()
```

### Credential overrides per novedad

Pass an `OdooCredentials` instance to target a specific company:

```python
from unergy_odoo import BonoGimnasio, OdooCredentials

creds = OdooCredentials(host="https://staging.odoo.com", db="staging-db",
                        username="test@example.com", password="staging-key")

bono = BonoGimnasio(..., odoo_credentials=creds)
```

---

## Django integration

An optional Django app that stores Odoo credentials encrypted in your database,
linked to any company model you already have.

### Installation

```bash
pip install "unergy-odoo[django]"
# or
uv add "unergy-odoo[django]"
```

Add `"unergy_odoo.django"` to `INSTALLED_APPS` and run migrations:

```python
INSTALLED_APPS = [
    ...
    "django.contrib.contenttypes",  # required (usually already present)
    "unergy_odoo.django",
]
```

```bash
python manage.py migrate unergy_odoo_django
```

### Configuration

```python
# settings.py

def _resolve_company(email: str):
    """Maps any argument to a company model instance."""
    from myapp.models import Company
    domain = email.split("@")[-1].lower()
    return Company.objects.get(email_domain=domain)

UNERGY_ODOO = {
    # Required — Fernet symmetric key for encrypting passwords at rest.
    # Generate: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
    # Also accepted via UNERGY_ODOO_FERNET_KEY environment variable.
    "FERNET_KEY": "your-fernet-key",

    # Required for get_odoo_credentials() — callable or dotted import path.
    # Signature: (arg: Any) -> company_instance
    "COMPANY_RESOLVER": _resolve_company,
    # or as a string: "COMPANY_RESOLVER": "myapp.utils.resolve_company",
}
```

### Usage

#### Resolving credentials

```python
from unergy_odoo.django.credentials import get_odoo_credentials, NoOdooCredentials

try:
    creds = get_odoo_credentials(request.user.email)
except NoOdooCredentials as e:
    # No credentials configured for this user's company
    return Response({"error": str(e)}, status=400)

# Pass to any Odoo client or novedad
partners = Odoo("res.partner", credentials=creds)
bono = BonoGimnasio(..., odoo_credentials=creds)
```

#### Linking credentials to a company

Via the Django admin — or programmatically:

```python
from django.contrib.contenttypes.models import ContentType
from unergy_odoo.django.models import OdooCredentials

company = Company.objects.get(email_domain="acme.io")
ct = ContentType.objects.get_for_model(company)

OdooCredentials.objects.create(
    content_type=ct,
    object_id=company.pk,
    host="https://acme.odoo.com",
    db="acme_db",
    username="admin@acme.io",
    password="api-key",          # encrypted automatically
)
```

#### Admin inline

Embed credentials directly in your existing Company admin:

```python
from django.contrib import admin
from unergy_odoo.django.admin import OdooCredentialsInline

@admin.register(Company)
class CompanyAdmin(admin.ModelAdmin):
    inlines = [OdooCredentialsInline]
```

---

### Adding new novedad types

Subclass `NovedadBase`, set `DEDUCTION_TYPE`, and override `_payload()` for any extra fields:

```python
from dataclasses import dataclass, field
from unergy_odoo import NovedadBase

@dataclass
class AuxilioTransporte(NovedadBase):
    DEDUCTION_TYPE: str = field(init=False, default="AUX_TRANSPORTE")

    def _payload(self) -> dict:
        return {}
```
