membershipworks: Add ability for instructors to generate and submit event invoices
Some checks failed
Ruff / ruff (push) Successful in 1m23s
Test / test (push) Failing after 6m7s

This commit is contained in:
Adam Goldsmith 2024-04-14 01:21:32 -04:00
parent 60e7fc90aa
commit 0ce441336f
19 changed files with 639 additions and 82 deletions

View File

@ -42,6 +42,7 @@ INSTALLED_APPS = [
"django_filters", "django_filters",
"django_db_views", "django_db_views",
"django_mysql", "django_mysql",
"django_sendfile",
"django_bootstrap5", "django_bootstrap5",
"tasks.apps.TasksConfig", "tasks.apps.TasksConfig",
"rentals.apps.RentalsConfig", "rentals.apps.RentalsConfig",
@ -121,6 +122,7 @@ LOGGING = {
MEDIA_ROOT = "media" MEDIA_ROOT = "media"
MEDIA_URL = "media/" MEDIA_URL = "media/"
SENDFILE_ROOT = str(Path(__file__).parents[2] / "media" / "protected")
WIKI_URL = "https://wiki.claremontmakerspace.org" WIKI_URL = "https://wiki.claremontmakerspace.org"

View File

@ -21,3 +21,5 @@ MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware") # noqa:
configure_hypothesis_profiles() configure_hypothesis_profiles()
settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev")) settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev"))
SENDFILE_BACKEND = "django_sendfile.backends.development"

View File

@ -48,3 +48,6 @@ AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
) )
AUTH_LDAP_GROUP_TYPE = PosixGroupType() AUTH_LDAP_GROUP_TYPE = PosixGroupType()
AUTH_LDAP_MIRROR_GROUPS = True AUTH_LDAP_MIRROR_GROUPS = True
SENDFILE_BACKEND = "django_sendfile.backends.nginx"
SENDFILE_URL = "/media/protected"

View File

@ -2,12 +2,15 @@ from django.urls import reverse
import dashboard import dashboard
from dashboard import Link from dashboard import Link
from membershipworks.models import EventExt, Member
@dashboard.register @dashboard.register
class MembershipworksDashboardFragment(dashboard.LinksCardDashboardFragment): class MembershipworksDashboardFragment(dashboard.LinksCardDashboardFragment):
name = "MembershipWorks" name = "MembershipWorks"
@property
def links(self):
links = [ links = [
Link( Link(
"Upcoming Events", "Upcoming Events",
@ -31,3 +34,16 @@ class MembershipworksDashboardFragment(dashboard.LinksCardDashboardFragment):
permission="membershipworks.view_member", 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

38
membershipworks/forms.py Normal file
View File

@ -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 <a href="mailto:info@claremontmakerspace.org">info@claremontmakerspace.org</a> 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 <a href="https://claremontmakerspace.org/membersonly/">change your payment information here</a>. 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()

View File

@ -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 <invoices@claremontmakerspace.org>",
to=to,
reply_to=["Claremont MakerSpace <Info@ClaremontMakerSpace.org>"],
)
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),
]

View File

@ -4,6 +4,7 @@ from typing import Optional
import django.core.mail.message import django.core.mail.message
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser
from django.db import models from django.db import models
from django.db.models import ( from django.db.models import (
Case, Case,
@ -20,6 +21,7 @@ from django.db.models import (
When, When,
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
import nh3 import nh3
@ -550,6 +552,15 @@ class EventExt(Event):
) )
details = models.JSONField(null=True, blank=True) 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: class Meta:
verbose_name = "event" verbose_name = "event"
ordering = ["-start"] ordering = ["-start"]

View File

@ -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%;
}

View File

@ -0,0 +1,6 @@
<p>
New invoice received from
<b>{{ invoice.event.instructor }}</b> on <b>{{ invoice.date_submitted }}</b>.
</p>
{% include "membershipworks/email/event_invoice_details_fragment.dj.html" %}

View File

@ -0,0 +1,19 @@
<p>
<div>
<b>Invoice #:</b> {{ invoice.uuid }}
</div>
<div>
<b>Event id:</b> {{ invoice.event.eid }}
</div>
<div>
<b>Event title:</b> {{ invoice.event }}
</div>
<div>
<b>Event duration:</b> {{ invoice.event.start }} - {{ invoice.event.end }}
</div>
<div>
<a href="{{ event_url }}">
<b>View this event and invoice in CMSManage</b>
</a>
</div>
</p>

View File

@ -0,0 +1,5 @@
<p>
Your invoice for <b>{{ invoice.event }}</b> has been received. A copy is attached for your records.
</p>
{% include "membershipworks/email/event_invoice_details_fragment.dj.html" %}

View File

@ -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 %}
<div class="container">
{% include "membershipworks/event_invoice.dj.html" %}
<div class="card w-auto mt-5">
<div class="card-body">
{% if event.invoice %}
<div class="card-text text-center">
<p>
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 %}
</p>
<a class="btn btn-primary"
href="{% url 'membershipworks:event-invoice-pdf' event.invoice.pk %}">View PDF</a>
</div>
{% elif event.total_due_to_instructor is None %}
<p class="card-text text-center">
This event is missing required information to generate an invoice. Please contact us at <a href="mailto:info@claremontmakerspace.org">info@claremontmakerspace.org</a>.
</p>
{% elif user_is_instructor %}
<form method="post" class="card-text">
{% csrf_token %}
{% bootstrap_form form %}
<div class="text-center">{% bootstrap_button button_type="submit" content="Submit Invoice" %}</div>
</form>
{% else %}
<p>No invoice has been created for this event, and you are not listed as the the instructor.</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -1,16 +1,14 @@
{% extends "base.dj.html" %}
{% load nh3_tags %} {% load nh3_tags %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block title %}Event Invoice for {{ event.details.ttl|nh3 }}{% endblock %} <div class="row">
{% block admin_link %} <div class="col-12 col-md-7">
{% url 'admin:membershipworks_eventext_change' event.pk %}
{% endblock %}
{% block content %}
<p> <p>
<b>Event Name:</b> {{ event.details.ttl|nh3 }} <b>Event Name:</b> {{ event.details.ttl|nh3 }}
</p> </p>
<p>
<b>Event ID:</b> {{ event.eid }}
</p>
<p> <p>
<b>Instructor:</b> {{ event.instructor }} <b>Instructor:</b> {{ event.instructor }}
</p> </p>
@ -19,11 +17,11 @@
{% if meeting_times|length == 0 %} {% if meeting_times|length == 0 %}
<b>Dates of Event:</b> Not set <b>Dates of Event:</b> Not set
{% elif meeting_times|length == 1 %} {% elif meeting_times|length == 1 %}
<b>Date of Event:</b> {{ meeting_times.0.start }} - {{ meeting_times.0.end }} <b>Date of Event:</b> {{ meeting_times.0.start }} {{ meeting_times.0.end }}
{% else %} {% else %}
<b>Dates of Event:</b> <b>Dates of Event:</b>
<ul> <ul>
{% for meeting_time in meeting_times %}<li>{{ meeting_time.start }} - {{ meeting_time.end }}</li>{% endfor %} {% for meeting_time in meeting_times %}<li>{{ meeting_time.start }} {{ meeting_time.end }}</li>{% endfor %}
</ul> </ul>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
@ -32,7 +30,7 @@
<b>Attendees:</b> {{ event.details.cnt }}/{{ event.details.cap }} <b>Attendees:</b> {{ event.details.cnt }}/{{ event.details.cap }}
</p> </p>
<p> <p>
<b>Materials Fee [m]:</b> <b>Materials Fee <span class="font-monospace fw-light">[m]</span>:</b>
{% if event.materials_fee_included_in_price is None %} {% if event.materials_fee_included_in_price is None %}
Unknown if included in price Unknown if included in price
{% elif event.materials_fee_included_in_price %} {% elif event.materials_fee_included_in_price %}
@ -46,7 +44,22 @@
{% endif %} {% endif %}
</p> </p>
<p> <p>
<b>Instructor Percentage [I]:</b> {{ event.instructor_percentage }} <b>Instructor Percentage <span class="font-monospace fw-light">[I]</span>:</b> {{ event.instructor_percentage }}
</p> </p>
{% render_table table "membershipworks/tables/invoice_table.dj.html" %} </div>
{% endblock %} <div class="vr d-none d-md-block m-4 p-0"></div>
<div class="col-12 col-md-4">
<div>
<h3>Remit to:</h3>
<div>{{ event.instructor.member.account_name }}</div>
<div>{{ event.instructor.member.address_street }}</div>
<div>
{{ event.instructor.member.address_city }},
{{ event.instructor.member.address_state_province }}
{{ event.instructor.member.address_postal_code }}
</div>
<div>{{ event.instructor.member.email }}</div>
</div>
</div>
</div>
{% render_table table "membershipworks/tables/invoice_table.dj.html" %}

View File

@ -0,0 +1,29 @@
{% load static %}
{% load nh3_tags %}
<head>
<meta charset="utf-8">
<meta name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap CSS -->
<link href="{% static 'bootstrap.min.css' %}" rel="stylesheet">
<link rel="stylesheet" href="{% static "membershipworks/css/event_invoice_pdf.css" %}" media="print">
<title>Event Invoice for {{ event.details.ttl|nh3 }}</title>
</head>
<body style="font-size: 10pt;">
<header>
<div>TwinState MakerSpaces, Inc.</div>
<div>PO Box 100</div>
<div>Lebanon, NH 03766-0100</div>
</header>
<footer>
<div class="left">Generated for {{ user }} on {{ now|date:"Y-m-d" }} {{ now|time:"H:i T" }} by CMSManage</div>
<div class="right">
Invoice ID: <span class="font-monospace text-nowrap">{{ invoice_uuid }}</span>
</div>
</footer>
{% include "membershipworks/event_invoice.dj.html" %}
</body>

View File

@ -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 %}

View File

@ -31,9 +31,19 @@ urlpatterns = [
name="event-month-report", name="event-month-report",
), ),
path( path(
"event-invoice/<eid>", "my-events/",
views.EventInvoiceView.as_view(), views.UserEventView.as_view(),
name="event-invoice", name="user-events",
),
path(
"event/<eid>",
views.EventDetailView.as_view(),
name="event-detail",
),
path(
"event/invoice/<uuid:uuid>.pdf",
views.EventInvoicePDFView.as_view(),
name="event-invoice-pdf",
), ),
path( path(
"event-attendees", "event-attendees",

View File

@ -1,13 +1,24 @@
import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any
from django.conf import settings 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 (
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 import OuterRef, Q, Subquery
from django.db.models.functions import TruncMonth, TruncYear from django.db.models.functions import TruncMonth, TruncYear
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.template.defaultfilters import floatformat 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.html import format_html
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
@ -16,18 +27,25 @@ from django.views.generic.dates import (
MonthArchiveView, MonthArchiveView,
YearArchiveView, YearArchiveView,
) )
from django.views.generic.detail import BaseDetailView
from django.views.generic.edit import FormMixin, ProcessFormView
import django_filters import django_filters
import django_tables2 as tables import django_tables2 as tables
import weasyprint
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_mysql.models import GroupConcat
from django_sendfile import sendfile
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
from django_weasyprint.utils import django_url_fetcher
from membershipworks.membershipworks_api import MembershipWorks 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): class MemberAutocomplete(autocomplete.Select2QuerySetView):
@ -137,7 +155,7 @@ class EventTable(tables.Table):
template_code=( template_code=(
'<a title="MembershipWorks" href="https://membershipworks.com/admin/#!event/admin/{{ record.url }}">{{ value }}</a> ' '<a title="MembershipWorks" href="https://membershipworks.com/admin/#!event/admin/{{ record.url }}">{{ value }}</a> '
'<a title="Admin" href="{% url "admin:membershipworks_eventext_change" record.pk %}"><i class="bi bi-pencil-square"></i></a> ' '<a title="Admin" href="{% url "admin:membershipworks_eventext_change" record.pk %}"><i class="bi bi-pencil-square"></i></a> '
'<a title="Invoice" href="{% url "membershipworks:event-invoice" record.pk %}"><i class="bi bi-receipt"></i></a> ' '<a title="Details" href="{% url "membershipworks:event-detail" record.pk %}"><i class="bi bi-receipt"></i></a> '
), ),
accessor="unescaped_title", accessor="unescaped_title",
) )
@ -149,6 +167,8 @@ class EventTable(tables.Table):
gross_revenue = tables.Column() gross_revenue = tables.Column()
total_due_to_instructor = tables.Column() total_due_to_instructor = tables.Column()
net_revenue = 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: class Meta:
model = EventExt model = EventExt
@ -275,7 +295,7 @@ class EventMonthReport(
return ( return (
super() super()
.get_table_data() .get_table_data()
.select_related("category", "instructor") .select_related("category", "instructor", "invoice")
.with_financials() .with_financials()
) )
@ -283,6 +303,42 @@ class EventMonthReport(
return f"mw_events_{self.get_year()}-{self.get_month():02}.{export_format}" 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): class InvoiceMoneyColumn(tables.columns.Column):
def render(self, value): def render(self, value):
return f"${super().render(value):.2f}" return f"${super().render(value):.2f}"
@ -333,26 +389,142 @@ class InvoiceTable(tables.Table):
class Meta: class Meta:
attrs = { attrs = {
"class": "table table-sm mx-auto w-auto",
"tbody": {"class": "table-group-divider"}, "tbody": {"class": "table-group-divider"},
"tfoot": {"class": "table-group-divider"}, "tfoot": {"class": "table-group-divider"},
} }
orderable = False orderable = False
class EventInvoiceView(SingleTableMixin, PermissionRequiredMixin, DetailView): class EventDetailView(
SingleTableMixin, FormMixin, AccessMixin, DetailView, ProcessFormView
):
permission_required = "membershipworks.view_eventext" permission_required = "membershipworks.view_eventext"
queryset = EventExt.objects.with_financials().all() queryset = EventExt.objects.with_financials().all()
pk_url_kwarg = "eid" pk_url_kwarg = "eid"
context_object_name = "event" context_object_name = "event"
template_name = "membershipworks/event_invoice.dj.html" template_name = "membershipworks/event_detail.dj.html"
table_pagination = False table_pagination = False
table_class = InvoiceTable 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): def get_table_data(self):
if self.display_instructor_version():
return self.object.ticket_types.group_by_ticket_type()
else:
return self.object.ticket_types.all() return self.object.ticket_types.all()
def get_table_kwargs(self): 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): class EventAttendeeTable(tables.Table):

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:b6c73855da789a2433a99ac698bd3e7d16ad5a37170752043afd14ca9212f228" content_hash = "sha256:8d03a3352b70059a88842e3f316ee61450b7690e39a4516b7ce8ebf22aaae5b2"
[[package]] [[package]]
name = "aiohttp" name = "aiohttp"
@ -659,6 +659,18 @@ files = [
{file = "django_recurrence-1.11.1-py3-none-any.whl", hash = "sha256:0c65f30872599b5813a9bab6952dada23c55894f28674490a753ada559f14bc5"}, {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]] [[package]]
name = "django-stubs" name = "django-stubs"
version = "4.2.7" version = "4.2.7"
@ -717,6 +729,20 @@ files = [
{file = "django_tables2-2.7.0-py2.py3-none-any.whl", hash = "sha256:99e06d966ca8ac69fd74092eb45c79a280dd5ca0ccb81395d96261f62128e1af"}, {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]] [[package]]
name = "django-widget-tweaks" name = "django-widget-tweaks"
version = "1.5.0" version = "1.5.0"

View File

@ -35,6 +35,8 @@ dependencies = [
"django-filter~=24.2", "django-filter~=24.2",
"django-db-views~=0.1", "django-db-views~=0.1",
"django-mysql~=4.12", "django-mysql~=4.12",
"django-weasyprint~=2.3",
"django-sendfile2~=0.7",
"django-bootstrap5~=23.4", "django-bootstrap5~=23.4",
] ]
requires-python = ">=3.11" requires-python = ">=3.11"