Compare commits
10 Commits
8c29462588
...
0ce441336f
Author | SHA1 | Date | |
---|---|---|---|
0ce441336f | |||
60e7fc90aa | |||
b6b16a17d8 | |||
132b134dc5 | |||
53e5ceea89 | |||
58cc8cb2f8 | |||
97502fe130 | |||
9c1771b414 | |||
0318a610e7 | |||
1ac1470d29 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@ __pycache__/
|
|||||||
*.sqlite3
|
*.sqlite3
|
||||||
/__pypackages__/
|
/__pypackages__/
|
||||||
/markdownx/
|
/markdownx/
|
||||||
|
/media/
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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),
|
||||||
|
]
|
41
membershipworks/migrations/0016_eventinvoice.py
Normal file
41
membershipworks/migrations/0016_eventinvoice.py
Normal 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
@ -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"
|
||||||
|
@ -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,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):
|
||||||
|
41
pdm.lock
41
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: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"
|
||||||
|
@ -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"]
|
||||||
|
Loading…
Reference in New Issue
Block a user