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_filters",
"django_db_views",
"django_mysql",
"tasks.apps.TasksConfig",
"rentals.apps.RentalsConfig",
"membershipworks.apps.MembershipworksConfig",

View File

@ -1,3 +1,4 @@
import dataclasses
from typing import Any
from django.http import HttpRequest
@ -10,6 +11,16 @@ def register(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:
name: str
template: str
@ -18,3 +29,12 @@ class DashboardFragment:
def __init__(self, request: HttpRequest):
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 class="card">
<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>
{% endif %}
@ -24,3 +24,9 @@
</div>
</div>
{% 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">
{% for text, link in ctx.links.items %}
<li class="list-group-item">
<a class="card-link" href="{{ link }}">{{ text }}</a>
</li>
<div class="accordion accordion-flush" id="{{ app|slugify }}-dash">
{% for link in ctx.links %}
{% if link.permission is None or link.permission in perms %}
<div class="accordion-item">
<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 %}
</ul>
</div>

View File

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

View File

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

View File

@ -273,7 +273,7 @@ class Member(BaseModel):
if hasattr(user, "ldap_user"):
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:
email = self.volunteer_email
elif self.email:
@ -285,7 +285,7 @@ class Member(BaseModel):
return email
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
lists["ShopLeads"] = {
"members": {
shoplead.sanitized_mailbox(
f" - {'/'.join(department.name for department in departments)}",
use_volunteer=True,
)
shoplead.sanitized_mailbox(use_volunteer=True)
for shoplead, departments in shopleads.items()
},
}

View File

@ -1,37 +1,61 @@
from typing import Any
from django.urls import reverse
import dashboard
from dashboard import Link
from membershipworks.models import Member
from .models import Department
@dashboard.register
class PaperworkDashboardFragment(dashboard.DashboardFragment):
class PaperworkDashboardFragment(dashboard.LinksCardDashboardFragment):
name = "Paperwork"
template = "dashboard/links_card.dj.html"
@property
def context(self) -> Any:
links = {}
def links(self) -> list[Link]:
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)
if member is not None:
links["Member Certifications"] = reverse(
"paperwork:member_certifications", kwargs={"uid": member.uid}
links.append(
dashboard.Link(
"Member Certifications",
reverse(
"paperwork:member_certifications", kwargs={"uid": member.uid}
),
permission=None,
)
)
if self.request.user.is_superuser or (
member is not None
and Department.objects.filter_by_shop_lead(member).exists()
):
links["Department Certifications"] = reverse(
"paperwork:department_certifications"
links.append(
Link(
"Department Certifications",
reverse("paperwork:department_certifications"),
permission=None,
)
)
return {"links": links}
return links
@property
def visible(self) -> bool:

View File

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

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(),
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.contrib import staticfiles
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.shortcuts import get_object_or_404, render
from django.views.generic import ListView
import django_tables2 as tables
import requests
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 .models import Certification, Department
from .models import (
Certification,
CertificationVersion,
Department,
InstructorOrVendor,
Waiver,
)
WIKI_URL = settings.WIKI_URL
@ -98,3 +112,215 @@ def certification_pdf(request, cert_name):
"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"]
strategy = ["cross_platform"]
lock_version = "4.4.1"
content_hash = "sha256:e647dfe717684c87b3a51bc1738ae7eb3d370ada3908a48c5ead3547c36a78d5"
content_hash = "sha256:88502778249494bd3f6fd407d51671f4e465065ec58427b60993d91d0808bdfd"
[[package]]
name = "aiohttp"
@ -499,6 +499,19 @@ files = [
{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]]
name = "django-nh3"
version = "0.1.1"

View File

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

View File

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