membershipworks: Add ability for instructors to generate and submit event invoices
This commit is contained in:
parent
60e7fc90aa
commit
0ce441336f
@ -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"
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
@ -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"
|
||||||
|
@ -2,32 +2,48 @@ 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"
|
||||||
|
|
||||||
links = [
|
@property
|
||||||
Link(
|
def links(self):
|
||||||
"Upcoming Events",
|
links = [
|
||||||
reverse("membershipworks:upcoming-events"),
|
Link(
|
||||||
permission="membershipworks.view_event",
|
"Upcoming Events",
|
||||||
tooltip="Generator for Wordpress posts",
|
reverse("membershipworks:upcoming-events"),
|
||||||
),
|
permission="membershipworks.view_event",
|
||||||
Link(
|
tooltip="Generator for Wordpress posts",
|
||||||
"Event Report",
|
),
|
||||||
reverse("membershipworks:event-index-report"),
|
Link(
|
||||||
permission="membershipworks.view_event",
|
"Event Report",
|
||||||
),
|
reverse("membershipworks:event-index-report"),
|
||||||
Link(
|
permission="membershipworks.view_event",
|
||||||
"Event Attendees",
|
),
|
||||||
reverse("membershipworks:event-attendees"),
|
Link(
|
||||||
permission="membershipworks.view_event",
|
"Event Attendees",
|
||||||
),
|
reverse("membershipworks:event-attendees"),
|
||||||
Link(
|
permission="membershipworks.view_event",
|
||||||
"Missing Paperwork",
|
),
|
||||||
reverse("membershipworks:missing-paperwork-report"),
|
Link(
|
||||||
permission="membershipworks.view_member",
|
"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
|
||||||
|
38
membershipworks/forms.py
Normal file
38
membershipworks/forms.py
Normal 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()
|
65
membershipworks/invoice_email.py
Normal file
65
membershipworks/invoice_email.py
Normal 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),
|
||||||
|
]
|
@ -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"]
|
||||||
|
@ -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%;
|
||||||
|
}
|
@ -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" %}
|
@ -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>
|
@ -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" %}
|
@ -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 %}
|
@ -1,52 +1,65 @@
|
|||||||
{% 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 %}
|
<p>
|
||||||
{% endblock %}
|
<b>Event Name:</b> {{ event.details.ttl|nh3 }}
|
||||||
{% block content %}
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<b>Event Name:</b> {{ event.details.ttl|nh3 }}
|
<b>Event ID:</b> {{ event.eid }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<b>Instructor:</b> {{ event.instructor }}
|
<b>Instructor:</b> {{ event.instructor }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{% with meeting_times=event.meeting_times.all %}
|
{% with meeting_times=event.meeting_times.all %}
|
||||||
{% 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 %}
|
||||||
|
<b>Dates of Event:</b>
|
||||||
|
<ul>
|
||||||
|
{% for meeting_time in meeting_times %}<li>{{ meeting_time.start }} – {{ meeting_time.end }}</li>{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Attendees:</b> {{ event.details.cnt }}/{{ event.details.cap }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Materials Fee <span class="font-monospace fw-light">[m]</span>:</b>
|
||||||
|
{% 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 %}
|
{% else %}
|
||||||
<b>Dates of Event:</b>
|
Not collected by CMS
|
||||||
<ul>
|
|
||||||
{% for meeting_time in meeting_times %}<li>{{ meeting_time.start }} - {{ meeting_time.end }}</li>{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
</p>
|
||||||
</p>
|
<p>
|
||||||
<p>
|
<b>Instructor Percentage <span class="font-monospace fw-light">[I]</span>:</b> {{ event.instructor_percentage }}
|
||||||
<b>Attendees:</b> {{ event.details.cnt }}/{{ event.details.cap }}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
<p>
|
<div class="vr d-none d-md-block m-4 p-0"></div>
|
||||||
<b>Materials Fee [m]:</b>
|
<div class="col-12 col-md-4">
|
||||||
{% if event.materials_fee_included_in_price is None %}
|
<div>
|
||||||
Unknown if included in price
|
<h3>Remit to:</h3>
|
||||||
{% elif event.materials_fee_included_in_price %}
|
<div>{{ event.instructor.member.account_name }}</div>
|
||||||
{% if event.materials_fee is not None %}
|
<div>{{ event.instructor.member.address_street }}</div>
|
||||||
${{ event.materials_fee|floatformat:2 }}
|
<div>
|
||||||
{% else %}
|
{{ event.instructor.member.address_city }},
|
||||||
Not defined
|
{{ event.instructor.member.address_state_province }}
|
||||||
{% endif %}
|
{{ event.instructor.member.address_postal_code }}
|
||||||
{% else %}
|
</div>
|
||||||
Not collected by CMS
|
<div>{{ event.instructor.member.email }}</div>
|
||||||
{% endif %}
|
</div>
|
||||||
</p>
|
</div>
|
||||||
<p>
|
</div>
|
||||||
<b>Instructor Percentage [I]:</b> {{ event.instructor_percentage }}
|
{% render_table table "membershipworks/tables/invoice_table.dj.html" %}
|
||||||
</p>
|
|
||||||
{% render_table table "membershipworks/tables/invoice_table.dj.html" %}
|
|
||||||
{% endblock %}
|
|
||||||
|
@ -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>
|
@ -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 %}
|
@ -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",
|
||||||
|
@ -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):
|
||||||
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):
|
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):
|
||||||
|
28
pdm.lock
28
pdm.lock
@ -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"
|
||||||
|
@ -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"
|
||||||
|
Loading…
Reference in New Issue
Block a user