Compare commits

..

1 commit

Author SHA1 Message Date
46d323528e
use organization role to check for edit and delete perms
All checks were successful
Tests / test (push) Successful in 27s
2025-07-07 13:08:58 +02:00
15 changed files with 103 additions and 250 deletions

View file

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

View file

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

View file

@ -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",
] ]

View file

@ -11,8 +11,7 @@
"matchFileNames": [ "matchFileNames": [
".forgejo/workflows/*.yml", ".forgejo/workflows/*.yml",
".forgejo/workflows/*.yaml" ".forgejo/workflows/*.yaml"
], ]
"automerge": true
}, },
{ {
"matchManagers": [ "matchManagers": [

View file

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

View file

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

View file

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

View file

@ -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: # Were building our conditions in order:
# - in exceptions, users may be using a billing account's email # - in exceptions, users may be using a billing accounts 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

View file

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

View file

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

View file

@ -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() {

View file

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

View file

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

View file

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

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