diff --git a/src/backoffice/static/js/ticket_scan.js b/src/backoffice/static/js/ticket_scan.js new file mode 100644 index 00000000..d88243d3 --- /dev/null +++ b/src/backoffice/static/js/ticket_scan.js @@ -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(); + } + }); + +}); diff --git a/src/backoffice/templates/badge_handout.html b/src/backoffice/templates/badge_handout.html index 722f9795..c92ed64b 100644 --- a/src/backoffice/templates/badge_handout.html +++ b/src/backoffice/templates/badge_handout.html @@ -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 Ticket Checkin view instead. To hand out merchandise and other products go to the Hand Out Products view instead.
- 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.

diff --git a/src/backoffice/templates/index.html b/src/backoffice/templates/index.html index d6764c7a..94e2be04 100644 --- a/src/backoffice/templates/index.html +++ b/src/backoffice/templates/index.html @@ -16,17 +16,14 @@
{% if perms.camps.infoteam_permission %}

Info Team

- -

Hand Out Products

-

Use this view to mark products such as merchandise, cabins, fridges and so on as handed out.

-
- -

Check-In Tickets

-

Use this view to check-in tickets when participants arrive.

-
- -

Hand Out Badges

-

Use this view to mark badges as handed out.

+
+

+ Scan tickets +

+

+ Use this to get scan tickets +

{% endif %} diff --git a/src/backoffice/templates/product_handout.html b/src/backoffice/templates/product_handout.html index 677ff974..a8db0054 100644 --- a/src/backoffice/templates/product_handout.html +++ b/src/backoffice/templates/product_handout.html @@ -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 Ticket Checkin view instead. To hand out badges go to the Badge Handout view instead.
- 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).

diff --git a/src/backoffice/templates/ticket_checkin.html b/src/backoffice/templates/ticket_checkin.html index b35181ef..85344325 100644 --- a/src/backoffice/templates/ticket_checkin.html +++ b/src/backoffice/templates/ticket_checkin.html @@ -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 Badge Handout view instead. To hand out other products go to the Hand Out Products view instead.
- 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.

diff --git a/src/backoffice/templates/tickets/scan.html b/src/backoffice/templates/tickets/scan.html new file mode 100644 index 00000000..692d74a4 --- /dev/null +++ b/src/backoffice/templates/tickets/scan.html @@ -0,0 +1,118 @@ +{% extends 'base.html' %} +{% load static from staticfiles %} +{% load qrcode %} + +{% block content %} + +
+ {% csrf_token %} + +
+ +
+

Scan the ticket!

+ + + + +
+ +
+ +
+ + +{% if ticket %} + + +
+ + + + + + + + +
+ Type: + + {{ ticket.ticket_type }} + +
+ Used?: + + {{ ticket.used }} + + {% if ticket.ticket_type.includes_badge %} +
+ Badge handed out?: + + {{ ticket.badge_handed_out }} + {% endif %} + + {% if ticket.product %} +
+ Product: + + {{ ticket.product }} +
+ Order: + + {{ ticket.order }} + {% endif %} + + {% if ticket.sponsor %} +
+ Sponsor + + {{ ticket.sponsor }} + {% endif %} + + +
+ +
+ +
{% csrf_token %} + + + +
+ +
+
+ {% qr_code "clear" %} +
+ + {% if not ticket.used %} +
+ {% qr_code "check-in" %} +
+ {% endif %} + + {% if ticket.ticket_type.includes_badge and not ticket.badge_handed_out %} +
+ {% qr_code "hand-out-badge" %} +
+ {% endif %} +
+{% endif %} + + + + + +{% endblock content %} + diff --git a/src/backoffice/urls.py b/src/backoffice/urls.py index aa5165f9..86a4d1a5 100644 --- a/src/backoffice/urls.py +++ b/src/backoffice/urls.py @@ -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"), diff --git a/src/backoffice/views.py b/src/backoffice/views.py index 76e121b4..b9817b80 100644 --- a/src/backoffice/views.py +++ b/src/backoffice/views.py @@ -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 diff --git a/src/camps/factories.py b/src/camps/factories.py new file mode 100644 index 00000000..32d02556 --- /dev/null +++ b/src/camps/factories.py @@ -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") diff --git a/src/profiles/templates/tickets/ticket_detail.html b/src/profiles/templates/tickets/ticket_detail.html index 16d24de7..a234a668 100644 --- a/src/profiles/templates/tickets/ticket_detail.html +++ b/src/profiles/templates/tickets/ticket_detail.html @@ -10,7 +10,7 @@
-

{% if ticket.checked_in %}This ticket has been used{% else %}This ticket is unused{% endif %}

