Compare commits

..

12 commits

20 changed files with 296 additions and 100 deletions

View file

@ -15,6 +15,8 @@ dependencies = [
"psycopg2-binary>=2.9.10", "psycopg2-binary>=2.9.10",
"pyjwt>=2.10.1", "pyjwt>=2.10.1",
"requests>=2.32.3", "requests>=2.32.3",
"rules>=3.5",
"urlman>=2.0.2",
] ]
[dependency-groups] [dependency-groups]

View file

@ -1,6 +1,6 @@
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import resolve from django.urls import resolve
from django_scopes import scope from django_scopes import scope, scopes_disabled
from servala.core.models import Organization from servala.core.models import Organization
@ -13,6 +13,10 @@ class OrganizationMiddleware:
def __call__(self, request): def __call__(self, request):
url = resolve(request.path_info) url = resolve(request.path_info)
if "admin" in url.namespaces:
with scopes_disabled():
return self.get_response(request)
organization_slug = url.kwargs.get("organization") organization_slug = url.kwargs.get("organization")
if organization_slug: if organization_slug:
pk = organization_slug.rsplit("-", maxsplit=1)[-1] pk = organization_slug.rsplit("-", maxsplit=1)[-1]

View file

@ -1,8 +1,9 @@
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rules.contrib.models import RulesModelBase, RulesModelMixin
class ServalaModelMixin(models.Model): class ServalaModelMixin(RulesModelMixin, models.Model, metaclass=RulesModelBase):
created_at = models.DateTimeField( created_at = models.DateTimeField(
auto_now_add=True, editable=False, verbose_name=_("Created") auto_now_add=True, editable=False, verbose_name=_("Created")
) )

View file

@ -1,12 +1,14 @@
import rules
import urlman
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.urls import reverse
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.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager from django_scopes import ScopedManager
from .mixins import ServalaModelMixin from servala.core import rules as perms
from servala.core.models.mixins import ServalaModelMixin
class Organization(ServalaModelMixin, models.Model): class Organization(ServalaModelMixin, models.Model):
@ -33,14 +35,16 @@ class Organization(ServalaModelMixin, models.Model):
verbose_name=_("Members"), verbose_name=_("Members"),
) )
class urls(urlman.Urls):
base = "/org/{self.slug}/"
details = "{base}details/"
@cached_property @cached_property
def slug(self): def slug(self):
return f"{slugify(self.name)}-{self.id}" return f"{slugify(self.name)}-{self.id}"
def get_absolute_url(self): def get_absolute_url(self):
return reverse( return self.urls.base
"frontend:organization.dashboard", kwargs={"organization": self.slug}
)
def set_owner(self, user): def set_owner(self, user):
OrganizationMembership.objects.filter(user=user, organization=self).delete() OrganizationMembership.objects.filter(user=user, organization=self).delete()
@ -63,6 +67,12 @@ class Organization(ServalaModelMixin, models.Model):
class Meta: class Meta:
verbose_name = _("Organization") verbose_name = _("Organization")
verbose_name_plural = _("Organizations") verbose_name_plural = _("Organizations")
rules_permissions = {
"view": rules.is_staff | perms.is_organization_member,
"change": rules.is_staff | perms.is_organization_admin,
"delete": rules.is_staff | perms.is_organization_owner,
"add": rules.is_authenticated,
}
def __str__(self): def __str__(self):
return self.name return self.name

View file

