Billing Entity Management #66

Open
rixx wants to merge 20 commits from 54-billing-entity-management into main
21 changed files with 867 additions and 55 deletions

View file

@ -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=''

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,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[]

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

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

@ -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 parents 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):
"""

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]

173
src/servala/core/odoo.py Normal file
View 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."""
# 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
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)],

Enhance the domain to include ('active','=',True) to make sure the user is active in Odoo.

Enhance the domain to include `('active','=',True)` to make sure the user is active in Odoo.
fields=["id"],
limit=1,
)
if odoo_users and (uid := odoo_users[0].get("id")):
or_conditions.append(("create_uid", "=", uid))

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

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 []

View file

@ -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 = [

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.

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

View file

@ -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"

View file

@ -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 %}

View file

@ -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 %}

View 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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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
)

View file

@ -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",

View file

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