Compare commits

...

8 Commits

16 changed files with 445 additions and 21 deletions

34
.gitea/workflows/test.yml Normal file
View File

@ -0,0 +1,34 @@
name: Test
on: [ push, pull_request ]
env:
DJANGO_SETTINGS_MODULE: cmsmanage.settings.ci
jobs:
test:
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-latest
services:
mariadb:
image: mariadb:latest
env:
MARIADB_ROOT_PASSWORD: whatever
ports:
- 3306:3306
healthcheck:
test: ["CMD", "healthcheck.sh", "--su-mysql", "--connect", "--innodb_initialized"]
steps:
- uses: actions/checkout@v4
- name: Setup PDM
uses: pdm-project/setup-pdm@v4
with:
cache: true
python-version: ~3.11
- name: Install apt dependencies
run: >-
sudo apt-get update &&
sudo apt-get -y install build-essential python3-dev libldap2-dev libsasl2-dev mariadb-client
- name: Install python dependencies
run: pdm install
- name: Run tests
run: pdm run ./manage.py test --parallel auto

View File

@ -141,3 +141,4 @@ Q_CLUSTER = {
# Django-Tables2 # Django-Tables2
DJANGO_TABLES2_TEMPLATE = "django_tables2/bootstrap5-responsive.html" DJANGO_TABLES2_TEMPLATE = "django_tables2/bootstrap5-responsive.html"
DJANGO_TABLES2_TABLE_ATTRS = {"class": "table mx-auto w-auto"}

33
cmsmanage/settings/ci.py Normal file
View File

@ -0,0 +1,33 @@
from .base import * # noqa: F403
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "aed7jee2kai1we9eithae0gaegh9ohthoh4phahk5bau4Ahxaijo3aicheex3qua"
# Database
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"HOST": "mariadb",
"NAME": "CMS_Database",
"USER": "root",
"PASSWORD": "whatever",
"OPTIONS": {
"charset": "utf8mb4",
},
},
"membershipworks": {
"ENGINE": "django.db.backends.mysql",
"HOST": "mariadb",
"NAME": "membershipworks",
"USER": "root",
"PASSWORD": "whatever",
"OPTIONS": {
"charset": "utf8mb4",
},
},
}

View File

@ -92,7 +92,6 @@ class Migration(migrations.Migration):
options={ options={
"db_table": "hidevent", "db_table": "hidevent",
"ordering": ("-timestamp",), "ordering": ("-timestamp",),
"managed": False,
}, },
), ),
migrations.AddConstraint( migrations.AddConstraint(

View File

@ -25,6 +25,11 @@ class MembershipworksDashboardFragment(dashboard.LinksCardDashboardFragment):
reverse("membershipworks:event-attendees"), reverse("membershipworks:event-attendees"),
permission="membershipworks.view_event", permission="membershipworks.view_event",
), ),
Link(
"Missing Paperwork",
reverse("membershipworks:missing-paperwork-report"),
permission="membershipworks.view_member",
),
] ]
@property @property

View File

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

View File

@ -1,51 +1,48 @@
from django.urls import path from django.urls import path
from .views import ( from . import views
EventAttendeeListView,
EventIndexReport,
EventInvoiceView,
EventMonthReport,
EventYearReport,
MemberAutocomplete,
upcoming_events,
)
app_name = "membershipworks" app_name = "membershipworks"
urlpatterns = [ urlpatterns = [
path( path(
"member-autocomplete/", "member-autocomplete/",
MemberAutocomplete.as_view(), views.MemberAutocomplete.as_view(),
name="member-autocomplete", name="member-autocomplete",
), ),
path( path(
"upcoming-events/", "upcoming-events/",
upcoming_events, views.upcoming_events,
name="upcoming-events", name="upcoming-events",
), ),
path( path(
"event-report/", "event-report/",
EventIndexReport.as_view(), views.EventIndexReport.as_view(),
name="event-index-report", name="event-index-report",
), ),
path( path(
"event-report/<int:year>/", "event-report/<int:year>/",
EventYearReport.as_view(), views.EventYearReport.as_view(),
name="event-year-report", name="event-year-report",
), ),
path( path(
"event-report/<int:year>/<int:month>/", "event-report/<int:year>/<int:month>/",
EventMonthReport.as_view(month_format="%m"), views.EventMonthReport.as_view(month_format="%m"),
name="event-month-report", name="event-month-report",
), ),
path( path(
"event-invoice/<eid>", "event-invoice/<eid>",
EventInvoiceView.as_view(), views.EventInvoiceView.as_view(),
name="event-invoice", name="event-invoice",
), ),
path( path(
"event-attendees", "event-attendees",
EventAttendeeListView.as_view(), views.EventAttendeeListView.as_view(),
name="event-attendees", name="event-attendees",
), ),
path(
"missing-paperwork",
views.MissingPaperworkReport.as_view(),
name="missing-paperwork-report",
),
] ]

