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_REGION_NAME='eu-central-1'
|
||||||
# SERVALA_S3_ADDRESSING_STYLE='virtual'
|
# SERVALA_S3_ADDRESSING_STYLE='virtual'
|
||||||
# SERVALA_S3_SIGNATURE_VERSION='s3v4'
|
# 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:
|
Useful commands:
|
||||||
|
|
||||||
- ``migrate``: Make sure database migrations are applied.
|
- ``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.
|
- ``showmigrations``: Show current database migrations status. Good for debugging.
|
||||||
- ``runserver``: Run development server
|
- ``runserver``: Run development server
|
||||||
- ``clearsessions``: Clear away expired user sessions. Recommended to run regularly, e.g. weekly or monthly (doesn’t
|
- ``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):
|
class CoreConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "servala.core"
|
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")
|
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:
|
class Meta:
|
||||||
verbose_name = _("Billing entity")
|
verbose_name = _("Billing entity")
|
||||||
verbose_name_plural = _("Billing entities")
|
verbose_name_plural = _("Billing entities")
|
||||||
|
|
|
@ -6,7 +6,8 @@ from django.contrib.auth.models import (
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
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):
|
class UserManager(BaseUserManager):
|
||||||
|
@ -73,3 +74,22 @@ class User(ServalaModelMixin, PermissionsMixin, AbstractBaseUser):
|
||||||
|
|
||||||
def normalize_username(self, username):
|
def normalize_username(self, username):
|
||||||
return super().normalize_username(username).strip().lower()
|
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_STORAGE_BUCKET_NAME = os.environ.get("SERVALA_STORAGE_BUCKET_NAME")
|
||||||
SERVALA_S3_ENDPOINT_URL = os.environ.get("SERVALA_S3_ENDPOINT_URL")
|
SERVALA_S3_ENDPOINT_URL = os.environ.get("SERVALA_S3_ENDPOINT_URL")
|
||||||
SERVALA_ACCESS_KEY_ID = os.environ.get("SERVALA_ACCESS_KEY_ID")
|
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 #
|
# Non-configurable settings below #
|
||||||
#######################################
|
#######################################
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue