Billing Entity Management #66
21 changed files with 867 additions and 55 deletions
|
@ -60,4 +60,9 @@ SERVALA_KEYCLOAK_SERVER_URL=''
|
|||
# SERVALA_S3_SIGNATURE_VERSION='s3v4'
|
||||
|
||||
# Configuration for Sentry error reporting
|
||||
SERVALA_SENTRY_DSN=''
|
||||
SERVALA_SENTRY_DSN=''
|
||||
|
||||
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,6 +4,7 @@
|
|||
* xref:web-portal.adoc[]
|
||||
** xref:web-portal-admin.adoc[Admin]
|
||||
** xref:web-portal-controlplanes.adoc[Control-Planes]
|
||||
** xref:web-portal-billingentity.adoc[Billing Entities]
|
||||
|
||||
* xref:web-portal-planning.adoc[]
|
||||
** xref:user-stories.adoc[]
|
||||
|
|
26
docs/modules/ROOT/pages/web-portal-billingentity.adoc
Normal file
26
docs/modules/ROOT/pages/web-portal-billingentity.adoc
Normal file
|
@ -0,0 +1,26 @@
|
|||
= Web Portal Billing Entities
|
||||
|
||||
Billing entities are used to connect an invoice address in Odoo to an organization in Servala.
|
||||
|
||||
When creating a new organization, the billing information is required to be added.
|
||||
|
||||
== Existing Billing Address
|
||||
|
||||
With the email address of the currently logged-in user, Odoo is searched for existing `res.partner` records and presented in the dropdown.
|
||||
|
||||
Search is done this way:
|
||||
|
||||
* `res.partner` records created by a matching Odoo user.
|
||||
* User email matches an invoice address or contact address
|
||||
|
||||
== New Billing Address
|
||||
|
||||
When choosing to add a new billing address, two new records are created in the Odoo `res.partner` model:
|
||||
|
||||
* A record with the field `company_type = company`
|
||||
* A record with the following field configuration:
|
||||
** `company_type = person`
|
||||
** `type = invoice`
|
||||
** `parent_id = company_id`
|
||||
|
||||
The resulting database IDs are stored in the Servala portal database for referencing the records in Odoo.
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -1,7 +1,7 @@
|
|||
import rules
|
||||
import urlman
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
@ -9,6 +9,7 @@ from django_scopes import ScopedManager, scopes_disabled
|
|||
|
||||
from servala.core import rules as perms
|
||||
from servala.core.models.mixins import ServalaModelMixin
|
||||
from servala.core.odoo import CLIENT
|
||||
|
||||
|
||||
class Organization(ServalaModelMixin, models.Model):
|
||||
|
@ -110,6 +111,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=invoice, 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")
|
||||
|
@ -117,6 +125,131 @@ class BillingEntity(ServalaModelMixin, models.Model):
|
|||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def create_from_data(cls, name, odoo_data):
|
||||
"""
|
||||
Creates a BillingEntity and corresponding Odoo records.
|
||||
|
||||
This method creates a `res.partner` record in Odoo with `company_type='company'`
|
||||
for the main company, and another `res.partner` record with `company_type='person'`
|
||||
and `type='invoice'` (linked via `parent_id` to the first record) for the
|
||||
invoice address. The IDs of these Odoo records are stored in the BillingEntity.
|
||||
|
||||
Args:
|
||||
odoo_data (dict): A dictionary containing the data for creating
|
||||
the BillingEntity and Odoo records.
|
||||
|
||||
Expected keys in `odoo_data`:
|
||||
- `invoice_street` (str): Street for the invoice address.
|
||||
- `invoice_street2` (str): Second line of street address for the invoice address.
|
||||
- `invoice_city` (str): City for the invoice address.
|
||||
- `invoice_zip` (str): ZIP/Postal code for the invoice address.
|
||||
- `invoice_country` (int): Odoo `res.country` ID for the invoice address country.
|
||||
- `invoice_email` (str): Email address for the invoice contact.
|
||||
- `invoice_phone` (str): Phone number for the invoice contact.
|
||||
"""
|
||||
instance = cls.objects.create(name=name)
|
||||
company_payload = {
|
||||
"name": odoo_data.get("company_name", name),
|
||||
"company_type": "company",
|
||||
}
|
||||
company_id = CLIENT.execute("res.partner", "create", [company_payload])
|
||||
instance.odoo_company_id = company_id
|
||||
|
||||
invoice_address_payload = {
|
||||
"name": name,
|
||||
"company_type": "person",
|
||||
"type": "invoice",
|
||||
"parent_id": company_id,
|
||||
}
|
||||
invoice_optional_fields = {
|
||||
"street": odoo_data.get("invoice_street"),
|
||||
"street2": odoo_data.get("invoice_street2"),
|
||||
"city": odoo_data.get("invoice_city"),
|
||||
"zip": odoo_data.get("invoice_zip"),
|
||||
"country_id": odoo_data.get("invoice_country"),
|
||||
"email": odoo_data.get("invoice_email"),
|
||||
}
|
||||
invoice_address_payload.update(
|
||||
{k: v for k, v in invoice_optional_fields.items() if v is not None}
|
||||
)
|
||||
|
||||
invoice_address_id = CLIENT.execute(
|
||||
"res.partner", "create", [invoice_address_payload]
|
||||
)
|
||||
instance.odoo_invoice_id = invoice_address_id
|
||||
|
||||
instance.save(update_fields=["odoo_company_id", "odoo_invoice_id"])
|
||||
return instance
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def create_from_id(cls, name, odoo_id):
|
||||
parent_data = CLIENT.search_read(
|
||||
model="res.partner",
|
||||
domain=[["id", "=", odoo_id]],
|
||||
fields=["parent_id"],
|
||||
limit=1,
|
||||
)
|
||||
# Data validation: If the data is not as expected, we just return None,
|
||||
# rather than raising an exception, for now.
|
||||
if not parent_data:
|
||||
return
|
||||
if not (parent_info := parent_data[0].get("parent_id")):
|
||||
return
|
||||
if not isinstance(parent_info, (list, tuple)) or not len(parent_info) > 0:
|
||||
# parent_info is a tuple of the parent’s ID and name
|
||||
return
|
||||
|
||||
instance = cls.objects.create(
|
||||
name=name, odoo_invoice_id=odoo_id, odoo_company_id=parent_info[0]
|
||||
)
|
||||
return instance
|
||||
|
||||
@cached_property
|
||||
def odoo_data(self):
|
||||
data = {
|
||||
"company": None,
|
||||
"invoice_address": None,
|
||||
}
|
||||
|
||||
company_fields = ["name", "company_type"]
|
||||
invoice_address_fields = [
|
||||
"name",
|
||||
"company_type",
|
||||
"type",
|
||||
"parent_id",
|
||||
"street",
|
||||
"street2",
|
||||
"city",
|
||||
"zip",
|
||||
"country_id",
|
||||
"email",
|
||||
]
|
||||
|
||||
if self.odoo_company_id:
|
||||
company_records = CLIENT.search_read(
|
||||
model="res.partner",
|
||||
domain=[["id", "=", self.odoo_company_id]],
|
||||
fields=company_fields,
|
||||
limit=1,
|
||||
)
|
||||
if company_records:
|
||||
data["company"] = company_records[0]
|
||||
|
||||
if self.odoo_invoice_id:
|
||||
invoice_address_records = CLIENT.search_read(
|
||||
model="res.partner",
|
||||
domain=[["id", "=", self.odoo_invoice_id]],
|
||||
fields=invoice_address_fields,
|
||||
limit=1,
|
||||
)
|
||||
if invoice_address_records:
|
||||
data["invoice_address"] = invoice_address_records[0]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class OrganizationOrigin(ServalaModelMixin, models.Model):
|
||||
"""
|
||||
|
|
|
@ -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]
|
||||
|
|
173
src/servala/core/odoo.py
Normal file
173
src/servala/core/odoo.py
Normal file
|
@ -0,0 +1,173 @@
|
|||
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()
|
||||
|
||||
# Odoo countries do not change, so they are fetched once per process
|
||||
COUNTRIES = []
|
||||
|
||||
|
||||
def get_odoo_countries():
|
||||
global COUNTRIES
|
||||
if COUNTRIES:
|
||||
return COUNTRIES
|
||||
|
||||
try:
|
||||
odoo_countries_data = CLIENT.search_read(
|
||||
model="res.country", domain=[], fields=["id", "name"]
|
||||
)
|
||||
# Format as Django choices: [(value, label), ...]
|
||||
COUNTRIES = [
|
||||
(country["id"], country["name"]) for country in odoo_countries_data
|
||||
]
|
||||
# Sort by country name for better UX in dropdowns
|
||||
COUNTRIES.sort(key=lambda x: x[1])
|
||||
except Exception as e:
|
||||
# Log the error or handle it as appropriate for your application
|
||||
# For now, return an empty list or a default if Odoo is unavailable
|
||||
print(f"Error fetching Odoo countries: {e}")
|
||||
return [("", "Error fetching countries")] # Or just []
|
||||
|
||||
return COUNTRIES
|
||||
|
||||
|
||||
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
|
||||
email = user.email
|
||||
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))
|
||||
tobru
commented
A user in the Servala Portal which matches an internal user in Odoo should be able to see all invoice addresses, like they do when they log in to the Odoo backend. I figured out that the "Internal Users" filter in Odoo uses the domain A user in the Servala Portal which matches an internal user in Odoo should be able to see all invoice addresses, like they do when they log in to the Odoo backend. I figured out that the "Internal Users" filter in Odoo uses the domain `('share', '=', False)` ([L342](https://github.com/odoo/odoo/blob/16.0/odoo/addons/base/views/res_users_views.xml#L342)) so let's use the same to decide if the user can see all invoice addresses. There are also "Portal Users" in Odoo, they should not see all invoice addresses and behave like a normal Servala Portal user.
|
||||
|
||||
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 []
|
|
@ -1,6 +1,9 @@
|
|||
from django import forms
|
||||
from django.forms import ModelForm
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from servala.core.models import Organization
|
||||
from servala.core.odoo import get_invoice_addresses, get_odoo_countries
|
||||
from servala.frontend.forms.mixins import HtmxMixin
|
||||
|
||||
|
||||
|
@ -8,3 +11,90 @@ class OrganizationForm(HtmxMixin, ModelForm):
|
|||
class Meta:
|
||||
model = Organization
|
||||
fields = ("name",)
|
||||
|
||||
|
||||
class OrganizationCreateForm(OrganizationForm):
|
||||
billing_processing_choice = forms.ChoiceField(
|
||||
choices=[
|
||||
("existing", _("Use an existing billing address")),
|
||||
("new", _("Create a new billing address")),
|
||||
],
|
||||
widget=forms.RadioSelect,
|
||||
label=_("Billing Address"),
|
||||
initial="new", # Will change to 'existing' if options are found
|
||||
)
|
||||
existing_odoo_address_id = forms.ChoiceField(
|
||||
label=_("Existing Billing Address"),
|
||||
required=False,
|
||||
)
|
||||
|
||||
# Fields for creating a new billing address in Odoo, prefixed with 'invoice_'
|
||||
invoice_street = forms.CharField(label=_("Line 1"), required=False, max_length=100)
|
||||
invoice_street2 = forms.CharField(label=_("Line 2"), required=False, max_length=100)
|
||||
invoice_city = forms.CharField(label=_("City"), required=False, max_length=100)
|
||||
invoice_zip = forms.CharField(label=_("Postal Code"), required=False, max_length=20)
|
||||
invoice_country = forms.ChoiceField(
|
||||
label=_("Country"),
|
||||
required=False,
|
||||
choices=get_odoo_countries(),
|
||||
)
|
||||
invoice_email = forms.EmailField(label=_("Billing Email"), required=False)
|
||||
invoice_phone = forms.CharField(label=_("Phone"), required=False, max_length=30)
|
||||
|
||||
class Meta(OrganizationForm.Meta):
|
||||
pass
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not self.initial.get("invoice_country"):
|
||||
default_country_name = "Switzerland"
|
||||
country_choices = self.fields["invoice_country"].choices
|
||||
for country_id, country_name_label in country_choices:
|
||||
if country_name_label == default_country_name:
|
||||
self.initial["invoice_country"] = country_id
|
||||
break
|
||||
|
||||
self.user = user
|
||||
self.odoo_addresses = get_invoice_addresses(self.user)
|
||||
|
||||
if self.odoo_addresses:
|
||||
address_choices = [("", _("---------"))]
|
||||
for addr in self.odoo_addresses:
|
||||
display_parts = [
|
||||
tobru
commented
We should also display the We should also display the `parent_id.name` field to make these addresses more discoverable to which company they belong to. And order alphabetically, if not already the case.
|
||||
addr.get("name"),
|
||||
addr.get("street"),
|
||||
addr.get("city"),
|
||||
addr.get("zip"),
|
||||
]
|
||||
display_name = ", ".join(filter(None, display_parts))
|
||||
address_choices.append((str(addr["id"]), display_name))
|
||||
|
||||
self.fields["existing_odoo_address_id"].choices = address_choices
|
||||
if not self.is_bound and "billing_processing_choice" not in self.initial:
|
||||
self.fields["billing_processing_choice"].initial = "existing"
|
||||
else:
|
||||
self.fields.pop("billing_processing_choice")
|
||||
self.fields["existing_odoo_address_id"].widget = forms.HiddenInput()
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
choice = cleaned_data.get("billing_processing_choice")
|
||||
if not choice or choice == "new":
|
||||
required_fields = [
|
||||
"invoice_street",
|
||||
"invoice_city",
|
||||
"invoice_zip",
|
||||
"invoice_country",
|
||||
"invoice_email",
|
||||
]
|
||||
for field_name in required_fields:
|
||||
if not cleaned_data.get(field_name):
|
||||
self.add_error(field_name, _("This field is required."))
|
||||
else:
|
||||
existing_id_str = cleaned_data.get("existing_odoo_address_id")
|
||||
if not existing_id_str:
|
||||
self.add_error(
|
||||
"existing_odoo_address_id", _("Please select an invoice address.")
|
||||
)
|
||||
return cleaned_data
|
||||
|
|
|
@ -20,6 +20,8 @@ class VerticalFormRenderer(TemplatesSetting):
|
|||
def get_class_names(self, field):
|
||||
input_type = self.get_field_input_type(field)
|
||||
errors = "is-invalid " if field.errors else ""
|
||||
if input_type == "radio":
|
||||
return f"{errors}form-check-input"
|
||||
if input_type == "checkbox":
|
||||
return f"{errors}form-check-input"
|
||||
return f"{errors}form-control"
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
{% include "django/forms/widgets/input.html" %}
|
||||
{% if widget.wrap_label %}
|
||||
<label {% if widget.attrs.id %}for="{{ widget.attrs.id }}"{% endif %}>
|
||||
{% if not widget.attrs.hide_label %}{{ widget.label }}{% endif %}
|
||||
</label>
|
||||
{% endif %}
|
|
@ -0,0 +1,15 @@
|
|||
{# Change compared to Django: only render widget.attrs.class in actual option widget, not in wrapper #}
|
||||
{% with id=widget.attrs.id %}
|
||||
<div {% if id %}id="{{ id }}"{% endif %}>
|
||||
{% for group, options, index in widget.optgroups %}
|
||||
{% if group %}
|
||||
<div>
|
||||
<label>{{ group }}</label>
|
||||
{% endif %}
|
||||
{% for option in options %}
|
||||
<div>{% include option.template_name with widget=option %}</div>
|
||||
{% endfor %}
|
||||
{% if group %}</div>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endwith %}
|
18
src/servala/frontend/templates/frontend/forms/errors.html
Normal file
18
src/servala/frontend/templates/frontend/forms/errors.html
Normal file
|
@ -0,0 +1,18 @@
|
|||
{% load i18n %}
|
||||
{% if form.non_field_errors or form.errors %}
|
||||
<div class="alert alert-danger form-errors" role="alert">
|
||||
<div>
|
||||
{% if form.non_field_errors %}
|
||||
{% if form.non_field_errors|length > 1 %}
|
||||
<ul>
|
||||
{% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{{ form.non_field_errors.0 }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% translate "We could not save your changes." %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
|
@ -1,21 +1,4 @@
|
|||
{% load i18n %}
|
||||
{% if form.non_field_errors or form.errors %}
|
||||
<div class="alert alert-danger form-errors" role="alert">
|
||||
<div>
|
||||
{% if form.non_field_errors %}
|
||||
{% if form.non_field_errors|length > 1 %}
|
||||
<ul>
|
||||
{% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{{ form.non_field_errors.0 }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% translate "We could not save your changes." %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include "frontend/forms/errors.html" %}
|
||||
<div class="form-body">
|
||||
<div class="row">
|
||||
{% for field, errors in fields %}{{ field.as_field_group }}{% endfor %}
|
||||
|
|
|
@ -5,6 +5,110 @@
|
|||
{% translate "Create a new organization" %}
|
||||
{% endblock page_title %}
|
||||
{% endblock html_title %}
|
||||
{% block card_content %}
|
||||
{% include "includes/form.html" %}
|
||||
{% endblock card_content %}
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<form method="post" class="form form-vertical">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="form-body card-body">
|
||||
<div class="row">
|
||||
{% include "frontend/forms/errors.html" %}
|
||||
{% csrf_token %}
|
||||
{{ form.name.as_field_group }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if form.billing_processing_choice %}
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">{% translate "Billing Information" %}</h4>
|
||||
</div>
|
||||
<div class="form-body card-body">
|
||||
<div class="row">
|
||||
{{ form.billing_processing_choice.as_field_group }}
|
||||
<div id="existing_billing_address_section" class="mt-3">{{ form.existing_odoo_address_id.as_field_group }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="new_billing_address_section">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">{% translate "Invoice Address" %}</h4>
|
||||
</div>
|
||||
<div class="form-body card-body">
|
||||
<div class="row">
|
||||
{{ form.invoice_street.as_field_group }}
|
||||
{{ form.invoice_street2.as_field_group }}
|
||||
<div class="col-md-2">{{ form.invoice_zip.as_field_group }}</div>
|
||||
<div class="col-md-10">{{ form.invoice_city.as_field_group }}</div>
|
||||
{{ form.invoice_country.as_field_group }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">{% translate "Invoice Contact" %}</h4>
|
||||
</div>
|
||||
<div class="form-body card-body">
|
||||
<div class="row">
|
||||
{{ form.invoice_email.as_field_group }}
|
||||
{{ form.invoice_phone.as_field_group }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="card-body">
|
||||
<div class="col-sm-12 d-flex justify-content-end">
|
||||
<button class="btn btn-primary me-1 mb-1" type="submit">{% translate "Create Organization" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const choiceRadios = document.querySelectorAll('[name="billing_processing_choice"]')
|
||||
const existingSection = document.getElementById('existing_billing_address_section')
|
||||
const newSection = document.getElementById('new_billing_address_section')
|
||||
const existingAddressField = document.querySelector('[name="existing_odoo_address_id"]')
|
||||
|
||||
const toggleSections = () => {
|
||||
let selectedValue = 'new'
|
||||
const checkedRadio = document.querySelector('[name="billing_processing_choice"]:checked')
|
||||
if (checkedRadio) {
|
||||
selectedValue = checkedRadio.value
|
||||
}
|
||||
|
||||
if (selectedValue === 'existing') {
|
||||
if (existingSection) existingSection.style.display = '';
|
||||
if (newSection) newSection.style.display = 'none';
|
||||
} else {
|
||||
if (existingSection) existingSection.style.display = 'none';
|
||||
if (newSection) newSection.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (choiceRadios.length > 0 && existingSection && newSection) {
|
||||
toggleSections()
|
||||
choiceRadios.forEach((radio) => {
|
||||
radio.addEventListener('change', toggleSections)
|
||||
})
|
||||
} else {
|
||||
// No existing addresses found, a new address has to be entered.
|
||||
if (existingSection) existingSection.style.display = 'none'
|
||||
if (newSection) newSection.style.display = '' // Ensure newSection is not null
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock content %}
|
||||
|
|
|
@ -36,26 +36,99 @@
|
|||
</form>
|
||||
</td>
|
||||
{% endpartialdef org-name-edit %}
|
||||
{% block card_content %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-lg">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Name" %}</span>
|
||||
</th>
|
||||
{% partial org-name %}
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Namespace" %}</span>
|
||||
</th>
|
||||
<td>
|
||||
<div>{{ form.instance.namespace }}</div>
|
||||
<small class="text-muted">{% translate "System-generated namespace for Kubernetes resources." %}</small>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock card_content %}
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-lg">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Name" %}</span>
|
||||
</th>
|
||||
{% partial org-name %}
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Namespace" %}</span>
|
||||
</th>
|
||||
<td>
|
||||
<div>{{ form.instance.namespace }}</div>
|
||||
<small class="text-muted">{% translate "System-generated namespace for Kubernetes resources." %}</small>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if form.instance.billing_entity and form.instance.billing_entity.odoo_data.invoice_address %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">{% translate "Billing Address" %}</h4>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-body">
|
||||
{% with odoo_data=form.instance.billing_entity.odoo_data %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-lg">
|
||||
<tbody>
|
||||
{% if odoo_data.invoice_address %}
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Invoice Contact Name" %}</span>
|
||||
</th>
|
||||
<td>{{ odoo_data.invoice_address.name|default:"" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Street" %}</span>
|
||||
</th>
|
||||
<td>{{ odoo_data.invoice_address.street|default:"" }}</td>
|
||||
</tr>
|
||||
{% if odoo_data.invoice_address.street2 %}
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Street 2" %}</span>
|
||||
</th>
|
||||
<td>{{ odoo_data.invoice_address.street2 }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "City" %}</span>
|
||||
</th>
|
||||
<td>{{ odoo_data.invoice_address.city|default:"" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "ZIP Code" %}</span>
|
||||
</th>
|
||||
<td>{{ odoo_data.invoice_address.zip|default:"" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Country" %}</span>
|
||||
</th>
|
||||
<td>{{ odoo_data.invoice_address.country_id.1|default:"" }}</td>
|
||||
</tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Invoice Email" %}</span>
|
||||
</th>
|
||||
<td>{{ odoo_data.invoice_address.email|default:"" }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock content %}
|
||||
|
|
|
@ -1,18 +1,55 @@
|
|||
from django.shortcuts import redirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import CreateView, DetailView
|
||||
from rules.contrib.views import AutoPermissionRequiredMixin
|
||||
|
||||
from servala.core.models import Organization
|
||||
from servala.frontend.forms import OrganizationForm
|
||||
from servala.core.models import BillingEntity, Organization
|
||||
from servala.frontend.forms.organization import OrganizationCreateForm, OrganizationForm
|
||||
from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin
|
||||
|
||||
|
||||
class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView):
|
||||
form_class = OrganizationForm
|
||||
form_class = OrganizationCreateForm
|
||||
model = Organization
|
||||
template_name = "frontend/organizations/create.html"
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["user"] = self.request.user
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
billing_choice = form.cleaned_data.get("billing_processing_choice")
|
||||
billing_entity = None
|
||||
name = form.cleaned_data["name"]
|
||||
|
||||
if not billing_choice or billing_choice == "new":
|
||||
billing_entity = BillingEntity.create_from_data(
|
||||
name,
|
||||
{
|
||||
key: value
|
||||
for key, value in form.cleaned_data.items()
|
||||
if key.startswith("invoice_")
|
||||
},
|
||||
)
|
||||
elif odoo_id := form.cleaned_data.get("existing_odoo_address_id"):
|
||||
billing_entity = BillingEntity.objects.filter(
|
||||
odoo_invoice_id=odoo_id
|
||||
).first()
|
||||
|
||||
if not billing_entity:
|
||||
billing_entity = BillingEntity.create_from_id(name, odoo_id)
|
||||
|
||||
if not billing_entity:
|
||||
form.add_error(
|
||||
None,
|
||||
_(
|
||||
"Could not determine or create the billing entity. Please check your input."
|
||||
),
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
form.instance.billing_entity = billing_entity
|
||||
instance = form.instance.create_organization(
|
||||
form.instance, owner=self.request.user
|
||||
)
|
||||
|
|
|
@ -10,11 +10,12 @@ Servala is run using environment variables. Documentation:
|
|||
"""
|
||||
|
||||
import os
|
||||
import sentry_sdk
|
||||
from pathlib import Path
|
||||
|
||||
import sentry_sdk
|
||||
from django.contrib import messages
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
from django.contrib import messages
|
||||
from servala.__about__ import __version__ as version
|
||||
|
||||
SERVALA_ENVIRONMENT = os.environ.get("SERVALA_ENVIRONMENT", "development")
|
||||
|
@ -90,7 +91,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")
|
||||
|
@ -126,6 +126,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 #
|
||||
#######################################
|
||||
|
@ -139,11 +146,11 @@ INSTALLED_APPS = [
|
|||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
# The frontend app is loaded early in order to supersede some allauth views/behaviour
|
||||
"servala.frontend",
|
||||
"django.forms",
|
||||
"template_partials",
|
||||
"rules.apps.AutodiscoverRulesConfig",
|
||||
# The frontend app is loaded early in order to supersede some allauth views/behaviour
|
||||
"servala.frontend",
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
"allauth.socialaccount",
|
||||
|
|
|
@ -54,6 +54,12 @@ html[data-bs-theme="dark"] .btn-outline-primary, .btn-outline-primary {
|
|||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
fieldset .form-check-input + label {
|
||||
font-weight: normal;
|
||||
min-height: 1.5rem;
|
||||
margin: .125rem;
|
||||
}
|
||||
|
||||
.search-form .form-body>.row {
|
||||
display: flex;
|
||||
&>.col-12 {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue
Enhance the domain to include
('active','=',True)
to make sure the user is active in Odoo.