Add accounts and organizations (Phase 1)

Sign-in via django-allauth: emailed one-time codes, GitHub, Discord,
and Forgejo instances as OpenID Connect apps registered in admin.
Regular users never have a password; sysadmins sign in only at
/admin/ and are redirected there from the normal sign-in.

Organizations at the URL root with owner and collaborator roles, a
per-member project-create grant, and invites by username or email.
Email invites carry a bearer token stored only as a hash. The last
owner can't leave, be removed or be demoted. Permission checks live
in intent/access.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alec K2XAP 2026-10-27 07:10:27 -04:00
parent 950a4030ee
commit 1af217f6fb
37 changed files with 1725 additions and 8 deletions

View file

@ -17,8 +17,16 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.discord",
"allauth.socialaccount.providers.github",
# Forgejo instances are OpenID Connect apps, one SocialApp per instance, added in admin.
"allauth.socialaccount.providers.openid_connect",
"intent.core", "intent.core",
"intent.accounts", "intent.accounts",
"intent.orgs",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -29,6 +37,7 @@ MIDDLEWARE = [
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"allauth.account.middleware.AccountMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
@ -68,6 +77,35 @@ DATABASES = {
AUTH_USER_MODEL = "accounts.User" AUTH_USER_MODEL = "accounts.User"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
AUTHENTICATION_BACKENDS = [
# Password login, used only by sysadmins at /admin/.
"django.contrib.auth.backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend",
]
LOGIN_URL = "account_login"
LOGIN_REDIRECT_URL = "home"
# Regular users never have a password: they sign in with an emailed code or a linked provider.
ACCOUNT_ADAPTER = "intent.accounts.adapter.AccountAdapter"
ACCOUNT_LOGIN_METHODS = {"email"}
ACCOUNT_SIGNUP_FIELDS = ["email*", "username*"]
ACCOUNT_USERNAME_VALIDATORS = "intent.accounts.validators.username_validators"
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
ACCOUNT_EMAIL_VERIFICATION_BY_CODE_ENABLED = True
ACCOUNT_EMAIL_VERIFICATION_BY_CODE_SUPPORTS_RESEND = True
ACCOUNT_LOGIN_BY_CODE_ENABLED = True
ACCOUNT_LOGIN_BY_CODE_SUPPORTS_RESEND = True
ACCOUNT_LOGIN_BY_CODE_TIMEOUT = 600
ACCOUNT_LOGOUT_ON_GET = False
SOCIALACCOUNT_PROVIDERS = {
"github": {"SCOPE": ["read:user", "user:email"]},
}
# Provider is TBD (see concept.md); console output until then.
EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend")
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "Intent <intent@localhost>")
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},

View file

@ -3,3 +3,5 @@ from .base import * # noqa: F403
DEBUG = True DEBUG = True
SECRET_KEY = SECRET_KEY or "dev-insecure-secret-key" # noqa: F405 SECRET_KEY = SECRET_KEY or "dev-insecure-secret-key" # noqa: F405
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"] ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"]
DATA_DIR.mkdir(exist_ok=True) # noqa: F405

View file

@ -1,9 +1,14 @@
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import include, path
from intent.core import views from intent.core import views
from intent.orgs.urls import invite_urlpatterns
urlpatterns = [ urlpatterns = [
path("", views.home, name="home"), path("", views.home, name="home"),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("accounts/", include("allauth.urls")),
path("invites/", include(invite_urlpatterns)),
# Last: organizations own the rest of the URL space (see RESERVED_SLUGS).
path("<slug:org_slug>/", include("intent.orgs.urls")),
] ]

View file

@ -19,7 +19,7 @@ Feature history is like git history, each edit is a commit with notes explaining
For V1.1: after making changes to a feature, the user can optionally create issues back on the forgejo instance to do the implementation (optionally passing agent-specific instructions on top of links back to the Intent content). (Flow is mostly one-way, Intent -> Forgejo, but delivery status does propagate back to Intent via commit messages. Issues on Forgejo do not propagate back to Intent.) For V1.1: after making changes to a feature, the user can optionally create issues back on the forgejo instance to do the implementation (optionally passing agent-specific instructions on top of links back to the Intent content). (Flow is mostly one-way, Intent -> Forgejo, but delivery status does propagate back to Intent via commit messages. Issues on Forgejo do not propagate back to Intent.)
Users can authenticate using Forgejo, GitHub, Discord, or email (magic link) (and multiple of these by linking their auths together). System admin users can use password authentication (these users are separate from regular users and cannot act within projects). Users can authenticate using Forgejo, GitHub, Discord, or email (one-time sign-in code) (and multiple of these by linking their auths together). System admin users can use password authentication (these users are separate from regular users and cannot act within projects).
Tenancy: multiple organizations and teams, potentially many public users Tenancy: multiple organizations and teams, potentially many public users

View file

@ -34,14 +34,15 @@ Decisions made in the concept are not repeated. These fill the gaps.
| Thread → issue | Original commenter or any team member. Thread gets `promoted_to` issue; issue body is prefilled with the thread text and links back. | | Thread → issue | Original commenter or any team member. Thread gets `promoted_to` issue; issue body is prefilled with the thread text and links back. |
| Deleted users | Content remains, attributed to a "ghost" user. | | Deleted users | Content remains, attributed to a "ghost" user. |
| Moderation | Project owners can delete comments and close issues in their project. Sysadmin can deactivate a user site-wide. | | Moderation | Project owners can delete comments and close issues in their project. Sysadmin can deactivate a user site-wide. |
| Invites | By email or existing username. Email invites create a pending invite, accepted via a signed link that also logs the user in (magic link). Expire after 14 days. The last org owner cannot be removed or demoted. | | Invites | By email or existing username. Username invites appear on the invitee's Invitations page (no notifications in V1). Email invites send a link carrying a random token (only its hash is stored); the link requires sign-in and whoever holds it may accept with any account. Email invites also appear on the Invitations page of a user with that verified email. Re-inviting replaces the pending invite. Expire after 14 days. The last org owner cannot be removed, demoted or leave. |
| URLs | Organizations live at the root (`/<org>/`, later `/<org>/<project>/`); app routes are reserved slugs. |
### Stack ### Stack
| Topic | Default | | Topic | Default |
|---|---| |---|---|
| Runtime | Python 3.13, Django 5.2 LTS, `uv` for dependencies, `ruff` for lint/format. | | Runtime | Python 3.13, Django 5.2 LTS, `uv` for dependencies, `ruff` for lint/format. |
| Auth | `django-allauth`: GitHub, Discord, OpenID Connect provider configured per Forgejo instance (Forgejo supports OIDC), email login-by-code for magic links. Multiple social accounts per user via allauth's connect flow. | | Auth | `django-allauth`: GitHub, Discord, OpenID Connect provider per Forgejo instance (Forgejo supports OIDC). Sysadmins register each provider as a SocialApp in Django admin, so adding a Forgejo instance needs no deploy. Email sign-in is allauth's login-by-code: the "magic link" is an emailed one-time code, not a clickable link. Signup asks for email and username only; regular users never get a password. Multiple social accounts per user via allauth's connect flow (no automatic linking by email). Usernames: letters, digits, `-`, `_`, max 39. |
| Sysadmins | Django `is_staff` users with passwords, Django admin at `/admin/`. A model constraint rejects staff users as org members. Regular users have no password (unusable). | | Sysadmins | Django `is_staff` users with passwords, Django admin at `/admin/`. A model constraint rejects staff users as org members. Regular users have no password (unusable). |
| Database | SQLite on a Fly volume. `transaction_mode = "IMMEDIATE"`, WAL mode, `busy_timeout` 5 s. Litestream replicates to B2 continuously. | | Database | SQLite on a Fly volume. `transaction_mode = "IMMEDIATE"`, WAL mode, `busy_timeout` 5 s. Litestream replicates to B2 continuously. |
| Files | Tigris via `django-storages` S3 backend, private bucket, time-limited signed URLs. HTML prototypes are shown in a sandboxed `<iframe>` whose `src` is a signed URL on the Tigris origin, never rendered inline on the app origin. | | Files | Tigris via `django-storages` S3 backend, private bucket, time-limited signed URLs. HTML prototypes are shown in a sandboxed `<iframe>` whose `src` is a signed URL on the Tigris origin, never rendered inline on the app origin. |
@ -97,7 +98,7 @@ CommentEdit comment, body_md, edited_at
Notes: Notes:
- `FeatureRevision` is append-only. `Feature` holds denormalized current values for cheap listing. - `FeatureRevision` is append-only. `Feature` holds denormalized current values for cheap listing.
- Feature number and issue number are allocated inside a transaction with `select_for_update` on the project row (fine under SQLite's single-writer model). - Feature number and issue number are allocated inside a transaction with `select_for_update` on the project row (fine under SQLite's single-writer model).
- A `CheckConstraint` on `OrgMembership` rejects `user.is_staff`. Enforced again in a model `clean()` for admin forms. - Staff users are kept out of orgs in three places: `OrgMembership.clean()` (admin forms), the membership services, and `intent/access.py`, which grants staff nothing even if a row exists. A database constraint can't express this because it spans tables. Staff signing in through allauth are redirected to the admin login.
- Thread target is validated in `clean()`: exactly the fields required for its `target` type are set. - Thread target is validated in `clean()`: exactly the fields required for its `target` type are set.
## 4. Permissions ## 4. Permissions

37
intent/access.py Normal file
View file

@ -0,0 +1,37 @@
"""What a user may do. Every permission check in views and templates goes through here."""
from dataclasses import dataclass
from intent.orgs.models import Organization, OrgMembership, Role
@dataclass(frozen=True)
class OrgAccess:
membership: OrgMembership | None
@property
def is_member(self) -> bool:
return self.membership is not None
@property
def is_owner(self) -> bool:
return self.is_member and self.membership.role == Role.OWNER
@property
def can_view_members(self) -> bool:
return self.is_member
@property
def can_manage_members(self) -> bool:
return self.is_owner
@property
def can_create_projects(self) -> bool:
return self.is_owner or (self.is_member and self.membership.can_create_projects)
def org_access(user, org: Organization) -> OrgAccess:
if not user.is_authenticated or user.is_staff:
return OrgAccess(membership=None)
membership = OrgMembership.objects.filter(org=org, user=user).first()
return OrgAccess(membership=membership)

View file

@ -0,0 +1,13 @@
from allauth.account.adapter import DefaultAccountAdapter
from django.contrib import messages
from django.shortcuts import redirect
class AccountAdapter(DefaultAccountAdapter):
def pre_login(self, request, user, **kwargs):
# Sysadmins are separate from regular users and cannot act within projects,
# so they only sign in through the admin site.
if user.is_staff:
messages.error(request, "Administrator accounts sign in at the admin site.")
return redirect("admin:login")
return super().pre_login(request, user, **kwargs)

View file

@ -3,4 +3,7 @@ from django.contrib.auth.admin import UserAdmin
from .models import User from .models import User
admin.site.register(User, UserAdmin)
@admin.register(User)
class IntentUserAdmin(UserAdmin):
search_fields = ["username", "email"]

View file

@ -0,0 +1,10 @@
from django.core.validators import RegexValidator
# Handles are used in URLs and mentions, and must never look like an email address,
# so a single "username or email" field can tell them apart.
username_validators = [
RegexValidator(
r"^[A-Za-z0-9][A-Za-z0-9_-]{0,38}$",
"Use 1–39 letters, numbers, hyphens or underscores, starting with a letter or number.",
),
]

View file

@ -1,5 +1,11 @@
from django.shortcuts import render from django.shortcuts import render
from intent.orgs.services import open_invites_for
def home(request): def home(request):
return render(request, "core/home.html") context = {}
if request.user.is_authenticated:
context["orgs"] = request.user.organizations.all()
context["invites"] = list(open_invites_for(request.user))
return render(request, "core/home.html", context)

0
intent/orgs/__init__.py Normal file
View file

51
intent/orgs/admin.py Normal file
View file

@ -0,0 +1,51 @@
from django import forms
from django.contrib import admin
from django.contrib.auth import get_user_model
from .models import Organization, OrgInvite, OrgMembership, Role
class OrganizationAddForm(forms.ModelForm):
owner = forms.ModelChoiceField(
queryset=get_user_model().objects.filter(is_staff=False),
help_text="The first owner. They can invite everyone else.",
)
class Meta:
model = Organization
fields = ["name", "slug", "owner"]
class OrgMembershipInline(admin.TabularInline):
model = OrgMembership
extra = 0
autocomplete_fields = ["user"]
@admin.register(Organization)
class OrganizationAdmin(admin.ModelAdmin):
list_display = ["name", "slug", "created_at"]
search_fields = ["name", "slug"]
prepopulated_fields = {"slug": ["name"]}
inlines = [OrgMembershipInline]
def get_form(self, request, obj=None, **kwargs):
if obj is None:
kwargs["form"] = OrganizationAddForm
return super().get_form(request, obj, **kwargs)
def get_inlines(self, request, obj):
# On add, the owner field replaces the membership inline.
return self.inlines if obj else []
def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)
if not change:
OrgMembership.objects.create(org=obj, user=form.cleaned_data["owner"], role=Role.OWNER)
@admin.register(OrgInvite)
class OrgInviteAdmin(admin.ModelAdmin):
list_display = ["org", "recipient", "role", "status", "expires_at"]
list_filter = ["status"]
readonly_fields = ["token_hash"]

6
intent/orgs/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class OrgsConfig(AppConfig):
name = "intent.orgs"
label = "orgs"

31
intent/orgs/forms.py Normal file
View file

@ -0,0 +1,31 @@
from django import forms
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from .models import Role
class InviteForm(forms.Form):
recipient = forms.CharField(
label="Username or email",
max_length=254,
widget=forms.TextInput(attrs={"autocomplete": "off"}),
)
role = forms.ChoiceField(choices=Role.choices, initial=Role.COLLABORATOR)
def clean_recipient(self):
value = self.cleaned_data["recipient"].strip()
try:
validate_email(value)
except ValidationError:
user = get_user_model().objects.filter(username__iexact=value).first()
if user is None:
raise ValidationError("No user with that username.") from None
return {"user": user}
return {"email": value}
class MembershipForm(forms.Form):
role = forms.ChoiceField(choices=Role.choices)
can_create_projects = forms.BooleanField(required=False)

View file

