Compare commits

...

8 commits

Author SHA1 Message Date
e18cafa813 Implement user:odoo contact mapping
All checks were successful
Tests / test (push) Successful in 24s
ref #60
2025-05-26 13:09:09 +02:00
22ff769b2c Fully implement odoo user search 2025-05-26 13:09:09 +02:00
8f75db5325 Use class-based odoo client for connection reuse 2025-05-26 13:09:09 +02:00
6ce13126d5 Implement user search in odoo 2025-05-26 13:09:09 +02:00
0bd620d68e Add system checks 2025-05-26 13:09:09 +02:00
549e1fa19a Add Odoo settings 2025-05-26 13:09:08 +02:00
97fc045375 First stab at Odoo filter logic 2025-05-26 13:08:07 +02:00
1ed383ea10 Add odoo fields 2025-05-26 13:08:07 +02:00
9 changed files with 298 additions and 2 deletions

View file

@ -58,3 +58,8 @@ SERVALA_KEYCLOAK_SERVER_URL=''
# SERVALA_S3_REGION_NAME='eu-central-1'
# SERVALA_S3_ADDRESSING_STYLE='virtual'
# SERVALA_S3_SIGNATURE_VERSION='s3v4'
SERVALA_ODOO_DB=''
SERVALA_ODOO_URL=''
SERVALA_ODOO_USERNAME=''
SERVALA_ODOO_PASSWORD=''

View file

@ -129,6 +129,7 @@ uv run --env-file=.env src/manage.py COMMAND
Useful commands:
- ``migrate``: Make sure database migrations are applied.
- ``check --deploy``: Runs checks, e.g. for missing or mismatched configuration, including custom servala configuration.
- ``showmigrations``: Show current database migrations status. Good for debugging.
- ``runserver``: Run development server
- ``clearsessions``: Clear away expired user sessions. Recommended to run regularly, e.g. weekly or monthly (doesnt

View file

@ -4,3 +4,6 @@ from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "servala.core"
def ready(self):
import servala.core.checks # noqa

View file

@ -0,0 +1,86 @@
from django.conf import settings
from django.core.checks import ERROR, WARNING, CheckMessage, register
@register()
def check_servala_settings(app_configs, **kwargs):
"""Checks all settings that should be present in all environments."""
if app_configs:
# Dont run if were meant to only test individual apps
return []
errors = []
required_fields = ("URL", "DB", "USERNAME", "PASSWORD")
missing_fields = [
field for field in required_fields if not settings.ODOO.get(field)
]
if missing_fields:
fields = ", ".join(missing_fields)
errors.append(
CheckMessage(
level=WARNING if settings.DEBUG else ERROR,
msg=f"Missing Odoo config: {fields}",
hint="Make sure you set the required SERVALA_ODOO_* settings.",
id="servala.E001",
)
)
oidc_config = settings.SOCIALACCOUNT_PROVIDERS["openid_connect"]["APPS"][0]
missing_fields = [
field for field in ("client_id", "secret") if not oidc_config.get(field)
]
if not oidc_config["settings"]["server_url"]:
missing_fields.append("server_url")
if missing_fields:
fields = ", ".join(
[f"SERVALA_KEYCLOAK_{field.upper()}" for field in missing_fields]
)
errors.append(
CheckMessage(
level=WARNING if settings.DEBUG else ERROR,
msg=f"Missing Keycloak config: {fields}",
id="servala.E002",
)
)
if settings.SERVALA_ENVIRONMENT not in ("development", "staging", "production"):
errors.append(
CheckMessage(
level=ERROR,
msg=f"Invalid environment {settings.SERVALA_ENVIRONMENT}",
hint="Must be one of development, staging, production.",
id="servala.E003",
)
)
return errors
@register(deploy=True)
def check_servala_production_settings(app_configs, **kwargs):
if app_configs:
# Dont run if were meant to only test individual apps
return []
errors = []
if settings.SERVALA_ENVIRONMENT == "development":
errors.append(
CheckMessage(
level=ERROR,
msg="Environment is set to 'development'.",
id="servala.E004",
)
)
if "insecure" in settings.SECRET_KEY:
errors.append(
CheckMessage(
level=ERROR, msg="Secret key contains 'insecure'.", id="servala.E005"
)
)
if settings.EMAIL_USE_SSL and settings.EMAIL_USE_TLS:
errors.append(
CheckMessage(
level=WARNING,
msg="Use either SSL or TLS in email config, not both!",
id="servala.W001",
)
)

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2 on 2025-05-26 05:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0003_alter_organization_namespace"),
]
operations = [
migrations.AddField(
model_name="billingentity",
name="odoo_company_id",
field=models.IntegerField(null=True),
),
migrations.AddField(
model_name="billingentity",
name="odoo_invoice_id",
field=models.IntegerField(null=True),
),
]

View file

@ -110,6 +110,13 @@ class BillingEntity(ServalaModelMixin, models.Model):
max_length=100, blank=True, verbose_name=_("ERP reference")
)
# Odoo IDs are nullable for creation, should never be null in practice
# The company ID points at a record of type res.partner with company_type=company
# The invoice ID points at a record of type res.partner with company_type=person,
# type=invoic, parent_id=company_id (the invoice address).
odoo_company_id = models.IntegerField(null=True)
odoo_invoice_id = models.IntegerField(null=True)
class Meta:
verbose_name = _("Billing entity")
verbose_name_plural = _("Billing entities")

View file

