From 0ce441336f214db56035d185f61a4b2c51efa0c5 Mon Sep 17 00:00:00 2001
From: Adam Goldsmith
Date: Sun, 14 Apr 2024 01:21:32 -0400
Subject: [PATCH] membershipworks: Add ability for instructors to generate and
submit event invoices
---
cmsmanage/settings/base.py | 2 +
cmsmanage/settings/dev_base.py | 2 +
cmsmanage/settings/prod_base.py | 3 +
membershipworks/dashboard.py | 62 +++---
membershipworks/forms.py | 38 ++++
membershipworks/invoice_email.py | 65 ++++++
membershipworks/models.py | 11 +
.../membershipworks/css/event_invoice_pdf.css | 82 ++++++++
.../email/event_invoice_admin.dj.html | 6 +
.../event_invoice_details_fragment.dj.html | 19 ++
.../email/event_invoice_instructor.dj.html | 5 +
.../membershipworks/event_detail.dj.html | 46 +++++
.../membershipworks/event_invoice.dj.html | 107 +++++-----
.../membershipworks/event_invoice_pdf.dj.html | 29 +++
.../membershipworks/user_event_list.dj.html | 10 +
membershipworks/urls.py | 16 +-
membershipworks/views.py | 188 +++++++++++++++++-
pdm.lock | 28 ++-
pyproject.toml | 2 +
19 files changed, 639 insertions(+), 82 deletions(-)
create mode 100644 membershipworks/forms.py
create mode 100644 membershipworks/invoice_email.py
create mode 100644 membershipworks/static/membershipworks/css/event_invoice_pdf.css
create mode 100644 membershipworks/templates/membershipworks/email/event_invoice_admin.dj.html
create mode 100644 membershipworks/templates/membershipworks/email/event_invoice_details_fragment.dj.html
create mode 100644 membershipworks/templates/membershipworks/email/event_invoice_instructor.dj.html
create mode 100644 membershipworks/templates/membershipworks/event_detail.dj.html
create mode 100644 membershipworks/templates/membershipworks/event_invoice_pdf.dj.html
create mode 100644 membershipworks/templates/membershipworks/user_event_list.dj.html
diff --git a/cmsmanage/settings/base.py b/cmsmanage/settings/base.py
index cdd81c2..48839c7 100644
--- a/cmsmanage/settings/base.py
+++ b/cmsmanage/settings/base.py
@@ -42,6 +42,7 @@ INSTALLED_APPS = [
"django_filters",
"django_db_views",
"django_mysql",
+ "django_sendfile",
"django_bootstrap5",
"tasks.apps.TasksConfig",
"rentals.apps.RentalsConfig",
@@ -121,6 +122,7 @@ LOGGING = {
MEDIA_ROOT = "media"
MEDIA_URL = "media/"
+SENDFILE_ROOT = str(Path(__file__).parents[2] / "media" / "protected")
WIKI_URL = "https://wiki.claremontmakerspace.org"
diff --git a/cmsmanage/settings/dev_base.py b/cmsmanage/settings/dev_base.py
index dd6de70..78e602c 100644
--- a/cmsmanage/settings/dev_base.py
+++ b/cmsmanage/settings/dev_base.py
@@ -21,3 +21,5 @@ MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware") # noqa:
configure_hypothesis_profiles()
settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev"))
+
+SENDFILE_BACKEND = "django_sendfile.backends.development"
diff --git a/cmsmanage/settings/prod_base.py b/cmsmanage/settings/prod_base.py
index 5809b48..5c0b9fe 100644
--- a/cmsmanage/settings/prod_base.py
+++ b/cmsmanage/settings/prod_base.py
@@ -48,3 +48,6 @@ AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
)
AUTH_LDAP_GROUP_TYPE = PosixGroupType()
AUTH_LDAP_MIRROR_GROUPS = True
+
+SENDFILE_BACKEND = "django_sendfile.backends.nginx"
+SENDFILE_URL = "/media/protected"
diff --git a/membershipworks/dashboard.py b/membershipworks/dashboard.py
index fba4810..e71808c 100644
--- a/membershipworks/dashboard.py
+++ b/membershipworks/dashboard.py
@@ -2,32 +2,48 @@ from django.urls import reverse
import dashboard
from dashboard import Link
+from membershipworks.models import EventExt, Member
@dashboard.register
class MembershipworksDashboardFragment(dashboard.LinksCardDashboardFragment):
name = "MembershipWorks"
- 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",
- ),
- Link(
- "Missing Paperwork",
- reverse("membershipworks:missing-paperwork-report"),
- permission="membershipworks.view_member",
- ),
- ]
+ @property
+ def links(self):
+ 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",
+ ),
+ Link(
+ "Missing Paperwork",
+ reverse("membershipworks:missing-paperwork-report"),
+ permission="membershipworks.view_member",
+ ),
+ ]
+
+ member = Member.from_user(self.request.user)
+ if (
+ member is not None
+ and EventExt.objects.filter(instructor__member=member).exists()
+ ):
+ links.append(
+ Link(
+ "My Events", reverse("membershipworks:user-events"), permission=None
+ )
+ )
+
+ return links
diff --git a/membershipworks/forms.py b/membershipworks/forms.py
new file mode 100644
index 0000000..0eaf6ae
--- /dev/null
+++ b/membershipworks/forms.py
@@ -0,0 +1,38 @@
+from django import forms
+from django.contrib.auth.models import AbstractBaseUser
+
+from membershipworks.models import EventExt
+
+
+class EventInvoiceForm(forms.Form):
+ reviewed_invoice = forms.BooleanField(
+ label="I have reviewed this invoice and confirm that the course information, materials fee, course fees, enrollment information, invoice amount and all other details are correct.",
+ help_text='Please contact us at info@claremontmakerspace.org if corrections are required.',
+ required=True,
+ )
+ verified_payment_and_contact = forms.BooleanField(
+ label="I have verified my contact and payment information.",
+ help_text='You can change your payment information here . It may take up to an hour for your changes to take effect.',
+ required=True,
+ )
+
+ def __init__(self, *args, event: EventExt, user: AbstractBaseUser, **kwargs):
+ self.event = event
+ self.user = user
+ super().__init__(*args, **kwargs)
+
+ def clean(self):
+ if self.event.total_due_to_instructor is None:
+ raise forms.ValidationError(
+ "Event missing required information to generate invoice"
+ )
+
+ if not self.event.user_is_instructor(self.user):
+ raise forms.ValidationError(
+ "You are not the listed as the instructor for this event"
+ )
+
+ if hasattr(self.event, "eventinvoice"):
+ raise forms.ValidationError("Invoice already exists for this event")
+
+ return super().clean()
diff --git a/membershipworks/invoice_email.py b/membershipworks/invoice_email.py
new file mode 100644
index 0000000..446d430
--- /dev/null
+++ b/membershipworks/invoice_email.py
@@ -0,0 +1,65 @@
+from django.conf import settings
+from django.core.mail import EmailMessage, EmailMultiAlternatives
+from django.template import loader
+
+import mdformat
+from markdownify import markdownify
+
+from membershipworks.models import EventInvoice
+
+
+def make_multipart_email(
+ subject: str, html_body: str, to: tuple[str]
+) -> EmailMultiAlternatives:
+ plain_body = mdformat.text(markdownify(html_body), extensions={"tables"})
+
+ email = EmailMultiAlternatives(
+ subject,
+ plain_body,
+ from_email="CMS Invoices ",
+ to=to,
+ reply_to=["Claremont MakerSpace "],
+ )
+
+ email.attach_alternative(html_body, "text/html")
+ return email
+
+
+def make_instructor_email(
+ invoice: EventInvoice, pdf: bytes, event_url: str
+) -> EmailMessage:
+ template = loader.get_template(
+ "membershipworks/email/event_invoice_instructor.dj.html"
+ )
+ html_body = template.render({"invoice": invoice, "event_url": event_url})
+
+ message = make_multipart_email(
+ f'Your CMS instructor invoice has been received for event "{invoice.event}" {invoice.event.start} - {invoice.event.end}',
+ html_body,
+ (invoice.event.instructor.member.sanitized_mailbox(),),
+ )
+ message.attach(f"CMS_event_invoice_{invoice.uuid}.pdf", pdf, "application/pdf")
+ return message
+
+
+def make_admin_email(invoice: EventInvoice, pdf: bytes, event_url: str) -> EmailMessage:
+ template = loader.get_template("membershipworks/email/event_invoice_admin.dj.html")
+ html_body = template.render({"invoice": invoice, "event_url": event_url})
+
+ message = make_multipart_email(
+ f'CMS instructor invoice created for event "{invoice.event}" {invoice.event.start} - {invoice.event.end}',
+ html_body,
+ # TODO: should this be in database instead?
+ settings.INVOICE_HANDLERS,
+ )
+ message.attach(f"CMS_event_invoice_{invoice.uuid}.pdf", pdf, "application/pdf")
+ return message
+
+
+def make_invoice_emails(
+ invoice: EventInvoice, pdf: bytes, event_url: str
+) -> list[EmailMessage]:
+ return [
+ make_instructor_email(invoice, pdf, event_url),
+ make_admin_email(invoice, pdf, event_url),
+ ]
diff --git a/membershipworks/models.py b/membershipworks/models.py
index 462b664..4098eff 100644
--- a/membershipworks/models.py
+++ b/membershipworks/models.py
@@ -4,6 +4,7 @@ from typing import Optional
import django.core.mail.message
from django.conf import settings
+from django.contrib.auth.models import AbstractBaseUser
from django.db import models
from django.db.models import (
Case,
@@ -20,6 +21,7 @@ from django.db.models import (
When,
)
from django.db.models.functions import Coalesce
+from django.urls import reverse
from django.utils import timezone
import nh3
@@ -550,6 +552,15 @@ class EventExt(Event):
)
details = models.JSONField(null=True, blank=True)
+ def get_absolute_url(self) -> str:
+ return reverse("membershipworks:event-detail", kwargs={"eid": self.eid})
+
+ def user_is_instructor(self, user: AbstractBaseUser) -> bool:
+ member = Member.from_user(user)
+ if member is not None:
+ return self.instructor.member == member
+ return False
+
class Meta:
verbose_name = "event"
ordering = ["-start"]
diff --git a/membershipworks/static/membershipworks/css/event_invoice_pdf.css b/membershipworks/static/membershipworks/css/event_invoice_pdf.css
new file mode 100644
index 0000000..91ef4e6
--- /dev/null
+++ b/membershipworks/static/membershipworks/css/event_invoice_pdf.css
@@ -0,0 +1,82 @@
+@page {
+ size: letter portrait;
+ margin: 1.2in 0.7in;
+
+ @top-center {
+ content: "Event Invoice";
+ font-size: 1.2em;
+ color: #444;
+ vertical-align: bottom;
+ }
+
+ @top-left {
+ vertical-align: bottom;
+ content: element(header);
+ }
+
+ @top-right {
+ content: "";
+ vertical-align: bottom;
+ background-position: bottom;
+ background-image: url("https://claremontmakerspace.org/wp-content/uploads/2018/06/cms_logo.png");
+ background-repeat: no-repeat;
+ background-size: 100%;
+ width: 7em;
+ }
+
+ @bottom-left {
+ content: element(footer-left);
+ }
+
+ @bottom-right {
+ content: element(footer-right);
+ }
+}
+
+footer {
+ font-size: 8pt;
+}
+
+footer .left {
+ position: running(footer-left);
+ margin-right: 1em;
+}
+
+footer .right {
+ position: running(footer-right);
+ text-align: right;
+}
+
+header {
+ position: running(header);
+}
+
+/* TODO: probably should just fix this server side */
+:root {
+ --bs-font-sans-serif: roboto;
+}
+
+body {
+ margin-top: 1em;
+}
+
+p {
+ margin-bottom: 0.2em;
+}
+
+/* Bootstrap fixes for Weasyprint */
+.table-group-divider {
+ border-top: 2px solid currentcolor;
+}
+
+.d-md-block {
+ display: block !important;
+}
+.col-md-7 {
+ flex: 0 0 auto;
+ width: 58.33333333%;
+}
+.col-md-4 {
+ flex: 0 0 auto;
+ width: 33.33333333%;
+}
diff --git a/membershipworks/templates/membershipworks/email/event_invoice_admin.dj.html b/membershipworks/templates/membershipworks/email/event_invoice_admin.dj.html
new file mode 100644
index 0000000..528a4af
--- /dev/null
+++ b/membershipworks/templates/membershipworks/email/event_invoice_admin.dj.html
@@ -0,0 +1,6 @@
+
+ New invoice received from
+ {{ invoice.event.instructor }} on {{ invoice.date_submitted }} .
+
+
+{% include "membershipworks/email/event_invoice_details_fragment.dj.html" %}
diff --git a/membershipworks/templates/membershipworks/email/event_invoice_details_fragment.dj.html b/membershipworks/templates/membershipworks/email/event_invoice_details_fragment.dj.html
new file mode 100644
index 0000000..0fb7a3f
--- /dev/null
+++ b/membershipworks/templates/membershipworks/email/event_invoice_details_fragment.dj.html
@@ -0,0 +1,19 @@
+
+
+ Invoice #: {{ invoice.uuid }}
+
+
+ Event id: {{ invoice.event.eid }}
+
+
+ Event title: {{ invoice.event }}
+
+
+ Event duration: {{ invoice.event.start }} - {{ invoice.event.end }}
+
+
+
diff --git a/membershipworks/templates/membershipworks/email/event_invoice_instructor.dj.html b/membershipworks/templates/membershipworks/email/event_invoice_instructor.dj.html
new file mode 100644
index 0000000..a062dfd
--- /dev/null
+++ b/membershipworks/templates/membershipworks/email/event_invoice_instructor.dj.html
@@ -0,0 +1,5 @@
+
+ Your invoice for {{ invoice.event }} has been received. A copy is attached for your records.
+
+
+{% include "membershipworks/email/event_invoice_details_fragment.dj.html" %}
diff --git a/membershipworks/templates/membershipworks/event_detail.dj.html b/membershipworks/templates/membershipworks/event_detail.dj.html
new file mode 100644
index 0000000..98b04be
--- /dev/null
+++ b/membershipworks/templates/membershipworks/event_detail.dj.html
@@ -0,0 +1,46 @@
+{% extends "base.dj.html" %}
+
+{% load nh3_tags %}
+{% load django_bootstrap5 %}
+
+{% block title %}Event Invoice for {{ event.details.ttl|nh3 }}{% endblock %}
+{% block admin_link %}
+ {% url 'admin:membershipworks_eventext_change' event.pk %}
+{% endblock %}
+{% block content %}
+
+ {% include "membershipworks/event_invoice.dj.html" %}
+
+
+
+ {% if event.invoice %}
+
+
+ Invoice submitted on {{ event.invoice.date_submitted }}
+ for ${{ event.invoice.amount|floatformat:2 }},
+ {% if event.invoice.date_paid %}
+ paid on {{ event.invoice.date_paid }}
+ {% else %}
+ not paid yet
+ {% endif %}
+
+
View PDF
+
+ {% elif event.total_due_to_instructor is None %}
+
+ This event is missing required information to generate an invoice. Please contact us at info@claremontmakerspace.org .
+
+ {% elif user_is_instructor %}
+
+ {% else %}
+
No invoice has been created for this event, and you are not listed as the the instructor.
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/membershipworks/templates/membershipworks/event_invoice.dj.html b/membershipworks/templates/membershipworks/event_invoice.dj.html
index 8c1b9a5..58bee54 100644
--- a/membershipworks/templates/membershipworks/event_invoice.dj.html
+++ b/membershipworks/templates/membershipworks/event_invoice.dj.html
@@ -1,52 +1,65 @@
-{% extends "base.dj.html" %}
-
{% load nh3_tags %}
{% load render_table from django_tables2 %}
-{% block title %}Event Invoice for {{ event.details.ttl|nh3 }}{% endblock %}
-{% block admin_link %}
- {% url 'admin:membershipworks_eventext_change' event.pk %}
-{% endblock %}
-{% block content %}
-
- Event Name: {{ event.details.ttl|nh3 }}
-
-
- Instructor: {{ event.instructor }}
-
-
- {% with meeting_times=event.meeting_times.all %}
- {% if meeting_times|length == 0 %}
- Dates of Event: Not set
- {% elif meeting_times|length == 1 %}
- Date of Event: {{ meeting_times.0.start }} - {{ meeting_times.0.end }}
+
+
+
+ Event Name: {{ event.details.ttl|nh3 }}
+
+
+ Event ID: {{ event.eid }}
+
+
+ Instructor: {{ event.instructor }}
+
+
+ {% with meeting_times=event.meeting_times.all %}
+ {% if meeting_times|length == 0 %}
+ Dates of Event: Not set
+ {% elif meeting_times|length == 1 %}
+ Date of Event: {{ meeting_times.0.start }} – {{ meeting_times.0.end }}
+ {% else %}
+ Dates of Event:
+
+ {% for meeting_time in meeting_times %}{{ meeting_time.start }} – {{ meeting_time.end }} {% endfor %}
+
+ {% endif %}
+ {% endwith %}
+
+
+ Attendees: {{ event.details.cnt }}/{{ event.details.cap }}
+
+
+ Materials Fee [m] :
+ {% if event.materials_fee_included_in_price is None %}
+ Unknown if included in price
+ {% elif event.materials_fee_included_in_price %}
+ {% if event.materials_fee is not None %}
+ ${{ event.materials_fee|floatformat:2 }}
+ {% else %}
+ Not defined
+ {% endif %}
{% else %}
- Dates of Event:
-
- {% for meeting_time in meeting_times %}{{ meeting_time.start }} - {{ meeting_time.end }} {% endfor %}
-
+ Not collected by CMS
{% endif %}
- {% endwith %}
-
-
- Attendees: {{ event.details.cnt }}/{{ event.details.cap }}
-
-
- Materials Fee [m]:
- {% if event.materials_fee_included_in_price is None %}
- Unknown if included in price
- {% elif event.materials_fee_included_in_price %}
- {% if event.materials_fee is not None %}
- ${{ event.materials_fee|floatformat:2 }}
- {% else %}
- Not defined
- {% endif %}
- {% else %}
- Not collected by CMS
- {% endif %}
-
-
- Instructor Percentage [I]: {{ event.instructor_percentage }}
-
- {% render_table table "membershipworks/tables/invoice_table.dj.html" %}
-{% endblock %}
+
+
+ Instructor Percentage [I] : {{ event.instructor_percentage }}
+
+
+
+
+
+
Remit to:
+
{{ event.instructor.member.account_name }}
+
{{ event.instructor.member.address_street }}
+
+ {{ event.instructor.member.address_city }},
+ {{ event.instructor.member.address_state_province }}
+ {{ event.instructor.member.address_postal_code }}
+
+
{{ event.instructor.member.email }}
+
+
+
+{% render_table table "membershipworks/tables/invoice_table.dj.html" %}
diff --git a/membershipworks/templates/membershipworks/event_invoice_pdf.dj.html b/membershipworks/templates/membershipworks/event_invoice_pdf.dj.html
new file mode 100644
index 0000000..53dce4b
--- /dev/null
+++ b/membershipworks/templates/membershipworks/event_invoice_pdf.dj.html
@@ -0,0 +1,29 @@
+{% load static %}
+
+{% load nh3_tags %}
+
+
+
+
+
+
+
+ Event Invoice for {{ event.details.ttl|nh3 }}
+
+
+
+
+
+ Generated for {{ user }} on {{ now|date:"Y-m-d" }} {{ now|time:"H:i T" }} by CMSManage
+
+ Invoice ID: {{ invoice_uuid }}
+
+
+
+ {% include "membershipworks/event_invoice.dj.html" %}
+
diff --git a/membershipworks/templates/membershipworks/user_event_list.dj.html b/membershipworks/templates/membershipworks/user_event_list.dj.html
new file mode 100644
index 0000000..3d6f09c
--- /dev/null
+++ b/membershipworks/templates/membershipworks/user_event_list.dj.html
@@ -0,0 +1,10 @@
+{% extends "base.dj.html" %}
+
+{% load render_table from django_tables2 %}
+
+{% block title %}My Events{% endblock %}
+{% block content %}
+ {% include "cmsmanage/components/download_table.dj.html" %}
+
+ {% render_table table %}
+{% endblock %}
diff --git a/membershipworks/urls.py b/membershipworks/urls.py
index 8fb9777..1d2af01 100644
--- a/membershipworks/urls.py
+++ b/membershipworks/urls.py
@@ -31,9 +31,19 @@ urlpatterns = [
name="event-month-report",
),
path(
- "event-invoice/",
- views.EventInvoiceView.as_view(),
- name="event-invoice",
+ "my-events/",
+ views.UserEventView.as_view(),
+ name="user-events",
+ ),
+ path(
+ "event/",
+ views.EventDetailView.as_view(),
+ name="event-detail",
+ ),
+ path(
+ "event/invoice/.pdf",
+ views.EventInvoicePDFView.as_view(),
+ name="event-invoice-pdf",
),
path(
"event-attendees",
diff --git a/membershipworks/views.py b/membershipworks/views.py
index 4dc8d6c..b18487d 100644
--- a/membershipworks/views.py
+++ b/membershipworks/views.py
@@ -1,13 +1,24 @@
+import uuid
from datetime import datetime, timedelta
+from typing import Any
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
-from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.contrib.auth.mixins import (
+ AccessMixin,
+ PermissionRequiredMixin,
+)
+from django.core import mail
+from django.core.files.base import ContentFile
from django.db.models import OuterRef, Q, Subquery
from django.db.models.functions import TruncMonth, TruncYear
+from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.template.defaultfilters import floatformat
+from django.template.loader import render_to_string
+from django.utils import timezone
+from django.utils.functional import cached_property
from django.utils.html import format_html
from django.utils.safestring import SafeString
from django.views.generic import DetailView, ListView
@@ -16,18 +27,25 @@ from django.views.generic.dates import (
MonthArchiveView,
YearArchiveView,
)
+from django.views.generic.detail import BaseDetailView
+from django.views.generic.edit import FormMixin, ProcessFormView
import django_filters
import django_tables2 as tables
+import weasyprint
from dal import autocomplete
from django_filters.views import BaseFilterView
from django_mysql.models import GroupConcat
+from django_sendfile import sendfile
from django_tables2 import A, SingleTableMixin
from django_tables2.export.views import ExportMixin
+from django_weasyprint.utils import django_url_fetcher
from membershipworks.membershipworks_api import MembershipWorks
-from .models import EventAttendee, EventExt, Member
+from .forms import EventInvoiceForm
+from .invoice_email import make_invoice_emails
+from .models import EventAttendee, EventExt, EventInvoice, Member
class MemberAutocomplete(autocomplete.Select2QuerySetView):
@@ -137,7 +155,7 @@ class EventTable(tables.Table):
template_code=(
'{{ value }} '
' '
- ' '
+ ' '
),
accessor="unescaped_title",
)
@@ -149,6 +167,8 @@ class EventTable(tables.Table):
gross_revenue = tables.Column()
total_due_to_instructor = tables.Column()
net_revenue = tables.Column()
+ invoice__date_submitted = tables.DateColumn(verbose_name="Invoice Submitted")
+ invoice__date_paid = tables.DateColumn(verbose_name="Invoice Paid")
class Meta:
model = EventExt
@@ -275,7 +295,7 @@ class EventMonthReport(
return (
super()
.get_table_data()
- .select_related("category", "instructor")
+ .select_related("category", "instructor", "invoice")
.with_financials()
)
@@ -283,6 +303,42 @@ class EventMonthReport(
return f"mw_events_{self.get_year()}-{self.get_month():02}.{export_format}"
+class UserEventTable(EventTable):
+ title = tables.Column(linkify=True, accessor="unescaped_title")
+ instructor = None
+ person_hours = None
+ gross_revenue = None
+ net_revenue = None
+
+
+class UserEventView(SingleTableMixin, ListView):
+ model = EventExt
+ table_class = UserEventTable
+ export_formats = ("csv", "xlsx", "ods")
+ template_name = "membershipworks/user_event_list.dj.html"
+
+ @cached_property
+ def member(self) -> Member | None:
+ return Member.from_user(self.request.user)
+
+ def get_queryset(self):
+ if self.member is None:
+ return self.model.objects.none()
+ else:
+ return super().get_queryset().filter(instructor__member=self.member)
+
+ def get_table_data(self):
+ return (
+ super()
+ .get_table_data()
+ .select_related("category", "instructor", "invoice")
+ .with_financials()
+ )
+
+ def get_export_filename(self, export_format):
+ return f"my_events_{self.member.uid if self.member is not None else 'no-uid'}.{export_format}"
+
+
class InvoiceMoneyColumn(tables.columns.Column):
def render(self, value):
return f"${super().render(value):.2f}"
@@ -333,26 +389,142 @@ class InvoiceTable(tables.Table):
class Meta:
attrs = {
+ "class": "table table-sm mx-auto w-auto",
"tbody": {"class": "table-group-divider"},
"tfoot": {"class": "table-group-divider"},
}
orderable = False
-class EventInvoiceView(SingleTableMixin, PermissionRequiredMixin, DetailView):
+class EventDetailView(
+ SingleTableMixin, FormMixin, AccessMixin, DetailView, ProcessFormView
+):
permission_required = "membershipworks.view_eventext"
queryset = EventExt.objects.with_financials().all()
pk_url_kwarg = "eid"
context_object_name = "event"
- template_name = "membershipworks/event_invoice.dj.html"
+ template_name = "membershipworks/event_detail.dj.html"
table_pagination = False
table_class = InvoiceTable
+ form_class = EventInvoiceForm
+
+ def render_to_response(
+ self, context: dict[str, Any], **response_kwargs: Any
+ ) -> HttpResponse:
+ if self.request.user.has_perm(
+ self.permission_required
+ ) or self.object.user_is_instructor(self.request.user):
+ return super().render_to_response(context, **response_kwargs)
+ else:
+ return self.handle_no_permission()
+
+ def display_instructor_version(self):
+ return (
+ self.request.method == "POST" # generating a PDF
+ or not self.request.user.has_perm(self.permission_required)
+ or self.request.GET.get("instructor_view")
+ )
def get_table_data(self):
- return self.object.ticket_types.all()
+ if self.display_instructor_version():
+ return self.object.ticket_types.group_by_ticket_type()
+ else:
+ return self.object.ticket_types.all()
def get_table_kwargs(self):
- return {"event": self.object}
+ kwargs = super().get_table_kwargs()
+
+ kwargs["event"] = self.object
+ if self.display_instructor_version():
+ kwargs["exclude"] = [
+ "list_price",
+ ]
+
+ return kwargs
+
+ def get_success_url(self):
+ return self.request.build_absolute_uri()
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["user_is_instructor"] = self.object.user_is_instructor(
+ self.request.user
+ )
+ return context
+
+ def post(self, request, *args, **kwargs):
+ self.object = self.get_object()
+ return super().post(request, *args, **kwargs)
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs["event"] = self.object
+ kwargs["user"] = self.request.user
+ return kwargs
+
+ def form_valid(self, form: EventInvoiceForm):
+ self.object = self.get_object()
+ event = self.object
+
+ invoice_uuid = uuid.uuid4()
+
+ pdf_context = self.get_context_data(object=event)
+ pdf_context.update({"now": timezone.now(), "invoice_uuid": invoice_uuid})
+ weasy_html = weasyprint.HTML(
+ string=render_to_string(
+ "membershipworks/event_invoice_pdf.dj.html",
+ context=pdf_context,
+ request=self.request,
+ ),
+ url_fetcher=django_url_fetcher,
+ base_url="file://",
+ )
+ pdf = weasy_html.write_pdf()
+ # the result will be None only if a target was provided
+ assert pdf is not None
+
+ # NOTE: this is only saved AFTER the emails are successfully sent
+ invoice = EventInvoice(
+ uuid=invoice_uuid,
+ event=event,
+ # NOTE: this needs to be resolved before the object is
+ # saved, so cannot use the Now() db function
+ date_submitted=timezone.now(),
+ amount=event.total_due_to_instructor,
+ )
+ # removed), currently used in event_invoice_admin.dj.html.
+
+ emails = make_invoice_emails(
+ invoice, pdf, self.request.build_absolute_uri(event.get_absolute_url())
+ )
+ with mail.get_connection() as conn:
+ conn.send_messages(emails)
+
+ # this also saves the invoice object
+ invoice.pdf.save(f"{event.eid}.pdf", ContentFile(pdf))
+
+ messages.success(
+ self.request,
+ "Created Invoice! You should receive a confirmation email shortly.",
+ )
+
+ return super().form_valid(form)
+
+
+class EventInvoicePDFView(AccessMixin, BaseDetailView):
+ model = EventInvoice
+ pk_url_kwarg = "uuid"
+
+ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
+ invoice = self.get_object()
+ if request.user.has_perm(
+ "membershipworks.view_eventinvoice"
+ ) or invoice.event.user_is_instructor(request.user):
+ # return HttpResponse(invoice.pdf.path)
+ return sendfile(request, invoice.pdf.path, mimetype="application/pdf")
+
+ else:
+ return self.handle_no_permission()
class EventAttendeeTable(tables.Table):
diff --git a/pdm.lock b/pdm.lock
index 17b60cc..578973e 100644
--- a/pdm.lock
+++ b/pdm.lock
@@ -5,7 +5,7 @@
groups = ["default", "debug", "lint", "server", "typing", "dev"]
strategy = ["cross_platform"]
lock_version = "4.4.1"
-content_hash = "sha256:b6c73855da789a2433a99ac698bd3e7d16ad5a37170752043afd14ca9212f228"
+content_hash = "sha256:8d03a3352b70059a88842e3f316ee61450b7690e39a4516b7ce8ebf22aaae5b2"
[[package]]
name = "aiohttp"
@@ -659,6 +659,18 @@ files = [
{file = "django_recurrence-1.11.1-py3-none-any.whl", hash = "sha256:0c65f30872599b5813a9bab6952dada23c55894f28674490a753ada559f14bc5"},
]
+[[package]]
+name = "django-sendfile2"
+version = "0.7.1"
+summary = "Abstraction to offload file uploads to web-server (e.g. Apache with mod_xsendfile) once Django has checked permissions etc."
+dependencies = [
+ "django",
+]
+files = [
+ {file = "django-sendfile2-0.7.1.tar.gz", hash = "sha256:b5bec07f1c9b1875a60ea74beb306e9aba964bd8b54f00b4432cb77cc35bc58c"},
+ {file = "django_sendfile2-0.7.1-py3-none-any.whl", hash = "sha256:81971df1db77f688c4834b8540930902d0d929c64cf374bd604b81f5ac52c04a"},
+]
+
[[package]]
name = "django-stubs"
version = "4.2.7"
@@ -717,6 +729,20 @@ files = [
{file = "django_tables2-2.7.0-py2.py3-none-any.whl", hash = "sha256:99e06d966ca8ac69fd74092eb45c79a280dd5ca0ccb81395d96261f62128e1af"},
]
+[[package]]
+name = "django-weasyprint"
+version = "2.3.0"
+requires_python = ">=3.8"
+summary = "Django WeasyPrint CBV"
+dependencies = [
+ "Django>=3.2",
+ "WeasyPrint>=53",
+]
+files = [
+ {file = "django-weasyprint-2.3.0.tar.gz", hash = "sha256:2f849e15bfd6c1b2a58512097b9042eddf3533651d37d2e096cd6f7d8be6442b"},
+ {file = "django_weasyprint-2.3.0-py3-none-any.whl", hash = "sha256:807cb3b16332123d97c8bbe2ac9c70286103fe353235351803ffd33b67284735"},
+]
+
[[package]]
name = "django-widget-tweaks"
version = "1.5.0"
diff --git a/pyproject.toml b/pyproject.toml
index 4191d3e..aa47e2c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,6 +35,8 @@ dependencies = [
"django-filter~=24.2",
"django-db-views~=0.1",
"django-mysql~=4.12",
+ "django-weasyprint~=2.3",
+ "django-sendfile2~=0.7",
"django-bootstrap5~=23.4",
]
requires-python = ">=3.11"