@ -1,4 +1,8 @@
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager from django.contrib.auth.models import (
AbstractBaseUser,
BaseUserManager,
PermissionsMixin,
)
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -32,7 +36,7 @@ class UserManager(BaseUserManager):
return self.create_user(email, password, **extra_fields) return self.create_user(email, password, **extra_fields)
class User(ServalaModelMixin, AbstractBaseUser): class User(ServalaModelMixin, PermissionsMixin, AbstractBaseUser):
"""The Django model provides a password and last_login field.""" """The Django model provides a password and last_login field."""
objects = UserManager() objects = UserManager()
@ -71,31 +75,3 @@ class User(ServalaModelMixin, AbstractBaseUser):
def normalize_username(self, username): def normalize_username(self, username):
return super().normalize_username(username).strip().lower() return super().normalize_username(username).strip().lower()
def has_perm(self, perm, obj=None):
"""
Return True if the user has the specified permission.
Superusers automatically have all permissions.
"""
return self.is_superuser
def has_module_perms(self, app_label):
"""
Return True if the user has any permissions in the given app label.
Superusers automatically have all permissions.
"""
return self.is_superuser
def get_all_permissions(self, obj=None):
"""
Return a set of permission strings that the user has.
Superusers have all permissions.
"""
if self.is_superuser:
from django.contrib.auth.models import Permission
return {
f"{perm.content_type.app_label}.{perm.codename}"
for perm in Permission.objects.all()
}
return set()

23
src/servala/core/rules.py Normal file
View file

@ -0,0 +1,23 @@
import rules
def has_organization_role(user, org, roles):
memberships = org.memberships.all().filter(user=user)
if roles:
memberships = memberships.filter(role__in=roles)
return memberships.exists()
@rules.predicate
def is_organization_owner(user, org):
return has_organization_role(user, org, ["owner"])
@rules.predicate
def is_organization_admin(user, org):
return has_organization_role(user, org, ["owner", "admin"])
@rules.predicate
def is_organization_member(user, org):
return has_organization_role(user, org, None)

View file

@ -1,4 +1,4 @@
from .organization import OrganizationCreateForm from .organization import OrganizationForm
from .profile import UserProfileForm from .profile import UserProfileForm
__all__ = ["OrganizationCreateForm", "UserProfileForm"] __all__ = ["OrganizationForm", "UserProfileForm"]

View file

@ -1,3 +1,5 @@
from django.utils.functional import cached_property
from servala.frontend.forms.renderers import InlineFormRenderer from servala.frontend.forms.renderers import InlineFormRenderer
@ -7,8 +9,6 @@ class HtmxMixin:
Useful when sending single fields with htmx. Useful when sending single fields with htmx.
""" """
default_renderer = InlineFormRenderer
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.single_field = kwargs.pop("single_field", None) self.single_field = kwargs.pop("single_field", None)
@ -18,3 +18,8 @@ class HtmxMixin:
field = self.fields[self.single_field] field = self.fields[self.single_field]
self.fields.clear() self.fields.clear()
self.fields[self.single_field] = field self.fields[self.single_field] = field
@cached_property
def default_renderer(self):
if self.single_field:
return InlineFormRenderer

View file

@ -1,9 +1,10 @@
from django.forms import ModelForm from django.forms import ModelForm
from servala.core.models import Organization from servala.core.models import Organization
from servala.frontend.forms.mixins import HtmxMixin
class OrganizationCreateForm(ModelForm): class OrganizationForm(HtmxMixin, ModelForm):
class Meta: class Meta:
model = Organization model = Organization
fields = ("name",) fields = ("name",)

View file

@ -0,0 +1,52 @@
{% extends "frontend/base.html" %}
{% load i18n static %}
{% load partials %}
{% block html_title %}
{% block page_title %}
{% translate "Organization Details" %}
{% endblock page_title %}
{% endblock html_title %}
{% partialdef org-name %}
<td>
{{ form.instance.name }}
{% if has_change_permission %}
<button class="btn btn-primary"
hx-get="{{ request.path }}?fragment=org-name-edit&hx-single-field=name"
hx-target="closest td"
hx-swap="outerHTML">{% translate "Edit" %}</button>
{% endif %}
</td>
{% endpartialdef org-name %}
{% partialdef org-name-edit %}
<td>
<form hx-target="closest td"
hx-swap="outerHTML"
hx-post="{{ request.url }}">
<div class="d-flex align-items-baseline">
{{ form.name.as_field_group }}
<input type="hidden" name="hx-single-field" value="name">
<input type="hidden" name="fragment" value="org-name">
<button type="submit" class="btn btn-primary mx-1">{% translate "Save" %}</button>
<button type="button"
class="btn btn-secondary"
hx-get="{{ request.path }}?fragment=org-name"
hx-target="closest td"
hx-swap="outerHTML">{% translate "Cancel" %}</button>
</div>
</form>
</td>
{% endpartialdef org-name-edit %}
{% block card_content %}
<div class="table-responsive">
<table class="table table-lg">
<tbody>
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Name" %}</span>
</th>
{% partial org-name %}
</tr>
</tbody>
</table>
</div>
{% endblock card_content %}

