New Service Request setup (#59)

* [x] Adds a ServiceRequest model for all service types
* [x] Pre-define simple service requests to begin with
* [x] Django admin for admins to handle requests
* [x] Notifications for Matrix
* [x] Moved Service Access to `services` app
* [x] Auto-create default service requests for new memberships
* [x] Most simple kinds of tests added
* [x] Fix issue in generating service requests (check for service access firstly)
* [ ] Channel and bot account

## Deployment

1. Create a bot account. Get an access token with:

   ```
   curl -XPOST \
     -d '{"type":"m.login.password", "user":"<userid>", "password":"<password>"}' \
     "https://data.coop/_matrix/client/r0/login"
   ```

2. Create an admin room for admins. Add admins + bot. Copy the room ID.
3. Add new environment variables for the setup `MATRIX_ACCESS_TOKEN` and `MATRIX_SERVICE_REQUEST_ADMIN_ROOM`

Reviewed-on: https://git.data.coop/data.coop/membersystem/pulls/59
Co-authored-by: bbb <benjamin@overtag.dk>
Co-committed-by: bbb <benjamin@overtag.dk>
This commit is contained in:
bbb 2025-01-15 07:16:12 +00:00 committed by valberg
parent c4df844da6
commit 43d19d3106
29 changed files with 643 additions and 263 deletions

View file

@ -8,3 +8,4 @@ DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres
DEBUG=True
STRIPE_API_KEY=sk_test_
STRIPE_ENDPOINT_SECRET=whsec_
MATRIX_ACCESS_TOKEN=

View file

@ -2,7 +2,6 @@ run:
@echo "Running the server"
docker compose up --watch --remove-orphans
[positional-arguments]
manage *ARGS:
@echo "Running manage command"
docker compose run -w /app/src --rm -u `id -u` app python manage.py {{ARGS}}
@ -12,7 +11,10 @@ build:
docker compose build
typecheck:
mypy --config-file=pyproject.toml .
docker compose run -w /app/src --rm app mypy .
test:
docker compose run --rm app pytest
# You need to install Stripe CLI from here to run this: https://github.com/stripe/stripe-cli/releases
stripe_cli:

View file

@ -6,10 +6,12 @@ requires-python = ">=3.11"
keywords = []
authors = [
{ name = "Víðir Valberg Guðmundsson", email = "valberg@orn.li" },
{ name = "Benjamin Balder Bach", email = "benjamin@overtag.dk" },
]
dependencies = [
"Django~=5.1",
"django-allauth~=0.63",
"django-dirtyfields~=1.9.5",
"django-money~=3.5",
"django-oauth-toolkit~=2.4",
"django-registries==0.0.3",
@ -19,6 +21,7 @@ dependencies = [
"django-zen-queries~=2.1",
"django_stubs_ext~=5.0",
"environs[django]>=11,<12",
"httpx~=0.28.1",
"psycopg[binary]~=3.2",
"stripe~=10.5",
"uvicorn~=0.30",
@ -28,15 +31,14 @@ version = "0.0.1"
[tool.uv]
dev-dependencies = [
"coverage[toml]==7.3.0",
"pytest==7.2.2",
"coverage[toml]~=7.6",
"pytest~=8.3",
"pytest-cov",
"pytest-django==4.5.2",
"mypy==1.1.1",
"django-stubs==1.16.0",
"pip-tools==7.3.0",
"django-debug-toolbar==4.2.0",
"django-browser-reload==1.7.0",
"pytest-django~=4.8",
"mypy~=1.11",
"django-stubs[compatible-mypy]~=5.0",
"django-debug-toolbar~=4.4",
"django-browser-reload~=1.15",
"model-bakery==1.17.0",
]
@ -47,7 +49,7 @@ addopts = "--reuse-db"
norecursedirs = "build dist docs .eggs/* *.egg-info htmlcov .git"
python_files = "test*.py"
testpaths = "tests"
pythonpath = ". tests"
pythonpath = ". src tests"
[tool.coverage.run]
branch = true
@ -108,6 +110,8 @@ ignore = [
"D105", # Missing docstring in magic method
"D106", # Missing docstring in public nested class
"D107", # Missing docstring in `__init__`
"D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible. Ignoring `one-blank-line-before-class`
"D213", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible. Ignoring `multi-line-summary-second-line`.
"FIX", # TODO, FIXME, XXX
"TD", # TODO, FIXME, XXX
"ANN002", # Missing type annotation for `*args`
@ -127,3 +131,14 @@ force-single-line = true
"D100", # Docstrings
"D103", # Docstrings
]
"tests/*" = [
"ANN001",
"ANN201",
"ARG001", # TODO: Unused function argument. These are mostly pytest fixtures. Find a way to allow these in tests. Remove this after.
"D103",
"D104",
"S101", # Use of `assert` detected
"PGH004",
"PT004",
"RET504",
]

View file

@ -9,20 +9,26 @@
# - django-stubs-ext~=5.0
# - django-view-decorator==0.0.4
# - django-zen-queries~=2.1
# - django<5.2,>=5.1b1
# - django~=5.1
# - environs[django]<12,>=11
# - httpx
# - psycopg[binary]~=3.2
# - stripe~=10.5
# - uvicorn~=0.30
# - whitenoise~=6.7
#
anyio==4.7.0
# via httpx
asgiref==3.8.1
# via django
babel==2.15.0
# via py-moneyed
certifi==2024.7.4
# via requests
# via
# httpcore
# httpx
# requests
cffi==1.16.0
# via cryptography
charset-normalizer==3.3.2
@ -35,7 +41,7 @@ dj-database-url==2.2.0
# via environs
dj-email-url==1.0.6
# via environs
django==5.1rc1
django==5.1.4
# via
# hatch.envs.default
# dj-database-url
@ -67,9 +73,18 @@ django-zen-queries==2.1.0
environs==11.0.0
# via hatch.envs.default
h11==0.14.0
# via uvicorn
# via
# httpcore
# uvicorn
httpcore==1.0.7
# via httpx
httpx==0.28.1
# via hatch.envs.default
idna==3.7
# via requests
# via
# anyio
# httpx
# requests
jwcrypto==1.5.6
# via django-oauth-toolkit
marshmallow==3.21.3
@ -96,12 +111,15 @@ requests==2.32.3
# stripe
setuptools==72.1.0
# via django-money
sniffio==1.3.1
# via anyio
sqlparse==0.5.1
# via django
stripe==10.6.0
# via hatch.envs.default
typing-extensions==4.12.2
# via
# anyio
# dj-database-url
# django-stubs-ext
# jwcrypto

View file

@ -1,15 +1,14 @@
#
# This file is autogenerated by hatch-pip-compile with Python 3.12
#
# - coverage[toml]==7.3.0
# - pytest==7.2.2
# - coverage[toml]~=7.6
# - pytest~=8.3
# - pytest-cov
# - pytest-django==4.5.2
# - mypy==1.1.1
# - django-stubs==1.16.0
# - pip-tools==7.3.0
# - django-debug-toolbar==4.2.0
# - django-browser-reload==1.7.0
# - pytest-django~=4.8
# - mypy~=1.11
# - django-stubs[compatible-mypy]~=5.0
# - django-debug-toolbar~=4.4
# - django-browser-reload~=1.15
# - model-bakery==1.17.0
# - django-allauth~=0.63
# - django-money~=3.5
@ -19,33 +18,36 @@
# - django-stubs-ext~=5.0
# - django-view-decorator==0.0.4
# - django-zen-queries~=2.1
# - django<5.2,>=5.1b1
# - django~=5.1
# - environs[django]<12,>=11
# - httpx
# - psycopg[binary]~=3.2
# - stripe~=10.5
# - uvicorn~=0.30
# - whitenoise~=6.7
#
anyio==4.7.0
# via httpx
asgiref==3.8.1
# via django
attrs==23.2.0
# via pytest
# via
# django
# django-browser-reload
# django-stubs
babel==2.15.0
# via py-moneyed
build==1.2.1
# via pip-tools
certifi==2024.7.4
# via requests
# via
# httpcore
# httpx
# requests
cffi==1.16.0
# via cryptography
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via
# pip-tools
# uvicorn
coverage==7.3.0
# via uvicorn
coverage==7.6.9
# via
# hatch.envs.dev
# pytest-cov
@ -55,7 +57,7 @@ dj-database-url==2.2.0
# via environs
dj-email-url==1.0.6
# via environs
django==5.1rc1
django==5.1.4
# via
# hatch.envs.dev
# dj-database-url
@ -72,11 +74,11 @@ django==5.1rc1
# model-bakery
django-allauth==0.63.6
# via hatch.envs.dev
django-browser-reload==1.7.0
django-browser-reload==1.17.0
# via hatch.envs.dev
django-cache-url==3.4.5
# via environs
django-debug-toolbar==4.2.0
django-debug-toolbar==4.4.6
# via hatch.envs.dev
django-money==3.5.3
# via hatch.envs.dev
@ -86,9 +88,9 @@ django-ratelimit==4.1.0
# via hatch.envs.dev
django-registries==0.0.3
# via hatch.envs.dev
django-stubs==1.16.0
django-stubs==5.1.1
# via hatch.envs.dev
django-stubs-ext==5.0.4
django-stubs-ext==5.1.1
# via
# hatch.envs.dev
# django-stubs
@ -99,9 +101,18 @@ django-zen-queries==2.1.0
environs==11.0.0
# via hatch.envs.dev
h11==0.14.0
# via uvicorn
# via
# httpcore
# uvicorn
httpcore==1.0.7
# via httpx
httpx==0.28.1
# via hatch.envs.dev
idna==3.7
# via requests
# via
# anyio
# httpx
# requests
iniconfig==2.0.0
# via pytest
jwcrypto==1.5.6
@ -110,7 +121,7 @@ marshmallow==3.21.3
# via environs
model-bakery==1.17.0
# via hatch.envs.dev
mypy==1.1.1
mypy==1.13.0
# via
# hatch.envs.dev
# django-stubs
@ -120,13 +131,8 @@ oauthlib==3.2.2
# via django-oauth-toolkit
packaging==24.1
# via
# build
# marshmallow
# pytest
pip==24.2
# via pip-tools
pip-tools==7.3.0
# via hatch.envs.dev
pluggy==1.5.0
# via pytest
psycopg==3.2.1
@ -137,16 +143,14 @@ py-moneyed==3.0
# via django-money
pycparser==2.22
# via cffi
pyproject-hooks==1.1.0
# via build
pytest==7.2.2
pytest==8.3.4
# via
# hatch.envs.dev
# pytest-cov
# pytest-django
pytest-cov==5.0.0
# via hatch.envs.dev
pytest-django==4.5.2
pytest-django==4.9.0
# via hatch.envs.dev
python-dotenv==1.0.1
# via environs
@ -157,23 +161,20 @@ requests==2.32.3
# django-oauth-toolkit
# stripe
setuptools==72.1.0
# via
# django-money
# pip-tools
# via django-money
sniffio==1.3.1
# via anyio
sqlparse==0.5.1
# via
# django
# django-debug-toolbar
stripe==10.6.0
# via hatch.envs.dev
tomli==2.0.1
# via django-stubs
types-pytz==2024.1.0.20240417
# via django-stubs
types-pyyaml==6.0.12.20240724
# via django-stubs
typing-extensions==4.12.2
# via
# anyio
# dj-database-url
# django-stubs
# django-stubs-ext
@ -186,7 +187,5 @@ urllib3==2.2.2
# via requests
uvicorn==0.30.5
# via hatch.envs.dev
wheel==0.43.0
# via pip-tools
whitenoise==6.7.0
# via hatch.envs.dev

View file

@ -32,7 +32,7 @@ class Migration(migrations.Migration):
),
(
"created",
models.DateTimeField(auto_now_add=True, verbose_name="oprettet"),
models.DateTimeField(auto_now_add=True, verbose_name="created"),
),
(
"owner",
@ -64,7 +64,7 @@ class Migration(migrations.Migration):
),
(
"created",
models.DateTimeField(auto_now_add=True, verbose_name="oprettet"),
models.DateTimeField(auto_now_add=True, verbose_name="created"),
),
(
"description",
@ -139,7 +139,7 @@ class Migration(migrations.Migration):
),
(
"created",
models.DateTimeField(auto_now_add=True, verbose_name="oprettet"),
models.DateTimeField(auto_now_add=True, verbose_name="created"),
),
(
"amount_currency",
@ -194,7 +194,7 @@ class Migration(migrations.Migration):
),
(
"created",
models.DateTimeField(auto_now_add=True, verbose_name="oprettet"),
models.DateTimeField(auto_now_add=True, verbose_name="created"),
),
(
"amount_currency",

View file

@ -17,7 +17,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='oprettet')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('name', models.CharField(max_length=1024, verbose_name='description')),
('description', models.TextField(blank=True, max_length=2048)),
('enabled', models.BooleanField(default=True)),
@ -31,7 +31,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='oprettet')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('name', models.CharField(max_length=512)),
('price_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)),
('price', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)),
@ -63,7 +63,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='oprettet')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('price_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)),
('price', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)),
('vat_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)),

View file

@ -7,20 +7,11 @@ from django.conf import settings
from django.contrib import admin
from django.db import models
from django.db.models.aggregates import Sum
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy
from djmoney.models.fields import MoneyField
from djmoney.money import Money
class CreatedModifiedAbstract(models.Model):
"""Abstract model to track creation and modification of objects."""
modified = models.DateTimeField(auto_now=True, verbose_name=_("modified"))
created = models.DateTimeField(auto_now_add=True, verbose_name=_("created"))
class Meta:
abstract = True
from utils.mixins import CreatedModifiedAbstract
class Account(CreatedModifiedAbstract):

View file

@ -20,7 +20,6 @@ from .emails import InviteEmail
from .models import Member
from .models import Membership
from .models import MembershipType
from .models import ServiceAccess
from .models import SubscriptionPeriod
from .models import WaitingListEntry
@ -47,12 +46,6 @@ class SubscriptionPeriodAdmin(admin.ModelAdmin):
"""Admin for SubscriptionPeriod model."""
@admin.register(ServiceAccess)
class ServiceAccessAdmin(admin.ModelAdmin):
"""Admin for ServiceAccess model."""
pass
class MembershipInlineAdmin(admin.TabularInline):
"""Inline admin."""

View file

@ -0,0 +1,24 @@
# Generated by Django 5.1rc1 on 2024-12-24 17:32
import django_registries.registry
import services.registry
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('membership', '0011_serviceaccess_alter_membership_options_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='membership',
options={'verbose_name': 'medlemskab', 'verbose_name_plural': 'medlemskaber'},
),
migrations.AlterField(
model_name='serviceaccess',
name='service',
field=django_registries.registry.ChoicesField(choices=[('forgejo', 'forgejo'), ('hedgedoc', 'hedgedoc'), ('mail', 'mail'), ('mastodon', 'mastodon'), ('matrix', 'matrix'), ('nextcloud', 'nextcloud'), ('rallly', 'rallly')], registry=services.registry.ServiceRegistry, verbose_name='service'),
),
]

View file

@ -0,0 +1,16 @@
# Generated by Django 5.1.4 on 2024-12-26 19:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('membership', '0012_alter_membership_options_alter_serviceaccess_service'),
]
operations = [
migrations.DeleteModel(
name='ServiceAccess',
),
]

View file

@ -1,9 +1,9 @@
"""Models for the membership app."""
import uuid
from typing import ClassVar
from typing import Self
from dirtyfields import DirtyFieldsMixin
from django.contrib.auth.models import User
from django.contrib.auth.models import UserManager
from django.contrib.postgres.constraints import ExclusionConstraint
@ -12,8 +12,8 @@ from django.contrib.postgres.fields import RangeOperators
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext as _
from services.registry import ServiceRegistry
from djmoney.money import Money
from services.models import ServiceRequest
from utils.mixins import CreatedModifiedAbstract
@ -105,7 +105,7 @@ class SubscriptionPeriod(CreatedModifiedAbstract):
return f"{self.period.lower} - {self.period.upper or _('next general assembly')}"
class Membership(CreatedModifiedAbstract):
class Membership(DirtyFieldsMixin, CreatedModifiedAbstract):
"""A membership.
Tracks that a user has membership of a given type for a given period.
@ -188,6 +188,18 @@ class Membership(CreatedModifiedAbstract):
def __str__(self) -> str:
return f"{self.user} - {self.period}"
def save(self, *args, **kwargs) -> None:
is_new = not self.pk
# A Membership is considered recently activated when:
# It was created w/ activated=True OR
# It was changed from activated=False to activated=True
# We use django-dirtyfields to detect changes to fields
is_activated = self.activated and (is_new or not self.get_dirty_fields().get("activated", False))
super().save(*args, **kwargs)
# When a membership is activated, we should create service requests
if is_activated:
ServiceRequest.create_defaults(membership=self)
class MembershipType(CreatedModifiedAbstract):
"""A membership type.
@ -246,30 +258,3 @@ class WaitingListEntry(CreatedModifiedAbstract):
class Meta:
verbose_name = _("waiting list entry")
verbose_name_plural = _("waiting list entries")
class ServiceAccess(CreatedModifiedAbstract):
"""Access to a service for a user."""
class Meta:
verbose_name = _("service access")
verbose_name_plural = _("service accesses")
constraints = (
models.UniqueConstraint(
fields=["user", "service"],
name="unique_user_service",
),
)
user = models.ForeignKey("auth.User", on_delete=models.PROTECT)
service = ServiceRegistry.choices_field(verbose_name=_("service"))
subscription_data = models.JSONField(
verbose_name=_("subscription data"),
null=True,
blank=True,
)
def __str__(self) -> str:
return f"{self.user} - {self.service}"

