Compare commits

...

2 Commits

Author SHA1 Message Date
19884bcbc4 doorcontrol: Use django-tables2 for access reports
All checks were successful
Ruff / ruff (push) Successful in 22s
2024-01-22 23:19:57 -05:00
a2950c0279 membershipworks: Add download buttons for event reports 2024-01-22 14:41:27 -05:00
8 changed files with 225 additions and 123 deletions

View File

@ -1,5 +1,7 @@
{% extends "base.dj.html" %} {% extends "base.dj.html" %}
{% load render_table from django_tables2 %}
{% block title %}{{ selected_report }} | Door Controls | CMS{% endblock %} {% block title %}{{ selected_report }} | Door Controls | CMS{% endblock %}
{% block content %} {% block content %}
<div class="vstack align-items-center"> <div class="vstack align-items-center">
@ -11,9 +13,9 @@
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<form method="get" class="container"> <form method="get" class="container-fluid">
<div class="row align-items-center"> <div class="row g-2 align-items-center justify-content-center">
<div class="col gy-1"> <div class="col-6 col-sm-auto">
<div class="form-floating"> <div class="form-floating">
<input type="date" <input type="date"
class="form-control" class="form-control"
@ -23,7 +25,7 @@
<label for="startDate">Start Date</label> <label for="startDate">Start Date</label>
</div> </div>
</div> </div>
<div class="col gy-1"> <div class="col-6 col-sm-auto">
<div class="form-floating"> <div class="form-floating">
<input type="date" <input type="date"
class="form-control" class="form-control"
@ -33,7 +35,7 @@
<label for="endDate">End Date</label> <label for="endDate">End Date</label>
</div> </div>
</div> </div>
<div class="col-md gy-1"> <div class="col-12 col-sm-auto">
<div class="form-floating"> <div class="form-floating">
<input type="number" <input type="number"
class="form-control" class="form-control"
@ -47,51 +49,15 @@
<label for="itemsPerPage">Items Per Page</label> <label for="itemsPerPage">Items Per Page</label>
</div> </div>
</div> </div>
<div class="col-6 col-md-auto gy-1"> <div class="btn-group col-auto" role="group" aria-label="Form Controls">
<button type="submit" class="btn btn-primary w-100">Submit</button> <button type="submit" class="btn btn-sm btn-primary">Submit</button>
<a href="?" class="btn btn-sm btn-warning">Reset</a>
</div> </div>
<div class="col-6 col-md-auto gy-1"> <div class="col-auto">
<a href="?" class="btn btn-warning w-100">Reset</a> {% include "membershipworks/components/download_table.dj.html" %}
</div> </div>
</div> </div>
</form> </form>
<div class="table-responsive mw-100"> {% render_table table %}
<table class="table table-striped table-hover mb-2 w-auto">
<thead>
<tr>
{% for column in object_list.0.keys %}<th>{{ column|title }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for object in object_list %}
<tr>
{% for field in object.values %}<td>{{ field }}</td>{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<nav aria-label="Page navigation">
<div class="text-center mb-2">Showing {{ page_obj.object_list|length }} of {{ paginator.count }} results.</div>
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link"
href="?{{ query_params }}&page={{ page_obj.previous_page_number }}">Previous</a>
</li>
{% endif %}
{% for page_num in paginator_range %}
<li class="page-item {% if page_num == page_obj.number %} active {% elif page_num == paginator.ELLIPSIS %} disabled {% endif %}">
<a class="page-link" href="?{{ query_params }}&page={{ page_num }}">{{ page_num }}</a>
</li>
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link"
href="?{{ query_params }}&page={{ page_obj.next_page_number }}">Next</a>
</li>
{% endif %}
</ul>
</nav>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -8,11 +8,14 @@ from django.db.models import Count, F, FloatField, Window
from django.db.models.functions import Lead, Trunc from django.db.models.functions import Lead, Trunc
from django.urls import path, reverse_lazy from django.urls import path, reverse_lazy
from django.utils import dateparse from django.utils import dateparse
from django.utils.formats import date_format
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.timezone import localtime from django.utils.timezone import localtime
from django.views.generic.list import ListView from django.views.generic.list import ListView
import django_tables2 as tables
from django_tables2 import SingleTableMixin
from django_tables2.export.views import ExportMixin
from .models import HIDEvent from .models import HIDEvent
REPORTS = [] REPORTS = []
@ -23,13 +26,17 @@ def register_report(cls: "BaseAccessReport"):
return cls return cls
class BaseAccessReport(PermissionRequiredMixin, ListView): class BaseAccessReport(
ExportMixin, SingleTableMixin, PermissionRequiredMixin, ListView
):
model = HIDEvent model = HIDEvent
permission_required = "doorcontrol.view_hidevent" permission_required = "doorcontrol.view_hidevent"
paginate_by = 20 paginate_by = 20
context_object_name = "object_list" context_object_name = "object_list"
template_name = "doorcontrol/access_report.dj.html" template_name = "doorcontrol/access_report.dj.html"
export_formats = ("csv", "xlsx", "ods")
_report_name = None _report_name = None
@classmethod @classmethod
@ -44,6 +51,10 @@ class BaseAccessReport(PermissionRequiredMixin, ListView):
slug = slugify(cls._report_name) slug = slugify(cls._report_name)
return path(f"reports/{slug}", cls.as_view(), name=slug) return path(f"reports/{slug}", cls.as_view(), name=slug)
@property
def export_name(self):
return slugify(self._report_name)
def _selected_report(self): def _selected_report(self):
return self._report_name return self._report_name
@ -95,8 +106,23 @@ class BaseAccessReport(PermissionRequiredMixin, ListView):
return context return context
class UnitTimeTable(tables.Table):
members = tables.columns.Column()
members_delta = tables.columns.TemplateColumn(
"{{ value|floatformat:2}}%", verbose_name="Δ Members"
)
access_count = tables.columns.Column()
access_count_delta = tables.columns.TemplateColumn(
"{{ value|floatformat:2}}%", verbose_name="Δ Access Count"
)
class Meta:
fields = ("members", "members_delta", "access_count", "access_count_delta")
@register_report @register_report
class AccessPerUnitTime(BaseAccessReport): class AccessPerUnitTime(BaseAccessReport):
table_class = UnitTimeTable
UNIT_TIMES = ["day", "week", "month", "year"] UNIT_TIMES = ["day", "week", "month", "year"]
@classmethod @classmethod
@ -115,23 +141,41 @@ class AccessPerUnitTime(BaseAccessReport):
name="access-per-unit-time", name="access-per-unit-time",
) )
@property
def _report_name(self):
unit_time = self.kwargs["unit_time"]
return "Access per " + unit_time.title()
def _selected_report(self) -> str: def _selected_report(self) -> str:
return "Access per " + self.kwargs["unit_time"].title() return "Access per " + self.kwargs["unit_time"].title()
def _format_date(self, date: datetime.datetime) -> str: def get_table_kwargs(self):
unit_time = self.kwargs["unit_time"] unit_time = self.kwargs["unit_time"]
if unit_time == "day": if unit_time == "week":
return date_format(date, "DATE_FORMAT") unit_time_column = tables.TemplateColumn(
elif unit_time == "week": verbose_name=unit_time.title(),
return ( template_code=(
date_format(date, "DATE_FORMAT") "{{ value|date|default:default }} - "
+ " - " "{{ value|add:one_week|date|default:default }}"
+ date_format(date + datetime.timedelta(weeks=1), "DATE_FORMAT") ),
extra_context={"one_week": datetime.timedelta(weeks=1)},
) )
else:
if unit_time == "day":
date_format = "DATE_FORMAT"
elif unit_time == "month": elif unit_time == "month":
return date_format(date, "N Y") date_format = "N Y"
elif unit_time == "year": elif unit_time == "year":
return date_format(date, "Y") date_format = "Y"
unit_time_column = tables.DateColumn(
date_format, verbose_name=unit_time.title()
)
return {
"sequence": ("unit_time", "..."),
"extra_columns": (("unit_time", unit_time_column),),
}
def get_queryset(self): def get_queryset(self):
unit_time = self.kwargs["unit_time"] unit_time = self.kwargs["unit_time"]
@ -141,7 +185,7 @@ class AccessPerUnitTime(BaseAccessReport):
granted_event_types = [ granted_event_types = [
t for t in HIDEvent.EventType if t.name.startswith("GRANTED_ACCESS") t for t in HIDEvent.EventType if t.name.startswith("GRANTED_ACCESS")
] ]
events = ( return (
super() super()
.get_queryset() .get_queryset()
.filter(event_type__in=granted_event_types) .filter(event_type__in=granted_event_types)
@ -172,64 +216,58 @@ class AccessPerUnitTime(BaseAccessReport):
) )
.order_by("-unit_time") .order_by("-unit_time")
) )
return [
{
unit_time: self._format_date(event["unit_time"]), class DeniedAccessTable(tables.Table):
"members": event["members"], name = tables.TemplateColumn(
"Δ members": ( "{{ record.forename|default:'' }} {{ record.surname|default:'' }}"
f'{event["members_delta"]:.2f}%' )
if event["members_delta"] is not None
else "" class Meta:
), model = HIDEvent
"access count": event["access_count"],
"Δ access count": ( fields = (
f'{event["access_count_delta"]:.2f}%' "timestamp",
if event["access_count_delta"] is not None "door",
else "" "event_type",
), "name",
} "raw_card_number",
for event in events "decoded_card_number",
] )
@register_report @register_report
class DeniedAccess(BaseAccessReport): class DeniedAccess(BaseAccessReport):
_report_name = "Denied Access" _report_name = "Denied Access"
table_class = DeniedAccessTable
def get_queryset(self): def get_queryset(self):
denied_event_types = [ denied_event_types = [
t for t in HIDEvent.EventType if t.name.startswith("DENIED_ACCESS") t for t in HIDEvent.EventType if t.name.startswith("DENIED_ACCESS")
] ]
events = ( return (
super() super()
.get_queryset() .get_queryset()
.filter(event_type__in=denied_event_types) .filter(event_type__in=denied_event_types)
.with_decoded_card_number() .with_decoded_card_number()
) )
return [
{ class MostActiveMembersTable(tables.Table):
"timestamp": event.timestamp, cardholder_id = tables.Column()
"door name": event.door.name, name = tables.TemplateColumn(
"event type": HIDEvent.EventType(event.event_type).label, "{{ record.forename|default:'' }} {{ record.surname|default:'' }}"
"name": " ".join( )
n for n in [event.forename, event.surname] if n is not None access_count = tables.Column()
),
"raw card number": (
event.raw_card_number if event.raw_card_number is not None else ""
),
"card number": event.decoded_card_number() or "",
}
for event in events
]
@register_report @register_report
class MostActiveMembers(BaseAccessReport): class MostActiveMembers(BaseAccessReport):
_report_name = "Most Active Members" _report_name = "Most Active Members"
table_class = MostActiveMembersTable
def get_queryset(self): def get_queryset(self):
counts = ( return (
super() super()
.get_queryset() .get_queryset()
.values("cardholder_id", "forename", "surname") .values("cardholder_id", "forename", "surname")
@ -238,45 +276,45 @@ class MostActiveMembers(BaseAccessReport):
.order_by("-access_count") .order_by("-access_count")
) )
return [
{ class BusiestDayOfWeekTable(tables.Table):
"cardholder id": count["cardholder_id"], timestamp__week_day = tables.Column("Week Day")
"name": " ".join( events = tables.Column()
n for n in [count["forename"], count["surname"]] if n is not None
), def render_timestamp__week_day(self, value):
"access count": count["access_count"], return calendar.day_name[(value - 2) % 7]
}
for count in counts
]
@register_report @register_report
class BusiestDayOfWeek(BaseAccessReport): class BusiestDayOfWeek(BaseAccessReport):
_report_name = "Busiest Day of the Week" _report_name = "Busiest Day of the Week"
table_pagination = False
table_class = BusiestDayOfWeekTable
def get_queryset(self): def get_queryset(self):
return [ return (
{ super()
"week day": calendar.day_name[(count["timestamp__week_day"] - 2) % 7],
"events": count["events"],
}
for count in super()
.get_queryset() .get_queryset()
.values("timestamp__week_day") .values("timestamp__week_day")
.annotate(events=Count("timestamp")) .annotate(events=Count("timestamp"))
] )
class BusiestTimeOfDayTable(tables.Table):
timestamp__hour = tables.TemplateColumn("{{ value }}:00", verbose_name="Hour")
events = tables.Column()
@register_report @register_report
class BusiestTimeOfDay(BaseAccessReport): class BusiestTimeOfDay(BaseAccessReport):
_report_name = "Busiest Time of Day" _report_name = "Busiest Time of Day"
paginate_by = 24 table_pagination = False
table_class = BusiestTimeOfDayTable
def get_queryset(self): def get_queryset(self):
return [ return (
{"hour": f'{count["timestamp__hour"]}:00', "events": count["events"]} super()
for count in super()
.get_queryset() .get_queryset()
.values("timestamp__hour") .values("timestamp__hour")
.annotate(events=Count("timestamp")) .annotate(events=Count("timestamp"))
] )