View file

@ -15,7 +15,7 @@
<td> <td>
{{ request.user.email }} {{ request.user.email }}
<button class="btn btn-primary" <button class="btn btn-primary"
hx-get="{% url 'frontend:profile' %}?fragment=user-email-edit" hx-get="{% url 'frontend:profile' %}?fragment=user-email-edit&hx-single-field=email"
hx-target="closest td" hx-target="closest td"
hx-swap="outerHTML">{% translate "Edit" %}</button> hx-swap="outerHTML">{% translate "Edit" %}</button>
</td> </td>
@ -24,7 +24,7 @@
<td> <td>
{{ request.user.company|default:"" }} {{ request.user.company|default:"" }}
<button class="btn btn-primary" <button class="btn btn-primary"
hx-get="{% url 'frontend:profile' %}?fragment=user-company-edit" hx-get="{% url 'frontend:profile' %}?fragment=user-company-edit&hx-single-field=company"
hx-target="closest td" hx-target="closest td"
hx-swap="outerHTML">{% translate "Edit" %}</button> hx-swap="outerHTML">{% translate "Edit" %}</button>
</td> </td>

View file

@ -1,11 +1,11 @@
{% load i18n %} {% load i18n static %}
<div id="sidebar"> <div id="sidebar">
<div id="sidebar"> <div id="sidebar">
<div class="sidebar-wrapper active"> <div class="sidebar-wrapper active">
<div class="sidebar-header position-relative"> <div class="sidebar-header position-relative">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div class="logo"> <div class="logo">
<a href="{% if request.organization %}{{ request.organization.get_absolute_url }}{% else %}/{% endif %}"> <a href="{% if request.organization %}{{ request.organization.urls.base }}{% else %}/{% endif %}">
<img src="" alt="{% translate 'Logo' %}" srcset=""> <img src="" alt="{% translate 'Logo' %}" srcset="">
</a> </a>
</div> </div>
@ -53,19 +53,8 @@
<a href="#" class="sidebar-hide d-xl-none d-block"><i class="bi bi-x bi-middle"></i></a> <a href="#" class="sidebar-hide d-xl-none d-block"><i class="bi bi-x bi-middle"></i></a>
</div> </div>
</div> </div>
</div> {% if request.user.is_authenticated %}
<div class="sidebar-menu">
<ul class="menu">
{% if not request.user.is_authenticated %}
<li class="sidebar-item">
<a href="{% url 'account_login' %}" class="sidebar-link">
<i class="bi bi-person-badge-fill"></i>
<span>{% translate 'Login' %}</span>
</a>
</li>
{% else %}
{# request.user.is_authenticated #} {# request.user.is_authenticated #}
<li class="sidebar-item">
{% if user_organizations.count %} {% if user_organizations.count %}
<button class="btn btn-outline-primary dropdown-toggle w-100" <button class="btn btn-outline-primary dropdown-toggle w-100"
type="button" type="button"
@ -84,7 +73,7 @@
id="organization-dropdown"> id="organization-dropdown">
{% for organization in user_organizations %} {% for organization in user_organizations %}
<a class="dropdown-item{% if organization == request.organization %} active{% endif %}" <a class="dropdown-item{% if organization == request.organization %} active{% endif %}"
href="{{ organization.get_absolute_url }}"> href="{{ organization.urls.base }}">
<i class="bi bi-building-fill me-1"></i> <i class="bi bi-building-fill me-1"></i>
{{ organization.name }} {{ organization.name }}
</a> </a>
@ -95,20 +84,38 @@
</a> </a>
</div> </div>
{% else %} {% else %}
<a href="{% url 'frontend:organization.create' %}" class="sidebar-link"> <a href="{% url 'frontend:organization.create' %}"
class="btn btn-outline-primary w-100">
<i class="bi bi-plus-square"></i> <i class="bi bi-plus-square"></i>
<span>{% translate "Create organization" %}</span> <span>{% translate "Create organization" %}</span>
</a> </a>
{% endif %} {% endif %}
{% endif %}
</div>
<div class="sidebar-menu">
<ul class="menu">
{% if not request.user.is_authenticated %}
<li class="sidebar-item">
<a href="{% url 'account_login' %}" class="sidebar-link">
<i class="bi bi-person-badge-fill"></i>
<span>{% translate 'Login' %}</span>
</a>
</li> </li>
{% else %}
{% if request.organization %} {% if request.organization %}
<li class="sidebar-item"> <li class="sidebar-item">
<a href="{{ request.organization.get_absolute_url }}" <a href="{{ request.organization.urls.base }}" class='sidebar-link'>
class='sidebar-link'>
<i class="bi bi-grid-fill"></i> <i class="bi bi-grid-fill"></i>
<span>{% translate 'Dashboard' %}</span> <span>{% translate 'Dashboard' %}</span>
</a> </a>
</li> </li>
<li class="sidebar-title">{% translate 'Organization' %}</li>
<li class="sidebar-item">
<a href="{{ request.organization.urls.details }}" class='sidebar-link'>
<i class="bi bi-building-gear"></i>
<span>{% translate 'Details' %}</span>
</a>
</li>
{% endif %} {% endif %}
<li class="sidebar-title">{% translate 'Account' %}</li> <li class="sidebar-title">{% translate 'Account' %}</li>
<li class="sidebar-item"> <li class="sidebar-item">
@ -132,3 +139,4 @@
</div> </div>
</div> </div>
</div> </div>
<script src="{% static 'js/sidebar.js' %}" defer></script>

View file

@ -12,9 +12,14 @@ urlpatterns = [
name="organization.create", name="organization.create",
), ),
path( path(
"<slug:organization>/", "org/<slug:organization>/",
include( include(
[ [
path(
"details/",
views.OrganizationUpdateView.as_view(),
name="organization.details",
),
path( path(
"", "",
views.OrganizationDashboardView.as_view(), views.OrganizationDashboardView.as_view(),

View file

@ -1,11 +1,16 @@
from .auth import LogoutView from .auth import LogoutView
from .generic import IndexView, ProfileView from .generic import IndexView, ProfileView
from .organization import OrganizationCreateView, OrganizationDashboardView from .organization import (
OrganizationCreateView,
OrganizationDashboardView,
OrganizationUpdateView,
)
__all__ = [ __all__ = [
"IndexView", "IndexView",
"LogoutView", "LogoutView",
"OrganizationCreateView", "OrganizationCreateView",
"OrganizationDashboardView", "OrganizationDashboardView",
"OrganizationUpdateView",
"ProfileView", "ProfileView",
] ]

View file

@ -1,24 +1,27 @@
from django.conf import settings from django.conf import settings
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.views.generic import TemplateView, UpdateView from django.views.generic import TemplateView
from servala.core.models import User from servala.core.models import User
from servala.frontend.forms.profile import UserProfileForm from servala.frontend.forms.profile import UserProfileForm
from servala.frontend.views.mixins import HtmxMixin from servala.frontend.views.mixins import HtmxUpdateView
class IndexView(TemplateView): class IndexView(TemplateView):
template_name = "frontend/index.html" template_name = "frontend/index.html"
class ProfileView(HtmxMixin, UpdateView): class ProfileView(HtmxUpdateView):
template_name = "frontend/profile.html" template_name = "frontend/profile.html"
form_class = UserProfileForm form_class = UserProfileForm
success_url = reverse_lazy("frontend:profile") success_url = reverse_lazy("frontend:profile")
fragments = ("user-email", "user-email-edit", "user-company", "user-company-edit") fragments = ("user-email", "user-email-edit", "user-company", "user-company-edit")
model = User model = User
def has_permission(self):
return True
def get_object(self): def get_object(self):
return self.request.user return self.request.user

View file

@ -1,14 +1,34 @@
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.views.generic import TemplateView from django.views.generic import UpdateView
from rules.contrib.views import AutoPermissionRequiredMixin
class HtmxMixin(TemplateView): class HtmxUpdateView(AutoPermissionRequiredMixin, UpdateView):
fragments = [] fragments = []
@cached_property @cached_property
def is_htmx(self): def is_htmx(self):
return self.request.headers.get("HX-Request") return self.request.headers.get("HX-Request")
@property
def permission_type(self):
if self.request.method == "POST" or getattr(
self, "_test_write_permission", False
):
return "change"
return "view"
def has_change_permission(self):
self._test_write_permission = True
permission = self.get_permission_required()[0]
self._test_write_permission = False
return self.request.user.has_perm(permission, self.get_permission_object())
def get_context_data(self, **kwargs):
result = super().get_context_data(**kwargs)
result["has_change_permission"] = self.has_change_permission()
return result
def _get_fragment(self): def _get_fragment(self):
if self.request.method == "POST": if self.request.method == "POST":
fragment = self.request.POST.get("fragment") fragment = self.request.POST.get("fragment")
@ -25,8 +45,11 @@ class HtmxMixin(TemplateView):
def get_form_kwargs(self): def get_form_kwargs(self):
result = super().get_form_kwargs() result = super().get_form_kwargs()
if self.is_htmx and (field_name := self.request.POST.get("hx-single-field")): if self.is_htmx:
result["single_field"] = field_name data = (
self.request.POST if self.request.method == "POST" else self.request.GET
)
result["single_field"] = data.get("hx-single-field")
return result return result
def form_valid(self, form): def form_valid(self, form):

View file

@ -1,19 +1,51 @@
from django.shortcuts import redirect from django.shortcuts import redirect
from django.views.generic import FormView, TemplateView from django.utils.functional import cached_property
from django.views.generic import CreateView, DetailView
from rules.contrib.views import AutoPermissionRequiredMixin
from servala.frontend.forms import OrganizationCreateForm from servala.core.models import Organization
from servala.frontend.forms import OrganizationForm
from servala.frontend.views.mixins import HtmxUpdateView
class OrganizationCreateView(FormView): class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView):
form_class = OrganizationCreateForm form_class = OrganizationForm
model = Organization
template_name = "frontend/organizations/create.html" template_name = "frontend/organizations/create.html"
def form_valid(self, form): def form_valid(self, form):
instance = form.instance.create_organization( instance = form.instance.create_organization(
form.instance, owner=self.request.user form.instance, owner=self.request.user
) )
return redirect(instance.get_absolute_url()) return redirect(instance.urls.base)
class OrganizationDashboardView(TemplateView): class OrganizationViewMixin:
model = Organization
context_object_name = "organization"
@cached_property
def organization(self):
return self.request.organization
def get_object(self):
return self.organization
@cached_property
def object(self):
return self.get_object()
class OrganizationDashboardView(
AutoPermissionRequiredMixin, OrganizationViewMixin, DetailView
):
template_name = "frontend/organizations/dashboard.html" template_name = "frontend/organizations/dashboard.html"
class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView):
template_name = "frontend/organizations/update.html"
form_class = OrganizationForm
fragments = ("org-name", "org-name-edit")
def get_success_url(self):
return self.request.path

View file

@ -97,6 +97,7 @@ INSTALLED_APPS = [
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django.forms", "django.forms",
"template_partials", "template_partials",
"rules.apps.AutodiscoverRulesConfig",
# The frontend app is loaded early in order to supersede some allauth views/behaviour # The frontend app is loaded early in order to supersede some allauth views/behaviour
"servala.frontend", "servala.frontend",
"allauth", "allauth",
@ -170,6 +171,7 @@ ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
ACCOUNT_SIGNUP_FORM_CLASS = "servala.frontend.forms.auth.ServalaSignupForm" ACCOUNT_SIGNUP_FORM_CLASS = "servala.frontend.forms.auth.ServalaSignupForm"
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"rules.permissions.ObjectPermissionBackend",
# Needed to login by username in Django admin, regardless of `allauth` # Needed to login by username in Django admin, regardless of `allauth`
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend", "allauth.account.auth_backends.AuthenticationBackend",

View file

@ -0,0 +1,22 @@
/**
* This script marks the current path as active in the sidebar.
*/
document.addEventListener('DOMContentLoaded', () => {
const currentPath = window.location.pathname;
const sidebarLinks = document.querySelectorAll('.sidebar-link');
sidebarLinks.forEach(link => {
// Skip links that are inside buttons (like logout)
if (link.tagName === 'BUTTON') return;
if (link.getAttribute('href') === currentPath) {
const parentItem = link.closest('.sidebar-item');
if (parentItem) {
parentItem.classList.add('active');
} else {
link.classList.add('active');
}
}
})
})

22
uv.lock generated
View file

@ -661,6 +661,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
] ]
[[package]]
name = "rules"
version = "3.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/36/918cf4cc9fd0e38bb9310b2d1a13ae6ebb2b5732d56e7de6feb4a992a6ed/rules-3.5.tar.gz", hash = "sha256:f01336218f4561bab95f53672d22418b4168baea271423d50d9e8490d64cb27a", size = 55504 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/33/16213dd62ca8ce8749985318a966ac1300ab55c977b2d66632a45b405c99/rules-3.5-py2.py3-none-any.whl", hash = "sha256:0f00fc9ee448b3f82e9aff9334ab0c56c76dce4dfa14f1598f57969f1022acc0", size = 25658 },
]
[[package]] [[package]]
name = "servala" name = "servala"
version = "0.0.0" version = "0.0.0"
@ -676,6 +685,8 @@ dependencies = [
{ name = "psycopg2-binary" }, { name = "psycopg2-binary" },
{ name = "pyjwt" }, { name = "pyjwt" },
{ name = "requests" }, { name = "requests" },
{ name = "rules" },
{ name = "urlman" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@ -702,6 +713,8 @@ requires-dist = [
{ name = "psycopg2-binary", specifier = ">=2.9.10" }, { name = "psycopg2-binary", specifier = ">=2.9.10" },
{ name = "pyjwt", specifier = ">=2.10.1" }, { name = "pyjwt", specifier = ">=2.10.1" },
{ name = "requests", specifier = ">=2.32.3" }, { name = "requests", specifier = ">=2.32.3" },
{ name = "rules", specifier = ">=3.5" },
{ name = "urlman", specifier = ">=2.0.2" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@ -763,3 +776,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 },
] ]
[[package]]
name = "urlman"
version = "2.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/c3/cc163cadf40a03d23d522d050ffa147c0589ccd7992a2cc4dd2b02aa9886/urlman-2.0.2.tar.gz", hash = "sha256:231afe89d0d0db358fe7a2626eb39310e5bf5911f3796318955cbe77e1b39601", size = 7684 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/0c/e8a418c9bc9349e7869e88a5b439cf39c4f6f8942da858000944c94a8f01/urlman-2.0.2-py2.py3-none-any.whl", hash = "sha256:2505bf310be424ffa6f4965a6f643ce32dc6194f61a3c5989f2f56453c614814", size = 8028 },
]