From cdd8838b3df8dad03d54ac1d9713a7036ab816d1 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 27 May 2025 14:17:20 +0200 Subject: [PATCH] Implement template/form/view for org create --- src/servala/core/models/organization.py | 13 +++ src/servala/core/odoo.py | 1 + src/servala/frontend/forms/organization.py | 102 ++++++++++++++++++ .../templates/frontend/forms/errors.html | 18 ++++ .../templates/frontend/forms/form.html | 19 +--- .../frontend/organizations/create.html | 64 ++++++++++- src/servala/frontend/views/organization.py | 41 ++++++- 7 files changed, 236 insertions(+), 22 deletions(-) create mode 100644 src/servala/frontend/templates/frontend/forms/errors.html diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 201a8b9..c113f78 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -124,6 +124,19 @@ class BillingEntity(ServalaModelMixin, models.Model): def __str__(self): 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 + 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 + class OrganizationOrigin(ServalaModelMixin, models.Model): """ diff --git a/src/servala/core/odoo.py b/src/servala/core/odoo.py index 81a24bc..85f0b35 100644 --- a/src/servala/core/odoo.py +++ b/src/servala/core/odoo.py @@ -97,6 +97,7 @@ def get_invoice_addresses(user): # 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 diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py index 41cc26c..115e574 100644 --- a/src/servala/frontend/forms/organization.py +++ b/src/servala/frontend/forms/organization.py @@ -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 from servala.frontend.forms.mixins import HtmxMixin @@ -8,3 +11,102 @@ 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 'ba_' + ba_name = forms.CharField( + label=_("Contact Person / Company Name"), required=False, max_length=100 + ) + 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) + + class Meta(OrganizationForm.Meta): + pass + + def __init__(self, *args, user=None, **kwargs): + super().__init__(*args, **kwargs) + 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: + # 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["existing_odoo_address_id"].widget = forms.HiddenInput() + + def clean(self): + cleaned_data = super().clean() + choice = cleaned_data.get("billing_processing_choice") + if choice == "new": + required_fields = [ + "ba_name", + "ba_street", + "ba_city", + "ba_zip", + "ba_state_name", + "ba_country_name", + "ba_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." + ), + ) + 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.") + ) + return cleaned_data diff --git a/src/servala/frontend/templates/frontend/forms/errors.html b/src/servala/frontend/templates/frontend/forms/errors.html new file mode 100644 index 0000000..964687d --- /dev/null +++ b/src/servala/frontend/templates/frontend/forms/errors.html @@ -0,0 +1,18 @@ +{% load i18n %} +{% if form.non_field_errors or form.errors %} + +{% endif %} diff --git a/src/servala/frontend/templates/frontend/forms/form.html b/src/servala/frontend/templates/frontend/forms/form.html index 8ab90d1..361b161 100644 --- a/src/servala/frontend/templates/frontend/forms/form.html +++ b/src/servala/frontend/templates/frontend/forms/form.html @@ -1,21 +1,4 @@ -{% load i18n %} -{% if form.non_field_errors or form.errors %} - -{% endif %} +{% include "frontend/forms/errors.html" %}
{% for field, errors in fields %}{{ field.as_field_group }}{% endfor %} diff --git a/src/servala/frontend/templates/frontend/organizations/create.html b/src/servala/frontend/templates/frontend/organizations/create.html index 2d7cfa4..fc0fe58 100644 --- a/src/servala/frontend/templates/frontend/organizations/create.html +++ b/src/servala/frontend/templates/frontend/organizations/create.html @@ -6,5 +6,67 @@ {% endblock page_title %} {% endblock html_title %} {% block card_content %} - {% include "includes/form.html" %} +
+ {% include "frontend/forms/errors.html" %} + {% csrf_token %} +
+
+ {{ form.name.as_field_group }} +
+

{% translate "Billing Information" %}

+ {{ form.billing_processing_choice.as_field_group }} +
{{ form.existing_odoo_address_id.as_field_group }}
+
+ {{ 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 }} +
+
+ +
+
+
+
+ {% endblock card_content %} diff --git a/src/servala/frontend/views/organization.py b/src/servala/frontend/views/organization.py index 7bfdee5..fe0bbb2 100644 --- a/src/servala/frontend/views/organization.py +++ b/src/servala/frontend/views/organization.py @@ -1,18 +1,53 @@ 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 billing_choice == "new": + billing_entity = BillingEntity.create_from_data( + { + key[3:]: value + for key, value in form.cleaned_data.items() + if key.startswith("ba_") + } + ) + 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 )