View File

@ -5,5 +5,6 @@
{% block title %}Event Report Index{% endblock %} {% block title %}Event Report Index{% endblock %}
{% block breadcrumbs %}<li class="breadcrumb-item active" aria-current="page">MW Event Reports</li>{% endblock %} {% block breadcrumbs %}<li class="breadcrumb-item active" aria-current="page">MW Event Reports</li>{% endblock %}
{% block content %} {% block content %}
{% include "membershipworks/components/download_table.dj.html" %}
{% render_table table %} {% render_table table %}
{% endblock %} {% endblock %}

View File

@ -13,6 +13,7 @@
<li class="breadcrumb-item active" aria-current="page">{{ month|date:"F" }}</li> <li class="breadcrumb-item active" aria-current="page">{{ month|date:"F" }}</li>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% include "membershipworks/components/download_table.dj.html" %}
{% render_table table %} {% render_table table %}
<nav aria-label="Page navigation"> <nav aria-label="Page navigation">
<ul class="pagination justify-content-center"> <ul class="pagination justify-content-center">

View File

@ -10,6 +10,7 @@
<li class="breadcrumb-item active" aria-current="page">{{ year|date:"Y" }}</li> <li class="breadcrumb-item active" aria-current="page">{{ year|date:"Y" }}</li>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% include "membershipworks/components/download_table.dj.html" %}
{% render_table table %} {% render_table table %}
<nav aria-label="Page navigation"> <nav aria-label="Page navigation">
<ul class="pagination justify-content-center"> <ul class="pagination justify-content-center">

View File

@ -16,6 +16,7 @@ from django.views.generic.dates import (
import django_tables2 as tables import django_tables2 as tables
from dal import autocomplete from dal import autocomplete
from django_tables2 import A, SingleTableMixin from django_tables2 import A, SingleTableMixin
from django_tables2.export.views import ExportMixin
from membershipworks.membershipworks_api import MembershipWorks from membershipworks.membershipworks_api import MembershipWorks
@ -118,11 +119,17 @@ class DurationColumn(tables.Column):
return None return None
return floatformat(value.total_seconds() / 60 / 60, -2) return floatformat(value.total_seconds() / 60 / 60, -2)
def value(self, value: timedelta):
if value is None:
return None
return value.total_seconds() / 60 / 60
class EventTable(tables.Table): class EventTable(tables.Table):
title = tables.Column( title = tables.Column(
linkify=lambda record: f"https://membershipworks.com/admin/#!event/admin/{record.url}" linkify=lambda record: f"https://membershipworks.com/admin/#!event/admin/{record.url}"
) )
occurred = tables.BooleanColumn(visible=False)
start = tables.DateColumn("N d, Y") start = tables.DateColumn("N d, Y")
duration = DurationColumn() duration = DurationColumn()
person_hours = DurationColumn() person_hours = DurationColumn()
@ -132,6 +139,7 @@ class EventTable(tables.Table):
model = EventExt model = EventExt
fields = ( fields = (
"title", "title",
"occurred",
"start", "start",
"instructor", "instructor",
"category", "category",
@ -158,13 +166,17 @@ class EventSummaryTable(tables.Table):
person_hours__sum = DurationColumn("Person Hours") person_hours__sum = DurationColumn("Person Hours")
class EventIndexReport(SingleTableMixin, PermissionRequiredMixin, ArchiveIndexView): class EventIndexReport(
ExportMixin, SingleTableMixin, PermissionRequiredMixin, ArchiveIndexView
):
permission_required = "membershipworks.view_eventext" permission_required = "membershipworks.view_eventext"
queryset = EventExt.objects.all() queryset = EventExt.objects.all()
date_field = "start" date_field = "start"
template_name = "membershipworks/event_index_report.dj.html" template_name = "membershipworks/event_index_report.dj.html"
make_object_list = True make_object_list = True
table_class = EventSummaryTable table_class = EventSummaryTable
export_formats = ("csv", "xlsx", "ods")
export_name = "mw_events_index"
def get_table_kwargs(self): def get_table_kwargs(self):
year_column = tables.DateColumn( year_column = tables.DateColumn(
@ -189,13 +201,19 @@ class EventIndexReport(SingleTableMixin, PermissionRequiredMixin, ArchiveIndexVi
) )
class EventYearReport(SingleTableMixin, PermissionRequiredMixin, YearArchiveView): class EventYearReport(
ExportMixin, SingleTableMixin, PermissionRequiredMixin, YearArchiveView
):
permission_required = "membershipworks.view_eventext" permission_required = "membershipworks.view_eventext"
queryset = EventExt.objects.all() queryset = EventExt.objects.all()
date_field = "start" date_field = "start"
template_name = "membershipworks/event_year_report.dj.html" template_name = "membershipworks/event_year_report.dj.html"
make_object_list = True make_object_list = True
table_class = EventSummaryTable table_class = EventSummaryTable
export_formats = ("csv", "xlsx", "ods")
def get_export_filename(self, export_format):
return f"mw_events_{self.get_year()}.{export_format}"
def get_table_kwargs(self): def get_table_kwargs(self):
month_column = tables.DateColumn( month_column = tables.DateColumn(
@ -220,9 +238,15 @@ class EventYearReport(SingleTableMixin, PermissionRequiredMixin, YearArchiveView
) )
class EventMonthReport(SingleTableMixin, PermissionRequiredMixin, MonthArchiveView): class EventMonthReport(
ExportMixin, SingleTableMixin, PermissionRequiredMixin, MonthArchiveView
):
permission_required = "membershipworks.view_eventext" permission_required = "membershipworks.view_eventext"
queryset = EventExt.objects.select_related("category", "instructor").all() queryset = EventExt.objects.select_related("category", "instructor").all()
date_field = "start" date_field = "start"
template_name = "membershipworks/event_month_report.dj.html" template_name = "membershipworks/event_month_report.dj.html"
table_class = EventTable table_class = EventTable
export_formats = ("csv", "xlsx", "ods")
def get_export_filename(self, export_format):
return f"mw_events_{self.get_year()}-{self.get_month():02}.{export_format}"

View File

@ -5,7 +5,7 @@
groups = ["default", "debug", "lint", "server", "typing", "dev"] groups = ["default", "debug", "lint", "server", "typing", "dev"]
strategy = ["cross_platform"] strategy = ["cross_platform"]
lock_version = "4.4.1" lock_version = "4.4.1"
content_hash = "sha256:b7ca762523a8ec97b04cc997ad7110cc9dcb77ffd1194d9d806a3a2784ccc62b" content_hash = "sha256:c0e5c80b47118152c5b0167a588d3d1d078b0f2a710b8fc97869052ca04e7874"
[[package]] [[package]]
name = "aiohttp" name = "aiohttp"
@ -368,6 +368,16 @@ files = [
{file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"},
] ]
[[package]]
name = "defusedxml"
version = "0.7.1"
requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
summary = "XML bomb protection for Python stdlib modules"
files = [
{file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"},
{file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"},
]
[[package]] [[package]]
name = "django" name = "django"
version = "5.0.1" version = "5.0.1"
@ -691,6 +701,16 @@ files = [
{file = "EditorConfig-0.12.3.tar.gz", hash = "sha256:57f8ce78afcba15c8b18d46b5170848c88d56fd38f05c2ec60dbbfcb8996e89e"}, {file = "EditorConfig-0.12.3.tar.gz", hash = "sha256:57f8ce78afcba15c8b18d46b5170848c88d56fd38f05c2ec60dbbfcb8996e89e"},
] ]
[[package]]
name = "et-xmlfile"
version = "1.1.0"
requires_python = ">=3.6"
summary = "An implementation of lxml.xmlfile for the standard library"
files = [
{file = "et_xmlfile-1.1.0-py3-none-any.whl", hash = "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada"},
{file = "et_xmlfile-1.1.0.tar.gz", hash = "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c"},
]
[[package]] [[package]]
name = "executing" name = "executing"
version = "2.0.1" version = "2.0.1"
@ -1172,6 +1192,17 @@ files = [
{file = "nh3-0.2.15.tar.gz", hash = "sha256:d1e30ff2d8d58fb2a14961f7aac1bbb1c51f9bdd7da727be35c63826060b0bf3"}, {file = "nh3-0.2.15.tar.gz", hash = "sha256:d1e30ff2d8d58fb2a14961f7aac1bbb1c51f9bdd7da727be35c63826060b0bf3"},
] ]
[[package]]
name = "odfpy"
version = "1.4.1"
summary = "Python API and tools to manipulate OpenDocument files"
dependencies = [
"defusedxml",
]
files = [
{file = "odfpy-1.4.1.tar.gz", hash = "sha256:db766a6e59c5103212f3cc92ec8dd50a0f3a02790233ed0b52148b70d3c438ec"},
]
[[package]] [[package]]
name = "openapi-client-udm" name = "openapi-client-udm"
version = "1.0.2" version = "1.0.2"
@ -1187,6 +1218,19 @@ files = [
{file = "openapi_client_udm-1.0.2-py3-none-any.whl", hash = "sha256:453d4fe405542729cd307005e4b9710ae475693c6795616b1568e045114e1be5"}, {file = "openapi_client_udm-1.0.2-py3-none-any.whl", hash = "sha256:453d4fe405542729cd307005e4b9710ae475693c6795616b1568e045114e1be5"},
] ]
[[package]]
name = "openpyxl"
version = "3.1.2"
requires_python = ">=3.6"
summary = "A Python library to read/write Excel 2010 xlsx/xlsm files"
dependencies = [
"et-xmlfile",
]
files = [
{file = "openpyxl-3.1.2-py2.py3-none-any.whl", hash = "sha256:f91456ead12ab3c6c2e9491cf33ba6d08357d802192379bb482f1033ade496f5"},
{file = "openpyxl-3.1.2.tar.gz", hash = "sha256:a6f5977418eff3b2d5500d54d9db50c8277a368436f4e4f8ddb1be3422870184"},
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "23.0" version = "23.0"
@ -1630,6 +1674,32 @@ files = [
{file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"},
] ]
[[package]]
name = "tablib"
version = "3.5.0"
requires_python = ">=3.8"
summary = "Format agnostic tabular data library (XLS, JSON, YAML, CSV, etc.)"
files = [
{file = "tablib-3.5.0-py3-none-any.whl", hash = "sha256:9821caa9eca6062ff7299fa645e737aecff982e6b2b42046928a6413c8dabfd9"},
{file = "tablib-3.5.0.tar.gz", hash = "sha256:f6661dfc45e1d4f51fa8a6239f9c8349380859a5bfaa73280645f046d6c96e33"},
]
[[package]]
name = "tablib"
version = "3.5.0"
extras = ["ods", "xlsx"]
requires_python = ">=3.8"
summary = "Format agnostic tabular data library (XLS, JSON, YAML, CSV, etc.)"
dependencies = [
"odfpy",
"openpyxl>=2.6.0",
"tablib==3.5.0",
]
files = [
{file = "tablib-3.5.0-py3-none-any.whl", hash = "sha256:9821caa9eca6062ff7299fa645e737aecff982e6b2b42046928a6413c8dabfd9"},
{file = "tablib-3.5.0.tar.gz", hash = "sha256:f6661dfc45e1d4f51fa8a6239f9c8349380859a5bfaa73280645f046d6c96e33"},
]
[[package]] [[package]]
name = "tinycss2" name = "tinycss2"
version = "1.1.1" version = "1.1.1"

View File

@ -30,6 +30,7 @@ dependencies = [
"django-nh3~=0.1", "django-nh3~=0.1",
"nh3~=0.2", "nh3~=0.2",
"django-tables2~=2.7", "django-tables2~=2.7",
"tablib[ods,xlsx]~=3.5",
] ]
requires-python = ">=3.11" requires-python = ">=3.11"