View File

@ -4,7 +4,7 @@ from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import permission_required from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Subquery from django.db.models import OuterRef, Q, Subquery
from django.db.models.functions import TruncMonth, TruncYear from django.db.models.functions import TruncMonth, TruncYear
from django.shortcuts import render from django.shortcuts import render
from django.template.defaultfilters import floatformat from django.template.defaultfilters import floatformat
@ -19,6 +19,7 @@ import django_filters
import django_tables2 as tables import django_tables2 as tables
from dal import autocomplete from dal import autocomplete
from django_filters.views import BaseFilterView from django_filters.views import BaseFilterView
from django_mysql.models import GroupConcat
from django_tables2 import A, SingleTableMixin from django_tables2 import A, SingleTableMixin
from django_tables2.export.views import ExportMixin from django_tables2.export.views import ExportMixin
@ -365,3 +366,53 @@ class EventAttendeeListView(
def get_table_data(self): def get_table_data(self):
return super().get_table_data().values("name", "email").distinct() return super().get_table_data().values("name", "email").distinct()
class MissingPaperworkTable(tables.Table):
policy_agreement = tables.BooleanColumn()
authorize_charge = tables.BooleanColumn()
class Meta:
model = Member
fields = [
"first_name",
"last_name",
"membership",
"billing_method",
"join_date",
"membership_agreement_signed_and_on_file_date",
"waiver_form_signed_and_on_file_date",
"policy_agreement",
"authorize_charge",
]
class MissingPaperworkReport(
ExportMixin,
SingleTableMixin,
PermissionRequiredMixin,
ListView,
):
model = Member
permission_required = "membershipworks.view_member"
template_name = "membershipworks/missing_paperwork_report.dj.html"
table_class = MissingPaperworkTable
export_formats = ("csv", "xlsx", "ods")
def get_queryset(self):
qs = super().get_queryset()
return (
qs.with_is_active()
.filter(
Q(membership_agreement_signed_and_on_file_date__isnull=True)
| Q(waiver_form_signed_and_on_file_date__isnull=True),
is_active=True,
)
.annotate(
membership=Subquery(
qs.filter(
pk=OuterRef("pk"), flags__type__in=("level", "addon")
).values(m=GroupConcat("flags__name"))
),
)
)

View File

@ -29,6 +29,16 @@ class PaperworkDashboardFragment(dashboard.LinksCardDashboardFragment):
reverse("paperwork:access-verification-report"), reverse("paperwork:access-verification-report"),
permission="paperwork.view_certification", permission="paperwork.view_certification",
), ),
Link(
"Certifiers",
reverse("paperwork:certifiers-report"),
permission="paperwork.view_certification",
),
Link(
"Certification Count",
reverse("paperwork:certification-count-report"),
permission="paperwork.view_certification",
),
] ]
member = Member.from_user(self.request.user) member = Member.from_user(self.request.user)

View File

