Compare commits
8 commits
d8cc90188e
...
e18cafa813
Author | SHA1 | Date | |
---|---|---|---|
e18cafa813 | |||
22ff769b2c | |||
8f75db5325 | |||
6ce13126d5 | |||
0bd620d68e | |||
549e1fa19a | |||
97fc045375 | |||
1ed383ea10 |
9 changed files with 298 additions and 2 deletions
|
@ -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=''
|
||||
|
|
|
@ -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 (doesn’t
|
||||
|
|
|
@ -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
|
||||
|
|
86
src/servala/core/checks.py
Normal file
86
src/servala/core/checks.py
Normal 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:
|
||||
# Don’t run if we’re 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:
|
||||
# Don’t run if we’re 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",
|
||||
)
|
||||
)
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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")
|
||||
|
|
|
@ -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
145
src/servala/core/odoo.py
Normal 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."""
|
||||
# We’re building our conditions in order:
|
||||
# - in exceptions, users may be using a billing account’s 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 []
|
|
@ -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 #
|
||||
#######################################
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue