Skip to main content

Lightweight Odoo XML-RPC client with buit-in payroll novedades support

Project description

unergy-odoo

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

Installation

# From PyPI
pip install unergy-odoo

# With uv
uv add unergy-odoo

Configuration

Credentials are resolved from environment variables by default:

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.

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

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.

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.

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.

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:

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
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.

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

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

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

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:

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

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

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

INSTALLED_APPS = [
    ...
    "django.contrib.contenttypes",  # required (usually already present)
    "unergy_odoo.django",
]
python manage.py migrate unergy_odoo_django

Configuration

# 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

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:

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:

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:

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 {}

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

unergy_odoo-0.7.0.tar.gz (14.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

unergy_odoo-0.7.0-py3-none-any.whl (19.1 kB view details)

Uploaded Python 3

File details

Details for the file unergy_odoo-0.7.0.tar.gz.

File metadata

  • Download URL: unergy_odoo-0.7.0.tar.gz
  • Upload date:
  • Size: 14.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.3

File hashes

Hashes for unergy_odoo-0.7.0.tar.gz
Algorithm Hash digest
SHA256 69bdff7020baff85a8db57b6da5793a09b6265a563c3d6f8291ef95997f6f7b1
MD5 1b389d24fabefc8329d2a176188cc551
BLAKE2b-256 c527a322212001bbb9ac86a55686b9aa37f18ecddba376f2820a88ced1783ed2

See more details on using hashes here.

File details

Details for the file unergy_odoo-0.7.0-py3-none-any.whl.

File metadata

  • Download URL: unergy_odoo-0.7.0-py3-none-any.whl
  • Upload date:
  • Size: 19.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.3

File hashes

Hashes for unergy_odoo-0.7.0-py3-none-any.whl
Algorithm Hash digest
SHA256 6980d918b8cf628fb6a29b2b762663a6f0f16e5b3cf6724c50ff64515814e8d1
MD5 d642d2801123c61ce7c60aed331807e2
BLAKE2b-256 8f8eb125d668cc382b934c5bfae48edae85a450c69e62af3500042d4f4a6e9f7

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page