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
24 changed files with 545 additions and 171 deletions

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

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

@ -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):
@ -125,9 +126,63 @@ class BillingEntity(ServalaModelMixin, models.Model):
return self.name
@classmethod
def create_from_data(cls, odoo_data):
instance = BillingEntity.objects.create(name=odoo_data.get("name"))
# TODO implement odoo creation from data
@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
@ -137,6 +192,49 @@ class BillingEntity(ServalaModelMixin, models.Model):
# 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

@ -87,6 +87,33 @@ class OdooClient:
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

View file

@ -3,7 +3,7 @@ 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
from servala.core.odoo import get_invoice_addresses, get_odoo_countries
from servala.frontend.forms.mixins import HtmxMixin
@ -28,33 +28,34 @@ class OrganizationCreateForm(OrganizationForm):
required=False,
)
# Fields for creating a new billing address in Odoo, prefixed with 'ba_'
ba_name = forms.CharField(
label=_("Contact Person / Company Name"), required=False, max_length=100
# 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(),
)
ba_street = forms.CharField(label=_("Street"), required=False, max_length=100)
ba_street2 = forms.CharField(
label=_("Street 2 (Optional)"), required=False, max_length=100
)
ba_city = forms.CharField(label=_("City"), required=False, max_length=100)
ba_zip = forms.CharField(label=_("ZIP Code"), required=False, max_length=20)
# For state & country, Odoo uses structured data. For now, text input.
# These will need mapping logic when actual Odoo creation is implemented.
ba_state_name = forms.CharField(
label=_("State / Province"), required=False, max_length=100
)
ba_country_name = forms.CharField(
label=_("Country"), required=False, max_length=100
)
ba_email = forms.EmailField(label=_("Billing Email"), required=False)
ba_phone = forms.CharField(label=_("Billing Phone"), required=False, max_length=30)
ba_vat = forms.CharField(label=_("VAT ID"), required=False, max_length=50)
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)
@ -74,39 +75,27 @@ class OrganizationCreateForm(OrganizationForm):
if not self.is_bound and "billing_processing_choice" not in self.initial:
self.fields["billing_processing_choice"].initial = "existing"
else:
# No existing Odoo addresses. Force 'new' choice.
self.fields["billing_processing_choice"].choices = [
("new", _("Create a new billing address")),
]
self.fields["billing_processing_choice"].initial = "new"
self.fields["billing_processing_choice"].widget = forms.HiddenInput()
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 choice == "new":
if not choice or choice == "new":
required_fields = [
"ba_name",
"ba_street",
"ba_city",
"ba_zip",
"ba_state_name",
"ba_country_name",
"ba_email",
"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 when creating a new billing address."
),
)
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 existing address.")
"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

@ -5,31 +5,75 @@
{% translate "Create a new organization" %}
{% endblock page_title %}
{% endblock html_title %}
{% block card_content %}
<form method="post" class="form form-vertical">
{% include "frontend/forms/errors.html" %}
{% csrf_token %}
<div class="form-body">
<div class="row">
{{ form.name.as_field_group }}
<hr class="my-4">
<h4>{% translate "Billing Information" %}</h4>
{{ 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 id="new_billing_address_section" class="mt-3">
{{ form.ba_name.as_field_group }}
{{ form.ba_street.as_field_group }}
{{ form.ba_street2.as_field_group }}
{{ form.ba_city.as_field_group }}
{{ form.ba_zip.as_field_group }}
{{ form.ba_state_name.as_field_group }}
{{ form.ba_country_name.as_field_group }}
{{ form.ba_email.as_field_group }}
{{ form.ba_phone.as_field_group }}
{{ form.ba_vat.as_field_group }}
{% 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 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>
{% 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>
@ -65,8 +109,8 @@
} else {
// No existing addresses found, a new address has to be entered.
if (existingSection) existingSection.style.display = 'none'
newSection.style.display = ''
if (newSection) newSection.style.display = '' // Ensure newSection is not null
}
});
</script>
{% endblock card_content %}
{% 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

@ -22,13 +22,14 @@ class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView):
billing_choice = form.cleaned_data.get("billing_processing_choice")
billing_entity = None
if billing_choice == "new":
if not billing_choice or billing_choice == "new":
billing_entity = BillingEntity.create_from_data(
form.cleaned_data["name"],
{
key[3:]: value
key: value
for key, value in form.cleaned_data.items()
if key.startswith("ba_")
}
if key.startswith("invoice_")
},
)
elif odoo_id := form.cleaned_data.get("existing_odoo_address_id"):
billing_entity = BillingEntity.objects.filter(

View file

@ -116,7 +116,10 @@ if all(
"addressing_style": SERVALA_S3_ADDRESSING_STYLE,
"signature_version": SERVALA_S3_SIGNATURE_VERSION,
},
}
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
ODOO = {

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