View file

@ -197,6 +197,13 @@ LOGGING = {
STRIPE_API_KEY = env.str("STRIPE_API_KEY", default="")
STRIPE_ENDPOINT_SECRET = env.str("STRIPE_ENDPOINT_SECRET", default="")
MATRIX_ACCESS_TOKEN = env.str("MATRIX_ACCESS_TOKEN", default="")
MATRIX_SERVICE_REQUEST_ADMIN_ROOM = env.str(
"MATRIX_SERVICE_REQUEST_ADMIN_ROOM",
default="https://matrix.data.coop/_matrix/client/r0/rooms/!wbQCiQKeqangsuUQWm:data.coop/",
)
# The number of seconds a password reset link is valid for (default: 3 days).
# We've extended this to 7 days because invites then last for 1 week.
PASSWORD_RESET_TIMEOUT = 60 * 60 * 24 * 7

View file

@ -1,13 +1,12 @@
"""Project views."""
from __future__ import annotations
from membership.models import ServiceAccess
from services.registry import ServiceRegistry
from utils.view_utils import render
from typing import TYPE_CHECKING
from accounting.models import Order
from django_view_decorator import view
from utils.view_utils import render
if TYPE_CHECKING:
from django.http import HttpRequest

View file

@ -1 +1,15 @@
# Register your models here.
from django.contrib import admin
from services.models import ServiceAccess
from services.models import ServiceRequest
@admin.register(ServiceRequest)
class ServiceRequestAdmin(admin.ModelAdmin):
list_display = ("member", "service", "request", "status")
list_filter = ("request", "status")
@admin.register(ServiceAccess)
class ServiceAccessAdmin(admin.ModelAdmin):
"""Admin for ServiceAccess model."""

View file

@ -0,0 +1,36 @@
# Generated by Django 5.1rc1 on 2024-12-24 17:32
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('membership', '0012_alter_membership_options_alter_serviceaccess_service'),
]
operations = [
migrations.CreateModel(
name='ServiceRequest',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('service', models.CharField(choices=[])),
('request', models.CharField(choices=[('CREATION', 'Creation'), ('PASSWORD_RESET', 'Password reset'), ('DELETION', 'Deletion')])),
('is_auto_created', models.BooleanField(default=False)),
('status', models.CharField(choices=[('NEW', 'New'), ('RESOLVED', 'Resolved')], default='NEW', max_length=24)),
('member_notes', models.TextField(blank=True, help_text='Notes from the member, intended to guide the resolution of the request.')),
('admin_notes', models.TextField(blank=True, help_text='Readable by member: Notes from the admin / status updates, resolutions etc.')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='membership.member')),
],
options={
'verbose_name': 'Service Request',
'verbose_name_plural': 'Service Requests',
'constraints': [models.CheckConstraint(condition=models.Q(('status__in', ['NEW', 'RESOLVED'])), name='services_servicerequest_status_valid')],
},
),
]

View file

@ -0,0 +1,52 @@
# Generated by Django 5.1.4 on 2024-12-26 19:45
import django.db.models.deletion
import django_registries.registry
import services.registry
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('membership', '0013_delete_serviceaccess'),
('services', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='servicerequest',
options={'verbose_name': 'service request', 'verbose_name_plural': 'service requests'},
),
migrations.AlterField(
model_name='servicerequest',
name='member',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='service_requests', to='membership.member'),
),
migrations.AlterField(
model_name='servicerequest',
name='request',
field=models.CharField(choices=[('CREATION', 'Creation'), ('PASSWORD_RESET', 'Password reset'), ('DELETION', 'Deletion')], max_length=24),
),
migrations.AlterField(
model_name='servicerequest',
name='service',
field=django_registries.registry.ChoicesField(choices=[('forgejo', 'forgejo'), ('hedgedoc', 'hedgedoc'), ('mail', 'mail'), ('mastodon', 'mastodon'), ('matrix', 'matrix'), ('nextcloud', 'nextcloud'), ('rallly', 'rallly')], registry=services.registry.ServiceRegistry),
),
migrations.CreateModel(
name='ServiceAccess',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('service', django_registries.registry.ChoicesField(choices=[('forgejo', 'forgejo'), ('hedgedoc', 'hedgedoc'), ('mail', 'mail'), ('mastodon', 'mastodon'), ('matrix', 'matrix'), ('nextcloud', 'nextcloud'), ('rallly', 'rallly')], registry=services.registry.ServiceRegistry, verbose_name='service')),
('subscription_data', models.JSONField(blank=True, null=True, verbose_name='subscription data')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='membership.member')),
],
options={
'verbose_name': 'service access',
'verbose_name_plural': 'service accesses',
'unique_together': {('member', 'service')},
},
),
]

View file

@ -1 +1,110 @@
# Create your models here.
import typing
from django.contrib.sites.models import Site
from django.db import models
from django.db.models import TextChoices
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from utils.matrix import notify_admins
from utils.mixins import CreatedModifiedAbstract
from .registry import ServiceRegistry
from .registry import ServiceRequests
if typing.TYPE_CHECKING:
from membership.models import Membership
class ServiceRequestStatus(TextChoices):
NEW = "NEW", _("New")
RESOLVED = "RESOLVED", _("Resolved")
class ServiceAccess(CreatedModifiedAbstract):
"""Access to a service for a user."""
member = models.ForeignKey("membership.Member", on_delete=models.PROTECT)
service = ServiceRegistry.choices_field(verbose_name=_("service"))
subscription_data = models.JSONField(
verbose_name=_("subscription data"),
null=True,
blank=True,
)
class Meta:
verbose_name = _("service access")
verbose_name_plural = _("service accesses")
constraints = (
models.UniqueConstraint(
fields=["member", "service"],
name="unique_user_service",
),
)
def __str__(self) -> str:
return f"{self.member} - {self.service}"
def save(self, *args, **kwargs) -> None:
"""Ensure that existing ServiceRequest objects are automatically resolved."""
is_new = not self.pk
super().save(*args, **kwargs)
# When a new Service Access is created for a user, we should ensure that all Service Requests for this service
# are set as RESOLVED.
if is_new:
self.member.service_requests.filter(
service=self.service, request=ServiceRequests.CREATION, status=ServiceRequestStatus.NEW
).update(status=ServiceRequestStatus.RESOLVED)
class ServiceRequest(CreatedModifiedAbstract):
member = models.ForeignKey("membership.Member", on_delete=models.CASCADE, related_name="service_requests")
service = ServiceRegistry.choices_field()
request = models.CharField(max_length=24, choices=ServiceRequests.choices)
is_auto_created = models.BooleanField(default=False)
status = models.CharField(max_length=24, choices=ServiceRequestStatus.choices, default=ServiceRequestStatus.NEW)
member_notes = models.TextField(
blank=True, help_text=_("Notes from the member, intended to guide the resolution of the request.")
)
admin_notes = models.TextField(
blank=True, help_text=_("Readable by member: Notes from the admin / status updates, resolutions etc.")
)
class Meta:
verbose_name = _("service request")
verbose_name_plural = _("service requests")
constraints = (
models.CheckConstraint(
name="%(app_label)s_%(class)s_status_valid",
condition=models.Q(status__in=ServiceRequestStatus.values),
),
)
@classmethod
def create_defaults(cls, membership: "Membership") -> None:
"""Ensure that a membership has service requests for all 'default' (auto_create=True) services."""
services_with_access = [
sa.service for sa in ServiceAccess.objects.filter(member=membership.user).values("service")
]
for __, service in ServiceRegistry.get_items():
if service.auto_create and service not in services_with_access:
# Use get_or_create so we don't end up with multiple requests for the same service+user
cls.objects.get_or_create(
member=membership.user, service=service.slug, request=ServiceRequests.CREATION
)
def save(self, *args, **kwargs) -> None:
"""Create notifications when new service requests are added."""
is_new = not self.pk
super().save(*args, **kwargs)
# When a new Service Request is saved, we should send a notification to admins
if is_new:
base_domain = Site.objects.get_current()
change_url = reverse("admin:services_servicerequest_change", kwargs={"object_id": self.pk})
notify_admins(f"New service request: https://{base_domain}{change_url}")

