Compare commits

...

7 Commits

13 changed files with 206 additions and 59 deletions

View File

@ -178,6 +178,7 @@ class Base(Configuration):
class NonCIBase(Base):
"""required for all but CI"""
CSRF_TRUSTED_ORIGINS = values.ListValue([])
DATABASES = values.DatabaseURLValue(environ_required=True)
EMAIL = values.EmailURLValue(environ_required=True)
# TODO: should validate emails

View File

@ -108,11 +108,6 @@ class EventMeetingTimeInline(admin.TabularInline):
readonly_fields = ["duration"]
# TODO: remove when switched to GeneratedField
@admin.display()
def duration(self, obj):
return obj.duration
@admin.register(EventInstructor)
class EventInstructorAdmin(admin.ModelAdmin):
@ -144,7 +139,7 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
show_facets = admin.ShowFacets.ALWAYS
search_fields = ["eid", "title", "url"]
date_hierarchy = "start"
exclude = ["url", "details"]
exclude = ["url", "details", "registrations"]
autocomplete_fields = ["instructor"]
change_actions = ["fetch_details"]
actions = ["fetch_details"]
@ -160,7 +155,7 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
else:
fields.append(field.name)
fields.insert(fields.index("end") + 1, "duration")
fields.append("_details_timestamp")
fields.append("details_timestamp")
return fields
@admin.display(ordering="title")
@ -178,10 +173,6 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
obj.url,
)
@admin.display(description="Last details fetch")
def _details_timestamp(self, obj):
return naturaltime(obj.details_timestamp)
@takes_instance_or_queryset
def fetch_details(self, request, queryset):
scrape_event_details(queryset)

View File

@ -0,0 +1,25 @@
# Generated by Django 5.0.6 on 2024-05-08 16:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("membershipworks", "0017_eventext_registrations_alter_eventinvoice_pdf"),
]
operations = [
migrations.AddField(
model_name="eventext",
name="details_timestamp",
field=models.GeneratedField(
db_persist=False,
expression=models.Func(
models.Func(models.F("details___ts"), function="FROM_UNIXTIME"),
template="CONVERT_TZ(%(expressions)s, @@session.time_zone, 'UTC')",
),
output_field=models.DateTimeField(),
verbose_name="Last details fetch",
),
),
]

View File

@ -520,14 +520,6 @@ class EventExtManager(models.Manager):
ExpressionWrapper(F("duration"), models.IntegerField()) * F("count"),
models.DurationField(),
),
# TODO: this could be a GeneratedField, but that
# currently breaks saving when the primary key is
# provided (Django 5.0.1)
details_timestamp=Func(
Func(F("details___ts"), function="FROM_UNIXTIME"),
template="CONVERT_TZ(%(expressions)s, @@session.time_zone, 'UTC')",
output_field=models.DateTimeField(),
),
)
@ -550,6 +542,16 @@ class EventExt(Event):
max_digits=13, decimal_places=4, default=0
)
details = models.JSONField(null=True, blank=True)
details_timestamp = models.GeneratedField(
expression=Func(
Func(F("details___ts"), function="FROM_UNIXTIME"),
template="CONVERT_TZ(%(expressions)s, @@session.time_zone, 'UTC')",
),
output_field=models.DateTimeField(),
db_persist=False,
verbose_name="Last details fetch",
)
registrations = models.JSONField(null=True, blank=True)
def get_absolute_url(self) -> str:

View File

@ -65,6 +65,30 @@ class EventTable(tables.Table):
}
class EventRegistrationsTable(tables.Table):
ticket_count = tables.Column(empty_values=())
name = tables.Column(accessor="Full name")
email = tables.EmailColumn(accessor="Email")
phone = tables.Column(accessor="Phone")
emergency_contact_name = tables.Column(accessor="Emergency Contact Name:")
emergency_contact_phone_number = tables.Column(
accessor="Emergency Contact Phone Number:"
)
emergency_contact_relation = tables.Column(accessor="Emergency Contact Relation:")
def render_ticket_count(self, record):
return sum(int(v) for k, v in record.items() if k.startswith("Ticket: "))
class Meta:
row_attrs = {
"class": lambda table, record: (
""
if table.render_ticket_count(record) > 0
else "text-decoration-line-through table-danger"
)
}
class EventSummaryTable(tables.Table):
event_count = tables.Column("Events")
canceled_event_count = tables.Column("Canceled Events")

View File