@ -6,7 +6,8 @@ from django.contrib.auth.models import (
from django.db import models
from django.utils.translation import gettext_lazy as _
from .mixins import ServalaModelMixin
from servala.core import odoo
from servala.core.models.mixins import ServalaModelMixin
class UserManager(BaseUserManager):
@ -73,3 +74,22 @@ class User(ServalaModelMixin, PermissionsMixin, AbstractBaseUser):
def normalize_username(self, username):
return super().normalize_username(username).strip().lower()
def get_odoo_contact(self, organization):
if (
not organization.billing_entity
or not organization.billing_entity.odoo_company_id
):
return
result = odoo.CLIENT.search_read(
model="res.partner",
domain=[
("company_type", "=", "person"),
("type", "=", "contact"),
("email", "ilike", self.email),
("parent_id", "=", organization.billing_entity.odoo_company_id),
],
fields=odoo.ADDRESS_FIELDS,
)
if result:
return result[0]

145
src/servala/core/odoo.py Normal file
View file

@ -0,0 +1,145 @@
import xmlrpc.client
from django.conf import settings
ADDRESS_FIELDS = [
"id",
"name",
"street",
"street2",
"city",
"zip",
"state_id",
"country_id",
"email",
"phone",
"vat",
"company_type",
"type",
]
class OdooClient:
def __init__(self):
self.url = settings.ODOO["URL"]
self.db = settings.ODOO["DB"]
self.username = settings.ODOO["USERNAME"]
self.password = settings.ODOO["PASSWORD"]
self.common_proxy = None
self.models_proxy = None
self.uid = None
def _connect(self):
"""This method is called on the first client request, not on instantiation,
so that we can instantiate the client on startup and reuse it across the entire
application."""
try:
self.common_proxy = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/common")
self.uid = self.common_proxy.authenticate(
self.db, self.username, self.password, {}
)
if not self.uid:
raise Exception("Authentication failed with Odoo: No UID returned.")
self.models_proxy = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/object")
except xmlrpc.client.Fault as e:
raise Exception(
f"Odoo XML-RPC Fault during connection: {e.faultString}"
) from e
except ConnectionRefusedError as e:
raise Exception(
f"Could not connect to Odoo at {self.url}. Connection refused."
) from e
except Exception as e:
raise Exception(
f"An error occurred while connecting to Odoo: {str(e)}"
) from e
def execute(self, model, method, args_list, **kwargs):
if not self.uid or not self.models_proxy:
self._connect()
try:
result = self.models_proxy.execute_kw(
self.db, self.uid, self.password, model, method, args_list, kwargs
)
return result
except xmlrpc.client.Fault as e:
print(f"Fault! {e}")
raise Exception(f"Odoo XML-RPC Fault: {e.faultString}") from e
except ConnectionRefusedError as e:
raise Exception(
f"Connection to Odoo at {self.url} lost or refused during operation."
) from e
except Exception as e:
print(e)
raise Exception(
f"An error occurred while communicating with Odoo: {str(e)}"
) from e
def search_read(self, model, domain, fields, **kwargs):
return self.execute(model, "search_read", args_list=[domain, fields], **kwargs)
CLIENT = OdooClient()
def get_invoice_addresses(user):
"""Used during organization creation: retrieves all invoice
addresses the user owns or is connected to from the Odoo API."""
# Were building our conditions in order:
# - in exceptions, users may be using a billing accounts email
# - if the user is associated with an odoo user, return all billing
# addresses / organizations created by the user
# - if the user is associated with an odoo contact, return all billing
# addresses with the same parent_id
or_conditions = [("email", "ilike", email)]
email = user if isinstance(user, str) else user.email
odoo_users = CLIENT.search_read(
model="res.users",
domain=[("login", "=", email)],
fields=["id"],
limit=1,
)
if odoo_users and (uid := odoo_users[0].get("id")):
or_conditions.append(("create_uid", "=", uid))
odoo_contacts = CLIENT.search_read(
model="res.partner",
domain=[
("company_type", "=", "person"),
("type", "=", "contact"),
("email", "ilike", email),
],
fields=["id", "parent_id"],
)
if odoo_contacts:
for contact in odoo_contacts:
or_conditions.append(("parent_id", "=", contact["parent_id"][0]))
if len(or_conditions) > 1:
or_conditions = ["|"] * (len(or_conditions) - 1) + or_conditions
# The domain requires the partner to be an invoice address, that is:
# Of the company_type=person, and type=invoice.
# If we were searching for an existing organization, we would also have to
# filter for parent_id=odoo_company_id
domain = [
("company_type", "=", "person"),
("type", "=", "invoice"),
] + or_conditions
try:
invoice_addresses = CLIENT.search_read(
model="res.partner",
domain=domain,
fields=ADDRESS_FIELDS,
)
return invoice_addresses or []
except Exception:
return []

View file

@ -87,7 +87,6 @@ SOCIALACCOUNT_PROVIDERS = {
}
}
SERVALA_STORAGE_BUCKET_NAME = os.environ.get("SERVALA_STORAGE_BUCKET_NAME")
SERVALA_S3_ENDPOINT_URL = os.environ.get("SERVALA_S3_ENDPOINT_URL")
SERVALA_ACCESS_KEY_ID = os.environ.get("SERVALA_ACCESS_KEY_ID")
@ -120,6 +119,13 @@ if all(
}
}
ODOO = {
"URL": os.environ.get("SERVALA_ODOO_URL"),
"DB": os.environ.get("SERVALA_ODOO_DB"),
"USERNAME": os.environ.get("SERVALA_ODOO_USERNAME"),
"PASSWORD": os.environ.get("SERVALA_ODOO_PASSWORD"),
}
#######################################
# Non-configurable settings below #
#######################################