+

{% if ticket.used %}This ticket has been used{% else %}This ticket is unused{% endif %}

{% csrf_token %} {% bootstrap_field form.name %} diff --git a/src/profiles/templates/tickets/ticket_list.html b/src/profiles/templates/tickets/ticket_list.html index 95aae6f1..2dc6d6b4 100644 --- a/src/profiles/templates/tickets/ticket_list.html +++ b/src/profiles/templates/tickets/ticket_list.html @@ -23,9 +23,13 @@ Checked in Actions - {% for ticket in tickets %} + {% ifchanged ticket.ticket_type.camp %} + + + {{ ticket.ticket_type.camp }} + {% endifchanged %} {% if ticket.name %} @@ -36,9 +40,10 @@ {{ ticket.product.name }} + {% if ticket.ticket_type.single_ticket_per_product %}{{ ticket.get_orp.quantity }} × {% endif %} {{ ticket.product.price|currency }} - {% if ticket.checked_in %} + {% if ticket.used %} Yes {% else %} Not yet diff --git a/src/program/forms.py b/src/program/forms.py index fed70538..614c2669 100644 --- a/src/program/forms.py +++ b/src/program/forms.py @@ -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." diff --git a/src/program/migrations/0073_eventproposal_use_provided_speaker_laptop.py b/src/program/migrations/0073_eventproposal_use_provided_speaker_laptop.py new file mode 100644 index 00000000..ebe5ff4c --- /dev/null +++ b/src/program/migrations/0073_eventproposal_use_provided_speaker_laptop.py @@ -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?'), + ), + ] diff --git a/src/program/migrations/0074_auto_20190801_0933.py b/src/program/migrations/0074_auto_20190801_0933.py new file mode 100644 index 00000000..d3c08892 --- /dev/null +++ b/src/program/migrations/0074_auto_20190801_0933.py @@ -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?'), + ), + ] diff --git a/src/program/models.py b/src/program/models.py index eace0260..b3fddbaa 100644 --- a/src/program/models.py +++ b/src/program/models.py @@ -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 diff --git a/src/program/templates/call_for_participation.html b/src/program/templates/call_for_participation.html index be660949..eb24718b 100644 --- a/src/program/templates/call_for_participation.html +++ b/src/program/templates/call_for_participation.html @@ -9,7 +9,7 @@ Call for Participation | {{ block.super }} {% if not camp.call_for_participation_open %}
- Note! This Call for Particilation is not open. + Note! This Call for Participation is not open.
{% endif %} diff --git a/src/program/templates/emails/update_eventproposal.html b/src/program/templates/emails/update_eventproposal.html index d3855fc6..dad21861 100644 --- a/src/program/templates/emails/update_eventproposal.html +++ b/src/program/templates/emails/update_eventproposal.html @@ -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 here. diff --git a/src/program/templates/emails/update_eventproposal.txt b/src/program/templates/emails/update_eventproposal.txt index f29ba96a..8c3d6351 100644 --- a/src/program/templates/emails/update_eventproposal.txt +++ b/src/program/templates/emails/update_eventproposal.txt @@ -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/ diff --git a/src/program/templates/eventproposal_detail.html b/src/program/templates/eventproposal_detail.html index d24c5e9e..0620be0b 100644 --- a/src/program/templates/eventproposal_detail.html +++ b/src/program/templates/eventproposal_detail.html @@ -4,7 +4,7 @@ {% if not camp.call_for_participation_open %}
- Note! This Call for Particilation is not open. + Note! This Call for Participation is not open.
{% endif %} diff --git a/src/program/templates/includes/eventproposal_detail.html b/src/program/templates/includes/eventproposal_detail.html index 9aeb715e..693f6eee 100644 --- a/src/program/templates/includes/eventproposal_detail.html +++ b/src/program/templates/includes/eventproposal_detail.html @@ -6,6 +6,9 @@ ID: {{ eventproposal.uuid }}
Status: {{ eventproposal.proposal_status }}
Duration: {{ eventproposal.duration|default:"Not defined" }}
+ {% if eventproposal.event_type.name == "Lightning Talk" %} + Use provided laptop?: {{ eventproposal.use_provided_speaker_laptop }}
+ {% endif %}
diff --git a/src/program/templates/proposal_list.html b/src/program/templates/proposal_list.html index 13245d8f..3b12db4e 100644 --- a/src/program/templates/proposal_list.html +++ b/src/program/templates/proposal_list.html @@ -10,7 +10,7 @@ Proposals | {{ block.super }} {% include 'includes/event_proposal_type_select.html' %} {% else %}
- Note! This Call for Particilation is not open. + Note! This Call for Participation is not open.
{% endif %} diff --git a/src/program/templates/speakerproposal_detail.html b/src/program/templates/speakerproposal_detail.html index 451909e4..d22839c5 100644 --- a/src/program/templates/speakerproposal_detail.html +++ b/src/program/templates/speakerproposal_detail.html @@ -4,7 +4,7 @@ {% if not camp.call_for_participation_open %}
- Note! This Call for Particilation is not open. + Note! This Call for Participation is not open.
{% endif %} diff --git a/src/requirements/dev.txt b/src/requirements/dev.txt index d89a0b3c..b033d4f9 100644 --- a/src/requirements/dev.txt +++ b/src/requirements/dev.txt @@ -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 diff --git a/src/requirements/production.txt b/src/requirements/production.txt index e28049df..af9336bb 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -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 diff --git a/src/shop/admin.py b/src/shop/admin.py index c996a98c..0a16c74c 100644 --- a/src/shop/admin.py +++ b/src/shop/admin.py @@ -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 diff --git a/src/shop/factories.py b/src/shop/factories.py index 3c65c43f..7cbb5752 100644 --- a/src/shop/factories.py +++ b/src/shop/factories.py @@ -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 diff --git a/src/shop/migrations/0059_auto_20190718_2051.py b/src/shop/migrations/0059_auto_20190718_2051.py new file mode 100644 index 00000000..8d423deb --- /dev/null +++ b/src/shop/migrations/0059_auto_20190718_2051.py @@ -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', + ), + ] diff --git a/src/shop/models.py b/src/shop/models.py index d8f1480a..929acd99 100644 --- a/src/shop/models.py +++ b/src/shop/models.py @@ -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." ) diff --git a/src/shop/templates/order_list.html b/src/shop/templates/order_list.html index 54802925..1c0f5507 100644 --- a/src/shop/templates/order_list.html +++ b/src/shop/templates/order_list.html @@ -25,13 +25,13 @@ {% for order in orders %} {% if order.products.exists %} - + {{ order.id }} {{ order.get_number_of_items }} {{ order.total|currency }} {{ order.open|truefalseicon }} {{ order.paid|truefalseicon }} - {{ order.handed_out_status }} + {{ order.ticket_generated_status }} {% if order.paid %} {% if order.invoice.pdf %} diff --git a/src/shop/tests.py b/src/shop/tests.py index 9efa0070..ba238451 100644 --- a/src/shop/tests.py +++ b/src/shop/tests.py @@ -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 + ) diff --git a/src/teams/templates/team_base.html b/src/teams/templates/team_base.html index bcd861e8..44dee5c4 100644 --- a/src/teams/templates/team_base.html +++ b/src/teams/templates/team_base.html @@ -53,11 +53,14 @@ Team: {{ team.name }} | {{ block.super }} {% endif %} + {% if request.user in team.members.all %}
  • Team guide
  • + {% endif %} + diff --git a/src/teams/views/guide.py b/src/teams/views/guide.py index 0bcc8a2a..9b1f0727 100644 --- a/src/teams/views/guide.py +++ b/src/teams/views/guide.py @@ -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): diff --git a/src/tickets/admin.py b/src/tickets/admin.py index 45a28a25..2bd71357 100644 --- a/src/tickets/admin.py +++ b/src/tickets/admin.py @@ -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 diff --git a/src/tickets/factories.py b/src/tickets/factories.py new file mode 100644 index 00000000..4e106b9a --- /dev/null +++ b/src/tickets/factories.py @@ -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) diff --git a/src/tickets/migrations/0006_auto_20190616_1746.py b/src/tickets/migrations/0006_auto_20190616_1746.py new file mode 100644 index 00000000..f2fac385 --- /dev/null +++ b/src/tickets/migrations/0006_auto_20190616_1746.py @@ -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), + ), + ] diff --git a/src/tickets/migrations/0007_save_token_to_db.py b/src/tickets/migrations/0007_save_token_to_db.py new file mode 100644 index 00000000..cef68d1e --- /dev/null +++ b/src/tickets/migrations/0007_save_token_to_db.py @@ -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)] diff --git a/src/tickets/migrations/0008_auto_20190718_2055.py b/src/tickets/migrations/0008_auto_20190718_2055.py new file mode 100644 index 00000000..3462307b --- /dev/null +++ b/src/tickets/migrations/0008_auto_20190718_2055.py @@ -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), + ), + ] diff --git a/src/tickets/migrations/0009_tickettype_includes_badge.py b/src/tickets/migrations/0009_tickettype_includes_badge.py new file mode 100644 index 00000000..82a5da1b --- /dev/null +++ b/src/tickets/migrations/0009_tickettype_includes_badge.py @@ -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), + ), + ] diff --git a/src/tickets/migrations/0010_auto_20190724_2037.py b/src/tickets/migrations/0010_auto_20190724_2037.py new file mode 100644 index 00000000..414dfbd5 --- /dev/null +++ b/src/tickets/migrations/0010_auto_20190724_2037.py @@ -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), + ), + ] diff --git a/src/tickets/migrations/0011_save_badge_token_to_db.py b/src/tickets/migrations/0011_save_badge_token_to_db.py new file mode 100644 index 00000000..dbc0883d --- /dev/null +++ b/src/tickets/migrations/0011_save_badge_token_to_db.py @@ -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)] diff --git a/src/tickets/migrations/0012_auto_20190724_2037.py b/src/tickets/migrations/0012_auto_20190724_2037.py new file mode 100644 index 00000000..ae3ce0db --- /dev/null +++ b/src/tickets/migrations/0012_auto_20190724_2037.py @@ -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), + ), + ] diff --git a/src/tickets/migrations/0013_tickettype_single_ticket_per_product.py b/src/tickets/migrations/0013_tickettype_single_ticket_per_product.py new file mode 100644 index 00000000..baca801d --- /dev/null +++ b/src/tickets/migrations/0013_tickettype_single_ticket_per_product.py @@ -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)'), + ), + ] diff --git a/src/tickets/migrations/0014_auto_20190803_2241.py b/src/tickets/migrations/0014_auto_20190803_2241.py new file mode 100644 index 00000000..57b6fe3d --- /dev/null +++ b/src/tickets/migrations/0014_auto_20190803_2241.py @@ -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), + ), + ] diff --git a/src/tickets/models.py b/src/tickets/models.py index 1f2e6cd7..d72d5662 100644 --- a/src/tickets/models.py +++ b/src/tickets/models.py @@ -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) diff --git a/src/tickets/templates/pdf/ticket.html b/src/tickets/templates/pdf/ticket.html index 7623325e..260516ea 100644 --- a/src/tickets/templates/pdf/ticket.html +++ b/src/tickets/templates/pdf/ticket.html @@ -1,6 +1,5 @@ {% load static from staticfiles %} - @@ -14,6 +13,10 @@

    {{ ticket.ticket_type.camp.title }} Ticket

    Type: {{ ticket.ticket_type.name }}

    +

    Product: {{ ticket.product.name }}

    +{% if quantity %} +

    Quantity: {{ quantity }}

    +{% endif %} {% if ticket.name %}

    Participant: {{ ticket.name }}

    @@ -23,13 +26,40 @@
    {% elif ticket.sponsor %}

    Sponsor: {{ ticket.sponsor.name }}

    - + {% endif %} -{% if ticket.checked_in %} -

    This ticket has been checked in.

    + +{% if ticket.used %} +

    This ticket has been used.

    {% endif %}
    -

    Ticket #{{ ticket.pk }}

    +

    {{ ticket.token }}

    + +{% if ticket.ticket_type.includes_badge %} +
    + +
     
    + + + + +
      +

    + {{ ticket.created|date:"b jS, Y" }}
    +

    +
    +
    + +

    Badge voucher

    + +
    + +

    {{ ticket.badge_token }}

    +
    + +{% endif %} + + diff --git a/src/tickets/tests.py b/src/tickets/tests.py new file mode 100644 index 00000000..9016e32b --- /dev/null +++ b/src/tickets/tests.py @@ -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()) + + diff --git a/src/tickets/views.py b/src/tickets/views.py index 7641664d..5a41f47b 100644 --- a/src/tickets/views.py +++ b/src/tickets/views.py @@ -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): diff --git a/src/utils/management/commands/bootstrap-devsite.py b/src/utils/management/commands/bootstrap-devsite.py index 4f669e91..6da7d9f7 100644 --- a/src/utils/management/commands/bootstrap-devsite.py +++ b/src/utils/management/commands/bootstrap-devsite.py @@ -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", ) diff --git a/src/utils/pdf.py b/src/utils/pdf.py index 345aa03a..f9d43079 100644 --- a/src/utils/pdf.py +++ b/src/utils/pdf.py @@ -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 - diff --git a/src/utils/templatetags/qrcode.py b/src/utils/templatetags/qrcode.py new file mode 100644 index 00000000..d989bc5e --- /dev/null +++ b/src/utils/templatetags/qrcode.py @@ -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( + "
    " + '' + "
    {}
    " + "
    ".format(data.decode(), value) + )