Compare commits

...

10 Commits

23 changed files with 791 additions and 99 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@ __pycache__/
*.sqlite3 *.sqlite3
/__pypackages__/ /__pypackages__/
/markdownx/ /markdownx/
/media/

View File

@ -42,6 +42,8 @@ INSTALLED_APPS = [
"django_filters", "django_filters",
"django_db_views", "django_db_views",
"django_mysql", "django_mysql",
"django_sendfile",
"django_bootstrap5",
"tasks.apps.TasksConfig", "tasks.apps.TasksConfig",
"rentals.apps.RentalsConfig", "rentals.apps.RentalsConfig",
"membershipworks.apps.MembershipworksConfig", "membershipworks.apps.MembershipworksConfig",
@ -118,6 +120,10 @@ LOGGING = {
}, },
} }
MEDIA_ROOT = "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"
# Django Rest Framework # Django Rest Framework

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

@ -15,6 +15,7 @@ Including another URLconf
""" """
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.views import LoginView, LogoutView from django.contrib.auth.views import LoginView, LogoutView
from django.urls import include, path from django.urls import include, path
@ -63,3 +64,4 @@ urlpatterns = [
if settings.DEBUG: if settings.DEBUG:
urlpatterns.append(path("__debug__/", include("debug_toolbar.urls"))) urlpatterns.append(path("__debug__/", include("debug_toolbar.urls")))
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@ -14,6 +14,7 @@ from .models import (
Event, Event,
EventExt, EventExt,
EventInstructor, EventInstructor,
EventInvoice,
EventMeetingTime, EventMeetingTime,
Flag, Flag,
Member, Member,
@ -119,11 +120,15 @@ class EventInstructorAdmin(admin.ModelAdmin):
search_fields = ["name", "member__account_name"] search_fields = ["name", "member__account_name"]
class EventInvoiceInline(admin.StackedInline):
model = EventInvoice
@admin.register(EventExt) @admin.register(EventExt)
class EventAdmin(DjangoObjectActions, admin.ModelAdmin): class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
inlines = [EventMeetingTimeInline] inlines = [EventInvoiceInline, EventMeetingTimeInline]
list_display = [ list_display = [
"title", "unescaped_title",
"start", "start",
"duration", "duration",
"count", "count",
@ -158,6 +163,10 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
fields.append("_details_timestamp") fields.append("_details_timestamp")
return fields return fields
@admin.display(ordering="title")
def unescaped_title(self, obj):
return obj.unescaped_title
@admin.display(ordering="duration") @admin.display(ordering="duration")
def duration(self, obj): def duration(self, obj):
return obj.duration return obj.duration

View File

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

@ -0,0 +1,41 @@
# Generated by Django 5.0.2 on 2024-03-08 21:30
import uuid
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("membershipworks", "0015_eventmeetingtime_end_after_start"),
]
operations = [
migrations.CreateModel(
name="EventInvoice",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("date_submitted", models.DateField()),
("date_paid", models.DateField(blank=True, null=True)),
("pdf", models.FileField(upload_to="invoices/%Y/%m/%d/")),
("amount", models.DecimalField(decimal_places=4, max_digits=13)),
(
"event",
models.OneToOneField(
on_delete=django.db.models.deletion.PROTECT,
related_name="invoice",
to="membershipworks.eventext",
),
),
],
),
]

View File

@ -1,8 +1,10 @@
import uuid
from datetime import datetime from datetime import datetime
from typing import Optional 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,
@ -15,11 +17,14 @@ from django.db.models import (
Q, Q,
Subquery, Subquery,
Sum, Sum,
Value,
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
from django_db_views.db_view import DBView from django_db_views.db_view import DBView
@ -424,8 +429,12 @@ class Event(BaseModel):
_allowed_missing_fields = ["cap", "edp", "adn"] _allowed_missing_fields = ["cap", "edp", "adn"]
@property
def unescaped_title(self):
return nh3.clean(self.title, tags=set())
def __str__(self): def __str__(self):
return self.title return self.unescaped_title
class EventInstructor(models.Model): class EventInstructor(models.Model):
@ -471,7 +480,7 @@ class EventExtQuerySet(models.QuerySet["EventExt"]):
"amount", "amount",
"materials", "materials",
"amount_without_materials", "amount_without_materials",
"instructor_fee", "instructor_revenue",
"instructor_amount", "instructor_amount",
] ]
}, },
@ -543,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"]
@ -570,6 +588,42 @@ class EventMeetingTime(models.Model):
] ]
class EventInvoice(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
event = models.OneToOneField(
EventExt, on_delete=models.PROTECT, related_name="invoice"
)
date_submitted = models.DateField()
date_paid = models.DateField(blank=True, null=True)
pdf = models.FileField(upload_to="protected/invoices/%Y/%m/%d/")
amount = models.DecimalField(max_digits=13, decimal_places=4)
def __str__(self) -> str:
return f'"{self.event}" submitted={self.date_submitted} paid:{self.date_paid} ${self.amount}'
class EventTicketTypeQuerySet(models.QuerySet["EventTicketType"]):
def group_by_ticket_type(self):
return self.values("is_members_ticket").annotate(
label=Case(
When(Q(is_members_ticket=True), Value("Members")),
default=Value("Non-Members"),
),
actual_price=F("actual_price"),
**{
field: Sum(field)
for field in [
"quantity",
"materials",
"amount",
"amount_without_materials",
"instructor_revenue",
"instructor_amount",
]
},
)
class EventTicketTypeManager(models.Manager["EventTicketType"]): class EventTicketTypeManager(models.Manager["EventTicketType"]):
def get_queryset(self) -> models.QuerySet["EventTicketType"]: def get_queryset(self) -> models.QuerySet["EventTicketType"]:
members_folder = Subquery( members_folder = Subquery(
@ -615,18 +669,19 @@ class EventTicketTypeManager(models.Manager["EventTicketType"]):
amount_without_materials=ExpressionWrapper( amount_without_materials=ExpressionWrapper(
F("amount") - F("materials"), output_field=models.DecimalField() F("amount") - F("materials"), output_field=models.DecimalField()
), ),
instructor_fee=ExpressionWrapper( instructor_revenue=ExpressionWrapper(
F("amount_without_materials") * F("event__instructor_percentage"), F("amount_without_materials") * F("event__instructor_percentage"),
output_field=models.DecimalField(), output_field=models.DecimalField(),
), ),
instructor_amount=ExpressionWrapper( instructor_amount=ExpressionWrapper(
F("instructor_fee") + F("materials"), output_field=models.DecimalField() F("instructor_revenue") + F("materials"),
output_field=models.DecimalField(),
), ),
) )
class EventTicketType(DBView): class EventTicketType(DBView):
objects = EventTicketTypeManager() objects = EventTicketTypeManager.from_queryset(EventTicketTypeQuerySet)()
event = models.ForeignKey( event = models.ForeignKey(
EventExt, on_delete=models.CASCADE, related_name="ticket_types" EventExt, on_delete=models.CASCADE, related_name="ticket_types"

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

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,31 +1,51 @@
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.safestring import SafeString
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.dates import ( from django.views.generic.dates import (
ArchiveIndexView, ArchiveIndexView,
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):
@ -135,8 +155,9 @@ 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",
) )
occurred = tables.BooleanColumn(visible=False) occurred = tables.BooleanColumn(visible=False)
start = tables.DateColumn("N d, Y") start = tables.DateColumn("N d, Y")
@ -146,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
@ -272,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()
) )
@ -280,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}"
@ -299,40 +358,173 @@ class InvoiceTable(tables.Table):
self.event = kwargs.pop("event") self.event = kwargs.pop("event")
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@staticmethod
def _math_header(name: str, formula: str) -> SafeString:
return format_html(
'{} <div class="text-nowrap font-monospace fw-light">[{}]</div>',
name,
formula,
)
label = tables.Column("Ticket Type", footer="Subtotals") label = tables.Column("Ticket Type", footer="Subtotals")
list_price = InvoiceMoneyColumn("Ticket Price") list_price = InvoiceMoneyColumn("Ticket Price")
actual_price = InvoiceMoneyColumn("Actual Price [P]") actual_price = InvoiceMoneyColumn(_math_header("Actual Price", "P"))
quantity = tables.Column("Quantity [Q]", footer=lambda table: table.event.quantity) quantity = tables.Column(
amount = InvoiceMoneyFooterColumn("Amount [A = P * Q]") _math_header("Quantity", "Q"),
materials = InvoiceMoneyFooterColumn("CMS Collected Materials Fee [M = m * Q]") footer=lambda table: table.event.quantity,
amount_without_materials = InvoiceMoneyFooterColumn( )
"Event Revenue Base [R = A - M]" amount = InvoiceMoneyFooterColumn(_math_header("Amount", "A=P*Q"))
materials = InvoiceMoneyFooterColumn(
_math_header("CMS Collected Materials Fee", "M=m*Q")
)
amount_without_materials = InvoiceMoneyFooterColumn(
_math_header("Event Revenue Base", "B=A-M")
)
instructor_revenue = InvoiceMoneyFooterColumn(
_math_header("Instructor Percentage Revenue", "R=B*I")
)
instructor_amount = InvoiceMoneyFooterColumn(
_math_header("Amount Due to Instructor", "R+M")
) )
instructor_fee = InvoiceMoneyFooterColumn("Instructor Fee [F = R * I]")
instructor_amount = InvoiceMoneyFooterColumn("Amount Due to Instructor [F + M]")
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):

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:4d5083f84a5eddb0e66de8dbd7c6cf2b6c78321aca649b9283d1e0ebe972455d" content_hash = "sha256:8d03a3352b70059a88842e3f316ee61450b7690e39a4516b7ce8ebf22aaae5b2"
[[package]] [[package]]
name = "aiohttp" name = "aiohttp"
@ -502,6 +502,19 @@ files = [
{file = "django-autocomplete-light-3.11.0.tar.gz", hash = "sha256:212576a17e3308ef7ca77e280b86684167916d2091d4b73640f38845d9516328"}, {file = "django-autocomplete-light-3.11.0.tar.gz", hash = "sha256:212576a17e3308ef7ca77e280b86684167916d2091d4b73640f38845d9516328"},
] ]
[[package]]
name = "django-bootstrap5"
version = "23.4"
requires_python = ">=3.8"
summary = "Bootstrap 5 for Django"
dependencies = [
"Django>=3.2",
]
files = [
{file = "django-bootstrap5-23.4.tar.gz", hash = "sha256:fbf9942a17e1f48b4142e78df9e85afb65e4066a20e38ec7c497d36ae5ef7256"},
{file = "django_bootstrap5-23.4-py3-none-any.whl", hash = "sha256:5181bf1e97afae6211e963f28f48d4a90c937a3b036b3f752f52260f0029f3bc"},
]
[[package]] [[package]]
name = "django-db-views" name = "django-db-views"
version = "0.1.6" version = "0.1.6"
@ -646,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"
@ -704,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,9 @@ 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",
] ]
requires-python = ">=3.11" requires-python = ">=3.11"
@ -51,7 +54,7 @@ admin_email = "cmsmanage.django_q2_admin_email_reporter:AdminEmailReporter"
line-length = 88 line-length = 88
[tool.ruff.lint] [tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I", "C4", "UP", "PERF", "PL", "SIM"] select = ["E4", "E7", "E9", "F", "I", "C4", "UP", "PERF", "PL", "SIM", "FIX003"]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
known-first-party = [ known-first-party = [
@ -132,7 +135,7 @@ dev = [
[tool.pdm.scripts] [tool.pdm.scripts]
start = "./manage.py runserver" start = "./manage.py runserver"
fmt.shell = "ruff check --fix && ruff format . && djlint --reformat ." fmt.shell = "ruff check --fix ; ruff format . ; djlint --reformat ."
[build-system] [build-system]
requires = ["pdm-backend"] requires = ["pdm-backend"]