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 }} +
+
+ + View this event and invoice in CMSManage + +
+

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 %} +
+ {% csrf_token %} + {% bootstrap_form form %} +
{% bootstrap_button button_type="submit" content="Submit Invoice" %}
+
+ {% 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 }} + + + +
+
TwinState MakerSpaces, Inc.
+
PO Box 100
+
Lebanon, NH 03766-0100
+
+
+
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"