Compare commits

...

25 commits

Author SHA1 Message Date
5b876de18a Code style
All checks were successful
Tests / test (push) Successful in 28s
2025-08-01 13:10:59 +02:00
18dc93fd96 Show external links on service detail page 2025-08-01 13:09:48 +02:00
98f759a936 Fix code style 2025-08-01 13:09:48 +02:00
ab4552e283 Show featured links 2025-08-01 13:09:48 +02:00
f4e8d7dcb7 Make sure service cards have consistent height 2025-08-01 13:09:48 +02:00
Renovate Bot
83a18837ec Update https://github.com/renovatebot/github-action action to v43.0.5 2025-07-28 03:01:03 +00:00
b403370b9d Merge pull request 'Update dependency sentry-sdk to >=2.33.0' (#155) from renovate/sentry-sdk-2.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m24s
Tests / test (push) Successful in 25s
Build and Deploy Staging / deploy (push) Successful in 9s
Reviewed-on: #155
2025-07-17 12:39:33 +00:00
Renovate Bot
934b47aadf Update dependency sentry-sdk to >=2.33.0
All checks were successful
Tests / test (push) Successful in 27s
2025-07-16 03:01:48 +00:00
56c258ee34 Merge pull request 'Update https://github.com/renovatebot/github-action action to v43.0.3' (#153) from renovate/https-github.com-renovatebot-github-action-43.x into main
Reviewed-on: #153
2025-07-14 08:27:02 +00:00
Renovate Bot
05fadd851b Update https://github.com/renovatebot/github-action action to v43.0.3
All checks were successful
Tests / test (push) Successful in 24s
2025-07-14 07:55:06 +00:00
3956a34ba0
automerge updates to workflows 2025-07-14 09:53:10 +02:00
8992ec51ff Merge pull request 'Lock file maintenance' (#154) from renovate/lock-file-maintenance into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m6s
Tests / test (push) Successful in 26s
Build and Deploy Staging / deploy (push) Successful in 8s
Reviewed-on: #154
2025-07-14 06:58:53 +00:00
Renovate Bot
6691b775f2 Lock file maintenance
All checks were successful
Tests / test (push) Successful in 25s
2025-07-14 03:01:18 +00:00
683977a001 Merge pull request 'Try to fix permissions issue' (#151) from 148-instance-edit-permissions into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m6s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 22s
Reviewed-on: #151
2025-07-11 14:55:47 +00:00
da2a1f6c64 Merge pull request 'Include link to support in error message' (#149) from error-with-help into main
Some checks failed
Build and Deploy Staging / build (push) Successful in 1m4s
Tests / test (push) Successful in 39s
Build and Deploy Staging / deploy (push) Has been cancelled
Reviewed-on: #149
2025-07-11 14:54:01 +00:00
0bd895c486 Make rules compatible with instance checks
All checks were successful
Tests / test (push) Successful in 26s
2025-07-11 16:37:45 +02:00
b5d691e407
remove unused def
All checks were successful
Tests / test (push) Successful in 24s
2025-07-11 14:45:27 +02:00
8a45a22759
sanitize kubernetes messages
All checks were successful
Tests / test (push) Successful in 28s
2025-07-11 14:43:26 +02:00
5feabda513 Make sure admin is visible to staff users
All checks were successful
Tests / test (push) Successful in 25s
2025-07-11 12:25:20 +02:00
3f8901aa93 Try to fix permissions issue 2025-07-11 12:24:26 +02:00
9c3ce54bb3
format and retext control plane errors
All checks were successful
Tests / test (push) Successful in 28s
2025-07-10 16:32:41 +02:00
9317547630
restore translated strings 2025-07-10 16:18:40 +02:00
3c270d9c12
refactor into a shared function
All checks were successful
Tests / test (push) Successful in 27s
2025-07-10 16:11:30 +02:00
5fa3d32c57
do not dismiss message when error message
All checks were successful
Tests / test (push) Successful in 25s
2025-07-07 13:38:09 +02:00
e78a63c67f
add link to support form on error message 2025-07-07 13:35:54 +02:00
18 changed files with 327 additions and 96 deletions

View file

@ -19,7 +19,7 @@ jobs:
node-version: "22"
- name: Renovate
uses: https://github.com/renovatebot/github-action@v43.0.2
uses: https://github.com/renovatebot/github-action@v43.0.5
with:
token: ${{ secrets.RENOVATE_TOKEN }}
env:

View file

@ -20,7 +20,7 @@ dependencies = [
"pyjwt>=2.10.1",
"requests>=2.32.4",
"rules>=3.5",
"sentry-sdk[django]>=2.32.0",
"sentry-sdk[django]>=2.33.0",
"urlman>=2.0.2",
]

View file

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

View file

@ -3,6 +3,7 @@ import urlman
from django.conf import settings
from django.db import models, transaction
from django.utils.functional import cached_property
from django.utils.safestring import mark_safe
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
@ -74,6 +75,14 @@ class Organization(ServalaModelMixin, models.Model):
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
@transaction.atomic
def create_organization(cls, instance, owner):

View file

@ -1,5 +1,7 @@
import copy
import html
import json
import re
import kubernetes
import rules
@ -10,6 +12,7 @@ from django.core.exceptions import ValidationError
from django.db import IntegrityError, models, transaction
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from encrypted_fields.fields import EncryptedJSONField
from kubernetes import client, config
@ -80,6 +83,13 @@ class Service(ServalaModelMixin, models.Model):
def __str__(self):
return self.name
@property
def featured_links(self):
"""Return external links marked as featured."""
if not self.external_links:
return []
return [link for link in self.external_links if link.get("featured")]
def validate_dict(data, required_fields=None, allow_empty=True):
if not data:
@ -571,7 +581,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
unique_together = [("name", "organization", "context")]
rules_permissions = {
"view": rules.is_staff | perms.is_organization_member,
"change": rules.is_staff | perms.is_organization_member,
"change": rules.is_staff | perms.is_organization_admin,
"delete": rules.is_staff | perms.is_organization_admin,
"add": rules.is_authenticated,
}
@ -603,6 +613,58 @@ class ServiceInstance(ServalaModelMixin, models.Model):
spec_data = prune_empty_data(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
def create_instance(cls, name, organization, context, created_by, spec_data):
# Ensure the namespace exists
@ -615,11 +677,10 @@ class ServiceInstance(ServalaModelMixin, models.Model):
context=context,
)
except IntegrityError:
raise ValidationError(
_(
"An instance with this name already exists in this organization. Please choose a different name."
)
message = _(
"An instance with this name already exists in this organization. Please choose a different name."
)
raise ValidationError(organization.add_support_message(message))
try:
spec_data = cls._prepare_spec_data(spec_data)
@ -657,10 +718,25 @@ class ServiceInstance(ServalaModelMixin, models.Model):
try:
error_body = json.loads(e.body)
reason = error_body.get("message", str(e))
raise ValidationError(_("Kubernetes API error: {}").format(reason))
error_data = cls._format_kubernetes_error(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):
raise ValidationError(_("Kubernetes API error: {}").format(str(e)))
raise ValidationError(_("Error creating instance: {}").format(str(e)))
error_data = cls._format_kubernetes_error(str(e))
formatted_error = cls._safe_format_error(error_data)
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
def update_spec(self, spec_data, updated_by):
@ -681,29 +757,33 @@ class ServiceInstance(ServalaModelMixin, models.Model):
self.save() # Updates updated_at timestamp
except ApiException as e:
if e.status == 404:
raise ValidationError(
_(
"Service instance not found in Kubernetes. It may have been deleted externally."
)
message = _(
"Service instance not found in control plane. It may have been deleted externally."
)
raise ValidationError(self.organization.add_support_message(message))
try:
error_body = json.loads(e.body)
reason = error_body.get("message", str(e))
raise ValidationError(
_("Kubernetes API error updating instance: {error}").format(
error=reason
)
)
error_data = self._format_kubernetes_error(reason)
formatted_reason = self._safe_format_error(error_data)
message = _(
"Error reported by control plane while updating instance: {reason}"
).format(reason=formatted_reason)
raise ValidationError(self.organization.add_support_message(message))
except (ValueError, TypeError):
raise ValidationError(
_("Kubernetes API error updating instance: {error}").format(
error=str(e)
)
)
error_data = self._format_kubernetes_error(str(e))
formatted_error = self._safe_format_error(error_data)
message = _(
"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:
raise ValidationError(
_("Error updating instance: {error}").format(error=str(e))
error_data = self._format_kubernetes_error(str(e))
formatted_error = self._safe_format_error(error_data)
message = _("Error updating instance: {error}").format(
error=formatted_error
)
raise ValidationError(self.organization.add_support_message(message))
@transaction.atomic
def delete_instance(self, user):

View file

@ -13,15 +13,30 @@ def has_organization_role(user, org, roles):
@rules.predicate
def is_organization_owner(user, org):
def is_organization_owner(user, obj):
if hasattr(obj, "organization"):
org = obj.organization
else:
org = obj
return has_organization_role(user, org, ["owner"])
@rules.predicate
def is_organization_admin(user, org):
def is_organization_admin(user, obj):
if hasattr(obj, "organization"):
org = obj.organization
else:
org = obj
return has_organization_role(user, org, ["owner", "admin"])
@rules.predicate
def is_organization_member(user, org):
def is_organization_member(user, obj):
if hasattr(obj, "organization"):
org = obj.organization
else:
org = obj
return has_organization_role(user, org, None)
rules.add_perm("core", rules.is_staff)

View file

@ -1,21 +1,22 @@
{% extends "frontend/base.html" %}
{% load static i18n %}
{% load allauth account socialaccount %}
{% block html_title %}
{% translate "Sign in" %}
{% endblock html_title %}
{% block page_title %}
{% translate "Welcome to Servala" %}
{% endblock page_title %}
{% block card_header %}
<div class="card-header text-center py-4" style="background: linear-gradient(135deg, var(--bs-primary), #8B5CF6); border-radius: 0.5rem 0.5rem 0 0;">
<img src="{% static 'img/Servala-4.png' %}" alt="Servala" class="mb-3" style="height: 70px;">
<div class="card-header text-center py-4"
style="background: linear-gradient(135deg, var(--bs-primary), #8B5CF6);
border-radius: 0.5rem 0.5rem 0 0">
<img src="{% static 'img/Servala-4.png' %}"
alt="Servala"
class="mb-3"
style="height: 70px">
</div>
{% endblock card_header %}
{% block card_content %}
<!-- Main Sign In Section -->
{% if SOCIALACCOUNT_ENABLED %}
@ -24,9 +25,10 @@
<div class="mb-4">
<div class="text-center mb-4">
<h5 class="text-primary mb-2">{% translate "Ready to get started?" %}</h5>
<p class="text-muted mb-0">{% translate "Sign in to access your managed service instances and the Servala service catalog" %}</p>
<p class="text-muted mb-0">
{% translate "Sign in to access your managed service instances and the Servala service catalog" %}
</p>
</div>
{% for provider in socialaccount_providers %}
{% provider_login_url provider process=process scope=scope auth_params=auth_params as href %}
<form method="post" action="{{ href }}">
@ -35,7 +37,9 @@
<button type="submit"
class="btn btn-primary btn-lg w-100 py-3 mb-4 fw-semibold"
title="{{ provider.name }}"
style="border-radius: 12px; box-shadow: 0 4px 15px rgba(154, 99, 236, 0.2); background: linear-gradient(135deg, var(--bs-primary), #8B5CF6);">
style="border-radius: 12px;
box-shadow: 0 4px 15px rgba(154, 99, 236, 0.2);
background: linear-gradient(135deg, var(--bs-primary), #8B5CF6)">
<span>{% translate "Sign in with VSHN Account" %}</span>
</button>
</form>
@ -43,7 +47,6 @@
</div>
{% endif %}
{% endif %}
<!-- Feature Preview & Learn More Section -->
<div class="mt-4 pt-3 border-top">
<div class="row g-3 text-center">
@ -72,8 +75,8 @@
<i class="bi bi-info-circle" style="font-size: 1.2rem;"></i>
</div>
<small class="text-muted fw-medium">
<a href="https://servala.com"
target="_blank"
<a href="https://servala.com"
target="_blank"
class="text-decoration-none text-muted">
{% translate "Learn more" %}
<i class="bi bi-arrow-up-right ms-1" style="font-size: 0.7rem;"></i>
@ -82,7 +85,6 @@
</div>
</div>
</div>
<!-- Alternative Login Options (Admin) -->
<div class="mt-4 pt-3 border-top text-center">
<small class="text-muted">
@ -96,7 +98,6 @@
</a>
</small>
</div>
<div class="collapse mt-3" id="login-form">
<div class="card bg-light border-0 shadow-sm" style="border-radius: 12px;">
<div class="card-body p-4">

View file

@ -5,13 +5,18 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'mazer/compiled/css/app.css' %}">
<link rel="stylesheet" href="{% static 'mazer/compiled/css/app-dark.css' %}">
<link rel="stylesheet"
href="{% static 'mazer/compiled/css/app-dark.css' %}">
<link rel="stylesheet" href="{% static 'mazer/compiled/css/iconly.css' %}">
<link rel="stylesheet" href="{% static 'css/servala.css' %}">
<link rel="icon" type="image/x-icon" href="{% static 'img/favicon.ico' %}">
<script src="{% static "js/htmx.min.js" %}" defer></script>
</head>
<title>{% block html_title %}Dashboard{% endblock html_title %} Servala</title>
<title>
{% block html_title %}
Dashboard
{% endblock html_title %}
Servala</title>
</head>
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<script src="{% static 'mazer/static/js/initTheme.js' %}"></script>

View file

@ -26,6 +26,24 @@
<div class="row">
<p>{{ service.description|default:"No description available."|urlize }}</p>
</div>
{% if service.external_links %}
<div class="row mt-3">
<div class="col-12">
<h6 class="mb-3">{% translate "External Links" %}</h6>
<div class="d-flex flex-wrap gap-2">
{% for link in service.external_links %}
<a href="{{ link.url }}"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline-primary btn-sm">
{{ link.title }}
<i class="bi bi-box-arrow-up-right ms-1"></i>
</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div>
</div>
<div class="row">

View file

@ -16,10 +16,10 @@
</div>
</div>
</div>
<div class="row">
<div class="row service-cards-container">
{% for service in services %}
<div class="col-6 col-lg-3 col-md-4">
<div class="card">
<div class="card h-100 d-flex flex-column">
<div class="card-header d-flex align-items-center">
{% if service.logo %}
<img src="{{ service.logo.url }}"
@ -33,11 +33,23 @@
<small class="text-muted">{{ service.category }}</small>
</div>
</div>
<div class="card-body">
<div class="card-body flex-grow-1">
{% if service.description %}<p class="card-text">{{ service.description|urlize }}</p>{% endif %}
</div>
<div class="card-footer d-flex justify-content-between">
<span></span>
<div class="card-footer d-flex justify-content-between align-items-center gap-2">
{% if service.featured_links %}
{% with featured_link=service.featured_links.0 %}
<a href="{{ featured_link.url }}"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline-primary">
{{ featured_link.title }}
<i class="bi bi-box-arrow-up-right ms-1"></i>
</a>
{% endwith %}
{% else %}
<span></span>
{% endif %}
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "View Availability" %}</a>
</div>
</div>

View file

@ -116,7 +116,8 @@
{% endblocktranslate %}
</p>
<div>
<a href="{{ account_href }}" target="_blank"
<a href="{{ account_href }}"
target="_blank"
class="btn btn-primary btn-lg icon icon-left btn-keycloak">
<span class="mx-1">{% translate "VSHN Account Console" %}</span>
</a>

View file

@ -0,0 +1,12 @@
{% 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

@ -1,28 +1,29 @@
<div class="alert alert-{{ message.tags }} alert-dismissible" id="auto-dismiss-alert-{{ forloop.counter0|default:'0' }}">
<div class="alert alert-{{ message.tags }} alert-dismissible"
id="auto-dismiss-alert-{{ forloop.counter0|default:'0' }}">
{{ message }}
<button type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close"></button>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const alert = document.getElementById('auto-dismiss-alert-{{ forloop.counter0|default:'0' }}');
if (alert) {
setTimeout(function() {
let opacity = 1;
const fadeOutInterval = setInterval(function() {
if (opacity > 0.05) {
opacity -= 0.05;
alert.style.opacity = opacity;
} else {
clearInterval(fadeOutInterval);
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
}
}, 25);
}, 5000);
}
});
</script>
document.addEventListener('DOMContentLoaded', function() {
const alert = document.getElementById('auto-dismiss-alert-{{ forloop.counter0|default:'
0 ' }}');
if (alert && !alert.classList.contains('alert-danger')) {
setTimeout(function() {
let opacity = 1;
const fadeOutInterval = setInterval(function() {
if (opacity > 0.05) {
opacity -= 0.05;
alert.style.opacity = opacity;
} else {
clearInterval(fadeOutInterval);
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
}
}, 25);
}, 5000);
}
});
</script>

View file

@ -0,0 +1,44 @@
"""
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

@ -144,7 +144,12 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
form = self.get_instance_form()
if not form: # Should not happen if context_object is valid, but as a safeguard
messages.error(self.request, _("Could not initialize service form."))
messages.error(
self.request,
self.organization.add_support_message(
_("Could not initialize service form.")
),
)
return self.render_to_response(context)
if form.is_valid():
@ -161,7 +166,10 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
messages.error(self.request, e.message or str(e))
except Exception as e:
messages.error(
self.request, _("Error creating instance: {}").format(str(e))
self.request,
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
@ -366,7 +374,10 @@ class ServiceInstanceUpdateView(
return self.form_invalid(form)
except Exception as e:
messages.error(
self.request, _("Error updating instance: {error}").format(error=str(e))
self.request,
self.organization.add_support_message(
_(f"Error updating instance: {str(e)}.")
),
)
return self.form_invalid(form)
@ -435,9 +446,11 @@ class ServiceInstanceDeleteView(
except Exception as e:
messages.error(
self.request,
_(
"An error occurred while trying to delete instance '{name}': {error}"
).format(name=self.object.name, error=str(e)),
self.organization.add_support_message(
_(
f"An error occurred while trying to delete instance '{self.object.name}': {str(e)}."
)
),
)
response = HttpResponse()
response["HX-Redirect"] = str(self.object.urls.base)

View file

@ -51,7 +51,8 @@ class SupportView(OrganizationViewMixin, FormView):
self.request,
mark_safe(
_(
'There was an error submitting your support request. Please try again or contact us directly at <a href="mailto:servala-support@vshn.ch">servala-support@vshn.ch</a>.'
"There was an error submitting your support request. "
'Please try again or contact us directly at <a href="mailto:servala-support@vshn.ch">servala-support@vshn.ch</a>.'
)
),
)

View file

@ -174,3 +174,21 @@ a.btn-keycloak {
margin-top: -16px;
padding-right: 28px;
}
/* Service cards equal height styling */
.service-cards-container .card {
height: 100%;
display: flex;
flex-direction: column;
}
.service-cards-container .card-body {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.service-cards-container .card-footer {
margin-top: auto;
}

32
uv.lock generated
View file

@ -37,11 +37,11 @@ wheels = [
[[package]]
name = "asgiref"
version = "3.9.0"
version = "3.9.1"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@ -75,30 +75,30 @@ wheels = [
[[package]]
name = "boto3"
version = "1.39.3"
version = "1.39.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
name = "botocore"
version = "1.39.3"
version = "1.39.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@ -127,11 +127,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2025.6.15"
version = "2025.7.9"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@ -1002,15 +1002,15 @@ wheels = [
[[package]]
name = "sentry-sdk"
version = "2.32.0"
version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[package.optional-dependencies]
@ -1074,7 +1074,7 @@ requires-dist = [
{ name = "pyjwt", specifier = ">=2.10.1" },
{ name = "requests", specifier = ">=2.32.4" },
{ name = "rules", specifier = ">=3.5" },
{ name = "sentry-sdk", extras = ["django"], specifier = ">=2.32.0" },
{ name = "sentry-sdk", extras = ["django"], specifier = ">=2.33.0" },
{ name = "urlman", specifier = ">=2.0.2" },
]