Compare commits

...

48 commits

Author SHA1 Message Date
4b3ddec4bb Show invoice address on organization detail page
All checks were successful
Tests / test (push) Successful in 25s
2025-06-03 11:33:32 +02:00
e646bae158 Implement billing address creation via odoo 2025-06-03 11:19:23 +02:00
81932c4da0 Improve look of invoice address form 2025-06-03 11:04:53 +02:00
87439c62d0 Rename form fields 2025-06-03 10:42:12 +02:00
4c0cd2a4fe Retrieve country list from odoo 2025-06-03 10:40:10 +02:00
c751f9d710 Implement template/form/view for org create 2025-06-02 10:42:34 +02:00
7c4dcb7df4 Implement user:odoo contact mapping
ref #60
2025-06-02 10:42:34 +02:00
611172afdb Fully implement odoo user search 2025-06-02 10:42:34 +02:00
284d716571 Use class-based odoo client for connection reuse 2025-06-02 10:42:34 +02:00
fdaac15b47 Implement user search in odoo 2025-06-02 10:42:34 +02:00
ed60ea5491 Add system checks 2025-06-02 10:42:34 +02:00
cedf9b4342 Add Odoo settings 2025-06-02 10:42:34 +02:00
1551900fa4 First stab at Odoo filter logic 2025-06-02 10:42:34 +02:00
12b88330d1 Add odoo fields 2025-06-02 10:42:33 +02:00
a37b0d4a13 Merge pull request 'Display/CSS fixes' (#88) from css into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m8s
Tests / test (push) Successful in 25s
Build and Deploy Staging / deploy (push) Successful in 7s
Reviewed-on: #88
2025-06-02 08:37:04 +00:00
fa6ac5334e Fix search field functionality
All checks were successful
Tests / test (push) Successful in 24s
2025-06-02 10:35:27 +02:00
cb7332f4e9 Fix display of search field 2025-06-02 10:35:22 +02:00
50a7f628e4 Display fix: Place service offerings in rows 2025-06-02 10:32:12 +02:00
c3d8fd9f56 Display fix: place services in rows 2025-06-02 10:31:07 +02:00
c19b73eb07 Merge pull request 'Update dependency django to v5.2.1' (#70) from renovate/django-5.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 2m27s
Tests / test (push) Successful in 38s
Build and Deploy Staging / deploy (push) Successful in 41s
Reviewed-on: #70
2025-06-02 07:35:08 +00:00
8566520f97 Merge pull request 'Update docker/build-push-action action to v6' (#85) from renovate/docker-build-push-action-6.x into main
Reviewed-on: #85
2025-06-02 07:34:41 +00:00
f480711f4e Merge pull request 'Update https://github.com/astral-sh/setup-uv action to v6' (#86) from renovate/https-github.com-astral-sh-setup-uv-6.x into main
Reviewed-on: #86
2025-06-02 07:34:29 +00:00
b146a6e59f Merge pull request 'Update dependency node to v22' (#84) from renovate/node-22.x into main
Reviewed-on: #84
2025-06-02 07:34:13 +00:00
d5a7133b23 Merge pull request 'Update quay.io/appuio/oc Docker tag to v4.18' (#82) from renovate/quay.io-appuio-oc-4.x into main
Reviewed-on: #82
2025-06-02 07:33:51 +00:00
Renovate Bot
79a1c4dc45 Update https://github.com/astral-sh/setup-uv action to v6 2025-05-27 13:17:11 +00:00
Renovate Bot
9ee826eb50 Update docker/build-push-action action to v6 2025-05-27 13:17:06 +00:00
Renovate Bot
891519e97b Update dependency node to v22 2025-05-27 13:16:58 +00:00
Renovate Bot
fd8c21895e Update quay.io/appuio/oc Docker tag to v4.18 2025-05-27 13:16:47 +00:00
Renovate Bot
87838a38c3 Update dependency django to v5.2.1
All checks were successful
Tests / test (push) Successful in 23s
2025-05-27 13:15:42 +00:00
15370f9739 Merge pull request 'specify storage for static files' (#69) from staticfiles-storage into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m9s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 7s
Reviewed-on: #69
2025-05-27 13:15:03 +00:00
880b38ce3f
remove wrong secret ref
This becomes annoying - Claude really didn't help with Renovate
2025-05-27 15:11:51 +02:00
4d5c8e3784
remove invalid setting from renovate config 2025-05-27 15:08:05 +02:00
e0a1197a70
adapt renovate config to recommendations 2025-05-27 15:03:17 +02:00
3976d2905b
define the github token for renovate 2025-05-27 14:58:00 +02:00
67a76e7f4c
specify which repo to renovate 2025-05-27 14:48:40 +02:00
995ace7d97
set the platform in renovate config 2025-05-27 14:09:02 +02:00
45a1825b70
remove config file location definition 2025-05-27 14:02:30 +02:00
06efd09f60
only execute python tests on src changes 2025-05-27 13:53:38 +02:00
52553796d3
restrict workflows to certain paths
Some checks failed
Tests / test (push) Has been cancelled
2025-05-27 13:52:24 +02:00
378f10c992
enhanced renovate config
Some checks failed
Tests / test (push) Has been cancelled
Build and Deploy Staging / deploy (push) Has been cancelled
Build and Deploy Staging / build (push) Has been cancelled
2025-05-27 13:44:38 +02:00
313a8a5492
use better container for renovate job
Some checks failed
Build and Deploy Staging / deploy (push) Has been cancelled
Build and Deploy Staging / build (push) Has been cancelled
Tests / test (push) Has been cancelled
2025-05-27 13:37:43 +02:00
8edb059831
specify storage for static files
All checks were successful
Tests / test (push) Successful in 24s
2025-05-27 13:34:16 +02:00
4ef2f9a31b
use full url for action location
Some checks failed
Build and Deploy Staging / deploy (push) Has been cancelled
Build and Deploy Staging / build (push) Has been cancelled
Tests / test (push) Has been cancelled
2025-05-27 13:27:29 +02:00
a6a617f229 Merge pull request 'configure renovate with a recurring action' (#68) from renovate-config into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m3s
Tests / test (push) Successful in 23s
Build and Deploy Staging / deploy (push) Successful in 9s
Reviewed-on: #68
2025-05-27 11:21:23 +00:00
126ff35065
configure renovate with a recurring action
All checks were successful
Tests / test (push) Successful in 23s
2025-05-27 13:20:45 +02:00
1dda974e11
Revert "configure django storages with env vars"
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m2s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 8s
This reverts commit 5f38856dd9.
2025-05-27 11:25:39 +02:00
5f38856dd9
configure django storages with env vars
All checks were successful
Build and Deploy Staging / build (push) Successful in 55s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 8s
2025-05-27 11:18:56 +02:00
eb73b35a5c
add objectstorage to deployment
All checks were successful
Build and Deploy Staging / build (push) Successful in 56s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 10s
2025-05-27 11:11:40 +02:00
32 changed files with 1006 additions and 122 deletions

View file

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

View file

@ -4,6 +4,13 @@ on:
push:
tags:
- "*"
paths:
- "deployment/**"
- "docker/**"
- "src/**"
- "Dockerfile"
- "pyproject.toml"
- "uv.lock"
workflow_dispatch:
jobs:
@ -44,7 +51,7 @@ jobs:
esac
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
push: true
@ -80,7 +87,7 @@ jobs:
esac
- name: Deploy to OpenShift
uses: docker://quay.io/appuio/oc:v4.16
uses: docker://quay.io/appuio/oc:v4.18
with:
entrypoint: /bin/bash
args: |
@ -97,7 +104,7 @@ jobs:
OPENSHIFT_URL: ${{ secrets.OPENSHIFT_URL }}
- name: Verify deployment
uses: docker://quay.io/appuio/oc:v4.16
uses: docker://quay.io/appuio/oc:v4.18
with:
entrypoint: /bin/bash
args: |

View file

@ -3,6 +3,13 @@ name: Build and Deploy Staging
on:
push:
branches: [main]
paths:
- "deployment/**"
- "docker/**"
- "src/**"
- "Dockerfile"
- "pyproject.toml"
- "uv.lock"
workflow_dispatch:
jobs:
@ -28,7 +35,7 @@ jobs:
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
push: true
@ -49,7 +56,7 @@ jobs:
uses: actions/checkout@v4
- name: Deploy to OpenShift
uses: docker://quay.io/appuio/oc:v4.16
uses: docker://quay.io/appuio/oc:v4.18
with:
entrypoint: /bin/bash
args: |

View file

@ -30,7 +30,7 @@ jobs:
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: docs/Dockerfile
@ -52,7 +52,7 @@ jobs:
uses: actions/checkout@v4
- name: Deploy to OpenShift
uses: docker://quay.io/appuio/oc:v4.16
uses: docker://quay.io/appuio/oc:v4.18
with:
entrypoint: /bin/bash
args: |

View file

@ -0,0 +1,33 @@
name: Renovate Dependency Bot
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
jobs:
renovate:
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Renovate
uses: https://github.com/renovatebot/github-action@v42.0.4
with:
token: ${{ secrets.RENOVATE_TOKEN }}
env:
LOG_LEVEL: info
RENOVATE_ENDPOINT: ${{ vars.RENOVATE_ENDPOINT }}
RENOVATE_PLATFORM: gitea
RENOVATE_REPOSITORIES: ${{ github.repository }}
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }}
RENOVATE_GIT_AUTHOR: "Renovate Bot <renovate@servala.app.codey.ch>"
RENOVATE_USERNAME: renovate
RENOVATE_ENABLE_PYTHON_TOOL_VERSIONS: true

View file

@ -2,6 +2,10 @@ name: Tests
on:
push:
paths:
- "src/**"
- "pyproject.toml"
- "uv.lock"
workflow_dispatch:
jobs:
@ -17,7 +21,7 @@ jobs:
uses: actions/checkout@v4
- name: Install uv
uses: https://github.com/astral-sh/setup-uv@v5
uses: https://github.com/astral-sh/setup-uv@v6
- name: Run tests
run: uv run --env-file=.env.example pytest

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

@ -2,3 +2,4 @@ resources:
- deployment.yaml
- service.yaml
- cronjob.yaml
- objectstorage.yaml

View file

@ -0,0 +1,9 @@
apiVersion: appcat.vshn.io/v1
kind: ObjectBucket
metadata:
name: portal-storage
spec:
parameters:
region: lpg
writeConnectionSecretToRef:
name: portal-storage-creds

View file

@ -11,3 +11,4 @@ resources:
- ingress.yaml
patches:
- path: portal-deployment.yaml
- path: objectstorage.yaml

View file

@ -0,0 +1,7 @@
apiVersion: appcat.vshn.io/v1
kind: ObjectBucket
metadata:
name: portal-storage
spec:
parameters:
bucketName: servala-portal-storage-production

View file

@ -11,3 +11,4 @@ resources:
- ingress.yaml
patches:
- path: portal-deployment.yaml
- path: objectstorage.yaml

View file

@ -0,0 +1,7 @@
apiVersion: appcat.vshn.io/v1
kind: ObjectBucket
metadata:
name: portal-storage
spec:
parameters:
bucketName: servala-portal-storage-staging

View file

@ -7,7 +7,7 @@ requires-python = ">=3.12"
dependencies = [
"argon2-cffi>=23.1.0",
"cryptography>=44.0.2",
"django==5.2",
"django==5.2.1",
"django-allauth>=65.5.0",
"django-fernet-encrypted-fields>=0.3.0",
"django-scopes>=2.0.0",

43
renovate.json Normal file
View file

@ -0,0 +1,43 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"packageRules": [
{
"matchManagers": [
"github-actions"
],
"matchFileNames": [
".forgejo/workflows/*.yml",
".forgejo/workflows/*.yaml"
]
},
{
"matchManagers": [
"pep621"
],
"rangeStrategy": "bump"
},
{
"matchPackageNames": [
"python"
],
"matchManagers": [
"dockerfile"
],
"versioning": "docker"
}
],
"labels": [
"dependencies"
],
"lockFileMaintenance": {
"enabled": true,
"schedule": [
"before 5am on monday"
]
},
"prConcurrentLimit": 5,
"branchConcurrentLimit": 10
}

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=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")
@ -117,6 +125,116 @@ 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",
}
if vat := odoo_data.get("invoice_vat"):
company_payload["vat"] = vat
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
def create_from_id(cls, odoo_id):
# TODO implement odoo creation from ID
# instance = BillingEntity.objects.create(name=odoo_data.get("name"))
# return instance
pass
@cached_property
def odoo_data(self):
data = {
"company": None,
"invoice_address": None,
}
company_fields = ["name", "company_type", "vat"]
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)],
fields=["id"],
limit=1,
)
if odoo_users and (uid := odoo_users[0].get("id")):
or_conditions.append(("create_uid", "=", uid))
odoo_contacts = CLIENT.search_read(
model="res.partner",
domain=[
("company_type", "=", "person"),
("type", "=", "contact"),
("email", "ilike", email),
],
fields=["id", "parent_id"],
)
if odoo_contacts:
for contact in odoo_contacts:
or_conditions.append(("parent_id", "=", contact["parent_id"][0]))
if len(or_conditions) > 1:
or_conditions = ["|"] * (len(or_conditions) - 1) + or_conditions
# The domain requires the partner to be an invoice address, that is:
# Of the company_type=person, and type=invoice.
# If we were searching for an existing organization, we would also have to
# filter for parent_id=odoo_company_id
domain = [
("company_type", "=", "person"),
("type", "=", "invoice"),
] + or_conditions
try:
invoice_addresses = CLIENT.search_read(
model="res.partner",
domain=domain,
fields=ADDRESS_FIELDS,
)
return invoice_addresses or []
except Exception:
return []

View file

@ -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,91 @@ 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)
invoice_vat = forms.CharField(label=_("VAT ID"), required=False, max_length=50)
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 = [
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

@ -1,4 +1,5 @@
from django import forms
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from servala.core.models import (
@ -18,13 +19,17 @@ class ServiceFilterForm(forms.Form):
cloud_provider = forms.ModelChoiceField(
queryset=CloudProvider.objects.all(), required=False
)
q = forms.CharField(required=False)
q = forms.CharField(label=_("Search"), required=False)
def filter_queryset(self, queryset):
if category := self.cleaned_data.get("category"):
queryset = queryset.filter(category=category)
if cloud_provider := self.cleaned_data.get("cloud_provider"):
queryset = queryset.filter(offerings__provider=cloud_provider)
if search := self.cleaned_data.get("q"):
queryset = queryset.filter(
Q(name__icontains=search) | Q(category__name__icontains=search)
)
return queryset

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,112 @@
{% 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.existing_odoo_address_id and form.existing_odoo_address_id.choices %}
<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_vat.as_field_group }}
<hr>
{{ 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

@ -28,38 +28,42 @@
</div>
</div>
</div>
{% for offering in service.offerings.all %}
<div class="card col-6 col-lg-3 col-md-4">
<div class="card-header d-flex align-items-center">
{% if offering.provider.logo %}
<img src="{{ offering.provider.logo.url }}"
alt="{{ offering.provider.name }}"
class="me-3"
style="max-width: 48px;
max-height: 48px">
{% endif %}
<div class="d-flex flex-column">
<h4 class="mb-0">{{ offering.provider.name }}</h4>
<div class="row">
{% for offering in service.offerings.all %}
<div class="col-6 col-lg-3 col-md-4">
<div class="card">
<div class="card-header d-flex align-items-center">
{% if offering.provider.logo %}
<img src="{{ offering.provider.logo.url }}"
alt="{{ offering.provider.name }}"
class="me-3"
style="max-width: 48px;
max-height: 48px">
{% endif %}
<div class="d-flex flex-column">
<h4 class="mb-0">{{ offering.provider.name }}</h4>
</div>
</div>
<div class="card-body">
{% if offering.description %}
<p class="card-text">{{ offering.description }}</p>
{% elif offering.provider.description %}
<p class="card-text">{{ offering.provider.description }}</p>
{% endif %}
</div>
<div class="card-footer d-flex justify-content-between">
<span></span>
<a href="offering/{{ offering.pk }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
</div>
</div>
</div>
<div class="card-body">
{% if offering.description %}
<p class="card-text">{{ offering.description }}</p>
{% elif offering.provider.description %}
<p class="card-text">{{ offering.provider.description }}</p>
{% endif %}
{% empty %}
<div class="card">
<div class="card-body">
<p>{% translate "No offerings found." %}</p>
</div>
</div>
<div class="card-footer d-flex justify-content-between">
<span></span>
<a href="offering/{{ offering.pk }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
</div>
</div>
{% empty %}
<div class="card">
<div class="card-body">
<p>{% translate "No offerings found." %}</p>
</div>
</div>
{% endfor %}
{% endfor %}
</div>
</section>
{% endblock content %}

View file

@ -16,36 +16,40 @@
</div>
</div>
</div>
{% for service in services %}
<div class="card col-6 col-lg-3 col-md-4">
<div class="card-header d-flex align-items-center">
{% if service.logo %}
<img src="{{ service.logo.url }}"
alt="{{ service.name }}"
class="me-3"
style="max-width: 48px;
max-height: 48px">
{% endif %}
<div class="d-flex flex-column">
<h4 class="mb-0">{{ service.name }}</h4>
<small class="text-muted">{{ service.category }}</small>
<div class="row">
{% for service in services %}
<div class="col-6 col-lg-3 col-md-4">
<div class="card">
<div class="card-header d-flex align-items-center">
{% if service.logo %}
<img src="{{ service.logo.url }}"
alt="{{ service.name }}"
class="me-3"
style="max-width: 48px;
max-height: 48px">
{% endif %}
<div class="d-flex flex-column">
<h4 class="mb-0">{{ service.name }}</h4>
<small class="text-muted">{{ service.category }}</small>
</div>
</div>
<div class="card-body">
{% if service.description %}<p class="card-text">{{ service.description }}</p>{% endif %}
</div>
<div class="card-footer d-flex justify-content-between">
<span></span>
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
</div>
</div>
</div>
<div class="card-body">
{% if service.description %}<p class="card-text">{{ service.description }}</p>{% endif %}
{% empty %}
<div class="card">
<div class="card-body">
<p>{% translate "No services found." %}</p>
</div>
</div>
<div class="card-footer d-flex justify-content-between">
<span></span>
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "Read More" %}</a>
</div>
</div>
{% empty %}
<div class="card">
<div class="card-body">
<p>{% translate "No services found." %}</p>
</div>
</div>
{% endfor %}
{% endfor %}
</div>
</section>
<script src="{% static "js/autosubmit.js" %}" defer></script>
{% endblock content %}

View file

@ -36,26 +36,105 @@
</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>
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "VAT ID" %}</span>
</th>
<td>{{ odoo_data.company.vat|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,54 @@
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
if not billing_choice or billing_choice == "new":
billing_entity = BillingEntity.create_from_data(
form.cleaned_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(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

@ -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")
@ -117,9 +116,19 @@ if all(
"addressing_style": SERVALA_S3_ADDRESSING_STYLE,
"signature_version": SERVALA_S3_SIGNATURE_VERSION,
},
}
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
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 #
#######################################

8
uv.lock generated
View file

@ -289,16 +289,16 @@ wheels = [
[[package]]
name = "django"
version = "5.2"
version = "5.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4c/1b/c6da718c65228eb3a7ff7ba6a32d8e80fa840ca9057490504e099e4dd1ef/Django-5.2.tar.gz", hash = "sha256:1a47f7a7a3d43ce64570d350e008d2949abe8c7e21737b351b6a1611277c6d89", size = 10824891, upload-time = "2025-04-02T13:08:06.874Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/10/0d546258772b8f31398e67c85e52c66ebc2b13a647193c3eef8ee433f1a8/django-5.2.1.tar.gz", hash = "sha256:57fe1f1b59462caed092c80b3dd324fd92161b620d59a9ba9181c34746c97284", size = 10818735, upload-time = "2025-05-07T14:06:17.543Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/e0/6a5b5ea350c5bd63fe94b05e4c146c18facb51229d9dee42aa39f9fc2214/Django-5.2-py3-none-any.whl", hash = "sha256:91ceed4e3a6db5aedced65e3c8f963118ea9ba753fc620831c77074e620e7d83", size = 8301361, upload-time = "2025-04-02T13:08:01.465Z" },
{ url = "https://files.pythonhosted.org/packages/90/92/7448697b5838b3a1c6e1d2d6a673e908d0398e84dc4f803a2ce11e7ffc0f/django-5.2.1-py3-none-any.whl", hash = "sha256:a9b680e84f9a0e71da83e399f1e922e1ab37b2173ced046b541c72e1589a5961", size = 8301833, upload-time = "2025-05-07T14:06:10.955Z" },
]
[[package]]
@ -1022,7 +1022,7 @@ dev = [
requires-dist = [
{ name = "argon2-cffi", specifier = ">=23.1.0" },
{ name = "cryptography", specifier = ">=44.0.2" },
{ name = "django", specifier = "==5.2" },
{ name = "django", specifier = "==5.2.1" },
{ name = "django-allauth", specifier = ">=65.5.0" },
{ name = "django-fernet-encrypted-fields", specifier = ">=0.3.0" },
{ name = "django-scopes", specifier = ">=2.0.0" },