@ -0,0 +1,15 @@
{% if perms.membershipworks.view_eventext %}
<li class="breadcrumb-item">
<a href="{% url 'membershipworks:event-index-report' %}">MW Event Reports</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'membershipworks:event-year-report' event.start|date:"Y" %}">{{ event.start|date:"Y" }}</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'membershipworks:event-month-report' event.start|date:"Y" event.start|date:"m" %}">{{ event.start|date:"F" }}</a>
</li>
{% else %}
<li class="breadcrumb-item">
<a href="{% url 'membershipworks:user-events' %}">My Events</a>
</li>
{% endif %}

View File

@ -7,8 +7,16 @@
{% block admin_link %}
{% url 'admin:membershipworks_eventext_change' event.pk %}
{% endblock %}
{% block breadcrumbs %}
{% include "./components/event_breadcrumbs.dj.html" %}
<li class="breadcrumb-item active" aria-current="page">{{ event.details.ttl|nh3 }}</li>
{% endblock %}
{% block content %}
<div class="container">
{% if event.registrations is not None %}
{% url 'membershipworks:event-registrations' event.pk as registrations_url %}
{% bootstrap_button href=registrations_url content="Show Registrations" %}
{% endif %}
{% include "membershipworks/event_invoice.dj.html" %}
<div class="card w-auto mt-5">

View File

@ -0,0 +1,29 @@
{% extends "base.dj.html" %}
{% load nh3_tags %}
{% load render_table from django_tables2 %}
{% load django_bootstrap5 %}
{% block title %}Registrations for {{ event.details.ttl|nh3 }}{% endblock %}
{% block admin_link %}
{% url 'admin:membershipworks_eventext_change' event.pk %}
{% endblock %}
{% block breadcrumbs %}
{% include "./components/event_breadcrumbs.dj.html" %}
<li class="breadcrumb-item">
<a href="{% url 'membershipworks:event-detail' event.pk %}">{{ event.details.ttl|nh3|truncatechars_html:40 }}</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Registrations</li>
{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-auto">
{% bootstrap_button extra_classes="btn-sm" href=email_link target="_blank" content="Email all attendees" %}
</div>
<div class="col-auto">{% include "cmsmanage/components/download_table.dj.html" %}</div>
</div>
{% render_table table %}
<p class="text-center">Data last updated {{ event.details_timestamp }}</p>
</div>
{% endblock %}

View File

@ -3,6 +3,7 @@
{% load render_table from django_tables2 %}
{% block title %}My Events{% endblock %}
{% block breadcrumbs %}<li class="breadcrumb-item active" aria-current="page">My Events</li>{% endblock %}
{% block content %}
{% include "cmsmanage/components/download_table.dj.html" %}

View File

@ -45,6 +45,11 @@ urlpatterns = [
views.EventDetailView.as_view(),
name="event-detail",
),
path(
"event/<eid>/registrations",
views.EventRegistrationsView.as_view(),
name="event-registrations",
),
path(
"event/invoice/<uuid:uuid>.pdf",
views.EventInvoicePDFView.as_view(),

View File

@ -1,6 +1,7 @@
import uuid
from datetime import datetime
from typing import Any
from urllib.parse import quote, urlencode
from django.conf import settings
from django.contrib import messages
@ -46,6 +47,7 @@ from .invoice_email import make_invoice_emails
from .models import EventAttendee, EventExt, EventInvoice, Member
from .tables import (
EventAttendeeTable,
EventRegistrationsTable,
EventSummaryTable,
EventTable,
InvoiceTable,
@ -418,6 +420,50 @@ class EventInvoicePDFView(AccessMixin, BaseDetailView):
return self.handle_no_permission()
class EventRegistrationsView(ExportMixin, SingleTableMixin, AccessMixin, DetailView):
permission_required = "membershipworks.view_eventext"
model = EventExt
pk_url_kwarg = "eid"
context_object_name = "event"
template_name = "membershipworks/event_registrations.dj.html"
table_class = EventRegistrationsTable
export_formats = ("csv", "xlsx", "ods")
def render_to_response(
self, context: dict[str, Any], **response_kwargs: Any
) -> HttpResponse:
if self.request.user.has_perm(
self.permission_required
) or self.object.user_is_instructor(self.request.user):
return super().render_to_response(context, **response_kwargs)
else:
return self.handle_no_permission()
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context_data = super().get_context_data(**kwargs)
context_data["email_link"] = "mailto:?" + urlencode(
{
"subject": f"[CMS Event] {self.object.title}",
"bcc": ",".join(
mail.message.sanitize_address(
(reg["Full name"], reg["Email"]), settings.DEFAULT_CHARSET
)
for reg in self.object.registrations
if any(
int(v) > 0 for k, v in reg.items() if k.startswith("Ticket: ")
)
),
},
quote_via=quote,
)
return context_data
def get_table_data(self):
return self.object.registrations
class EventAttendeeFilters(django_filters.FilterSet):
new_since = django_filters.DateFilter(
field_name="event__start", method="filter_new_since"

View File

@ -5,7 +5,7 @@
groups = ["default", "debug", "lint", "server", "typing", "dev"]
strategy = ["cross_platform"]
lock_version = "4.4.1"
content_hash = "sha256:8e68a7f1608469e70bc3e7502f747bbe5f38ca4bc15f504811377509599bb7a1"
content_hash = "sha256:651200bd58f4159fe99a599564e0f83a89fd149e5e7300abd151d6a3bb6477a9"
[[package]]
name = "aiohttp"
@ -483,7 +483,7 @@ files = [
[[package]]
name = "django"
version = "5.0.4"
version = "5.0.6"
requires_python = ">=3.10"
summary = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
dependencies = [
@ -492,8 +492,8 @@ dependencies = [
"tzdata; sys_platform == \"win32\"",
]
files = [
{file = "Django-5.0.4-py3-none-any.whl", hash = "sha256:916423499d75d62da7aa038d19aef23d23498d8df229775eb0a6309ee1013775"},
{file = "Django-5.0.4.tar.gz", hash = "sha256:4bd01a8c830bb77a8a3b0e7d8b25b887e536ad17a81ba2dce5476135c73312bd"},
{file = "Django-5.0.6-py3-none-any.whl", hash = "sha256:8363ac062bb4ef7c3f12d078f6fa5d154031d129a15170a1066412af49d30905"},
{file = "Django-5.0.6.tar.gz", hash = "sha256:ff1b61005004e476e0aeea47c7f79b85864c70124030e95146315396f1e7951f"},
]
[[package]]
@ -578,15 +578,15 @@ files = [
[[package]]
name = "django-db-views"
version = "0.1.6"
version = "0.1.7"
summary = "Handle database views. Allow to create migrations for database views. View migrations using django code. They can be reversed. Changes in model view definition are detected automatically. Support almost all options as regular makemigrations command"
dependencies = [
"Django",
"six",
]
files = [
{file = "django-db-views-0.1.6.tar.gz", hash = "sha256:05718bb87c819323d577b294ee75f25807e5bb767793aa27f1ecc4c7ae073172"},
{file = "django_db_views-0.1.6-py3-none-any.whl", hash = "sha256:1ae8a6b389a2e8a7a2e246050ce7688780343bf4fd4f9622263b607ae27e5524"},
{file = "django-db-views-0.1.7.tar.gz", hash = "sha256:7c0dc78aa5f53cc4eefc4d880450a8cb61bb8376b5494356e20383f71a3e1657"},
{file = "django_db_views-0.1.7-py3-none-any.whl", hash = "sha256:ffa399af1678e60f532f8c2b531927e94e8249e1012a83fc865234384419bff8"},
]
[[package]]
@ -1079,7 +1079,7 @@ files = [
[[package]]
name = "hypothesis"
version = "6.100.2"
version = "6.100.5"
requires_python = ">=3.8"
summary = "A library for property-based testing"
dependencies = [
@ -1087,23 +1087,23 @@ dependencies = [
"sortedcontainers<3.0.0,>=2.1.0",
]
files = [
{file = "hypothesis-6.100.2-py3-none-any.whl", hash = "sha256:4ae5918f5a47f979e53fe9e8be4ba6edf17d7a133c13542938d8a6e18f890a2a"},
{file = "hypothesis-6.100.2.tar.gz", hash = "sha256:7d68e45d371cee5b9d2236516341aca57fb7f2cfabef6cb9e5b3a7ec62219cf2"},
{file = "hypothesis-6.100.5-py3-none-any.whl", hash = "sha256:d2f875a8791abdf68599e85cc9238f7239a73b72362d34be95e532e811766723"},
{file = "hypothesis-6.100.5.tar.gz", hash = "sha256:14e06081459ee96ca8f1ed996b6fc19f71910281e01f6a9fa3d9d6e68bbe4a25"},
]
[[package]]
name = "hypothesis"
version = "6.100.2"
version = "6.100.5"
extras = ["django"]
requires_python = ">=3.8"
summary = "A library for property-based testing"
dependencies = [
"django>=3.2",
"hypothesis==6.100.2",
"hypothesis==6.100.5",
]
files = [
{file = "hypothesis-6.100.2-py3-none-any.whl", hash = "sha256:4ae5918f5a47f979e53fe9e8be4ba6edf17d7a133c13542938d8a6e18f890a2a"},
{file = "hypothesis-6.100.2.tar.gz", hash = "sha256:7d68e45d371cee5b9d2236516341aca57fb7f2cfabef6cb9e5b3a7ec62219cf2"},
{file = "hypothesis-6.100.5-py3-none-any.whl", hash = "sha256:d2f875a8791abdf68599e85cc9238f7239a73b72362d34be95e532e811766723"},
{file = "hypothesis-6.100.5.tar.gz", hash = "sha256:14e06081459ee96ca8f1ed996b6fc19f71910281e01f6a9fa3d9d6e68bbe4a25"},
]
[[package]]
@ -1833,27 +1833,27 @@ files = [
[[package]]
name = "ruff"
version = "0.4.2"
version = "0.4.3"
requires_python = ">=3.7"
summary = "An extremely fast Python linter and code formatter, written in Rust."
files = [
{file = "ruff-0.4.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:8d14dc8953f8af7e003a485ef560bbefa5f8cc1ad994eebb5b12136049bbccc5"},
{file = "ruff-0.4.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:24016ed18db3dc9786af103ff49c03bdf408ea253f3cb9e3638f39ac9cf2d483"},
{file = "ruff-0.4.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2e06459042ac841ed510196c350ba35a9b24a643e23db60d79b2db92af0c2b"},
{file = "ruff-0.4.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3afabaf7ba8e9c485a14ad8f4122feff6b2b93cc53cd4dad2fd24ae35112d5c5"},
{file = "ruff-0.4.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:799eb468ea6bc54b95527143a4ceaf970d5aa3613050c6cff54c85fda3fde480"},
{file = "ruff-0.4.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ec4ba9436a51527fb6931a8839af4c36a5481f8c19e8f5e42c2f7ad3a49f5069"},
{file = "ruff-0.4.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6a2243f8f434e487c2a010c7252150b1fdf019035130f41b77626f5655c9ca22"},
{file = "ruff-0.4.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8772130a063f3eebdf7095da00c0b9898bd1774c43b336272c3e98667d4fb8fa"},
{file = "ruff-0.4.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ab165ef5d72392b4ebb85a8b0fbd321f69832a632e07a74794c0e598e7a8376"},
{file = "ruff-0.4.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1f32cadf44c2020e75e0c56c3408ed1d32c024766bd41aedef92aa3ca28eef68"},
{file = "ruff-0.4.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:22e306bf15e09af45ca812bc42fa59b628646fa7c26072555f278994890bc7ac"},
{file = "ruff-0.4.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82986bb77ad83a1719c90b9528a9dd663c9206f7c0ab69282af8223566a0c34e"},
{file = "ruff-0.4.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:652e4ba553e421a6dc2a6d4868bc3b3881311702633eb3672f9f244ded8908cd"},
{file = "ruff-0.4.2-py3-none-win32.whl", hash = "sha256:7891ee376770ac094da3ad40c116258a381b86c7352552788377c6eb16d784fe"},
{file = "ruff-0.4.2-py3-none-win_amd64.whl", hash = "sha256:5ec481661fb2fd88a5d6cf1f83403d388ec90f9daaa36e40e2c003de66751798"},
{file = "ruff-0.4.2-py3-none-win_arm64.whl", hash = "sha256:cbd1e87c71bca14792948c4ccb51ee61c3296e164019d2d484f3eaa2d360dfaf"},
{file = "ruff-0.4.2.tar.gz", hash = "sha256:33bcc160aee2520664bc0859cfeaebc84bb7323becff3f303b8f1f2d81cb4edc"},
{file = "ruff-0.4.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b70800c290f14ae6fcbb41bbe201cf62dfca024d124a1f373e76371a007454ce"},
{file = "ruff-0.4.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08a0d6a22918ab2552ace96adeaca308833873a4d7d1d587bb1d37bae8728eb3"},
{file = "ruff-0.4.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba1f14df3c758dd7de5b55fbae7e1c8af238597961e5fb628f3de446c3c40c5"},
{file = "ruff-0.4.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:819fb06d535cc76dfddbfe8d3068ff602ddeb40e3eacbc90e0d1272bb8d97113"},
{file = "ruff-0.4.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bfc9e955e6dc6359eb6f82ea150c4f4e82b660e5b58d9a20a0e42ec3bb6342b"},
{file = "ruff-0.4.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:510a67d232d2ebe983fddea324dbf9d69b71c4d2dfeb8a862f4a127536dd4cfb"},
{file = "ruff-0.4.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc9ff11cd9a092ee7680a56d21f302bdda14327772cd870d806610a3503d001f"},
{file = "ruff-0.4.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29efff25bf9ee685c2c8390563a5b5c006a3fee5230d28ea39f4f75f9d0b6f2f"},
{file = "ruff-0.4.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18b00e0bcccf0fc8d7186ed21e311dffd19761cb632241a6e4fe4477cc80ef6e"},
{file = "ruff-0.4.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262f5635e2c74d80b7507fbc2fac28fe0d4fef26373bbc62039526f7722bca1b"},
{file = "ruff-0.4.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7363691198719c26459e08cc17c6a3dac6f592e9ea3d2fa772f4e561b5fe82a3"},
{file = "ruff-0.4.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:eeb039f8428fcb6725bb63cbae92ad67b0559e68b5d80f840f11914afd8ddf7f"},
{file = "ruff-0.4.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:927b11c1e4d0727ce1a729eace61cee88a334623ec424c0b1c8fe3e5f9d3c865"},
{file = "ruff-0.4.3-py3-none-win32.whl", hash = "sha256:25cacda2155778beb0d064e0ec5a3944dcca9c12715f7c4634fd9d93ac33fd30"},
{file = "ruff-0.4.3-py3-none-win_amd64.whl", hash = "sha256:7a1c3a450bc6539ef00da6c819fb1b76b6b065dec585f91456e7c0d6a0bbc725"},
{file = "ruff-0.4.3-py3-none-win_arm64.whl", hash = "sha256:71ca5f8ccf1121b95a59649482470c5601c60a416bf189d553955b0338e34614"},
{file = "ruff-0.4.3.tar.gz", hash = "sha256:ff0a3ef2e3c4b6d133fbedcf9586abfbe38d076041f2dc18ffb2c7e0485d5a07"},
]
[[package]]
@ -2094,7 +2094,7 @@ files = [
[[package]]
name = "types-pygments"
version = "2.17.0.20240310"
version = "2.18.0.20240506"
requires_python = ">=3.8"
summary = "Typing stubs for Pygments"
dependencies = [
@ -2102,8 +2102,8 @@ dependencies = [
"types-setuptools",
]
files = [
{file = "types-Pygments-2.17.0.20240310.tar.gz", hash = "sha256:b1d97e905ce36343c7283b0319182ae6d4f967188f361f45502a18ae43e03e1f"},
{file = "types_Pygments-2.17.0.20240310-py3-none-any.whl", hash = "sha256:b101ca9448aaff52af6966506f1fdd73b1e60a79b8a79a8bace3366cbf1f7ed9"},
{file = "types-Pygments-2.18.0.20240506.tar.gz", hash = "sha256:4b4c37812c87bbde687dbf27adf5bac593745a321e57f678dbc311571ba2ac9d"},
{file = "types_Pygments-2.18.0.20240506-py3-none-any.whl", hash = "sha256:11c90bc1737c9af55e5569558b88df7c2233e12325cb516215f722271444e91d"},
]
[[package]]
@ -2311,7 +2311,7 @@ files = [
[[package]]
name = "weasyprint"
version = "62.0"
version = "62.1"
requires_python = ">=3.9"
summary = "The Awesome Document Factory"
dependencies = [
@ -2325,8 +2325,8 @@ dependencies = [
"tinycss2>=1.3.0",
]
files = [
{file = "weasyprint-62.0-py3-none-any.whl", hash = "sha256:021b2fcde720756dd496645f251e40240b742e91a85f446f612f4666fdca316d"},
{file = "weasyprint-62.0.tar.gz", hash = "sha256:9f56eaefdaafcf35ae568c56eee5dba189af1272a46f4d205bd12936315f9481"},
{file = "weasyprint-62.1-py3-none-any.whl", hash = "sha256:654d4c266336cbf9acc4da118c7778ef5839717e6055d5b8f995cf50be200c46"},
{file = "weasyprint-62.1.tar.gz", hash = "sha256:bf3c1a9ac4194271a7cf117229c093744105b50ac2fa64c0a6e44e68ae742992"},
]
[[package]]

View File

@ -18,7 +18,7 @@ dependencies = [
"mdformat-tables~=0.4",
"mysqlclient~=2.2",
"django-autocomplete-light~=3.11",
"weasyprint~=62.0",
"weasyprint~=62.1",
"requests~=2.31",
"semver~=3.0",
"djangorestframework~=3.15",
@ -124,7 +124,7 @@ typing = [
"types-urllib3~=1.26",
"djangorestframework-stubs[compatible-mypy]~=3.15",
"types-Markdown~=3.6",
"types-Pygments~=2.17",
"types-Pygments~=2.18",
"types-psycopg2~=2.9",
"types-lxml~=2024.4",
]