Compare commits

..

No commits in common. "bbd4d8d7003b80153d3486407f474491565bec1e" and "72a1ce8750ed74763bb38b26fd00bd6d64fae2dc" have entirely different histories.

18 changed files with 73 additions and 431 deletions

View File

@ -41,7 +41,6 @@ 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,4 +1,3 @@
import dataclasses
from typing import Any from typing import Any
from django.http import HttpRequest from django.http import HttpRequest
@ -11,16 +10,6 @@ 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
@ -29,12 +18,3 @@ 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 app=app ctx=app_dash.context %} {% include app_dash.template with ctx=app_dash.context %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -24,9 +24,3 @@
</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,28 +1,7 @@
<div class="accordion accordion-flush" id="{{ app|slugify }}-dash"> <ul class="list-group list-group-flush">
{% for link in ctx.links %} {% for text, link in ctx.links.items %}
{% if link.permission is None or link.permission in perms %} <li class="list-group-item">
<div class="accordion-item"> <a class="card-link" href="{{ link }}">{{ text }}</a>
<div class="accordion-header d-flex align-items-center"> </li>
<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 %}
</div> </ul>

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.LinksCardDashboardFragment): class DoorControlDashboardFragment(dashboard.DashboardFragment):
name = "Door Controls" name = "Door Controls"
template = "dashboard/links_card.dj.html"
@property @property
def links(self) -> list[Link]: def context(self) -> Any:
return [ return {
Link(name, link, permission="doorcontrol.view_hidevent") "links": dict(rt for report in REPORTS for rt in report._report_types())
for report in REPORTS }
for name, link in report._report_types()
]
@property @property
def visible(self) -> bool: def visible(self) -> bool:

View File

@ -1,31 +1,24 @@
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.LinksCardDashboardFragment): class MembershipworksDashboardFragment(dashboard.DashboardFragment):
name = "MembershipWorks" name = "MembershipWorks"
template = "dashboard/links_card.dj.html"
links = [ @property
Link( def context(self) -> Any:
"Upcoming Events", links = {}
reverse("membershipworks:upcoming-events"),
permission="membershipworks.view_event", if self.request.user.has_perm("membershipworks.view_event"):
tooltip="Generator for Wordpress posts", links["Event Report"] = reverse("membershipworks:event-index-report")
), links["Event Attendees"] = reverse("membershipworks:event-attendees")
Link(
"Event Report", return {"links": links}
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, use_volunteer=False) -> str: def sanitized_mailbox(self, name_ext: str = "", 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, email), settings.DEFAULT_CHARSET (self.account_name + name_ext, email), settings.DEFAULT_CHARSET
) )

View File

@ -92,7 +92,10 @@ 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(use_volunteer=True) shoplead.sanitized_mailbox(
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,61 +1,37 @@
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.LinksCardDashboardFragment): class PaperworkDashboardFragment(dashboard.DashboardFragment):
name = "Paperwork" name = "Paperwork"
template = "dashboard/links_card.dj.html"
@property @property
def links(self) -> list[Link]: def context(self) -> Any:
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.append( links["Member Certifications"] = reverse(
dashboard.Link(
"Member Certifications",
reverse(
"paperwork:member_certifications", kwargs={"uid": member.uid} "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.append( links["Department Certifications"] = reverse(
Link( "paperwork:department_certifications"
"Department Certifications",
reverse("paperwork:department_certifications"),
permission=None,
)
) )
return links return {"links": links}
@property @property
def visible(self) -> bool: def visible(self) -> bool:

View File

@ -5,7 +5,8 @@ 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 OuterRef, Q, Subquery from django.db.models import Q, Window
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
@ -141,18 +142,23 @@ 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"]:
qs = super().get_queryset() window = {
department_versions = qs.filter(definition=OuterRef("definition")).order_by( "partition_by": "definition",
"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()
.get_queryset()
.annotate(
is_latest=Q(pk=Window(FirstValue("pk"), **window)),
# TODO: should do a more correct comparison than just major version # TODO: should do a more correct comparison than just major version
is_current=Q(major=Subquery(department_versions.values("major")[:1])), is_current=Q(major=Window(FirstValue("major"), **window)),
)
) )

View File

@ -1,12 +0,0 @@
{% 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

@ -1,12 +0,0 @@
{% 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

@ -1,12 +0,0 @@
{% 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,19 +25,4 @@ 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,30 +1,16 @@
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 ( from .models import Certification, Department
Certification,
CertificationVersion,
Department,
InstructorOrVendor,
Waiver,
)
WIKI_URL = settings.WIKI_URL WIKI_URL = settings.WIKI_URL
@ -112,215 +98,3 @@ 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

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:88502778249494bd3f6fd407d51671f4e465065ec58427b60993d91d0808bdfd" content_hash = "sha256:e647dfe717684c87b3a51bc1738ae7eb3d370ada3908a48c5ead3547c36a78d5"
[[package]] [[package]]
name = "aiohttp" name = "aiohttp"
@ -499,19 +499,6 @@ 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,7 +33,6 @@ 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"
@ -129,7 +128,7 @@ dev = [
[tool.pdm.scripts] [tool.pdm.scripts]
start = "./manage.py runserver" start = "./manage.py runserver"
fmt.shell = "ruff check --fix && ruff format . && djlint --reformat ." fmt.shell = "black . && djlint --reformat ."
[build-system] [build-system]
requires = ["pdm-backend"] requires = ["pdm-backend"]

View File

@ -1,12 +1,15 @@
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.LinksCardDashboardFragment): class RentalsDashboardFragment(dashboard.DashboardFragment):
name = "Rentals" name = "Rentals"
template = "dashboard/links_card.dj.html" template = "dashboard/links_card.dj.html"
links = [Link("Locker Index", reverse("rentals:index"), permission=None)] @property
def context(self) -> Any:
return {"links": {"Locker Index": reverse("rentals:index")}}