View file

@ -1,6 +1,8 @@
"""Registry for services."""
from django import forms
from django.db.models import TextChoices
from django.utils.translation import gettext_lazy as _
from django_registries.registry import Interface
from django_registries.registry import Registry
@ -11,6 +13,15 @@ class ServiceRegistry(Registry):
implementations_module = "services"
class ServiceRequests(TextChoices):
CREATION = "CREATION", _("Creation")
PASSWORD_RESET = "PASSWORD_RESET", _("Password reset")
DELETION = "DELETION", _("Deletion")
DEFAULT_SERVICE_REQUEST_TYPES = [ServiceRequests.CREATION, ServiceRequests.PASSWORD_RESET, ServiceRequests.DELETION]
class ServiceInterface(Interface):
"""Interface for services."""
@ -22,6 +33,12 @@ class ServiceInterface(Interface):
public: bool = False
# An auto-created service is added for all new members.
# This is a way of saying that the service is "mandatory" to have.
auto_create: bool = False
request_types: list[str, str] = DEFAULT_SERVICE_REQUEST_TYPES
# TODO: add a way to add a something which defines the required fields for a service
# - maybe a list of tuples with the field name and the type of the field
# this could be used to generate a form for the service, and also to validate
@ -36,3 +53,6 @@ class ServiceInterface(Interface):
(forms.Form,),
dict(self.subscribe_fields),
)
def __str__(self) -> str:
return self.name

View file

@ -26,6 +26,8 @@ class MatrixService(ServiceInterface):
subscribe_fields = (("username", forms.CharField()),)
auto_create = True
class MastodonService(ServiceInterface):
"""Mastodon service."""
@ -64,6 +66,7 @@ class ForgejoService(ServiceInterface):
name = "Forgejo"
url = "https://git.data.coop"
description = "Git service for data.coop"
auto_create = True
class RalllyService(ServiceInterface):

View file

@ -1 +0,0 @@
# Create your tests here.

View file

@ -6,9 +6,9 @@ from typing import TYPE_CHECKING
from django.shortcuts import redirect
from django_view_decorator import namespaced_decorator_factory
from membership.models import ServiceAccess
from utils.view_utils import render
from services.models import ServiceAccess
from services.registry import ServiceInterface
from services.registry import ServiceRegistry

25
src/utils/matrix.py Normal file
View file

@ -0,0 +1,25 @@
from urllib.parse import urljoin
import httpx
from django.conf import settings
def notify_admins(message: str) -> None:
"""Notify admins on their own Matrix channel."""
return notify_matrix_channel(settings.MATRIX_SERVICE_REQUEST_ADMIN_ROOM, message)
def notify_matrix_channel(channel_url: str, message: str) -> None:
"""Send a message to a matrix channel."""
result = httpx.post(
urljoin(channel_url, "send/m.room.message"),
json={
"msgtype": "m.text",
"body": message,
},
params={
"access_token": settings.MATRIX_ACCESS_TOKEN,
},
)
if settings.MATRIX_ACCESS_TOKEN and not settings.DEBUG:
result.raise_for_status()

0
tests/__init__.py Normal file
View file

41
tests/conftest.py Normal file
View file

@ -0,0 +1,41 @@
from datetime import timedelta
from unittest import mock
import pytest
from django.db.backends.postgresql.psycopg_any import DateRange
from django.utils import timezone
from membership.models import Member
from membership.models import Membership
from membership.models import MembershipType
from membership.models import SubscriptionPeriod
@pytest.fixture()
def membership_type():
return MembershipType.objects.create(name="Test Membership Type")
@pytest.fixture()
def current_period():
SubscriptionPeriod.objects.create(
period=DateRange(timezone.now().date() - timedelta(days=182), timezone.now().date() + timedelta(days=183))
)
return SubscriptionPeriod.objects.current()
@pytest.fixture()
def active_membership(membership_type, current_period):
member = Member.objects.create_user("test", "lala@adas.com", "1234")
membership = Membership.objects.create(
user=member,
membership_type=membership_type,
period=current_period,
activated=True,
)
return membership
@pytest.fixture(autouse=True)
def mock_matrix_notify():
with mock.patch("utils.matrix.httpx.post"):
yield

1
tests/settings.py Normal file
View file

@ -0,0 +1 @@
from project.settings import * # noqa

View file

@ -1,12 +1,7 @@
import pytest
from accounting import models
from django.contrib.auth.models import User
from . import models
# @pytest.fixture
# def test():
# do stuff
@pytest.mark.django_db()
def test_balance() -> None:

12
tests/test_services.py Normal file
View file

@ -0,0 +1,12 @@
import pytest
from membership.models import Membership
from services.models import ServiceRequest
from services.models import ServiceRequestStatus
@pytest.mark.django_db()
def test_membership_activation(active_membership: Membership):
assert ServiceRequest.objects.filter(
member=active_membership.user,
status=ServiceRequestStatus.NEW,
).exists()

291
uv.lock generated
View file

@ -1,6 +1,20 @@
version = 1
requires-python = ">=3.11"
[[package]]
name = "anyio"
version = "4.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/40/318e58f669b1a9e00f5c4453910682e2d9dd594334539c7b7817dabb765f/anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48", size = 177076 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/7a/4daaf3b6c08ad7ceffea4634ec206faeff697526421c20f07628c7372156/anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352", size = 93052 },
]
[[package]]
name = "asgiref"
version = "3.8.1"
@ -10,15 +24,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 },
]
[[package]]
name = "attrs"
version = "24.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/48/c8/6260f8ccc11f0917360fc0da435c5c9c7504e3db174d5a12a1494887b045/attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", size = 805984 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/aa/ab0f7891a01eeb2d2e338ae8fecbe57fcebea1a24dbb64d45801bfab481d/attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397 },
]
[[package]]
name = "babel"
version = "2.16.0"
@ -28,20 +33,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/20/bc79bc575ba2e2a7f70e8a1155618bb1301eaa5132a8271373a6903f73f8/babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", size = 9587599 },
]
[[package]]
name = "build"
version = "1.2.2.post1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "os_name == 'nt'" },
{ name = "packaging" },
{ name = "pyproject-hooks" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/46/aeab111f8e06793e4f0e421fcad593d547fb8313b50990f31681ee2fb1ad/build-1.2.2.post1.tar.gz", hash = "sha256:b36993e92ca9375a219c99e606a122ff365a760a2d4bba0caa09bd5278b608b7", size = 46701 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/c2/80633736cd183ee4a62107413def345f7e6e3c01563dbca1417363cf957e/build-1.2.2.post1-py3-none-any.whl", hash = "sha256:1d61c0887fa860c01971625baae8bdd338e517b836a2f70dd1f7aa3a6b2fc5b5", size = 22950 },
]
[[package]]
name = "certifi"
version = "2024.12.14"
@ -167,30 +158,50 @@ wheels = [
[[package]]
name = "coverage"
version = "7.3.0"
version = "7.6.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/87/c0163d39ac70cab62ebcaee164c988215cd312919a78940c2251a2fcfabb/coverage-7.3.0.tar.gz", hash = "sha256:49dbb19cdcafc130f597d9e04a29d0a032ceedf729e41b181f51cd170e6ee865", size = 763902 }
sdist = { url = "https://files.pythonhosted.org/packages/84/ba/ac14d281f80aab516275012e8875991bb06203957aa1e19950139238d658/coverage-7.6.10.tar.gz", hash = "sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23", size = 803868 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/c5/c94da7b5ee14a0e7b046b2d59b50fe37d50ae78046e3459639961d3dccf5/coverage-7.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:74c160285f2dfe0acf0f72d425f3e970b21b6de04157fc65adc9fd07ee44177f", size = 201209 },
{ url = "https://files.pythonhosted.org/packages/56/61/0bc551ef5e4cd459c34e769969b080d667ea9b2b3265819d4ae1f8d07702/coverage-7.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b543302a3707245d454fc49b8ecd2c2d5982b50eb63f3535244fd79a4be0c99d", size = 201423 },
{ url = "https://files.pythonhosted.org/packages/7a/6b/f16c757f34adaf76413b061ff412d599958a299dba5dfb9371e5567b77d9/coverage-7.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad0f87826c4ebd3ef484502e79b39614e9c03a5d1510cfb623f4a4a051edc6fd", size = 233474 },
{ url = "https://files.pythonhosted.org/packages/62/b9/de6fc3a608b4c0438b96e120fe83304d39b6be640b14363004843602118d/coverage-7.3.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13c6cbbd5f31211d8fdb477f0f7b03438591bdd077054076eec362cf2207b4a7", size = 231048 },
{ url = "https://files.pythonhosted.org/packages/55/63/f2dcc8f7f1587ae54bf8cc1c3b08e07e442633a953537dfaf658a0cbac2c/coverage-7.3.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fac440c43e9b479d1241fe9d768645e7ccec3fb65dc3a5f6e90675e75c3f3e3a", size = 232856 },
{ url = "https://files.pythonhosted.org/packages/44/39/809e546b31d871e9636315d0097891ae3177e0f6da2021c489f64dbe00b7/coverage-7.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3c9834d5e3df9d2aba0275c9f67989c590e05732439b3318fa37a725dff51e74", size = 241805 },
{ url = "https://files.pythonhosted.org/packages/2a/b2/f2b519d33ececf73cf3d616fc7d051a73aa9609859fde376e902d79b69ce/coverage-7.3.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4c8e31cf29b60859876474034a83f59a14381af50cbe8a9dbaadbf70adc4b214", size = 240219 },
{ url = "https://files.pythonhosted.org/packages/c5/ad/1559ab85952a47531004f9a32bcac51f9755e9541fb03eae42a9358e00dd/coverage-7.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7a9baf8e230f9621f8e1d00c580394a0aa328fdac0df2b3f8384387c44083c0f", size = 241271 },
{ url = "https://files.pythonhosted.org/packages/32/5a/d8e474e01fde6511bf8354df005248aeb2e3a71dacfe1624fbc2916a15f4/coverage-7.3.0-cp311-cp311-win32.whl", hash = "sha256:ccc51713b5581e12f93ccb9c5e39e8b5d4b16776d584c0f5e9e4e63381356482", size = 203467 },
{ url = "https://files.pythonhosted.org/packages/b1/cb/48d62b864e408bea2608b4ce19ba1feba0ffbf5a03640cf024cb3122e895/coverage-7.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:887665f00ea4e488501ba755a0e3c2cfd6278e846ada3185f42d391ef95e7e70", size = 204490 },
{ url = "https://files.pythonhosted.org/packages/dd/53/2de98835e2976d042fd30967e6b00d57e688cfcc17ad10f11dc2c307ec9c/coverage-7.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d000a739f9feed900381605a12a61f7aaced6beae832719ae0d15058a1e81c1b", size = 201331 },
{ url = "https://files.pythonhosted.org/packages/9b/01/49a4f47d87acc3be6cd0013c33b7ef6e1acc13f67ac9ff2fd1f7d73b4b12/coverage-7.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:59777652e245bb1e300e620ce2bef0d341945842e4eb888c23a7f1d9e143c446", size = 201429 },
{ url = "https://files.pythonhosted.org/packages/05/1d/45d448cfa9cdf7aea9ec49711a143c82afc793e9542f9ba9e3f5b83c4d4d/coverage-7.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9737bc49a9255d78da085fa04f628a310c2332b187cd49b958b0e494c125071", size = 234294 },
{ url = "https://files.pythonhosted.org/packages/a9/43/29bb5ceabd87bdff07ac29333a68828f210e7c2e928c85464e9264f7a8df/coverage-7.3.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5247bab12f84a1d608213b96b8af0cbb30d090d705b6663ad794c2f2a5e5b9fe", size = 231652 },
{ url = "https://files.pythonhosted.org/packages/82/a6/194198e62702d82ee581a035fcc5032a7bebc0264eb5ebffb466c6b5b4ea/coverage-7.3.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2ac9a1de294773b9fa77447ab7e529cf4fe3910f6a0832816e5f3d538cfea9a", size = 233627 },
{ url = "https://files.pythonhosted.org/packages/5b/5b/4e7ec6cc17a0cb4afc1aa99e6877d5e2c6377cdfeac67dba39643e1d4809/coverage-7.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:85b7335c22455ec12444cec0d600533a238d6439d8d709d545158c1208483873", size = 240463 },
{ url = "https://files.pythonhosted.org/packages/d1/6b/b7f5e6e7ae64f0b8795dfb499ba73a5bae66131b518c1e5c448fb838d3c9/coverage-7.3.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:36ce5d43a072a036f287029a55b5c6a0e9bd73db58961a273b6dc11a2c6eb9c2", size = 238427 },
{ url = "https://files.pythonhosted.org/packages/01/40/a0f76d77a9a64947fc3dac90b0f62fbd7f4d02e62d10a7126f6785eb2cbe/coverage-7.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:211a4576e984f96d9fce61766ffaed0115d5dab1419e4f63d6992b480c2bd60b", size = 240139 },
{ url = "https://files.pythonhosted.org/packages/e3/b9/6244d38d1574bd13995025802dbc5577acd5aab143e53ddecc087d485a30/coverage-7.3.0-cp312-cp312-win32.whl", hash = "sha256:56afbf41fa4a7b27f6635bc4289050ac3ab7951b8a821bca46f5b024500e6321", size = 203768 },
{ url = "https://files.pythonhosted.org/packages/17/11/48d4804db0f3b0277a857b57ade93f03cb9f2afbce0e07c208a9f9b01805/coverage-7.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f297e0c1ae55300ff688568b04ff26b01c13dfbf4c9d2b7d0cb688ac60df479", size = 204653 },
{ url = "https://files.pythonhosted.org/packages/85/d2/5e175fcf6766cf7501a8541d81778fd2f52f4870100e791f5327fd23270b/coverage-7.6.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ea3c8f04b3e4af80e17bab607c386a830ffc2fb88a5484e1df756478cf70d1d3", size = 208088 },
{ url = "https://files.pythonhosted.org/packages/4b/6f/06db4dc8fca33c13b673986e20e466fd936235a6ec1f0045c3853ac1b593/coverage-7.6.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:507a20fc863cae1d5720797761b42d2d87a04b3e5aeb682ef3b7332e90598f43", size = 208536 },
{ url = "https://files.pythonhosted.org/packages/0d/62/c6a0cf80318c1c1af376d52df444da3608eafc913b82c84a4600d8349472/coverage-7.6.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d37a84878285b903c0fe21ac8794c6dab58150e9359f1aaebbeddd6412d53132", size = 240474 },
{ url = "https://files.pythonhosted.org/packages/a3/59/750adafc2e57786d2e8739a46b680d4fb0fbc2d57fbcb161290a9f1ecf23/coverage-7.6.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a534738b47b0de1995f85f582d983d94031dffb48ab86c95bdf88dc62212142f", size = 237880 },
{ url = "https://files.pythonhosted.org/packages/2c/f8/ef009b3b98e9f7033c19deb40d629354aab1d8b2d7f9cfec284dbedf5096/coverage-7.6.10-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d7a2bf79378d8fb8afaa994f91bfd8215134f8631d27eba3e0e2c13546ce994", size = 239750 },
{ url = "https://files.pythonhosted.org/packages/a6/e2/6622f3b70f5f5b59f705e680dae6db64421af05a5d1e389afd24dae62e5b/coverage-7.6.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6713ba4b4ebc330f3def51df1d5d38fad60b66720948112f114968feb52d3f99", size = 238642 },
{ url = "https://files.pythonhosted.org/packages/2d/10/57ac3f191a3c95c67844099514ff44e6e19b2915cd1c22269fb27f9b17b6/coverage-7.6.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab32947f481f7e8c763fa2c92fd9f44eeb143e7610c4ca9ecd6a36adab4081bd", size = 237266 },
{ url = "https://files.pythonhosted.org/packages/ee/2d/7016f4ad9d553cabcb7333ed78ff9d27248ec4eba8dd21fa488254dff894/coverage-7.6.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7bbd8c8f1b115b892e34ba66a097b915d3871db7ce0e6b9901f462ff3a975377", size = 238045 },
{ url = "https://files.pythonhosted.org/packages/a7/fe/45af5c82389a71e0cae4546413266d2195c3744849669b0bab4b5f2c75da/coverage-7.6.10-cp311-cp311-win32.whl", hash = "sha256:299e91b274c5c9cdb64cbdf1b3e4a8fe538a7a86acdd08fae52301b28ba297f8", size = 210647 },
{ url = "https://files.pythonhosted.org/packages/db/11/3f8e803a43b79bc534c6a506674da9d614e990e37118b4506faf70d46ed6/coverage-7.6.10-cp311-cp311-win_amd64.whl", hash = "sha256:489a01f94aa581dbd961f306e37d75d4ba16104bbfa2b0edb21d29b73be83609", size = 211508 },
{ url = "https://files.pythonhosted.org/packages/86/77/19d09ea06f92fdf0487499283b1b7af06bc422ea94534c8fe3a4cd023641/coverage-7.6.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853", size = 208281 },
{ url = "https://files.pythonhosted.org/packages/b6/67/5479b9f2f99fcfb49c0d5cf61912a5255ef80b6e80a3cddba39c38146cf4/coverage-7.6.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078", size = 208514 },
{ url = "https://files.pythonhosted.org/packages/15/d1/febf59030ce1c83b7331c3546d7317e5120c5966471727aa7ac157729c4b/coverage-7.6.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0", size = 241537 },
{ url = "https://files.pythonhosted.org/packages/4b/7e/5ac4c90192130e7cf8b63153fe620c8bfd9068f89a6d9b5f26f1550f7a26/coverage-7.6.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50", size = 238572 },
{ url = "https://files.pythonhosted.org/packages/dc/03/0334a79b26ecf59958f2fe9dd1f5ab3e2f88db876f5071933de39af09647/coverage-7.6.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022", size = 240639 },
{ url = "https://files.pythonhosted.org/packages/d7/45/8a707f23c202208d7b286d78ad6233f50dcf929319b664b6cc18a03c1aae/coverage-7.6.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b", size = 240072 },
{ url = "https://files.pythonhosted.org/packages/66/02/603ce0ac2d02bc7b393279ef618940b4a0535b0868ee791140bda9ecfa40/coverage-7.6.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0", size = 238386 },
{ url = "https://files.pythonhosted.org/packages/04/62/4e6887e9be060f5d18f1dd58c2838b2d9646faf353232dec4e2d4b1c8644/coverage-7.6.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852", size = 240054 },
{ url = "https://files.pythonhosted.org/packages/5c/74/83ae4151c170d8bd071924f212add22a0e62a7fe2b149edf016aeecad17c/coverage-7.6.10-cp312-cp312-win32.whl", hash = "sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359", size = 210904 },
{ url = "https://files.pythonhosted.org/packages/c3/54/de0893186a221478f5880283119fc40483bc460b27c4c71d1b8bba3474b9/coverage-7.6.10-cp312-cp312-win_amd64.whl", hash = "sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247", size = 211692 },
{ url = "https://files.pythonhosted.org/packages/25/6d/31883d78865529257bf847df5789e2ae80e99de8a460c3453dbfbe0db069/coverage-7.6.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05fca8ba6a87aabdd2d30d0b6c838b50510b56cdcfc604d40760dae7153b73d9", size = 208308 },
{ url = "https://files.pythonhosted.org/packages/70/22/3f2b129cc08de00c83b0ad6252e034320946abfc3e4235c009e57cfeee05/coverage-7.6.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e80eba8801c386f72e0712a0453431259c45c3249f0009aff537a517b52942b", size = 208565 },
{ url = "https://files.pythonhosted.org/packages/97/0a/d89bc2d1cc61d3a8dfe9e9d75217b2be85f6c73ebf1b9e3c2f4e797f4531/coverage-7.6.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a372c89c939d57abe09e08c0578c1d212e7a678135d53aa16eec4430adc5e690", size = 241083 },
{ url = "https://files.pythonhosted.org/packages/4c/81/6d64b88a00c7a7aaed3a657b8eaa0931f37a6395fcef61e53ff742b49c97/coverage-7.6.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec22b5e7fe7a0fa8509181c4aac1db48f3dd4d3a566131b313d1efc102892c18", size = 238235 },
{ url = "https://files.pythonhosted.org/packages/9a/0b/7797d4193f5adb4b837207ed87fecf5fc38f7cc612b369a8e8e12d9fa114/coverage-7.6.10-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26bcf5c4df41cad1b19c84af71c22cbc9ea9a547fc973f1f2cc9a290002c8b3c", size = 240220 },
{ url = "https://files.pythonhosted.org/packages/65/4d/6f83ca1bddcf8e51bf8ff71572f39a1c73c34cf50e752a952c34f24d0a60/coverage-7.6.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e4630c26b6084c9b3cb53b15bd488f30ceb50b73c35c5ad7871b869cb7365fd", size = 239847 },
{ url = "https://files.pythonhosted.org/packages/30/9d/2470df6aa146aff4c65fee0f87f58d2164a67533c771c9cc12ffcdb865d5/coverage-7.6.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2396e8116db77789f819d2bc8a7e200232b7a282c66e0ae2d2cd84581a89757e", size = 237922 },
{ url = "https://files.pythonhosted.org/packages/08/dd/723fef5d901e6a89f2507094db66c091449c8ba03272861eaefa773ad95c/coverage-7.6.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79109c70cc0882e4d2d002fe69a24aa504dec0cc17169b3c7f41a1d341a73694", size = 239783 },
{ url = "https://files.pythonhosted.org/packages/3d/f7/64d3298b2baf261cb35466000628706ce20a82d42faf9b771af447cd2b76/coverage-7.6.10-cp313-cp313-win32.whl", hash = "sha256:9e1747bab246d6ff2c4f28b4d186b205adced9f7bd9dc362051cc37c4a0c7bd6", size = 210965 },
{ url = "https://files.pythonhosted.org/packages/d5/58/ec43499a7fc681212fe7742fe90b2bc361cdb72e3181ace1604247a5b24d/coverage-7.6.10-cp313-cp313-win_amd64.whl", hash = "sha256:254f1a3b1eef5f7ed23ef265eaa89c65c8c5b6b257327c149db1ca9d4a35f25e", size = 211719 },
{ url = "https://files.pythonhosted.org/packages/ab/c9/f2857a135bcff4330c1e90e7d03446b036b2363d4ad37eb5e3a47bbac8a6/coverage-7.6.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ccf240eb719789cedbb9fd1338055de2761088202a9a0b73032857e53f612fe", size = 209050 },
{ url = "https://files.pythonhosted.org/packages/aa/b3/f840e5bd777d8433caa9e4a1eb20503495709f697341ac1a8ee6a3c906ad/coverage-7.6.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0c807ca74d5a5e64427c8805de15b9ca140bba13572d6d74e262f46f50b13273", size = 209321 },
{ url = "https://files.pythonhosted.org/packages/85/7d/125a5362180fcc1c03d91850fc020f3831d5cda09319522bcfa6b2b70be7/coverage-7.6.10-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bcfa46d7709b5a7ffe089075799b902020b62e7ee56ebaed2f4bdac04c508d8", size = 252039 },
{ url = "https://files.pythonhosted.org/packages/a9/9c/4358bf3c74baf1f9bddd2baf3756b54c07f2cfd2535f0a47f1e7757e54b3/coverage-7.6.10-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e0de1e902669dccbf80b0415fb6b43d27edca2fbd48c74da378923b05316098", size = 247758 },
{ url = "https://files.pythonhosted.org/packages/cf/c7/de3eb6fc5263b26fab5cda3de7a0f80e317597a4bad4781859f72885f300/coverage-7.6.10-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7b444c42bbc533aaae6b5a2166fd1a797cdb5eb58ee51a92bee1eb94a1e1cb", size = 250119 },
{ url = "https://files.pythonhosted.org/packages/3e/e6/43de91f8ba2ec9140c6a4af1102141712949903dc732cf739167cfa7a3bc/coverage-7.6.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b330368cb99ef72fcd2dc3ed260adf67b31499584dc8a20225e85bfe6f6cfed0", size = 249597 },
{ url = "https://files.pythonhosted.org/packages/08/40/61158b5499aa2adf9e37bc6d0117e8f6788625b283d51e7e0c53cf340530/coverage-7.6.10-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9a7cfb50515f87f7ed30bc882f68812fd98bc2852957df69f3003d22a2aa0abf", size = 247473 },
{ url = "https://files.pythonhosted.org/packages/50/69/b3f2416725621e9f112e74e8470793d5b5995f146f596f133678a633b77e/coverage-7.6.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f93531882a5f68c28090f901b1d135de61b56331bba82028489bc51bdd818d2", size = 248737 },
{ url = "https://files.pythonhosted.org/packages/3c/6e/fe899fb937657db6df31cc3e61c6968cb56d36d7326361847440a430152e/coverage-7.6.10-cp313-cp313t-win32.whl", hash = "sha256:89d76815a26197c858f53c7f6a656686ec392b25991f9e409bcef020cd532312", size = 211611 },
{ url = "https://files.pythonhosted.org/packages/1c/55/52f5e66142a9d7bc93a15192eba7a78513d2abf6b3558d77b4ca32f5f424/coverage-7.6.10-cp313-cp313t-win_amd64.whl", hash = "sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d", size = 212781 },
]
[package.optional-dependencies]
@ -276,14 +287,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/a1/6f/3ccc016b901d7e5a1
[[package]]
name = "django-browser-reload"
version = "1.7.0"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/73/1f/aceee2f52d4c1d68289faac23ee7b5ad4597bd637aa17f41c10f9c59fe55/django_browser_reload-1.7.0.tar.gz", hash = "sha256:712d0a4d6caa6833c8c205d4ce177b474feb45583cd6ee684c9ea7b71dd921b6", size = 15924 }
sdist = { url = "https://files.pythonhosted.org/packages/9f/bc/3c67f7daca53b826ec51888576fe5e117d9442d2d0acb58f4264d48b9dba/django_browser_reload-1.17.0.tar.gz", hash = "sha256:3667939cde0eee1a6d698dbe3b78cf10b573dabc4e711fb7933f1ba91fb98da4", size = 14312 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/56/12a04d5d34acd8dd7e01b6268a1d341220d78280b63115a5ef6470233103/django_browser_reload-1.7.0-py3-none-any.whl", hash = "sha256:0d7cc4308ebbdf9b5637e28ee7b41ae28d4fcdf350d55ce007515b44cd10291b", size = 12070 },
{ url = "https://files.pythonhosted.org/packages/7f/8f/62fc4fbf5c05c2210e6cb616f1c2a3da53871dfecbaa4c44b1f482ca3e8f/django_browser_reload-1.17.0-py3-none-any.whl", hash = "sha256:d372c12c1c5962c02279a53cac7e8a020c48f104592c637a06d0768b28d2d6be", size = 12228 },
]
[[package]]
@ -297,15 +309,27 @@ wheels = [
[[package]]
name = "django-debug-toolbar"
version = "4.2.0"
version = "4.4.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "sqlparse" },
]
sdist = { url = "https://files.pythonhosted.org/packages/65/bd/81b812b3a69874f382514a8982f1e7c30e9851e86c9d976eef4960da97ac/django_debug_toolbar-4.2.0.tar.gz", hash = "sha256:bc7fdaafafcdedefcc67a4a5ad9dac96efd6e41db15bc74d402a54a2ba4854dc", size = 259709 }
sdist = { url = "https://files.pythonhosted.org/packages/d4/9c/0a3238eda0a46df20f2e3fe2a30313d34f5042a1a737d08230b77c29a3e9/django_debug_toolbar-4.4.6.tar.gz", hash = "sha256:36e421cb908c2f0675e07f9f41e3d1d8618dc386392ec82d23bcfcd5d29c7044", size = 272610 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/5c/fffae0d49d0b9d6a0782540e172272edf70493588ec9fca10b01a3a75c3e/django_debug_toolbar-4.2.0-py3-none-any.whl", hash = "sha256:af99128c06e8e794479e65ab62cc6c7d1e74e1c19beb44dcbf9bad7a9c017327", size = 223156 },
{ url = "https://files.pythonhosted.org/packages/2f/33/2036a472eedfbe49240dffea965242b3f444de4ea4fbeceb82ccea33a2ce/django_debug_toolbar-4.4.6-py3-none-any.whl", hash = "sha256:3beb671c9ec44ffb817fad2780667f172bd1c067dbcabad6268ce39a81335f45", size = 229621 },
]
[[package]]
name = "django-dirtyfields"
version = "1.9.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/c6/5f9a642ad623f8a66e538aadfa002a99561e794b20539575ac50c72bfda3/django_dirtyfields-1.9.5.tar.gz", hash = "sha256:c316ab2e12cfc9da16e714007f0b313d35c3216f6b90b7b1d66c50bf1f4f3f19", size = 21664 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/33/98b2d301e9167ca5bca286a2a153d0640a49e96fdb584e77cecf726afbd6/django_dirtyfields-1.9.5-py3-none-any.whl", hash = "sha256:d544e648df2f13256683c3e20b93f61672b63087acf3f81ee8cedb4b0482a8c1", size = 8445 },
]
[[package]]
@ -361,20 +385,23 @@ wheels = [
[[package]]
name = "django-stubs"
version = "1.16.0"
version = "5.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "django" },
{ name = "django-stubs-ext" },
{ name = "mypy" },
{ name = "tomli" },
{ name = "types-pytz" },
{ name = "types-pyyaml" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8c/a8/0e2323bf6fd080623976006860421d47ca4a6d5d21e27980010c187a05ad/django-stubs-1.16.0.tar.gz", hash = "sha256:1bd96207576cd220221a0e615f0259f13d453d515a80f576c1246e0fb547f561", size = 236630 }
sdist = { url = "https://files.pythonhosted.org/packages/bf/60/1ae90eb6e2e107bc64a3de9de78a5add7f3b85e491113504eed38d6d2c63/django_stubs-5.1.1.tar.gz", hash = "sha256:126d354bbdff4906c4e93e6361197f6fbfb6231c3df6def85a291dae6f9f577b", size = 265624 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/3e/9c1a097b80002e340d5b3f4b6777a79501a1a5cba29a18f137b9fb91aa91/django_stubs-1.16.0-py3-none-any.whl", hash = "sha256:c95f948e2bfc565f3147e969ff361ef033841a0b8a51cac974a6cc6d0486732c", size = 432684 },
{ url = "https://files.pythonhosted.org/packages/98/c8/3081d5f994351248fcd60f9aab10cb2020bdd7df0f14e80854373e15d7d4/django_stubs-5.1.1-py3-none-any.whl", hash = "sha256:c4dc64260bd72e6d32b9e536e8dd0d9247922f0271f82d1d5132a18f24b388ac", size = 470790 },
]
[package.optional-dependencies]
compatible-mypy = [
{ name = "mypy" },
]
[[package]]
@ -443,6 +470,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 },
]
[[package]]
name = "httpcore"
version = "1.0.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
[[package]]
name = "idna"
version = "3.10"
@ -493,6 +548,7 @@ source = { virtual = "." }
dependencies = [
{ name = "django" },
{ name = "django-allauth" },
{ name = "django-dirtyfields" },
{ name = "django-money" },
{ name = "django-oauth-toolkit" },
{ name = "django-ratelimit" },
@ -501,6 +557,7 @@ dependencies = [
{ name = "django-view-decorator" },
{ name = "django-zen-queries" },
{ name = "environs", extra = ["django"] },
{ name = "httpx" },
{ name = "psycopg", extra = ["binary"] },
{ name = "stripe" },
{ name = "uvicorn" },
@ -512,10 +569,9 @@ dev = [
{ name = "coverage", extra = ["toml"] },
{ name = "django-browser-reload" },
{ name = "django-debug-toolbar" },
{ name = "django-stubs" },
{ name = "django-stubs", extra = ["compatible-mypy"] },
{ name = "model-bakery" },
{ name = "mypy" },
{ name = "pip-tools" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-django" },
@ -525,6 +581,7 @@ dev = [
requires-dist = [
{ name = "django", specifier = "~=5.1" },
{ name = "django-allauth", specifier = "~=0.63" },
{ name = "django-dirtyfields", specifier = "~=1.9.5" },
{ name = "django-money", specifier = "~=3.5" },
{ name = "django-oauth-toolkit", specifier = "~=2.4" },
{ name = "django-ratelimit", specifier = "~=4.1" },
@ -533,6 +590,7 @@ requires-dist = [
{ name = "django-view-decorator", specifier = "==0.0.4" },
{ name = "django-zen-queries", specifier = "~=2.1" },
{ name = "environs", extras = ["django"], specifier = ">=11,<12" },
{ name = "httpx", specifier = "~=0.28.1" },
{ name = "psycopg", extras = ["binary"], specifier = "~=3.2" },
{ name = "stripe", specifier = "~=10.5" },
{ name = "uvicorn", specifier = "~=0.30" },
@ -541,16 +599,15 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "coverage", extras = ["toml"], specifier = "==7.3.0" },
{ name = "django-browser-reload", specifier = "==1.7.0" },
{ name = "django-debug-toolbar", specifier = "==4.2.0" },
{ name = "django-stubs", specifier = "==1.16.0" },
{ name = "coverage", extras = ["toml"], specifier = "~=7.6" },
{ name = "django-browser-reload", specifier = "~=1.15" },
{ name = "django-debug-toolbar", specifier = "~=4.4" },
{ name = "django-stubs", extras = ["compatible-mypy"], specifier = "~=5.0" },
{ name = "model-bakery", specifier = "==1.17.0" },
{ name = "mypy", specifier = "==1.1.1" },
{ name = "pip-tools", specifier = "==7.3.0" },
{ name = "pytest", specifier = "==7.2.2" },
{ name = "mypy", specifier = "~=1.11" },
{ name = "pytest", specifier = "~=8.3" },
{ name = "pytest-cov" },
{ name = "pytest-django", specifier = "==4.5.2" },
{ name = "pytest-django", specifier = "~=4.8" },
]
[[package]]
@ -567,20 +624,30 @@ wheels = [
[[package]]
name = "mypy"
version = "1.1.1"
version = "1.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mypy-extensions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/54/be80f8d01f5cf72f774a77f9f750527a6fa733f09f78b1da30e8fa3914e6/mypy-1.1.1.tar.gz", hash = "sha256:ae9ceae0f5b9059f33dbc62dea087e942c0ccab4b7a003719cb70f9b8abfa32f", size = 2778293 }
sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/ab/d6d3884c3f432898458e2ade712988a7d1da562c1a363f2003b31677acd8/mypy-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:26cdd6a22b9b40b2fd71881a8a4f34b4d7914c679f154f43385ca878a8297389", size = 10475489 },
{ url = "https://files.pythonhosted.org/packages/b9/e5/71eef5239219ee2f4d85e2ca6368d736705a3b874023b57f7237b977839c/mypy-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5b5f81b40d94c785f288948c16e1f2da37203c6006546c5d947aab6f90aefef2", size = 9567148 },
{ url = "https://files.pythonhosted.org/packages/bf/2d/45a526f248719ee32ecf1261564247a2e717a9c6167de5eb67d53599c4df/mypy-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21b437be1c02712a605591e1ed1d858aba681757a1e55fe678a15c2244cd68a5", size = 11978458 },
{ url = "https://files.pythonhosted.org/packages/64/63/6a04ca7a8b7f34811cada43ed6119736a7f4a07c5e1cbd8eec0e0f4962d5/mypy-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d809f88734f44a0d44959d795b1e6f64b2bbe0ea4d9cc4776aa588bb4229fc1c", size = 12055007 },
{ url = "https://files.pythonhosted.org/packages/ed/89/85a04f32135fe4e35fd59d47100c939c7425fcb29868894c4b7a6171e065/mypy-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:a380c041db500e1410bb5b16b3c1c35e61e773a5c3517926b81dfdab7582be54", size = 8862912 },
{ url = "https://files.pythonhosted.org/packages/a4/0b/3a30f50287e42a4230320fa2eac25eb3017d38a7c31f083d407ab627607c/mypy-1.1.1-py3-none-any.whl", hash = "sha256:4e4e8b362cdf99ba00c2b218036002bdcdf1e0de085cdb296a49df03fb31dfc4", size = 2373884 },
{ url = "https://files.pythonhosted.org/packages/d0/19/de0822609e5b93d02579075248c7aa6ceaddcea92f00bf4ea8e4c22e3598/mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", size = 10939027 },
{ url = "https://files.pythonhosted.org/packages/c8/71/6950fcc6ca84179137e4cbf7cf41e6b68b4a339a1f5d3e954f8c34e02d66/mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", size = 10108699 },
{ url = "https://files.pythonhosted.org/packages/26/50/29d3e7dd166e74dc13d46050b23f7d6d7533acf48f5217663a3719db024e/mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", size = 12506263 },
{ url = "https://files.pythonhosted.org/packages/3f/1d/676e76f07f7d5ddcd4227af3938a9c9640f293b7d8a44dd4ff41d4db25c1/mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", size = 12984688 },
{ url = "https://files.pythonhosted.org/packages/9c/03/5a85a30ae5407b1d28fab51bd3e2103e52ad0918d1e68f02a7778669a307/mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", size = 9626811 },
{ url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900 },
{ url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818 },
{ url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275 },
{ url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783 },
{ url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197 },
{ url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 },
{ url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 },
{ url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 },
{ url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 },
{ url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 },
{ url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 },
]
[[package]]
@ -610,31 +677,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
]
[[package]]
name = "pip"
version = "24.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f4/b1/b422acd212ad7eedddaf7981eee6e5de085154ff726459cf2da7c5a184c1/pip-24.3.1.tar.gz", hash = "sha256:ebcb60557f2aefabc2e0f918751cd24ea0d56d8ec5445fe1807f1d2109660b99", size = 1931073 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/7d/500c9ad20238fcfcb4cb9243eede163594d7020ce87bd9610c9e02771876/pip-24.3.1-py3-none-any.whl", hash = "sha256:3790624780082365f47549d032f3770eeb2b1e8bd1f7b2e02dace1afa361b4ed", size = 1822182 },
]
[[package]]
name = "pip-tools"
version = "7.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "build" },
{ name = "click" },
{ name = "pip" },
{ name = "setuptools" },
{ name = "wheel" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fd/01/f0055058a86a888f32ac794fa68d5a25c2d2f7a3e8181474b711faaa2145/pip-tools-7.3.0.tar.gz", hash = "sha256:8e9c99127fe024c025b46a0b2d15c7bd47f18f33226cf7330d35493663fc1d1d", size = 136178 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/df/47e6267c6b5cdae867adbdd84b437393e6202ce4322de0a5e0b92960e1d6/pip_tools-7.3.0-py3-none-any.whl", hash = "sha256:8717693288720a8c6ebd07149c93ab0be1fced0b5191df9e9decd3263e20d85e", size = 57367 },
]
[[package]]
name = "pluggy"
version = "1.5.0"
@ -724,29 +766,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 },
]
[[package]]
name = "pyproject-hooks"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216 },
]
[[package]]
name = "pytest"
version = "7.2.2"
version = "8.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/29/311895d9cd3f003dd58e8fdea36dd895ba2da5c0c90601836f7de79f76fe/pytest-7.2.2.tar.gz", hash = "sha256:c99ab0c73aceb050f68929bc93af19ab6db0558791c6a0715723abe9d0ade9d4", size = 1320028 }
sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/68/5321b5793bd506961bd40bdbdd0674e7de4fb873ee7cab33dd27283ad513/pytest-7.2.2-py3-none-any.whl", hash = "sha256:130328f552dcfac0b1cec75c12e3f005619dc5f874f0a06e8ff7263f0ee6225e", size = 317207 },
{ url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 },
]
[[package]]
@ -764,14 +796,14 @@ wheels = [
[[package]]
name = "pytest-django"
version = "4.5.2"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/42/6d6563165b82289d4a30ea477f85c04386303e51cf4e4e4651d4f9910830/pytest-django-4.5.2.tar.gz", hash = "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2", size = 79949 }
sdist = { url = "https://files.pythonhosted.org/packages/02/c0/43c8b2528c24d7f1a48a47e3f7381f5ab2ae8c64634b0c3f4bd843063955/pytest_django-4.9.0.tar.gz", hash = "sha256:8bf7bc358c9ae6f6fc51b6cebb190fe20212196e6807121f11bd6a3b03428314", size = 84067 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/21/b65ecd6686da400e2f6e3c49c2a428325abd979c9670cd97e1671f53296e/pytest_django-4.5.2-py3-none-any.whl", hash = "sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e", size = 20752 },
{ url = "https://files.pythonhosted.org/packages/47/fe/54f387ee1b41c9ad59e48fb8368a361fad0600fe404315e31a12bacaea7d/pytest_django-4.9.0-py3-none-any.whl", hash = "sha256:1d83692cb39188682dbb419ff0393867e9904094a549a7d38a3154d5731b2b99", size = 23723 },
]
[[package]]
@ -816,6 +848,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/55/21/47d163f615df1d30c094f6c8bbb353619274edccf0327b185cc2493c2c33/setuptools-75.6.0-py3-none-any.whl", hash = "sha256:ce74b49e8f7110f9bf04883b730f4765b774ef3ef28f722cce7c273d253aaf7d", size = 1224032 },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
]
[[package]]
name = "sqlparse"
version = "0.5.3"
@ -877,15 +918,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 },
]
[[package]]
name = "types-pytz"
version = "2024.2.0.20241221"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/26/516311b02b5a215e721155fb65db8a965d061372e388d6125ebce8d674b0/types_pytz-2024.2.0.20241221.tar.gz", hash = "sha256:06d7cde9613e9f7504766a0554a270c369434b50e00975b3a4a0f6eed0f2c1a9", size = 10213 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/db/c92ca6920cccd9c2998b013601542e2ac5e59bc805bcff94c94ad254b7df/types_pytz-2024.2.0.20241221-py3-none-any.whl", hash = "sha256:8fc03195329c43637ed4f593663df721fef919b60a969066e22606edf0b53ad5", size = 10008 },
]
[[package]]
name = "types-pyyaml"
version = "6.0.12.20241221"
@ -935,15 +967,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 },
]
[[package]]
name = "wheel"
version = "0.45.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8a/98/2d9906746cdc6a6ef809ae6338005b3f21bb568bea3165cfc6a243fdc25c/wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729", size = 107545 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/2c/87f3254fd8ffd29e4c02732eee68a83a1d3c346ae39bc6822dcbcb697f2b/wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", size = 72494 },
]
[[package]]
name = "whitenoise"
version = "6.8.2"