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:
parent
950a4030ee
commit
1af217f6fb
37 changed files with 1725 additions and 8 deletions
|
|
@ -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"},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
37
intent/access.py
Normal 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)
|
||||||
13
intent/accounts/adapter.py
Normal file
13
intent/accounts/adapter.py
Normal 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)
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
10
intent/accounts/validators.py
Normal file
10
intent/accounts/validators.py
Normal 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.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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
0
intent/orgs/__init__.py
Normal file
51
intent/orgs/admin.py
Normal file
51
intent/orgs/admin.py
Normal 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
6
intent/orgs/apps.py
Normal 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
31
intent/orgs/forms.py
Normal 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)
|
||||||
78
intent/orgs/migrations/0001_initial.py
Normal file
78
intent/orgs/migrations/0001_initial.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
intent/orgs/migrations/__init__.py
Normal file
0
intent/orgs/migrations/__init__.py
Normal file
151
intent/orgs/models.py
Normal file
151
intent/orgs/models.py
Normal 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
150
intent/orgs/services.py
Normal 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
22
intent/orgs/urls.py
Normal 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
162
intent/orgs/views.py
Normal 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})
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
20
templates/account/login.html
Normal file
20
templates/account/login.html
Normal 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 %}
|
||||||
5
templates/allauth/layouts/base.html
Normal file
5
templates/allauth/layouts/base.html
Normal 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 %}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
23
templates/orgs/detail.html
Normal file
23
templates/orgs/detail.html
Normal 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 %}
|
||||||
7
templates/orgs/email/invite.txt
Normal file
7
templates/orgs/email/invite.txt
Normal 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 %}
|
||||||
20
templates/orgs/invite.html
Normal file
20
templates/orgs/invite.html
Normal 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
102
templates/orgs/members.html
Normal 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 %}
|
||||||
26
templates/orgs/my_invites.html
Normal file
26
templates/orgs/my_invites.html
Normal 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
41
tests/conftest.py
Normal 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
41
tests/test_access.py
Normal 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
52
tests/test_accounts.py
Normal 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)
|
||||||
99
tests/test_org_services.py
Normal file
99
tests/test_org_services.py
Normal 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
113
tests/test_org_views.py
Normal 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
329
uv.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue