Compare commits
20 commits
main
...
54-billing
Author | SHA1 | Date | |
---|---|---|---|
9008833306 | |||
dae5e7153b | |||
9a46229e81 | |||
c7d079e9bf | |||
3a16a4a301 | |||
b54f8ecbc2 | |||
19fbc4b5f3 | |||
fe6580c3d3 | |||
f973d33e51 | |||
e2b13a3223 | |||
0bb9f8785d | |||
86cf2956a2 | |||
d72c853e72 | |||
29b630ea2b | |||
f774b37696 | |||
fcca4ea514 | |||
93c4fc9b26 | |||
3abd298dea | |||
639e183184 | |||
edafff897e |
23 changed files with 874 additions and 62 deletions
|
@ -60,4 +60,9 @@ SERVALA_KEYCLOAK_SERVER_URL=''
|
|||
# SERVALA_S3_SIGNATURE_VERSION='s3v4'
|
||||
|
||||
# Configuration for Sentry error reporting
|
||||
SERVALA_SENTRY_DSN=''
|
||||
SERVALA_SENTRY_DSN=''
|
||||
|
||||
SERVALA_ODOO_DB=''
|
||||
SERVALA_ODOO_URL=''
|
||||
SERVALA_ODOO_USERNAME=''
|
||||
SERVALA_ODOO_PASSWORD=''
|
||||
|
|
|
@ -129,6 +129,7 @@ uv run --env-file=.env src/manage.py COMMAND
|
|||
Useful commands:
|
||||
|
||||
- ``migrate``: Make sure database migrations are applied.
|
||||
- ``check --deploy``: Runs checks, e.g. for missing or mismatched configuration, including custom servala configuration.
|
||||
- ``showmigrations``: Show current database migrations status. Good for debugging.
|
||||
- ``runserver``: Run development server
|
||||
- ``clearsessions``: Clear away expired user sessions. Recommended to run regularly, e.g. weekly or monthly (doesn’t
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* xref:web-portal.adoc[]
|
||||
** xref:web-portal-admin.adoc[Admin]
|
||||
** xref:web-portal-controlplanes.adoc[Control-Planes]
|
||||
** xref:web-portal-billingentity.adoc[Billing Entities]
|
||||
|
||||
* xref:web-portal-planning.adoc[]
|
||||
** xref:user-stories.adoc[]
|
||||
|
|
26
docs/modules/ROOT/pages/web-portal-billingentity.adoc
Normal file
26
docs/modules/ROOT/pages/web-portal-billingentity.adoc
Normal file
|
@ -0,0 +1,26 @@
|
|||
= Web Portal Billing Entities
|
||||
|
||||
Billing entities are used to connect an invoice address in Odoo to an organization in Servala.
|
||||
|
||||
When creating a new organization, the billing information is required to be added.
|
||||
|
||||
== Existing Billing Address
|
||||
|
||||
With the email address of the currently logged-in user, Odoo is searched for existing `res.partner` records and presented in the dropdown.
|
||||
|
||||
Search is done this way:
|
||||
|
||||
* `res.partner` records created by a matching Odoo user.
|
||||
* User email matches an invoice address or contact address
|
||||
|
||||
== New Billing Address
|
||||
|
||||
When choosing to add a new billing address, two new records are created in the Odoo `res.partner` model:
|
||||
|
||||
* A record with the field `company_type = company`
|
||||
* A record with the following field configuration:
|
||||
** `company_type = person`
|
||||
** `type = invoice`
|
||||
** `parent_id = company_id`
|
||||
|
||||
The resulting database IDs are stored in the Servala portal database for referencing the records in Odoo.
|
|
@ -3,11 +3,11 @@ name = "servala"
|
|||
version = "0.0.0"
|
||||
description = "Servala portal server and frontend"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13.4"
|
||||
requires-python = ">=3.13.3"
|
||||
dependencies = [
|
||||
"argon2-cffi>=25.1.0",
|
||||
"cryptography>=45.0.3",
|
||||
"django==5.2.2",
|
||||
"django==5.2.1",
|
||||
"django-allauth>=65.9.0",
|
||||
"django-fernet-encrypted-fields>=0.3.0",
|
||||
"django-scopes>=2.0.0",
|
||||
|
|
|
@ -4,3 +4,6 @@ from django.apps import AppConfig
|
|||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "servala.core"
|
||||
|
||||
def ready(self):
|
||||
import servala.core.checks # noqa
|
||||
|
|
86
src/servala/core/checks.py
Normal file
86
src/servala/core/checks.py
Normal file
|
@ -0,0 +1,86 @@
|
|||
from django.conf import settings
|
||||
from django.core.checks import ERROR, WARNING, CheckMessage, register
|
||||
|
||||
|
||||
@register()
|
||||
def check_servala_settings(app_configs, **kwargs):
|
||||
"""Checks all settings that should be present in all environments."""
|
||||
if app_configs:
|
||||
# Don’t run if we’re meant to only test individual apps
|
||||
return []
|
||||
|
||||
errors = []
|
||||
required_fields = ("URL", "DB", "USERNAME", "PASSWORD")
|
||||
missing_fields = [
|
||||
field for field in required_fields if not settings.ODOO.get(field)
|
||||
]
|
||||
if missing_fields:
|
||||
fields = ", ".join(missing_fields)
|
||||
errors.append(
|
||||
CheckMessage(
|
||||
level=WARNING if settings.DEBUG else ERROR,
|
||||
msg=f"Missing Odoo config: {fields}",
|
||||
hint="Make sure you set the required SERVALA_ODOO_* settings.",
|
||||
id="servala.E001",
|
||||
)
|
||||
)
|
||||
oidc_config = settings.SOCIALACCOUNT_PROVIDERS["openid_connect"]["APPS"][0]
|
||||
missing_fields = [
|
||||
field for field in ("client_id", "secret") if not oidc_config.get(field)
|
||||
]
|
||||
if not oidc_config["settings"]["server_url"]:
|
||||
missing_fields.append("server_url")
|
||||
if missing_fields:
|
||||
fields = ", ".join(
|
||||
[f"SERVALA_KEYCLOAK_{field.upper()}" for field in missing_fields]
|
||||
)
|
||||
errors.append(
|
||||
CheckMessage(
|
||||
level=WARNING if settings.DEBUG else ERROR,
|
||||
msg=f"Missing Keycloak config: {fields}",
|
||||
id="servala.E002",
|
||||
)
|
||||
)
|
||||
|
||||
if settings.SERVALA_ENVIRONMENT not in ("development", "staging", "production"):
|
||||
errors.append(
|
||||
CheckMessage(
|
||||
level=ERROR,
|
||||
msg=f"Invalid environment {settings.SERVALA_ENVIRONMENT}",
|
||||
hint="Must be one of development, staging, production.",
|
||||
id="servala.E003",
|
||||
)
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
@register(deploy=True)
|
||||
def check_servala_production_settings(app_configs, **kwargs):
|
||||
if app_configs:
|
||||
# Don’t run if we’re meant to only test individual apps
|
||||
return []
|
||||
|
||||
errors = []
|
||||
if settings.SERVALA_ENVIRONMENT == "development":
|
||||
errors.append(
|
||||
CheckMessage(
|
||||
level=ERROR,
|
||||
msg="Environment is set to 'development'.",
|
||||
id="servala.E004",
|
||||
)
|
||||
)
|
||||
if "insecure" in settings.SECRET_KEY:
|
||||
errors.append(
|
||||
CheckMessage(
|
||||
level=ERROR, msg="Secret key contains 'insecure'.", id="servala.E005"
|
||||
)
|
||||
)
|
||||
if settings.EMAIL_USE_SSL and settings.EMAIL_USE_TLS:
|
||||
errors.append(
|
||||
CheckMessage(
|
||||
level=WARNING,
|
||||
msg="Use either SSL or TLS in email config, not both!",
|
||||
id="servala.W001",
|
||||
)
|
||||
)
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 5.2 on 2025-05-26 05:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0003_alter_organization_namespace"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="billingentity",
|
||||
name="odoo_company_id",
|
||||
field=models.IntegerField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="billingentity",
|
||||
name="odoo_invoice_id",
|
||||
field=models.IntegerField(null=True),
|
||||
),
|
||||
]
|
|
@ -1,7 +1,7 @@
|
|||
import rules
|
||||
import urlman
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
@ -9,6 +9,7 @@ from django_scopes import ScopedManager, scopes_disabled
|
|||
|
||||
from servala.core import rules as perms
|
||||
from servala.core.models.mixins import ServalaModelMixin
|
||||
from servala.core.odoo import CLIENT
|
||||
|
||||
|
||||
class Organization(ServalaModelMixin, models.Model):
|
||||
|
@ -110,6 +111,13 @@ class BillingEntity(ServalaModelMixin, models.Model):
|
|||
max_length=100, blank=True, verbose_name=_("ERP reference")
|
||||
)
|
||||
|
||||
# Odoo IDs are nullable for creation, should never be null in practice
|
||||
# The company ID points at a record of type res.partner with company_type=company
|
||||
# The invoice ID points at a record of type res.partner with company_type=person,
|
||||
# type=invoice, parent_id=company_id (the invoice address).
|
||||
odoo_company_id = models.IntegerField(null=True)
|
||||
odoo_invoice_id = models.IntegerField(null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Billing entity")
|
||||
verbose_name_plural = _("Billing entities")
|
||||
|
@ -117,6 +125,131 @@ class BillingEntity(ServalaModelMixin, models.Model):
|
|||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def create_from_data(cls, name, odoo_data):
|
||||
"""
|
||||
Creates a BillingEntity and corresponding Odoo records.
|
||||
|
||||
This method creates a `res.partner` record in Odoo with `company_type='company'`
|
||||
for the main company, and another `res.partner` record with `company_type='person'`
|
||||
and `type='invoice'` (linked via `parent_id` to the first record) for the
|
||||
invoice address. The IDs of these Odoo records are stored in the BillingEntity.
|
||||
|
||||
Args:
|
||||
odoo_data (dict): A dictionary containing the data for creating
|
||||
the BillingEntity and Odoo records.
|
||||
|
||||
Expected keys in `odoo_data`:
|
||||
- `invoice_street` (str): Street for the invoice address.
|
||||
- `invoice_street2` (str): Second line of street address for the invoice address.
|
||||
- `invoice_city` (str): City for the invoice address.
|
||||
- `invoice_zip` (str): ZIP/Postal code for the invoice address.
|
||||
- `invoice_country` (int): Odoo `res.country` ID for the invoice address country.
|
||||
- `invoice_email` (str): Email address for the invoice contact.
|
||||
- `invoice_phone` (str): Phone number for the invoice contact.
|
||||
"""
|
||||
instance = cls.objects.create(name=name)
|
||||
company_payload = {
|
||||
"name": odoo_data.get("company_name", name),
|
||||
"company_type": "company",
|
||||
}
|
||||
company_id = CLIENT.execute("res.partner", "create", [company_payload])
|
||||
instance.odoo_company_id = company_id
|
||||
|
||||
invoice_address_payload = {
|
||||
"name": name,
|
||||
"company_type": "person",
|
||||
"type": "invoice",
|
||||
"parent_id": company_id,
|
||||
}
|
||||
invoice_optional_fields = {
|
||||
"street": odoo_data.get("invoice_street"),
|
||||
"street2": odoo_data.get("invoice_street2"),
|
||||
"city": odoo_data.get("invoice_city"),
|
||||
"zip": odoo_data.get("invoice_zip"),
|
||||
"country_id": odoo_data.get("invoice_country"),
|
||||
"email": odoo_data.get("invoice_email"),
|
||||
}
|
||||
invoice_address_payload.update(
|
||||
{k: v for k, v in invoice_optional_fields.items() if v is not None}
|
||||
)
|
||||
|
||||
invoice_address_id = CLIENT.execute(
|
||||
"res.partner", "create", [invoice_address_payload]
|
||||
)
|
||||
instance.odoo_invoice_id = invoice_address_id
|
||||
|
||||
instance.save(update_fields=["odoo_company_id", "odoo_invoice_id"])
|
||||
return instance
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def create_from_id(cls, name, odoo_id):
|
||||
parent_data = CLIENT.search_read(
|
||||
model="res.partner",
|
||||
domain=[["id", "=", odoo_id]],
|
||||
fields=["parent_id"],
|
||||
limit=1,
|
||||
)
|
||||
# Data validation: If the data is not as expected, we just return None,
|
||||
# rather than raising an exception, for now.
|
||||
if not parent_data:
|
||||
return
|
||||
if not (parent_info := parent_data[0].get("parent_id")):
|
||||
return
|
||||
if not isinstance(parent_info, (list, tuple)) or not len(parent_info) > 0:
|
||||
# parent_info is a tuple of the parent’s ID and name
|
||||
return
|
||||
|
||||
instance = cls.objects.create(
|
||||
name=name, odoo_invoice_id=odoo_id, odoo_company_id=parent_info[0]
|
||||
)
|
||||
return instance
|
||||
|
||||
@cached_property
|
||||
def odoo_data(self):
|
||||
data = {
|
||||
"company": None,
|
||||
"invoice_address": None,
|
||||
}
|
||||
|
||||
company_fields = ["name", "company_type"]
|
||||
invoice_address_fields = [
|
||||
"name",
|
||||
"company_type",
|
||||
"type",
|
||||
"parent_id",
|
||||
"street",
|
||||
"street2",
|
||||
"city",
|
||||
"zip",
|
||||
"country_id",
|
||||
"email",
|
||||
]
|
||||
|
||||
if self.odoo_company_id:
|
||||
company_records = CLIENT.search_read(
|
||||
model="res.partner",
|
||||
domain=[["id", "=", self.odoo_company_id]],
|
||||
fields=company_fields,
|
||||
limit=1,
|
||||
)
|
||||
if company_records:
|
||||
data["company"] = company_records[0]
|
||||
|
||||
if self.odoo_invoice_id:
|
||||
invoice_address_records = CLIENT.search_read(
|
||||
model="res.partner",
|
||||
domain=[["id", "=", self.odoo_invoice_id]],
|
||||
fields=invoice_address_fields,
|
||||
limit=1,
|
||||
)
|
||||
if invoice_address_records:
|
||||
data["invoice_address"] = invoice_address_records[0]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class OrganizationOrigin(ServalaModelMixin, models.Model):
|
||||
"""
|
||||
|
|
|
@ -6,7 +6,8 @@ from django.contrib.auth.models import (
|
|||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .mixins import ServalaModelMixin
|
||||
from servala.core import odoo
|
||||
from servala.core.models.mixins import ServalaModelMixin
|
||||
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
|
@ -73,3 +74,22 @@ class User(ServalaModelMixin, PermissionsMixin, AbstractBaseUser):
|
|||
|
||||
def normalize_username(self, username):
|
||||
return super().normalize_username(username).strip().lower()
|
||||
|
||||
def get_odoo_contact(self, organization):
|
||||
if (
|
||||
not organization.billing_entity
|
||||
or not organization.billing_entity.odoo_company_id
|
||||
):
|
||||
return
|
||||
result = odoo.CLIENT.search_read(
|
||||
model="res.partner",
|
||||
domain=[
|
||||
("company_type", "=", "person"),
|
||||
("type", "=", "contact"),
|
||||
("email", "ilike", self.email),
|
||||
("parent_id", "=", organization.billing_entity.odoo_company_id),
|
||||
],
|
||||
fields=odoo.ADDRESS_FIELDS,
|
||||
)
|
||||
if result:
|
||||
return result[0]
|
||||
|
|
173
src/servala/core/odoo.py
Normal file
173
src/servala/core/odoo.py
Normal file
|
@ -0,0 +1,173 @@
|
|||
import xmlrpc.client
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
ADDRESS_FIELDS = [
|
||||
"id",
|
||||
"name",
|
||||
"street",
|
||||
"street2",
|
||||
"city",
|
||||
"zip",
|
||||
"state_id",
|
||||
"country_id",
|
||||
"email",
|
||||
"phone",
|
||||
"vat",
|
||||
"company_type",
|
||||
"type",
|
||||
]
|
||||
|
||||
|
||||
class OdooClient:
|
||||
def __init__(self):
|
||||
self.url = settings.ODOO["URL"]
|
||||
self.db = settings.ODOO["DB"]
|
||||
self.username = settings.ODOO["USERNAME"]
|
||||
self.password = settings.ODOO["PASSWORD"]
|
||||
|
||||
self.common_proxy = None
|
||||
self.models_proxy = None
|
||||
self.uid = None
|
||||
|
||||
def _connect(self):
|
||||
"""This method is called on the first client request, not on instantiation,
|
||||
so that we can instantiate the client on startup and reuse it across the entire
|
||||
application."""
|
||||
try:
|
||||
self.common_proxy = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/common")
|
||||
self.uid = self.common_proxy.authenticate(
|
||||
self.db, self.username, self.password, {}
|
||||
)
|
||||
|
||||
if not self.uid:
|
||||
raise Exception("Authentication failed with Odoo: No UID returned.")
|
||||
|
||||
self.models_proxy = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/object")
|
||||
|
||||
except xmlrpc.client.Fault as e:
|
||||
raise Exception(
|
||||
f"Odoo XML-RPC Fault during connection: {e.faultString}"
|
||||
) from e
|
||||
except ConnectionRefusedError as e:
|
||||
raise Exception(
|
||||
f"Could not connect to Odoo at {self.url}. Connection refused."
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise Exception(
|
||||
f"An error occurred while connecting to Odoo: {str(e)}"
|
||||
) from e
|
||||
|
||||
def execute(self, model, method, args_list, **kwargs):
|
||||
if not self.uid or not self.models_proxy:
|
||||
self._connect()
|
||||
|
||||
try:
|
||||
result = self.models_proxy.execute_kw(
|
||||
self.db, self.uid, self.password, model, method, args_list, kwargs
|
||||
)
|
||||
return result
|
||||
|
||||
except xmlrpc.client.Fault as e:
|
||||
print(f"Fault! {e}")
|
||||
raise Exception(f"Odoo XML-RPC Fault: {e.faultString}") from e
|
||||
except ConnectionRefusedError as e:
|
||||
raise Exception(
|
||||
f"Connection to Odoo at {self.url} lost or refused during operation."
|
||||
) from e
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise Exception(
|
||||
f"An error occurred while communicating with Odoo: {str(e)}"
|
||||
) from e
|
||||
|
||||
def search_read(self, model, domain, fields, **kwargs):
|
||||
return self.execute(model, "search_read", args_list=[domain, fields], **kwargs)
|
||||
|
||||
|
||||
CLIENT = OdooClient()
|
||||
|
||||
# Odoo countries do not change, so they are fetched once per process
|
||||
COUNTRIES = []
|
||||
|
||||
|
||||
def get_odoo_countries():
|
||||
global COUNTRIES
|
||||
if COUNTRIES:
|
||||
return COUNTRIES
|
||||
|
||||
try:
|
||||
odoo_countries_data = CLIENT.search_read(
|
||||
model="res.country", domain=[], fields=["id", "name"]
|
||||
)
|
||||
# Format as Django choices: [(value, label), ...]
|
||||
COUNTRIES = [
|
||||
(country["id"], country["name"]) for country in odoo_countries_data
|
||||
]
|
||||
# Sort by country name for better UX in dropdowns
|
||||
COUNTRIES.sort(key=lambda x: x[1])
|
||||
except Exception as e:
|
||||
# Log the error or handle it as appropriate for your application
|
||||
# For now, return an empty list or a default if Odoo is unavailable
|
||||
print(f"Error fetching Odoo countries: {e}")
|
||||
return [("", "Error fetching countries")] # Or just []
|
||||
|
||||
return COUNTRIES
|
||||
|
||||
|
||||
def get_invoice_addresses(user):
|
||||
"""Used during organization creation: retrieves all invoice
|
||||
addresses the user owns or is connected to from the Odoo API."""
|
||||
# We’re building our conditions in order:
|
||||
# - in exceptions, users may be using a billing account’s email
|
||||
# - if the user is associated with an odoo user, return all billing
|
||||
# addresses / organizations created by the user
|
||||
# - if the user is associated with an odoo contact, return all billing
|
||||
# addresses with the same parent_id
|
||||
email = user.email
|
||||
or_conditions = [("email", "ilike", email)]
|
||||
|
||||
email = user if isinstance(user, str) else user.email
|
||||
odoo_users = CLIENT.search_read(
|
||||
model="res.users",
|
||||
domain=[("login", "=", email)],
|
||||
fields=["id"],
|
||||
limit=1,
|
||||
)
|
||||
if odoo_users and (uid := odoo_users[0].get("id")):
|
||||
or_conditions.append(("create_uid", "=", uid))
|
||||
|
||||
odoo_contacts = CLIENT.search_read(
|
||||
model="res.partner",
|
||||
domain=[
|
||||
("company_type", "=", "person"),
|
||||
("type", "=", "contact"),
|
||||
("email", "ilike", email),
|
||||
],
|
||||
fields=["id", "parent_id"],
|
||||
)
|
||||
if odoo_contacts:
|
||||
for contact in odoo_contacts:
|
||||
or_conditions.append(("parent_id", "=", contact["parent_id"][0]))
|
||||
|
||||
if len(or_conditions) > 1:
|
||||
or_conditions = ["|"] * (len(or_conditions) - 1) + or_conditions
|
||||
|
||||
# The domain requires the partner to be an invoice address, that is:
|
||||
# Of the company_type=person, and type=invoice.
|
||||
# If we were searching for an existing organization, we would also have to
|
||||
# filter for parent_id=odoo_company_id
|
||||
domain = [
|
||||
("company_type", "=", "person"),
|
||||
("type", "=", "invoice"),
|
||||
] + or_conditions
|
||||
|
||||
try:
|
||||
invoice_addresses = CLIENT.search_read(
|
||||
model="res.partner",
|
||||
domain=domain,
|
||||
fields=ADDRESS_FIELDS,
|
||||
)
|
||||
return invoice_addresses or []
|
||||
except Exception:
|
||||
return []
|
|
@ -1,6 +1,9 @@
|
|||
from django import forms
|
||||
from django.forms import ModelForm
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from servala.core.models import Organization
|
||||
from servala.core.odoo import get_invoice_addresses, get_odoo_countries
|
||||
from servala.frontend.forms.mixins import HtmxMixin
|
||||
|
||||
|
||||
|
@ -8,3 +11,90 @@ class OrganizationForm(HtmxMixin, ModelForm):
|
|||
class Meta:
|
||||
model = Organization
|
||||
fields = ("name",)
|
||||
|
||||
|
||||
class OrganizationCreateForm(OrganizationForm):
|
||||
billing_processing_choice = forms.ChoiceField(
|
||||
choices=[
|
||||
("existing", _("Use an existing billing address")),
|
||||
("new", _("Create a new billing address")),
|
||||
],
|
||||
widget=forms.RadioSelect,
|
||||
label=_("Billing Address"),
|
||||
initial="new", # Will change to 'existing' if options are found
|
||||
)
|
||||
existing_odoo_address_id = forms.ChoiceField(
|
||||
label=_("Existing Billing Address"),
|
||||
required=False,
|
||||
)
|
||||
|
||||
# Fields for creating a new billing address in Odoo, prefixed with 'invoice_'
|
||||
invoice_street = forms.CharField(label=_("Line 1"), required=False, max_length=100)
|
||||
invoice_street2 = forms.CharField(label=_("Line 2"), required=False, max_length=100)
|
||||
invoice_city = forms.CharField(label=_("City"), required=False, max_length=100)
|
||||
invoice_zip = forms.CharField(label=_("Postal Code"), required=False, max_length=20)
|
||||
invoice_country = forms.ChoiceField(
|
||||
label=_("Country"),
|
||||
required=False,
|
||||
choices=get_odoo_countries(),
|
||||
)
|
||||
invoice_email = forms.EmailField(label=_("Billing Email"), required=False)
|
||||
invoice_phone = forms.CharField(label=_("Phone"), required=False, max_length=30)
|
||||
|
||||
class Meta(OrganizationForm.Meta):
|
||||
pass
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not self.initial.get("invoice_country"):
|
||||
default_country_name = "Switzerland"
|
||||
country_choices = self.fields["invoice_country"].choices
|
||||
for country_id, country_name_label in country_choices:
|
||||
if country_name_label == default_country_name:
|
||||
self.initial["invoice_country"] = country_id
|
||||
break
|
||||
|
||||
self.user = user
|
||||
self.odoo_addresses = get_invoice_addresses(self.user)
|
||||
|
||||
if self.odoo_addresses:
|
||||
address_choices = [("", _("---------"))]
|
||||
for addr in self.odoo_addresses:
|
||||
display_parts = [
|
||||
addr.get("name"),
|
||||
addr.get("street"),
|
||||
addr.get("city"),
|
||||
addr.get("zip"),
|
||||
]
|
||||
display_name = ", ".join(filter(None, display_parts))
|
||||
address_choices.append((str(addr["id"]), display_name))
|
||||
|
||||
self.fields["existing_odoo_address_id"].choices = address_choices
|
||||
if not self.is_bound and "billing_processing_choice" not in self.initial:
|
||||
self.fields["billing_processing_choice"].initial = "existing"
|
||||
else:
|
||||
self.fields.pop("billing_processing_choice")
|
||||
self.fields["existing_odoo_address_id"].widget = forms.HiddenInput()
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
choice = cleaned_data.get("billing_processing_choice")
|
||||
if not choice or choice == "new":
|
||||
required_fields = [
|
||||
"invoice_street",
|
||||
"invoice_city",
|
||||
"invoice_zip",
|
||||
"invoice_country",
|
||||
"invoice_email",
|
||||
]
|
||||
for field_name in required_fields:
|
||||
if not cleaned_data.get(field_name):
|
||||
self.add_error(field_name, _("This field is required."))
|
||||
else:
|
||||
existing_id_str = cleaned_data.get("existing_odoo_address_id")
|
||||
if not existing_id_str:
|
||||
self.add_error(
|
||||
"existing_odoo_address_id", _("Please select an invoice address.")
|
||||
)
|
||||
return cleaned_data
|
||||
|
|
|
@ -20,6 +20,8 @@ class VerticalFormRenderer(TemplatesSetting):
|
|||
def get_class_names(self, field):
|
||||
input_type = self.get_field_input_type(field)
|
||||
errors = "is-invalid " if field.errors else ""
|
||||
if input_type == "radio":
|
||||
return f"{errors}form-check-input"
|
||||
if input_type == "checkbox":
|
||||
return f"{errors}form-check-input"
|
||||
return f"{errors}form-control"
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
{% include "django/forms/widgets/input.html" %}
|
||||
{% if widget.wrap_label %}
|
||||
<label {% if widget.attrs.id %}for="{{ widget.attrs.id }}"{% endif %}>
|
||||
{% if not widget.attrs.hide_label %}{{ widget.label }}{% endif %}
|
||||
</label>
|
||||
{% endif %}
|
|
@ -0,0 +1,15 @@
|
|||
{# Change compared to Django: only render widget.attrs.class in actual option widget, not in wrapper #}
|
||||
{% with id=widget.attrs.id %}
|
||||
<div {% if id %}id="{{ id }}"{% endif %}>
|
||||
{% for group, options, index in widget.optgroups %}
|
||||
{% if group %}
|
||||
<div>
|
||||
<label>{{ group }}</label>
|
||||
{% endif %}
|
||||
{% for option in options %}
|
||||
<div>{% include option.template_name with widget=option %}</div>
|
||||
{% endfor %}
|
||||
{% if group %}</div>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endwith %}
|
18
src/servala/frontend/templates/frontend/forms/errors.html
Normal file
18
src/servala/frontend/templates/frontend/forms/errors.html
Normal file
|
@ -0,0 +1,18 @@
|
|||
{% load i18n %}
|
||||
{% if form.non_field_errors or form.errors %}
|
||||
<div class="alert alert-danger form-errors" role="alert">
|
||||
<div>
|
||||
{% if form.non_field_errors %}
|
||||
{% if form.non_field_errors|length > 1 %}
|
||||
<ul>
|
||||
{% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{{ form.non_field_errors.0 }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% translate "We could not save your changes." %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
|
@ -1,21 +1,4 @@
|
|||
{% load i18n %}
|
||||
{% if form.non_field_errors or form.errors %}
|
||||
<div class="alert alert-danger form-errors" role="alert">
|
||||
<div>
|
||||
{% if form.non_field_errors %}
|
||||
{% if form.non_field_errors|length > 1 %}
|
||||
<ul>
|
||||
{% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{{ form.non_field_errors.0 }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% translate "We could not save your changes." %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include "frontend/forms/errors.html" %}
|
||||
<div class="form-body">
|
||||
<div class="row">
|
||||
{% for field, errors in fields %}{{ field.as_field_group }}{% endfor %}
|
||||
|
|
|
@ -5,6 +5,110 @@
|
|||
{% translate "Create a new organization" %}
|
||||
{% endblock page_title %}
|
||||
{% endblock html_title %}
|
||||
{% block card_content %}
|
||||
{% include "includes/form.html" %}
|
||||
{% endblock card_content %}
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<form method="post" class="form form-vertical">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="form-body card-body">
|
||||
<div class="row">
|
||||
{% include "frontend/forms/errors.html" %}
|
||||
{% csrf_token %}
|
||||
{{ form.name.as_field_group }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if form.billing_processing_choice %}
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">{% translate "Billing Information" %}</h4>
|
||||
</div>
|
||||
<div class="form-body card-body">
|
||||
<div class="row">
|
||||
{{ form.billing_processing_choice.as_field_group }}
|
||||
<div id="existing_billing_address_section" class="mt-3">{{ form.existing_odoo_address_id.as_field_group }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="new_billing_address_section">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">{% translate "Invoice Address" %}</h4>
|
||||
</div>
|
||||
<div class="form-body card-body">
|
||||
<div class="row">
|
||||
{{ form.invoice_street.as_field_group }}
|
||||
{{ form.invoice_street2.as_field_group }}
|
||||
<div class="col-md-2">{{ form.invoice_zip.as_field_group }}</div>
|
||||
<div class="col-md-10">{{ form.invoice_city.as_field_group }}</div>
|
||||
{{ form.invoice_country.as_field_group }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">{% translate "Invoice Contact" %}</h4>
|
||||
</div>
|
||||
<div class="form-body card-body">
|
||||
<div class="row">
|
||||
{{ form.invoice_email.as_field_group }}
|
||||
{{ form.invoice_phone.as_field_group }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="card-body">
|
||||
<div class="col-sm-12 d-flex justify-content-end">
|
||||
<button class="btn btn-primary me-1 mb-1" type="submit">{% translate "Create Organization" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const choiceRadios = document.querySelectorAll('[name="billing_processing_choice"]')
|
||||
const existingSection = document.getElementById('existing_billing_address_section')
|
||||
const newSection = document.getElementById('new_billing_address_section')
|
||||
const existingAddressField = document.querySelector('[name="existing_odoo_address_id"]')
|
||||
|
||||
const toggleSections = () => {
|
||||
let selectedValue = 'new'
|
||||
const checkedRadio = document.querySelector('[name="billing_processing_choice"]:checked')
|
||||
if (checkedRadio) {
|
||||
selectedValue = checkedRadio.value
|
||||
}
|
||||
|
||||
if (selectedValue === 'existing') {
|
||||
if (existingSection) existingSection.style.display = '';
|
||||
if (newSection) newSection.style.display = 'none';
|
||||
} else {
|
||||
if (existingSection) existingSection.style.display = 'none';
|
||||
if (newSection) newSection.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (choiceRadios.length > 0 && existingSection && newSection) {
|
||||
toggleSections()
|
||||
choiceRadios.forEach((radio) => {
|
||||
radio.addEventListener('change', toggleSections)
|
||||
})
|
||||
} else {
|
||||
// No existing addresses found, a new address has to be entered.
|
||||
if (existingSection) existingSection.style.display = 'none'
|
||||
if (newSection) newSection.style.display = '' // Ensure newSection is not null
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock content %}
|
||||
|
|
|
@ -36,26 +36,99 @@
|
|||
</form>
|
||||
</td>
|
||||
{% endpartialdef org-name-edit %}
|
||||
{% block card_content %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-lg">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Name" %}</span>
|
||||
</th>
|
||||
{% partial org-name %}
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Namespace" %}</span>
|
||||
</th>
|
||||
<td>
|
||||
<div>{{ form.instance.namespace }}</div>
|
||||
<small class="text-muted">{% translate "System-generated namespace for Kubernetes resources." %}</small>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock card_content %}
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-lg">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Name" %}</span>
|
||||
</th>
|
||||
{% partial org-name %}
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Namespace" %}</span>
|
||||
</th>
|
||||
<td>
|
||||
<div>{{ form.instance.namespace }}</div>
|
||||
<small class="text-muted">{% translate "System-generated namespace for Kubernetes resources." %}</small>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if form.instance.billing_entity and form.instance.billing_entity.odoo_data.invoice_address %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">{% translate "Billing Address" %}</h4>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-body">
|
||||
{% with odoo_data=form.instance.billing_entity.odoo_data %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-lg">
|
||||
<tbody>
|
||||
{% if odoo_data.invoice_address %}
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Invoice Contact Name" %}</span>
|
||||
</th>
|
||||
<td>{{ odoo_data.invoice_address.name|default:"" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Street" %}</span>
|
||||
</th>
|
||||
<td>{{ odoo_data.invoice_address.street|default:"" }}</td>
|
||||
</tr>
|
||||
{% if odoo_data.invoice_address.street2 %}
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Street 2" %}</span>
|
||||
</th>
|
||||
<td>{{ odoo_data.invoice_address.street2 }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "City" %}</span>
|
||||
</th>
|
||||
<td>{{ odoo_data.invoice_address.city|default:"" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "ZIP Code" %}</span>
|
||||
</th>
|
||||
<td>{{ odoo_data.invoice_address.zip|default:"" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Country" %}</span>
|
||||
</th>
|
||||
<td>{{ odoo_data.invoice_address.country_id.1|default:"" }}</td>
|
||||
</tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Invoice Email" %}</span>
|
||||
</th>
|
||||
<td>{{ odoo_data.invoice_address.email|default:"" }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock content %}
|
||||
|
|
|
@ -1,18 +1,55 @@
|
|||
from django.shortcuts import redirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import CreateView, DetailView
|
||||
from rules.contrib.views import AutoPermissionRequiredMixin
|
||||
|
||||
from servala.core.models import Organization
|
||||
from servala.frontend.forms import OrganizationForm
|
||||
from servala.core.models import BillingEntity, Organization
|
||||
from servala.frontend.forms.organization import OrganizationCreateForm, OrganizationForm
|
||||
from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin
|
||||
|
||||
|
||||
class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView):
|
||||
form_class = OrganizationForm
|
||||
form_class = OrganizationCreateForm
|
||||
model = Organization
|
||||
template_name = "frontend/organizations/create.html"
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["user"] = self.request.user
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
billing_choice = form.cleaned_data.get("billing_processing_choice")
|
||||
billing_entity = None
|
||||
name = form.cleaned_data["name"]
|
||||
|
||||
if not billing_choice or billing_choice == "new":
|
||||
billing_entity = BillingEntity.create_from_data(
|
||||
name,
|
||||
{
|
||||
key: value
|
||||
for key, value in form.cleaned_data.items()
|
||||
if key.startswith("invoice_")
|
||||
},
|
||||
)
|
||||
elif odoo_id := form.cleaned_data.get("existing_odoo_address_id"):
|
||||
billing_entity = BillingEntity.objects.filter(
|
||||
odoo_invoice_id=odoo_id
|
||||
).first()
|
||||
|
||||
if not billing_entity:
|
||||
billing_entity = BillingEntity.create_from_id(name, odoo_id)
|
||||
|
||||
if not billing_entity:
|
||||
form.add_error(
|
||||
None,
|
||||
_(
|
||||
"Could not determine or create the billing entity. Please check your input."
|
||||
),
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
form.instance.billing_entity = billing_entity
|
||||
instance = form.instance.create_organization(
|
||||
form.instance, owner=self.request.user
|
||||
)
|
||||
|
|
|
@ -10,11 +10,12 @@ Servala is run using environment variables. Documentation:
|
|||
"""
|
||||
|
||||
import os
|
||||
import sentry_sdk
|
||||
from pathlib import Path
|
||||
|
||||
import sentry_sdk
|
||||
from django.contrib import messages
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
from django.contrib import messages
|
||||
from servala.__about__ import __version__ as version
|
||||
|
||||
SERVALA_ENVIRONMENT = os.environ.get("SERVALA_ENVIRONMENT", "development")
|
||||
|
@ -90,7 +91,6 @@ SOCIALACCOUNT_PROVIDERS = {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
SERVALA_STORAGE_BUCKET_NAME = os.environ.get("SERVALA_STORAGE_BUCKET_NAME")
|
||||
SERVALA_S3_ENDPOINT_URL = os.environ.get("SERVALA_S3_ENDPOINT_URL")
|
||||
SERVALA_ACCESS_KEY_ID = os.environ.get("SERVALA_ACCESS_KEY_ID")
|
||||
|
@ -126,6 +126,13 @@ if all(
|
|||
},
|
||||
}
|
||||
|
||||
ODOO = {
|
||||
"URL": os.environ.get("SERVALA_ODOO_URL"),
|
||||
"DB": os.environ.get("SERVALA_ODOO_DB"),
|
||||
"USERNAME": os.environ.get("SERVALA_ODOO_USERNAME"),
|
||||
"PASSWORD": os.environ.get("SERVALA_ODOO_PASSWORD"),
|
||||
}
|
||||
|
||||
#######################################
|
||||
# Non-configurable settings below #
|
||||
#######################################
|
||||
|
@ -139,11 +146,11 @@ INSTALLED_APPS = [
|
|||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
# The frontend app is loaded early in order to supersede some allauth views/behaviour
|
||||
"servala.frontend",
|
||||
"django.forms",
|
||||
"template_partials",
|
||||
"rules.apps.AutodiscoverRulesConfig",
|
||||
# The frontend app is loaded early in order to supersede some allauth views/behaviour
|
||||
"servala.frontend",
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
"allauth.socialaccount",
|
||||
|
|
|
@ -54,6 +54,12 @@ html[data-bs-theme="dark"] .btn-outline-primary, .btn-outline-primary {
|
|||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
fieldset .form-check-input + label {
|
||||
font-weight: normal;
|
||||
min-height: 1.5rem;
|
||||
margin: .125rem;
|
||||
}
|
||||
|
||||
.search-form .form-body>.row {
|
||||
display: flex;
|
||||
&>.col-12 {
|
||||
|
|
10
uv.lock
generated
10
uv.lock
generated
|
@ -1,6 +1,6 @@
|
|||
version = 1
|
||||
revision = 2
|
||||
requires-python = ">=3.13.4"
|
||||
requires-python = ">=3.13.3"
|
||||
|
||||
[[package]]
|
||||
name = "argon2-cffi"
|
||||
|
@ -253,16 +253,16 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.2.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/39/17/4567ee12bb84114c544d5c4a792e7226db517ac78f552111e9dc62d1de14/django-5.2.2.tar.gz", hash = "sha256:85852e517f84435e9b13421379cd6c43ef5b48a9c8b391d29a26f7900967e952", size = 10827542, upload-time = "2025-06-04T13:52:40.879Z" }
|
||||
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/b4/5c/5d00acab6c062b154e5a0f092938ae5a0c698dbc4362b68e23200960f32c/django-5.2.2-py3-none-any.whl", hash = "sha256:997ef2162d04ead6869551b22cde4e06da1f94cf595f4af3f3d3afeae1f3f6fe", size = 8302562, upload-time = "2025-06-04T13:52:33.14Z" },
|
||||
{ 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]]
|
||||
|
@ -954,7 +954,7 @@ dev = [
|
|||
requires-dist = [
|
||||
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
||||
{ name = "cryptography", specifier = ">=45.0.3" },
|
||||
{ name = "django", specifier = "==5.2.2" },
|
||||
{ name = "django", specifier = "==5.2.1" },
|
||||
{ name = "django-allauth", specifier = ">=65.9.0" },
|
||||
{ name = "django-fernet-encrypted-fields", specifier = ">=0.3.0" },
|
||||
{ name = "django-scopes", specifier = ">=2.0.0" },
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue