Compare commits

...

7 Commits

Author SHA1 Message Date
bbd4d8d700 paperwork: Fix CertificationVersion is_latest/is_current when filtered
All checks were successful
Ruff / ruff (push) Successful in 24s
Unsurprisingly, filtering breaks window functions. Subqueries are less
elegant, but actually work :)
2024-02-07 21:30:28 -05:00
c07e3ac07a paperwork: Add access verification report 2024-02-07 21:13:18 -05:00
43f992e2c3 paperwork: Remove suffixes from names in shopleads mailing list
Mailman3 doesn't have display names per list anymore, so this is
somewhat confusing
2024-02-07 13:40:04 -05:00
83da93b712 membershipworks: Add waiver and Instructor/Vendor reports 2024-02-07 00:27:07 -05:00
26514e60fb dashboard: Add more flexible Link Card dashboard fragment
with support for tooltips and accordions
2024-02-06 00:42:14 -05:00
387767204a membershipworks: Add upcoming events to dashboard 2024-02-05 19:51:55 -05:00
af70e0899f Update fmt command to use Ruff 2024-02-05 01:00:15 -05:00
18 changed files with 431 additions and 73 deletions

View File

@ -41,6 +41,7 @@ INSTALLED_APPS = [
"django_tables2", "django_tables2",
"django_filters", "django_filters",
"django_db_views", "django_db_views",
"django_mysql",
"tasks.apps.TasksConfig", "tasks.apps.TasksConfig",
"rentals.apps.RentalsConfig", "rentals.apps.RentalsConfig",
"membershipworks.apps.MembershipworksConfig", "membershipworks.apps.MembershipworksConfig",

View File

@ -1,3 +1,4 @@
import dataclasses
from typing import Any from typing import Any
from django.http import HttpRequest from django.http import HttpRequest
@ -10,6 +11,16 @@ def register(fragment):
return fragment return fragment
@dataclasses.dataclass
class Link:
text: str
href: str
tooltip: str | None = None
body: str | None = None
_: dataclasses.KW_ONLY
permission: str | None
class DashboardFragment: class DashboardFragment:
name: str name: str
template: str template: str
@ -18,3 +29,12 @@ class DashboardFragment:
def __init__(self, request: HttpRequest): def __init__(self, request: HttpRequest):
self.request = request self.request = request
class LinksCardDashboardFragment(DashboardFragment):
template = "dashboard/links_card.dj.html"
links: [Link] = []
@property
def context(self):
return {"links": self.links}

View File

@ -16,7 +16,7 @@
<div> <div>
<div class="card"> <div class="card">
<h5 class="card-header">{{ app }}</h5> <h5 class="card-header">{{ app }}</h5>
{% include app_dash.template with ctx=app_dash.context %} {% include app_dash.template with app=app ctx=app_dash.context %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -24,3 +24,9 @@
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block script %}
<script>
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]')
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl))
</script>
{% endblock %}

View File

@ -1,7 +1,28 @@
<ul class="list-group list-group-flush"> <div class="accordion accordion-flush" id="{{ app|slugify }}-dash">
{% for text, link in ctx.links.items %} {% for link in ctx.links %}
<li class="list-group-item"> {% if link.permission is None or link.permission in perms %}
<a class="card-link" href="{{ link }}">{{ text }}</a> <div class="accordion-item">
</li> <div class="accordion-header d-flex align-items-center">
<a {% if link.tooltip is not None %}data-bs-toggle="tooltip" title="{{ link.tooltip }}"{% endif %}
href="{{ link.href }}"
class="text-nowrap mx-3 p-1">{{ link.text }}</a>
{% if link.body is not None %}
<button class="accordion-button collapsed bg-transparent shadow-none py-1"
type="button"
data-bs-toggle="collapse"
data-bs-target="#{{ app|slugify }}-{{ forloop.counter }}"
aria-expanded="false"
aria-controls="{{ app|slugify }}-{{ forloop.counter }}"></button>
{% endif %}
</div>
{% if link.body is not None %}
<div id="{{ app|slugify }}-{{ forloop.counter }}"
class="accordion-collapse collapse"
data-bs-parent="#{{ app|slugify }}-dash">
<div class="accordion-body">{{ link.body }}</div>
</div>
{% endif %}
</div>
{% endif %}
{% endfor %} {% endfor %}
</ul> </div>

View File

@ -1,20 +1,20 @@
from typing import Any
import dashboard import dashboard
from dashboard import Link
from .views import REPORTS from .views import REPORTS
@dashboard.register @dashboard.register
class DoorControlDashboardFragment(dashboard.DashboardFragment): class DoorControlDashboardFragment(dashboard.LinksCardDashboardFragment):
name = "Door Controls" name = "Door Controls"
template = "dashboard/links_card.dj.html"
@property @property
def context(self) -> Any: def links(self) -> list[Link]:
return { return [
"links": dict(rt for report in REPORTS for rt in report._report_types()) Link(name, link, permission="doorcontrol.view_hidevent")
} for report in REPORTS
for name, link in report._report_types()
]
@property @property
def visible(self) -> bool: def visible(self) -> bool:

View File

@ -1,24 +1,31 @@
from typing import Any
from django.urls import reverse from django.urls import reverse
import dashboard import dashboard
from dashboard import Link
@dashboard.register @dashboard.register
class MembershipworksDashboardFragment(dashboard.DashboardFragment): class MembershipworksDashboardFragment(dashboard.LinksCardDashboardFragment):
name = "MembershipWorks" name = "MembershipWorks"
template = "dashboard/links_card.dj.html"
@property links = [
def context(self) -> Any: Link(
links = {} "Upcoming Events",
reverse("membershipworks:upcoming-events"),
if self.request.user.has_perm("membershipworks.view_event"): permission="membershipworks.view_event",
links["Event Report"] = reverse("membershipworks:event-index-report") tooltip="Generator for Wordpress posts",
links["Event Attendees"] = reverse("membershipworks:event-attendees") ),
Link(
return {"links": links} "Event Report",
reverse("membershipworks:event-index-report"),
permission="membershipworks.view_event",
),
Link(
"Event Attendees",
reverse("membershipworks:event-attendees"),
permission="membershipworks.view_event",
),
]
@property @property
def visible(self) -> bool: def visible(self) -> bool:

View File

@ -273,7 +273,7 @@ class Member(BaseModel):
if hasattr(user, "ldap_user"): if hasattr(user, "ldap_user"):
return cls.objects.get(uid=user.ldap_user.attrs["employeeNumber"][0]) return cls.objects.get(uid=user.ldap_user.attrs["employeeNumber"][0])
def sanitized_mailbox(self, name_ext: str = "", use_volunteer=False) -> str: def sanitized_mailbox(self, use_volunteer=False) -> str:
if use_volunteer and self.volunteer_email: if use_volunteer and self.volunteer_email:
email = self.volunteer_email email = self.volunteer_email
elif self.email: elif self.email:
@ -285,7 +285,7 @@ class Member(BaseModel):
return email return email
return django.core.mail.message.sanitize_address( return django.core.mail.message.sanitize_address(
(self.account_name + name_ext, email), settings.DEFAULT_CHARSET (self.account_name, email), settings.DEFAULT_CHARSET
) )

View File

@ -92,10 +92,7 @@ class DepartmentViewSet(viewsets.ModelViewSet):
# Add members to the Shop Leads mailing list, but don't configure it # Add members to the Shop Leads mailing list, but don't configure it
lists["ShopLeads"] = { lists["ShopLeads"] = {
"members": { "members": {
shoplead.sanitized_mailbox( shoplead.sanitized_mailbox(use_volunteer=True)
f" - {'/'.join(department.name for department in departments)}",
use_volunteer=True,
)
for shoplead, departments in shopleads.items() for shoplead, departments in shopleads.items()
}, },
} }

View File

@ -1,37 +1,61 @@
from typing import Any
from django.urls import reverse from django.urls import reverse
import dashboard import dashboard
from dashboard import Link
from membershipworks.models import Member from membershipworks.models import Member
from .models import Department from .models import Department
@dashboard.register @dashboard.register
class PaperworkDashboardFragment(dashboard.DashboardFragment): class PaperworkDashboardFragment(dashboard.LinksCardDashboardFragment):
name = "Paperwork" name = "Paperwork"
template = "dashboard/links_card.dj.html"
@property @property
def context(self) -> Any: def links(self) -> list[Link]:
links = {} links = [
Link(
"Waivers",
reverse("paperwork:waivers-report"),
permission="paperwork.view_waiver",
),
Link(
"Instructors and Vendors",
reverse("paperwork:instructors-and-vendor-report"),
permission="paperwork.view_instructororvendor",
),
Link(
"Access Verification",
reverse("paperwork:access-verification-report"),
permission="paperwork.view_certification",
),
]
member = Member.from_user(self.request.user) member = Member.from_user(self.request.user)
if member is not None: if member is not None:
links["Member Certifications"] = reverse( links.append(
"paperwork:member_certifications", kwargs={"uid": member.uid} dashboard.Link(
"Member Certifications",
reverse(
"paperwork:member_certifications", kwargs={"uid": member.uid}
),
permission=None,
)
) )
if self.request.user.is_superuser or ( if self.request.user.is_superuser or (
member is not None member is not None
and Department.objects.filter_by_shop_lead(member).exists() and Department.objects.filter_by_shop_lead(member).exists()
): ):
links["Department Certifications"] = reverse( links.append(
"paperwork:department_certifications" Link(
"Department Certifications",
reverse("paperwork:department_certifications"),
permission=None,
)
) )
return {"links": links} return links
@property @property
def visible(self) -> bool: def visible(self) -> bool:

View File

@ -5,8 +5,7 @@ from typing import TYPE_CHECKING, TypedDict
from django.conf import settings from django.conf import settings
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.db import models from django.db import models
from django.db.models import Q, Window from django.db.models import OuterRef, Q, Subquery
from django.db.models.functions import FirstValue
from django_stubs_ext import WithAnnotations from django_stubs_ext import WithAnnotations
from semver import VersionInfo from semver import VersionInfo
@ -142,23 +141,18 @@ class CertificationVersionAnnotations(TypedDict):
class CertificationVersionManager(models.Manager["CertificationVersion"]): class CertificationVersionManager(models.Manager["CertificationVersion"]):
def get_queryset(self) -> models.QuerySet["CertificationVersion"]: def get_queryset(self) -> models.QuerySet["CertificationVersion"]:
window = { qs = super().get_queryset()
"partition_by": "definition", department_versions = qs.filter(definition=OuterRef("definition")).order_by(
"order_by": [ "-major",
"-major", "-minor",
"-minor", "-patch",
"-patch", "-prerelease",
"-prerelease", )
],
} return qs.annotate(
return ( is_latest=Q(pk=Subquery(department_versions.values("pk")[:1])),
super() # TODO: should do a more correct comparison than just major version
.get_queryset() is_current=Q(major=Subquery(department_versions.values("major")[:1])),
.annotate(
is_latest=Q(pk=Window(FirstValue("pk"), **window)),
# TODO: should do a more correct comparison than just major version
is_current=Q(major=Window(FirstValue("major"), **window)),
)
) )

View File

@ -0,0 +1,12 @@
{% extends "base.dj.html" %}
{% load render_table from django_tables2 %}
{% block title %}Access Verification{% endblock %}
{% block admin_link %}
{% url 'admin:paperwork_certification_changelist' %}
{% endblock %}
{% block content %}
{% include "cmsmanage/components/download_table.dj.html" %}
{% render_table table %}
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "base.dj.html" %}
{% load render_table from django_tables2 %}
{% block title %}Instructors and Vendors{% endblock %}
{% block admin_link %}
{% url 'admin:paperwork_instructororvendor_changelist' %}
{% endblock %}
{% block content %}
{% include "cmsmanage/components/download_table.dj.html" %}
{% render_table table %}
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "base.dj.html" %}
{% load render_table from django_tables2 %}
{% block title %}Waiver Report{% endblock %}
{% block admin_link %}
{% url 'admin:paperwork_waiver_changelist' %}
{% endblock %}
{% block content %}
{% include "cmsmanage/components/download_table.dj.html" %}
{% render_table table %}
{% endblock %}

View File

@ -25,4 +25,19 @@ urlpatterns = [
autocomplete_views.CertificationVersionAutocomplete.as_view(), autocomplete_views.CertificationVersionAutocomplete.as_view(),
name="certification_version_autocomplete", name="certification_version_autocomplete",
), ),
path(
"waivers",
views.WaiverReport.as_view(),
name="waivers-report",
),
path(
"instructors-and-vendors",
views.InstructorOrVendorReport.as_view(),
name="instructors-and-vendor-report",
),
path(
"access-verification",
views.AccessVerificationReport.as_view(),
name="access-verification-report",
),
] ]

View File

@ -1,16 +1,30 @@
from django.conf import settings from django.conf import settings
from django.contrib import staticfiles from django.contrib import staticfiles
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import models
from django.db.models import Case, Q, Value, When
from django.db.models.functions import Concat
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from django.views.generic import ListView from django.views.generic import ListView
import django_tables2 as tables
import requests import requests
import weasyprint import weasyprint
from django_mysql.models import GroupConcat
from django_tables2 import SingleTableMixin
from django_tables2.export.views import ExportMixin
from membershipworks.models import Member from membershipworks.models import Member
from .models import Certification, Department from .models import (
Certification,
CertificationVersion,
Department,
InstructorOrVendor,
Waiver,
)
WIKI_URL = settings.WIKI_URL WIKI_URL = settings.WIKI_URL
@ -98,3 +112,215 @@ def certification_pdf(request, cert_name):
"Content-Disposition": f'inline; filename="{filename}"', "Content-Disposition": f'inline; filename="{filename}"',
}, },
) )
class WarnEmptyColumn(tables.Column):
attrs = {
"td": {
"class": lambda value, bound_column: "table-danger"
if value == bound_column.default
else ""
}
}
class WaiverReportTable(tables.Table):
emergency_contact_name = WarnEmptyColumn()
emergency_contact_number = WarnEmptyColumn()
class Meta:
model = Waiver
fields = [
"name",
"date",
"emergency_contact_name",
"emergency_contact_number",
"waiver_version",
"guardian_name",
"guardian_relation",
"guardian_date",
]
class WaiverReport(ExportMixin, SingleTableMixin, PermissionRequiredMixin, ListView):
permission_required = "paperwork.view_waiver"
template_name = "paperwork/waiver_report.dj.html"
queryset = Waiver.objects.order_by("name").all()
table_class = WaiverReportTable
table_pagination = False
class InstructorOrVendorTable(tables.Table):
instructor_agreement_date = WarnEmptyColumn(
"Instructor Agreement Date(s)", default="Missing"
)
w9_date = WarnEmptyColumn(default="Missing")
class Meta:
model = InstructorOrVendor
fields = [
"name",
"instructor_agreement_date",
"w9_date",
"phone",
"email_address",
]
class InstructorOrVendorReport(
ExportMixin,
SingleTableMixin,
PermissionRequiredMixin,
ListView,
):
permission_required = "paperwork.view_instructororvendor"
template_name = "paperwork/instructor_or_vendor_report.dj.html"
queryset = InstructorOrVendor.objects.order_by("name").all()
table_class = InstructorOrVendorTable
export_formats = ("csv", "xlsx", "ods")
def get_table_data(self):
return (
super()
.get_table_data()
.values("name")
.annotate(
instructor_agreement_date=GroupConcat(
"instructor_agreement_date", distinct=True, ordering="asc"
),
w9_date=GroupConcat("w9_date", distinct=True, ordering="asc"),
phone=GroupConcat("phone", distinct=True, ordering="asc"),
email_address=GroupConcat(
"email_address", distinct=True, ordering="asc"
),
)
)
class ShopAccessErrorColumn(tables.Column):
def td_class(value):
if value.startswith("Has access but"):
return "table-danger"
elif value.startswith("Has cert but"):
return "table-warning"
else:
return ""
attrs = {"td": {"class": td_class}}
class AccessVerificationTable(tables.Table):
account_name = tables.Column()
access_card = tables.Column()
billing_method = tables.Column()
join_date = tables.DateColumn()
renewal_date = tables.DateColumn()
access_front_door = tables.BooleanColumn(verbose_name="Front Door")
access_studio_space = tables.BooleanColumn(verbose_name="Studio Space")
wood_shop_error = ShopAccessErrorColumn()
metal_shop_error = ShopAccessErrorColumn()
extended_hours_error = ShopAccessErrorColumn()
extended_hours_shops_error = ShopAccessErrorColumn()
storage_closet_error = ShopAccessErrorColumn()
class AccessVerificationReport(
ExportMixin,
SingleTableMixin,
PermissionRequiredMixin,
ListView,
):
permission_required = "paperwork.view_certification"
template_name = "paperwork/access_verification_report.dj.html"
table_class = AccessVerificationTable
export_formats = ("csv", "xlsx", "ods")
def get_queryset(self):
# TODO: could be done with subqueries if membershipworks was not a separate DB
def shop_error(access_field: str, shop_name: str):
member_list = list(
CertificationVersion.objects.filter(
is_current=True,
definition__department__name=shop_name,
certification__member__pk__isnull=False,
)
.values_list(
"certification__member__pk",
flat=True,
)
.distinct()
)
return Case(
When(
Q(**{access_field: True}) & ~Q(uid__in=member_list),
Value("Has access but no cert"),
),
When(
Q(**{access_field: False}) & Q(uid__in=member_list),
Value("Has cert but no access"),
),
default=None,
)
# TODO: could be a lot cleaner if membershipworks was not a separate DB
storage_closet_members = (
Member.objects.filter(
Member.objects.has_flag("label", "Volunteer: Desker")
| Q(billing_method__startswith="Desker")
)
.union(
*[
department.shop_lead_flag.members.all()
for department in Department.objects.filter(
shop_lead_flag__isnull=False
)
]
)
.values_list("pk", flat=True)
)
qs = (
Member.objects.with_is_active()
.filter(is_active=True)
.values(
"account_name",
"billing_method",
"join_date",
"renewal_date",
"access_front_door",
"access_studio_space",
access_card=Concat(
"access_card_facility_code",
Value("-", models.TextField()),
"access_card_number",
),
wood_shop_error=shop_error("access_wood_shop", "Wood Shop"),
metal_shop_error=shop_error("access_metal_shop", "Metal Shop"),
extended_hours_error=shop_error(
"access_front_door_and_studio_space_during_extended_hours",
"Closure/Lock-Up",
),
extended_hours_shops_error=shop_error(
"access_permitted_shops_during_extended_hours",
"Closure/Lock-Up",
),
storage_closet_error=Case(
When(
Q(access_storage_closet=True)
& ~Q(uid__in=storage_closet_members),
Value("Has access but not shop lead or desker"),
),
default=None,
),
)
.filter(
Q(access_front_door=False)
| Q(access_studio_space=False)
| Q(wood_shop_error__isnull=False)
| Q(metal_shop_error__isnull=False)
| Q(extended_hours_error__isnull=False)
| Q(extended_hours_shops_error__isnull=False)
| Q(storage_closet_error__isnull=False)
)
)
return qs

15
pdm.lock generated
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:e647dfe717684c87b3a51bc1738ae7eb3d370ada3908a48c5ead3547c36a78d5" content_hash = "sha256:88502778249494bd3f6fd407d51671f4e465065ec58427b60993d91d0808bdfd"
[[package]] [[package]]
name = "aiohttp" name = "aiohttp"
@ -499,6 +499,19 @@ files = [
{file = "django_markdownx-4.0.7-py2.py3-none-any.whl", hash = "sha256:c1975ae3053481d4c111abd38997a5b5bb89235a1e3215f995d835942925fe7b"}, {file = "django_markdownx-4.0.7-py2.py3-none-any.whl", hash = "sha256:c1975ae3053481d4c111abd38997a5b5bb89235a1e3215f995d835942925fe7b"},
] ]
[[package]]
name = "django-mysql"
version = "4.12.0"
requires_python = ">=3.8"
summary = "Django-MySQL extends Django's built-in MySQL and MariaDB support their specific features not available on other databases."
dependencies = [
"Django>=3.2",
]
files = [
{file = "django_mysql-4.12.0-py3-none-any.whl", hash = "sha256:1c188ee3a92590da21ce23351c309bb02df3b6611926521d312a9cdf6373c3d0"},
{file = "django_mysql-4.12.0.tar.gz", hash = "sha256:9a29b69ad30c85362984903379783b53731ee7b0cef4dfb4eb078f80e24f26d4"},
]
[[package]] [[package]]
name = "django-nh3" name = "django-nh3"
version = "0.1.1" version = "0.1.1"

View File

@ -33,6 +33,7 @@ dependencies = [
"tablib[ods,xlsx]~=3.5", "tablib[ods,xlsx]~=3.5",
"django-filter~=23.5", "django-filter~=23.5",
"django-db-views~=0.1", "django-db-views~=0.1",
"django-mysql~=4.12",
] ]
requires-python = ">=3.11" requires-python = ">=3.11"
@ -128,7 +129,7 @@ dev = [
[tool.pdm.scripts] [tool.pdm.scripts]
start = "./manage.py runserver" start = "./manage.py runserver"
fmt.shell = "black . && djlint --reformat ." fmt.shell = "ruff check --fix && ruff format . && djlint --reformat ."
[build-system] [build-system]
requires = ["pdm-backend"] requires = ["pdm-backend"]

View File

@ -1,15 +1,12 @@
from typing import Any
from django.urls import reverse from django.urls import reverse
import dashboard import dashboard
from dashboard import Link
@dashboard.register @dashboard.register
class RentalsDashboardFragment(dashboard.DashboardFragment): class RentalsDashboardFragment(dashboard.LinksCardDashboardFragment):
name = "Rentals" name = "Rentals"
template = "dashboard/links_card.dj.html" template = "dashboard/links_card.dj.html"
@property links = [Link("Locker Index", reverse("rentals:index"), permission=None)]
def context(self) -> Any:
return {"links": {"Locker Index": reverse("rentals:index")}}