Compare commits
1 commit
main
...
instance-e
Author | SHA1 | Date | |
---|---|---|---|
46d323528e |
15 changed files with 103 additions and 250 deletions
|
@ -19,7 +19,7 @@ jobs:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
|
|
||||||
- name: Renovate
|
- name: Renovate
|
||||||
uses: https://github.com/renovatebot/github-action@v43.0.5
|
uses: https://github.com/renovatebot/github-action@v43.0.2
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.RENOVATE_TOKEN }}
|
token: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
env:
|
env:
|
||||||
|
|
|
@ -17,9 +17,9 @@ Search is done this way:
|
||||||
|
|
||||||
When choosing to add a new billing address, two new records are created in the Odoo `res.partner` model:
|
When choosing to add a new billing address, two new records are created in the Odoo `res.partner` model:
|
||||||
|
|
||||||
* A record with the field `is_company = False`
|
* A record with the field `company_type = company`
|
||||||
* A record with the following field configuration:
|
* A record with the following field configuration:
|
||||||
** `is_company = False`
|
** `company_type = person`
|
||||||
** `type = invoice`
|
** `type = invoice`
|
||||||
** `parent_id = company_id`
|
** `parent_id = company_id`
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ dependencies = [
|
||||||
"argon2-cffi>=25.1.0",
|
"argon2-cffi>=25.1.0",
|
||||||
"cryptography>=45.0.5",
|
"cryptography>=45.0.5",
|
||||||
"django==5.2.4",
|
"django==5.2.4",
|
||||||
"django-allauth>=65.10.0",
|
"django-allauth>=65.9.0",
|
||||||
"django-fernet-encrypted-fields>=0.3.0",
|
"django-fernet-encrypted-fields>=0.3.0",
|
||||||
"django-scopes>=2.0.0",
|
"django-scopes>=2.0.0",
|
||||||
"django-storages[s3]>=1.14.6",
|
"django-storages[s3]>=1.14.6",
|
||||||
|
@ -20,7 +20,7 @@ dependencies = [
|
||||||
"pyjwt>=2.10.1",
|
"pyjwt>=2.10.1",
|
||||||
"requests>=2.32.4",
|
"requests>=2.32.4",
|
||||||
"rules>=3.5",
|
"rules>=3.5",
|
||||||
"sentry-sdk[django]>=2.33.0",
|
"sentry-sdk[django]>=2.32.0",
|
||||||
"urlman>=2.0.2",
|
"urlman>=2.0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -11,8 +11,7 @@
|
||||||
"matchFileNames": [
|
"matchFileNames": [
|
||||||
".forgejo/workflows/*.yml",
|
".forgejo/workflows/*.yml",
|
||||||
".forgejo/workflows/*.yaml"
|
".forgejo/workflows/*.yaml"
|
||||||
],
|
]
|
||||||
"automerge": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"matchManagers": [
|
"matchManagers": [
|
||||||
|
|
|
@ -4,7 +4,6 @@ from django.conf import settings
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_scopes import ScopedManager, scopes_disabled
|
from django_scopes import ScopedManager, scopes_disabled
|
||||||
|
|
||||||
|
@ -75,14 +74,6 @@ class Organization(ServalaModelMixin, models.Model):
|
||||||
user=user, organization=self, role=OrganizationRole.OWNER
|
user=user, organization=self, role=OrganizationRole.OWNER
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_support_message(self, message):
|
|
||||||
support_message = _(
|
|
||||||
"Need help? We're happy to help via the <a href='{support_url}'>support form</a>."
|
|
||||||
).format(support_url=self.urls.support)
|
|
||||||
return mark_safe(
|
|
||||||
f'{message} <i class="bi bi-person-raised-hand"></i> {support_message}'
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def create_organization(cls, instance, owner):
|
def create_organization(cls, instance, owner):
|
||||||
|
@ -156,8 +147,8 @@ class BillingEntity(ServalaModelMixin, models.Model):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Odoo IDs are nullable for creation, should never be null in practice
|
# Odoo IDs are nullable for creation, should never be null in practice
|
||||||
# The company ID points at a record of type res.partner with is_company=True
|
# 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 is_company=False,
|
# The invoice ID points at a record of type res.partner with company_type=person,
|
||||||
# type=invoice, parent_id=company_id (the invoice address).
|
# type=invoice, parent_id=company_id (the invoice address).
|
||||||
odoo_company_id = models.IntegerField(null=True)
|
odoo_company_id = models.IntegerField(null=True)
|
||||||
odoo_invoice_id = models.IntegerField(null=True)
|
odoo_invoice_id = models.IntegerField(null=True)
|
||||||
|
@ -175,8 +166,8 @@ class BillingEntity(ServalaModelMixin, models.Model):
|
||||||
"""
|
"""
|
||||||
Creates a BillingEntity and corresponding Odoo records.
|
Creates a BillingEntity and corresponding Odoo records.
|
||||||
|
|
||||||
This method creates a `res.partner` record in Odoo with `is_company=True`
|
This method creates a `res.partner` record in Odoo with `company_type='company'`
|
||||||
for the main company, and another `res.partner` record with `is_company=False`
|
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
|
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.
|
invoice address. The IDs of these Odoo records are stored in the BillingEntity.
|
||||||
|
|
||||||
|
@ -196,14 +187,14 @@ class BillingEntity(ServalaModelMixin, models.Model):
|
||||||
instance = cls.objects.create(name=name)
|
instance = cls.objects.create(name=name)
|
||||||
company_payload = {
|
company_payload = {
|
||||||
"name": odoo_data.get("company_name", name),
|
"name": odoo_data.get("company_name", name),
|
||||||
"is_company": True,
|
"company_type": "company",
|
||||||
}
|
}
|
||||||
company_id = CLIENT.execute("res.partner", "create", [company_payload])
|
company_id = CLIENT.execute("res.partner", "create", [company_payload])
|
||||||
instance.odoo_company_id = company_id
|
instance.odoo_company_id = company_id
|
||||||
|
|
||||||
invoice_address_payload = {
|
invoice_address_payload = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"is_company": False,
|
"company_type": "person",
|
||||||
"type": "invoice",
|
"type": "invoice",
|
||||||
"parent_id": company_id,
|
"parent_id": company_id,
|
||||||
}
|
}
|
||||||
|
@ -258,10 +249,10 @@ class BillingEntity(ServalaModelMixin, models.Model):
|
||||||
"invoice_address": None,
|
"invoice_address": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
company_fields = ["name", "is_company"]
|
company_fields = ["name", "company_type"]
|
||||||
invoice_address_fields = [
|
invoice_address_fields = [
|
||||||
"name",
|
"name",
|
||||||
"is_company",
|
"company_type",
|
||||||
"type",
|
"type",
|
||||||
"parent_id",
|
"parent_id",
|
||||||
"street",
|
"street",
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import copy
|
import copy
|
||||||
import html
|
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
|
|
||||||
import kubernetes
|
import kubernetes
|
||||||
import rules
|
import rules
|
||||||
|
@ -12,7 +10,6 @@ from django.core.exceptions import ValidationError
|
||||||
from django.db import IntegrityError, models, transaction
|
from django.db import IntegrityError, models, transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from encrypted_fields.fields import EncryptedJSONField
|
from encrypted_fields.fields import EncryptedJSONField
|
||||||
from kubernetes import client, config
|
from kubernetes import client, config
|
||||||
|
@ -574,7 +571,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
unique_together = [("name", "organization", "context")]
|
unique_together = [("name", "organization", "context")]
|
||||||
rules_permissions = {
|
rules_permissions = {
|
||||||
"view": rules.is_staff | perms.is_organization_member,
|
"view": rules.is_staff | perms.is_organization_member,
|
||||||
"change": rules.is_staff | perms.is_organization_admin,
|
"change": rules.is_staff | perms.is_organization_member,
|
||||||
"delete": rules.is_staff | perms.is_organization_admin,
|
"delete": rules.is_staff | perms.is_organization_admin,
|
||||||
"add": rules.is_authenticated,
|
"add": rules.is_authenticated,
|
||||||
}
|
}
|
||||||
|
@ -606,58 +603,6 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
spec_data = prune_empty_data(spec_data)
|
spec_data = prune_empty_data(spec_data)
|
||||||
return spec_data
|
return spec_data
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _format_kubernetes_error(cls, error_message):
|
|
||||||
if not error_message:
|
|
||||||
return {"message": "", "errors": None, "has_list": False}
|
|
||||||
|
|
||||||
error_message = str(error_message).strip()
|
|
||||||
|
|
||||||
# Pattern to match validation errors in brackets like [error1, error2, error3]
|
|
||||||
pattern = r"\[([^\]]+)\]"
|
|
||||||
match = re.search(pattern, error_message)
|
|
||||||
|
|
||||||
if not match:
|
|
||||||
return {"message": error_message, "errors": None, "has_list": False}
|
|
||||||
|
|
||||||
errors_text = match.group(1).strip()
|
|
||||||
|
|
||||||
if "," not in errors_text:
|
|
||||||
return {"message": error_message, "errors": None, "has_list": False}
|
|
||||||
|
|
||||||
errors = [error.strip().strip("\"'") for error in errors_text.split(",")]
|
|
||||||
errors = [error for error in errors if error]
|
|
||||||
|
|
||||||
if len(errors) <= 1:
|
|
||||||
return {"message": error_message, "errors": None, "has_list": False}
|
|
||||||
|
|
||||||
base_message = re.sub(pattern, "", error_message).strip()
|
|
||||||
base_message = base_message.rstrip(":").strip()
|
|
||||||
|
|
||||||
return {"message": base_message, "errors": errors, "has_list": True}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _safe_format_error(cls, error_data):
|
|
||||||
if not isinstance(error_data, dict):
|
|
||||||
return html.escape(str(error_data))
|
|
||||||
|
|
||||||
if not error_data.get("has_list", False):
|
|
||||||
return html.escape(error_data.get("message", ""))
|
|
||||||
|
|
||||||
message = html.escape(error_data.get("message", ""))
|
|
||||||
errors = error_data.get("errors", [])
|
|
||||||
|
|
||||||
if not errors:
|
|
||||||
return message
|
|
||||||
|
|
||||||
escaped_errors = [html.escape(str(error)) for error in errors]
|
|
||||||
error_items = "".join(f"<li>{error}</li>" for error in escaped_errors)
|
|
||||||
|
|
||||||
if message:
|
|
||||||
return mark_safe(f"{message}<ul>{error_items}</ul>")
|
|
||||||
else:
|
|
||||||
return mark_safe(f"<ul>{error_items}</ul>")
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_instance(cls, name, organization, context, created_by, spec_data):
|
def create_instance(cls, name, organization, context, created_by, spec_data):
|
||||||
# Ensure the namespace exists
|
# Ensure the namespace exists
|
||||||
|
@ -670,10 +615,11 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
context=context,
|
context=context,
|
||||||
)
|
)
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
message = _(
|
raise ValidationError(
|
||||||
"An instance with this name already exists in this organization. Please choose a different name."
|
_(
|
||||||
|
"An instance with this name already exists in this organization. Please choose a different name."
|
||||||
|
)
|
||||||
)
|
)
|
||||||
raise ValidationError(organization.add_support_message(message))
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
spec_data = cls._prepare_spec_data(spec_data)
|
spec_data = cls._prepare_spec_data(spec_data)
|
||||||
|
@ -711,25 +657,10 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
try:
|
try:
|
||||||
error_body = json.loads(e.body)
|
error_body = json.loads(e.body)
|
||||||
reason = error_body.get("message", str(e))
|
reason = error_body.get("message", str(e))
|
||||||
error_data = cls._format_kubernetes_error(reason)
|
raise ValidationError(_("Kubernetes API error: {}").format(reason))
|
||||||
formatted_reason = cls._safe_format_error(error_data)
|
|
||||||
message = _("Error reported by control plane: {reason}").format(
|
|
||||||
reason=formatted_reason
|
|
||||||
)
|
|
||||||
raise ValidationError(organization.add_support_message(message))
|
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
error_data = cls._format_kubernetes_error(str(e))
|
raise ValidationError(_("Kubernetes API error: {}").format(str(e)))
|
||||||
formatted_error = cls._safe_format_error(error_data)
|
raise ValidationError(_("Error creating instance: {}").format(str(e)))
|
||||||
message = _("Error reported by control plane: {error}").format(
|
|
||||||
error=formatted_error
|
|
||||||
)
|
|
||||||
raise ValidationError(organization.add_support_message(message))
|
|
||||||
error_data = cls._format_kubernetes_error(str(e))
|
|
||||||
formatted_error = cls._safe_format_error(error_data)
|
|
||||||
message = _("Error creating instance: {error}").format(
|
|
||||||
error=formatted_error
|
|
||||||
)
|
|
||||||
raise ValidationError(organization.add_support_message(message))
|
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
def update_spec(self, spec_data, updated_by):
|
def update_spec(self, spec_data, updated_by):
|
||||||
|
@ -750,33 +681,29 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
self.save() # Updates updated_at timestamp
|
self.save() # Updates updated_at timestamp
|
||||||
except ApiException as e:
|
except ApiException as e:
|
||||||
if e.status == 404:
|
if e.status == 404:
|
||||||
message = _(
|
raise ValidationError(
|
||||||
"Service instance not found in control plane. It may have been deleted externally."
|
_(
|
||||||
|
"Service instance not found in Kubernetes. It may have been deleted externally."
|
||||||
|
)
|
||||||
)
|
)
|
||||||
raise ValidationError(self.organization.add_support_message(message))
|
|
||||||
try:
|
try:
|
||||||
error_body = json.loads(e.body)
|
error_body = json.loads(e.body)
|
||||||
reason = error_body.get("message", str(e))
|
reason = error_body.get("message", str(e))
|
||||||
error_data = self._format_kubernetes_error(reason)
|
raise ValidationError(
|
||||||
formatted_reason = self._safe_format_error(error_data)
|
_("Kubernetes API error updating instance: {error}").format(
|
||||||
message = _(
|
error=reason
|
||||||
"Error reported by control plane while updating instance: {reason}"
|
)
|
||||||
).format(reason=formatted_reason)
|
)
|
||||||
raise ValidationError(self.organization.add_support_message(message))
|
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
error_data = self._format_kubernetes_error(str(e))
|
raise ValidationError(
|
||||||
formatted_error = self._safe_format_error(error_data)
|
_("Kubernetes API error updating instance: {error}").format(
|
||||||
message = _(
|
error=str(e)
|
||||||
"Error reported by control plane while updating instance: {error}"
|
)
|
||||||
).format(error=formatted_error)
|
)
|
||||||
raise ValidationError(self.organization.add_support_message(message))
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_data = self._format_kubernetes_error(str(e))
|
raise ValidationError(
|
||||||
formatted_error = self._safe_format_error(error_data)
|
_("Error updating instance: {error}").format(error=str(e))
|
||||||
message = _("Error updating instance: {error}").format(
|
|
||||||
error=formatted_error
|
|
||||||
)
|
)
|
||||||
raise ValidationError(self.organization.add_support_message(message))
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def delete_instance(self, user):
|
def delete_instance(self, user):
|
||||||
|
|
|
@ -84,7 +84,7 @@ class User(ServalaModelMixin, PermissionsMixin, AbstractBaseUser):
|
||||||
result = odoo.CLIENT.search_read(
|
result = odoo.CLIENT.search_read(
|
||||||
model="res.partner",
|
model="res.partner",
|
||||||
domain=[
|
domain=[
|
||||||
("is_company", "=", False),
|
("company_type", "=", "person"),
|
||||||
("type", "=", "contact"),
|
("type", "=", "contact"),
|
||||||
("email", "ilike", self.email),
|
("email", "ilike", self.email),
|
||||||
("parent_id", "=", organization.billing_entity.odoo_company_id),
|
("parent_id", "=", organization.billing_entity.odoo_company_id),
|
||||||
|
@ -107,7 +107,7 @@ class User(ServalaModelMixin, PermissionsMixin, AbstractBaseUser):
|
||||||
partner_data = {
|
partner_data = {
|
||||||
"name": f"{self.first_name} {self.last_name}".strip() or self.email,
|
"name": f"{self.first_name} {self.last_name}".strip() or self.email,
|
||||||
"email": self.email,
|
"email": self.email,
|
||||||
"is_company": False,
|
"company_type": "person",
|
||||||
"type": "contact",
|
"type": "contact",
|
||||||
"parent_id": organization.billing_entity.odoo_company_id,
|
"parent_id": organization.billing_entity.odoo_company_id,
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ ADDRESS_FIELDS = [
|
||||||
"email",
|
"email",
|
||||||
"phone",
|
"phone",
|
||||||
"vat",
|
"vat",
|
||||||
"is_company",
|
"company_type",
|
||||||
"type",
|
"type",
|
||||||
"parent_id",
|
"parent_id",
|
||||||
]
|
]
|
||||||
|
@ -118,8 +118,8 @@ def get_odoo_countries():
|
||||||
|
|
||||||
|
|
||||||
def get_odoo_access_conditions(user):
|
def get_odoo_access_conditions(user):
|
||||||
# We're building our conditions in order:
|
# We’re building our conditions in order:
|
||||||
# - in exceptions, users may be using a billing account's email
|
# - in exceptions, users may be using a billing account’s email
|
||||||
# - if the user is an admin or owner of a Servala organization
|
# - if the user is an admin or owner of a Servala organization
|
||||||
# - if the user is associated with an odoo user, return all billing
|
# - if the user is associated with an odoo user, return all billing
|
||||||
# addresses / organizations created by the user
|
# addresses / organizations created by the user
|
||||||
|
@ -153,7 +153,7 @@ def get_odoo_access_conditions(user):
|
||||||
odoo_contacts = CLIENT.search_read(
|
odoo_contacts = CLIENT.search_read(
|
||||||
model="res.partner",
|
model="res.partner",
|
||||||
domain=[
|
domain=[
|
||||||
("is_company", "=", False),
|
("company_type", "=", "person"),
|
||||||
("type", "=", "contact"),
|
("type", "=", "contact"),
|
||||||
("email", "ilike", email),
|
("email", "ilike", email),
|
||||||
],
|
],
|
||||||
|
@ -186,7 +186,7 @@ def get_invoice_addresses(user):
|
||||||
|
|
||||||
or_conditions = get_odoo_access_conditions(user)
|
or_conditions = get_odoo_access_conditions(user)
|
||||||
domain = [
|
domain = [
|
||||||
("is_company", "=", False),
|
("company_type", "=", "person"),
|
||||||
("type", "=", "invoice"),
|
("type", "=", "invoice"),
|
||||||
] + or_conditions
|
] + or_conditions
|
||||||
|
|
||||||
|
|
|
@ -13,30 +13,15 @@ def has_organization_role(user, org, roles):
|
||||||
|
|
||||||
|
|
||||||
@rules.predicate
|
@rules.predicate
|
||||||
def is_organization_owner(user, obj):
|
def is_organization_owner(user, org):
|
||||||
if hasattr(obj, "organization"):
|
|
||||||
org = obj.organization
|
|
||||||
else:
|
|
||||||
org = obj
|
|
||||||
return has_organization_role(user, org, ["owner"])
|
return has_organization_role(user, org, ["owner"])
|
||||||
|
|
||||||
|
|
||||||
@rules.predicate
|
@rules.predicate
|
||||||
def is_organization_admin(user, obj):
|
def is_organization_admin(user, org):
|
||||||
if hasattr(obj, "organization"):
|
|
||||||
org = obj.organization
|
|
||||||
else:
|
|
||||||
org = obj
|
|
||||||
return has_organization_role(user, org, ["owner", "admin"])
|
return has_organization_role(user, org, ["owner", "admin"])
|
||||||
|
|
||||||
|
|
||||||
@rules.predicate
|
@rules.predicate
|
||||||
def is_organization_member(user, obj):
|
def is_organization_member(user, org):
|
||||||
if hasattr(obj, "organization"):
|
|
||||||
org = obj.organization
|
|
||||||
else:
|
|
||||||
org = obj
|
|
||||||
return has_organization_role(user, org, None)
|
return has_organization_role(user, org, None)
|
||||||
|
|
||||||
|
|
||||||
rules.add_perm("core", rules.is_staff)
|
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
{% if show_error %}
|
|
||||||
<div class="{{ css_class }}">
|
|
||||||
{% if has_list %}
|
|
||||||
{% if message %}{{ message }}{% endif %}
|
|
||||||
<ul>
|
|
||||||
{% for error in errors %}
|
|
||||||
<li>{{ error }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
{{ message }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
|
@ -9,7 +9,7 @@
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const alert = document.getElementById('auto-dismiss-alert-{{ forloop.counter0|default:'0' }}');
|
const alert = document.getElementById('auto-dismiss-alert-{{ forloop.counter0|default:'0' }}');
|
||||||
if (alert && !alert.classList.contains('alert-danger')) {
|
if (alert) {
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
let opacity = 1;
|
let opacity = 1;
|
||||||
const fadeOutInterval = setInterval(function() {
|
const fadeOutInterval = setInterval(function() {
|
||||||
|
|
|
@ -1,43 +0,0 @@
|
||||||
"""
|
|
||||||
Template filters for safe error formatting.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import html
|
|
||||||
from django import template
|
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
|
||||||
def format_k8s_error(error_data):
|
|
||||||
"""
|
|
||||||
Template filter to safely format Kubernetes error data.
|
|
||||||
Usage: {{ error_data|format_k8s_error }}
|
|
||||||
|
|
||||||
Args:
|
|
||||||
error_data: Dictionary with structure from _format_kubernetes_error method
|
|
||||||
or a simple string
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Safely formatted HTML string
|
|
||||||
"""
|
|
||||||
if not error_data:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
if not error_data.get("has_list", False):
|
|
||||||
return html.escape(error_data.get("message", ""))
|
|
||||||
|
|
||||||
message = html.escape(error_data.get("message", ""))
|
|
||||||
errors = error_data.get("errors", [])
|
|
||||||
|
|
||||||
if not errors:
|
|
||||||
return message
|
|
||||||
|
|
||||||
escaped_errors = [html.escape(str(error)) for error in errors]
|
|
||||||
error_items = "".join(f"<li>{error}</li>" for error in escaped_errors)
|
|
||||||
|
|
||||||
if message:
|
|
||||||
return mark_safe(f"{message}<ul>{error_items}</ul>")
|
|
||||||
else:
|
|
||||||
return mark_safe(f"<ul>{error_items}</ul>")
|
|
|
@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import CreateView, DetailView
|
from django.views.generic import CreateView, DetailView
|
||||||
from rules.contrib.views import AutoPermissionRequiredMixin
|
from rules.contrib.views import AutoPermissionRequiredMixin
|
||||||
|
|
||||||
|
from servala.core.rules import is_organization_admin
|
||||||
from servala.core.models import (
|
from servala.core.models import (
|
||||||
BillingEntity,
|
BillingEntity,
|
||||||
Organization,
|
Organization,
|
||||||
|
@ -75,10 +76,10 @@ class OrganizationDashboardView(
|
||||||
)
|
)
|
||||||
recent_instances = service_instances.order_by("-created_at")[:5]
|
recent_instances = service_instances.order_by("-created_at")[:5]
|
||||||
|
|
||||||
|
has_admin_permission = is_organization_admin(self.request.user, organization)
|
||||||
|
|
||||||
for instance in recent_instances:
|
for instance in recent_instances:
|
||||||
instance.has_change_permission = self.request.user.has_perm(
|
instance.has_change_permission = has_admin_permission
|
||||||
"core.change_serviceinstance", instance
|
|
||||||
)
|
|
||||||
|
|
||||||
user_membership = OrganizationMembership.objects.filter(
|
user_membership = OrganizationMembership.objects.filter(
|
||||||
user=self.request.user, organization=organization
|
user=self.request.user, organization=organization
|
||||||
|
|
|
@ -6,6 +6,7 @@ from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import DetailView, ListView, UpdateView
|
from django.views.generic import DetailView, ListView, UpdateView
|
||||||
|
|
||||||
|
from servala.core.rules import is_organization_admin
|
||||||
from servala.core.crd import deslugify
|
from servala.core.crd import deslugify
|
||||||
from servala.core.models import (
|
from servala.core.models import (
|
||||||
ControlPlaneCRD,
|
ControlPlaneCRD,
|
||||||
|
@ -144,12 +145,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
|
|
||||||
form = self.get_instance_form()
|
form = self.get_instance_form()
|
||||||
if not form: # Should not happen if context_object is valid, but as a safeguard
|
if not form: # Should not happen if context_object is valid, but as a safeguard
|
||||||
messages.error(
|
messages.error(self.request, _("Could not initialize service form."))
|
||||||
self.request,
|
|
||||||
self.organization.add_support_message(
|
|
||||||
_("Could not initialize service form.")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
@ -166,10 +162,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
messages.error(self.request, e.message or str(e))
|
messages.error(self.request, e.message or str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(
|
messages.error(
|
||||||
self.request,
|
self.request, _("Error creating instance: {}").format(str(e))
|
||||||
self.organization.add_support_message(
|
|
||||||
_(f"Error creating instance: {str(e)}.")
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# If the form is not valid or if the service creation failed, we render it again
|
# If the form is not valid or if the service creation failed, we render it again
|
||||||
|
@ -227,12 +220,13 @@ class ServiceInstanceDetailView(
|
||||||
and self.object.spec
|
and self.object.spec
|
||||||
):
|
):
|
||||||
context["spec_fieldsets"] = self.get_nested_spec()
|
context["spec_fieldsets"] = self.get_nested_spec()
|
||||||
context["has_change_permission"] = self.request.user.has_perm(
|
|
||||||
ServiceInstance.get_perm("change"), self.object
|
has_admin_permission = is_organization_admin(
|
||||||
)
|
self.request.user, self.object.organization
|
||||||
context["has_delete_permission"] = self.request.user.has_perm(
|
|
||||||
ServiceInstance.get_perm("delete"), self.object
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
context["has_change_permission"] = has_admin_permission
|
||||||
|
context["has_delete_permission"] = has_admin_permission
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get_nested_spec(self):
|
def get_nested_spec(self):
|
||||||
|
@ -346,6 +340,15 @@ class ServiceInstanceUpdateView(
|
||||||
template_name = "frontend/organizations/service_instance_update.html"
|
template_name = "frontend/organizations/service_instance_update.html"
|
||||||
permission_type = "change"
|
permission_type = "change"
|
||||||
|
|
||||||
|
def has_permission(self):
|
||||||
|
"""Override to use organization role-based permissions."""
|
||||||
|
# First check if user has organization access
|
||||||
|
if not self.has_organization_permission():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Then check if user has admin or owner role
|
||||||
|
return is_organization_admin(self.request.user, self.object.organization)
|
||||||
|
|
||||||
def get_form_class(self):
|
def get_form_class(self):
|
||||||
return self.object.context.model_form_class
|
return self.object.context.model_form_class
|
||||||
|
|
||||||
|
@ -374,10 +377,7 @@ class ServiceInstanceUpdateView(
|
||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(
|
messages.error(
|
||||||
self.request,
|
self.request, _("Error updating instance: {error}").format(error=str(e))
|
||||||
self.organization.add_support_message(
|
|
||||||
_(f"Error updating instance: {str(e)}.")
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
@ -431,6 +431,15 @@ class ServiceInstanceDeleteView(
|
||||||
form_class = ServiceInstanceDeleteForm
|
form_class = ServiceInstanceDeleteForm
|
||||||
permission_type = "delete"
|
permission_type = "delete"
|
||||||
|
|
||||||
|
def has_permission(self):
|
||||||
|
"""Override to use organization role-based permissions."""
|
||||||
|
# First check if user has organization access
|
||||||
|
if not self.has_organization_permission():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Then check if user has admin or owner role
|
||||||
|
return is_organization_admin(self.request.user, self.object.organization)
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
try:
|
try:
|
||||||
self.object.delete_instance(user=self.request.user)
|
self.object.delete_instance(user=self.request.user)
|
||||||
|
@ -446,11 +455,9 @@ class ServiceInstanceDeleteView(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(
|
messages.error(
|
||||||
self.request,
|
self.request,
|
||||||
self.organization.add_support_message(
|
_(
|
||||||
_(
|
"An error occurred while trying to delete instance '{name}': {error}"
|
||||||
f"An error occurred while trying to delete instance '{self.object.name}': {str(e)}."
|
).format(name=self.object.name, error=str(e)),
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
response = HttpResponse()
|
response = HttpResponse()
|
||||||
response["HX-Redirect"] = str(self.object.urls.base)
|
response["HX-Redirect"] = str(self.object.urls.base)
|
||||||
|
|
38
uv.lock
generated
38
uv.lock
generated
|
@ -37,11 +37,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asgiref"
|
name = "asgiref"
|
||||||
version = "3.9.1"
|
version = "3.9.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/90/61/0aa957eec22ff70b830b22ff91f825e70e1ef732c06666a805730f28b36b/asgiref-3.9.1.tar.gz", hash = "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142", size = 36870, upload-time = "2025-07-08T09:07:43.344Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/6a/68/fb4fb78c9eac59d5e819108a57664737f855c5a8e9b76aec1738bb137f9e/asgiref-3.9.0.tar.gz", hash = "sha256:3dd2556d0f08c4fab8a010d9ab05ef8c34565f6bf32381d17505f7ca5b273767", size = 36772, upload-time = "2025-07-03T13:25:01.491Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/3c/0464dcada90d5da0e71018c04a140ad6349558afb30b3051b4264cc5b965/asgiref-3.9.1-py3-none-any.whl", hash = "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c", size = 23790, upload-time = "2025-07-08T09:07:41.548Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/f9/76c9f4d4985b5a642926162e2d41fe6019b1fa929cfa58abb7d2dc9041e5/asgiref-3.9.0-py3-none-any.whl", hash = "sha256:06a41250a0114d2b6f6a2cb3ab962147d355b53d1de15eebc34a9d04a7b79981", size = 23788, upload-time = "2025-07-03T13:24:59.115Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -75,30 +75,30 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "boto3"
|
name = "boto3"
|
||||||
version = "1.39.4"
|
version = "1.39.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "botocore" },
|
{ name = "botocore" },
|
||||||
{ name = "jmespath" },
|
{ name = "jmespath" },
|
||||||
{ name = "s3transfer" },
|
{ name = "s3transfer" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/6a/1f/b7510dcd26eb14735d6f4b2904e219b825660425a0cf0b6f35b84c7249b0/boto3-1.39.4.tar.gz", hash = "sha256:6c955729a1d70181bc8368e02a7d3f350884290def63815ebca8408ee6d47571", size = 111829, upload-time = "2025-07-09T19:23:01.512Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/02/42/712a74bb86d06538c55067a35b8a82c57aa303eba95b2b1ee91c829288f4/boto3-1.39.3.tar.gz", hash = "sha256:0a367106497649ae3d8a7b571b8c3be01b7b935a0fe303d4cc2574ed03aecbb4", size = 111838, upload-time = "2025-07-03T19:26:00.988Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/5c/93292e4d8c809950c13950b3168e0eabdac828629c21047959251ad3f28c/boto3-1.39.4-py3-none-any.whl", hash = "sha256:f8e9534b429121aa5c5b7c685c6a94dd33edf14f87926e9a182d5b50220ba284", size = 139908, upload-time = "2025-07-09T19:22:59.808Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/70/723d2ab259aeaed6c96e5c1857ebe7d474ed9aa8f487dea352c60f33798f/boto3-1.39.3-py3-none-any.whl", hash = "sha256:056cfa2440fe1a157a7c2be897c749c83e1a322144aa4dad889f2fca66571019", size = 139906, upload-time = "2025-07-03T19:25:58.803Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "botocore"
|
name = "botocore"
|
||||||
version = "1.39.4"
|
version = "1.39.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "jmespath" },
|
{ name = "jmespath" },
|
||||||
{ name = "python-dateutil" },
|
{ name = "python-dateutil" },
|
||||||
{ name = "urllib3" },
|
{ name = "urllib3" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/9f/21c823ea2fae3fa5a6c9e8caaa1f858acd55018e6d317505a4f14c5bb999/botocore-1.39.4.tar.gz", hash = "sha256:e662ac35c681f7942a93f2ec7b4cde8f8b56dd399da47a79fa3e370338521a56", size = 14136116, upload-time = "2025-07-09T19:22:49.811Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/60/66/96e89cc261d75f0b8125436272c335c74d2a39df84504a0c3956adcd1301/botocore-1.39.3.tar.gz", hash = "sha256:da8f477e119f9f8a3aaa8b3c99d9c6856ed0a243680aa3a3fbbfc15a8d4093fb", size = 14132316, upload-time = "2025-07-03T19:25:49.502Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/44/f120319e0a9afface645e99f300175b9b308e4724cb400b32e1bd6eb3060/botocore-1.39.4-py3-none-any.whl", hash = "sha256:c41e167ce01cfd1973c3fa9856ef5244a51ddf9c82cb131120d8617913b6812a", size = 13795516, upload-time = "2025-07-09T19:22:44.446Z" },
|
{ url = "https://files.pythonhosted.org/packages/53/e4/3698dbb037a44d82a501577c6e3824c19f4289f4afbcadb06793866250d8/botocore-1.39.3-py3-none-any.whl", hash = "sha256:66a81cfac18ad5e9f47696c73fdf44cdbd8f8ca51ab3fca1effca0aabf61f02f", size = 13791724, upload-time = "2025-07-03T19:25:44.026Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -127,11 +127,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2025.7.9"
|
version = "2025.6.15"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/de/8a/c729b6b60c66a38f590c4e774decc4b2ec7b0576be8f1aa984a53ffa812a/certifi-2025.7.9.tar.gz", hash = "sha256:c1d2ec05395148ee10cf672ffc28cd37ea0ab0d99f9cc74c43e588cbd111b079", size = 160386, upload-time = "2025-07-09T02:13:58.874Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/f3/80a3f974c8b535d394ff960a11ac20368e06b736da395b551a49ce950cce/certifi-2025.7.9-py3-none-any.whl", hash = "sha256:d842783a14f8fdd646895ac26f719a061408834473cfc10203f6a575beb15d39", size = 159230, upload-time = "2025-07-09T02:13:57.007Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -295,13 +295,13 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-allauth"
|
name = "django-allauth"
|
||||||
version = "65.10.0"
|
version = "65.9.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asgiref" },
|
{ name = "asgiref" },
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/9e/271e3b8ea27c089ddf3431140cf4aa86df86556ec102e360da5af62c3a99/django_allauth-65.10.0.tar.gz", hash = "sha256:47daa3b0e11a1d75724ea32995de37bd2b8963e9e4cce2b3a7fd64eb6d3b3c48", size = 1897777, upload-time = "2025-07-10T11:32:44.098Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/9d/a3/00aa9d5bb5df4f7464495675074dc11107c08b3eea3462fb3edc059d71e1/django_allauth-65.9.0.tar.gz", hash = "sha256:a06bca9974df44321e94c33bcf770bb6f924d1a44b57defbce4d7ec54a55483e", size = 1710514, upload-time = "2025-06-01T19:21:07.771Z" }
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-fernet-encrypted-fields"
|
name = "django-fernet-encrypted-fields"
|
||||||
|
@ -1002,15 +1002,15 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sentry-sdk"
|
name = "sentry-sdk"
|
||||||
version = "2.33.0"
|
version = "2.32.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
{ name = "urllib3" },
|
{ name = "urllib3" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/09/0b/6139f589436c278b33359845ed77019cd093c41371f898283bbc14d26c02/sentry_sdk-2.33.0.tar.gz", hash = "sha256:cdceed05e186846fdf80ceea261fe0a11ebc93aab2f228ed73d076a07804152e", size = 335233, upload-time = "2025-07-15T12:07:42.413Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/10/59/eb90c45cb836cf8bec973bba10230ddad1c55e2b2e9ffa9d7d7368948358/sentry_sdk-2.32.0.tar.gz", hash = "sha256:9016c75d9316b0f6921ac14c8cd4fb938f26002430ac5be9945ab280f78bec6b", size = 334932, upload-time = "2025-06-27T08:10:02.89Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/e5/f24e9f81c9822a24a2627cfcb44c10a3971382e67e5015c6e068421f5787/sentry_sdk-2.33.0-py2.py3-none-any.whl", hash = "sha256:a762d3f19a1c240e16c98796f2a5023f6e58872997d5ae2147ac3ed378b23ec2", size = 356397, upload-time = "2025-07-15T12:07:40.729Z" },
|
{ url = "https://files.pythonhosted.org/packages/01/a1/fc4856bd02d2097324fb7ce05b3021fb850f864b83ca765f6e37e92ff8ca/sentry_sdk-2.32.0-py2.py3-none-any.whl", hash = "sha256:6cf51521b099562d7ce3606da928c473643abe99b00ce4cb5626ea735f4ec345", size = 356122, upload-time = "2025-06-27T08:10:01.424Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
|
@ -1062,7 +1062,7 @@ requires-dist = [
|
||||||
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
||||||
{ name = "cryptography", specifier = ">=45.0.5" },
|
{ name = "cryptography", specifier = ">=45.0.5" },
|
||||||
{ name = "django", specifier = "==5.2.4" },
|
{ name = "django", specifier = "==5.2.4" },
|
||||||
{ name = "django-allauth", specifier = ">=65.10.0" },
|
{ name = "django-allauth", specifier = ">=65.9.0" },
|
||||||
{ name = "django-fernet-encrypted-fields", specifier = ">=0.3.0" },
|
{ name = "django-fernet-encrypted-fields", specifier = ">=0.3.0" },
|
||||||
{ name = "django-scopes", specifier = ">=2.0.0" },
|
{ name = "django-scopes", specifier = ">=2.0.0" },
|
||||||
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
|
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
|
||||||
|
@ -1074,7 +1074,7 @@ requires-dist = [
|
||||||
{ name = "pyjwt", specifier = ">=2.10.1" },
|
{ name = "pyjwt", specifier = ">=2.10.1" },
|
||||||
{ name = "requests", specifier = ">=2.32.4" },
|
{ name = "requests", specifier = ">=2.32.4" },
|
||||||
{ name = "rules", specifier = ">=3.5" },
|
{ name = "rules", specifier = ">=3.5" },
|
||||||
{ name = "sentry-sdk", extras = ["django"], specifier = ">=2.33.0" },
|
{ name = "sentry-sdk", extras = ["django"], specifier = ">=2.32.0" },
|
||||||
{ name = "urlman", specifier = ">=2.0.2" },
|
{ name = "urlman", specifier = ">=2.0.2" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue