Compare commits
No commits in common. "0ce441336f214db56035d185f61a4b2c51efa0c5" and "8c294625881ca3bbe32db30bc31d58aeb58d03a5" have entirely different histories.
0ce441336f
...
8c29462588
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,4 +2,3 @@ __pycache__/
|
||||
*.sqlite3
|
||||
/__pypackages__/
|
||||
/markdownx/
|
||||
/media/
|
||||
|
@ -42,8 +42,6 @@ INSTALLED_APPS = [
|
||||
"django_filters",
|
||||
"django_db_views",
|
||||
"django_mysql",
|
||||
"django_sendfile",
|
||||
"django_bootstrap5",
|
||||
"tasks.apps.TasksConfig",
|
||||
"rentals.apps.RentalsConfig",
|
||||
"membershipworks.apps.MembershipworksConfig",
|
||||
@ -120,10 +118,6 @@ LOGGING = {
|
||||
},
|
||||
}
|
||||
|
||||
MEDIA_ROOT = "media"
|
||||
MEDIA_URL = "media/"
|
||||
SENDFILE_ROOT = str(Path(__file__).parents[2] / "media" / "protected")
|
||||
|
||||
WIKI_URL = "https://wiki.claremontmakerspace.org"
|
||||
|
||||
# Django Rest Framework
|
||||
|
@ -21,5 +21,3 @@ MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware") # noqa:
|
||||
|
||||
configure_hypothesis_profiles()
|
||||
settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev"))
|
||||
|
||||
SENDFILE_BACKEND = "django_sendfile.backends.development"
|
||||
|
@ -48,6 +48,3 @@ AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
|
||||
)
|
||||
AUTH_LDAP_GROUP_TYPE = PosixGroupType()
|
||||
AUTH_LDAP_MIRROR_GROUPS = True
|
||||
|
||||
SENDFILE_BACKEND = "django_sendfile.backends.nginx"
|
||||
SENDFILE_URL = "/media/protected"
|
||||
|
@ -15,7 +15,6 @@ Including another URLconf
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.views import LoginView, LogoutView
|
||||
from django.urls import include, path
|
||||
@ -64,4 +63,3 @@ urlpatterns = [
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns.append(path("__debug__/", include("debug_toolbar.urls")))
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
@ -14,7 +14,6 @@ from .models import (
|
||||
Event,
|
||||
EventExt,
|
||||
EventInstructor,
|
||||
EventInvoice,
|
||||
EventMeetingTime,
|
||||
Flag,
|
||||
Member,
|
||||
@ -120,15 +119,11 @@ class EventInstructorAdmin(admin.ModelAdmin):
|
||||
search_fields = ["name", "member__account_name"]
|
||||
|
||||
|
||||
class EventInvoiceInline(admin.StackedInline):
|
||||
model = EventInvoice
|
||||
|
||||
|
||||
@admin.register(EventExt)
|
||||
class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
inlines = [EventInvoiceInline, EventMeetingTimeInline]
|
||||
inlines = [EventMeetingTimeInline]
|
||||
list_display = [
|
||||
"unescaped_title",
|
||||
"title",
|
||||
"start",
|
||||
"duration",
|
||||
"count",
|
||||
@ -163,10 +158,6 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
fields.append("_details_timestamp")
|
||||
return fields
|
||||
|
||||
@admin.display(ordering="title")
|
||||
def unescaped_title(self, obj):
|
||||
return obj.unescaped_title
|
||||
|
||||
@admin.display(ordering="duration")
|
||||
def duration(self, obj):
|
||||
return obj.duration
|
||||
|
@ -2,15 +2,12 @@ from django.urls import reverse
|
||||
|
||||
import dashboard
|
||||
from dashboard import Link
|
||||
from membershipworks.models import EventExt, Member
|
||||
|
||||
|
||||
@dashboard.register
|
||||
class MembershipworksDashboardFragment(dashboard.LinksCardDashboardFragment):
|
||||
name = "MembershipWorks"
|
||||
|
||||
@property
|
||||
def links(self):
|
||||
links = [
|
||||
Link(
|
||||
"Upcoming Events",
|
||||
@ -34,16 +31,3 @@ class MembershipworksDashboardFragment(dashboard.LinksCardDashboardFragment):
|
||||
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
|
||||
|
@ -1,38 +0,0 @@
|
||||
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()
|
@ -1,65 +0,0 @@
|
||||
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),
|
||||
]
|
@ -1,41 +0,0 @@
|
||||
# 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",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
@ -1,10 +1,8 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import django.core.mail.message
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractBaseUser
|
||||
from django.db import models
|
||||
from django.db.models import (
|
||||
Case,
|
||||
@ -17,14 +15,11 @@ from django.db.models import (
|
||||
Q,
|
||||
Subquery,
|
||||
Sum,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
import nh3
|
||||
from django_db_views.db_view import DBView
|
||||
|
||||
|
||||
@ -429,12 +424,8 @@ class Event(BaseModel):
|
||||
|
||||
_allowed_missing_fields = ["cap", "edp", "adn"]
|
||||
|
||||
@property
|
||||
def unescaped_title(self):
|
||||
return nh3.clean(self.title, tags=set())
|
||||
|
||||
def __str__(self):
|
||||
return self.unescaped_title
|
||||
return self.title
|
||||
|
||||
|
||||
class EventInstructor(models.Model):
|
||||
@ -480,7 +471,7 @@ class EventExtQuerySet(models.QuerySet["EventExt"]):
|
||||
"amount",
|
||||
"materials",
|
||||
"amount_without_materials",
|
||||
"instructor_revenue",
|
||||
"instructor_fee",
|
||||
"instructor_amount",
|
||||
]
|
||||
},
|
||||
@ -552,15 +543,6 @@ class EventExt(Event):
|
||||
)
|
||||
details = models.JSONField(null=True, blank=True)
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("membershipworks:event-detail", kwargs={"eid": self.eid})
|
||||
|
||||
def user_is_instructor(self, user: AbstractBaseUser) -> bool:
|
||||
member = Member.from_user(user)
|
||||
if member is not None:
|
||||
return self.instructor.member == member
|
||||
return False
|
||||
|
||||
class Meta:
|
||||
verbose_name = "event"
|
||||
ordering = ["-start"]
|
||||
@ -588,42 +570,6 @@ 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"]):
|
||||
def get_queryset(self) -> models.QuerySet["EventTicketType"]:
|
||||
members_folder = Subquery(
|
||||
@ -669,19 +615,18 @@ class EventTicketTypeManager(models.Manager["EventTicketType"]):
|
||||
amount_without_materials=ExpressionWrapper(
|
||||
F("amount") - F("materials"), output_field=models.DecimalField()
|
||||
),
|
||||
instructor_revenue=ExpressionWrapper(
|
||||
instructor_fee=ExpressionWrapper(
|
||||
F("amount_without_materials") * F("event__instructor_percentage"),
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
instructor_amount=ExpressionWrapper(
|
||||
F("instructor_revenue") + F("materials"),
|
||||
output_field=models.DecimalField(),
|
||||
F("instructor_fee") + F("materials"), output_field=models.DecimalField()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class EventTicketType(DBView):
|
||||
objects = EventTicketTypeManager.from_queryset(EventTicketTypeQuerySet)()
|
||||
objects = EventTicketTypeManager()
|
||||
|
||||
event = models.ForeignKey(
|
||||
EventExt, on_delete=models.CASCADE, related_name="ticket_types"
|
||||
|
@ -1,82 +0,0 @@
|
||||
@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%;
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
<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" %}
|
@ -1,19 +0,0 @@
|
||||
<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>
|
@ -1,5 +0,0 @@
|
||||
<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" %}
|
@ -1,46 +0,0 @@
|
||||
{% 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,14 +1,16 @@
|
||||
{% extends "base.dj.html" %}
|
||||
|
||||
{% load nh3_tags %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-7">
|
||||
{% block title %}Event Invoice for {{ event.details.ttl|nh3 }}{% endblock %}
|
||||
{% block admin_link %}
|
||||
{% url 'admin:membershipworks_eventext_change' event.pk %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<p>
|
||||
<b>Event Name:</b> {{ event.details.ttl|nh3 }}
|
||||
</p>
|
||||
<p>
|
||||
<b>Event ID:</b> {{ event.eid }}
|
||||
</p>
|
||||
<p>
|
||||
<b>Instructor:</b> {{ event.instructor }}
|
||||
</p>
|
||||
@ -17,11 +19,11 @@
|
||||
{% if meeting_times|length == 0 %}
|
||||
<b>Dates of Event:</b> Not set
|
||||
{% 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 %}
|
||||
{% for meeting_time in meeting_times %}<li>{{ meeting_time.start }} - {{ meeting_time.end }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
@ -30,7 +32,7 @@
|
||||
<b>Attendees:</b> {{ event.details.cnt }}/{{ event.details.cap }}
|
||||
</p>
|
||||
<p>
|
||||
<b>Materials Fee <span class="font-monospace fw-light">[m]</span>:</b>
|
||||
<b>Materials Fee [m]:</b>
|
||||
{% if event.materials_fee_included_in_price is None %}
|
||||
Unknown if included in price
|
||||
{% elif event.materials_fee_included_in_price %}
|
||||
@ -44,22 +46,7 @@
|
||||
{% endif %}
|
||||
</p>
|
||||
<p>
|
||||
<b>Instructor Percentage <span class="font-monospace fw-light">[I]</span>:</b> {{ event.instructor_percentage }}
|
||||
<b>Instructor Percentage [I]:</b> {{ event.instructor_percentage }}
|
||||
</p>
|
||||
</div>
|
||||
<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" %}
|
||||
{% endblock %}
|
||||
|
@ -1,29 +0,0 @@
|
||||
{% 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>
|
@ -1,10 +0,0 @@
|
||||
{% 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,19 +31,9 @@ urlpatterns = [
|
||||
name="event-month-report",
|
||||
),
|
||||
path(
|
||||
"my-events/",
|
||||
views.UserEventView.as_view(),
|
||||
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",
|
||||
"event-invoice/<eid>",
|
||||
views.EventInvoiceView.as_view(),
|
||||
name="event-invoice",
|
||||
),
|
||||
path(
|
||||
"event-attendees",
|
||||
|
@ -1,51 +1,31 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import permission_required
|
||||
from django.contrib.auth.mixins import (
|
||||
AccessMixin,
|
||||
PermissionRequiredMixin,
|
||||
)
|
||||
from django.core import mail
|
||||
from django.core.files.base import ContentFile
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.db.models import OuterRef, Q, Subquery
|
||||
from django.db.models.functions import TruncMonth, TruncYear
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.html import format_html
|
||||
from django.utils.safestring import SafeString
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.views.generic.dates import (
|
||||
ArchiveIndexView,
|
||||
MonthArchiveView,
|
||||
YearArchiveView,
|
||||
)
|
||||
from django.views.generic.detail import BaseDetailView
|
||||
from django.views.generic.edit import FormMixin, ProcessFormView
|
||||
|
||||
import django_filters
|
||||
import django_tables2 as tables
|
||||
import weasyprint
|
||||
from dal import autocomplete
|
||||
from django_filters.views import BaseFilterView
|
||||
from django_mysql.models import GroupConcat
|
||||
from django_sendfile import sendfile
|
||||
from django_tables2 import A, SingleTableMixin
|
||||
from django_tables2.export.views import ExportMixin
|
||||
from django_weasyprint.utils import django_url_fetcher
|
||||
|
||||
from membershipworks.membershipworks_api import MembershipWorks
|
||||
|
||||
from .forms import EventInvoiceForm
|
||||
from .invoice_email import make_invoice_emails
|
||||
from .models import EventAttendee, EventExt, EventInvoice, Member
|
||||
from .models import EventAttendee, EventExt, Member
|
||||
|
||||
|
||||
class MemberAutocomplete(autocomplete.Select2QuerySetView):
|
||||
@ -155,9 +135,8 @@ class EventTable(tables.Table):
|
||||
template_code=(
|
||||
'<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="Details" href="{% url "membershipworks:event-detail" record.pk %}"><i class="bi bi-receipt"></i></a> '
|
||||
'<a title="Invoice" href="{% url "membershipworks:event-invoice" record.pk %}"><i class="bi bi-receipt"></i></a> '
|
||||
),
|
||||
accessor="unescaped_title",
|
||||
)
|
||||
occurred = tables.BooleanColumn(visible=False)
|
||||
start = tables.DateColumn("N d, Y")
|
||||
@ -167,8 +146,6 @@ class EventTable(tables.Table):
|
||||
gross_revenue = tables.Column()
|
||||
total_due_to_instructor = tables.Column()
|
||||
net_revenue = tables.Column()
|
||||
invoice__date_submitted = tables.DateColumn(verbose_name="Invoice Submitted")
|
||||
invoice__date_paid = tables.DateColumn(verbose_name="Invoice Paid")
|
||||
|
||||
class Meta:
|
||||
model = EventExt
|
||||
@ -295,7 +272,7 @@ class EventMonthReport(
|
||||
return (
|
||||
super()
|
||||
.get_table_data()
|
||||
.select_related("category", "instructor", "invoice")
|
||||
.select_related("category", "instructor")
|
||||
.with_financials()
|
||||
)
|
||||
|
||||
@ -303,42 +280,6 @@ class EventMonthReport(
|
||||
return f"mw_events_{self.get_year()}-{self.get_month():02}.{export_format}"
|
||||
|
||||
|
||||
class UserEventTable(EventTable):
|
||||
title = tables.Column(linkify=True, accessor="unescaped_title")
|
||||
instructor = None
|
||||
person_hours = None
|
||||
gross_revenue = None
|
||||
net_revenue = None
|
||||
|
||||
|
||||
class UserEventView(SingleTableMixin, ListView):
|
||||
model = EventExt
|
||||
table_class = UserEventTable
|
||||
export_formats = ("csv", "xlsx", "ods")
|
||||
template_name = "membershipworks/user_event_list.dj.html"
|
||||
|
||||
@cached_property
|
||||
def member(self) -> Member | None:
|
||||
return Member.from_user(self.request.user)
|
||||
|
||||
def get_queryset(self):
|
||||
if self.member is None:
|
||||
return self.model.objects.none()
|
||||
else:
|
||||
return super().get_queryset().filter(instructor__member=self.member)
|
||||
|
||||
def get_table_data(self):
|
||||
return (
|
||||
super()
|
||||
.get_table_data()
|
||||
.select_related("category", "instructor", "invoice")
|
||||
.with_financials()
|
||||
)
|
||||
|
||||
def get_export_filename(self, export_format):
|
||||
return f"my_events_{self.member.uid if self.member is not None else 'no-uid'}.{export_format}"
|
||||
|
||||
|
||||
class InvoiceMoneyColumn(tables.columns.Column):
|
||||
def render(self, value):
|
||||
return f"${super().render(value):.2f}"
|
||||
@ -358,173 +299,40 @@ class InvoiceTable(tables.Table):
|
||||
self.event = kwargs.pop("event")
|
||||
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")
|
||||
list_price = InvoiceMoneyColumn("Ticket Price")
|
||||
actual_price = InvoiceMoneyColumn(_math_header("Actual Price", "P"))
|
||||
quantity = tables.Column(
|
||||
_math_header("Quantity", "Q"),
|
||||
footer=lambda table: table.event.quantity,
|
||||
)
|
||||
amount = InvoiceMoneyFooterColumn(_math_header("Amount", "A=P*Q"))
|
||||
materials = InvoiceMoneyFooterColumn(
|
||||
_math_header("CMS Collected Materials Fee", "M=m*Q")
|
||||
)
|
||||
actual_price = InvoiceMoneyColumn("Actual Price [P]")
|
||||
quantity = tables.Column("Quantity [Q]", footer=lambda table: table.event.quantity)
|
||||
amount = InvoiceMoneyFooterColumn("Amount [A = P * Q]")
|
||||
materials = InvoiceMoneyFooterColumn("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")
|
||||
"Event Revenue Base [R = A - M]"
|
||||
)
|
||||
instructor_fee = InvoiceMoneyFooterColumn("Instructor Fee [F = R * I]")
|
||||
instructor_amount = InvoiceMoneyFooterColumn("Amount Due to Instructor [F + M]")
|
||||
|
||||
class Meta:
|
||||
attrs = {
|
||||
"class": "table table-sm mx-auto w-auto",
|
||||
"tbody": {"class": "table-group-divider"},
|
||||
"tfoot": {"class": "table-group-divider"},
|
||||
}
|
||||
orderable = False
|
||||
|
||||
|
||||
class EventDetailView(
|
||||
SingleTableMixin, FormMixin, AccessMixin, DetailView, ProcessFormView
|
||||
):
|
||||
class EventInvoiceView(SingleTableMixin, PermissionRequiredMixin, DetailView):
|
||||
permission_required = "membershipworks.view_eventext"
|
||||
queryset = EventExt.objects.with_financials().all()
|
||||
pk_url_kwarg = "eid"
|
||||
context_object_name = "event"
|
||||
template_name = "membershipworks/event_detail.dj.html"
|
||||
template_name = "membershipworks/event_invoice.dj.html"
|
||||
table_pagination = False
|
||||
table_class = InvoiceTable
|
||||
form_class = EventInvoiceForm
|
||||
|
||||
def render_to_response(
|
||||
self, context: dict[str, Any], **response_kwargs: Any
|
||||
) -> HttpResponse:
|
||||
if self.request.user.has_perm(
|
||||
self.permission_required
|
||||
) or self.object.user_is_instructor(self.request.user):
|
||||
return super().render_to_response(context, **response_kwargs)
|
||||
else:
|
||||
return self.handle_no_permission()
|
||||
|
||||
def display_instructor_version(self):
|
||||
return (
|
||||
self.request.method == "POST" # generating a PDF
|
||||
or not self.request.user.has_perm(self.permission_required)
|
||||
or self.request.GET.get("instructor_view")
|
||||
)
|
||||
|
||||
def get_table_data(self):
|
||||
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):
|
||||
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()
|
||||
return {"event": self.object}
|
||||
|
||||
|
||||
class EventAttendeeTable(tables.Table):
|
||||
|
41
pdm.lock
41
pdm.lock
@ -5,7 +5,7 @@
|
||||
groups = ["default", "debug", "lint", "server", "typing", "dev"]
|
||||
strategy = ["cross_platform"]
|
||||
lock_version = "4.4.1"
|
||||
content_hash = "sha256:8d03a3352b70059a88842e3f316ee61450b7690e39a4516b7ce8ebf22aaae5b2"
|
||||
content_hash = "sha256:4d5083f84a5eddb0e66de8dbd7c6cf2b6c78321aca649b9283d1e0ebe972455d"
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
@ -502,19 +502,6 @@ files = [
|
||||
{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]]
|
||||
name = "django-db-views"
|
||||
version = "0.1.6"
|
||||
@ -659,18 +646,6 @@ files = [
|
||||
{file = "django_recurrence-1.11.1-py3-none-any.whl", hash = "sha256:0c65f30872599b5813a9bab6952dada23c55894f28674490a753ada559f14bc5"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-sendfile2"
|
||||
version = "0.7.1"
|
||||
summary = "Abstraction to offload file uploads to web-server (e.g. Apache with mod_xsendfile) once Django has checked permissions etc."
|
||||
dependencies = [
|
||||
"django",
|
||||
]
|
||||
files = [
|
||||
{file = "django-sendfile2-0.7.1.tar.gz", hash = "sha256:b5bec07f1c9b1875a60ea74beb306e9aba964bd8b54f00b4432cb77cc35bc58c"},
|
||||
{file = "django_sendfile2-0.7.1-py3-none-any.whl", hash = "sha256:81971df1db77f688c4834b8540930902d0d929c64cf374bd604b81f5ac52c04a"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-stubs"
|
||||
version = "4.2.7"
|
||||
@ -729,20 +704,6 @@ files = [
|
||||
{file = "django_tables2-2.7.0-py2.py3-none-any.whl", hash = "sha256:99e06d966ca8ac69fd74092eb45c79a280dd5ca0ccb81395d96261f62128e1af"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-weasyprint"
|
||||
version = "2.3.0"
|
||||
requires_python = ">=3.8"
|
||||
summary = "Django WeasyPrint CBV"
|
||||
dependencies = [
|
||||
"Django>=3.2",
|
||||
"WeasyPrint>=53",
|
||||
]
|
||||
files = [
|
||||
{file = "django-weasyprint-2.3.0.tar.gz", hash = "sha256:2f849e15bfd6c1b2a58512097b9042eddf3533651d37d2e096cd6f7d8be6442b"},
|
||||
{file = "django_weasyprint-2.3.0-py3-none-any.whl", hash = "sha256:807cb3b16332123d97c8bbe2ac9c70286103fe353235351803ffd33b67284735"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-widget-tweaks"
|
||||
version = "1.5.0"
|
||||
|
@ -35,9 +35,6 @@ dependencies = [
|
||||
"django-filter~=24.2",
|
||||
"django-db-views~=0.1",
|
||||
"django-mysql~=4.12",
|
||||
"django-weasyprint~=2.3",
|
||||
"django-sendfile2~=0.7",
|
||||
"django-bootstrap5~=23.4",
|
||||
]
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@ -54,7 +51,7 @@ admin_email = "cmsmanage.django_q2_admin_email_reporter:AdminEmailReporter"
|
||||
line-length = 88
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E4", "E7", "E9", "F", "I", "C4", "UP", "PERF", "PL", "SIM", "FIX003"]
|
||||
select = ["E4", "E7", "E9", "F", "I", "C4", "UP", "PERF", "PL", "SIM"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = [
|
||||
@ -135,7 +132,7 @@ dev = [
|
||||
|
||||
[tool.pdm.scripts]
|
||||
start = "./manage.py runserver"
|
||||
fmt.shell = "ruff check --fix ; ruff format . ; djlint --reformat ."
|
||||
fmt.shell = "ruff check --fix && ruff format . && djlint --reformat ."
|
||||
|
||||
[build-system]
|
||||
requires = ["pdm-backend"]
|
||||
|
Loading…
Reference in New Issue
Block a user