@ -0,0 +1,12 @@
{% extends "base.dj.html" %}
{% load render_table from django_tables2 %}
{% block title %}Certification Count Report{% 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 %}Certifiers Report{% 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 +1,130 @@
# Create your tests here. from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.test import Client
from hypothesis import given
from hypothesis import strategies as st
from hypothesis.extra.django import TestCase, from_model
from paperwork.models import (
Certification,
CertificationDefinition,
CertificationVersion,
Department,
InstructorOrVendor,
Waiver,
)
class PermissionRequiredViewTestCaseMixin:
permissions = []
path = None
@classmethod
def setUpTestData(cls):
User = get_user_model()
cls.client = Client()
cls.user_without_permission = User.objects.create_user(
username="user_without_permission"
)
cls.user_with_permission = User.objects.create_user(
username="user_with_permission"
)
resolved_permissions = [
Permission.objects.get(
content_type=ContentType.objects.get_for_model(permission["model"]),
codename=permission["codename"],
)
for permission in cls.permissions
]
cls.user_with_permission.user_permissions.add(*resolved_permissions)
def test_missing_permission(self) -> None:
self.client.force_login(self.user_without_permission)
response = self.client.get(self.path)
self.assertEqual(response.status_code, 403)
class WaiverReportTestCase(PermissionRequiredViewTestCaseMixin, TestCase):
permissions = [{"model": Waiver, "codename": "view_waiver"}]
path = "/paperwork/waivers"
@given(waivers=st.lists(from_model(Waiver, number=st.none())))
def test_waiver_report(self, waivers: list[Waiver]) -> None:
self.client.force_login(self.user_with_permission)
response = self.client.get(self.path)
self.assertEqual(response.status_code, 200)
class InstructorOrVendorReportTestCase(PermissionRequiredViewTestCaseMixin, TestCase):
permissions = [{"model": InstructorOrVendor, "codename": "view_instructororvendor"}]
path = "/paperwork/instructors-and-vendors"
@given(
instructors_or_vendors=st.lists(
from_model(InstructorOrVendor, serial=st.none())
)
)
def test_waiver_report(
self, instructors_or_vendors: list[InstructorOrVendor]
) -> None:
self.client.force_login(self.user_with_permission)
response = self.client.get(self.path)
self.assertEqual(response.status_code, 200)
@st.composite
def random_certifications(draw):
departments = draw(st.lists(from_model(Department), min_size=1))
definitions = draw(
st.lists(
from_model(
CertificationDefinition, department=st.sampled_from(departments)
),
min_size=1,
)
)
certification_versions = draw(
st.lists(
from_model(CertificationVersion, definition=st.sampled_from(definitions)),
min_size=1,
)
)
return draw(
st.lists(
from_model(
Certification,
number=st.none(),
certification_version=st.sampled_from(certification_versions),
)
)
)
class CertifiersReportTestCase(PermissionRequiredViewTestCaseMixin, TestCase):
permissions = [{"model": Certification, "codename": "view_certification"}]
path = "/paperwork/certifiers"
@given(certifications=random_certifications())
def test_certifers_report(self, certifications: list[Certification]) -> None:
self.client.force_login(self.user_with_permission)
response = self.client.get(self.path)
self.assertEqual(response.status_code, 200)
class CertificationCountReportTestCase(PermissionRequiredViewTestCaseMixin, TestCase):
permissions = [{"model": Certification, "codename": "view_certification"}]
path = "/paperwork/certification-count"
@given(certifications=random_certifications())
def test_certification_count_report(
self, certifications: list[Certification]
) -> None:
self.client.force_login(self.user_with_permission)
response = self.client.get(self.path)
self.assertEqual(response.status_code, 200)

View File

@ -40,4 +40,14 @@ urlpatterns = [
views.AccessVerificationReport.as_view(), views.AccessVerificationReport.as_view(),
name="access-verification-report", name="access-verification-report",
), ),
path(
"certifiers",
views.CertifiersReport.as_view(),
name="certifiers-report",
),
path(
"certification-count",
views.CertificationCountReport.as_view(),
name="certification-count-report",
),
] ]

View File

@ -3,7 +3,7 @@ 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.contrib.auth.mixins import PermissionRequiredMixin
from django.db import models from django.db import models
from django.db.models import Case, Q, Value, When from django.db.models import Case, Count, Max, Q, Value, When
from django.db.models.functions import Concat 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
@ -324,3 +324,72 @@ class AccessVerificationReport(
) )
) )
return qs return qs
class CertifiersTable(tables.Table):
certified_by = tables.Column()
certification_version__definition__name = tables.Column("Certification")
certification_version__definition__department__name = tables.Column("Department")
number_issued_on_this_tool = tables.Column()
last_issued_certification_date = tables.Column()
class CertifiersReport(
ExportMixin,
SingleTableMixin,
PermissionRequiredMixin,
ListView,
):
model = Certification
permission_required = "paperwork.view_certification"
template_name = "paperwork/certifiers_report.dj.html"
table_class = CertifiersTable
export_formats = ("csv", "xlsx", "ods")
def get_queryset(self):
qs = super().get_queryset()
return (
qs.values(
"certification_version__definition__department__name",
"certification_version__definition__name",
"certified_by",
)
.annotate(
number_issued_on_this_tool=Count("*"),
last_issued_certification_date=Max("date"),
)
.order_by(
"certification_version__definition__name",
"last_issued_certification_date",
)
)
class CertificationCountTable(tables.Table):
certification_version__definition__name = tables.Column("Certification")
certification_version__definition__department__name = tables.Column("Department")
total_issued = tables.Column()
class CertificationCountReport(
ExportMixin,
SingleTableMixin,
PermissionRequiredMixin,
ListView,
):
model = Certification
permission_required = "paperwork.view_certification"
template_name = "paperwork/certification_count_report.dj.html"
table_class = CertificationCountTable
export_formats = ("csv", "xlsx", "ods")
def get_queryset(self):
qs = super().get_queryset()
return (
qs.values(
"certification_version__definition__name",
"certification_version__definition__department__name",
)
.annotate(total_issued=Count("*"))
.order_by("certification_version__definition__name")
)

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:379d72c4be2ef3f09d6a3f9cf0517f784fa18df7367386dfc09cd40fa521a25f"
[[package]] [[package]]
name = "aiohttp" name = "aiohttp"
@ -908,6 +908,35 @@ files = [
{file = "httptools-0.6.1.tar.gz", hash = "sha256:c6e26c30455600b95d94b1b836085138e82f177351454ee841c148f93a9bad5a"}, {file = "httptools-0.6.1.tar.gz", hash = "sha256:c6e26c30455600b95d94b1b836085138e82f177351454ee841c148f93a9bad5a"},
] ]
[[package]]
name = "hypothesis"
version = "6.98.5"
requires_python = ">=3.8"
summary = "A library for property-based testing"
dependencies = [
"attrs>=22.2.0",
"sortedcontainers<3.0.0,>=2.1.0",
]
files = [
{file = "hypothesis-6.98.5-py3-none-any.whl", hash = "sha256:9449b9878116133269da4941b6a20e83003ef95503a2106365d4756ef3adc2b7"},
{file = "hypothesis-6.98.5.tar.gz", hash = "sha256:cfe4c2320580f97dd0d11cd3ee954a347764aec42aa0c95b7a0285c2b02447ab"},
]
[[package]]
name = "hypothesis"
version = "6.98.5"
extras = ["django"]
requires_python = ">=3.8"
summary = "A library for property-based testing"
dependencies = [
"django>=3.2",
"hypothesis==6.98.5",
]
files = [
{file = "hypothesis-6.98.5-py3-none-any.whl", hash = "sha256:9449b9878116133269da4941b6a20e83003ef95503a2106365d4756ef3adc2b7"},
{file = "hypothesis-6.98.5.tar.gz", hash = "sha256:cfe4c2320580f97dd0d11cd3ee954a347764aec42aa0c95b7a0285c2b02447ab"},
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.4" version = "3.4"
@ -1681,6 +1710,15 @@ files = [
{file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"},
] ]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
summary = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
files = [
{file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"},
{file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
]
[[package]] [[package]]
name = "soupsieve" name = "soupsieve"
version = "2.3.2.post1" version = "2.3.2.post1"
@ -1741,6 +1779,16 @@ files = [
{file = "tablib-3.5.0.tar.gz", hash = "sha256:f6661dfc45e1d4f51fa8a6239f9c8349380859a5bfaa73280645f046d6c96e33"}, {file = "tablib-3.5.0.tar.gz", hash = "sha256:f6661dfc45e1d4f51fa8a6239f9c8349380859a5bfaa73280645f046d6c96e33"},
] ]
[[package]]
name = "tblib"
version = "3.0.0"
requires_python = ">=3.8"
summary = "Traceback serialization library."
files = [
{file = "tblib-3.0.0-py3-none-any.whl", hash = "sha256:80a6c77e59b55e83911e1e607c649836a69c103963c5f28a46cbeef44acf8129"},
{file = "tblib-3.0.0.tar.gz", hash = "sha256:93622790a0a29e04f0346458face1e144dc4d32f493714c6c3dff82a4adb77e6"},
]
[[package]] [[package]]
name = "tinycss2" name = "tinycss2"
version = "1.1.1" version = "1.1.1"

View File

@ -125,6 +125,8 @@ debug = [
dev = [ dev = [
"django-extensions~=3.2", "django-extensions~=3.2",
"ipython~=8.21", "ipython~=8.21",
"hypothesis[django]~=6.98",
"tblib~=3.0",
] ]
[tool.pdm.scripts] [tool.pdm.scripts]