@ -0,0 +1,78 @@
# Generated by Django 5.2.18 on 2026-10-27 11:06
import django.db.models.deletion
import intent.core.models
import intent.orgs.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Organization',
fields=[
('id', intent.core.models.ULIDField(default=intent.core.models.new_ulid, editable=False, max_length=26, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('slug', models.SlugField(max_length=40, unique=True, validators=[intent.orgs.models.validate_org_slug])),
('name', models.CharField(max_length=100)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='OrgMembership',
fields=[
('id', intent.core.models.ULIDField(default=intent.core.models.new_ulid, editable=False, max_length=26, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('role', models.CharField(choices=[('owner', 'Owner'), ('collaborator', 'Collaborator')], default='collaborator', max_length=20)),
('can_create_projects', models.BooleanField(default=False)),
('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='orgs.organization')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='org_memberships', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['user__username'],
},
),
migrations.AddField(
model_name='organization',
name='members',
field=models.ManyToManyField(related_name='organizations', through='orgs.OrgMembership', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='OrgInvite',
fields=[
('id', intent.core.models.ULIDField(default=intent.core.models.new_ulid, editable=False, max_length=26, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('email', models.EmailField(blank=True, max_length=254)),
('role', models.CharField(choices=[('owner', 'Owner'), ('collaborator', 'Collaborator')], default='collaborator', max_length=20)),
('token_hash', models.CharField(max_length=64, unique=True)),
('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('declined', 'Declined'), ('revoked', 'Revoked')], default='pending', max_length=20)),
('expires_at', models.DateTimeField(default=intent.orgs.models._expiry)),
('responded_at', models.DateTimeField(blank=True, null=True)),
('accepted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('invited_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='orgs.organization')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='org_invites', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
'constraints': [models.CheckConstraint(condition=models.Q(models.Q(('user__isnull', False), ('email', '')), models.Q(('user__isnull', True), models.Q(('email', ''), _negated=True)), _connector='OR'), name='orginvite_one_recipient'), models.UniqueConstraint(condition=models.Q(('status', 'pending'), ('user__isnull', False)), fields=('org', 'user'), name='orginvite_one_pending_per_user'), models.UniqueConstraint(condition=models.Q(('status', 'pending'), models.Q(('email', ''), _negated=True)), fields=('org', 'email'), name='orginvite_one_pending_per_email')],
},
),
migrations.AddConstraint(
model_name='orgmembership',
constraint=models.UniqueConstraint(fields=('org', 'user'), name='orgmembership_unique_user'),
),
]

View file

151
intent/orgs/models.py Normal file
View file

@ -0,0 +1,151 @@
import hashlib
import secrets
from datetime import timedelta
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from django.utils import timezone
from intent.core.models import BaseModel
INVITE_LIFETIME = timedelta(days=14)
class Role(models.TextChoices):
OWNER = "owner", "Owner"
COLLABORATOR = "collaborator", "Collaborator"
# Organizations live at the URL root, so their slugs can't shadow app routes.
RESERVED_SLUGS = {
"about", "accounts", "admin", "api", "healthz", "help", "invites", "login",
"logout", "media", "new", "orgs", "settings", "signup", "static", "users",
} # fmt: skip
def validate_org_slug(value):
if value.lower() in RESERVED_SLUGS:
raise ValidationError(f"“{value}” is reserved.")
class Organization(BaseModel):
slug = models.SlugField(max_length=40, unique=True, validators=[validate_org_slug])
name = models.CharField(max_length=100)
members = models.ManyToManyField(
settings.AUTH_USER_MODEL, through="OrgMembership", related_name="organizations"
)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class OrgMembership(BaseModel):
org = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="memberships")
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="org_memberships"
)
role = models.CharField(max_length=20, choices=Role.choices, default=Role.COLLABORATOR)
# Owners can always create projects; this grants it to collaborators.
can_create_projects = models.BooleanField(default=False)
class Meta:
constraints = [
models.UniqueConstraint(fields=["org", "user"], name="orgmembership_unique_user"),
]
ordering = ["user__username"]
def __str__(self):
return f"{self.user} in {self.org} ({self.role})"
def clean(self):
if self.user_id and self.user.is_staff:
raise ValidationError("Administrator accounts cannot be organization members.")
def _expiry():
return timezone.now() + INVITE_LIFETIME
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
class InviteStatus(models.TextChoices):
PENDING = "pending", "Pending"
ACCEPTED = "accepted", "Accepted"
DECLINED = "declined", "Declined"
REVOKED = "revoked", "Revoked"
class OrgInvite(BaseModel):
"""An invitation addressed to either an existing user or an email address."""
org = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="invites")
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="org_invites",
)
email = models.EmailField(blank=True)
role = models.CharField(max_length=20, choices=Role.choices, default=Role.COLLABORATOR)
invited_by = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+"
)
# Only a hash is stored; the raw token travels in the invite email link.
token_hash = models.CharField(max_length=64, unique=True)
status = models.CharField(
max_length=20, choices=InviteStatus.choices, default=InviteStatus.PENDING
)
expires_at = models.DateTimeField(default=_expiry)
responded_at = models.DateTimeField(null=True, blank=True)
# Who actually accepted. For email invites, whoever held the link.
accepted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
class Meta:
constraints = [
models.CheckConstraint(
condition=(Q(user__isnull=False) & Q(email=""))
| (Q(user__isnull=True) & ~Q(email="")),
name="orginvite_one_recipient",
),
models.UniqueConstraint(
fields=["org", "user"],
condition=Q(status="pending", user__isnull=False),
name="orginvite_one_pending_per_user",
),
models.UniqueConstraint(
fields=["org", "email"],
condition=Q(status="pending") & ~Q(email=""),
name="orginvite_one_pending_per_email",
),
]
ordering = ["-created_at"]
def __str__(self):
return f"Invite to {self.org} for {self.recipient}"
@property
def recipient(self) -> str:
return self.user.username if self.user_id else self.email
@property
def is_open(self) -> bool:
return self.status == InviteStatus.PENDING and self.expires_at > timezone.now()
def set_new_token(self) -> str:
token = secrets.token_urlsafe(32)
self.token_hash = hash_token(token)
return token

150
intent/orgs/services.py Normal file
View file

@ -0,0 +1,150 @@
"""Organization membership rules. Views call these; they enforce permissions and invariants.
Rule violations raise ValidationError with a user-facing message.
Missing permissions raise PermissionDenied.
"""
from allauth.account.models import EmailAddress
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from intent.access import org_access
from .models import InviteStatus, Organization, OrgInvite, OrgMembership, Role
LAST_OWNER_MESSAGE = "An organization needs at least one owner. Make someone else an owner first."
def _require_owner(by, org):
if not org_access(by, org).can_manage_members:
raise PermissionDenied
def _is_last_owner(membership) -> bool:
return (
membership.role == Role.OWNER
and membership.org.memberships.filter(role=Role.OWNER).count() == 1
)
def _verified_emails(user) -> list[str]:
return [
e.lower()
for e in EmailAddress.objects.filter(user=user, verified=True).values_list(
"email", flat=True
)
]
def create_org(*, slug, name, owner) -> Organization:
if owner.is_staff:
raise ValidationError("Administrator accounts cannot own organizations.")
with transaction.atomic():
org = Organization.objects.create(slug=slug, name=name)
OrgMembership.objects.create(org=org, user=owner, role=Role.OWNER)
return org
def invite(org, by, *, user=None, email="", role=Role.COLLABORATOR) -> tuple[OrgInvite, str]:
"""Invite an existing user or an email address. Returns the invite and its raw token.
A new invite to the same recipient replaces any pending one.
"""
_require_owner(by, org)
email = email.strip().lower()
if bool(user) == bool(email):
raise ValueError("Invite exactly one of user or email")
with transaction.atomic():
if user:
if user.is_staff:
raise ValidationError("Administrator accounts cannot be invited.")
if org.memberships.filter(user=user).exists():
raise ValidationError(f"{user.username} is already a member.")
pending = org.invites.filter(status=InviteStatus.PENDING, user=user)
else:
if org.memberships.filter(
user__emailaddress__email__iexact=email, user__emailaddress__verified=True
).exists():
raise ValidationError(f"Someone with {email} is already a member.")
pending = org.invites.filter(status=InviteStatus.PENDING, email=email)
pending.update(status=InviteStatus.REVOKED, responded_at=timezone.now())
new_invite = OrgInvite(org=org, user=user, email=email, role=role, invited_by=by)
token = new_invite.set_new_token()
new_invite.save()
return new_invite, token
def open_invites_for(user):
"""Pending, unexpired invites addressed to this user by username or verified email."""
return (
OrgInvite.objects.filter(status=InviteStatus.PENDING, expires_at__gt=timezone.now())
.filter(Q(user=user) | Q(email__in=_verified_emails(user), email__gt=""))
.select_related("org", "invited_by")
)
def is_addressed_to(invite, user) -> bool:
if invite.user_id:
return invite.user_id == user.pk
return invite.email in _verified_emails(user)
def accept_invite(invite, user) -> OrgMembership:
with transaction.atomic():
invite.refresh_from_db()
if not invite.is_open:
raise ValidationError("This invitation is no longer valid.")
if user.is_staff:
raise ValidationError("Administrator accounts cannot join organizations.")
if invite.org.memberships.filter(user=user).exists():
raise ValidationError(f"You are already a member of {invite.org}.")
membership = OrgMembership.objects.create(org=invite.org, user=user, role=invite.role)
invite.status = InviteStatus.ACCEPTED
invite.accepted_by = user
invite.responded_at = timezone.now()
invite.save(update_fields=["status", "accepted_by", "responded_at", "updated_at"])
return membership
def decline_invite(invite) -> None:
with transaction.atomic():
invite.refresh_from_db()
if not invite.is_open:
raise ValidationError("This invitation is no longer valid.")
invite.status = InviteStatus.DECLINED
invite.responded_at = timezone.now()
invite.save(update_fields=["status", "responded_at", "updated_at"])
def revoke_invite(invite, by) -> None:
_require_owner(by, invite.org)
if invite.status == InviteStatus.PENDING:
invite.status = InviteStatus.REVOKED
invite.responded_at = timezone.now()
invite.save(update_fields=["status", "responded_at", "updated_at"])
def update_membership(membership, by, *, role, can_create_projects) -> None:
_require_owner(by, membership.org)
with transaction.atomic():
membership.refresh_from_db()
if role != Role.OWNER and _is_last_owner(membership):
raise ValidationError(LAST_OWNER_MESSAGE)
membership.role = role
membership.can_create_projects = can_create_projects
membership.save(update_fields=["role", "can_create_projects", "updated_at"])
def remove_member(membership, by) -> None:
"""Owners can remove anyone; members can remove themselves (leave)."""
if membership.user_id != by.pk:
_require_owner(by, membership.org)
with transaction.atomic():
membership.refresh_from_db()
if _is_last_owner(membership):
raise ValidationError(LAST_OWNER_MESSAGE)
membership.delete()

22
intent/orgs/urls.py Normal file
View file

@ -0,0 +1,22 @@
from django.urls import path
from . import views
app_name = "orgs"
urlpatterns = [
path("", views.org_detail, name="detail"),
path("members/", views.members, name="members"),
path("members/<str:membership_id>/", views.member_update, name="member_update"),
path("members/<str:membership_id>/remove/", views.member_remove, name="member_remove"),
path("invites/<str:invite_id>/revoke/", views.invite_revoke, name="invite_revoke"),
]
invite_urlpatterns = (
[
path("", views.my_invites, name="list"),
path("<str:invite_id>/respond/", views.invite_respond, name="respond"),
path("t/<str:token>/", views.invite_by_token, name="by_token"),
],
"invites",
)

162
intent/orgs/views.py Normal file
View file

@ -0,0 +1,162 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.mail import send_mail
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from django.views.decorators.http import require_POST
from intent.access import org_access
from . import services
from .forms import InviteForm, MembershipForm
from .models import InviteStatus, Organization, OrgInvite, hash_token
def _error(request, exc: ValidationError):
for message in exc.messages:
messages.error(request, message)
def org_detail(request, org_slug):
org = get_object_or_404(Organization, slug=org_slug)
return render(
request, "orgs/detail.html", {"org": org, "access": org_access(request.user, org)}
)
def members(request, org_slug):
org = get_object_or_404(Organization, slug=org_slug)
access = org_access(request.user, org)
if not access.can_view_members:
raise PermissionDenied
invite_form = InviteForm()
if request.method == "POST":
if not access.can_manage_members:
raise PermissionDenied
invite_form = InviteForm(request.POST)
if invite_form.is_valid():
try:
invite, token = services.invite(
org,
request.user,
role=invite_form.cleaned_data["role"],
**invite_form.cleaned_data["recipient"],
)
except ValidationError as exc:
_error(request, exc)
else:
if invite.email:
_send_invite_email(request, invite, token)
messages.success(request, f"Invited {invite.recipient}.")
return redirect("orgs:members", org_slug=org.slug)
context = {
"org": org,
"access": access,
"memberships": org.memberships.select_related("user"),
"pending_invites": org.invites.filter(status=InviteStatus.PENDING).select_related(
"user", "invited_by"
),
"invite_form": invite_form,
}
return render(request, "orgs/members.html", context)
def _send_invite_email(request, invite, token):
context = {
"invite": invite,
"url": request.build_absolute_uri(reverse("invites:by_token", args=[token])),
}
send_mail(
subject=f"Invitation to join {invite.org} on Intent",
message=render_to_string("orgs/email/invite.txt", context),
from_email=None,
recipient_list=[invite.email],
)
@login_required
@require_POST
def member_update(request, org_slug, membership_id):
org = get_object_or_404(Organization, slug=org_slug)
membership = get_object_or_404(org.memberships, pk=membership_id)
form = MembershipForm(request.POST)
if form.is_valid():
try:
services.update_membership(membership, request.user, **form.cleaned_data)
except ValidationError as exc:
_error(request, exc)
else:
messages.success(request, f"Updated {membership.user.username}.")
return redirect("orgs:members", org_slug=org.slug)
@login_required
@require_POST
def member_remove(request, org_slug, membership_id):
org = get_object_or_404(Organization, slug=org_slug)
membership = get_object_or_404(org.memberships.select_related("user"), pk=membership_id)
leaving = membership.user_id == request.user.pk
try:
services.remove_member(membership, request.user)
except ValidationError as exc:
_error(request, exc)
return redirect("orgs:members", org_slug=org.slug)
if leaving:
messages.success(request, f"You left {org}.")
return redirect("home")
messages.success(request, f"Removed {membership.user.username}.")
return redirect("orgs:members", org_slug=org.slug)
@login_required
@require_POST
def invite_revoke(request, org_slug, invite_id):
org = get_object_or_404(Organization, slug=org_slug)
invite = get_object_or_404(org.invites, pk=invite_id)
services.revoke_invite(invite, request.user)
messages.success(request, f"Revoked the invitation for {invite.recipient}.")
return redirect("orgs:members", org_slug=org.slug)
@login_required
def my_invites(request):
return render(
request, "orgs/my_invites.html", {"invites": services.open_invites_for(request.user)}
)
def _respond(request, invite):
try:
if request.POST.get("action") == "accept":
services.accept_invite(invite, request.user)
messages.success(request, f"You joined {invite.org}.")
return redirect("orgs:detail", org_slug=invite.org.slug)
services.decline_invite(invite)
messages.success(request, f"Declined the invitation to {invite.org}.")
except ValidationError as exc:
_error(request, exc)
return redirect("invites:list")
@login_required
@require_POST
def invite_respond(request, invite_id):
invite = get_object_or_404(OrgInvite.objects.select_related("org"), pk=invite_id)
if not services.is_addressed_to(invite, request.user):
raise PermissionDenied
return _respond(request, invite)
@login_required
def invite_by_token(request, token):
"""Email invites: whoever holds the link may accept, whichever account they sign in with."""
invite = get_object_or_404(
OrgInvite.objects.select_related("org", "invited_by"), token_hash=hash_token(token)
)
if request.method == "POST":
return _respond(request, invite)
return render(request, "orgs/invite.html", {"invite": invite})

View file

@ -5,6 +5,7 @@ description = "Track the why behind features, across implementations."
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"django>=5.2,<5.3", "django>=5.2,<5.3",
"django-allauth[socialaccount]>=65.21",
"django-storages[s3]>=1.14.6", "django-storages[s3]>=1.14.6",
"gunicorn>=26.2.0", "gunicorn>=26.2.0",
"python-ulid>=4.0.1", "python-ulid>=4.0.1",

View file

@ -28,6 +28,31 @@ footer {
padding-block: 1rem; padding-block: 1rem;
} }
nav ul {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 1.5rem;
margin: 0;
padding: 0;
list-style: none;
}
nav form {
margin: 0;
}
table {
border-collapse: collapse;
}
th,
td {
padding: 0.25rem 0.75rem 0.25rem 0;
text-align: start;
vertical-align: top;
}
p, p,
li, li,
dd { dd {

View file

@ -0,0 +1,20 @@
{% extends "account/base_entrance.html" %}
{% comment %}
Replaces allauth's password form: regular users have no password.
The email form posts straight to the login-code view.
{% endcomment %}
{% block head_title %}Sign in{% endblock head_title %}
{% block content %}
<h1>Sign in</h1>
<form method="post" action="{% url 'account_request_login_code' %}">
{% csrf_token %}
<p>
<label for="id_email">Email address</label>
<input type="email" name="email" id="id_email" autocomplete="email" required>
</p>
{{ redirect_field }}
<p><button type="submit">Email me a sign-in code</button></p>
</form>
<p>New here? <a href="{{ signup_url }}">Create an account</a>.</p>
{% include "socialaccount/snippets/login.html" with page_layout="entrance" %}
{% endblock content %}

View file

@ -0,0 +1,5 @@
{% extends "base.html" %}
{# Puts allauth's pages inside the site layout; their pages fill `content`. #}
{% block title %}{% block head_title %}{% endblock head_title %} · Intent{% endblock title %}
{% block head %}{% block extra_head %}{% endblock extra_head %}{% endblock head %}
{% block body_end %}{% block extra_body %}{% endblock extra_body %}{% endblock body_end %}

View file

@ -8,15 +8,37 @@
{# Blocking on purpose: applies the saved theme before first paint. #} {# Blocking on purpose: applies the saved theme before first paint. #}
<script src="{% static 'js/theme.js' %}"></script> <script src="{% static 'js/theme.js' %}"></script>
<link rel="stylesheet" href="{% static 'css/intent.css' %}"> <link rel="stylesheet" href="{% static 'css/intent.css' %}">
{% block head %}{% endblock %}
</head> </head>
<body> <body>
<a class="skip-link" href="#main">Skip to content</a> <a class="skip-link" href="#main">Skip to content</a>
<header> <header>
<nav aria-label="Main"> <nav aria-label="Main">
<a href="{% url 'home' %}">Intent</a> <ul>
<li><a href="{% url 'home' %}">Intent</a></li>
{% if user.is_authenticated %}
<li><a href="{% url 'invites:list' %}">Invitations</a></li>
<li><a href="{% url 'account_email' %}">Account</a></li>
<li>
<form method="post" action="{% url 'account_logout' %}">
{% csrf_token %}
<button type="submit">Sign out <span class="visually-hidden">{{ user.username }}</span></button>
</form>
</li>
{% else %}
<li><a href="{% url 'account_login' %}">Sign in</a></li>
{% endif %}
</ul>
</nav> </nav>
</header> </header>
<main id="main" tabindex="-1"> <main id="main" tabindex="-1">
{% if messages %}
<ul class="messages" role="status">
{% for message in messages %}
<li>{% if message.level_tag == "error" %}<strong>Error:</strong> {% endif %}{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<footer> <footer>
@ -30,5 +52,6 @@
</select> </select>
</form> </form>
</footer> </footer>
{% block body_end %}{% endblock %}
</body> </body>
</html> </html>

View file

@ -3,4 +3,28 @@
{% block content %} {% block content %}
<h1>Intent</h1> <h1>Intent</h1>
<p>Track the why behind features, across every implementation.</p> <p>Track the why behind features, across every implementation.</p>
{% if user.is_authenticated %}
{% if invites %}
<section aria-labelledby="invites-heading">
<h2 id="invites-heading">Pending invitations</h2>
<p><a href="{% url 'invites:list' %}">{{ invites|length }} invitation{{ invites|length|pluralize }} waiting</a></p>
</section>
{% endif %}
<section aria-labelledby="orgs-heading">
<h2 id="orgs-heading">Your organizations</h2>
{% if orgs %}
<ul>
{% for org in orgs %}
<li><a href="{% url 'orgs:detail' org_slug=org.slug %}">{{ org.name }}</a></li>
{% endfor %}
</ul>
{% else %}
<p>You're not in any organizations yet. Ask an organization owner to invite you.</p>
{% endif %}
</section>
{% else %}
<p><a href="{% url 'account_login' %}">Sign in</a> or <a href="{% url 'account_signup' %}">create an account</a>.</p>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}{{ org.name }} · Intent{% endblock %}
{% block content %}
<h1>{{ org.name }}</h1>
{% if access.is_member %}
<p>You're {% if access.is_owner %}an owner{% else %}a collaborator{% endif %} of this organization.</p>
<ul>
<li><a href="{% url 'orgs:members' org_slug=org.slug %}">Members</a></li>
</ul>
<details>
<summary>Leave {{ org.name }}</summary>
<form method="post" action="{% url 'orgs:member_remove' org_slug=org.slug membership_id=access.membership.pk %}">
{% csrf_token %}
<p>You'll need a new invitation to rejoin.</p>
<p><button type="submit">Leave {{ org.name }}</button></p>
</form>
</details>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,7 @@
{% autoescape off %}{% if invite.invited_by %}{{ invite.invited_by.username }} invited you{% else %}You're invited{% endif %} to join {{ invite.org.name }} on Intent as {{ invite.get_role_display|lower }}.
Accept or decline:
{{ url }}
This link expires on {{ invite.expires_at|date:"j F Y" }}. If you weren't expecting it, you can ignore this email.
{% endautoescape %}

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Invitation to {{ invite.org.name }} · Intent{% endblock %}
{% block content %}
<h1>Join {{ invite.org.name }}</h1>
{% if invite.is_open %}
<p>
{% if invite.invited_by %}{{ invite.invited_by.username }} invited you{% else %}You're invited{% endif %}
to join as {{ invite.get_role_display|lower }}. You'll join as <strong>{{ user.username }}</strong>.
</p>
<form method="post">
{% csrf_token %}
<button type="submit" name="action" value="accept">Accept</button>
<button type="submit" name="action" value="decline">Decline</button>
</form>
{% else %}
<p>This invitation is no longer valid. Ask an owner of {{ invite.org.name }} for a new one.</p>
{% endif %}
{% endblock %}

102
templates/orgs/members.html Normal file
View file

@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}Members · {{ org.name }} · Intent{% endblock %}
{% block content %}
<p><a href="{% url 'orgs:detail' org_slug=org.slug %}">{{ org.name }}</a></p>
<h1>Members</h1>
{% if access.can_manage_members %}
{# Row forms live outside the table; controls join them with the form attribute. #}
{% for m in memberships %}
<form id="member-{{ m.pk }}" method="post" action="{% url 'orgs:member_update' org_slug=org.slug membership_id=m.pk %}">{% csrf_token %}</form>
<form id="remove-{{ m.pk }}" method="post" action="{% url 'orgs:member_remove' org_slug=org.slug membership_id=m.pk %}">{% csrf_token %}</form>
{% endfor %}
{% endif %}
<table>
<caption>{{ memberships|length }} member{{ memberships|length|pluralize }}</caption>
<thead>
<tr>
<th scope="col">Member</th>
<th scope="col">Role</th>
<th scope="col">Can create projects</th>
{% if access.can_manage_members %}<th scope="col">Actions</th>{% endif %}
</tr>
</thead>
<tbody>
{% for m in memberships %}
<tr>
<th scope="row">{{ m.user.username }}{% if m.user == user %} (you){% endif %}</th>
{% if access.can_manage_members %}
<td>
<label for="role-{{ m.pk }}" class="visually-hidden">Role for {{ m.user.username }}</label>
<select id="role-{{ m.pk }}" name="role" form="member-{{ m.pk }}">
<option value="owner"{% if m.role == "owner" %} selected{% endif %}>Owner</option>
<option value="collaborator"{% if m.role == "collaborator" %} selected{% endif %}>Collaborator</option>
</select>
</td>
<td>
<input type="checkbox" id="create-{{ m.pk }}" name="can_create_projects" form="member-{{ m.pk }}"{% if m.can_create_projects or m.role == "owner" %} checked{% endif %}{% if m.role == "owner" %} disabled{% endif %}>
<label for="create-{{ m.pk }}" class="visually-hidden">{{ m.user.username }} can create projects</label>
{% if m.role == "owner" %}<span class="muted">(owners always can)</span>{% endif %}
</td>
<td>
<button type="submit" form="member-{{ m.pk }}">Save <span class="visually-hidden">{{ m.user.username }}</span></button>
<button type="submit" form="remove-{{ m.pk }}">Remove <span class="visually-hidden">{{ m.user.username }}</span></button>
</td>
{% else %}
<td>{{ m.get_role_display }}</td>
<td>{% if m.role == "owner" or m.can_create_projects %}Yes{% else %}No{% endif %}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% if access.can_manage_members %}
<section aria-labelledby="invite-heading">
<h2 id="invite-heading">Invite someone</h2>
<form method="post">
{% csrf_token %}
{{ invite_form.as_p }}
<p><button type="submit">Send invitation</button></p>
</form>
</section>
<section aria-labelledby="pending-heading">
<h2 id="pending-heading">Pending invitations</h2>
{% if pending_invites %}
<table>
<thead>
<tr>
<th scope="col">Invited</th>
<th scope="col">Role</th>
<th scope="col">Invited by</th>
<th scope="col">Expires</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{% for invite in pending_invites %}
<tr>
<th scope="row">{{ invite.recipient }}</th>
<td>{{ invite.get_role_display }}</td>
<td>{{ invite.invited_by.username|default:"—" }}</td>
<td>{% if invite.is_open %}<time datetime="{{ invite.expires_at|date:'c' }}">{{ invite.expires_at|date:"j M Y" }}</time>{% else %}Expired{% endif %}</td>
<td>
<form method="post" action="{% url 'orgs:invite_revoke' org_slug=org.slug invite_id=invite.pk %}">
{% csrf_token %}
<button type="submit">Revoke <span class="visually-hidden">invitation for {{ invite.recipient }}</span></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>None.</p>
{% endif %}
</section>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Invitations · Intent{% endblock %}
{% block content %}
<h1>Invitations</h1>
{% if invites %}
<ul>
{% for invite in invites %}
<li>
<p>
<strong>{{ invite.org.name }}</strong>, as {{ invite.get_role_display|lower }}
{% if invite.invited_by %}(invited by {{ invite.invited_by.username }}){% endif %}
</p>
<form method="post" action="{% url 'invites:respond' invite_id=invite.pk %}">
{% csrf_token %}
<button type="submit" name="action" value="accept">Accept <span class="visually-hidden">{{ invite.org.name }}</span></button>
<button type="submit" name="action" value="decline">Decline <span class="visually-hidden">{{ invite.org.name }}</span></button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p>No pending invitations.</p>
{% endif %}
{% endblock %}

41
tests/conftest.py Normal file
View file

@ -0,0 +1,41 @@
import itertools
import pytest
from allauth.account.models import EmailAddress
from django.contrib.auth import get_user_model
from intent.orgs.models import OrgMembership, Role
from intent.orgs.services import create_org
_counter = itertools.count()
@pytest.fixture
def make_user(db):
def make(username=None, *, email=None, is_staff=False):
username = username or f"user{next(_counter)}"
email = email or f"{username}@example.com"
user = get_user_model().objects.create_user(
username=username, email=email, is_staff=is_staff
)
EmailAddress.objects.create(user=user, email=email, verified=True, primary=True)
return user
return make
@pytest.fixture
def owner(make_user):
return make_user("olive")
@pytest.fixture
def org(owner):
return create_org(slug="catbriar", name="Catbriar", owner=owner)
@pytest.fixture
def collaborator(make_user, org):
user = make_user("cole")
OrgMembership.objects.create(org=org, user=user, role=Role.COLLABORATOR)
return user

41
tests/test_access.py Normal file
View file

@ -0,0 +1,41 @@
from django.contrib.auth.models import AnonymousUser
from intent.access import org_access
from intent.orgs.models import OrgMembership
def test_owner_access(owner, org):
access = org_access(owner, org)
assert access.is_owner
assert access.can_view_members
assert access.can_manage_members
assert access.can_create_projects
def test_collaborator_access(collaborator, org):
access = org_access(collaborator, org)
assert access.is_member
assert not access.is_owner
assert access.can_view_members
assert not access.can_manage_members
assert not access.can_create_projects
def test_collaborator_with_grant_can_create_projects(collaborator, org):
OrgMembership.objects.filter(user=collaborator).update(can_create_projects=True)
assert org_access(collaborator, org).can_create_projects
def test_outsiders_have_no_access(make_user, org):
for user in [AnonymousUser(), make_user()]:
access = org_access(user, org)
assert not access.is_member
assert not access.can_view_members
assert not access.can_create_projects
def test_staff_never_have_access_even_with_a_membership_row(make_user, org):
admin = make_user(is_staff=True)
# Bypasses the service checks to prove access.py doesn't rely on them alone.
OrgMembership.objects.create(org=org, user=admin, role="owner")
assert not org_access(admin, org).is_member

52
tests/test_accounts.py Normal file
View file

@ -0,0 +1,52 @@
import re
import pytest
from django.core import mail
from django.core.exceptions import ValidationError
from django.urls import reverse
from intent.accounts.validators import username_validators
def _sign_in_with_code(client, email):
client.post(reverse("account_request_login_code"), {"email": email})
code = re.search(r"^\s*([A-Z0-9-]{6,})\s*$", mail.outbox[-1].body, re.MULTILINE).group(1)
return client.post(reverse("account_confirm_login_code"), {"code": code})
def test_login_page_offers_code_not_password(client, db):
html = client.get(reverse("account_login")).content.decode()
assert 'type="password"' not in html
assert reverse("account_request_login_code") in html
def test_signup_has_no_password(client, db):
html = client.get(reverse("account_signup")).content.decode()
assert 'type="password"' not in html
def test_sign_in_with_emailed_code(client, make_user):
user = make_user("ada")
response = _sign_in_with_code(client, "ada@example.com")
assert response.status_code == 302
assert client.session["_auth_user_id"] == user.pk
def test_staff_cannot_sign_in_through_allauth(client, make_user):
make_user("root", is_staff=True)
response = _sign_in_with_code(client, "root@example.com")
assert response["Location"].startswith(reverse("admin:login"))
assert "_auth_user_id" not in client.session
@pytest.mark.parametrize("value", ["ada", "ada-lovelace", "a_1"])
def test_valid_usernames(value):
for validator in username_validators:
validator(value)
@pytest.mark.parametrize("value", ["ada@example.com", "-ada", "ada.l", "x" * 40])
def test_invalid_usernames(value):
with pytest.raises(ValidationError):
for validator in username_validators:
validator(value)

View file

@ -0,0 +1,99 @@
from datetime import timedelta
import pytest
from django.core.exceptions import PermissionDenied, ValidationError
from django.utils import timezone
from intent.orgs import services
from intent.orgs.models import InviteStatus, OrgMembership, Role, validate_org_slug
def test_create_org_makes_creator_owner(owner, org):
assert org.memberships.get(user=owner).role == Role.OWNER
def test_staff_cannot_own_org(make_user):
with pytest.raises(ValidationError):
services.create_org(slug="x", name="X", owner=make_user(is_staff=True))
def test_reserved_slug_rejected():
with pytest.raises(ValidationError):
validate_org_slug("Admin")
def test_invite_requires_owner(collaborator, make_user, org):
with pytest.raises(PermissionDenied):
services.invite(org, collaborator, user=make_user())
def test_reinvite_replaces_pending_invite(owner, make_user, org):
invitee = make_user()
first, _ = services.invite(org, owner, user=invitee)
second, _ = services.invite(org, owner, user=invitee, role=Role.OWNER)
first.refresh_from_db()
assert first.status == InviteStatus.REVOKED
assert second.status == InviteStatus.PENDING
def test_cannot_invite_existing_member_or_staff(owner, collaborator, make_user, org):
with pytest.raises(ValidationError):
services.invite(org, owner, user=collaborator)
with pytest.raises(ValidationError):
services.invite(org, owner, email="COLE@example.com")
with pytest.raises(ValidationError):
services.invite(org, owner, user=make_user(is_staff=True))
def test_accept_creates_membership_with_invited_role(owner, make_user, org):
invitee = make_user()
invite, _ = services.invite(org, owner, user=invitee, role=Role.OWNER)
services.accept_invite(invite, invitee)
assert org.memberships.get(user=invitee).role == Role.OWNER
invite.refresh_from_db()
assert invite.status == InviteStatus.ACCEPTED
assert invite.accepted_by == invitee
def test_expired_invite_cannot_be_accepted(owner, make_user, org):
invitee = make_user()
invite, _ = services.invite(org, owner, user=invitee)
invite.expires_at = timezone.now() - timedelta(seconds=1)
invite.save()
with pytest.raises(ValidationError):
services.accept_invite(invite, invitee)
def test_email_invite_is_found_by_verified_email(owner, make_user, org):
invitee = make_user("ivy")
invite, _ = services.invite(org, owner, email="Ivy@Example.com")
assert list(services.open_invites_for(invitee)) == [invite]
assert services.is_addressed_to(invite, invitee)
assert not services.is_addressed_to(invite, make_user())
def test_last_owner_cannot_leave_or_be_demoted(owner, org):
membership = org.memberships.get(user=owner)
with pytest.raises(ValidationError):
services.remove_member(membership, owner)
with pytest.raises(ValidationError):
services.update_membership(
membership, owner, role=Role.COLLABORATOR, can_create_projects=False
)
def test_owner_can_step_down_once_another_owner_exists(owner, collaborator, org):
services.update_membership(
org.memberships.get(user=collaborator), owner, role=Role.OWNER, can_create_projects=False
)
services.remove_member(org.memberships.get(user=owner), owner)
assert not org.memberships.filter(user=owner).exists()
def test_member_can_leave_but_not_remove_others(owner, collaborator, make_user, org):
other = make_user()
OrgMembership.objects.create(org=org, user=other)
with pytest.raises(PermissionDenied):
services.remove_member(org.memberships.get(user=other), collaborator)
services.remove_member(org.memberships.get(user=collaborator), collaborator)
assert not org.memberships.filter(user=collaborator).exists()

113
tests/test_org_views.py Normal file
View file

@ -0,0 +1,113 @@
import re
from django.core import mail
from django.urls import reverse
from intent.orgs.models import InviteStatus, OrgInvite, Role
def members_url(org):
return reverse("orgs:members", kwargs={"org_slug": org.slug})
def test_org_page_is_public(client, org):
response = client.get(reverse("orgs:detail", kwargs={"org_slug": org.slug}))
assert response.status_code == 200
assert b"Catbriar" in response.content
def test_members_page_requires_membership(client, make_user, org):
assert client.get(members_url(org)).status_code == 403
client.force_login(make_user())
assert client.get(members_url(org)).status_code == 403
def test_collaborator_sees_members_but_cannot_invite(client, collaborator, org):
client.force_login(collaborator)
response = client.get(members_url(org))
assert response.status_code == 200
assert b"Invite someone" not in response.content
response = client.post(members_url(org), {"recipient": "x@example.com", "role": "owner"})
assert response.status_code == 403
def test_invite_by_username_then_accept_from_invitations_page(client, owner, make_user, org):
invitee = make_user("ivan")
client.force_login(owner)
response = client.post(members_url(org), {"recipient": "IVAN", "role": "collaborator"})
assert response.status_code == 302
invite = OrgInvite.objects.get(user=invitee)
assert mail.outbox == [] # in-app only; no notifications in V1
client.force_login(invitee)
assert b"Catbriar" in client.get(reverse("invites:list")).content
response = client.post(
reverse("invites:respond", kwargs={"invite_id": invite.pk}), {"action": "accept"}
)
assert response.status_code == 302
assert org.memberships.filter(user=invitee, role=Role.COLLABORATOR).exists()
def test_cannot_respond_to_someone_elses_invite(client, owner, make_user, org):
invite = OrgInvite.objects.create(org=org, user=make_user(), invited_by=owner, token_hash="x")
client.force_login(make_user())
response = client.post(
reverse("invites:respond", kwargs={"invite_id": invite.pk}), {"action": "accept"}
)
assert response.status_code == 403
def test_email_invite_link_flow(client, owner, make_user, org):
client.force_login(owner)
client.post(members_url(org), {"recipient": "new@example.com", "role": "collaborator"})
assert len(mail.outbox) == 1
path = re.search(r"https?://[^/]+(/invites/t/\S+/)", mail.outbox[0].body).group(1)
client.logout()
response = client.get(path)
assert response.status_code == 302
assert response["Location"].startswith(reverse("account_login"))
# Bearer link: any signed-in account can accept, whatever its email.
joiner = make_user("june", email="june@elsewhere.example")
client.force_login(joiner)
assert client.get(path).status_code == 200
client.post(path, {"action": "accept"})
assert org.memberships.filter(user=joiner).exists()
assert OrgInvite.objects.get(email="new@example.com").status == InviteStatus.ACCEPTED
def test_invalid_token_is_404(client, make_user):
client.force_login(make_user())
assert client.get(reverse("invites:by_token", kwargs={"token": "nope"})).status_code == 404
def test_owner_updates_role_and_grant(client, owner, collaborator, org):
membership = org.memberships.get(user=collaborator)
client.force_login(owner)
client.post(
reverse(
"orgs:member_update", kwargs={"org_slug": org.slug, "membership_id": membership.pk}
),
{"role": "collaborator", "can_create_projects": "on"},
)
membership.refresh_from_db()
assert membership.can_create_projects
def test_last_owner_leaving_shows_error(client, owner, org):
membership = org.memberships.get(user=owner)
client.force_login(owner)
response = client.post(
reverse(
"orgs:member_remove", kwargs={"org_slug": org.slug, "membership_id": membership.pk}
),
follow=True,
)
assert b"at least one owner" in response.content
assert org.memberships.filter(user=owner).exists()
def test_home_lists_orgs_and_invites(client, owner, make_user, org):
client.force_login(owner)
assert b"Catbriar" in client.get("/").content

329
uv.lock generated
View file

@ -47,6 +47,194 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082 }, { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082 },
] ]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248 },
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908 },
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805 },
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764 },
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722 },
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369 },
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175 },
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670 },
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824 },
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148 },
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564 },
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263 },
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688 },
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078 },
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064 },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720 },
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964 },
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962 },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328 },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985 },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530 },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525 },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053 },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213 },
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682 },
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949 },
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947 },
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504 },
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259 },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864 },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538 },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688 },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803 },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763 },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688 },
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868 },
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104 },
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402 },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043 },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737 },
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933 },
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002 },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271 },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919 },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529 },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630 },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134 },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197 },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683 },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897 },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935 },
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464 },
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262 },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779 },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520 },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673 },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835 },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705 },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539 },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707 },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772 },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360 },
]
[[package]]
name = "charset-normalizer"
version = "3.5.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946 },
{ url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619 },
{ url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701 },
{ url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506 },
{ url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626 },
{ url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624 },
{ url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731 },
{ url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352 },
{ url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393 },
{ url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660 },
{ url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996 },
{ url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028 },
{ url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562 },
{ url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699 },
{ url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030 },
{ url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565 },
{ url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627 },
{ url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407 },
{ url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207 },
{ url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489 },
{ url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501 },
{ url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010 },
{ url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266 },
{ url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783 },
{ url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449 },
{ url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815 },
{ url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095 },
{ url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942 },
{ url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762 },
{ url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514 },
{ url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056 },
{ url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311 },
{ url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190 },
{ url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098 },
{ url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228 },
{ url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401 },
{ url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846 },
{ url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151 },
{ url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570 },
{ url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412 },
{ url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965 },
{ url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406 },
{ url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871 },
{ url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711 },
{ url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146 },
{ url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552 },
{ url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810 },
{ url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845 },
{ url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825 },
{ url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659 },
{ url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819 },
{ url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973 },
{ url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692 },
{ url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757 },
{ url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220 },
{ url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797 },
{ url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765 },
{ url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985 },
{ url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676 },
{ url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414 },
{ url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775 },
{ url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877 },
{ url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455 },
{ url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336 },
{ url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584 },
{ url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854 },
{ url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767 },
{ url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479 },
{ url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310 },
{ url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100 },
{ url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936 },
{ url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301 },
{ url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858 },
{ url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309 },
{ url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343 },
{ url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114 },
{ url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997 },
{ url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313 },
{ url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986 },
{ url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202 },
{ url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083 },
{ url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081 },
{ url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712 },
{ url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176 },
{ url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413 },
{ url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434 },
{ url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716 },
{ url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159 },
{ url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322 },
{ url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192 },
{ url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815 },
{ url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276 },
{ url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239 },
{ url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121 },
{ url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350 },
{ url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430 },
{ url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612 },
{ url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083 },
{ url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738 },
{ url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703 },
{ url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622 },
{ url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500 },
{ url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100 },
{ url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499 },
{ url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092 },
{ url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363 },
{ url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872 },
]
[[package]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.6" version = "0.4.6"
@ -56,6 +244,69 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
] ]
[[package]]
name = "cryptography"
version = "50.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904 },
{ url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146 },
{ url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841 },
{ url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340 },
{ url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029 },
{ url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050 },
{ url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724 },
{ url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859 },
{ url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103 },
{ url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576 },
{ url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819 },
{ url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152 },
{ url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692 },
{ url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731 },
{ url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431 },
{ url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824 },
{ url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967 },
{ url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676 },
{ url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698 },
{ url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821 },
{ url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748 },
{ url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085 },
{ url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268 },
{ url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503 },
{ url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057 },
{ url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868 },
{ url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708 },
{ url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267 },
{ url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465 },
{ url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356 },
{ url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822 },
{ url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199 },
{ url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333 },
{ url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822 },
{ url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351 },
{ url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859 },
{ url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151 },
{ url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120 },
{ url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557 },
{ url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588 },
{ url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166 },
{ url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145 },
{ url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638 },
{ url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217 },
{ url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387 },
{ url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790 },
{ url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319 },
{ url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560 },
{ url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973 },
{ url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738 },
{ url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280 },
{ url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095 },
]
[[package]] [[package]]
name = "django" name = "django"
version = "5.2.18" version = "5.2.18"
@ -70,6 +321,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/01/6568ec52b26548ca1b83d33249f7274db3bf74698f42c0da42ec9db1a24a/django-5.2.18-py3-none-any.whl", hash = "sha256:92ed81d500be6408ecd704d7bd1366c534f30427bffcc63c5fefb129561aec7c", size = 8316071 }, { url = "https://files.pythonhosted.org/packages/d6/01/6568ec52b26548ca1b83d33249f7274db3bf74698f42c0da42ec9db1a24a/django-5.2.18-py3-none-any.whl", hash = "sha256:92ed81d500be6408ecd704d7bd1366c534f30427bffcc63c5fefb129561aec7c", size = 8316071 },
] ]
[[package]]
name = "django-allauth"
version = "65.21.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/99/e4/ee7ec90e0efc1c091ff1e93bfa8737c0e46ab492e21c495ad79752373e9f/django_allauth-65.21.0.tar.gz", hash = "sha256:26ab74262fbe1aa4bb5a3494d0af220b1606d040c0e31f1af6c9dc0bf7447f61", size = 2305918 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/31/d840d33e4dcda897158413e03fff0b97ce274322ce532eab2a8431d6c801/django_allauth-65.21.0-py3-none-any.whl", hash = "sha256:0f55a8bf7c4f7d66df45064eb004f53c6bb50d17381df3b281ae4759e62ab1b5", size = 2077985 },
]
[package.optional-dependencies]
socialaccount = [
{ name = "oauthlib" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "requests" },
]
[[package]] [[package]]
name = "django-storages" name = "django-storages"
version = "1.14.6" version = "1.14.6"
@ -96,6 +367,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389 }, { url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389 },
] ]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583 },
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.3.1" version = "2.3.1"
@ -111,6 +391,7 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
{ name = "django-allauth", extra = ["socialaccount"] },
{ name = "django-storages", extra = ["s3"] }, { name = "django-storages", extra = ["s3"] },
{ name = "gunicorn" }, { name = "gunicorn" },
{ name = "python-ulid" }, { name = "python-ulid" },
@ -128,6 +409,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "django", specifier = ">=5.2,<5.3" }, { name = "django", specifier = ">=5.2,<5.3" },
{ name = "django-allauth", extras = ["socialaccount"], specifier = ">=65.21" },
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" }, { name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
{ name = "gunicorn", specifier = ">=26.2.0" }, { name = "gunicorn", specifier = ">=26.2.0" },
{ name = "python-ulid", specifier = ">=4.0.1" }, { name = "python-ulid", specifier = ">=4.0.1" },
@ -151,6 +433,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419 }, { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419 },
] ]
[[package]]
name = "oauthlib"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7a/d8/a1bcc8ba112a627f8ffbdc212a78ce18d3ac07e91a5ca65d27918eee25a1/oauthlib-4.0.0.tar.gz", hash = "sha256:efb274799819440f95b4ab3b818869f1ce9ae26c5beacba0201d1a1b76b54f86", size = 187232 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/f4/78229a1066068ca14fc60fb26cf7381cabe4382261392b90e5f9552722d4/oauthlib-4.0.0-py3-none-any.whl", hash = "sha256:624c28c13a0a59cabf9747dfa52af63be3e512a7f2714df16e91b5b3a145e6cd", size = 159715 },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.3" version = "26.3"
@ -169,6 +460,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082 }, { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082 },
] ]
[[package]]
name = "pycparser"
version = "3.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz", hash = "sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6", size = 158785 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl", hash = "sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e", size = 62600 },
]
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.21.0" version = "2.21.0"
@ -178,6 +478,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147 }, { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147 },
] ]
[[package]]
name = "pyjwt"
version = "2.15.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860 },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.1.1" version = "9.1.1"
@ -227,6 +541,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/15/8b39b36f55b6618ec4ca9b55134dcfd9c04cecbc72709f5d8e6bdebed9cd/python_ulid-4.0.1-py3-none-any.whl", hash = "sha256:6f1d69ceb97e99fe542df8476ebcd7a668284bf53ee14b3106bcc6a341a95ed9", size = 14609 }, { url = "https://files.pythonhosted.org/packages/b4/15/8b39b36f55b6618ec4ca9b55134dcfd9c04cecbc72709f5d8e6bdebed9cd/python_ulid-4.0.1-py3-none-any.whl", hash = "sha256:6f1d69ceb97e99fe542df8476ebcd7a668284bf53ee14b3106bcc6a341a95ed9", size = 14609 },
] ]
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075 },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.17.3" version = "0.17.3"