Merge branch 'master' into sponsors
This commit is contained in:
commit
ecb188c55a
39
src/backoffice/static/js/ticket_scan.js
Normal file
39
src/backoffice/static/js/ticket_scan.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
document.addEventListener("DOMContentLoaded", () => {
|
||||
"use strict";
|
||||
|
||||
const search_form = document.getElementById("search_form");
|
||||
const ticket_token_input = document.getElementById("ticket_token_input");
|
||||
const scan_again = document.getElementById("scan_again");
|
||||
|
||||
const check_in_input = document.getElementById("check_in_input");
|
||||
const hand_out_badge_input = document.getElementById("hand_out_badge_input");
|
||||
const check_in_form = document.getElementById("check_in_form");
|
||||
|
||||
search_form.onsubmit = submit;
|
||||
|
||||
function submit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (ticket_token_input.value === "#clear") {
|
||||
window.location.replace(window.location.pathname);
|
||||
} else if (ticket_token_input.value === "#check-in") {
|
||||
check_in_input.checked = true;
|
||||
check_in_form.submit();
|
||||
} else if (ticket_token_input.value === "#hand-out-badge") {
|
||||
hand_out_badge_input.checked = true;
|
||||
check_in_form.submit();
|
||||
} else if (ticket_token_input.value.length === 65) {
|
||||
search_form.submit();
|
||||
} else {
|
||||
scan_again.removeAttribute("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", event => {
|
||||
if (event.key === "#") {
|
||||
ticket_token_input.value = "";
|
||||
ticket_token_input.focus();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
|
@ -13,7 +13,7 @@
|
|||
Use this view to hand out badges to participants. Use the search field to search for username, email, products, order ID, ticket UUID, etc. To check in participants go to the <a href="{% url 'backoffice:ticket_checkin' camp_slug=camp.slug %}">Ticket Checkin view</a> instead. To hand out merchandise and other products go to the <a href="{% url 'backoffice:product_handout' camp_slug=camp.slug %}">Hand Out Products</a> view instead.
|
||||
</div>
|
||||
<div>
|
||||
This table shows all (Shop|Discount|Sponsor)Tickets which are badge_handed_out=False. Tickets must be checked in before they are shown in this list.
|
||||
This table shows all (Shop|Discount|Sponsor)Tickets which are badge_ticket_generated=False. Tickets must be checked in before they are shown in this list.
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
|
|
|
@ -16,17 +16,14 @@
|
|||
<div class="list-group">
|
||||
{% if perms.camps.infoteam_permission %}
|
||||
<h3>Info Team</h3>
|
||||
<a href="{% url 'backoffice:product_handout' camp_slug=camp.slug %}" class="list-group-item">
|
||||
<h4 class="list-group-item-heading">Hand Out Products</h4>
|
||||
<p class="list-group-item-text">Use this view to mark products such as merchandise, cabins, fridges and so on as handed out.</p>
|
||||
</a>
|
||||
<a href="{% url 'backoffice:ticket_checkin' camp_slug=camp.slug %}" class="list-group-item">
|
||||
<h4 class="list-group-item-heading">Check-In Tickets</h4>
|
||||
<p class="list-group-item-text">Use this view to check-in tickets when participants arrive.</p>
|
||||
</a>
|
||||
<a href="{% url 'backoffice:badge_handout' camp_slug=camp.slug %}" class="list-group-item">
|
||||
<h4 class="list-group-item-heading">Hand Out Badges</h4>
|
||||
<p class="list-group-item-text">Use this view to mark badges as handed out.</p>
|
||||
<a href="{% url 'backoffice:scan_tickets' camp_slug=camp.slug %}"
|
||||
class="list-group-item">
|
||||
<h4 class="list-group-item-heading">
|
||||
Scan tickets
|
||||
</h4>
|
||||
<p class="list-group-item-text">
|
||||
Use this to get scan tickets
|
||||
</p>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
Use this view to hand out products to participants. Use the search field to search for username, email, products, order ID etc. To check in participants go to the <a href="{% url 'backoffice:ticket_checkin' camp_slug=camp.slug %}">Ticket Checkin view</a> instead. To hand out badges go to the <a href="{% url 'backoffice:badge_handout' camp_slug=camp.slug %}">Badge Handout view</a> instead.
|
||||
</div>
|
||||
<div>
|
||||
This table shows all OrderProductRelations which are handed_out=False (not including unpaid, cancelled and refunded orders). The table is initally sorted by order ID but the sorting can be changed by clicking the column headlines (if javascript is enabled).
|
||||
This table shows all OrderProductRelations which are ticket_generated=False (not including unpaid, cancelled and refunded orders). The table is initally sorted by order ID but the sorting can be changed by clicking the column headlines (if javascript is enabled).
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
Use this view to check in participants. Use the search field to search for username, email, products, order ID, ticket UUID, etc. To hand out badges go to the <a href="{% url 'backoffice:badge_handout' camp_slug=camp.slug %}">Badge Handout view</a> instead. To hand out other products go to the <a href="{% url 'backoffice:product_handout' camp_slug=camp.slug %}">Hand Out Products</a> view instead.
|
||||
</div>
|
||||
<div>
|
||||
This table shows all (Shop|Discount|Sponsor)Tickets which are checked_in=False.
|
||||
This table shows all (Shop|Discount|Sponsor)Tickets which are used=False.
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
|
|
118
src/backoffice/templates/tickets/scan.html
Normal file
118
src/backoffice/templates/tickets/scan.html
Normal file
|
@ -0,0 +1,118 @@
|
|||
{% extends 'base.html' %}
|
||||
{% load static from staticfiles %}
|
||||
{% load qrcode %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<form id="search_form" method="POST" action="">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md-12">
|
||||
<h3>Scan the ticket!</h3>
|
||||
<input type="text" name="ticket_token" id="ticket_token_input" autocomplete="off" style="color: #fff; background: #fff; border: 0; height: 0; width: 0;"/>
|
||||
|
||||
<div id="scan_again" hidden>
|
||||
Scan again!
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
|
||||
{% if ticket %}
|
||||
|
||||
|
||||
<br />
|
||||
|
||||
<table class="table">
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Type:</strong>
|
||||
<td>
|
||||
{{ ticket.ticket_type }}
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Used?:</strong>
|
||||
<td>
|
||||
{{ ticket.used }}
|
||||
|
||||
{% if ticket.ticket_type.includes_badge %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Badge handed out?:</strong>
|
||||
<td>
|
||||
{{ ticket.badge_handed_out }}
|
||||
{% endif %}
|
||||
|
||||
{% if ticket.product %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Product:</strong>
|
||||
<td>
|
||||
{{ ticket.product }}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Order:</strong>
|
||||
<td>
|
||||
{{ ticket.order }}
|
||||
{% endif %}
|
||||
|
||||
{% if ticket.sponsor %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Sponsor</strong>
|
||||
<td>
|
||||
{{ ticket.sponsor }}
|
||||
{% endif %}
|
||||
|
||||
|
||||
</table>
|
||||
|
||||
<hr />
|
||||
|
||||
<form id="check_in_form" method="POST" action="">{% csrf_token %}
|
||||
<input type="checkbox"
|
||||
name="check_in_ticket_id"
|
||||
id="check_in_input"
|
||||
value="{{ ticket.pk }}"
|
||||
style="color: #fff; background: #fff; border: 0; height: 0; width: 0; opacity: 0;" />
|
||||
|
||||
<input type="checkbox"
|
||||
name="badge_ticket_id"
|
||||
id="hand_out_badge_input"
|
||||
value="{{ ticket.pk }}"
|
||||
style="color: #fff; background: #fff; border: 0; height: 0; width: 0; opacity: 0;" />
|
||||
</form>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
{% qr_code "clear" %}
|
||||
</div>
|
||||
|
||||
{% if not ticket.used %}
|
||||
<div class="col-md-4">
|
||||
{% qr_code "check-in" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ticket.ticket_type.includes_badge and not ticket.badge_handed_out %}
|
||||
<div class="col-md-4">
|
||||
{% qr_code "hand-out-badge" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
<script src="{% static 'js/ticket_scan.js' %}"></script>
|
||||
|
||||
{% endblock content %}
|
||||
|
|
@ -7,6 +7,9 @@ app_name = "backoffice"
|
|||
urlpatterns = [
|
||||
path("", BackofficeIndexView.as_view(), name="index"),
|
||||
# infodesk
|
||||
path(
|
||||
"tickets/", include([path("", ScanTicketsView.as_view(), name="scan_tickets")])
|
||||
),
|
||||
path("product_handout/", ProductHandoutView.as_view(), name="product_handout"),
|
||||
path("badge_handout/", BadgeHandoutView.as_view(), name="badge_handout"),
|
||||
path("ticket_checkin/", TicketCheckinView.as_view(), name="ticket_checkin"),
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import logging, os
|
||||
from itertools import chain
|
||||
|
||||
import qrcode
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin
|
||||
from django.contrib.auth.models import User
|
||||
from django.views.generic import TemplateView, ListView, DetailView
|
||||
|
@ -14,7 +15,7 @@ from django.conf import settings
|
|||
from django.core.files import File
|
||||
|
||||
from camps.mixins import CampViewMixin
|
||||
from shop.models import OrderProductRelation
|
||||
from shop.models import OrderProductRelation, Invoice, Order
|
||||
from tickets.models import ShopTicket, SponsorTicket, DiscountTicket
|
||||
from profiles.models import Profile
|
||||
from program.models import SpeakerProposal, EventProposal
|
||||
|
@ -40,7 +41,7 @@ class ProductHandoutView(CampViewMixin, InfoTeamPermissionMixin, ListView):
|
|||
|
||||
def get_queryset(self, **kwargs):
|
||||
return OrderProductRelation.objects.filter(
|
||||
handed_out=False,
|
||||
ticket_generated=False,
|
||||
order__paid=True,
|
||||
order__refunded=False,
|
||||
order__cancelled=False,
|
||||
|
@ -52,9 +53,9 @@ class BadgeHandoutView(CampViewMixin, InfoTeamPermissionMixin, ListView):
|
|||
context_object_name = "tickets"
|
||||
|
||||
def get_queryset(self, **kwargs):
|
||||
shoptickets = ShopTicket.objects.filter(badge_handed_out=False)
|
||||
sponsortickets = SponsorTicket.objects.filter(badge_handed_out=False)
|
||||
discounttickets = DiscountTicket.objects.filter(badge_handed_out=False)
|
||||
shoptickets = ShopTicket.objects.filter(badge_ticket_generated=False)
|
||||
sponsortickets = SponsorTicket.objects.filter(badge_ticket_generated=False)
|
||||
discounttickets = DiscountTicket.objects.filter(badge_ticket_generated=False)
|
||||
return list(chain(shoptickets, sponsortickets, discounttickets))
|
||||
|
||||
|
||||
|
@ -63,9 +64,9 @@ class TicketCheckinView(CampViewMixin, InfoTeamPermissionMixin, ListView):
|
|||
context_object_name = "tickets"
|
||||
|
||||
def get_queryset(self, **kwargs):
|
||||
shoptickets = ShopTicket.objects.filter(checked_in=False)
|
||||
sponsortickets = SponsorTicket.objects.filter(checked_in=False)
|
||||
discounttickets = DiscountTicket.objects.filter(checked_in=False)
|
||||
shoptickets = ShopTicket.objects.filter(used=False)
|
||||
sponsortickets = SponsorTicket.objects.filter(used=False)
|
||||
discounttickets = DiscountTicket.objects.filter(used=False)
|
||||
return list(chain(shoptickets, sponsortickets, discounttickets))
|
||||
|
||||
|
||||
|
@ -151,7 +152,7 @@ class MerchandiseOrdersView(CampViewMixin, OrgaTeamPermissionMixin, ListView):
|
|||
|
||||
return (
|
||||
OrderProductRelation.objects.filter(
|
||||
handed_out=False,
|
||||
ticket_generated=False,
|
||||
order__paid=True,
|
||||
order__refunded=False,
|
||||
order__cancelled=False,
|
||||
|
@ -169,7 +170,7 @@ class MerchandiseToOrderView(CampViewMixin, OrgaTeamPermissionMixin, TemplateVie
|
|||
camp_prefix = "BornHack {}".format(timezone.now().year)
|
||||
|
||||
order_relations = OrderProductRelation.objects.filter(
|
||||
handed_out=False,
|
||||
ticket_generated=False,
|
||||
order__paid=True,
|
||||
order__refunded=False,
|
||||
order__cancelled=False,
|
||||
|
@ -197,7 +198,7 @@ class VillageOrdersView(CampViewMixin, OrgaTeamPermissionMixin, ListView):
|
|||
|
||||
return (
|
||||
OrderProductRelation.objects.filter(
|
||||
handed_out=False,
|
||||
ticket_generated=False,
|
||||
order__paid=True,
|
||||
order__refunded=False,
|
||||
order__cancelled=False,
|
||||
|
@ -215,7 +216,7 @@ class VillageToOrderView(CampViewMixin, OrgaTeamPermissionMixin, TemplateView):
|
|||
camp_prefix = "BornHack {}".format(timezone.now().year)
|
||||
|
||||
order_relations = OrderProductRelation.objects.filter(
|
||||
handed_out=False,
|
||||
ticket_generated=False,
|
||||
order__paid=True,
|
||||
order__refunded=False,
|
||||
order__cancelled=False,
|
||||
|
@ -345,7 +346,6 @@ class ReimbursementCreateView(CampViewMixin, EconomyTeamPermissionMixin, CreateV
|
|||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
""" Get the user from kwargs """
|
||||
print("inside dispatch() with method %s" % request.method)
|
||||
self.reimbursement_user = get_object_or_404(User, pk=kwargs["user_id"])
|
||||
|
||||
# get response now so we have self.camp available below
|
||||
|
@ -544,3 +544,73 @@ class RevenueDetailView(CampViewMixin, EconomyTeamPermissionMixin, UpdateView):
|
|||
return redirect(
|
||||
reverse("backoffice:revenue_list", kwargs={"camp_slug": self.camp.slug})
|
||||
)
|
||||
|
||||
|
||||
def _ticket_getter_by_token(token):
|
||||
for ticket_class in [ShopTicket, SponsorTicket, DiscountTicket]:
|
||||
try:
|
||||
return ticket_class.objects.get(token=token), False
|
||||
except ticket_class.DoesNotExist:
|
||||
try:
|
||||
return ticket_class.objects.get(badge_token=token), True
|
||||
except ticket_class.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
def _ticket_getter_by_pk(pk):
|
||||
for ticket_class in [ShopTicket, SponsorTicket, DiscountTicket]:
|
||||
try:
|
||||
return ticket_class.objects.get(pk=pk)
|
||||
except ticket_class.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class ScanTicketsView(TemplateView):
|
||||
template_name = "tickets/scan.html"
|
||||
|
||||
ticket = None
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
if self.ticket:
|
||||
context["ticket"] = self.ticket
|
||||
|
||||
elif "ticket_token" in self.request.POST:
|
||||
|
||||
# Slice to get rid of the first character which is a '#'
|
||||
ticket_token = self.request.POST.get("ticket_token")[1:]
|
||||
|
||||
ticket, is_badge = _ticket_getter_by_token(ticket_token)
|
||||
|
||||
if ticket:
|
||||
context["ticket"] = ticket
|
||||
context["is_badge"] = is_badge
|
||||
else:
|
||||
messages.warning(self.request, "Ticket not found!")
|
||||
|
||||
return context
|
||||
|
||||
def post(self, request, **kwargs):
|
||||
if 'check_in_ticket_id' in request.POST:
|
||||
self.ticket = self.check_in_ticket(request)
|
||||
elif 'badge_ticket_id' in request.POST:
|
||||
self.ticket = self.hand_out_badge(request)
|
||||
|
||||
return super().get(request, **kwargs)
|
||||
|
||||
def check_in_ticket(self, request):
|
||||
check_in_ticket_id = request.POST.get("check_in_ticket_id")
|
||||
ticket_to_check_in = _ticket_getter_by_pk(check_in_ticket_id)
|
||||
ticket_to_check_in.used = True
|
||||
ticket_to_check_in.save()
|
||||
messages.info(request, "Ticket checked-in!")
|
||||
return ticket_to_check_in
|
||||
|
||||
def hand_out_badge(self, request):
|
||||
badge_ticket_id = request.POST.get('badge_ticket_id')
|
||||
ticket_to_handout_badge_for = _ticket_getter_by_pk(badge_ticket_id)
|
||||
ticket_to_handout_badge_for.badge_handed_out = True
|
||||
ticket_to_handout_badge_for.save()
|
||||
messages.info(request, "Badge marked as handed out!")
|
||||
return ticket_to_handout_badge_for
|
||||
|
|
37
src/camps/factories.py
Normal file
37
src/camps/factories.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
import factory
|
||||
from django.utils import timezone
|
||||
|
||||
from factory.django import DjangoModelFactory
|
||||
from psycopg2._range import DateTimeTZRange
|
||||
|
||||
|
||||
class CampFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = "camps.Camp"
|
||||
|
||||
read_only = False
|
||||
|
||||
title = factory.Faker("word")
|
||||
tagline = factory.Faker("sentence")
|
||||
slug = factory.Faker("slug")
|
||||
shortslug = factory.Faker("slug")
|
||||
|
||||
buildup = factory.LazyFunction(
|
||||
lambda: DateTimeTZRange(
|
||||
lower=timezone.now() - timezone.timedelta(days=3),
|
||||
upper=timezone.now() - timezone.timedelta(hours=1)
|
||||
)
|
||||
)
|
||||
|
||||
camp = factory.LazyFunction(
|
||||
lambda: DateTimeTZRange(lower=timezone.now(), upper=timezone.now() + timezone.timedelta(days=8))
|
||||
)
|
||||
|
||||
teardown = factory.LazyFunction(
|
||||
lambda: DateTimeTZRange(
|
||||
lower=timezone.now() + timezone.timedelta(days=8, hours=1),
|
||||
upper=timezone.now() + timezone.timedelta(days=11),
|
||||
)
|
||||
)
|
||||
|
||||
colour = factory.Faker("hex_color")
|
|
@ -10,7 +10,7 @@
|
|||
</div>
|
||||
<div class="panel-body">
|
||||
|
||||
<h2>{% if ticket.checked_in %}This ticket has been used{% else %}This ticket is unused{% endif %}</h2>
|
||||
<h2>{% if ticket.used %}This ticket has been used{% else %}This ticket is unused{% endif %}</h2>
|
||||
<form method="POST" class="form">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_field form.name %}
|
||||
|
|
|
@ -23,9 +23,13 @@
|
|||
Checked in
|
||||
<th>
|
||||
Actions
|
||||
|
||||
<tbody>
|
||||
{% for ticket in tickets %}
|
||||
{% ifchanged ticket.ticket_type.camp %}
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<strong>{{ ticket.ticket_type.camp }}</strong>
|
||||
{% endifchanged %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if ticket.name %}
|
||||
|
@ -36,9 +40,10 @@
|
|||
<td>
|
||||
{{ ticket.product.name }}
|
||||
<td>
|
||||
{% if ticket.ticket_type.single_ticket_per_product %}{{ ticket.get_orp.quantity }} × {% endif %}
|
||||
{{ ticket.product.price|currency }}
|
||||
<td>
|
||||
{% if ticket.checked_in %}
|
||||
{% if ticket.used %}
|
||||
Yes
|
||||
{% else %}
|
||||
Not yet
|
||||
|
|
|
@ -218,6 +218,7 @@ class EventProposalForm(forms.ModelForm):
|
|||
"slides_url",
|
||||
"submission_notes",
|
||||
"track",
|
||||
"use_provided_speaker_laptop",
|
||||
]
|
||||
|
||||
def clean_duration(self):
|
||||
|
@ -262,6 +263,15 @@ class EventProposalForm(forms.ModelForm):
|
|||
# initialise form
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
TALK = "Talk"
|
||||
LIGHTNING_TALK = "Lightning Talk"
|
||||
DEBATE = "Debate"
|
||||
MUSIC_ACT = "Music Act"
|
||||
RECREATIONAL_EVENT = "Recreational Event"
|
||||
WORKSHOP = "Workshop"
|
||||
SLACKING_OFF = "Slacking Off"
|
||||
MEETUP = "Meetup"
|
||||
|
||||
# disable the empty_label for the track select box
|
||||
self.fields["track"].empty_label = None
|
||||
self.fields["track"].queryset = EventTrack.objects.filter(camp=camp)
|
||||
|
@ -269,11 +279,15 @@ class EventProposalForm(forms.ModelForm):
|
|||
# make sure video_recording checkbox defaults to checked
|
||||
self.fields["allow_video_recording"].initial = True
|
||||
|
||||
if not (eventtype.name == "Talk" or eventtype.name == "Lightning Talk"):
|
||||
if eventtype.name not in [TALK, LIGHTNING_TALK]:
|
||||
# Only talk or lightning talk should show the slides_url field
|
||||
del self.fields["slides_url"]
|
||||
|
||||
if eventtype.name == "Debate":
|
||||
if not eventtype.name == LIGHTNING_TALK:
|
||||
# Only lightning talks submissions will have to choose whether to use provided speaker laptop
|
||||
del self.fields["use_provided_speaker_laptop"]
|
||||
|
||||
if eventtype.name == DEBATE:
|
||||
# fix label and help_text for the title field
|
||||
self.fields["title"].label = "Title of debate"
|
||||
self.fields["title"].help_text = "The title of this debate"
|
||||
|
@ -293,7 +307,7 @@ class EventProposalForm(forms.ModelForm):
|
|||
"placeholder"
|
||||
] = "Debate Duration (minutes)"
|
||||
|
||||
elif eventtype.name == "Music Act":
|
||||
elif eventtype.name == MUSIC_ACT:
|
||||
# fix label and help_text for the title field
|
||||
self.fields["title"].label = "Title of music act"
|
||||
self.fields["title"].help_text = "The title of this music act/concert/set."
|
||||
|
@ -314,7 +328,7 @@ class EventProposalForm(forms.ModelForm):
|
|||
# better placeholder text for duration field
|
||||
self.fields["duration"].widget.attrs["placeholder"] = "Duration (minutes)"
|
||||
|
||||
elif eventtype.name == "Recreational Event":
|
||||
elif eventtype.name == RECREATIONAL_EVENT:
|
||||
# fix label and help_text for the title field
|
||||
self.fields["title"].label = "Event Title"
|
||||
self.fields["title"].help_text = "The title of this recreational event"
|
||||
|
@ -338,7 +352,7 @@ class EventProposalForm(forms.ModelForm):
|
|||
self.fields["duration"].label = "Event Duration"
|
||||
self.fields["duration"].widget.attrs["placeholder"] = "Duration (minutes)"
|
||||
|
||||
elif eventtype.name == "Talk" or eventtype.name == "Lightning Talk":
|
||||
elif eventtype.name in [TALK, LIGHTNING_TALK]:
|
||||
# fix label and help_text for the title field
|
||||
self.fields["title"].label = "Title of Talk"
|
||||
self.fields["title"].help_text = "The title of this talk/presentation."
|
||||
|
@ -355,7 +369,7 @@ class EventProposalForm(forms.ModelForm):
|
|||
"submission_notes"
|
||||
].help_text = "Private notes regarding this talk. Only visible to yourself and the BornHack organisers."
|
||||
|
||||
if self.fields.get("slides_url") and eventtype.name == "Lightning Talk":
|
||||
if self.fields.get("slides_url") and eventtype.name == LIGHTNING_TALK:
|
||||
self.fields[
|
||||
"slides_url"
|
||||
].help_text += " You will only get assigned a slot if you have provided slides (a title slide is enough if you don't use slides for the talk). You can add an URL later if need be."
|
||||
|
@ -363,7 +377,7 @@ class EventProposalForm(forms.ModelForm):
|
|||
# no duration for talks
|
||||
del self.fields["duration"]
|
||||
|
||||
elif eventtype.name == "Workshop":
|
||||
elif eventtype.name == WORKSHOP:
|
||||
# fix label and help_text for the title field
|
||||
self.fields["title"].label = "Workshop Title"
|
||||
self.fields["title"].help_text = "The title of this workshop."
|
||||
|
@ -389,7 +403,7 @@ class EventProposalForm(forms.ModelForm):
|
|||
"duration"
|
||||
].help_text = "How much time (in minutes) should we set aside for this workshop? Please keep it between 60 and 180 minutes (1-3 hours)."
|
||||
|
||||
elif eventtype.name == "Slacking Off":
|
||||
elif eventtype.name == SLACKING_OFF:
|
||||
# fix label and help_text for the title field
|
||||
self.fields["title"].label = "Event Title"
|
||||
self.fields["title"].help_text = "The title of this recreational event."
|
||||
|
@ -415,7 +429,7 @@ class EventProposalForm(forms.ModelForm):
|
|||
"duration"
|
||||
].help_text = "How much time (in minutes) should we set aside for this event? Please keep it between 60 and 180 minutes (1-3 hours)."
|
||||
|
||||
elif eventtype.name == "Meetup":
|
||||
elif eventtype.name == MEETUP:
|
||||
# fix label and help_text for the title field
|
||||
self.fields["title"].label = "Meetup Title"
|
||||
self.fields["title"].help_text = "The title of this meetup."
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 2.2.3 on 2019-07-31 10:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('program', '0072_auto_20190616_1746'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='eventproposal',
|
||||
name='use_provided_speaker_laptop',
|
||||
field=models.BooleanField(default=False, help_text='Will you be using the provided speaker laptop?'),
|
||||
),
|
||||
]
|
18
src/program/migrations/0074_auto_20190801_0933.py
Normal file
18
src/program/migrations/0074_auto_20190801_0933.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 2.2.3 on 2019-08-01 07:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('program', '0073_eventproposal_use_provided_speaker_laptop'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='eventproposal',
|
||||
name='use_provided_speaker_laptop',
|
||||
field=models.BooleanField(default=True, help_text='Will you be using the provided speaker laptop?'),
|
||||
),
|
||||
]
|
|
@ -340,6 +340,10 @@ class EventProposal(UserSubmittedModel):
|
|||
blank=True,
|
||||
)
|
||||
|
||||
use_provided_speaker_laptop = models.BooleanField(
|
||||
help_text="Will you be using the provided speaker laptop?", default=True
|
||||
)
|
||||
|
||||
@property
|
||||
def camp(self):
|
||||
return self.track.camp
|
||||
|
|
|
@ -9,7 +9,7 @@ Call for Participation | {{ block.super }}
|
|||
|
||||
{% if not camp.call_for_participation_open %}
|
||||
<div class="alert alert-danger">
|
||||
<strong>Note!</strong> This Call for Particilation is not open.
|
||||
<strong>Note!</strong> This Call for Participation is not open.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
Hello!<br>
|
||||
<br>
|
||||
Event "{{ proposal.name }}" for {{ proposal.camp }} was just updated!
|
||||
Event "{{ proposal.title }}" for {{ proposal.camp }} was just updated!
|
||||
<br>
|
||||
<br>
|
||||
More info <a href="https://bornhack.dk/admin/program/eventproposal/{{ proposal.uuid }}/change/">here</a>.
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
Hello!
|
||||
|
||||
Event "{{ proposal.name }}" for {{ proposal.camp }} was just updated!
|
||||
Event "{{ proposal.title }}" for {{ proposal.camp }} was just updated!
|
||||
|
||||
More info: https://bornhack.dk/admin/program/eventproposal/{{ proposal.uuid }}/change/
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
{% if not camp.call_for_participation_open %}
|
||||
<div class="alert alert-danger">
|
||||
<strong>Note!</strong> This Call for Particilation is not open.
|
||||
<strong>Note!</strong> This Call for Participation is not open.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
@ -6,6 +6,9 @@
|
|||
<strong>ID</strong>: {{ eventproposal.uuid }}<br />
|
||||
<strong>Status</strong>: {{ eventproposal.proposal_status }}<br />
|
||||
<strong>Duration</strong>: {{ eventproposal.duration|default:"Not defined" }}<br />
|
||||
{% if eventproposal.event_type.name == "Lightning Talk" %}
|
||||
<strong>Use provided laptop?</strong>: {{ eventproposal.use_provided_speaker_laptop }}<br />
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ Proposals | {{ block.super }}
|
|||
{% include 'includes/event_proposal_type_select.html' %}
|
||||
{% else %}
|
||||
<div class="alert alert-danger">
|
||||
<strong>Note!</strong> This Call for Particilation is not open.
|
||||
<strong>Note!</strong> This Call for Participation is not open.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
{% if not camp.call_for_participation_open %}
|
||||
<div class="alert alert-danger">
|
||||
<strong>Note!</strong> This Call for Particilation is not open.
|
||||
<strong>Note!</strong> This Call for Participation is not open.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
@ -2,5 +2,5 @@
|
|||
|
||||
django-debug-toolbar==2.0
|
||||
factory_boy==2.12.0
|
||||
coverage==4.5.3
|
||||
coverage==4.5.4
|
||||
codecov==2.0.15
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
Django==2.2.3
|
||||
Django==2.2.4
|
||||
channels==2.2.0
|
||||
channels-redis==2.3.2
|
||||
|
||||
|
@ -23,7 +23,7 @@ docopt==0.6.2
|
|||
future==0.17.1
|
||||
html5lib==1.0.1
|
||||
icalendar==4.0.3
|
||||
ipython==7.6.1
|
||||
ipython==7.7.0
|
||||
irc3==1.1.2
|
||||
oauthlib==3.0.2
|
||||
olefile==0.46
|
||||
|
|
|
@ -101,6 +101,8 @@ class ProductAdmin(admin.ModelAdmin):
|
|||
available_to,
|
||||
]
|
||||
|
||||
list_editable = ["ticket_type"]
|
||||
|
||||
list_filter = ["category", "ticket_type"]
|
||||
|
||||
search_fields = ["name"]
|
||||
|
@ -142,6 +144,7 @@ class OrderAdmin(admin.ModelAdmin):
|
|||
"mark_order_as_paid",
|
||||
"mark_order_as_refunded",
|
||||
"mark_order_as_cancelled",
|
||||
"create_tickets",
|
||||
]
|
||||
|
||||
def mark_order_as_paid(self, request, queryset):
|
||||
|
@ -162,6 +165,12 @@ class OrderAdmin(admin.ModelAdmin):
|
|||
|
||||
mark_order_as_cancelled.description = "Mark order(s) as cancelled"
|
||||
|
||||
def create_tickets(self, request, queryset):
|
||||
for order in queryset.filter(paid=True):
|
||||
order.create_tickets(request)
|
||||
|
||||
create_tickets.description = "Create tickets for order(s) (paid only)"
|
||||
|
||||
|
||||
def get_user_email(obj):
|
||||
return obj.order.user.email
|
||||
|
|
|
@ -30,6 +30,7 @@ class ProductFactory(DjangoModelFactory):
|
|||
lower=timezone.now(), upper=timezone.now() + timezone.timedelta(31)
|
||||
)
|
||||
)
|
||||
ticket_type = factory.SubFactory("tickets.factories.TicketTypeFactory")
|
||||
|
||||
|
||||
class OrderFactory(DjangoModelFactory):
|
||||
|
@ -46,4 +47,4 @@ class OrderProductRelationFactory(DjangoModelFactory):
|
|||
product = factory.SubFactory(ProductFactory)
|
||||
order = factory.SubFactory(OrderFactory)
|
||||
quantity = 1
|
||||
handed_out = False
|
||||
ticket_generated = False
|
||||
|
|
18
src/shop/migrations/0059_auto_20190718_2051.py
Normal file
18
src/shop/migrations/0059_auto_20190718_2051.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 2.2.2 on 2019-07-18 18:51
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0058_order_pdf'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='orderproductrelation',
|
||||
old_name='handed_out',
|
||||
new_name='ticket_generated',
|
||||
),
|
||||
]
|
|
@ -208,6 +208,7 @@ class Order(CreatedUpdatedModel):
|
|||
return str(reverse_lazy("shop:order_detail", kwargs={"pk": self.pk}))
|
||||
|
||||
def create_tickets(self, request=None):
|
||||
tickets = []
|
||||
for order_product in self.orderproductrelation_set.all():
|
||||
# if this is a Ticket product?
|
||||
if order_product.product.ticket_type:
|
||||
|
@ -216,32 +217,57 @@ class Order(CreatedUpdatedModel):
|
|||
ticket_type=order_product.product.ticket_type,
|
||||
)
|
||||
|
||||
already_created_tickets = self.shoptickets.filter(
|
||||
**query_kwargs
|
||||
).count()
|
||||
tickets_to_create = max(
|
||||
0, order_product.quantity - already_created_tickets
|
||||
)
|
||||
if order_product.product.ticket_type.single_ticket_per_product:
|
||||
# This ticket type is one where we only create one ticket
|
||||
ticket, created = self.shoptickets.get_or_create(**query_kwargs)
|
||||
|
||||
# create the number of tickets required
|
||||
if tickets_to_create > 0:
|
||||
for _ in range(
|
||||
0, (order_product.quantity - already_created_tickets)
|
||||
):
|
||||
self.shoptickets.create(**query_kwargs)
|
||||
if created:
|
||||
msg = (
|
||||
"Created ticket for product %s on order %s (quantity: %s)"
|
||||
% (
|
||||
order_product.product,
|
||||
order_product.order.pk,
|
||||
order_product.quantity,
|
||||
)
|
||||
)
|
||||
tickets.append(ticket)
|
||||
else:
|
||||
msg = "Ticket already created for product %s on order %s" % (
|
||||
order_product.product,
|
||||
order_product.order.pk,
|
||||
)
|
||||
|
||||
msg = "Created %s tickets of type: %s" % (
|
||||
order_product.quantity,
|
||||
order_product.product.ticket_type.name,
|
||||
)
|
||||
if request:
|
||||
messages.success(request, msg)
|
||||
else:
|
||||
print(msg)
|
||||
else:
|
||||
# We should create a number of tickets equal to OrderProductRelation quantity
|
||||
already_created_tickets = self.shoptickets.filter(
|
||||
**query_kwargs
|
||||
).count()
|
||||
tickets_to_create = max(
|
||||
0, order_product.quantity - already_created_tickets
|
||||
)
|
||||
|
||||
# and mark the OPR as handed_out=True
|
||||
order_product.handed_out = True
|
||||
order_product.save()
|
||||
# create the number of tickets required
|
||||
if tickets_to_create > 0:
|
||||
for _ in range(
|
||||
0, (order_product.quantity - already_created_tickets)
|
||||
):
|
||||
ticket = self.shoptickets.create(**query_kwargs)
|
||||
tickets.append(ticket)
|
||||
|
||||
msg = "Created %s tickets of type: %s" % (
|
||||
order_product.quantity,
|
||||
order_product.product.ticket_type.name,
|
||||
)
|
||||
if request:
|
||||
messages.success(request, msg)
|
||||
|
||||
# and mark the OPR as ticket_generated=True
|
||||
order_product.ticket_generated = True
|
||||
order_product.save()
|
||||
|
||||
return tickets
|
||||
|
||||
def mark_as_paid(self, request=None):
|
||||
self.paid = True
|
||||
|
@ -289,35 +315,36 @@ class Order(CreatedUpdatedModel):
|
|||
self.open = None
|
||||
self.save()
|
||||
|
||||
def is_not_handed_out(self):
|
||||
if self.orderproductrelation_set.filter(handed_out=True).count() == 0:
|
||||
def is_not_ticket_generated(self):
|
||||
if self.orderproductrelation_set.filter(ticket_generated=True).count() == 0:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def is_partially_handed_out(self):
|
||||
def is_partially_ticket_generated(self):
|
||||
if (
|
||||
self.orderproductrelation_set.filter(handed_out=True).count() != 0
|
||||
and self.orderproductrelation_set.filter(handed_out=False).count() != 0
|
||||
self.orderproductrelation_set.filter(ticket_generated=True).count() != 0
|
||||
and self.orderproductrelation_set.filter(ticket_generated=False).count()
|
||||
!= 0
|
||||
):
|
||||
# some products are handed out, others are not
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def is_fully_handed_out(self):
|
||||
if self.orderproductrelation_set.filter(handed_out=False).count() == 0:
|
||||
def is_fully_ticket_generated(self):
|
||||
if self.orderproductrelation_set.filter(ticket_generated=False).count() == 0:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
@property
|
||||
def handed_out_status(self):
|
||||
if self.is_not_handed_out():
|
||||
def ticket_generated_status(self):
|
||||
if self.is_not_ticket_generated():
|
||||
return "no"
|
||||
elif self.is_partially_handed_out():
|
||||
elif self.is_partially_ticket_generated():
|
||||
return "partially"
|
||||
elif self.is_fully_handed_out():
|
||||
elif self.is_fully_ticket_generated():
|
||||
return "fully"
|
||||
else:
|
||||
return False
|
||||
|
@ -466,14 +493,14 @@ class OrderProductRelation(CreatedUpdatedModel):
|
|||
order = models.ForeignKey("shop.Order", on_delete=models.PROTECT)
|
||||
product = models.ForeignKey("shop.Product", on_delete=models.PROTECT)
|
||||
quantity = models.PositiveIntegerField()
|
||||
handed_out = models.BooleanField(default=False)
|
||||
ticket_generated = models.BooleanField(default=False)
|
||||
|
||||
@property
|
||||
def total(self):
|
||||
return Decimal(self.product.price * self.quantity)
|
||||
|
||||
def clean(self):
|
||||
if self.handed_out and not self.order.paid:
|
||||
if self.ticket_generated and not self.order.paid:
|
||||
raise ValidationError(
|
||||
"Product can not be handed out when order is not paid."
|
||||
)
|
||||
|
|
|
@ -25,13 +25,13 @@
|
|||
<tbody>
|
||||
{% for order in orders %}
|
||||
{% if order.products.exists %}
|
||||
<tr {% if not order.open and order.paid and order.is_fully_handed_out %}style="color: lightgrey"{% endif %}>
|
||||
<tr {% if not order.open and order.paid and order.is_fully_ticket_generated %}style="color: lightgrey"{% endif %}>
|
||||
<td>{{ order.id }}</td>
|
||||
<td>{{ order.get_number_of_items }}</td>
|
||||
<td>{{ order.total|currency }}</td>
|
||||
<td class="text-center">{{ order.open|truefalseicon }}</td>
|
||||
<td class="text-center">{{ order.paid|truefalseicon }}</td>
|
||||
<td class="text-center">{{ order.handed_out_status }}</td>
|
||||
<td class="text-center">{{ order.ticket_generated_status }}</td>
|
||||
<td>
|
||||
{% if order.paid %}
|
||||
{% if order.invoice.pdf %}
|
||||
|
|
|
@ -4,6 +4,8 @@ from django.utils import timezone
|
|||
from psycopg2.extras import DateTimeTZRange
|
||||
|
||||
from shop.forms import OrderProductRelationForm
|
||||
from tickets.factories import TicketTypeFactory
|
||||
from tickets.models import ShopTicket
|
||||
from utils.factories import UserFactory
|
||||
from .factories import ProductFactory, OrderProductRelationFactory, OrderFactory
|
||||
|
||||
|
@ -371,3 +373,27 @@ class TestOrderListView(TestCase):
|
|||
path = reverse("shop:order_list")
|
||||
response = self.client.get(path)
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
|
||||
class TestTicketCreation(TestCase):
|
||||
def test_multiple_tickets_created(self):
|
||||
user = UserFactory()
|
||||
ticket_type = TicketTypeFactory(single_ticket_per_product=False)
|
||||
product = ProductFactory(ticket_type=ticket_type)
|
||||
order = OrderFactory(user=user)
|
||||
OrderProductRelationFactory(order=order, product=product, quantity=5)
|
||||
order.mark_as_paid()
|
||||
self.assertEquals(
|
||||
ShopTicket.objects.filter(product=product, order=order).count(), 5
|
||||
)
|
||||
|
||||
def test_single_ticket_created(self):
|
||||
user = UserFactory()
|
||||
ticket_type = TicketTypeFactory(single_ticket_per_product=True)
|
||||
product = ProductFactory(ticket_type=ticket_type)
|
||||
order = OrderFactory(user=user)
|
||||
OrderProductRelationFactory(order=order, product=product, quantity=5)
|
||||
order.mark_as_paid()
|
||||
self.assertEquals(
|
||||
ShopTicket.objects.filter(product=product, order=order).count(), 1
|
||||
)
|
||||
|
|
|
@ -53,11 +53,14 @@ Team: {{ team.name }} | {{ block.super }}
|
|||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if request.user in team.members.all %}
|
||||
<li {% if view.active_menu == "guide" %}class="active"{% endif %}>
|
||||
<a href="{% url "teams:guide" camp_slug=team.camp.slug team_slug=team.slug %}">
|
||||
Team guide
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
|
|
@ -1,22 +1,28 @@
|
|||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.views.generic import ListView, DetailView
|
||||
from django.views.generic import DetailView
|
||||
|
||||
from camps.mixins import CampViewMixin
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin
|
||||
|
||||
from ..models import Team
|
||||
from ..models import Team, TeamMember
|
||||
|
||||
|
||||
class TeamGuideView(LoginRequiredMixin, CampViewMixin, DetailView):
|
||||
class TeamGuideView(LoginRequiredMixin, CampViewMixin, UserPassesTestMixin, DetailView):
|
||||
template_name = "team_guide.html"
|
||||
context_object_name = "team"
|
||||
model = Team
|
||||
slug_url_kwarg = "team_slug"
|
||||
active_menu = "guide"
|
||||
|
||||
def get_queryset(self):
|
||||
qs = CampViewMixin.get_queryset(self)
|
||||
qs.filter(teammember__approved=True, teammember__user=self.request.user)
|
||||
return qs
|
||||
def test_func(self):
|
||||
# Make sure that the user is an approved member of the team
|
||||
try:
|
||||
TeamMember.objects.get(
|
||||
user=self.request.user, team=self.get_object(), approved=True
|
||||
)
|
||||
except TeamMember.DoesNotExist:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
class TeamGuidePrintView(TeamGuideView):
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from shop.models import OrderProductRelation
|
||||
from .models import TicketType, SponsorTicket, DiscountTicket, ShopTicket
|
||||
|
||||
|
||||
class BaseTicketAdmin(admin.ModelAdmin):
|
||||
actions = ["generate_pdf"]
|
||||
exclude = ["qrcode_base64"]
|
||||
readonly_fields = ['token', 'badge_token']
|
||||
|
||||
def generate_pdf(self, request, queryset):
|
||||
for ticket in queryset.all():
|
||||
|
@ -23,9 +25,9 @@ class TicketTypeAdmin(admin.ModelAdmin):
|
|||
|
||||
@admin.register(SponsorTicket)
|
||||
class SponsorTicketAdmin(BaseTicketAdmin):
|
||||
list_display = ["pk", "ticket_type", "sponsor", "checked_in"]
|
||||
list_display = ["pk", "ticket_type", "sponsor", "used"]
|
||||
|
||||
list_filter = ["ticket_type__camp", "checked_in", "ticket_type", "sponsor"]
|
||||
list_filter = ["ticket_type__camp", "used", "ticket_type", "sponsor"]
|
||||
|
||||
search_fields = ["pk", "sponsor__name"]
|
||||
|
||||
|
@ -50,13 +52,25 @@ class ShopTicketAdmin(BaseTicketAdmin):
|
|||
"ticket_type",
|
||||
"order",
|
||||
"product",
|
||||
"checked_in",
|
||||
"used",
|
||||
"product_quantity",
|
||||
]
|
||||
|
||||
list_filter = ["ticket_type__camp", "checked_in", "ticket_type", "order", "product"]
|
||||
list_filter = ["ticket_type__camp", "used", "ticket_type", "order", "product"]
|
||||
|
||||
search_fields = ["uuid", "order__id", "order__user__email", "name", "email"]
|
||||
|
||||
def product_quantity(self, ticket):
|
||||
orp = OrderProductRelation.objects.get(
|
||||
product=ticket.product, order=ticket.order
|
||||
)
|
||||
|
||||
return (
|
||||
str(orp.quantity) if ticket.ticket_type.single_ticket_per_product else "1"
|
||||
)
|
||||
|
||||
product_quantity.short_description = "Quantity"
|
||||
|
||||
|
||||
class ShopTicketInline(admin.TabularInline):
|
||||
model = ShopTicket
|
||||
|
|
18
src/tickets/factories.py
Normal file
18
src/tickets/factories.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
import factory
|
||||
|
||||
from factory.django import DjangoModelFactory
|
||||
|
||||
|
||||
class TicketTypeFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = "tickets.TicketType"
|
||||
|
||||
name = factory.Faker("sentence")
|
||||
camp = factory.SubFactory("camps.factories.CampFactory")
|
||||
|
||||
|
||||
class ShopTicketFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = "tickets.ShopTicket"
|
||||
|
||||
ticket_type = factory.SubFactory(TicketTypeFactory)
|
28
src/tickets/migrations/0006_auto_20190616_1746.py
Normal file
28
src/tickets/migrations/0006_auto_20190616_1746.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 2.2.2 on 2019-06-16 15:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0005_auto_20180318_0906'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='discountticket',
|
||||
name='token',
|
||||
field=models.CharField(max_length=64, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shopticket',
|
||||
name='token',
|
||||
field=models.CharField(max_length=64, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sponsorticket',
|
||||
name='token',
|
||||
field=models.CharField(max_length=64, null=True),
|
||||
),
|
||||
]
|
29
src/tickets/migrations/0007_save_token_to_db.py
Normal file
29
src/tickets/migrations/0007_save_token_to_db.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
# Generated by Django 2.2.2 on 2019-07-18 18:52
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
from tickets.models import create_ticket_token
|
||||
|
||||
|
||||
def save_tokens(apps, schema_editor):
|
||||
ShopTicket = apps.get_model("tickets", "ShopTicket")
|
||||
SponsorTicket = apps.get_model("tickets", "SponsorTicket")
|
||||
DiscountTicket = apps.get_model("tickets", "DiscountTicket")
|
||||
|
||||
for model in (ShopTicket, SponsorTicket, DiscountTicket):
|
||||
|
||||
for ticket in model.objects.all():
|
||||
token = create_ticket_token(
|
||||
"{_id}{secret_key}".format(
|
||||
_id=ticket.uuid, secret_key=settings.SECRET_KEY
|
||||
).encode("utf-8")
|
||||
)
|
||||
ticket.token = token
|
||||
ticket.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("tickets", "0006_auto_20190616_1746")]
|
||||
|
||||
operations = [migrations.RunPython(save_tokens)]
|
43
src/tickets/migrations/0008_auto_20190718_2055.py
Normal file
43
src/tickets/migrations/0008_auto_20190718_2055.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
# Generated by Django 2.2.2 on 2019-07-18 18:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0007_save_token_to_db'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='discountticket',
|
||||
old_name='checked_in',
|
||||
new_name='used',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='shopticket',
|
||||
old_name='checked_in',
|
||||
new_name='used',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='sponsorticket',
|
||||
old_name='checked_in',
|
||||
new_name='used',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='discountticket',
|
||||
name='token',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='shopticket',
|
||||
name='token',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sponsorticket',
|
||||
name='token',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
]
|
18
src/tickets/migrations/0009_tickettype_includes_badge.py
Normal file
18
src/tickets/migrations/0009_tickettype_includes_badge.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 2.2.3 on 2019-07-23 20:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0008_auto_20190718_2055'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tickettype',
|
||||
name='includes_badge',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
28
src/tickets/migrations/0010_auto_20190724_2037.py
Normal file
28
src/tickets/migrations/0010_auto_20190724_2037.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 2.2.3 on 2019-07-24 18:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0009_tickettype_includes_badge'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='discountticket',
|
||||
name='badge_token',
|
||||
field=models.CharField(max_length=64, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shopticket',
|
||||
name='badge_token',
|
||||
field=models.CharField(max_length=64, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sponsorticket',
|
||||
name='badge_token',
|
||||
field=models.CharField(max_length=64, null=True),
|
||||
),
|
||||
]
|
29
src/tickets/migrations/0011_save_badge_token_to_db.py
Normal file
29
src/tickets/migrations/0011_save_badge_token_to_db.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
# Generated by Django 2.2.3 on 2019-07-24 18:37
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
from tickets.models import create_ticket_token
|
||||
|
||||
|
||||
def save_badge_tokens(apps, schema_editor):
|
||||
ShopTicket = apps.get_model("tickets", "ShopTicket")
|
||||
SponsorTicket = apps.get_model("tickets", "SponsorTicket")
|
||||
DiscountTicket = apps.get_model("tickets", "DiscountTicket")
|
||||
|
||||
for model in (ShopTicket, SponsorTicket, DiscountTicket):
|
||||
|
||||
for ticket in model.objects.all():
|
||||
badge_token = create_ticket_token(
|
||||
"{_id}{secret_key}-badge".format(
|
||||
_id=ticket.uuid, secret_key=settings.SECRET_KEY
|
||||
).encode("utf-8")
|
||||
)
|
||||
ticket.badge_token = badge_token
|
||||
ticket.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("tickets", "0010_auto_20190724_2037")]
|
||||
|
||||
operations = [migrations.RunPython(save_badge_tokens)]
|
28
src/tickets/migrations/0012_auto_20190724_2037.py
Normal file
28
src/tickets/migrations/0012_auto_20190724_2037.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 2.2.3 on 2019-07-24 18:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0011_save_badge_token_to_db'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='discountticket',
|
||||
name='badge_token',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='shopticket',
|
||||
name='badge_token',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sponsorticket',
|
||||
name='badge_token',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 2.2.3 on 2019-07-30 20:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0012_auto_20190724_2037'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tickettype',
|
||||
name='single_ticket_per_product',
|
||||
field=models.BooleanField(default=False, help_text='Only create one ticket for a product/order pair no matter the quantity. Useful for products which are bought in larger quantity (ie. village chairs)'),
|
||||
),
|
||||
]
|
43
src/tickets/migrations/0014_auto_20190803_2241.py
Normal file
43
src/tickets/migrations/0014_auto_20190803_2241.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
# Generated by Django 2.2.3 on 2019-08-03 20:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0013_tickettype_single_ticket_per_product'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='discountticket',
|
||||
name='badge_token',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='discountticket',
|
||||
name='token',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='shopticket',
|
||||
name='badge_token',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='shopticket',
|
||||
name='token',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sponsorticket',
|
||||
name='badge_token',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sponsorticket',
|
||||
name='token',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
]
|
|
@ -5,6 +5,8 @@ import qrcode
|
|||
from django.conf import settings
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from shop.models import OrderProductRelation
|
||||
from utils.models import UUIDModel, CampRelatedModel
|
||||
from utils.pdf import generate_pdf_letter
|
||||
from django.db import models
|
||||
|
@ -17,15 +19,39 @@ logger = logging.getLogger("bornhack.%s" % __name__)
|
|||
class TicketType(CampRelatedModel, UUIDModel):
|
||||
name = models.TextField()
|
||||
camp = models.ForeignKey("camps.Camp", on_delete=models.PROTECT)
|
||||
includes_badge = models.BooleanField(default=False)
|
||||
single_ticket_per_product = models.BooleanField(
|
||||
default=False,
|
||||
help_text=(
|
||||
"Only create one ticket for a product/order pair no matter the quantity. "
|
||||
"Useful for products which are bought in larger quantity (ie. village chairs)"
|
||||
),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return "{} ({})".format(self.name, self.camp.title)
|
||||
|
||||
|
||||
def create_ticket_token(string):
|
||||
return hashlib.sha256(string).hexdigest()
|
||||
|
||||
|
||||
def qr_code_base64(token):
|
||||
qr = qrcode.make(
|
||||
token, version=1, error_correction=qrcode.constants.ERROR_CORRECT_H
|
||||
).resize((250, 250))
|
||||
file_like = io.BytesIO()
|
||||
qr.save(file_like, format="png")
|
||||
qrcode_base64 = base64.b64encode(file_like.getvalue())
|
||||
return qrcode_base64
|
||||
|
||||
|
||||
class BaseTicket(CampRelatedModel, UUIDModel):
|
||||
ticket_type = models.ForeignKey("TicketType", on_delete=models.PROTECT)
|
||||
checked_in = models.BooleanField(default=False)
|
||||
used = models.BooleanField(default=False)
|
||||
badge_handed_out = models.BooleanField(default=False)
|
||||
token = models.CharField(max_length=64, blank=True)
|
||||
badge_token = models.CharField(max_length=64, blank=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
@ -34,33 +60,45 @@ class BaseTicket(CampRelatedModel, UUIDModel):
|
|||
def camp(self):
|
||||
return self.ticket_type.camp
|
||||
|
||||
def _get_token(self):
|
||||
return hashlib.sha256(
|
||||
"{_id}{secret_key}".format(
|
||||
_id=self.pk, secret_key=settings.SECRET_KEY
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
def save(self, **kwargs):
|
||||
self.token = self._get_token()
|
||||
self.badge_token = self._get_badge_token()
|
||||
super().save(**kwargs)
|
||||
|
||||
def get_qr_code_base64(self):
|
||||
qr = qrcode.make(
|
||||
self._get_token(),
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_H,
|
||||
).resize((250, 250))
|
||||
file_like = io.BytesIO()
|
||||
qr.save(file_like, format="png")
|
||||
qrcode_base64 = base64.b64encode(file_like.getvalue())
|
||||
return qrcode_base64
|
||||
def _get_token(self):
|
||||
return create_ticket_token(
|
||||
"{_id}{secret_key}".format(
|
||||
_id=self.uuid, secret_key=settings.SECRET_KEY
|
||||
).encode("utf-8")
|
||||
)
|
||||
|
||||
def _get_badge_token(self):
|
||||
return create_ticket_token(
|
||||
"{_id}{secret_key}-badge".format(
|
||||
_id=self.uuid, secret_key=settings.SECRET_KEY
|
||||
).encode("utf-8")
|
||||
)
|
||||
|
||||
def get_qr_code_url(self):
|
||||
return "data:image/png;base64,{}".format(
|
||||
self.get_qr_code_base64().decode("utf-8")
|
||||
qr_code_base64(self._get_token()).decode("utf-8")
|
||||
)
|
||||
|
||||
def get_qr_badge_code_url(self):
|
||||
return "data:image/png;base64,{}".format(
|
||||
qr_code_base64(self._get_badge_token()).decode("utf-8")
|
||||
)
|
||||
|
||||
def generate_pdf(self):
|
||||
formatdict = {"ticket": self}
|
||||
|
||||
if self.ticket_type.single_ticket_per_product and self.shortname == "shop":
|
||||
orp = self.get_orp()
|
||||
formatdict["quantity"] = orp.quantity
|
||||
|
||||
return generate_pdf_letter(
|
||||
filename="{}_ticket_{}.pdf".format(self.shortname, self.pk),
|
||||
formatdict={"ticket": self},
|
||||
formatdict=formatdict,
|
||||
template="pdf/ticket.html",
|
||||
)
|
||||
|
||||
|
@ -126,3 +164,6 @@ class ShopTicket(BaseTicket):
|
|||
@property
|
||||
def shortname(self):
|
||||
return "shop"
|
||||
|
||||
def get_orp(self):
|
||||
return OrderProductRelation.objects.get(product=self.product, order=self.order)
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
{% load static from staticfiles %}
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
|
||||
<table style="width:100%;">
|
||||
<tr>
|
||||
<td style="width: 75%;"> </td>
|
||||
|
@ -14,6 +13,10 @@
|
|||
<br>
|
||||
<h2>{{ ticket.ticket_type.camp.title }} Ticket</h2>
|
||||
<h3>Type: {{ ticket.ticket_type.name }}</h3>
|
||||
<h3>Product: {{ ticket.product.name }}</h3>
|
||||
{% if quantity %}
|
||||
<h3>Quantity: {{ quantity }}</h3>
|
||||
{% endif %}
|
||||
|
||||
{% if ticket.name %}
|
||||
<h3>Participant: {{ ticket.name }}</h3>
|
||||
|
@ -23,13 +26,40 @@
|
|||
<br>
|
||||
{% elif ticket.sponsor %}
|
||||
<h3>Sponsor: {{ ticket.sponsor.name }} </h3>
|
||||
<img src="{% static 'img/sponsors/' %}{{ sponsor.logo_filename }}"></img>
|
||||
<img src="{% static 'img/sponsors/' %}{{ ticket.sponsor.logo_filename }}" />
|
||||
{% endif %}
|
||||
{% if ticket.checked_in %}
|
||||
<h2>This ticket has been checked in.</h2>
|
||||
|
||||
{% if ticket.used %}
|
||||
<h2>This ticket has been used.</h2>
|
||||
{% endif %}
|
||||
|
||||
<center>
|
||||
<img src="{{ ticket.get_qr_code_url }}"></img>
|
||||
<p>Ticket #{{ ticket.pk }}</p>
|
||||
<p>{{ ticket.token }}</p>
|
||||
</center>
|
||||
|
||||
{% if ticket.ticket_type.includes_badge %}
|
||||
<div style="display:block; clear:both; page-break-after:always;"></div>
|
||||
|
||||
<table style="width:100%;">
|
||||
<tr>
|
||||
<td style="width: 75%;"> </td>
|
||||
<td>
|
||||
<h3>
|
||||
{{ ticket.created|date:"b jS, Y" }}<br>
|
||||
</h3>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<br />
|
||||
|
||||
<h2>Badge voucher</h2>
|
||||
|
||||
<center>
|
||||
<img src="{{ ticket.get_qr_badge_code_url }}"></img>
|
||||
<p>{{ ticket.badge_token }}</p>
|
||||
</center>
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
|
25
src/tickets/tests.py
Normal file
25
src/tickets/tests.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
from django.test import TestCase
|
||||
|
||||
from shop.factories import OrderProductRelationFactory
|
||||
from .factories import TicketTypeFactory
|
||||
from .models import ShopTicket
|
||||
|
||||
|
||||
class TicketTests(TestCase):
|
||||
|
||||
def test_correct_token_and_badge_token_are_different(self):
|
||||
|
||||
ticket_type = TicketTypeFactory()
|
||||
|
||||
orp = OrderProductRelationFactory()
|
||||
shop_ticket = ShopTicket.objects.create(
|
||||
ticket_type=ticket_type,
|
||||
product=orp.product,
|
||||
order=orp.order,
|
||||
)
|
||||
|
||||
self.assertNotEqual(shop_ticket.token, shop_ticket.badge_token)
|
||||
self.assertEqual(shop_ticket.token, shop_ticket._get_token())
|
||||
self.assertEqual(shop_ticket.badge_token, shop_ticket._get_badge_token())
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ class ShopTicketListView(LoginRequiredMixin, ListView):
|
|||
def get_queryset(self):
|
||||
tickets = super(ShopTicketListView, self).get_queryset()
|
||||
user = self.request.user
|
||||
return tickets.filter(order__user=user)
|
||||
return tickets.filter(order__user=user).order_by("ticket_type__camp")
|
||||
|
||||
|
||||
class ShopTicketDownloadView(LoginRequiredMixin, SingleObjectMixin, View):
|
||||
|
|
|
@ -1399,7 +1399,8 @@ class Command(BaseCommand):
|
|||
camp=camp,
|
||||
user=users[1],
|
||||
seats=2,
|
||||
location="From Copenhagen",
|
||||
from_location="Copenhagen",
|
||||
to_location="BornHack",
|
||||
when=timezone.datetime(year, 8, 27, 12, 0, tzinfo=timezone.utc),
|
||||
description="I have space for two people and a little bit of luggage",
|
||||
)
|
||||
|
@ -1407,7 +1408,8 @@ class Command(BaseCommand):
|
|||
camp=camp,
|
||||
user=users[1],
|
||||
seats=2,
|
||||
location="To Copenhagen",
|
||||
from_location="BornHack",
|
||||
to_location="Copenhagen",
|
||||
when=timezone.datetime(year, 9, 4, 12, 0, tzinfo=timezone.utc),
|
||||
description="I have space for two people and a little bit of luggage",
|
||||
)
|
||||
|
@ -1415,7 +1417,8 @@ class Command(BaseCommand):
|
|||
camp=camp,
|
||||
user=users[4],
|
||||
seats=1,
|
||||
location="From Aarhus",
|
||||
from_location="Aarhus",
|
||||
to_location="BornHack",
|
||||
when=timezone.datetime(year, 8, 27, 12, 0, tzinfo=timezone.utc),
|
||||
description="I need a ride and have a large backpack",
|
||||
)
|
||||
|
|
|
@ -12,7 +12,9 @@ logger = logging.getLogger("bornhack.%s" % __name__)
|
|||
|
||||
|
||||
def generate_pdf_letter(filename, template, formatdict):
|
||||
logger.debug("Generating PDF with filename %s and template %s" % (filename, template))
|
||||
logger.debug(
|
||||
"Generating PDF with filename %s and template %s" % (filename, template)
|
||||
)
|
||||
|
||||
# conjure up a fake request for PDFTemplateResponse
|
||||
request = RequestFactory().get("/")
|
||||
|
@ -47,9 +49,9 @@ def generate_pdf_letter(filename, template, formatdict):
|
|||
|
||||
# add the watermark to all pages
|
||||
for pagenum in range(pdfreader.getNumPages()):
|
||||
page = watermark.getPage(0)
|
||||
page = pdfreader.getPage(pagenum)
|
||||
try:
|
||||
page.mergePage(pdfreader.getPage(pagenum))
|
||||
page.mergePage(watermark.getPage(0))
|
||||
except ValueError:
|
||||
# watermark pdf might be broken?
|
||||
return False
|
||||
|
@ -65,4 +67,3 @@ def generate_pdf_letter(filename, template, formatdict):
|
|||
returnfile = io.BytesIO()
|
||||
finalpdf.write(returnfile)
|
||||
return returnfile
|
||||
|
||||
|
|
23
src/utils/templatetags/qrcode.py
Normal file
23
src/utils/templatetags/qrcode.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
import base64
|
||||
import io
|
||||
|
||||
import qrcode
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def qr_code(value):
|
||||
stream = io.BytesIO()
|
||||
img = qrcode.make("#" + value, box_size=7)
|
||||
img.save(stream, "PNG")
|
||||
data = base64.b64encode(stream.getvalue())
|
||||
|
||||
return mark_safe(
|
||||
"<figure style='text-align: center;'>"
|
||||
'<img src="data:image/png;base64,{}" alt="">'
|
||||
"<figcaption style='text-align: center;'>{}</figcaption>"
|
||||
"</figure>".format(data.decode(), value)
|
||||
)
|
Loading…
Reference in a new issue